From fd8624bd703ccc72f3f66049bf3857a59c70476a Mon Sep 17 00:00:00 2001 From: Sean Teramae Date: Thu, 6 Aug 2026 15:52:44 -0700 Subject: [PATCH 1/3] feat(studio): Support searching for fileset file select Signed-off-by: Sean Teramae --- .../FilesetSearchableSelect/index.tsx | 102 ++++++++ .../useFilesetSearch.test.tsx | 133 ++++++++++ .../useFilesetSearch.ts | 98 ++++++++ .../DatasetUploader/Select.test.tsx | 163 ++++++------- .../UploadModal/DatasetUploader/Select.tsx | 229 ++++++++---------- .../ColumnConfigPanel/SeedDatasetConfig.tsx | 2 +- .../FilesetSearchableSelect.tsx | 97 -------- .../WorkspaceSourceFields.tsx | 2 +- 8 files changed, 511 insertions(+), 315 deletions(-) create mode 100644 web/packages/common/src/components/FilesetSearchableSelect/index.tsx create mode 100644 web/packages/common/src/components/FilesetSearchableSelect/useFilesetSearch.test.tsx create mode 100644 web/packages/common/src/components/FilesetSearchableSelect/useFilesetSearch.ts delete mode 100644 web/packages/studio/src/routes/DeploymentsListRoute/CreateDeploymentSidePanel/FilesetSearchableSelect.tsx diff --git a/web/packages/common/src/components/FilesetSearchableSelect/index.tsx b/web/packages/common/src/components/FilesetSearchableSelect/index.tsx new file mode 100644 index 0000000000..a6494c2ef6 --- /dev/null +++ b/web/packages/common/src/components/FilesetSearchableSelect/index.tsx @@ -0,0 +1,102 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { useFilesetSearch } from '@nemo/common/src/components/FilesetSearchableSelect/useFilesetSearch'; +import { + ControlledSearchableSelect, + type SelectItemOption, +} from '@nemo/common/src/components/form/ControlledSearchableSelect'; +import { getEntityReference } from '@nemo/common/src/namedEntity'; +import type { FilesetOutput, FilesetPurpose } from '@nemo/sdk/generated/platform/schema'; +import { type ReactNode, useCallback, useMemo } from 'react'; +import { type FieldValues, type UseControllerProps } from 'react-hook-form'; + +export type FilesetSearchableSelectFormFieldProps = { + slotLabel?: ReactNode; + slotInfo?: ReactNode; + slotError?: string; +}; + +export type FilesetSearchableSelectProps = { + workspace: string; + queryEnabled?: boolean; + useControllerProps: UseControllerProps; + formFieldProps: FilesetSearchableSelectFormFieldProps; + triggerPlaceholder?: string; + /** Restrict to one fileset `purpose`. Omit to list every purpose. */ + purpose?: FilesetPurpose; + /** Options rendered above the fileset list (e.g. a "New Dataset" entry). */ + leadingOptions?: SelectItemOption[]; + groupLabels?: Record; + /** Build the option row for a fileset. Defaults to its `workspace/name` reference. */ + renderOption?: (fileset: FilesetOutput) => SelectItemOption; + /** Fired with the picked option value, alongside the form field update. The matching + * fileset is resolved from the loaded pages, and is undefined for `leadingOptions`. */ + onChange?: (value: string, fileset?: FilesetOutput) => void; + disabled?: boolean; +}; + +const defaultRenderOption = (fileset: FilesetOutput): SelectItemOption => { + const ref = getEntityReference(fileset); + return { value: ref, label: ref }; +}; + +/** + * A fileset picker with server-side search and pagination. + * + * Prefer this over a plain `Select` fed by a single `filesListFilesets` page: that shape + * caps out at the API's 100-item page and gives the user no way to reach the rest. + */ +export function FilesetSearchableSelect({ + workspace, + queryEnabled = true, + useControllerProps, + formFieldProps, + triggerPlaceholder = 'Select a fileset', + purpose, + leadingOptions, + groupLabels, + renderOption = defaultRenderOption, + onChange, + disabled, +}: FilesetSearchableSelectProps) { + const { filesets, setSearch, loadMore, hasMore, isLoading, isLoadingMore } = useFilesetSearch({ + workspace, + purpose, + enabled: queryEnabled, + }); + + const options = useMemo( + () => [...(leadingOptions ?? []), ...filesets.map(renderOption)], + [filesets, leadingOptions, renderOption] + ); + + const handleChange = useCallback( + (value: string) => { + onChange?.( + value, + filesets.find((fileset) => getEntityReference(fileset) === value) + ); + }, + [onChange, filesets] + ); + + return ( + + ); +} diff --git a/web/packages/common/src/components/FilesetSearchableSelect/useFilesetSearch.test.tsx b/web/packages/common/src/components/FilesetSearchableSelect/useFilesetSearch.test.tsx new file mode 100644 index 0000000000..a67200eab3 --- /dev/null +++ b/web/packages/common/src/components/FilesetSearchableSelect/useFilesetSearch.test.tsx @@ -0,0 +1,133 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { withOperators } from '@nemo/common/src/api/filterOperators'; +import { useFilesetSearch } from '@nemo/common/src/components/FilesetSearchableSelect/useFilesetSearch'; +import { filesListFilesets } from '@nemo/sdk/generated/platform/api'; +import { FilesetOutput } from '@nemo/sdk/generated/platform/schema'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { act, renderHook, waitFor } from '@testing-library/react'; + +vi.mock('@nemo/sdk/generated/platform/api', () => ({ + filesListFilesets: vi.fn(), + getFilesListFilesetsQueryKey: vi.fn((workspace: string) => ['filesets', workspace]), +})); + +const fileset = (name: string) => ({ id: `default/${name}`, name, workspace: 'default' }); + +const page = (names: string[], pageNumber: number, totalPages: number) => + ({ + data: names.map(fileset) as FilesetOutput[], + pagination: { page: pageNumber, total_pages: totalPages }, + }) as Awaited>; + +const wrapper = ({ children }: { children: React.ReactNode }) => { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return {children}; +}; + +describe('useFilesetSearch', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('requests the first page newest-first, at the shared page size', async () => { + vi.mocked(filesListFilesets).mockResolvedValue(page(['a'], 1, 1)); + + const { result } = renderHook(() => useFilesetSearch({ workspace: 'ws' }), { wrapper }); + + await waitFor(() => expect(result.current.filesets).toHaveLength(1)); + expect(filesListFilesets).toHaveBeenCalledWith( + 'ws', + expect.objectContaining({ page: 1, page_size: 20, sort: '-created_at' }), + expect.anything() + ); + }); + + it('accumulates pages instead of truncating at the first one', async () => { + vi.mocked(filesListFilesets) + .mockResolvedValueOnce(page(['a', 'b'], 1, 2)) + .mockResolvedValueOnce(page(['c'], 2, 2)); + + const { result } = renderHook(() => useFilesetSearch({ workspace: 'ws' }), { wrapper }); + + await waitFor(() => expect(result.current.filesets).toHaveLength(2)); + expect(result.current.hasMore).toBe(true); + + await act(async () => { + await result.current.loadMore(); + }); + + await waitFor(() => expect(result.current.filesets).toHaveLength(3)); + expect(result.current.filesets.map((f) => f.name)).toEqual(['a', 'b', 'c']); + expect(result.current.hasMore).toBe(false); + }); + + it('stops paging at the last page', async () => { + vi.mocked(filesListFilesets).mockResolvedValue(page(['a'], 1, 1)); + + const { result } = renderHook(() => useFilesetSearch({ workspace: 'ws' }), { wrapper }); + + await waitFor(() => expect(result.current.hasMore).toBe(false)); + await act(async () => { + await result.current.loadMore(); + }); + expect(filesListFilesets).toHaveBeenCalledTimes(1); + }); + + it('sends the search term as a server-side $like filter', async () => { + vi.mocked(filesListFilesets).mockResolvedValue(page(['a'], 1, 1)); + + const { result } = renderHook(() => useFilesetSearch({ workspace: 'ws' }), { wrapper }); + await waitFor(() => expect(result.current.filesets).toHaveLength(1)); + + act(() => result.current.setSearch('pay')); + + await waitFor(() => + expect(filesListFilesets).toHaveBeenCalledWith( + 'ws', + expect.objectContaining({ filter: withOperators({ name: { $like: '%pay%' } }) }), + expect.anything() + ) + ); + }); + + it('combines search and purpose into one filter', async () => { + vi.mocked(filesListFilesets).mockResolvedValue(page(['a'], 1, 1)); + + const { result } = renderHook(() => useFilesetSearch({ workspace: 'ws', purpose: 'generic' }), { + wrapper, + }); + await waitFor(() => expect(result.current.filesets).toHaveLength(1)); + + act(() => result.current.setSearch('pay')); + + await waitFor(() => + expect(filesListFilesets).toHaveBeenCalledWith( + 'ws', + expect.objectContaining({ + filter: withOperators({ name: { $like: '%pay%' }, purpose: 'generic' }), + }), + expect.anything() + ) + ); + }); + + it('sends no filter when unfiltered, so every purpose is listed', async () => { + vi.mocked(filesListFilesets).mockResolvedValue(page(['a'], 1, 1)); + + const { result } = renderHook(() => useFilesetSearch({ workspace: 'ws' }), { wrapper }); + + await waitFor(() => expect(result.current.filesets).toHaveLength(1)); + expect(filesListFilesets).toHaveBeenCalledWith( + 'ws', + expect.objectContaining({ filter: undefined }), + expect.anything() + ); + }); + + it('does not query without a workspace', () => { + renderHook(() => useFilesetSearch({ workspace: '' }), { wrapper }); + expect(filesListFilesets).not.toHaveBeenCalled(); + }); +}); diff --git a/web/packages/common/src/components/FilesetSearchableSelect/useFilesetSearch.ts b/web/packages/common/src/components/FilesetSearchableSelect/useFilesetSearch.ts new file mode 100644 index 0000000000..f3fe46a92c --- /dev/null +++ b/web/packages/common/src/components/FilesetSearchableSelect/useFilesetSearch.ts @@ -0,0 +1,98 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { withOperators } from '@nemo/common/src/api/filterOperators'; +import { filesListFilesets, getFilesListFilesetsQueryKey } from '@nemo/sdk/generated/platform/api'; +import type { + FilesetOutput, + FilesetPurpose, + FilesListFilesetsParams, +} from '@nemo/sdk/generated/platform/schema'; +import { useInfiniteQuery } from '@tanstack/react-query'; +import { useCallback, useMemo, useState } from 'react'; + +/** The v2 API caps `page_size` at 100; 20 keeps the first paint small and pages on scroll. */ +export const FILESETS_PAGE_SIZE = 20; + +export interface UseFilesetSearchOptions { + workspace: string; + /** Restrict to one fileset `purpose`. Omit to list every purpose. */ + purpose?: FilesetPurpose; + enabled?: boolean; + pageSize?: number; +} + +export interface UseFilesetSearchResult { + /** Every fileset loaded so far, newest first. */ + filesets: FilesetOutput[]; + search: string; + setSearch: (value: string) => void; + loadMore: () => Promise; + hasMore: boolean; + isLoading: boolean; + isLoadingMore: boolean; +} + +/** + * Search + paginate a workspace's filesets. + * + * Server-side on both counts: name search goes out as a `$like` filter and results are + * paged, so this does not silently truncate the way a single capped page does. Sorted + * newest-first, since a fileset the user just created is the one they are looking for. + */ +export const useFilesetSearch = ({ + workspace, + purpose, + enabled = true, + pageSize = FILESETS_PAGE_SIZE, +}: UseFilesetSearchOptions): UseFilesetSearchResult => { + const [search, setSearch] = useState(''); + + const filter = useMemo(() => { + const clauses = { + ...(search ? { name: { $like: `%${search}%` } } : {}), + ...(purpose ? { purpose } : {}), + }; + return Object.keys(clauses).length + ? withOperators(clauses) + : undefined; + }, [search, purpose]); + + const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading } = useInfiniteQuery({ + queryKey: [ + ...getFilesListFilesetsQueryKey(workspace), + 'infinite', + 'newest', + purpose ?? 'all', + search, + ] as const, + queryFn: ({ signal, pageParam }) => + filesListFilesets( + workspace, + { page: pageParam, page_size: pageSize, sort: '-created_at', filter }, + signal + ), + initialPageParam: 1, + getNextPageParam: (lastPage) => { + const p = lastPage.pagination; + return p && p.page < p.total_pages ? p.page + 1 : undefined; + }, + enabled: enabled && !!workspace, + }); + + const filesets = useMemo(() => data?.pages.flatMap((page) => page.data) ?? [], [data?.pages]); + + const loadMore = useCallback(async () => { + if (hasNextPage && !isFetchingNextPage) await fetchNextPage(); + }, [fetchNextPage, hasNextPage, isFetchingNextPage]); + + return { + filesets, + search, + setSearch, + loadMore, + hasMore: hasNextPage ?? false, + isLoading, + isLoadingMore: isFetchingNextPage, + }; +}; diff --git a/web/packages/common/src/components/UploadModal/DatasetUploader/Select.test.tsx b/web/packages/common/src/components/UploadModal/DatasetUploader/Select.test.tsx index 458215eb87..f00c19880a 100644 --- a/web/packages/common/src/components/UploadModal/DatasetUploader/Select.test.tsx +++ b/web/packages/common/src/components/UploadModal/DatasetUploader/Select.test.tsx @@ -1,6 +1,7 @@ // SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +import { withOperators } from '@nemo/common/src/api/filterOperators'; import { UploadModalProvider } from '@nemo/common/src/components/UploadModal/Context/UploadModalProvider'; import { useUploadModalContext } from '@nemo/common/src/components/UploadModal/Context/useUploadModalContext'; import { @@ -8,7 +9,7 @@ import { 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 { filesListFilesetFiles, filesListFilesets } from '@nemo/sdk/generated/platform/api'; import { FilesetOutput } from '@nemo/sdk/generated/platform/schema'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { render, screen, waitFor } from '@testing-library/react'; @@ -16,38 +17,31 @@ import userEvent from '@testing-library/user-event'; // Mock the SDK hooks vi.mock('@nemo/sdk/generated/platform/api', () => ({ - useFilesListFilesets: vi.fn(), + filesListFilesets: vi.fn(), + getFilesListFilesetsQueryKey: vi.fn((workspace: string) => ['filesets', workspace]), filesListFilesetFiles: vi.fn(), })); -const mockFilesets: FilesetOutput[] = [ - { - id: 'default/dataset1', - name: 'dataset1', - workspace: 'default', - description: '', - purpose: 'dataset', - storage: { type: 'local', path: '/data' } as const, - metadata: {}, - custom_fields: {}, - project: 'default', - created_at: '2024-01-01T00:00:00Z', - updated_at: '2024-01-01T00:00:00Z', - }, - { - id: 'default/dataset2', - name: 'dataset2', - workspace: 'default', - description: '', - purpose: 'dataset', - storage: { type: 'local', path: '/data' } as const, - metadata: {}, - custom_fields: {}, - project: 'default', - created_at: '2024-01-01T00:00:00Z', - updated_at: '2024-01-01T00:00:00Z', - }, -]; +const fileset = (name: string): FilesetOutput => ({ + id: `default/${name}`, + name, + workspace: 'default', + description: '', + purpose: 'dataset', + storage: { type: 'local', path: '/data' } as const, + metadata: {}, + custom_fields: {}, + project: 'default', + created_at: '2024-01-01T00:00:00Z', + updated_at: '2024-01-01T00:00:00Z', +}); + +const mockFilesets: FilesetOutput[] = [fileset('dataset1'), fileset('dataset2')]; + +const page = (data: FilesetOutput[], pageNumber = 1, totalPages = 1) => + ({ data, pagination: { page: pageNumber, total_pages: totalPages } }) as Awaited< + ReturnType + >; // Helper component to access context in tests const ContextReader = ({ @@ -82,11 +76,7 @@ describe('DatasetSelect', () => { beforeEach(() => { vi.clearAllMocks(); - vi.mocked(useFilesListFilesets).mockReturnValue({ - data: { data: mockFilesets }, - isLoading: false, - isError: false, - } as ReturnType); + vi.mocked(filesListFilesets).mockResolvedValue(page(mockFilesets)); vi.mocked(filesListFilesetFiles).mockResolvedValue({ data: [] }); }); @@ -98,55 +88,62 @@ describe('DatasetSelect', () => { expect(screen.getByRole('combobox')).toBeInTheDocument(); }); - it('queries filesets with purpose filter set to dataset', () => { + it('queries filesets paginated, newest first, filtered by purpose', async () => { render(, { wrapper: createWrapper(), }); - expect(useFilesListFilesets).toHaveBeenCalledWith( - expect.any(String), - expect.objectContaining({ - filter: { purpose: 'dataset' }, - }) - ); + await waitFor(() => { + expect(filesListFilesets).toHaveBeenCalledWith( + 'test-project', + expect.objectContaining({ + page: 1, + page_size: 20, + sort: '-created_at', + filter: withOperators({ purpose: 'dataset' }), + }), + expect.anything() + ); + }); + }); + + it('searches filesets server-side', async () => { + render(, { wrapper: createWrapper() }); + + await user.click(screen.getByRole('combobox')); + await user.type(screen.getByTestId('dataset-search'), 'data'); + + await waitFor(() => { + expect(filesListFilesets).toHaveBeenCalledWith( + 'test-project', + expect.objectContaining({ + filter: withOperators({ name: { $like: '%data%' }, purpose: 'dataset' }), + }), + expect.anything() + ); + }); }); it('shows loading state', async () => { - vi.mocked(useFilesListFilesets).mockReturnValue({ - data: undefined, - isLoading: true, - isError: false, - } as ReturnType); + vi.mocked(filesListFilesets).mockReturnValue(new Promise(() => {})); render(, { - wrapper: createWrapper(), + wrapper: createWrapper({ allowNewDataset: false }), }); - const select = screen.getByRole('combobox'); - expect(select).toBeInTheDocument(); - - // Open the dropdown to see the loading state - await user.click(select); - expect(screen.getByText('Loading datasets...')).toBeInTheDocument(); + await user.click(screen.getByRole('combobox')); + expect(await screen.findByLabelText('Loading options')).toBeInTheDocument(); }); - it('shows error state', async () => { - vi.mocked(useFilesListFilesets).mockReturnValue({ - data: undefined, - isLoading: false, - isError: true, - } as ReturnType); + it('shows an empty state when the query fails', async () => { + vi.mocked(filesListFilesets).mockRejectedValue(new Error('boom')); render(, { - wrapper: createWrapper(), + wrapper: createWrapper({ allowNewDataset: false }), }); - const select = screen.getByRole('combobox'); - expect(select).toBeInTheDocument(); - - // Open the dropdown to see the error state - await user.click(select); - expect(screen.getByText('Error loading datasets...')).toBeInTheDocument(); + await user.click(screen.getByRole('combobox')); + expect(await screen.findByText('No filesets found')).toBeInTheDocument(); }); it('updates context when dataset is selected', async () => { @@ -162,22 +159,8 @@ describe('DatasetSelect', () => { } ); - const select = screen.getByRole('combobox'); - await user.click(select); - - await waitFor(() => { - const options = screen.getAllByRole('option'); - // Filter to only enabled options (the dataset options, not loading/error) - const enabledOptions = options.filter( - (option) => - !option.hasAttribute('aria-disabled') || option.getAttribute('aria-disabled') === 'false' - ); - expect(enabledOptions.length).toBeGreaterThan(0); - }); - - // Find an existing dataset option (not "New Dataset", which is now first) - const datasetOption = screen.getByRole('option', { name: 'dataset1' }); - await user.click(datasetOption); + await user.click(screen.getByRole('combobox')); + await user.click(await screen.findByRole('option', { name: 'dataset1' })); await waitFor(() => { expect(contextState?.dataset).toBeDefined(); @@ -242,10 +225,11 @@ describe('DatasetSelect', () => { wrapper: createWrapper(), }); - const select = screen.getByRole('combobox'); - await user.click(select); + await user.click(screen.getByRole('combobox')); - expect(await screen.findByText('New Dataset')).toBeInTheDocument(); + // Queried by role, not text: a hidden native