diff --git a/web/packages/studio/src/components/AddColumnPalette/ColumnTypeCard.tsx b/web/packages/studio/src/components/AddColumnPalette/ColumnTypeCard.tsx index 3d9e371892..5336b7d3c1 100644 --- a/web/packages/studio/src/components/AddColumnPalette/ColumnTypeCard.tsx +++ b/web/packages/studio/src/components/AddColumnPalette/ColumnTypeCard.tsx @@ -12,19 +12,22 @@ import type { FC } from 'react'; interface ColumnTypeCardProps { option: ColumnTypeOption; onSelect: (selection: AddColumnSelection) => void; + disabledReason?: string; } /** * A single column-type option, rendered as a {@link SelectableCard} so it is reachable and * activatable by keyboard (Tab to focus, Enter/Space to add) with no drag interaction. */ -export const ColumnTypeCard: FC = ({ option, onSelect }) => { +export const ColumnTypeCard: FC = ({ option, onSelect, disabledReason }) => { const { icon: Icon, label, description, color, columnType, samplerType } = option; return ( onSelect({ columnType, samplerType })} leading={ diff --git a/web/packages/studio/src/components/AddColumnPalette/ColumnTypeGroupSection.tsx b/web/packages/studio/src/components/AddColumnPalette/ColumnTypeGroupSection.tsx index fba79786f9..67e805ff58 100644 --- a/web/packages/studio/src/components/AddColumnPalette/ColumnTypeGroupSection.tsx +++ b/web/packages/studio/src/components/AddColumnPalette/ColumnTypeGroupSection.tsx @@ -14,6 +14,8 @@ interface ColumnTypeGroupSectionProps { group: ColumnTypeGroup; options: ColumnTypeOption[]; onSelect: (selection: AddColumnSelection) => void; + /** Disabled reasons keyed by column type; a set entry disables that option's card. */ + disabledReasons?: Partial>; } /** A labeled group heading (with a count) above its option cards. */ @@ -21,6 +23,7 @@ export const ColumnTypeGroupSection: FC = ({ group, options, onSelect, + disabledReasons, }) => ( @@ -30,7 +33,12 @@ export const ColumnTypeGroupSection: FC = ({ {options.map((option) => ( - + ))} diff --git a/web/packages/studio/src/components/AddColumnPalette/index.tsx b/web/packages/studio/src/components/AddColumnPalette/index.tsx index 4914509089..fc2d6b40a3 100644 --- a/web/packages/studio/src/components/AddColumnPalette/index.tsx +++ b/web/packages/studio/src/components/AddColumnPalette/index.tsx @@ -18,6 +18,11 @@ const matchesQuery = (option: ColumnTypeOption, query: string): boolean => export interface AddColumnPaletteProps { /** Called with the chosen column type when an option is activated. */ onAddColumn?: (selection: AddColumnSelection) => void; + /** + * Disabled reasons keyed by column type. An option whose column type has an entry renders as a + * disabled card with the reason as its tooltip — e.g. `{ 'seed-dataset': 'Only one…' }`. + */ + disabledReasons?: Partial>; className?: string; } @@ -30,7 +35,11 @@ export interface AddColumnPaletteProps { * presentational: wire {@link AddColumnPaletteProps.onAddColumn} to append a column to * the recipe. */ -export const AddColumnPalette: FC = ({ onAddColumn, className }) => { +export const AddColumnPalette: FC = ({ + onAddColumn, + disabledReasons, + className, +}) => { const [search, setSearch] = useState(''); const handleSelect = (selection: AddColumnSelection) => onAddColumn?.(selection); @@ -76,6 +85,7 @@ export const AddColumnPalette: FC = ({ onAddColumn, class group={group} options={options} onSelect={handleSelect} + disabledReasons={disabledReasons} /> )) )} diff --git a/web/packages/studio/src/components/ColumnConfigPanel/SeedDatasetConfig.tsx b/web/packages/studio/src/components/ColumnConfigPanel/SeedDatasetConfig.tsx new file mode 100644 index 0000000000..cd529bf66f --- /dev/null +++ b/web/packages/studio/src/components/ColumnConfigPanel/SeedDatasetConfig.tsx @@ -0,0 +1,189 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { getPartsFromReference } from '@nemo/common/src/namedEntity'; +import { useFilesListFilesetFiles } from '@nemo/sdk/generated/platform/api'; +import { Flex, FormField, Select, Tag, Text } from '@nvidia/foundations-react-core'; +import { useDatasetFileContent } from '@studio/api/datasets/useDatasetFileContent'; +import { useWorkspaceFromPath } from '@studio/hooks/useWorkspaceFromPath'; +import { + SAMPLING_STRATEGY_OPTIONS, + SEED_AVAILABLE_COLUMNS_KEY, + SEED_FILE_PATH_KEY, + SEED_FILESET_REF_KEY, + SEED_SAMPLING_STRATEGY_KEY, +} from '@studio/routes/DataDesignerJobBuildRoute/columns'; +import { FilesetSearchableSelect } from '@studio/routes/DeploymentsListRoute/CreateDeploymentSidePanel/FilesetSearchableSelect'; +import { getContentColumns, getFileExtension } from '@studio/util/files'; +import { type FC, useEffect, useMemo } from 'react'; +import { useForm } from 'react-hook-form'; + +export interface SeedDatasetConfigProps { + /** The seed-dataset column's current field values. */ + values: Record; + /** Merges the given keys into the column's values (parent spreads onto the rest). */ + onPatch: (patch: Record) => void; +} + +interface SeedFilesetForm { + [SEED_FILESET_REF_KEY]: string; +} + +/** + * Config controls for a seed-dataset column, sourced from a platform fileset. + * + * The SDK's `FilesetFileSeedSource` takes a single composite `path` + * (`{workspace}/{fileset}#{file}`); rather than have the user hand-type that, this collects the + * fileset and the in-fileset file as separate picks (stored under {@link SEED_FILESET_REF_KEY} / + * {@link SEED_FILE_PATH_KEY}). `buildSeedConfig` assembles them into the composite path at submit. + * + * The fileset uses {@link FilesetSearchableSelect} for server-side `$like` search + paging. It is + * react-hook-form based, so a local form holds its value and pushes changes up via `onPatch`. + * The panel keys this component by column id, so each column mounts a form seeded from its values. + */ +export const SeedDatasetConfig: FC = ({ values, onPatch }) => { + const workspace = useWorkspaceFromPath(); + const filesetRef = values[SEED_FILESET_REF_KEY] ?? ''; + const filePath = values[SEED_FILE_PATH_KEY] ?? ''; + const samplingStrategy = values[SEED_SAMPLING_STRATEGY_KEY] ?? ''; + + const { control, watch } = useForm({ + defaultValues: { [SEED_FILESET_REF_KEY]: filesetRef }, + }); + + useEffect(() => { + const subscription = watch((formValues, { name }) => { + if (name !== SEED_FILESET_REF_KEY) return; + onPatch({ + [SEED_FILESET_REF_KEY]: formValues[SEED_FILESET_REF_KEY] ?? '', + [SEED_FILE_PATH_KEY]: '', + [SEED_AVAILABLE_COLUMNS_KEY]: '', + }); + }); + return () => subscription.unsubscribe(); + }, [watch, onPatch]); + + const { workspace: filesetWorkspace, name: filesetName } = getPartsFromReference(filesetRef); + const { data: filesResponse, isLoading: isLoadingFiles } = useFilesListFilesetFiles( + filesetWorkspace, + filesetName, + undefined, + { query: { enabled: Boolean(filesetRef) } } + ); + const fileItems = useMemo( + () => (filesResponse?.data ?? []).map((file) => ({ children: file.path, value: file.path })), + [filesResponse?.data] + ); + + const isParquet = filePath.endsWith('parquet'); + const { + data: fileContent, + isLoading: isLoadingSchema, + isError: isSchemaError, + } = useDatasetFileContent({ + workspace: filesetWorkspace, + name: filesetName, + path: filePath, + range: isParquet ? [0, 1] : undefined, + enabled: Boolean(filesetRef && filePath), + }); + const availableColumns = useMemo(() => { + if (!fileContent) return []; + const fileType = isParquet ? 'jsonl' : (getFileExtension(filePath) ?? undefined); + return getContentColumns(fileContent, fileType); + }, [fileContent, filePath, isParquet]); + + useEffect(() => { + const joined = availableColumns.join(','); + if ((values[SEED_AVAILABLE_COLUMNS_KEY] ?? '') !== joined) { + onPatch({ [SEED_AVAILABLE_COLUMNS_KEY]: joined }); + } + }, [availableColumns, values, onPatch]); + + const samplingItems = SAMPLING_STRATEGY_OPTIONS.map((option) => ({ + children: option.label, + value: option.value, + })); + + return ( + <> + + + + onPatch({ [SEED_SAMPLING_STRATEGY_KEY]: value ?? '' })} + placeholder="Ordered" + /> + + + ); +}; diff --git a/web/packages/studio/src/components/ColumnConfigPanel/index.tsx b/web/packages/studio/src/components/ColumnConfigPanel/index.tsx index 9d33fd84d0..e6f3013e3f 100644 --- a/web/packages/studio/src/components/ColumnConfigPanel/index.tsx +++ b/web/packages/studio/src/components/ColumnConfigPanel/index.tsx @@ -19,6 +19,7 @@ import { TextInput, } from '@nvidia/foundations-react-core'; import { ICON_COLOR_CLASS } from '@studio/components/AddColumnPalette/constants'; +import { SeedDatasetConfig } from '@studio/components/ColumnConfigPanel/SeedDatasetConfig'; import { CardIconBadge } from '@studio/components/common/SelectableCard'; import { type BuilderColumn, @@ -209,14 +210,22 @@ export const ColumnConfigPanel: FC = ({ /> - {fields.map((field) => ( - setValue(field.key, value)} + {option.columnType === 'seed-dataset' ? ( + onChange({ values: { ...values, ...patch } })} /> - ))} + ) : ( + fields.map((field) => ( + setValue(field.key, value)} + /> + )) + )} diff --git a/web/packages/studio/src/components/SafeSynthesizerFilesetPreview/util.test.ts b/web/packages/studio/src/components/SafeSynthesizerFilesetPreview/util.test.ts index 527d0831ed..a05d9175fd 100644 --- a/web/packages/studio/src/components/SafeSynthesizerFilesetPreview/util.test.ts +++ b/web/packages/studio/src/components/SafeSynthesizerFilesetPreview/util.test.ts @@ -2,7 +2,10 @@ // SPDX-License-Identifier: Apache-2.0 import { parseFilesetUrl } from '@nemo/common/src/components/DatasetFileSelect/utils'; -import { parseCSV, parseFileContent } from '@studio/components/SafeSynthesizerFilesetPreview/util'; +import { + parseCSVTable, + parseFileContent, +} from '@studio/components/SafeSynthesizerFilesetPreview/util'; vi.mock('papaparse', () => ({ default: { @@ -39,7 +42,7 @@ describe('SafeSynthesizerDatasetPreview utils', () => { mockPapaParse.mockImplementation(vi.fn().mockReturnValue(mockParsedData)); - const result = parseCSV(mockCsvContent); + const result = parseCSVTable(mockCsvContent); expect(mockPapaParse).toHaveBeenCalledWith(mockCsvContent, { header: true }); expect(result.columns).toEqual([ @@ -71,7 +74,7 @@ describe('SafeSynthesizerDatasetPreview utils', () => { mockPapaParse.mockImplementation(vi.fn().mockReturnValue(mockParsedData)); - const result = parseCSV('id,name\ncustom-id,John'); + const result = parseCSVTable('id,name\ncustom-id,John'); expect(result.rows[0].id).toBe('custom-id'); }); @@ -92,7 +95,7 @@ describe('SafeSynthesizerDatasetPreview utils', () => { mockPapaParse.mockImplementation(vi.fn().mockReturnValue(mockParsedData)); - const result = parseCSV('name\nJohn\nJane'); + const result = parseCSVTable('name\nJohn\nJane'); expect(result.rows[0].id).toBe('0'); expect(result.rows[1].id).toBe('1'); @@ -114,7 +117,7 @@ describe('SafeSynthesizerDatasetPreview utils', () => { mockPapaParse.mockImplementation(vi.fn().mockReturnValue(mockParsedData)); - const result = parseCSV('name,age,city\nJohn,,'); + const result = parseCSVTable('name,age,city\nJohn,,'); expect(result.rows[0].cells[1].children).toBe(''); expect(result.rows[0].cells[2].children).toBe(''); diff --git a/web/packages/studio/src/components/SafeSynthesizerFilesetPreview/util.ts b/web/packages/studio/src/components/SafeSynthesizerFilesetPreview/util.ts index e206b705f0..a033e14cfd 100644 --- a/web/packages/studio/src/components/SafeSynthesizerFilesetPreview/util.ts +++ b/web/packages/studio/src/components/SafeSynthesizerFilesetPreview/util.ts @@ -4,7 +4,12 @@ import Papa from 'papaparse'; import { ReactNode } from 'react'; -export const parseCSV = (response: string) => { +export interface ParsedCSVTable { + rows: { id: string; cells: { children: ReactNode }[] }[]; + columns: { children: string }[]; +} + +export const parseCSVTable = (response: string): ParsedCSVTable => { const csvData = Papa.parse(response, { header: true }); const rawRows = csvData.data as Record[]; const columnNames = csvData.meta.fields || []; @@ -28,10 +33,7 @@ export const parseCSV = (response: string) => { export interface ParsedFileContent { type: 'json' | 'csv' | 'error'; jsonData?: string; - tabularData?: { - rows: { id: string; cells: { children: ReactNode }[] }[]; - columns: { children: string }[]; - }; + tabularData?: ParsedCSVTable; error?: string; } @@ -43,7 +45,7 @@ export interface ParsedFileContent { */ export const parseFileContent = (filePath: string, content: string): ParsedFileContent => { if (filePath.endsWith('.csv')) { - const csvData = parseCSV(content); + const csvData = parseCSVTable(content); return { type: 'csv', tabularData: csvData }; } if (filePath.endsWith('.json') || filePath.endsWith('.jsonl')) { diff --git a/web/packages/studio/src/components/common/SelectableCard/index.tsx b/web/packages/studio/src/components/common/SelectableCard/index.tsx index b7c4677ba5..1dbeee5caa 100644 --- a/web/packages/studio/src/components/common/SelectableCard/index.tsx +++ b/web/packages/studio/src/components/common/SelectableCard/index.tsx @@ -39,6 +39,10 @@ export interface SelectableCardProps { tags?: string[]; /** Whether the card reads as selected (draws the strong border). */ selected?: boolean; + /** When true, the card is non-interactive and muted; `onActivate` never fires. */ + disabled?: boolean; + /** Tooltip explaining why the card is disabled (shown on hover when `disabled`). */ + disabledReason?: string; /** Activation handler; fired on click and on keyboard Enter/Space. */ onActivate?: () => void; className?: string; @@ -62,14 +66,22 @@ export const SelectableCard: FC = ({ description, tags, selected = false, + disabled = false, + disabledReason, onActivate, className, }) => (