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 @@ -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<ColumnTypeCardProps> = ({ option, onSelect }) => {
export const ColumnTypeCard: FC<ColumnTypeCardProps> = ({ option, onSelect, disabledReason }) => {
const { icon: Icon, label, description, color, columnType, samplerType } = option;
return (
<SelectableCard
className="w-full"
title={label}
subtitle={description}
disabled={Boolean(disabledReason)}
disabledReason={disabledReason}
onActivate={() => onSelect({ columnType, samplerType })}
leading={
<CardIconBadge>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,16 @@ 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<Record<string, string>>;
}

/** A labeled group heading (with a count) above its option cards. */
export const ColumnTypeGroupSection: FC<ColumnTypeGroupSectionProps> = ({
group,
options,
onSelect,
disabledReasons,
}) => (
<Stack gap="1" className="w-full">
<Flex align="center" gap="density-xs">
Expand All @@ -30,7 +33,12 @@ export const ColumnTypeGroupSection: FC<ColumnTypeGroupSectionProps> = ({
</Flex>
<Stack gap="1.5" className="w-full">
{options.map((option) => (
<ColumnTypeCard key={option.id} option={option} onSelect={onSelect} />
<ColumnTypeCard
key={option.id}
option={option}
onSelect={onSelect}
disabledReason={option.columnType ? disabledReasons?.[option.columnType] : undefined}
/>
))}
</Stack>
</Stack>
Expand Down
12 changes: 11 additions & 1 deletion web/packages/studio/src/components/AddColumnPalette/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<Record<string, string>>;
className?: string;
}

Expand All @@ -30,7 +35,11 @@ export interface AddColumnPaletteProps {
* presentational: wire {@link AddColumnPaletteProps.onAddColumn} to append a column to
* the recipe.
*/
export const AddColumnPalette: FC<AddColumnPaletteProps> = ({ onAddColumn, className }) => {
export const AddColumnPalette: FC<AddColumnPaletteProps> = ({
onAddColumn,
disabledReasons,
className,
}) => {
const [search, setSearch] = useState('');

const handleSelect = (selection: AddColumnSelection) => onAddColumn?.(selection);
Expand Down Expand Up @@ -76,6 +85,7 @@ export const AddColumnPalette: FC<AddColumnPaletteProps> = ({ onAddColumn, class
group={group}
options={options}
onSelect={handleSelect}
disabledReasons={disabledReasons}
/>
))
)}
Expand Down
Original file line number Diff line number Diff line change
@@ -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<string, string>;
/** Merges the given keys into the column's values (parent spreads onto the rest). */
onPatch: (patch: Record<string, string>) => 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<SeedDatasetConfigProps> = ({ 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<SeedFilesetForm>({
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 (
<>
<FilesetSearchableSelect
workspace={workspace}
useControllerProps={{ control, name: SEED_FILESET_REF_KEY }}
formFieldProps={{
slotLabel: 'Fileset',
slotInfo: 'The platform fileset to seed rows from.',
}}
triggerPlaceholder="Select a fileset"
/>

<FormField
slotLabel="File"
required
slotInfo="The file within the fileset to read rows from."
>
<Select
aria-label="Seed file"
disabled={!filesetRef}
items={fileItems}
value={filePath || undefined}
onValueChange={(value) =>
onPatch({
[SEED_FILE_PATH_KEY]: value ?? '',
[SEED_AVAILABLE_COLUMNS_KEY]: '',
})
}
placeholder={
!filesetRef
? 'Select a fileset first'
: isLoadingFiles
? 'Loading files…'
: 'Select a file'
}
/>
</FormField>

{filePath && (
<FormField
slotLabel="Available columns"
slotInfo="Columns provided by the seed file. Reference them from other columns with {{ name }}."
>
{isLoadingSchema ? (
<Text kind="body/regular/sm" className="text-secondary">
Reading columns…
</Text>
) : isSchemaError ? (
<Text kind="body/regular/sm" className="text-feedback-danger">
Couldn't read columns from this file.
</Text>
) : availableColumns.length === 0 ? (
<Text kind="body/regular/sm" className="text-secondary">
No columns found in this file.
</Text>
) : (
<Flex gap="density-xs" className="flex-wrap">
{availableColumns.map((name) => (
<Tag key={name} kind="outline" color="gray" readOnly>
{name}
</Tag>
))}
</Flex>
)}
</FormField>
)}

<FormField
slotLabel="Sampling strategy"
slotInfo="How rows are read from the seed dataset. Defaults to ordered."
>
<Select
aria-label="Sampling strategy"
items={samplingItems}
value={samplingStrategy || undefined}
onValueChange={(value) => onPatch({ [SEED_SAMPLING_STRATEGY_KEY]: value ?? '' })}
placeholder="Ordered"
/>
</FormField>
</>
);
};
23 changes: 16 additions & 7 deletions web/packages/studio/src/components/ColumnConfigPanel/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -209,14 +210,22 @@ export const ColumnConfigPanel: FC<ColumnConfigPanelProps> = ({
/>
</FormField>

{fields.map((field) => (
<FieldControl
key={field.key}
field={field}
value={values[field.key] ?? ''}
onChange={(value) => setValue(field.key, value)}
{option.columnType === 'seed-dataset' ? (
<SeedDatasetConfig
key={column.id}
values={values}
onPatch={(patch) => onChange({ values: { ...values, ...patch } })}
/>
))}
) : (
fields.map((field) => (
<FieldControl
key={field.key}
field={field}
value={values[field.key] ?? ''}
onChange={(value) => setValue(field.key, value)}
/>
))
)}
</Stack>

<Flex align="center" justify="start" className="shrink-0 border-t border-base p-density-lg">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down Expand Up @@ -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([
Expand Down Expand Up @@ -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');
});
Expand All @@ -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');
Expand All @@ -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('');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>[];
const columnNames = csvData.meta.fields || [];
Expand All @@ -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;
}

Expand All @@ -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')) {
Expand Down
Loading