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
@@ -1,7 +1,11 @@
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import { datasetFileContentQueryOptions } from '@studio/api/datasets/useDatasetFileContent';
import {
datasetFileContentQueryOptions,
EDITOR_MAX_BYTES,
} from '@studio/api/datasets/useDatasetFileContent';
import axios from 'axios';

vi.mock('@nemo/sdk/generated/platform/api', async (importOriginal) => {
const actual = await importOriginal<typeof import('@nemo/sdk/generated/platform/api')>();
Expand Down Expand Up @@ -49,6 +53,24 @@ describe('useDatasetFileContent gate', () => {
await expect((queryFn as () => Promise<string>)()).resolves.toBe('# heading');
});

it('returns full text (no preview cap) for fullContent editor loads', async () => {
const { queryFn } = datasetFileContentQueryOptions({ ...baseParams, fullContent: true });
await expect((queryFn as () => Promise<string>)()).resolves.toBe('# heading');
});

it('refuses fullContent loads above the editor ceiling instead of truncating', async () => {
// A file reporting a size past EDITOR_MAX_BYTES must throw so the caller shows a
// "too large" state — never silently load a prefix that would truncate on save.
const headSpy = vi.spyOn(axios, 'head').mockResolvedValueOnce({
headers: { 'content-length': String(EDITOR_MAX_BYTES + 1) },
} as never);

const { queryFn } = datasetFileContentQueryOptions({ ...baseParams, fullContent: true });
await expect((queryFn as () => Promise<string>)()).rejects.toThrow(/too large to edit/i);

headSpy.mockRestore();
});

it('serializes parquet rows with BigInt columns as JSONL text', async () => {
const { filesDownloadFile } = await import('@nemo/sdk/generated/platform/api');
vi.mocked(filesDownloadFile).mockResolvedValueOnce(new Blob(['parquet-bytes']));
Expand Down
26 changes: 23 additions & 3 deletions web/packages/studio/src/api/datasets/useDatasetFileContent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,17 @@ function serializeParquetRow(row: unknown): string {
// overwrite the source (the loaded rows are only a prefix of the file).
export const FILE_PREVIEW_MAX_BYTES = 512 * 1024;

export const EDITOR_MAX_BYTES = 8 * 1024 * 1024;

interface UseDatasetFileContentParams extends Required<EntityIdentifier> {
path: string;
range?: [number, number];
/**
* Load the entire file (no preview cap) so its content can be safely edited and written
* back. Refuses files larger than {@link EDITOR_MAX_BYTES}. Ignored when `range` is set.
* @defaultValue false — callers get the size-capped preview.
*/
fullContent?: boolean;
}

export type UseDatasetFilesOptions = Omit<UseQueryOptions<string, Error>, 'queryFn' | 'queryKey'> &
Expand All @@ -39,12 +47,14 @@ export const datasetFileContentQueryOptions = ({
name,
path,
range,
fullContent,
}: UseDatasetFileContentParams) =>
queryOptions<string, Error>({
staleTime: Infinity, // We should prevent refetching full files (costly) unless directly invalidated
queryKey: [
...getDatasetFileContentQueryKey(workspace!, name, path),
...(range ? range.map((bound) => String(bound)) : []),
...(fullContent ? ['full'] : []),
],
queryFn: async () => {
if (isBinaryExtension(path)) {
Expand Down Expand Up @@ -94,10 +104,18 @@ export const datasetFileContentQueryOptions = ({
throw new Error('Invalid response while downloading parquet file');
}
} else {
if (fullContent && range === undefined) {
if (fileSize !== null && fileSize > EDITOR_MAX_BYTES) {
throw new Error('File is too large to edit in the browser.');
}
}
const start = range ? range[0] : 0;
const end = range ? range[1] : FILE_PREVIEW_MAX_BYTES - 1;
const isSizeCappedPreview =
range === undefined && fileSize !== null && fileSize > FILE_PREVIEW_MAX_BYTES;
!fullContent &&
range === undefined &&
fileSize !== null &&
fileSize > FILE_PREVIEW_MAX_BYTES;
const needsRange = range !== undefined || isSizeCappedPreview;
const blob = await customFetch<Blob>({
url: fileUrl,
Expand All @@ -120,10 +138,11 @@ export const useDatasetFileContent = ({
name,
path,
range,
fullContent,
...options
}: UseDatasetFilesOptions) => {
return useQuery({
...datasetFileContentQueryOptions({ workspace, name, path, range }),
...datasetFileContentQueryOptions({ workspace, name, path, range, fullContent }),
enabled: Boolean(workspace && name && path),
...options,
});
Expand All @@ -134,10 +153,11 @@ export const useDatasetFileContentSuspense = ({
name,
path,
range,
fullContent,
...options
}: UseDatasetFilesOptions) => {
return useSuspenseQuery({
...datasetFileContentQueryOptions({ workspace, name, path, range }),
...datasetFileContentQueryOptions({ workspace, name, path, range, fullContent }),
...options,
});
};
Original file line number Diff line number Diff line change
@@ -1,16 +1,20 @@
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import { Divider, Flex, Grid, Stack, Text } from '@nvidia/foundations-react-core';
import { Banner, Divider, Flex, Grid, Stack, Text } from '@nvidia/foundations-react-core';
import { TemplateCard } from '@studio/components/CreateFilesetStart/TemplateCard';
import { FILESET_TEMPLATES } from '@studio/components/CreateFilesetStart/templates';
import {
FILESET_TEMPLATES,
templateRequiresLlm,
} from '@studio/components/CreateFilesetStart/templates';
import type {
DetailPoint,
StartOption,
StartOptionDetailProps,
} from '@studio/components/CreateFilesetStart/types';
import { Layers, Sparkles, Wand2 } from 'lucide-react';
import type { FC, ReactNode } from 'react';
import { Link } from 'react-router-dom';

const SCRATCH_POINTS: DetailPoint[] = [
{
Expand Down Expand Up @@ -63,15 +67,22 @@ export const StartOptionDetail: FC<StartOptionDetailProps> = ({
option,
selectedTemplateId,
onSelectTemplate,
llmDisabled,
inferenceProvidersHref,
}) => {
// Show the provider error label only when a card is actually disabled by it.
const hasDisabledTemplate =
option.id === 'template' && llmDisabled && FILESET_TEMPLATES.some(templateRequiresLlm);

const content =
option.id === 'template' ? (
<Grid colMinWidth="260px" gap="density-md">
<Grid colMinWidth="300px" gap="density-md">
{FILESET_TEMPLATES.map((template) => (
<TemplateCard
key={template.id}
template={template}
selected={selectedTemplateId === template.id}
disabled={llmDisabled && templateRequiresLlm(template)}
onSelect={() => onSelectTemplate(template.id)}
/>
))}
Expand All @@ -91,6 +102,16 @@ export const StartOptionDetail: FC<StartOptionDetailProps> = ({
{option.title}
</Text>
{content}
{hasDisabledTemplate ? (
<Banner status="warning" kind="inline">
Templates that generate data with a model are disabled — this workspace has no NVIDIA
Build inference provider.{' '}
<Link to={inferenceProvidersHref} className="underline">
Add the NVIDIA Build provider
</Link>{' '}
to enable them.
</Banner>
) : null}
</Stack>
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -10,18 +10,27 @@ import type { FC } from 'react';
* a leading icon badge above a title, description, and a use-case badge, rendered as a
* selectable `<button>`. Mirrors {@link StartOptionCard} so the two card rows read alike.
*/
export const TemplateCard: FC<TemplateCardProps> = ({ template, selected, onSelect }) => {
export const TemplateCard: FC<TemplateCardProps> = ({
template,
selected,
disabled = false,
onSelect,
}) => {
const Icon = template.icon;

const stateClasses = selected
? 'cursor-pointer border-[#76b900]'
: 'cursor-pointer border-base hover:-translate-y-0.5 hover:border-[#76b900] hover:bg-surface-hover hover:shadow-md';
const stateClasses = disabled
? 'cursor-not-allowed border-base opacity-50'
: selected
? 'cursor-pointer border-[#76b900]'
: 'cursor-pointer border-base hover:-translate-y-0.5 hover:border-[#76b900] hover:bg-surface-hover hover:shadow-md';

return (
<button
type="button"
onClick={onSelect}
disabled={disabled}
aria-pressed={selected}
title={disabled ? 'Requires the NVIDIA Build inference provider' : template.description}
className={`flex h-[200px] min-w-[260px] flex-1 flex-col items-start gap-3 overflow-hidden rounded-md border bg-surface-raised p-5 text-left transition focus-visible:border-strong focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#76b900] ${stateClasses}`}
>
<Flex
Expand Down
Original file line number Diff line number Diff line change
@@ -1,29 +1,66 @@
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import { useModelsListProviders } from '@nemo/sdk/generated/platform/api';
import { CreateFilesetStart } from '@studio/components/CreateFilesetStart';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { MemoryRouter } from 'react-router-dom';

vi.mock('@studio/hooks/useWorkspaceFromPath', () => ({
useWorkspaceFromPath: () => 'default',
}));

vi.mock('@nemo/sdk/generated/platform/api', () => ({
useModelsListProviders: vi.fn(),
}));

const mockUseModelsListProviders = vi.mocked(useModelsListProviders);

/** A provider list containing the NVIDIA Build (integrate.api.nvidia.com) endpoint. */
const withBuildProvider = () =>
({
data: { data: [{ host_url: 'https://integrate.api.nvidia.com/v1' }] },
isLoading: false,
}) as unknown as ReturnType<typeof useModelsListProviders>;

/** A provider list with only a non-build provider. */
const withoutBuildProvider = () =>
({
data: { data: [{ host_url: 'https://api.openai.com/v1' }] },
isLoading: false,
}) as unknown as ReturnType<typeof useModelsListProviders>;

const renderComponent = (onContinue = vi.fn()) =>
render(
<MemoryRouter>
<CreateFilesetStart onContinue={onContinue} />
</MemoryRouter>
);

describe('CreateFilesetStart', () => {
beforeEach(() => {
mockUseModelsListProviders.mockReturnValue(withBuildProvider());
});

it('renders all four start options', () => {
render(<CreateFilesetStart onContinue={vi.fn()} />);
renderComponent();

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(<CreateFilesetStart onContinue={vi.fn()} />);
renderComponent();

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(<CreateFilesetStart onContinue={onContinue} />);
renderComponent(onContinue);

await user.click(screen.getByText('Describe with AI'));

Expand All @@ -34,7 +71,7 @@ describe('CreateFilesetStart', () => {
it('selecting Build from scratch reveals Continue and invokes onContinue with "scratch"', async () => {
const user = userEvent.setup();
const onContinue = vi.fn();
render(<CreateFilesetStart onContinue={onContinue} />);
renderComponent(onContinue);

await user.click(screen.getByText('Build from scratch'));

Expand All @@ -48,7 +85,7 @@ describe('CreateFilesetStart', () => {

it('reveals template cards but no Continue until a template is chosen', async () => {
const user = userEvent.setup();
render(<CreateFilesetStart onContinue={vi.fn()} />);
renderComponent();

await user.click(screen.getByText('Start from a template'));

Expand All @@ -59,7 +96,7 @@ describe('CreateFilesetStart', () => {
it('choosing a template reveals Continue and invokes onContinue with the template id', async () => {
const user = userEvent.setup();
const onContinue = vi.fn();
render(<CreateFilesetStart onContinue={onContinue} />);
renderComponent(onContinue);

await user.click(screen.getByText('Start from a template'));
await user.click(screen.getByText('Instruction fine-tuning (SFT)'));
Expand All @@ -73,7 +110,7 @@ describe('CreateFilesetStart', () => {

it('switching options clears a prior template selection', async () => {
const user = userEvent.setup();
render(<CreateFilesetStart onContinue={vi.fn()} />);
renderComponent();

await user.click(screen.getByText('Start from a template'));
await user.click(screen.getByText('Instruction fine-tuning (SFT)'));
Expand All @@ -85,4 +122,47 @@ describe('CreateFilesetStart', () => {
// Template selection was reset when the option changed, so Continue is gone again.
expect(screen.queryByRole('button', { name: /continue/i })).not.toBeInTheDocument();
});

describe('when no NVIDIA Build inference provider is configured', () => {
beforeEach(() => {
mockUseModelsListProviders.mockReturnValue(withoutBuildProvider());
});

it('disables LLM-backed templates and shows a link to add the provider', async () => {
const user = userEvent.setup();
const onContinue = vi.fn();
renderComponent(onContinue);

await user.click(screen.getByText('Start from a template'));

// LLM template (SFT references a model) is disabled and cannot be selected.
const sftCard = screen.getByRole('button', { name: /Instruction fine-tuning/i });
expect(sftCard).toBeDisabled();
await user.click(sftCard);
expect(screen.queryByRole('button', { name: /continue/i })).not.toBeInTheDocument();

// Error label links to the inference-providers create view (build preset).
const link = screen.getByRole('link', { name: /Add the NVIDIA Build provider/i });
expect(link).toHaveAttribute(
'href',
expect.stringContaining('/inference-providers?create=true&preset=build')
);
});

it('keeps non-LLM templates enabled and selectable', async () => {
const user = userEvent.setup();
const onContinue = vi.fn();
renderComponent(onContinue);

await user.click(screen.getByText('Start from a template'));

const noLlmCard = screen.getByRole('button', { name: /Expression transforms/i });
expect(noLlmCard).toBeEnabled();

await user.click(noLlmCard);
const continueButton = screen.getByRole('button', { name: /continue/i });
await user.click(continueButton);
expect(onContinue).toHaveBeenCalledWith('template', 'expression-transforms');
});
});
});
Loading