diff --git a/web/packages/common/src/components/form/MappingFields/MappingRow.tsx b/web/packages/common/src/components/form/MappingFields/MappingRow.tsx index f06ac81104..18df542526 100644 --- a/web/packages/common/src/components/form/MappingFields/MappingRow.tsx +++ b/web/packages/common/src/components/form/MappingFields/MappingRow.tsx @@ -16,10 +16,10 @@ import type { KeyValueComboboxPassthrough, KeyValueTextInputPassthrough, } from '@nemo/common/src/components/form/MappingFields/types'; -import { Button, Flex } from '@nvidia/foundations-react-core'; +import { Button, Grid, Text } from '@nvidia/foundations-react-core'; import cn from 'classnames'; import { Trash } from 'lucide-react'; -import { memo } from 'react'; +import { memo, ReactNode } from 'react'; import { Control, FieldValues } from 'react-hook-form'; interface Props { @@ -34,6 +34,11 @@ interface Props { valueOpts: string[]; keyColumnLabel: string; valueColumnLabel: string; + /** Popover content for the column header info icons; only the labelled first row shows them. */ + keyColumnInfo?: ReactNode; + valueColumnInfo?: ReactNode; + /** Help text for this row's key, rendered beneath the inputs. */ + description?: string; keyCombobox: Partial; valueCombobox: Partial; keyTextInput: Partial; @@ -51,6 +56,9 @@ const MappingRowInner = ({ valueOpts, keyColumnLabel, valueColumnLabel, + keyColumnInfo, + valueColumnInfo, + description, keyCombobox, valueCombobox, keyTextInput, @@ -87,8 +95,11 @@ const MappingRowInner = ({ ...valueTextRest } = valueTextInput; + /** Only the first row carries the column labels, and with them the info popovers. */ + const isHeaderRow = index === 0; + return ( - + {keyOpts.length > 0 ? ( ({ className={cn('font-normal', keyComboboxClassName)} attributes={keyComboboxAttributes} formFieldProps={{ - className: 'min-w-0 flex-1 font-bold', + className: 'min-w-0 font-bold', + slotInfo: isHeaderRow ? keyColumnInfo : undefined, ...keyComboboxFormFieldProps, }} useControllerProps={{ control, name: `${name}.${index}.key`, disabled: isDisabled }} items={keyOpts} - label={index === 0 ? keyColumnLabel : ''} + label={isHeaderRow ? keyColumnLabel : ''} /> ) : ( ({ className={keyTextClassName} attributes={keyTextAttributes} formFieldProps={{ - className: 'min-w-0 flex-1', + className: 'min-w-0', + slotInfo: isHeaderRow ? keyColumnInfo : undefined, ...keyTextFormFieldProps, }} useControllerProps={{ control, name: `${name}.${index}.key`, disabled: isDisabled }} - label={index === 0 ? keyColumnLabel : ''} + label={isHeaderRow ? keyColumnLabel : ''} /> )} {valueOpts.length > 0 ? ( @@ -131,12 +144,13 @@ const MappingRowInner = ({ className={cn('font-normal', valueComboboxClassName)} attributes={valueComboboxAttributes} formFieldProps={{ - className: 'min-w-0 flex-1 font-bold', + className: 'min-w-0 font-bold', + slotInfo: isHeaderRow ? valueColumnInfo : undefined, ...valueComboboxFormFieldProps, }} useControllerProps={{ control, name: `${name}.${index}.value`, disabled: isDisabled }} items={valueOpts} - label={index === 0 ? valueColumnLabel : ''} + label={isHeaderRow ? valueColumnLabel : ''} /> ) : ( ({ className={valueTextClassName} attributes={valueTextAttributes} formFieldProps={{ - className: 'min-w-0 flex-1', + className: 'min-w-0', + slotInfo: isHeaderRow ? valueColumnInfo : undefined, ...valueTextFormFieldProps, }} useControllerProps={{ control, name: `${name}.${index}.value`, disabled: isDisabled }} - label={index === 0 ? valueColumnLabel : ''} + label={isHeaderRow ? valueColumnLabel : ''} /> )} - + {description ? ( + {description} + ) : null} + ); }; diff --git a/web/packages/common/src/components/form/MappingFields/index.tsx b/web/packages/common/src/components/form/MappingFields/index.tsx index 9ee750bf61..08e01bf6b8 100644 --- a/web/packages/common/src/components/form/MappingFields/index.tsx +++ b/web/packages/common/src/components/form/MappingFields/index.tsx @@ -17,7 +17,7 @@ import type { } from '@nemo/common/src/components/form/MappingFields/types'; import { isDefined } from '@nemo/common/src/utils/isDefined'; import { Banner, Stack } from '@nvidia/foundations-react-core'; -import { useEffect, useMemo } from 'react'; +import { ReactNode, useEffect, useMemo } from 'react'; import { Control, FieldArrayPath, @@ -98,6 +98,14 @@ export interface MappingFieldsProps< valueSuggestions?: string[]; keyColumnLabel?: string; valueColumnLabel?: string; + /** Popover content for the info icon beside each column's header label. */ + keyColumnInfo?: ReactNode; + valueColumnInfo?: ReactNode; + /** + * Help text keyed by mapping key, rendered under whichever row currently holds that key. + * Use it to document the fields of a fixed target schema. + */ + keyDescriptions?: Record; /** Forward props to the key/value field controls (combobox vs text input is chosen automatically). */ attributes?: { keyCombobox?: Partial; @@ -120,6 +128,9 @@ export const MappingFields = < valueSuggestions: valueSuggestionsProp, keyColumnLabel = 'Key', valueColumnLabel = 'Value', + keyColumnInfo, + valueColumnInfo, + keyDescriptions, attributes, }: MappingFieldsProps) => { const nameStr = name as string; @@ -217,6 +228,9 @@ export const MappingFields = < valueOpts={valueOpts} keyColumnLabel={keyColumnLabel} valueColumnLabel={valueColumnLabel} + keyColumnInfo={keyColumnInfo} + valueColumnInfo={valueColumnInfo} + description={keyDescriptions?.[watchedRows?.[index]?.key ?? '']} keyCombobox={keyComboboxProps} valueCombobox={valueComboboxProps} keyTextInput={keyTextInputProps} diff --git a/web/packages/studio/src/api/datasets/useDatasetFileTransform.ts b/web/packages/studio/src/api/datasets/useDatasetFileTransform.ts index bd27c30e93..3de498cca7 100644 --- a/web/packages/studio/src/api/datasets/useDatasetFileTransform.ts +++ b/web/packages/studio/src/api/datasets/useDatasetFileTransform.ts @@ -20,6 +20,8 @@ type MutationProps = { workspace: string; datasetName: string; filepath: TransformFileFormFields['filepath']; + /** Destination for the transformed rows; the source file is left untouched. */ + outputFilepath: TransformFileFormFields['outputFilepath']; mappings: TransformFileFormFields['mappings']; fileContent: string; model?: ModelEntity; @@ -36,7 +38,15 @@ export const useDatasetFileTransform = ({ onError, onSuccess }: Props) => { const progPostInferValue = 90; const mutationFn = useCallback( - async ({ fileContent, filepath, model, mappings, workspace, datasetName }: MutationProps) => { + async ({ + fileContent, + filepath, + outputFilepath, + model, + mappings, + workspace, + datasetName, + }: MutationProps) => { setProgressValue(10); // Parse JSON objects @@ -53,7 +63,7 @@ export const useDatasetFileTransform = ({ onError, onSuccess }: Props) => { // Re-map each row to the described mappings if (mappings) { rows = rows - .map((row) => { + .map((row, rowIndex) => { const newRow: Record = {}; let skipInvalidRow = false; mappings.forEach(({ key, value }) => { @@ -81,7 +91,7 @@ export const useDatasetFileTransform = ({ onError, onSuccess }: Props) => { // Handle the last part of the key const lastPart = keyParts[keyParts.length - 1]; - const compiledValue = template(processedRow); + const compiledValue = template(processedRow, { data: { row: rowIndex + 1 } }); // Try to parse as JSON if it looks like an array or object try { @@ -155,7 +165,7 @@ export const useDatasetFileTransform = ({ onError, onSuccess }: Props) => { const fileContent2 = rows.map((row) => JSON.stringify(row)).join('\n'); const blob = new Blob([fileContent2], { type: 'application/json' }); - return filesUploadFile(workspace, datasetName, filepath, blob); + return filesUploadFile(workspace, datasetName, outputFilepath, blob); }, [createChatCompletions, toast] ); @@ -171,7 +181,7 @@ export const useDatasetFileTransform = ({ onError, onSuccess }: Props) => { variables.workspace, variables.datasetName, ['files', 'content'], - variables.filepath + variables.outputFilepath ); onSuccess?.(data, variables, onMutateResult, context); }, diff --git a/web/packages/studio/src/components/FilesTable/FileQuickActions/index.test.tsx b/web/packages/studio/src/components/FilesTable/FileQuickActions/index.test.tsx index 1af958e5e4..1360659ab5 100644 --- a/web/packages/studio/src/components/FilesTable/FileQuickActions/index.test.tsx +++ b/web/packages/studio/src/components/FilesTable/FileQuickActions/index.test.tsx @@ -73,6 +73,13 @@ describe('FileQuickActions', () => { expect(screen.getByText('Delete')).toBeInTheDocument(); }); + it('renders nothing when the file has no path', () => { + const pathlessFile = { size: 0, type: 'file', oid: 'oid-none' } as unknown as FileSystemNode; + renderComponent({ file: pathlessFile, isReadWriteDataset: true }); + + expect(screen.queryByTestId(rootTestId)).not.toBeInTheDocument(); + }); + it('uses currentFolder prop instead of query params', async () => { const onViewFile = vi.fn(); renderComponent({ diff --git a/web/packages/studio/src/components/FilesTable/FileQuickActions/index.tsx b/web/packages/studio/src/components/FilesTable/FileQuickActions/index.tsx index 1f4b015205..bbc7a289fc 100644 --- a/web/packages/studio/src/components/FilesTable/FileQuickActions/index.tsx +++ b/web/packages/studio/src/components/FilesTable/FileQuickActions/index.tsx @@ -18,6 +18,7 @@ import { } from '@studio/components/QuickActionsMenu/QuickActionsMenuRoot'; import { useSelectedDatasetId } from '@studio/hooks/useSelectedDatasetId'; import { resolveDatasetFilePath } from '@studio/util/files'; +import { logger } from '@studio/util/logger'; import { FC, useState } from 'react'; type ModalType = 'createSplit' | 'rename' | 'delete' | 'info' | 'transform' | 'addToFolder'; @@ -33,6 +34,10 @@ interface Props { onViewFile?: (filePath: string) => void; /** When true, show full menu (Move, Duplicate, Create Split, Transform, Rename). Read/write storage: local, s3. Read-only: ngc, huggingface. */ isReadWriteDataset?: boolean; + /** Callback when the file is successfully deleted */ + onDeleteSuccess?: () => void; + /** Callback when the file is successfully renamed */ + onRenameSuccess?: (newPath: string) => void; } export const FileQuickActions: FC = ({ datasetId, @@ -40,6 +45,8 @@ export const FileQuickActions: FC = ({ currentFolder, onViewFile, isReadWriteDataset = false, + onDeleteSuccess, + onRenameSuccess, }) => { const [modalFile, setModalFile] = useState(); const [openModal, setOpenModal] = useState(); @@ -86,6 +93,9 @@ export const FileQuickActions: FC = ({ try { const response = await mutateAsync({ workspace, datasetName: name, path }); + if (response) { + onDeleteSuccess?.(); + } return Boolean(response); } catch { return false; @@ -101,6 +111,11 @@ export const FileQuickActions: FC = ({ setOpenModal(modal); }; + if (!path) { + logger.warn('FileQuickActions received a file without a path', file); + return null; + } + const handleCopyPath = async () => { try { await navigator.clipboard.writeText(path); @@ -148,6 +163,7 @@ export const FileQuickActions: FC = ({ onClose={() => setOpenModal(undefined)} filepath={path} datasetId={datasetFullName} + onSuccess={onRenameSuccess} /> )} {openModal === 'createSplit' && modalFile && ( diff --git a/web/packages/studio/src/components/FilesTable/TransformFileModal/MappingValueHelp.tsx b/web/packages/studio/src/components/FilesTable/TransformFileModal/MappingValueHelp.tsx new file mode 100644 index 0000000000..447fbfff43 --- /dev/null +++ b/web/packages/studio/src/components/FilesTable/TransformFileModal/MappingValueHelp.tsx @@ -0,0 +1,34 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { Stack, Text } from '@nvidia/foundations-react-core'; +import { FC } from 'react'; + +const EXAMPLES: { template: string; description: string }[] = [ + { template: '{{{column}}}', description: 'Insert a source column verbatim.' }, + { template: '{{column}}', description: 'Same, but HTML-escapes the value.' }, + { template: '{{@row}}', description: 'The 1-based row number.' }, + { template: 'task-{{@row}}', description: 'Mix literal text with templates.' }, + { template: '{{#if a}}{{{a}}}{{else}}{{{b}}}{{/if}}', description: 'Fall back when a is empty.' }, + { + template: '["{{{a}}}", "{{{b}}}"]', + description: 'Output starting with [ or { is parsed as JSON.', + }, +]; + +/** Popover content for the mapping grid's value column. */ +export const MappingValueHelp: FC = () => ( + + + Values are Handlebars templates evaluated once per row. Anything outside the braces is copied + through as literal text. + + + {EXAMPLES.map(({ template, description }) => ( + + {template} — {description} + + ))} + + +); diff --git a/web/packages/studio/src/components/FilesTable/TransformFileModal/index.tsx b/web/packages/studio/src/components/FilesTable/TransformFileModal/index.tsx index 267cec2d72..0a3666d3e4 100644 --- a/web/packages/studio/src/components/FilesTable/TransformFileModal/index.tsx +++ b/web/packages/studio/src/components/FilesTable/TransformFileModal/index.tsx @@ -2,6 +2,8 @@ // SPDX-License-Identifier: Apache-2.0 import { zodResolver } from '@hookform/resolvers/zod'; +import { ControlledSelect } from '@nemo/common/src/components/form/ControlledSelect'; +import { ControlledTextInput } from '@nemo/common/src/components/form/ControlledTextInput'; import { MappingFields } from '@nemo/common/src/components/form/MappingFields'; import { FormModal } from '@nemo/common/src/components/FormModal'; import { ModelSelect } from '@nemo/common/src/components/ModelSelect'; @@ -11,6 +13,20 @@ import { useModelsListModels } from '@nemo/sdk/generated/platform/api'; import { Divider, Flex, Label, Spinner, Stack, Text } from '@nvidia/foundations-react-core'; import { useDatasetFileContent } from '@studio/api/datasets/useDatasetFileContent'; import { useDatasetFileTransform } from '@studio/api/datasets/useDatasetFileTransform'; +import { MappingValueHelp } from '@studio/components/FilesTable/TransformFileModal/MappingValueHelp'; +import { + columnTemplate, + DEFAULT_TARGET_FORMAT, + getDefaultMappingValue, + getKeyDescriptions, + getDefaultOutputFilepath, + getKeySuggestions, + getPrefilledSchema, + getTargetFormatHelp, + ROW_NUMBER_TEMPLATE, + TARGET_FORMAT_OPTIONS, + TargetFormat, +} from '@studio/components/FilesTable/TransformFileModal/targetFormats'; import { TransformPreview } from '@studio/components/FilesTable/TransformFileModal/TransformPreview'; import { type TransformFileFormFields, @@ -22,8 +38,8 @@ import { useWorkspaceFromPath } from '@studio/hooks/useWorkspaceFromPath'; import { getContentSchema } from '@studio/util/files'; import { handleFormErrorsGeneric } from '@studio/util/forms/error'; import { GitBranch } from 'lucide-react'; -import { useMemo, type ComponentProps, type FC } from 'react'; -import { useForm } from 'react-hook-form'; +import { useCallback, useMemo, type ComponentProps, type FC } from 'react'; +import { useForm, useWatch } from 'react-hook-form'; interface Props extends Pick, 'open' | 'onClose'> { filepath?: string; @@ -40,14 +56,17 @@ export const TransformFileModal: FC = ({ open, onClose, filepath, dataset const datasetNameSplit = getPartsFromReference(resolvedDatasetId); const workspace = useWorkspaceFromPath(); - const { control, reset, handleSubmit } = useForm({ + const { control, reset, handleSubmit, getValues, setValue } = useForm({ mode: 'onChange', resolver: zodResolver(transformFileSchema), defaultValues: { filepath, + outputFilepath: getDefaultOutputFilepath(DEFAULT_TARGET_FORMAT, filepath ?? ''), + targetFormat: DEFAULT_TARGET_FORMAT, mappings: [], }, }); + const targetFormat = useWatch({ control, name: 'targetFormat' }); const resetAndClose = () => { reset(); onClose(); @@ -73,9 +92,34 @@ export const TransformFileModal: FC = ({ open, onClose, filepath, dataset return getContentSchema(fileContent, { fileType }); }, [fileType, fileContent]); + const sourceColumns = useMemo(() => Object.keys(schema ?? {}), [schema]); + + const valueSuggestions = useMemo( + () => [...sourceColumns.map(columnTemplate), ROW_NUMBER_TEMPLATE], + [sourceColumns] + ); + + const mappingSchema = getPrefilledSchema(targetFormat) ?? schema; + + const retargetOutputFilepath = (nextFormat: string) => { + if (getValues('outputFilepath') !== getDefaultOutputFilepath(targetFormat, resolvedFilepath)) { + return; + } + setValue( + 'outputFilepath', + getDefaultOutputFilepath(nextFormat as TargetFormat, resolvedFilepath), + { shouldValidate: true } + ); + }; + + const schemaValueForKey = useCallback( + (key: string) => getDefaultMappingValue(targetFormat, key, sourceColumns), + [targetFormat, sourceColumns] + ); + const { mutate: transformFile, isPending } = useDatasetFileTransform({ - onSuccess: () => { - toast.success('Successfully finished file transformation!'); + onSuccess: (_data, variables) => { + toast.success(`Transformed rows written to ${variables.outputFilepath}`); resetAndClose(); }, }); @@ -94,6 +138,7 @@ export const TransformFileModal: FC = ({ open, onClose, filepath, dataset workspace: datasetNameSplit.workspace, datasetName: datasetNameSplit.name, filepath: resolvedFilepath, + outputFilepath: data.outputFilepath.trim(), mappings: data.mappings.filter((m) => m.key.trim() !== ''), fileContent: fileContent, model, @@ -123,7 +168,8 @@ export const TransformFileModal: FC = ({ open, onClose, filepath, dataset Map existing columns to new names, add computed fields, and optionally run inference - models on each row to enhance your data with AI-generated content. + models on each row to enhance your data with AI-generated content. The result is written + to a new file. = ({ open, onClose, filepath, dataset ) : ( + Target Format, + slotHelp: getTargetFormatHelp(targetFormat), + }} + useControllerProps={{ control, name: 'targetFormat' }} + /> + Output File, + slotHelp: 'Written as JSON Lines. The source file is left unchanged.', + }} + useControllerProps={{ control, name: 'outputFilepath' }} + /> } /> { + it('documents every field of every format', () => { + for (const format of TARGET_FORMATS) { + const descriptions = getKeyDescriptions(format); + for (const field of TARGET_FORMAT_DEFINITIONS[format].fields) { + expect(descriptions[field.key]).toBeTruthy(); + } + } + }); + + it('prefills a row for every required field', () => { + for (const format of TARGET_FORMATS) { + const prefilled = getPrefilledSchema(format) ?? {}; + for (const key of getRequiredKeys(format)) { + expect(prefilled).toHaveProperty(key); + } + } + }); + + it('suggests an output file beside the source, never on top of it', () => { + for (const format of TARGET_FORMATS) { + const source = 'nested/dir/data.csv'; + const output = getDefaultOutputFilepath(format, source); + expect(output).not.toBe(source); + expect(output.startsWith('nested/dir/data-')).toBe(true); + expect(output.endsWith('.jsonl')).toBe(true); + } + }); + + it('handles a bare filename and a missing source', () => { + expect(getDefaultOutputFilepath('custom', 'data.jsonl')).toBe('data-transformed.jsonl'); + expect(getDefaultOutputFilepath('custom', 'noextension')).toBe('noextension-transformed.jsonl'); + expect(getDefaultOutputFilepath('custom', '')).toBe(''); + }); + + it('leaves the custom format free-form', () => { + expect(getPrefilledSchema('custom')).toBeUndefined(); + expect(getKeySuggestions('custom')).toBeUndefined(); + expect(getDefaultMappingValue('custom', 'anything', [])).toBe('{{{anything}}}'); + }); +}); + +describe('agent eval task format', () => { + it('resolves help text from the generated zod describe blocks', () => { + const descriptions = getKeyDescriptions('agent-eval-task'); + expect(descriptions.intent).toContain( + 'Human-readable description of the desired agent behavior.' + ); + expect(descriptions['inputs.instruction']).toContain("The agent's instruction (its prompt)."); + expect(descriptions.metrics).toContain('Metrics that score this task'); + expect(descriptions.metadata).toContain('Key/value annotations'); + }); + + it('falls back to the row number when the file has no id column', () => { + expect(getDefaultMappingValue('agent-eval-task', 'id', ['question'])).toBe('task-{{@row}}'); + }); + + it('maps id to the source column when one is present', () => { + expect(getDefaultMappingValue('agent-eval-task', 'id', ['task_id'])).toBe('{{{task_id}}}'); + }); + + it('auto-maps the instruction from a likely prompt column', () => { + expect(getDefaultMappingValue('agent-eval-task', 'inputs.instruction', ['prompt'])).toBe( + '{{{prompt}}}' + ); + expect(getDefaultMappingValue('agent-eval-task', 'inputs.instruction', ['other'])).toBe(''); + }); +}); diff --git a/web/packages/studio/src/components/FilesTable/TransformFileModal/targetFormats.ts b/web/packages/studio/src/components/FilesTable/TransformFileModal/targetFormats.ts new file mode 100644 index 0000000000..0ad8f86dac --- /dev/null +++ b/web/packages/studio/src/components/FilesTable/TransformFileModal/targetFormats.ts @@ -0,0 +1,176 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { EvaluatorCreateTaskBody } from '@nemo/sdk/generated/evaluator/zod/evaluator-plugin-tasks-routes/evaluatorCreateTask'; + +export const TARGET_FORMATS = ['custom', 'agent-eval-task'] as const; + +export type TargetFormat = (typeof TARGET_FORMATS)[number]; + +export const DEFAULT_TARGET_FORMAT: TargetFormat = 'custom'; + +/** 1-based row number, supplied to Handlebars as a `@data` variable by the transform and preview. */ +export const ROW_NUMBER_TEMPLATE = '{{@row}}'; + +/** Handlebars expression that substitutes a source column, unescaped. */ +export const columnTemplate = (column: string) => `{{{${column}}}}`; + +export interface TargetFormatField { + key: string; + /** Help text shown beneath the mapping row. Prefer the generated zod describe blocks. */ + description: string; + /** Blocks submit until the row resolves to a value. */ + required?: boolean; + /** Prefill a mapping row for this key. Fields without it are offered as key suggestions only. */ + prefill?: boolean; + /** Value the prefilled row starts with, given the source file's column names. */ + defaultValue?: (sourceColumns: string[]) => string; +} + +export interface TargetFormatDefinition { + value: TargetFormat; + label: string; + help: string; + /** Appended to the source file's base name when suggesting an output file. */ + outputSuffix: string; + /** Empty for free-form formats, whose keys come from the source file instead. */ + fields: TargetFormatField[]; +} + +/** Maps to the first candidate the source file actually has, else `fallback`. */ +const firstMatchingColumn = + (candidates: string[], fallback = '') => + (sourceColumns: string[]) => { + const match = candidates.find((candidate) => sourceColumns.includes(candidate)); + return match ? columnTemplate(match) : fallback; + }; + +const taskShape = EvaluatorCreateTaskBody.shape; + +/** + * `id` and `reference` have no describe block on the create-task body — `id` comes from the URL + * path and `reference` only exists on job-inline tasks — so their help text is copied from the + * generated `AgentEvalTaskInput` / `AgentEvalTaskInputReference` docs. + */ +const TASK_ID_DESCRIPTION = 'Stable task identifier, unique within the task collection.'; +const TASK_REFERENCE_DESCRIPTION = + "Grader-only ground truth (held-out tests, expected outputs, rubric data). Surfaced to metrics but never seeded into the agent's workspace or shown to the agent."; + +const AGENT_EVAL_TASK_FIELDS: TargetFormatField[] = [ + { + key: 'id', + description: `Required. ${TASK_ID_DESCRIPTION} Defaults to the row number when the file has no id column.`, + required: true, + prefill: true, + defaultValue: firstMatchingColumn(['id', 'task_id', 'name'], `task-${ROW_NUMBER_TEMPLATE}`), + }, + { + key: 'intent', + description: `Required. ${taskShape.intent.description ?? ''}`, + required: true, + prefill: true, + defaultValue: firstMatchingColumn(['intent']), + }, + { + key: 'inputs.instruction', + description: taskShape.inputs.unwrap().shape.instruction.description ?? '', + prefill: true, + defaultValue: firstMatchingColumn(['instruction', 'prompt', 'question']), + }, + { key: 'reference.expected', description: TASK_REFERENCE_DESCRIPTION }, + { key: 'metadata', description: taskShape.metadata.description ?? '' }, + { key: 'metrics', description: taskShape.metrics.description ?? '' }, +]; + +export const TARGET_FORMAT_DEFINITIONS: Record = { + custom: { + value: 'custom', + label: 'Custom', + help: 'Map source columns to any output keys you choose.', + outputSuffix: 'transformed', + fields: [], + }, + 'agent-eval-task': { + value: 'agent-eval-task', + label: 'Task (Evaluation)', + help: 'Emit one agent-eval task per row, using the keys the Evaluator task API expects.', + outputSuffix: 'tasks', + fields: AGENT_EVAL_TASK_FIELDS, + }, +}; + +export const TARGET_FORMAT_OPTIONS = TARGET_FORMATS.map((value) => ({ + value, + children: TARGET_FORMAT_DEFINITIONS[value].label, +})); + +const byFormat = (build: (definition: TargetFormatDefinition) => T): Record => + Object.fromEntries( + TARGET_FORMATS.map((format) => [format, build(TARGET_FORMAT_DEFINITIONS[format])]) + ) as Record; + +/** + * Keys the mapping grid is seeded with. `undefined` hands the grid back to the source file's own + * schema, which is what makes the custom format free-form. + */ +const PREFILLED_SCHEMAS = byFormat((definition) => { + const prefilled = definition.fields.filter((field) => field.prefill); + return prefilled.length + ? (Object.fromEntries(prefilled.map((field) => [field.key, null])) as Record) + : undefined; +}); + +const KEY_SUGGESTIONS = byFormat((definition) => + definition.fields.length ? definition.fields.map((field) => field.key) : undefined +); + +const KEY_DESCRIPTIONS = byFormat((definition) => + Object.fromEntries(definition.fields.map((field) => [field.key, field.description])) +); + +const REQUIRED_KEYS = byFormat((definition) => + definition.fields.filter((field) => field.required).map((field) => field.key) +); + +const FIELDS_BY_KEY = byFormat( + (definition) => new Map(definition.fields.map((field) => [field.key, field])) +); + +export const getPrefilledSchema = (format: TargetFormat) => PREFILLED_SCHEMAS[format]; + +export const getKeySuggestions = (format: TargetFormat) => KEY_SUGGESTIONS[format]; + +export const getKeyDescriptions = (format: TargetFormat) => KEY_DESCRIPTIONS[format]; + +export const getRequiredKeys = (format: TargetFormat) => REQUIRED_KEYS[format]; + +export const getTargetFormatHelp = (format: TargetFormat) => TARGET_FORMAT_DEFINITIONS[format].help; + +/** The transform emits JSON Lines whatever the source file was, so the output is always `.jsonl`. */ +const OUTPUT_EXTENSION = 'jsonl'; + +/** Suggested destination, alongside the source file rather than on top of it. */ +export const getDefaultOutputFilepath = (format: TargetFormat, sourceFilepath: string) => { + if (!sourceFilepath) return ''; + const lastSlash = sourceFilepath.lastIndexOf('/'); + const directory = sourceFilepath.slice(0, lastSlash + 1); + const filename = sourceFilepath.slice(lastSlash + 1); + const extension = filename.lastIndexOf('.'); + const base = extension > 0 ? filename.slice(0, extension) : filename; + const suffix = TARGET_FORMAT_DEFINITIONS[format].outputSuffix; + return `${directory}${base}-${suffix}.${OUTPUT_EXTENSION}`; +}; + +/** + * Value a freshly seeded mapping row starts with. Keys the format does not define — every key in + * the custom format — echo their source column, preserving the original transform behavior. + */ +export const getDefaultMappingValue = ( + format: TargetFormat, + key: string, + sourceColumns: string[] +) => { + const field = FIELDS_BY_KEY[format].get(key); + if (!field) return columnTemplate(key); + return field.defaultValue?.(sourceColumns) ?? ''; +}; diff --git a/web/packages/studio/src/components/FilesTable/TransformFileModal/types.ts b/web/packages/studio/src/components/FilesTable/TransformFileModal/types.ts index 57c61cf615..a8576c5d51 100644 --- a/web/packages/studio/src/components/FilesTable/TransformFileModal/types.ts +++ b/web/packages/studio/src/components/FilesTable/TransformFileModal/types.ts @@ -1,6 +1,11 @@ // SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +import { + getRequiredKeys, + TARGET_FORMATS, + TargetFormat, +} from '@studio/components/FilesTable/TransformFileModal/targetFormats'; import { z } from 'zod'; export const mappingSchema = z.object({ @@ -12,9 +17,26 @@ export const mappingSchema = z.object({ export const transformFileSchema = z .object({ filepath: z.string().nonempty('Filepath is required'), + outputFilepath: z.string().nonempty('Output file is required'), + targetFormat: z.enum(TARGET_FORMATS), model: z.string().optional(), mappings: z.array(mappingSchema), }) + .refine((data) => data.outputFilepath.trim() !== data.filepath.trim(), { + path: ['outputFilepath'], + message: 'The transform writes a new file. Choose a name other than the source file.', + }) + .superRefine((data, ctx) => { + for (const requiredKey of getRequiredKeys(data.targetFormat)) { + const index = data.mappings.findIndex((mapping) => mapping.key.trim() === requiredKey); + if (index >= 0 && data.mappings[index].value?.trim()) continue; + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `"${requiredKey}" is required by the task format. Map it to a source column or a literal value.`, + path: ['mappings', index >= 0 ? index : 0, 'value'], + }); + } + }) .superRefine((data, ctx) => { const keys = new Set(); for (let i = 0; i < data.mappings.length; i++) { @@ -54,6 +76,8 @@ export const transformFileSchema = z export type TransformFileFormFields = { filepath: string; + outputFilepath: string; + targetFormat: TargetFormat; model?: string; mappings: z.infer[]; }; diff --git a/web/packages/studio/src/components/FilesTable/TransformFileModal/useTransformPreview.ts b/web/packages/studio/src/components/FilesTable/TransformFileModal/useTransformPreview.ts index fb0ada7886..4f4937b0ba 100644 --- a/web/packages/studio/src/components/FilesTable/TransformFileModal/useTransformPreview.ts +++ b/web/packages/studio/src/components/FilesTable/TransformFileModal/useTransformPreview.ts @@ -25,16 +25,20 @@ const parseKeyParts = (key: string): string[] | null => { return parts; }; -const renderMapping = (value: string | undefined, row: Record): string => { +const renderMapping = ( + value: string | undefined, + row: Record, + context: Handlebars.HelperOptions +): string => { try { - return Handlebars.compile(value ?? '')(row); + return Handlebars.compile(value ?? '')(row, context); } catch { // An in-progress template (e.g. `{{name`) must not break the whole preview. return value ?? ''; } }; -const applyMappings = (row: Row, mappings: Mapping[]): Row => { +const applyMappings = (row: Row, mappings: Mapping[], rowNumber: number): Row => { const newRow: Record = {}; const processedRow = Object.fromEntries( @@ -60,7 +64,9 @@ const applyMappings = (row: Row, mappings: Mapping[]): Row => { } const lastPart = keyParts[keyParts.length - 1]; - const compiledValue = renderMapping(value, processedRow); + const compiledValue = renderMapping(value, processedRow, { + data: { row: rowNumber }, + } as Handlebars.HelperOptions); try { if (compiledValue.trim().startsWith('[') || compiledValue.trim().startsWith('{')) { @@ -101,8 +107,8 @@ export const useTransformPreview = ({ fileContent, fileType, mappings }: Props) const afterRow = useMemo(() => { if (!sourceRow || activeMappings.length === 0) return null; - return applyMappings(sourceRow, activeMappings); - }, [sourceRow, activeMappings]); + return applyMappings(sourceRow, activeMappings, rowIndex + 1); + }, [sourceRow, activeMappings, rowIndex]); const totalRows = rows.length; diff --git a/web/packages/studio/src/components/FilesetFilePreviewPanel/FilesetFilePreviewContent/index.tsx b/web/packages/studio/src/components/FilesetFilePreviewPanel/FilesetFilePreviewContent/index.tsx index ea1b0ce548..a6eceeb23d 100644 --- a/web/packages/studio/src/components/FilesetFilePreviewPanel/FilesetFilePreviewContent/index.tsx +++ b/web/packages/studio/src/components/FilesetFilePreviewPanel/FilesetFilePreviewContent/index.tsx @@ -25,6 +25,8 @@ export interface FilesetFilePreviewContentProps { // File actions onDeleteSuccess?: () => void; onRenameSuccess?: (newPath: string) => void; + /** When true, the header action menu shows the full set (Move, Duplicate, Create Split, Transform, Rename). */ + isReadWriteDataset?: boolean; // Optional: pre-fetched data (parent already has the file + content) file?: FileSystemFile; @@ -58,6 +60,7 @@ export const FilesetFilePreviewContent: FC = ({ onFolderClick, onDeleteSuccess, onRenameSuccess, + isReadWriteDataset, file: externalFile, fileContent: externalContent, isLoading: externalLoading, @@ -138,6 +141,7 @@ export const FilesetFilePreviewContent: FC = ({ filesetName={filesetName} filePath={filePath} file={file} + isReadWriteDataset={isReadWriteDataset} onFilesetClick={onFilesetClick} onFolderClick={onFolderClick} onDeleteSuccess={onDeleteSuccess} diff --git a/web/packages/studio/src/components/FilesetFilePreviewPanel/components/FileActions/index.tsx b/web/packages/studio/src/components/FilesetFilePreviewPanel/components/FileActions/index.tsx deleted file mode 100644 index 22ac0fa163..0000000000 --- a/web/packages/studio/src/components/FilesetFilePreviewPanel/components/FileActions/index.tsx +++ /dev/null @@ -1,173 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -import { useToast } from '@nemo/common/src/providers/toast/useToast'; -import { triggerDownload } from '@nemo/common/src/utils/file'; -import { - Button, - DropdownContent, - DropdownItem, - DropdownRoot, - DropdownTrigger, - Flex, -} from '@nvidia/foundations-react-core'; -import { useDatasetFileDelete } from '@studio/api/datasets/useDatasetFileDelete'; -import { DeleteConfirmationModal } from '@studio/components/DeleteConfirmationModal'; -import { CreateFileSplitsModal } from '@studio/components/FilesTable/CreateFileSplitsModal'; -import { RenameFileModal } from '@studio/components/FilesTable/RenameFileModal'; -import { FileSystemFile, FileSystemNode } from '@studio/components/FilesTable/utils'; -import { useWorkers } from '@studio/providers/workers/useWorkers'; -import LargeFileWorker from '@studio/workers/LargeFileWorker?worker'; -import { Download as DownloadIcon, Pencil, Trash, Split, EllipsisVertical } from 'lucide-react'; -import { FC, useState } from 'react'; -import { useAuth } from 'react-oidc-context'; - -type ModalType = 'createSplit' | 'rename' | 'delete'; - -interface Props { - /** Fileset workspace (e.g., 'my-workspace') */ - workspace: string; - /** Fileset name (e.g., 'my-dataset' / 'my-model') */ - filesetName: string; - /** File to perform actions on */ - file: FileSystemFile; - /** Callback when file is successfully deleted */ - onDeleteSuccess?: () => void; - /** Callback when file is successfully renamed */ - onRenameSuccess?: (newPath: string) => void; -} - -export const FileActions: FC = ({ - workspace, - filesetName, - file, - onDeleteSuccess, - onRenameSuccess, -}) => { - const [modalFile, setModalFile] = useState(); - const [openModal, setOpenModal] = useState(); - const toast = useToast(); - const auth = useAuth(); - const { createWorker } = useWorkers(); - - const { mutateAsync: deleteFile, error: deleteError } = useDatasetFileDelete(); - - const downloadFile = async () => { - const worker = new LargeFileWorker(); - createWorker(worker, { - onMessage: (e) => { - const { done, arrayBuffer, error } = e.data; - if (done && arrayBuffer) { - triggerDownload(arrayBuffer, file.path); - toast.success('Successfully downloaded file!'); - } else if (done && error) { - toast.error(`Download failed: ${error}`); - } - }, - onError: () => { - toast.error('Unable to download file. Please try again later.'); - }, - }); - worker.postMessage({ - action: 'downloadAsFile', - workspace, - dataset: filesetName, - path: file.path, - accessToken: auth.user?.access_token, - }); - }; - - const handleDeleteFile = async () => { - if (!workspace || !filesetName) { - toast.error('Failed to delete file: invalid fileset name'); - return false; - } - - try { - const response = await deleteFile({ - workspace, - datasetName: filesetName, - path: file.path, - }); - if (response) { - onDeleteSuccess?.(); - } - return Boolean(response); - } catch { - return false; - } - }; - - const handleRenameSuccess = (newPath: string) => { - onRenameSuccess?.(newPath); - }; - - const openModalWithFile = (modal: ModalType) => () => { - setModalFile(file); - setOpenModal(modal); - }; - - return ( - <> - - - - - - - - - Create Split - - - - - - Download - - - - - - Rename - - - - - - Delete - - - - - {openModal === 'delete' && modalFile && ( - setOpenModal(undefined)} - /> - )} - {openModal === 'rename' && modalFile && ( - setOpenModal(undefined)} - onSuccess={handleRenameSuccess} - /> - )} - {openModal === 'createSplit' && modalFile && ( - setOpenModal(undefined)} filepath={file.path} /> - )} - - ); -}; diff --git a/web/packages/studio/src/components/FilesetFilePreviewPanel/components/FilesetFilePreviewHeader/index.tsx b/web/packages/studio/src/components/FilesetFilePreviewPanel/components/FilesetFilePreviewHeader/index.tsx index 15fd54d247..f9d1ea8d8b 100644 --- a/web/packages/studio/src/components/FilesetFilePreviewPanel/components/FilesetFilePreviewHeader/index.tsx +++ b/web/packages/studio/src/components/FilesetFilePreviewPanel/components/FilesetFilePreviewHeader/index.tsx @@ -2,8 +2,8 @@ // SPDX-License-Identifier: Apache-2.0 import { Flex } from '@nvidia/foundations-react-core'; -import { FileActions } from '@studio/components/FilesetFilePreviewPanel/components/FileActions'; import { FileBreadcrumbs } from '@studio/components/FilesetFilePreviewPanel/components/FileBreadcrumbs'; +import { FileQuickActions } from '@studio/components/FilesTable/FileQuickActions'; import type { FileSystemFile } from '@studio/components/FilesTable/utils'; import { FolderOpen } from 'lucide-react'; import type { FC } from 'react'; @@ -14,17 +14,37 @@ export interface FilesetFilePreviewHeaderProps { filePath: string; /** Resolved file used to render delete/rename/split actions. Omit to hide actions. */ file?: FileSystemFile; + /** When true, show the full action menu (Move, Duplicate, Create Split, Transform, Rename). */ + isReadWriteDataset?: boolean; + /** + * `inline` (default) keeps the actions menu in flow at the end of the header row. + * `overlay` pins it to the SidePanel heading's absolute close button so the two + * line up; the host must reserve the horizontal space (see `SIDE_PANEL_HEADING_CLASS`). + */ + actionsPlacement?: 'inline' | 'overlay'; onFilesetClick?: () => void; onFolderClick?: (folderPath: string) => void; onDeleteSuccess?: () => void; onRenameSuccess?: (newPath: string) => void; } +/** + * Padding the SidePanel heading needs so the breadcrumbs stop before the overlaid + * actions menu (48px button at `right-19`) instead of running underneath it. + */ +export const SIDE_PANEL_HEADING_CLASS = 'font-normal pr-32'; + +/** Mirrors `.nv-side-panel-close`'s offsets so the menu sits level with the close button. */ +const OVERLAY_ACTIONS_CLASS = + 'absolute top-[calc(var(--heading-padding-block)-1px)] right-19 translate-y-[-25%]'; + export const FilesetFilePreviewHeader: FC = ({ workspace, filesetName, filePath, file, + isReadWriteDataset = true, + actionsPlacement = 'inline', onFilesetClick, onFolderClick, onDeleteSuccess, @@ -41,13 +61,15 @@ export const FilesetFilePreviewHeader: FC = ({ /> {file && ( - +
+ +
)} ); diff --git a/web/packages/studio/src/components/FilesetFilePreviewPanel/index.test.tsx b/web/packages/studio/src/components/FilesetFilePreviewPanel/index.test.tsx index 036a3f78db..0cbe748397 100644 --- a/web/packages/studio/src/components/FilesetFilePreviewPanel/index.test.tsx +++ b/web/packages/studio/src/components/FilesetFilePreviewPanel/index.test.tsx @@ -5,7 +5,7 @@ import { FilesetFilePreviewPanel } from '@studio/components/FilesetFilePreviewPa import { TestProviders } from '@studio/tests/util/TestProviders'; import { render, screen, fireEvent, waitFor } from '@testing-library/react'; -// Mock the useWorkers hook since FileActions uses it +// Mock the useWorkers hook since the file actions menu downloads via a worker vi.mock('@studio/providers/workers/useWorkers', () => ({ useWorkers: () => ({ createWorker: vi.fn(), diff --git a/web/packages/studio/src/components/FilesetFilePreviewPanel/index.tsx b/web/packages/studio/src/components/FilesetFilePreviewPanel/index.tsx index 48f363a0da..66e7dbfa43 100644 --- a/web/packages/studio/src/components/FilesetFilePreviewPanel/index.tsx +++ b/web/packages/studio/src/components/FilesetFilePreviewPanel/index.tsx @@ -3,7 +3,10 @@ import { useFilesListFilesetFiles } from '@nemo/sdk/generated/platform/api'; import { SidePanel, SidePanelCloseButton } from '@nvidia/foundations-react-core'; -import { FilesetFilePreviewHeader } from '@studio/components/FilesetFilePreviewPanel/components/FilesetFilePreviewHeader'; +import { + FilesetFilePreviewHeader, + SIDE_PANEL_HEADING_CLASS, +} from '@studio/components/FilesetFilePreviewPanel/components/FilesetFilePreviewHeader'; import { FilesetFilePreviewContent } from '@studio/components/FilesetFilePreviewPanel/FilesetFilePreviewContent'; import type { FileSystemFile } from '@studio/components/FilesTable/utils'; import { useRef, type FC } from 'react'; @@ -27,6 +30,8 @@ export interface FilesetFilePreviewPanelProps { // File actions onDeleteSuccess?: () => void; onRenameSuccess?: (newPath: string) => void; + /** When true, the header action menu shows the full set (Move, Duplicate, Create Split, Transform, Rename). */ + isReadWriteDataset?: boolean; // Optional: pre-fetched data (for performance or when parent already has data) file?: FileSystemFile; @@ -55,6 +60,7 @@ export const FilesetFilePreviewPanel: FC = ({ onFolderClick, onDeleteSuccess, onRenameSuccess, + isReadWriteDataset, file: externalFile, fileContent, isLoading, @@ -101,6 +107,8 @@ export const FilesetFilePreviewPanel: FC = ({ filesetName={filesetName} filePath={filePath} file={file} + isReadWriteDataset={isReadWriteDataset} + actionsPlacement="overlay" onFilesetClick={onFilesetClick ? () => closeWith(onFilesetClick) : undefined} onFolderClick={ onFolderClick ? (folderPath) => closeWith(() => onFolderClick(folderPath)) : undefined @@ -110,7 +118,7 @@ export const FilesetFilePreviewPanel: FC = ({ /> } attributes={{ - SidePanelHeading: { className: 'font-normal' }, + SidePanelHeading: { className: SIDE_PANEL_HEADING_CLASS }, SidePanelCloseButton: { type: 'button' }, }} bordered @@ -131,6 +139,7 @@ export const FilesetFilePreviewPanel: FC = ({ filesetName={filesetName} filePath={filePath} file={file} + isReadWriteDataset={isReadWriteDataset} fileContent={fileContent} isLoading={isLoading} error={error} diff --git a/web/packages/studio/src/util/files.test.ts b/web/packages/studio/src/util/files.test.ts index edb70f1a96..eff858af8e 100644 --- a/web/packages/studio/src/util/files.test.ts +++ b/web/packages/studio/src/util/files.test.ts @@ -252,6 +252,12 @@ describe('resolveDatasetFilePath', () => { it('returns multi-segment paths unchanged when folder is empty string', () => { expect(resolveDatasetFilePath('a/b/c.txt', '')).toBe('a/b/c.txt'); }); + + it('returns empty string when the filepath is missing', () => { + expect(resolveDatasetFilePath(undefined)).toBe(''); + expect(resolveDatasetFilePath(undefined, 'training')).toBe(''); + expect(resolveDatasetFilePath('', 'training')).toBe(''); + }); }); describe('getContentColumns', () => { diff --git a/web/packages/studio/src/util/files.ts b/web/packages/studio/src/util/files.ts index d34c6f60b7..7a97e65901 100644 --- a/web/packages/studio/src/util/files.ts +++ b/web/packages/studio/src/util/files.ts @@ -51,8 +51,14 @@ export const getFullFilePath = (filepath: string, folder?: string) => { * Tree nodes and API responses use full paths (e.g. `training/data/file.txt`). When the path * already contains `/`, it is returned as-is. Only single-segment paths (filename or root-level * name) are joined with `folder` — the legacy case for folder-scoped listings. + * + * Returns an empty string when `filepath` is missing, so callers can detect a + * file entry that carries no usable path instead of throwing during render. */ -export const resolveDatasetFilePath = (filepath: string, folder?: string) => { +export const resolveDatasetFilePath = (filepath: string | undefined, folder?: string): string => { + if (!filepath) { + return ''; + } if (filepath.includes('/')) { return filepath; }