diff --git a/web/packages/studio/src/components/CreateFilesetStart/StartOptionCard.tsx b/web/packages/studio/src/components/CreateFilesetStart/StartOptionCard.tsx new file mode 100644 index 0000000000..344a8a08c9 --- /dev/null +++ b/web/packages/studio/src/components/CreateFilesetStart/StartOptionCard.tsx @@ -0,0 +1,66 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { Badge, Flex, Text } from '@nvidia/foundations-react-core'; +import type { StartOption } from '@studio/components/CreateFilesetStart/types'; +import type { FC } from 'react'; + +export interface StartOptionCardProps { + option: StartOption; + /** Whether this tile reads as selected (draws the brand-green border). */ + selected: boolean; + /** Fired on click / keyboard activation. Only invoked for enabled options. */ + onSelect: () => void; +} + +/** + * A single "How do you want to start?" tile: a leading icon badge above a title, + * description, and a metadata badge, in a bordered card rendered as a ` + ); +}; diff --git a/web/packages/studio/src/components/CreateFilesetStart/StartOptionDetail.tsx b/web/packages/studio/src/components/CreateFilesetStart/StartOptionDetail.tsx new file mode 100644 index 0000000000..abaa0e01b2 --- /dev/null +++ b/web/packages/studio/src/components/CreateFilesetStart/StartOptionDetail.tsx @@ -0,0 +1,87 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { Divider, Flex, Stack, Text } from '@nvidia/foundations-react-core'; +import type { StartOption } from '@studio/components/CreateFilesetStart/types'; +import { Layers, Sparkles, Wand2 } from 'lucide-react'; +import type { FC, ReactNode } from 'react'; + +interface DetailPoint { + icon: typeof Layers; + title: string; + description: string; +} + +const SCRATCH_POINTS: DetailPoint[] = [ + { + icon: Layers, + title: 'Add columns block by block', + description: + 'Drop in samplers, LLM generations, transforms and validators in any order on an empty canvas.', + }, + { + icon: Wand2, + title: 'Wire columns together', + description: 'Reference earlier columns in prompts and expressions to build up each record.', + }, + { + icon: Sparkles, + title: 'Preview and run', + description: 'Generate a sample at any time, tweak, and run the full job when it looks right.', + }, +]; + +/** Per-option content for the section that appears below the tiles once a tile is selected. */ +const DETAIL_CONTENT: Partial> = { + scratch: ( + + {SCRATCH_POINTS.map(({ icon: Icon, title, description }) => ( + + + + + + {title} + + + {description} + + + ))} + + ), +}; + +export interface StartOptionDetailProps { + option: StartOption; +} + +/** + * The lower half of the new-fileset view. Its content changes based on the selected + * tile. Today only "Build from scratch" is wired up; selecting it shows what the empty + * canvas offers. + */ +export const StartOptionDetail: FC = ({ option }) => { + const content = DETAIL_CONTENT[option.id]; + if (!content) { + return null; + } + + return ( + + + + {option.title} + + {content} + + ); +}; diff --git a/web/packages/studio/src/components/CreateFilesetStart/constants.ts b/web/packages/studio/src/components/CreateFilesetStart/constants.ts new file mode 100644 index 0000000000..9f20047dbe --- /dev/null +++ b/web/packages/studio/src/components/CreateFilesetStart/constants.ts @@ -0,0 +1,35 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { StartOption } from '@studio/components/CreateFilesetStart/types'; +import { LayoutGrid, Plus, Sparkles } from 'lucide-react'; + +/** + * The "How do you want to start?" tiles, in display order. Only "Build from scratch" + * is enabled today; the others are placeholders for upcoming entry points. + */ +export const START_OPTIONS: StartOption[] = [ + { + id: 'ai', + title: 'Describe with AI', + description: + 'Tell us what you need in plain language. AI drafts the columns and prompts — then you refine everything visually.', + icon: Sparkles, + enabled: false, + }, + { + id: 'template', + title: 'Start from a template', + description: 'Pick a ready-made recipe for SFT, classification, RAG eval, tool-use and more.', + icon: LayoutGrid, + tag: { label: '8 recipes', color: 'blue', kind: 'outline' }, + enabled: false, + }, + { + id: 'scratch', + title: 'Build from scratch', + description: 'Open an empty canvas and add columns block by block, your way.', + icon: Plus, + enabled: true, + }, +]; diff --git a/web/packages/studio/src/components/CreateFilesetStart/index.test.tsx b/web/packages/studio/src/components/CreateFilesetStart/index.test.tsx new file mode 100644 index 0000000000..1e9ba8b66e --- /dev/null +++ b/web/packages/studio/src/components/CreateFilesetStart/index.test.tsx @@ -0,0 +1,48 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { CreateFilesetStart } from '@studio/components/CreateFilesetStart'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +describe('CreateFilesetStart', () => { + it('renders all four start options', () => { + render(); + + expect(screen.getByText('Describe with AI')).toBeInTheDocument(); + expect(screen.getByText('Start from a template')).toBeInTheDocument(); + expect(screen.getByText('Build from scratch')).toBeInTheDocument(); + }); + + it('shows no Continue footer until a selectable option is chosen', () => { + render(); + + expect(screen.queryByRole('button', { name: /continue/i })).not.toBeInTheDocument(); + }); + + it('does not select disabled options (they are no-ops)', async () => { + const user = userEvent.setup(); + const onContinue = vi.fn(); + render(); + + await user.click(screen.getByText('Describe with AI')); + + expect(screen.queryByRole('button', { name: /continue/i })).not.toBeInTheDocument(); + expect(onContinue).not.toHaveBeenCalled(); + }); + + it('selecting Build from scratch reveals Continue and invokes onContinue with "scratch"', async () => { + const user = userEvent.setup(); + const onContinue = vi.fn(); + render(); + + await user.click(screen.getByText('Build from scratch')); + + const continueButton = screen.getByRole('button', { name: /continue/i }); + expect(continueButton).toBeInTheDocument(); + + await user.click(continueButton); + expect(onContinue).toHaveBeenCalledTimes(1); + expect(onContinue).toHaveBeenCalledWith('scratch'); + }); +}); diff --git a/web/packages/studio/src/components/CreateFilesetStart/index.tsx b/web/packages/studio/src/components/CreateFilesetStart/index.tsx new file mode 100644 index 0000000000..a379ff5721 --- /dev/null +++ b/web/packages/studio/src/components/CreateFilesetStart/index.tsx @@ -0,0 +1,79 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { + Block, + Button, + Flex, + Grid, + GridItem, + PageHeader, + Stack, + Text, +} from '@nvidia/foundations-react-core'; +import { START_OPTIONS } from '@studio/components/CreateFilesetStart/constants'; +import { StartOptionCard } from '@studio/components/CreateFilesetStart/StartOptionCard'; +import { StartOptionDetail } from '@studio/components/CreateFilesetStart/StartOptionDetail'; +import type { StartOptionId } from '@studio/components/CreateFilesetStart/types'; +import { ArrowRight } from 'lucide-react'; +import { useState, type FC } from 'react'; + +export interface CreateFilesetStartProps { + /** Fired when the user confirms a selected start option via the Continue footer. */ + onContinue: (optionId: StartOptionId) => void; +} + +/** + * The Data Designer "Create a fileset" landing view: a row of start-option tiles whose + * lower half changes with the selection, plus a bottom-anchored footer with a Continue + * action that appears once a (selectable) tile is chosen. + */ +export const CreateFilesetStart: FC = ({ onContinue }) => { + const [selectedId, setSelectedId] = useState(null); + const selectedOption = START_OPTIONS.find((option) => option.id === selectedId) ?? null; + + return ( + + + + + + + + How do you want to start? + + + {START_OPTIONS.map((option) => ( + + setSelectedId(option.id)} + /> + + ))} + + + + {selectedOption ? : null} + + + + {selectedOption ? ( + + + + ) : null} + + ); +}; diff --git a/web/packages/studio/src/components/CreateFilesetStart/types.ts b/web/packages/studio/src/components/CreateFilesetStart/types.ts new file mode 100644 index 0000000000..1690df4956 --- /dev/null +++ b/web/packages/studio/src/components/CreateFilesetStart/types.ts @@ -0,0 +1,32 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { BadgeProps } from '@nvidia/foundations-react-core'; +import type { LucideIcon } from 'lucide-react'; + +/** The four ways to start a Data Designer fileset shown as tiles on the new-fileset view. */ +export type StartOptionId = 'ai' | 'template' | 'clone' | 'scratch'; + +export interface StartOptionTag { + label: string; + color: NonNullable; + kind: NonNullable; +} + +export interface StartOption { + id: StartOptionId; + /** Tile title. */ + title: string; + /** One-line tile description. */ + description: string; + /** Leading Lucide icon. */ + icon: LucideIcon; + /** Small badge rendered at the bottom of the tile. */ + tag?: StartOptionTag; + /** + * Whether this option is wired up. Disabled options still render (so the full set + * of future entry points is visible) but are no-ops — they cannot be selected and + * never reveal a detail panel or the Continue footer. + */ + enabled: boolean; +} diff --git a/web/packages/studio/src/constants/routes.ts b/web/packages/studio/src/constants/routes.ts index 09f11f4e7d..8f0ab7d645 100644 --- a/web/packages/studio/src/constants/routes.ts +++ b/web/packages/studio/src/constants/routes.ts @@ -103,6 +103,9 @@ export const ROUTES = { dataDesignerJobList: `/workspaces/:${P.workspace}/data-designer`, dataDesignerJobDetails: `/workspaces/:${P.workspace}/data-designer/:${P.dataDesignerJobName}`, dataDesignerJobNew: `/workspaces/:${P.workspace}/data-designer/new`, + dataDesignerJobBuild: `/workspaces/:${P.workspace}/data-designer/new/build`, + /** Legacy job-creation form, not linked from any UI — reachable only by typing the URL. */ + dataDesignerJobNewLegacy: `/workspaces/:${P.workspace}/data-designer/new/legacy`, secrets: `/workspaces/:${P.workspace}/secrets`, guardrails: `/workspaces/:${P.workspace}/guardrails`, guardrailDetail: `/workspaces/:${P.workspace}/guardrails/:${P.guardrailConfigName}`, diff --git a/web/packages/studio/src/routes/DataDesignerJobBuildRoute/index.tsx b/web/packages/studio/src/routes/DataDesignerJobBuildRoute/index.tsx new file mode 100644 index 0000000000..0f93ffd39f --- /dev/null +++ b/web/packages/studio/src/routes/DataDesignerJobBuildRoute/index.tsx @@ -0,0 +1,45 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { Flex, PageHeader, Stack, Text } from '@nvidia/foundations-react-core'; +import { AccessibleTitle } from '@studio/components/AccessibleTitle'; +import { useWorkspaceFromPath } from '@studio/hooks/useWorkspaceFromPath'; +import { useBreadcrumbs } from '@studio/providers/breadcrumbs/useBreadcrumbs'; +import { getDataDesignerJobListRoute, getNewDataDesignerJobRoute } from '@studio/routes/utils'; +import type { FC } from 'react'; + +/** + * Placeholder for the "Build from scratch" empty-canvas column builder. Reached from the + * Create-a-fileset view's Continue action; the canvas itself is not wired up yet. + */ +export const DataDesignerJobBuildRoute: FC = () => { + const workspace = useWorkspaceFromPath(); + + useBreadcrumbs({ + items: [ + { href: getDataDesignerJobListRoute(workspace), slotLabel: 'Data Designer' }, + { href: getNewDataDesignerJobRoute(workspace), slotLabel: 'New fileset' }, + { slotLabel: 'Build from scratch' }, + ], + }); + + return ( + + + + + + Empty canvas — the column builder is coming soon. + + + + + ); +}; diff --git a/web/packages/studio/src/routes/LegacyNewDataDesignerJobRoute/index.tsx b/web/packages/studio/src/routes/LegacyNewDataDesignerJobRoute/index.tsx new file mode 100644 index 0000000000..fa993f6d3b --- /dev/null +++ b/web/packages/studio/src/routes/LegacyNewDataDesignerJobRoute/index.tsx @@ -0,0 +1,37 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { PageHeader, Stack } from '@nvidia/foundations-react-core'; +import { AccessibleTitle } from '@studio/components/AccessibleTitle'; +import { NewDataDesignerJobForm } from '@studio/components/NewDataDesignerJobForm'; +import { useWorkspaceFromPath } from '@studio/hooks/useWorkspaceFromPath'; +import { useBreadcrumbs } from '@studio/providers/breadcrumbs/useBreadcrumbs'; +import { getDataDesignerJobListRoute } from '@studio/routes/utils'; +import type { FC } from 'react'; + +/** + * Legacy job-creation form, superseded by {@link CreateFilesetStart} and the DAG canvas + * builder. Kept for now but not linked from any UI — reachable only by typing the URL. + */ +export const LegacyNewDataDesignerJobRoute: FC = () => { + const workspace = useWorkspaceFromPath(); + + useBreadcrumbs({ + items: [ + { href: getDataDesignerJobListRoute(workspace), slotLabel: 'Data Designer' }, + { slotLabel: 'New Job' }, + ], + }); + + return ( + + + + + + + ); +}; diff --git a/web/packages/studio/src/routes/NewDataDesignerJobRoute/index.tsx b/web/packages/studio/src/routes/NewDataDesignerJobRoute/index.tsx index 0d8cd01b4b..18763c5865 100644 --- a/web/packages/studio/src/routes/NewDataDesignerJobRoute/index.tsx +++ b/web/packages/studio/src/routes/NewDataDesignerJobRoute/index.tsx @@ -1,33 +1,35 @@ // SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { PageHeader, Stack } from '@nvidia/foundations-react-core'; import { AccessibleTitle } from '@studio/components/AccessibleTitle'; -import { NewDataDesignerJobForm } from '@studio/components/NewDataDesignerJobForm'; +import { CreateFilesetStart } from '@studio/components/CreateFilesetStart'; +import type { StartOptionId } from '@studio/components/CreateFilesetStart/types'; import { useWorkspaceFromPath } from '@studio/hooks/useWorkspaceFromPath'; import { useBreadcrumbs } from '@studio/providers/breadcrumbs/useBreadcrumbs'; -import { getDataDesignerJobListRoute } from '@studio/routes/utils'; +import { getDataDesignerJobBuildRoute, getDataDesignerJobListRoute } from '@studio/routes/utils'; import type { FC } from 'react'; +import { useNavigate } from 'react-router-dom'; export const NewDataDesignerJobRoute: FC = () => { const workspace = useWorkspaceFromPath(); + const navigate = useNavigate(); useBreadcrumbs({ items: [ { href: getDataDesignerJobListRoute(workspace), slotLabel: 'Data Designer' }, - { slotLabel: 'New Job' }, + { slotLabel: 'New fileset' }, ], }); + const handleContinue = (optionId: StartOptionId) => { + if (optionId === 'scratch') { + navigate(getDataDesignerJobBuildRoute(workspace)); + } + }; + return ( - - - - - + + ); }; diff --git a/web/packages/studio/src/routes/groups/dataDesignerRoutes.tsx b/web/packages/studio/src/routes/groups/dataDesignerRoutes.tsx index ccfab8aecf..4000146558 100644 --- a/web/packages/studio/src/routes/groups/dataDesignerRoutes.tsx +++ b/web/packages/studio/src/routes/groups/dataDesignerRoutes.tsx @@ -29,6 +29,20 @@ const NewDataDesignerJobRoute = default: m.NewDataDesignerJobRoute, })) ); +const DataDesignerJobBuildRoute = + DATA_DESIGNER_ENABLED && + lazy(() => + import('@studio/routes/DataDesignerJobBuildRoute').then((m) => ({ + default: m.DataDesignerJobBuildRoute, + })) + ); +const LegacyNewDataDesignerJobRoute = + DATA_DESIGNER_ENABLED && + lazy(() => + import('@studio/routes/LegacyNewDataDesignerJobRoute').then((m) => ({ + default: m.LegacyNewDataDesignerJobRoute, + })) + ); export const dataDesignerRoutes: RouteObject[] = gateDataDesignerRoutes([ { @@ -46,4 +60,14 @@ export const dataDesignerRoutes: RouteObject[] = gateDataDesignerRoutes([ element: NewDataDesignerJobRoute ? : null, errorElement: , }, + { + path: ROUTES.workspace.dataDesignerJobBuild, + element: DataDesignerJobBuildRoute ? : null, + errorElement: , + }, + { + path: ROUTES.workspace.dataDesignerJobNewLegacy, + element: LegacyNewDataDesignerJobRoute ? : null, + errorElement: , + }, ]); diff --git a/web/packages/studio/src/routes/utils.ts b/web/packages/studio/src/routes/utils.ts index b6caa70c3e..c2877a3c60 100644 --- a/web/packages/studio/src/routes/utils.ts +++ b/web/packages/studio/src/routes/utils.ts @@ -480,6 +480,15 @@ export const getNewDataDesignerJobRoute = (workspace: string) => { return generatePath(ROUTES.workspace.dataDesignerJobNew, { workspace }); }; +export const getDataDesignerJobBuildRoute = (workspace: string) => { + return generatePath(ROUTES.workspace.dataDesignerJobBuild, { workspace }); +}; + +/** Not linked from any UI — reachable only by typing the URL. */ +export const getLegacyNewDataDesignerJobRoute = (workspace: string) => { + return generatePath(ROUTES.workspace.dataDesignerJobNewLegacy, { workspace }); +}; + export const getModelChatRoute = (model: NamedEntityRef) => { const { modelNamespace, modelName } = getModelRouteParamsFromEntityRef(model); return generatePath(ROUTES.models.modelChat, { modelNamespace, modelName });