diff --git a/web/packages/studio/src/components/DataDesignerJobActionsMenu/index.tsx b/web/packages/studio/src/components/DataDesignerJobActionsMenu/index.tsx new file mode 100644 index 0000000000..b5d23fee2a --- /dev/null +++ b/web/packages/studio/src/components/DataDesignerJobActionsMenu/index.tsx @@ -0,0 +1,126 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { CJobCancellableStatuses } from '@nemo/common/src/constants/query'; +import { + getDataDesignerListCreateJobsQueryKey, + useDataDesignerCancelCreateJob, +} from '@nemo/sdk/generated/data-designer/api'; +import type { CreateJob as DataDesignerJob } from '@nemo/sdk/generated/data-designer/schema'; +import { DeleteJobModal } from '@studio/components/dataViews/DataDesignerJobsDataView/DeleteJobModal'; +import { buildClonedJobRequest } from '@studio/components/NewDataDesignerJobForm/utils'; +import { + type QuickActionItem, + QuickActionsMenuRoot, +} from '@studio/components/QuickActionsMenu/QuickActionsMenuRoot'; +import { useWorkspaceFromPath } from '@studio/hooks/useWorkspaceFromPath'; +import { getDataDesignerJobDetailsRoute, getNewDataDesignerJobRoute } from '@studio/routes/utils'; +import { useQueryClient } from '@tanstack/react-query'; +import { type FC, useCallback, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; + +interface DataDesignerJobActionsMenuProps { + job: DataDesignerJob; + /** Include a "View details" entry. Used in the table row, omitted on the details page. */ + includeViewDetails?: boolean; + /** Called after the job is successfully deleted, e.g. to navigate away from the details page. */ + onDeleted?: () => void; + /** Surface a cancel error (or `undefined` to clear) so the caller can render it. */ + onCancelError?: (message: string | undefined) => void; +} + +/** + * Quick-actions menu for a single Data Designer job: View details (optional), Clone, Cancel + * (when cancellable), and Delete. Shared by the jobs table and the job details page so both + * expose the same actions. Owns its own delete modal; cancel errors are surfaced via callback. + */ +export const DataDesignerJobActionsMenu: FC = ({ + job, + includeViewDetails = false, + onDeleted, + onCancelError, +}) => { + const navigate = useNavigate(); + const workspace = useWorkspaceFromPath(); + const queryClient = useQueryClient(); + const [showDeleteModal, setShowDeleteModal] = useState(false); + + const cancelJobMutation = useDataDesignerCancelCreateJob({ + mutation: { + onSuccess: () => { + queryClient.resetQueries({ + queryKey: getDataDesignerListCreateJobsQueryKey(workspace), + }); + onCancelError?.(undefined); + }, + onError: (error) => { + onCancelError?.(error instanceof Error ? error.message : 'Failed to cancel job'); + }, + }, + }); + + const handleClone = useCallback(() => { + const cloneJobRequest = buildClonedJobRequest(job); + if (!cloneJobRequest) return; + navigate(getNewDataDesignerJobRoute(workspace), { + state: { cloneJobRequest }, + }); + }, [job, navigate, workspace]); + + const handleCancel = useCallback(async () => { + if (!job.workspace || !job.name) return; + try { + onCancelError?.(undefined); + await cancelJobMutation.mutateAsync({ workspace: job.workspace, name: job.name }); + } catch { + // Error is surfaced via the mutation's onError callback. + } + }, [job.workspace, job.name, cancelJobMutation, onCancelError]); + + const isCancellable = job.status != null && CJobCancellableStatuses.includes(job.status); + + const actions: QuickActionItem[] = [ + ...(includeViewDetails + ? [ + { + label: 'View details', + onSelect: () => { + if (job.name) { + navigate(getDataDesignerJobDetailsRoute(workspace, job.name)); + } + }, + }, + ] + : []), + { + label: 'Clone', + onSelect: handleClone, + }, + ...(isCancellable + ? [ + { + label: 'Cancel', + onSelect: handleCancel, + }, + ] + : []), + { + label: 'Delete', + onSelect: () => setShowDeleteModal(true), + danger: true, + }, + ]; + + return ( + <> + + {showDeleteModal && ( + setShowDeleteModal(false)} + onDeleted={onDeleted} + /> + )} + + ); +}; diff --git a/web/packages/studio/src/components/DatasetFileManagementSidePanel/index.test.tsx b/web/packages/studio/src/components/DatasetFileManagementSidePanel/index.test.tsx index bbabfab926..03dedfcd9a 100644 --- a/web/packages/studio/src/components/DatasetFileManagementSidePanel/index.test.tsx +++ b/web/packages/studio/src/components/DatasetFileManagementSidePanel/index.test.tsx @@ -113,4 +113,30 @@ describe('DatasetFileManagementSidePanel', () => { expect(await screen.findByText('No Files')).toBeInTheDocument(); }); + + it('shows subfolder breadcrumb segments when navigating into a folder', async () => { + renderComponent({ currentFolder: 'folder1/subfolder' }); + + // Breadcrumb segments for each part of the current folder path are rendered + await waitFor(() => { + expect(screen.getByRole('button', { name: 'folder1' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'subfolder' })).toBeInTheDocument(); + }); + + // The explorer (search/toolbar) is always rendered + expect(screen.getByTestId('dataset-details-search-input')).toBeInTheDocument(); + }); + + it('navigates to the fileset root when the fileset breadcrumb is clicked', async () => { + const user = userEvent.setup(); + const onFolderChange = vi.fn(); + renderComponent({ + currentFolder: 'folder1', + onFolderChange, + }); + + await user.click(await screen.findByRole('button', { name: 'test-dataset' })); + + expect(onFolderChange).toHaveBeenCalledWith(); + }); }); diff --git a/web/packages/studio/src/components/NewDataDesignerJobForm/JobBasics.tsx b/web/packages/studio/src/components/NewDataDesignerJobForm/JobBasics.tsx new file mode 100644 index 0000000000..fb89a812fb --- /dev/null +++ b/web/packages/studio/src/components/NewDataDesignerJobForm/JobBasics.tsx @@ -0,0 +1,69 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { ControlledTextArea } from '@nemo/common/src/components/form/ControlledTextArea'; +import { ControlledTextInput } from '@nemo/common/src/components/form/ControlledTextInput'; +import { Flex, Panel, Stack, Text } from '@nvidia/foundations-react-core'; +import { type Control, type FieldValues, type Path } from 'react-hook-form'; + +export interface JobBasicsProps { + control: Control; + nameName: Path; + rowsName: Path; + descriptionName: Path; + disabled?: boolean; +} + +/** + * "Job basics" card: name the dataset, set the full-run record count, and describe the job. + */ +export function JobBasics({ + control, + nameName, + rowsName, + descriptionName, + disabled = false, +}: JobBasicsProps) { + return ( + + + + Job basics + + Name your fileset and set the full-run size. + + + + + + + + + + + + + + + + ); +} diff --git a/web/packages/studio/src/components/NewDataDesignerJobForm/JobRequestGenerator.tsx b/web/packages/studio/src/components/NewDataDesignerJobForm/JobRequestGenerator.tsx index 32c7efe53d..4ebac32948 100644 --- a/web/packages/studio/src/components/NewDataDesignerJobForm/JobRequestGenerator.tsx +++ b/web/packages/studio/src/components/NewDataDesignerJobForm/JobRequestGenerator.tsx @@ -23,8 +23,8 @@ import { sanitizeJobRequestName, } from '@studio/components/NewDataDesignerJobForm/utils'; import type { ChatCompletion } from 'openai/resources/index.mjs'; -import { useCallback, useEffect, useRef, useState } from 'react'; -import { type Control, type FieldValues, type Path, useForm, useWatch } from 'react-hook-form'; +import { useCallback, useMemo, useState } from 'react'; +import { type Control, type FieldValues, type Path, useWatch } from 'react-hook-form'; const ERROR_NO_TOOL_CALL = 'Model did not return a tool call. Try again or choose a different model.'; @@ -55,13 +55,11 @@ function getJobRequestFromChatResponse( return { jobRequest: sanitizeJobRequestName(applied) }; } -interface JobRequestGeneratorFormFields { - jsonContent: string; -} - export interface JobRequestGeneratorProps { control: Control; descriptionName: Path; + /** Form field holding the (editable) job request JSON. Owned by the parent form. */ + jsonContentName: Path; descriptionRules?: object; descriptionFormFieldProps?: { slotInfo?: string }; /** Current workspace (used when modelRef is just a model name with no slash). */ @@ -69,45 +67,36 @@ export interface JobRequestGeneratorProps { modelRef: string; provider: string; servedModelName: string; - onJobRequestChange: (jobRequest: DataDesignerJobRequest | null) => void; + /** Write generated JSON back into the parent's jsonContent field. */ + setJsonContent: (value: string) => void; disabled?: boolean; } /** * Generates a Data Designer job request JSON via LLM tool use. Renders the description * textarea with Generate button below it, and the JSON editor next to it (side by side). - * Reports the current (parsed) job request to the parent via onJobRequestChange. + * The JSON lives in the parent form's `jsonContentName` field — read here via `useWatch` and + * written back through `setJsonContent` — so the parent form stays the single source of truth. */ export function JobRequestGenerator({ - control: parentControl, + control, descriptionName, + jsonContentName, descriptionRules, descriptionFormFieldProps, workspace, modelRef, provider, servedModelName, - onJobRequestChange, + setJsonContent, disabled = false, }: JobRequestGeneratorProps) { - const description = useWatch({ control: parentControl, name: descriptionName }) as string; + const description = useWatch({ control, name: descriptionName }) as string; + const jsonContent = (useWatch({ control, name: jsonContentName }) as string) ?? ''; const chatCompletion = useChatCompletion(); const [generationError, setGenerationError] = useState(null); - const [parseError, setParseError] = useState(null); - const { control, watch, setValue } = useForm({ - defaultValues: { jsonContent: '' }, - }); - const jsonContent = watch('jsonContent') ?? ''; - const onJobRequestChangeRef = useRef(onJobRequestChange); - onJobRequestChangeRef.current = onJobRequestChange; - - // Sync parsed job request to parent when JSON or model context changes - useEffect(() => { - const result = parseJsonContentToJobRequest(jsonContent); - setParseError(result.error ?? null); - onJobRequestChangeRef.current(result.jobRequest); - }, [jsonContent]); + const parseError = useMemo(() => parseJsonContentToJobRequest(jsonContent).error, [jsonContent]); const runGeneration = useCallback(async () => { setGenerationError(null); @@ -133,13 +122,11 @@ export function JobRequestGenerator({ return; } - setValue('jsonContent', JSON.stringify(result.jobRequest, null, 2)); - onJobRequestChangeRef.current(result.jobRequest); + setJsonContent(JSON.stringify(result.jobRequest, null, 2)); } catch (err) { setGenerationError(getErrorMessage(err, 'Generation failed.')); - onJobRequestChangeRef.current(null); } - }, [modelRef, provider, servedModelName, workspace, description, chatCompletion, setValue]); + }, [modelRef, provider, servedModelName, workspace, description, chatCompletion, setJsonContent]); const hasContent = !!jsonContent.trim(); const isGenerating = chatCompletion.isPending; @@ -155,7 +142,7 @@ export function JobRequestGenerator({ className="w-full" useControllerProps={{ name: descriptionName, - control: parentControl, + control, rules: descriptionRules, }} formFieldProps={descriptionFormFieldProps} @@ -182,7 +169,7 @@ export function JobRequestGenerator({ rows={16} className="w-full font-mono text-sm" useControllerProps={{ - name: 'jsonContent', + name: jsonContentName, control, }} formFieldProps={{ diff --git a/web/packages/studio/src/components/NewDataDesignerJobForm/index.test.tsx b/web/packages/studio/src/components/NewDataDesignerJobForm/index.test.tsx index 693192f608..b63b97fa02 100644 --- a/web/packages/studio/src/components/NewDataDesignerJobForm/index.test.tsx +++ b/web/packages/studio/src/components/NewDataDesignerJobForm/index.test.tsx @@ -24,11 +24,11 @@ vi.mock('@studio/components/NewDataDesignerJobForm/JobRequestGenerator', async ( const { Controller } = await import('react-hook-form'); return { JobRequestGenerator: ({ - onJobRequestChange, + setJsonContent, control, descriptionName, }: { - onJobRequestChange: (req: DataDesignerJobRequest | null) => void; + setJsonContent: (value: string) => void; control: Parameters[0]['control']; descriptionName: string; [key: string]: unknown; @@ -42,13 +42,15 @@ vi.mock('@studio/components/NewDataDesignerJobForm/JobRequestGenerator', async ( + + + navigate(getDataDesignerJobListRoute(workspace))} + onCancelError={setCancelError} + /> + + {cancelError && ( + + {cancelError} + + )} {job.description && ( {job.description}