Skip to content
Merged
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 @@ -9,6 +9,7 @@ import { parseFilesetLocation } from '@nemo/common/src/components/DatasetFileSel
import { parseFilesetUrl } from '@nemo/common/src/components/DatasetFileSelect/utils';
import type { FileListItem } from '@nemo/common/src/components/FileList';
import type { UseControllerComponentProps } from '@nemo/common/src/utils/types';
import type { FilesetPurpose } from '@nemo/sdk/generated/platform/schema';
import { FormField } from '@nvidia/foundations-react-core';
import { FC, useMemo } from 'react';
import { useController } from 'react-hook-form';
Expand All @@ -32,6 +33,12 @@ interface ControlledDatasetFileSelectProps extends UseControllerComponentProps {
/** Inline-only: skip the "Add" button and commit on selection; also hides
* the file list rendered below the picker. */
autoCommit?: boolean;
/** Fileset ``purpose`` the picker lists. Defaults to ``'dataset'``. */
filesetPurpose?: FilesetPurpose;
/** Label for the fileset picker. Defaults to ``'Dataset'``. */
datasetLabel?: string;
/** Auto-select the first root-level accepted file on fileset selection. */
autoSelectFirstAcceptable?: boolean;
/**
* Callback fired when a file is selected. Useful for custom validation or processing.
* Called with the selected file info, or null when file is cleared.
Expand Down Expand Up @@ -73,6 +80,9 @@ export const ControlledDatasetFileSelect: FC<ControlledDatasetFileSelectProps> =
listLabel,
inline,
autoCommit,
filesetPurpose,
datasetLabel,
autoSelectFirstAcceptable,
}) => {
const {
field: { onChange, value },
Expand Down Expand Up @@ -131,6 +141,9 @@ export const ControlledDatasetFileSelect: FC<ControlledDatasetFileSelectProps> =
listLabel={listLabel}
inline={inline}
autoCommit={autoCommit}
filesetPurpose={filesetPurpose}
datasetLabel={datasetLabel}
autoSelectFirstAcceptable={autoSelectFirstAcceptable}
/>
</FormField>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { FileList, FileListItem } from '@nemo/common/src/components/FileList';
import { UploadModal } from '@nemo/common/src/components/UploadModal/index';
import { InlineUploadPicker } from '@nemo/common/src/components/UploadModal/InlineUploadPicker';
import type { SubmitUploadType } from '@nemo/common/src/components/UploadModal/types';
import type { FilesetPurpose } from '@nemo/sdk/generated/platform/schema';
import { SidePanel, Stack, Text } from '@nvidia/foundations-react-core';
import { FolderOpen } from 'lucide-react';
import { FC, useEffect, useMemo, useRef, useState } from 'react';
Expand Down Expand Up @@ -49,6 +50,12 @@ interface DatasetFileSelectProps {
* selects a file. Also hides the post-commit file list since the parent
* form already reflects the selection. */
autoCommit?: boolean;
/** Fileset ``purpose`` the picker lists. Defaults to ``'dataset'``. */
filesetPurpose?: FilesetPurpose;
/** Label for the fileset picker. Defaults to ``'Dataset'``. */
datasetLabel?: string;
/** Auto-select the first root-level accepted file on fileset selection. */
autoSelectFirstAcceptable?: boolean;
}

/**
Expand Down Expand Up @@ -77,6 +84,9 @@ export const DatasetFileSelect: FC<DatasetFileSelectProps> = ({
listLabel,
inline = false,
autoCommit = false,
filesetPurpose,
datasetLabel,
autoSelectFirstAcceptable,
}) => {
const [isModalOpen, setIsModalOpen] = useState(false);

Expand Down Expand Up @@ -198,6 +208,9 @@ export const DatasetFileSelect: FC<DatasetFileSelectProps> = ({
invalidFileMode={invalidFileMode}
onSubmit={handleModalSubmit}
autoCommit={autoCommit}
filesetPurpose={filesetPurpose}
datasetLabel={datasetLabel}
autoSelectFirstAcceptable={autoSelectFirstAcceptable}
/>
) : (
<DatasetFileSelectButton
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@

import { UploadFile, UploadDataset } from '@nemo/common/src/components/UploadModal/types';
import { sanitizeFilenameForDatasetName } from '@nemo/common/src/components/UploadModal/utils';
import type { FilesetPurpose } from '@nemo/sdk/generated/platform/schema';
import { useReducer } from 'react';

/**
Expand Down Expand Up @@ -45,6 +46,12 @@ export type UploadModalState = {
* picker in ``autoCommit`` mode where uploading new files would race with
* the user editing the dataset name. Defaults to ``true``. */
allowNewDataset: boolean;
/** Fileset ``purpose`` the picker lists. Defaults to ``'dataset'``. */
filesetPurpose?: FilesetPurpose;
/** Label for the fileset picker. Defaults to ``'Dataset'``. */
datasetLabel?: string;
/** Auto-select the first root-level accepted file on fileset selection. */
autoSelectFirstAcceptable?: boolean;
errors: Record<string, string>;
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,10 @@

import { UploadModalProvider } from '@nemo/common/src/components/UploadModal/Context/UploadModalProvider';
import { useUploadModalContext } from '@nemo/common/src/components/UploadModal/Context/useUploadModalContext';
import { UploadModalState } from '@nemo/common/src/components/UploadModal/Context/useUploadModalReducer';
import {
uploadModalInitialState,
UploadModalState,
} from '@nemo/common/src/components/UploadModal/Context/useUploadModalReducer';
import { DatasetSelect } from '@nemo/common/src/components/UploadModal/DatasetUploader/Select';
import { filesListFilesetFiles, useFilesListFilesets } from '@nemo/sdk/generated/platform/api';
import { FilesetOutput } from '@nemo/sdk/generated/platform/schema';
Expand Down Expand Up @@ -57,19 +60,23 @@ const ContextReader = ({
return null;
};

const createWrapper = () => {
const createWrapper = (initialState?: Partial<UploadModalState>) => {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
},
});
return ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={queryClient}>
<UploadModalProvider>{children}</UploadModalProvider>
<UploadModalProvider initialState={{ ...uploadModalInitialState, ...initialState }}>
{children}
</UploadModalProvider>
</QueryClientProvider>
);
};

const filesetFile = (path: string) => ({ path, file_ref: `ref-${path}` });

describe('DatasetSelect', () => {
const user = userEvent.setup();

Expand Down Expand Up @@ -182,6 +189,54 @@ describe('DatasetSelect', () => {
);
});

it('auto-selects the first root-level accepted file when autoSelectFirstAcceptable is set', async () => {
vi.mocked(filesListFilesetFiles).mockResolvedValueOnce({
data: [filesetFile('smaller_test.csv'), filesetFile('email_phishing_analyzer-eval.yml')],
} as Awaited<ReturnType<typeof filesListFilesetFiles>>);

let contextState: UploadModalState | undefined;
render(
<>
<DatasetSelect project="test-project" />
<ContextReader onContextChange={(state) => (contextState = state)} />
</>,
{ wrapper: createWrapper({ autoSelectFirstAcceptable: true, acceptableFileTypes: ['.yml'] }) }
);

await user.click(screen.getByRole('combobox'));
await user.click(await screen.findByRole('option', { name: 'dataset1' }));

await waitFor(() => {
expect(contextState?.selectedFiles).toHaveLength(1);
});
expect((contextState?.selectedFiles[0]?.file as { path?: string }).path).toBe(
'email_phishing_analyzer-eval.yml'
);
});

it('selects nothing when no root-level accepted file exists', async () => {
vi.mocked(filesListFilesetFiles).mockResolvedValueOnce({
data: [filesetFile('smaller_test.csv'), filesetFile('nested/config.yml')],
} as Awaited<ReturnType<typeof filesListFilesetFiles>>);

let contextState: UploadModalState | undefined;
render(
<>
<DatasetSelect project="test-project" />
<ContextReader onContextChange={(state) => (contextState = state)} />
</>,
{ wrapper: createWrapper({ autoSelectFirstAcceptable: true, acceptableFileTypes: ['.yml'] }) }
);

await user.click(screen.getByRole('combobox'));
await user.click(await screen.findByRole('option', { name: 'dataset1' }));

await waitFor(() => {
expect(contextState?.dataset?.type).toBe('existing');
});
expect(contextState?.selectedFiles).toHaveLength(0);
});

it('includes "New Dataset" option', async () => {
render(<DatasetSelect project="test-project" />, {
wrapper: createWrapper(),
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import { getFileExtension } from '@nemo/common/src/components/DatasetFileSelect/utils';
import { useUploadModalContext } from '@nemo/common/src/components/UploadModal/Context/useUploadModalContext';
import { getExistingFileId } from '@nemo/common/src/components/UploadModal/utils';
import { getEntityReference } from '@nemo/common/src/namedEntity';
Expand All @@ -26,7 +27,9 @@ const filesetToOption = (fileset: FilesetOutput) => ({

export const DatasetSelect: FC<Props> = ({ project, disabled, error }) => {
const [state, dispatch] = useUploadModalContext();
const { dataset, allowNewDataset } = state;
const { dataset, allowNewDataset, acceptableFileTypes, autoSelectFirstAcceptable } = state;
const purpose = state.filesetPurpose ?? 'dataset';
const label = state.datasetLabel ?? 'Dataset';

// Extract workspace from project (project format is "workspace/name" or just "workspace")
const workspace = project.includes('/') ? project.split('/')[0] : project;
Expand All @@ -43,7 +46,7 @@ export const DatasetSelect: FC<Props> = ({ project, disabled, error }) => {
*/
page_size: 100, // v2 API max is 100
sort: 'created_at',
filter: { purpose: 'dataset' },
filter: { purpose },
});

const filesets = useMemo(() => filesetsResponse?.data ?? [], [filesetsResponse]);
Expand All @@ -67,14 +70,22 @@ export const DatasetSelect: FC<Props> = ({ project, disabled, error }) => {
try {
const filesResponse = await filesListFilesetFiles(fileset.workspace, fileset.name);
const filesetFiles = filesResponse.data ?? [];
dispatch({
type: 'SET_FILES',
payload: filesetFiles.map((file) => ({
id: getExistingFileId(file),
type: 'existing',
file,
})),
});
const uploadFiles = filesetFiles.map(
(file) => ({ id: getExistingFileId(file), type: 'existing', file }) as const
);
dispatch({ type: 'SET_FILES', payload: uploadFiles });
// Auto-select the first root-level accepted file (only when >1, since
// the reducer already auto-selects a lone file).
if (autoSelectFirstAcceptable && uploadFiles.length > 1) {
const allowed = acceptableFileTypes.map((t) => t.toLowerCase());
const target = uploadFiles.find((f) => {
const path = f.file.path;
if (path.includes('/')) return false;
const ext = getFileExtension(path)?.toLowerCase();
return !!ext && allowed.includes(ext);
});
if (target) dispatch({ type: 'TOGGLE_FILE_SELECTION', payload: target });
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
dispatch({ type: 'SET_FETCHING', payload: false });
} catch (error) {
console.error('Error fetching dataset files', error);
Expand All @@ -100,7 +111,7 @@ export const DatasetSelect: FC<Props> = ({ project, disabled, error }) => {

return (
<FormField
slotLabel="Dataset"
slotLabel={label}
slotError={
<Flex gap="density-md" align="center">
<CircleAlert className="text-feedback-danger" />
Expand Down Expand Up @@ -135,14 +146,14 @@ export const DatasetSelect: FC<Props> = ({ project, disabled, error }) => {
]
: []),
{
slotHeading: 'Existing Datasets',
slotHeading: `Existing ${label}s`,
attributes: { MenuHeading: { className: 'hidden', 'aria-hidden': true } },
items: datasetOptions,
},
]}
value={selectedDatasetOption}
onValueChange={handleDatasetSelect}
placeholder="Select a dataset"
placeholder={`Select a ${label.toLowerCase()}`}
/>
)}
</FormField>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@ type InlineUploadPickerProps = Pick<
| 'acceptableFileSize'
| 'invalidFileMode'
| 'allowNewDataset'
| 'filesetPurpose'
| 'datasetLabel'
| 'autoSelectFirstAcceptable'
> & {
/** Called once the picked / uploaded file is committed. */
onSubmit: (data: SubmitUploadType) => void;
Expand Down Expand Up @@ -148,6 +151,9 @@ export const InlineUploadPicker: FC<InlineUploadPickerProps> = ({
acceptableFileSize,
invalidFileMode,
allowNewDataset,
filesetPurpose,
datasetLabel,
autoSelectFirstAcceptable,
onSubmit,
addButtonText = 'Add file',
autoCommit = false,
Expand All @@ -168,13 +174,20 @@ export const InlineUploadPicker: FC<InlineUploadPickerProps> = ({
acceptableFileSize: acceptableFileSize ?? uploadModalInitialState.acceptableFileSize,
invalidFileMode: invalidFileMode ?? uploadModalInitialState.invalidFileMode,
allowNewDataset: effectiveAllowNewDataset,
filesetPurpose: filesetPurpose ?? uploadModalInitialState.filesetPurpose,
datasetLabel: datasetLabel ?? uploadModalInitialState.datasetLabel,
autoSelectFirstAcceptable:
autoSelectFirstAcceptable ?? uploadModalInitialState.autoSelectFirstAcceptable,
}),
[
allowMultipleFileSelection,
acceptableFileTypes,
acceptableFileSize,
invalidFileMode,
effectiveAllowNewDataset,
filesetPurpose,
datasetLabel,
autoSelectFirstAcceptable,
]
);
return (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -62,9 +62,11 @@ export const SimpleFilesTable = () => {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [files, allowedExtensions, invalidFileMode]);

const hasValidSelection = selectedFiles.some((file) => isFileAllowed(file));
const disabledFilesMessage =
invalidFileMode === 'disable' &&
allowedExtensions.size > 0 &&
!hasValidSelection &&
visibleFiles.some((file) => !isFileAllowed(file))
? `Only ${acceptableFileTypes.join(', ')} files can be selected. Upload a supported file or choose a different fileset.`
: null;
Expand Down Expand Up @@ -111,6 +113,7 @@ export const SimpleFilesTable = () => {
col.accessor('name', { header: 'Name' }),
col.accessor('size', {
header: 'Size',
size: 120,
cell: (ctx) => formatFileSize(ctx.getValue()),
}),
],
Expand All @@ -129,7 +132,8 @@ export const SimpleFilesTable = () => {

return (
<Stack className="min-h-0 flex-1 w-full" gap="density-md">
<div className="border border-base rounded-md overflow-hidden">
{/* Name column fills the row; Size (col 3) is pinned to 120px. */}
<div className="border border-base rounded-md overflow-hidden [&_tr>*:nth-child(2)]:w-full! [&_tr>*:nth-child(2)]:max-w-none! [&_tr>*:nth-child(3)]:w-[120px]! [&_tr>*:nth-child(3)]:min-w-[120px]! [&_tr>*:nth-child(3)]:max-w-[120px]!">
<RadioGroupRoot
name="simple-files-table"
value={selectedFiles[0]?.id ?? ''}
Expand Down
12 changes: 11 additions & 1 deletion web/packages/common/src/components/UploadModal/types.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import { FilesetFileOutput, FilesetOutput } from '@nemo/sdk/generated/platform/schema';
import {
FilesetFileOutput,
FilesetOutput,
FilesetPurpose,
} from '@nemo/sdk/generated/platform/schema';
import {
ModalContent,
ModalHeading,
Expand Down Expand Up @@ -62,6 +66,12 @@ export interface UploadModalProps {
/** When false, the dataset picker hides the "Create new dataset" option.
* Defaults to ``true`` (legacy behaviour). */
allowNewDataset?: boolean;
/** Fileset ``purpose`` the picker lists. Defaults to ``'dataset'``. */
filesetPurpose?: FilesetPurpose;
/** Label for the fileset picker. Defaults to ``'Dataset'``. */
datasetLabel?: string;
/** Auto-select the first root-level accepted file on fileset selection. */
autoSelectFirstAcceptable?: boolean;
attributes?: {
ModalRoot?: React.ComponentProps<typeof ModalRoot>;
ModalContent?: React.ComponentProps<typeof ModalContent>;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ llms:
eval:
general:
max_concurrency: 4
output_dir: eval/agent
output_dir: .
dataset:
_type: csv
file_path: smaller_test.csv
Expand Down
Loading