Skip to content
Closed
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 @@ -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<TFieldValues extends FieldValues> {
Expand All @@ -34,6 +34,11 @@ interface Props<TFieldValues extends FieldValues> {
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<KeyValueComboboxPassthrough>;
valueCombobox: Partial<KeyValueComboboxPassthrough>;
keyTextInput: Partial<KeyValueTextInputPassthrough>;
Expand All @@ -51,6 +56,9 @@ const MappingRowInner = <TFieldValues extends FieldValues>({
valueOpts,
keyColumnLabel,
valueColumnLabel,
keyColumnInfo,
valueColumnInfo,
description,
keyCombobox,
valueCombobox,
keyTextInput,
Expand Down Expand Up @@ -87,8 +95,11 @@ const MappingRowInner = <TFieldValues extends FieldValues>({
...valueTextRest
} = valueTextInput;

/** Only the first row carries the column labels, and with them the info popovers. */
const isHeaderRow = index === 0;

return (
<Flex gap="density-lg" align="end" justify="between">
<Grid className="grid-cols-[minmax(0,1fr)_minmax(0,1fr)_auto] items-end gap-x-density-lg gap-y-density-xs">
{keyOpts.length > 0 ? (
<ControlledCombobox
{...keyComboboxRest}
Expand All @@ -99,12 +110,13 @@ const MappingRowInner = <TFieldValues extends FieldValues>({
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 : ''}
/>
) : (
<ControlledTextInput
Expand All @@ -114,11 +126,12 @@ const MappingRowInner = <TFieldValues extends FieldValues>({
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 ? (
Expand All @@ -131,12 +144,13 @@ const MappingRowInner = <TFieldValues extends FieldValues>({
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 : ''}
/>
) : (
<ControlledTextInput
Expand All @@ -146,11 +160,12 @@ const MappingRowInner = <TFieldValues extends FieldValues>({
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 : ''}
/>
)}
<Button
Expand All @@ -164,7 +179,10 @@ const MappingRowInner = <TFieldValues extends FieldValues>({
>
<Trash />
</Button>
</Flex>
{description ? (
<Text className="col-start-1 col-end-2 text-secondary">{description}</Text>
) : null}
</Grid>
);
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<string, string>;
/** Forward props to the key/value field controls (combobox vs text input is chosen automatically). */
attributes?: {
keyCombobox?: Partial<KeyValueComboboxPassthrough>;
Expand All @@ -120,6 +128,9 @@ export const MappingFields = <
valueSuggestions: valueSuggestionsProp,
keyColumnLabel = 'Key',
valueColumnLabel = 'Value',
keyColumnInfo,
valueColumnInfo,
keyDescriptions,
attributes,
}: MappingFieldsProps<TFieldValues, TName>) => {
const nameStr = name as string;
Expand Down Expand Up @@ -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}
Expand Down
20 changes: 15 additions & 5 deletions web/packages/studio/src/api/datasets/useDatasetFileTransform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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
Expand All @@ -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<string, unknown> = {};
let skipInvalidRow = false;
mappings.forEach(({ key, value }) => {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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]
);
Expand All @@ -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);
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -33,13 +34,19 @@ 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<Props> = ({
datasetId,
file,
currentFolder,
onViewFile,
isReadWriteDataset = false,
onDeleteSuccess,
onRenameSuccess,
}) => {
const [modalFile, setModalFile] = useState<FileSystemNode | undefined>();
const [openModal, setOpenModal] = useState<ModalType | undefined>();
Expand Down Expand Up @@ -86,6 +93,9 @@ export const FileQuickActions: FC<Props> = ({

try {
const response = await mutateAsync({ workspace, datasetName: name, path });
if (response) {
onDeleteSuccess?.();
}
Comment on lines +96 to +98

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Keep callback failures separate from mutation failures.

onDeleteSuccess?.() runs inside the try block that catches mutateAsync. If the callback throws after the delete succeeds, handleDeleteFile returns false, so the caller receives an incorrect failure result. Limit the try/catch to mutateAsync, then invoke the callback after the catch.

Proposed fix
-    try {
-      const response = await mutateAsync({ workspace, datasetName: name, path });
-      if (response) {
-        onDeleteSuccess?.();
-      }
-      return Boolean(response);
+    let response: Awaited<ReturnType<typeof mutateAsync>>;
+    try {
+      response = await mutateAsync({ workspace, datasetName: name, path });
     } catch {
       return false;
     }
+    if (response) {
+      onDeleteSuccess?.();
+    }
+    return Boolean(response);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (response) {
onDeleteSuccess?.();
}
let response: Awaited<ReturnType<typeof mutateAsync>>;
try {
response = await mutateAsync({ workspace, datasetName: name, path });
} catch {
return false;
}
if (response) {
onDeleteSuccess?.();
}
return Boolean(response);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@web/packages/studio/src/components/FilesTable/FileQuickActions/index.tsx`
around lines 96 - 98, Restructure handleDeleteFile so the try/catch around
mutateAsync only handles mutation failures; after a successful response and
completed catch path, invoke onDeleteSuccess?.() outside the try/catch so
callback exceptions do not cause the delete operation to return false.

return Boolean(response);
} catch {
return false;
Expand All @@ -101,6 +111,11 @@ export const FileQuickActions: FC<Props> = ({
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);
Expand Down Expand Up @@ -148,6 +163,7 @@ export const FileQuickActions: FC<Props> = ({
onClose={() => setOpenModal(undefined)}
filepath={path}
datasetId={datasetFullName}
onSuccess={onRenameSuccess}
/>
)}
{openModal === 'createSplit' && modalFile && (
Expand Down
Original file line number Diff line number Diff line change
@@ -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 = () => (
<Stack gap="density-md">
<Text>
Values are Handlebars templates evaluated once per row. Anything outside the braces is copied
through as literal text.
</Text>
<Stack gap="density-xs">
{EXAMPLES.map(({ template, description }) => (
<Text key={template}>
<code>{template}</code> — {description}
</Text>
))}
</Stack>
</Stack>
);
Loading
Loading