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 @@ -73,7 +73,7 @@ export const AddColumnPalette: FC<AddColumnPaletteProps> = ({
attributes={{ Input: { 'aria-label': 'Search column types' } }}
/>

<Stack gap="density-lg" className="min-h-0 flex-1 overflow-y-auto">
<Stack gap="density-lg" className="min-h-0 flex-1 overflow-y-auto" paddingX="1">
{filteredGroups.length === 0 ? (
<Text kind="body/regular/sm" className="text-secondary">
No column types match “{search}”.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,11 @@ export const SeedDatasetConfig: FC<SeedDatasetConfigProps> = ({ columnIndex }) =
}
}, [availableColumns, availableColumnsPath, availableColumnsValue, setValue]);

const samplingItems = SAMPLING_STRATEGY_OPTIONS.map((option) => ({
children: option.label,
value: option.value,
}));

return (
<>
<FilesetSearchableSelect
Expand Down Expand Up @@ -159,10 +164,7 @@ export const SeedDatasetConfig: FC<SeedDatasetConfigProps> = ({ columnIndex }) =

<ControlledSelect
aria-label="Sampling strategy"
items={SAMPLING_STRATEGY_OPTIONS.map((option) => ({
children: option.label,
value: option.value,
}))}
items={samplingItems}
useControllerProps={{ name: samplingStrategyPath }}
formFieldProps={{
slotLabel: 'Sampling strategy',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,23 @@

import { ControlledTextInput } from '@nemo/common/src/components/form/ControlledTextInput';
import { LoadingButton } from '@nemo/common/src/components/LoadingButton';
import { Button, Flex, Tag, Text } from '@nvidia/foundations-react-core';
import { Button, Flex, SegmentedControl, Tag, Text } from '@nvidia/foundations-react-core';
import type { StartOptionTag } from '@studio/components/CreateFilesetStart/types';
import type { JobBuilderFormValues } from '@studio/routes/DataDesignerJobBuildRoute/useJobBuilder';
import { FileJson, Pencil } from 'lucide-react';
import { FileJson, ListTree, Pencil, SplinePointer } from 'lucide-react';
import { type FC, useState } from 'react';
import { useFormContext, useWatch } from 'react-hook-form';

/** Which renderer the center pane shows: the flat schema list or the DAG canvas. */
export type BuilderViewMode = 'list' | 'canvas';

export interface BuilderToolbarProps {
/** The template's badge (recipe use case), shown when building from a template. */
templateTag?: StartOptionTag;
columnCount: number;
/** Which renderer the center pane shows. */
viewMode: BuilderViewMode;
onViewModeChange: (mode: BuilderViewMode) => void;
onPreview: () => void;
isPreviewing: boolean;
onSubmit: () => void;
Expand All @@ -23,6 +29,8 @@ export interface BuilderToolbarProps {
export const BuilderToolbar: FC<BuilderToolbarProps> = ({
templateTag,
columnCount,
viewMode,
onViewModeChange,
onPreview,
isPreviewing,
onSubmit,
Expand All @@ -47,6 +55,7 @@ export const BuilderToolbar: FC<BuilderToolbarProps> = ({
<ControlledTextInput
autoFocus
useControllerProps={{ name: 'name' }}
value={name}
onBlur={() => setIsEditingName(false)}
formFieldProps={{ className: 'w-[220px]' }}
attributes={{ Input: { 'aria-label': 'Fileset name' } }}
Expand Down Expand Up @@ -79,6 +88,15 @@ export const BuilderToolbar: FC<BuilderToolbarProps> = ({
</Flex>

<Flex align="center" gap="density-md">
<SegmentedControl
size="tiny"
value={viewMode}
onValueChange={(value) => onViewModeChange(value as BuilderViewMode)}
items={[
{ value: 'list', children: <ListTree /> },
{ value: 'canvas', children: <SplinePointer /> },
]}
/>
Comment thread
steramae-nvidia marked this conversation as resolved.
<Flex align="center" gap="density-sm">
<Text kind="body/regular/sm" className="text-secondary whitespace-nowrap">
Rows
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import { Flex, Stack, Text } from '@nvidia/foundations-react-core';
import {
getColumnReferences,
topologicalSortColumns,
} from '@studio/routes/DataDesignerJobBuildRoute/columns';
import { SchemaRow } from '@studio/routes/DataDesignerJobBuildRoute/SchemaRow';
import type { JobBuilderFormValues } from '@studio/routes/DataDesignerJobBuildRoute/useJobBuilder';
import { type FC, useMemo } from 'react';
import { useFormContext, useWatch } from 'react-hook-form';

export interface SchemaListProps {
selectedId: string | null;
onSelect: (id: string | null) => void;
onDelete: (id: string) => void;
}

/**
* A flat, top-to-bottom list of the recipe's columns — a simpler alternative to the DAG
* canvas. Each row shows the column's type and summary, with its dependencies listed
* inline as relationship tags rather than drawn as edges. Selecting a row opens the same
* config pane the canvas uses, so the surrounding left/right panels are unchanged.
*/
export const SchemaList: FC<SchemaListProps> = ({ selectedId, onSelect, onDelete }) => {
const { control } = useFormContext<JobBuilderFormValues>();
const columnRecord = useWatch({ control, name: 'columns' });
const columns = useMemo(() => topologicalSortColumns(columnRecord), [columnRecord]);
const referencesById = useMemo(() => {
const knownNames = new Set(columns.map((column) => column.name).filter(Boolean));
return new Map(columns.map((column) => [column.id, getColumnReferences(column, knownNames)]));
}, [columns]);

return (
<Stack className="h-full overflow-y-auto px-density-2xl py-density-xl">
<Flex align="start" justify="between" gap="density-lg" className="mb-density-lg">
<Stack gap="density-xxs" className="min-w-0">
<Text kind="title/md" className="text-primary">
Schema
</Text>
<Text kind="body/regular/sm" className="text-secondary">
Reference columns with {'{{ column }}'}.
</Text>
</Stack>
</Flex>

{columns.length === 0 ? (
<Flex
align="center"
justify="center"
className="min-h-[160px] flex-1 rounded-md border border-dashed border-base"
>
<Text kind="body/regular/md" className="text-secondary">
No columns yet — add one from the left to get started.
</Text>
</Flex>
) : (
<Stack gap="density-md">
{columns.map((column) => (
<SchemaRow
key={column.id}
column={column}
references={referencesById.get(column.id) ?? []}
selected={column.id === selectedId}
onSelect={() => onSelect(column.id)}
onDelete={() => onDelete(column.id)}
/>
))}
</Stack>
)}
</Stack>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import { Badge, Button, Flex, Stack, Tag, Text } from '@nvidia/foundations-react-core';
import { CardIconBadge } from '@studio/components/common/SelectableCard';
import type { BuilderColumn } from '@studio/routes/DataDesignerJobBuildRoute/columns';
import { describeColumn } from '@studio/routes/DataDesignerJobBuildRoute/describeColumn';
import { Box, Trash2 } from 'lucide-react';
import type { FC } from 'react';

/** Accent color → NVIDIA Foundations text token, matching the DAG node icon styling. */
const ACCENT_ICON_CLASS: Record<string, string> = {
blue: 'text-[color:var(--text-color-accent-blue)]',
gray: 'text-[color:var(--text-color-accent-gray)]',
green: 'text-[color:var(--text-color-accent-green)]',
purple: 'text-[color:var(--text-color-accent-purple)]',
red: 'text-[color:var(--text-color-accent-red)]',
teal: 'text-[color:var(--text-color-accent-teal)]',
yellow: 'text-[color:var(--text-color-accent-yellow)]',
};

export interface SchemaRowProps {
column: BuilderColumn;
/** Names of columns this one references, shown inline as `{{ name }}` relationship tags. */
references: string[];
selected: boolean;
onSelect: () => void;
onDelete: () => void;
}

/**
* One column rendered as a row in the schema list: a generation-step number, an icon badge,
* the column name, a type badge, a one-line summary, and its relationship tags. Selecting the
* row opens the same config pane the DAG canvas uses; the trailing button deletes the column.
*/
export const SchemaRow: FC<SchemaRowProps> = ({
column,
references,
selected,
onSelect,
onDelete,
}) => {
const { option } = column;
const { typeLabel, detail } = describeColumn(column);
const Icon = option.icon ?? Box;

return (
<Flex
align="stretch"
className={`group overflow-hidden rounded-md border bg-surface-raised transition-colors has-[[data-select]:focus-visible]:ring-2 has-[[data-select]:focus-visible]:ring-(--color-brand,#76b900) ${
selected ? 'border-strong' : 'border-base hover:border-strong'
}`}
>
<button
type="button"
data-select=""
onClick={onSelect}
aria-pressed={selected}
className="flex min-w-0 flex-1 items-center gap-density-md px-density-lg py-density-md text-left focus-visible:outline-none cursor-pointer"
>
<CardIconBadge>
<Icon size={16} className={ACCENT_ICON_CLASS[option.color] ?? ''} aria-hidden />
</CardIconBadge>

<Stack gap="density-xxs" className="min-w-0 flex-1">
<Flex align="center" gap="density-sm" className="min-w-0">
<Text kind="body/semibold/sm" className="truncate text-primary">
{column.name || option.label}
</Text>
<Tag color={option.color} kind="outline" density="compact" readOnly>
{typeLabel}
</Tag>
</Flex>
{detail ? (
<Text kind="body/regular/xs" className="truncate text-secondary">
{detail}
</Text>
) : null}
{references.length > 0 ? (
<Flex wrap="wrap" gap="density-xs" className="mt-density-xxs">
{references.map((name) => (
<Badge key={name} color="blue" kind="solid" className="text-[10px]">
{`{{${name}}}`}
</Badge>
))}
</Flex>
) : null}
</Stack>
</button>

<Flex align="center" className="shrink-0 border-l border-base">
<Button
kind="tertiary"
color="danger"
size="tiny"
onClick={onDelete}
aria-label={`Delete ${column.name || option.label}`}
className="h-full rounded-none opacity-0 transition-opacity focus-visible:opacity-100 group-hover:opacity-100"
>
<Trash2 size={16} aria-hidden />
</Button>
</Flex>
</Flex>
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
defaultColumnName,
extractJinjaReferences,
findColumnOption,
topologicalSortColumns,
validateColumnName,
validateColumns,
} from '@studio/routes/DataDesignerJobBuildRoute/columns';
Expand Down Expand Up @@ -494,6 +495,67 @@ describe('preference-pairs template', () => {
});
});

describe('topologicalSortColumns', () => {
const ids = (columns: BuilderColumn[]) => columns.map((c) => c.id);

it('places each column after the columns it references', () => {
// Input order deliberately reversed: judge → response → instruction → topic.
const input = [
column('c3', 'quality', 'llm-judge', { prompt: 'Rate {{ response }}', model_alias: 'm' }),
column('c2', 'response', 'llm-text', {
prompt: 'Answer {{ instruction }}',
model_alias: 'm',
}),
column('c1', 'instruction', 'llm-text', {
prompt: 'Ask about {{ topic }}',
model_alias: 'm',
}),
column('c0', 'topic', 'sampler', { values: 'a, b' }, SamplerType.category),
];
expect(ids(topologicalSortColumns(input))).toEqual(['c0', 'c1', 'c2', 'c3']);
});

it('keeps independent columns and ties in their original relative order', () => {
const input = [
column('a', 'alpha', 'sampler', { values: 'x' }, SamplerType.category),
column('b', 'beta', 'sampler', { values: 'y' }, SamplerType.category),
column('c', 'gamma', 'sampler', { values: 'z' }, SamplerType.category),
];
expect(ids(topologicalSortColumns(input))).toEqual(['a', 'b', 'c']);
});

it('groups by dependency depth, not add order — a shallow dependent sorts above a deeper one', () => {
// entity_type → description → structured is a depth-2 chain; llm_text_1 depends only on
// entity_type (depth 1). Even though it was added last, it must sort above `structured`.
const input = [
column('c0', 'entity_type', 'sampler', { values: 'a, b' }, SamplerType.category),
column('c1', 'description', 'llm-text', {
prompt: 'Describe {{ entity_type }}',
model_alias: 'm',
}),
column('c2', 'structured', 'llm-structured', {
prompt: 'Structure {{ description }}',
model_alias: 'm',
output_format: '{}',
}),
column('c3', 'llm_text_1', 'llm-text', {
prompt: 'Another {{ entity_type }}',
model_alias: 'm',
}),
];
// depths: entity_type 0, description 1, llm_text_1 1, structured 2.
expect(ids(topologicalSortColumns(input))).toEqual(['c0', 'c1', 'c3', 'c2']);
});

it('emits cyclic references without looping', () => {
const input = [
column('c1', 'one', 'expression', { expr: '{{ two }}' }),
column('c2', 'two', 'expression', { expr: '{{ one }}' }),
];
expect(ids(topologicalSortColumns(input)).sort()).toEqual(['c1', 'c2']);
});
});

describe('palette catalog', () => {
it('has a field descriptor path for every catalog column type', () => {
// Sanity: findColumnOption resolves every option the palette can emit.
Expand Down
Loading
Loading