Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@

import { FilesetFilePreviewPanel } from '@studio/components/FilesetFilePreviewPanel';
import { TestProviders } from '@studio/tests/util/TestProviders';
import { render, screen, fireEvent } from '@testing-library/react';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';

// Mock the useWorkers hook since FileActions uses it
vi.mock('@studio/providers/workers/useWorkers', () => ({
Expand Down Expand Up @@ -208,7 +208,7 @@ describe('FilesetFilePreviewPanel', () => {
expect(codeEditor).toBeInTheDocument();
});

it('calls onFolderClick when folder breadcrumb is clicked', () => {
it('calls onFolderClick when folder breadcrumb is clicked', async () => {
const onFolderClick = vi.fn();
const props = {
...defaultProps,
Expand All @@ -222,14 +222,17 @@ describe('FilesetFilePreviewPanel', () => {
</TestProviders>
);

// Click on the first folder breadcrumb
// Click on the first folder breadcrumb. Navigation is deferred until the
// panel's close animation finishes, so the callback fires asynchronously.
const folder1Breadcrumb = screen.getByText('folder1');
fireEvent.click(folder1Breadcrumb);

expect(onFolderClick).toHaveBeenCalledWith('folder1');
// Navigation must not fire synchronously — it waits for the close animation.
expect(onFolderClick).not.toHaveBeenCalled();
await waitFor(() => expect(onFolderClick).toHaveBeenCalledWith('folder1'));
Comment thread
steramae-nvidia marked this conversation as resolved.
});

it('calls onFolderClick with correct path for nested folders', () => {
it('calls onFolderClick with correct path for nested folders', async () => {
const onFolderClick = vi.fn();
const props = {
...defaultProps,
Expand All @@ -243,11 +246,13 @@ describe('FilesetFilePreviewPanel', () => {
</TestProviders>
);

// Click on the second folder breadcrumb
// Click on the second folder breadcrumb (navigation deferred until close).
const folder2Breadcrumb = screen.getByText('folder2');
fireEvent.click(folder2Breadcrumb);

expect(onFolderClick).toHaveBeenCalledWith('folder1/folder2');
// Navigation must not fire synchronously — it waits for the close animation.
expect(onFolderClick).not.toHaveBeenCalled();
await waitFor(() => expect(onFolderClick).toHaveBeenCalledWith('folder1/folder2'));
});

it('does not make file breadcrumb clickable', () => {
Expand All @@ -269,7 +274,7 @@ describe('FilesetFilePreviewPanel', () => {
expect(fileBreadcrumb.tagName).toBe('SPAN');
});

it('calls onFilesetClick when fileset breadcrumb is clicked', () => {
it('calls onFilesetClick when fileset breadcrumb is clicked', async () => {
const onFilesetClick = vi.fn();
const props = {
...defaultProps,
Expand All @@ -282,9 +287,12 @@ describe('FilesetFilePreviewPanel', () => {
</TestProviders>
);

// Navigation is deferred until the panel's close animation finishes.
const filesetBreadcrumb = screen.getByText('test-dataset');
fireEvent.click(filesetBreadcrumb);

expect(onFilesetClick).toHaveBeenCalledTimes(1);
// Navigation must not fire synchronously — it waits for the close animation.
expect(onFilesetClick).not.toHaveBeenCalled();
await waitFor(() => expect(onFilesetClick).toHaveBeenCalledTimes(1));
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,18 @@
// SPDX-License-Identifier: Apache-2.0

import { useFilesListFilesetFiles } from '@nemo/sdk/generated/platform/api';
import { SidePanel } from '@nvidia/foundations-react-core';
import { SidePanel, SidePanelCloseButton } from '@nvidia/foundations-react-core';
import { FilesetFilePreviewHeader } from '@studio/components/FilesetFilePreviewPanel/components/FilesetFilePreviewHeader';
import { FilesetFilePreviewContent } from '@studio/components/FilesetFilePreviewPanel/FilesetFilePreviewContent';
import type { FileSystemFile } from '@studio/components/FilesTable/utils';
import type { FC } from 'react';
import { useRef, type FC } from 'react';

export interface FilesetFilePreviewPanelProps {
// Panel chrome
open: boolean;
onCloseClick: () => void;
onOutsideClick?: () => void;
onClosing?: () => void;

// Fileset context
workspace: string;
Expand Down Expand Up @@ -46,6 +47,7 @@ export const FilesetFilePreviewPanel: FC<FilesetFilePreviewPanelProps> = ({
open,
onCloseClick,
onOutsideClick,
onClosing,
workspace,
filesetName,
filePath,
Expand All @@ -58,16 +60,25 @@ export const FilesetFilePreviewPanel: FC<FilesetFilePreviewPanelProps> = ({
isLoading,
error,
}) => {
const closeTriggerRef = useRef<HTMLButtonElement>(null);
const pendingCloseRef = useRef<(() => void) | null>(null);

const handleOpenChange = (isOpen: boolean) => {
if (!isOpen) onCloseClick();
if (isOpen) return;
onClosing?.();
const navigateOnClose = pendingCloseRef.current ?? onCloseClick;
pendingCloseRef.current = null;
navigateOnClose();
};

const markDismiss = () => {
pendingCloseRef.current = onOutsideClick ?? onCloseClick;
};

const handleOutside = () => {
if (onOutsideClick) {
onOutsideClick();
} else {
onCloseClick();
}
// Run the animated close, then `action` once it finishes.
const closeWith = (action: () => void) => {
pendingCloseRef.current = action;
closeTriggerRef.current?.click();
};

const { data: allFilesResponse } = useFilesListFilesetFiles(workspace, filesetName, undefined, {
Expand All @@ -82,33 +93,39 @@ export const FilesetFilePreviewPanel: FC<FilesetFilePreviewPanelProps> = ({
side="right"
open={open}
onOpenChange={handleOpenChange}
onEscapeKeyDown={(e) => {
e.preventDefault();
handleOutside();
}}
onPointerDownOutside={(e) => {
e.preventDefault();
handleOutside();
}}
onEscapeKeyDown={markDismiss}
onPointerDownOutside={markDismiss}
slotHeading={
<FilesetFilePreviewHeader
workspace={workspace}
filesetName={filesetName}
filePath={filePath}
file={file}
onFilesetClick={onFilesetClick}
onFolderClick={onFolderClick}
onFilesetClick={onFilesetClick ? () => closeWith(onFilesetClick) : undefined}
onFolderClick={
onFolderClick ? (folderPath) => closeWith(() => onFolderClick(folderPath)) : undefined
}
onDeleteSuccess={onDeleteSuccess}
onRenameSuccess={onRenameSuccess}
/>
}
attributes={{
SidePanelHeading: { className: 'font-normal' },
SidePanelCloseButton: { type: 'button' },
}}
bordered
modal
className="max-w-[960px] w-full"
>
<SidePanelCloseButton
ref={closeTriggerRef}
type="button"
className="sr-only"
tabIndex={-1}
aria-hidden
>
Close
</SidePanelCloseButton>
<FilesetFilePreviewContent
workspace={workspace}
filesetName={filesetName}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ export const FilesetSidePanelWrapper: FC<FilesetSidePanelWrapperProps> = ({
attributes={{
SidePanelHeading: { className: 'font-normal' },
SidePanelMain: { className: 'p-0 overflow-x-hidden' },
SidePanelCloseButton: { type: 'button' },
}}
bordered
modal
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,50 +17,42 @@ interface PanelManagementProps {
}

/**
* Manages the dataset and file preview panels using the animation pattern.
* Dataset and file preview panels.
*
* This component handles:
* - URL param reading and syncing
* - Animation state management
* - Panel open/close animations
* - Dataset file fetching
* - Navigation handlers for both panels
* **Mount** follows the URL; close navigation is deferred until the SidePanel exit
* animation finishes, so panel data stays mounted for the whole close.
*
* **Open** is separate. SidePanel skips enter animation when mounting already open, so
* we mount closed and flip open one commit later (effect). Close must flip open false
* synchronously in the handler — an effect-driven close races SidePanel's isClosing
* reset and causes a visible flash.
*/
export const PanelManagement: FC<PanelManagementProps> = ({ workspace }) => {
const navigate = useNavigate();
const { getQueryParam } = useQueryParams();

// ==========================================
// URL PARAMS & ANIMATION STATE
// URL PARAMS & OPEN STATE
// ==========================================

// 1. Read URL params
// `useParams()` already returns decoded values, and `getFilesetFileRoute()`
// encodes them when building the URL — decoding again here is redundant and
// throws on valid names containing a literal `%`.
const {
[ROUTE_PARAMS.filesetId]: datasetIdEncoded,
[ROUTE_PARAMS.filesetId]: datasetIdFromUrl,
[ROUTE_PARAMS.filePathEncoded]: filePathFromUrl,
} = useParams();

const datasetIdFromUrl = datasetIdEncoded ? decodeURIComponent(datasetIdEncoded) : undefined;
const currentFolder = getQueryParam(QUERY_PARAMETERS.filesetFolder);

// 2. Animation state (holds values during close animations)
const [animatingDatasetId, setAnimatingDatasetId] = useState<string | undefined>();
const [animatingFilePath, setAnimatingFilePath] = useState<string | undefined>();
// Mount as soon as the URL references the panel (see component doc).
const showDatasetPanel = !!datasetIdFromUrl;
const showFilePanel = !!filePathFromUrl;
Comment thread
steramae-nvidia marked this conversation as resolved.

// 3. Open state (controls animations)
// Open flag — lags the URL on open (drives the slide-in), flips synchronously
// on close (see close handlers below).
const [isDatasetPanelOpen, setIsDatasetPanelOpen] = useState(false);
const [isFilePanelOpen, setIsFilePanelOpen] = useState(false);

// 4. Sync URL params to animating state
useEffect(() => {
if (datasetIdFromUrl) setAnimatingDatasetId(datasetIdFromUrl);
}, [datasetIdFromUrl]);

useEffect(() => {
if (filePathFromUrl) setAnimatingFilePath(filePathFromUrl);
}, [filePathFromUrl]);

// 5. Sync open state with URL (triggers animations)
useEffect(() => {
setIsDatasetPanelOpen(!!datasetIdFromUrl);
}, [datasetIdFromUrl]);
Expand All @@ -69,16 +61,12 @@ export const PanelManagement: FC<PanelManagementProps> = ({ workspace }) => {
setIsFilePanelOpen(!!filePathFromUrl);
}, [filePathFromUrl]);

// 6. Determine panel visibility
const showDatasetPanel = !!(datasetIdFromUrl || animatingDatasetId);
const showFilePanel = !!(filePathFromUrl || animatingFilePath);

// ==========================================
// DATASET PANEL LOGIC
// ==========================================

// Parse dataset info from URL
const datasetFullName = animatingDatasetId || '';
const datasetFullName = datasetIdFromUrl ?? '';
const { workspace: datasetworkspace, name: datasetName } = getPartsFromReference(datasetFullName);

// Fetch dataset files
Expand All @@ -93,15 +81,10 @@ export const PanelManagement: FC<PanelManagementProps> = ({ workspace }) => {

// Dataset panel handlers
const handleDatasetPanelClose = () => {
setIsDatasetPanelOpen(false);
navigate(generatePath(ROUTES.workspace.filesets, { workspace }));
};

const handleDatasetPanelOpenChange = (open: boolean) => {
if (!open && !datasetIdFromUrl) {
setAnimatingDatasetId(undefined); // Safe to unmount
}
};

const handleFileSelect = (filePath: string) => {
const to = getFilesetFileRoute(workspace, datasetFullName, filePath);
navigate(to);
Expand All @@ -111,9 +94,12 @@ export const PanelManagement: FC<PanelManagementProps> = ({ workspace }) => {
// FILE PANEL LOGIC
// ==========================================

const decodedFilePath = animatingFilePath ? decodeURIComponent(animatingFilePath) : '';
const decodedFilePath = filePathFromUrl ?? '';

const handleFilePanelClosing = () => {
setIsFilePanelOpen(false);
};

// File panel handlers
const handleFilePanelClose = () => {
const folderPathFromFile = decodedFilePath.split('/').slice(0, -1).join('/');
navigate(
Expand Down Expand Up @@ -180,7 +166,6 @@ export const PanelManagement: FC<PanelManagementProps> = ({ workspace }) => {
onFolderChange={handleFolderClick}
onFileSelect={handleFileSelect}
onClose={handleDatasetPanelClose}
onOpenChange={handleDatasetPanelOpenChange}
/>
)}

Expand All @@ -190,6 +175,7 @@ export const PanelManagement: FC<PanelManagementProps> = ({ workspace }) => {
open={isFilePanelOpen}
onCloseClick={handleFilePanelClose}
onOutsideClick={handleFilePanelOutsideClick}
onClosing={handleFilePanelClosing}
workspace={datasetworkspace || ''}
filesetName={datasetName || ''}
filePath={decodedFilePath}
Expand Down