Skip to content
Open
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
5 changes: 5 additions & 0 deletions services/studio/src/nmp/studio/env_mappings.py
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,11 @@ class EnvMapping:
config_path="studio.feature_flags.model_compare_enabled",
default="false",
),
EnvMapping(
marker="STUDIO_UI_VITE_FF_PROMPTS_ENABLED",
config_path="studio.feature_flags.prompts_enabled",
default="false",
),
EnvMapping(
marker="STUDIO_UI_VITE_FF_SAFE_SYNTHESIZER_ENABLED",
config_path="studio.feature_flags.safe_synthesizer_enabled",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import { BadgeStatus, badgeStatus } from '@nemo/common/src/components/StatusBadge/badgeStatus';
import { StatusBadge } from '@nemo/common/src/components/StatusBadge/index';
import * as customQueries from '@nemo/common/src/tests/customQueries';
import { getLucideIcon } from '@nemo/common/src/tests/lucideIconQueries';
import { queries, render, screen, within } from '@testing-library/react';
import { CircleCheck } from 'lucide-react';

Expand Down Expand Up @@ -47,9 +48,7 @@ describe('StatusBadge component', () => {
const status: BadgeStatus = 'in_progress';
render(<StatusBadge status={status} />, { queries: allQueries });

// Use querySelector with document to find SVG (escape hatch for SVG testing)
// eslint-disable-next-line testing-library/no-node-access
const icon = document.querySelector('.lucide-refresh-cw');
const icon = getLucideIcon('refresh-cw');
expect(icon).toBeInTheDocument();
expect(icon).toHaveAttribute('width', '12px');
expect(icon).toHaveAttribute('height', '12px');
Expand All @@ -59,8 +58,7 @@ describe('StatusBadge component', () => {
// Since all current statuses have icons, we'll verify the icon exists
render(<StatusBadge status="completed" />);

// eslint-disable-next-line testing-library/no-node-access
const icon = document.querySelector('.lucide-circle-check');
const icon = getLucideIcon('circle-check');
expect(icon).toBeInTheDocument();
});

Expand Down
8 changes: 8 additions & 0 deletions web/packages/common/src/tests/customQueries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,14 @@

import { queryHelpers, buildQueries, Matcher, MatcherOptions } from '@testing-library/react';

export {
findAllByLucideIcon,
findByLucideIcon,
getAllByLucideIcon,
getByLucideIcon,
queryByLucideIcon,
} from '@nemo/common/src/tests/lucideIconQueries';

// The queryAllByAttribute is a shortcut for attribute-based matchers
// You can also use document.querySelector or a combination of existing
// testing library utilities to find matching nodes for your query
Expand Down
56 changes: 56 additions & 0 deletions web/packages/common/src/tests/lucideIconQueries.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import { buildQueries, Matcher } from '@testing-library/react';

/** Lucide icons render as `<svg class="lucide lucide-{name}">`. Use the kebab-case suffix (e.g. `cog`, `refresh-cw`). */
const lucideIconSelector = (iconName: string) => `svg.lucide-${iconName}`;

/** Returns all matching Lucide SVGs under `container` (defaults to `document.body`). */
export const queryAllLucideIcons = (
iconName: string,
container: ParentNode = document.body
): SVGElement[] => {
return [...container.querySelectorAll(lucideIconSelector(iconName))] as SVGElement[];
};

const getMultipleError = (_container: Element | null, iconName: string) =>
`Found multiple lucide icons matching: ${iconName}`;
const getMissingError = (_container: Element | null, iconName: string) =>
`Unable to find lucide icon: ${iconName}`;

/** Finds a single Lucide icon by kebab-case name, optionally scoped to a container. */
export const getLucideIcon = (
iconName: string,
container: ParentNode = document.body
): SVGElement => {
const icons = queryAllLucideIcons(iconName, container);
if (icons.length === 0) {
throw new Error(getMissingError(null, iconName));
}
if (icons.length > 1) {
throw new Error(getMultipleError(null, iconName));
}
return icons[0];
};

/** Returns the first matching Lucide icon, or null if none. */
export const queryLucideIcon = (
iconName: string,
container: ParentNode = document.body
): SVGElement | null => queryAllLucideIcons(iconName, container)[0] ?? null;

const queryAllByLucideIcon = (container: HTMLElement, iconName: Matcher) => {
if (typeof iconName !== 'string') {
throw new TypeError(`Lucide icon name must be a string, received ${typeof iconName}`);
}
return queryAllLucideIcons(iconName, container) as unknown as HTMLElement[];
};

export const [
queryByLucideIcon,
getAllByLucideIcon,
getByLucideIcon,
findAllByLucideIcon,
findByLucideIcon,
] = buildQueries(queryAllByLucideIcon, getMultipleError, getMissingError);
1 change: 1 addition & 0 deletions web/packages/studio/env/.env.dev.local.sample
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ VITE_FF_INTAKE_ENABLED='true'
VITE_FF_JOBS_ENABLED='true'
VITE_FF_MEMBERS_ENABLED='preview'
VITE_FF_MODEL_COMPARE_ENABLED='true'
VITE_FF_PROMPTS_ENABLED='false'
Comment thread
steramae-nvidia marked this conversation as resolved.
VITE_FF_SAFE_SYNTHESIZER_ENABLED='false'
VITE_FF_SECRETS_ENABLED='true'
VITE_FF_SETTINGS_ENABLED='true'
1 change: 1 addition & 0 deletions web/packages/studio/env/.env.fastapi
Original file line number Diff line number Diff line change
Expand Up @@ -32,5 +32,6 @@ VITE_FF_INFERENCE_PROVIDER_ENABLED=STUDIO_UI_VITE_FF_INFERENCE_PROVIDER_ENABLED
VITE_FF_INTAKE_ENABLED=STUDIO_UI_VITE_FF_INTAKE_ENABLED
VITE_FF_MEMBERS_ENABLED=STUDIO_UI_VITE_FF_MEMBERS_ENABLED
VITE_FF_MODEL_COMPARE_ENABLED=STUDIO_UI_VITE_FF_MODEL_COMPARE_ENABLED
VITE_FF_PROMPTS_ENABLED=STUDIO_UI_VITE_FF_PROMPTS_ENABLED
VITE_FF_SAFE_SYNTHESIZER_ENABLED=STUDIO_UI_VITE_FF_SAFE_SYNTHESIZER_ENABLED
VITE_FF_SECRETS_ENABLED=STUDIO_UI_VITE_FF_SECRETS_ENABLED
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import { getLucideIcon } from '@nemo/common/src/tests/lucideIconQueries';
import { ROUTES } from '@studio/constants/routes';
import { workspace1 } from '@studio/mocks/entity-store/projects';
import { PageLayout } from '@studio/routes/PageLayout';
Expand Down Expand Up @@ -130,14 +131,12 @@ describe('NavigationDrawer', () => {

// Starts expanded
expect(accordionTrigger).toHaveAttribute('data-state', 'open');
// eslint-disable-next-line testing-library/no-node-access
expect(document.querySelector('.lucide-chevron-up')).toBeInTheDocument();
expect(getLucideIcon('chevron-up')).toBeInTheDocument();

// Clicking closes it (chevron-down icon visible)
await user.click(accordionTrigger as HTMLElement);
expect(accordionTrigger).toHaveAttribute('data-state', 'closed');
// eslint-disable-next-line testing-library/no-node-access
expect(document.querySelector('.lucide-chevron-down')).toBeInTheDocument();
expect(getLucideIcon('chevron-down')).toBeInTheDocument();
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import type {
import { Banner, Button, Text } from '@nvidia/foundations-react-core';
import { DeleteJobModal } from '@studio/components/dataViews/DataDesignerJobsDataView/DeleteJobModal';
import { QuickActionsMenuRoot } from '@studio/components/QuickActionsMenu/QuickActionsMenuRoot';
import { DataDesignerIconFc } from '@studio/constants/constants';
import { STATUS_FILTER_OPTIONS } from '@studio/constants/platformJobs';
import { useWorkspaceFromPath } from '@studio/hooks/useWorkspaceFromPath';
import { getDataDesignerJobDetailsRoute, getNewDataDesignerJobRoute } from '@studio/routes/utils';
Expand Down Expand Up @@ -248,6 +249,7 @@ export const DataDesignerJobsDataView: FC = () => {
/>
) : (
<TableEmptyState
icon={<DataDesignerIconFc className="size-16" />}
header="Data Designer Jobs"
emptyMessage="Create and manage data designer jobs to generate or transform datasets."
actions={
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,227 @@
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import {
ROW_ACTIONS_COLUMN_SIZE,
StudioDataView,
} from '@nemo/common/src/components/DataView/StudioDataView';
import { RelativeTime } from '@nemo/common/src/components/RelativeTime';
import { TableEmptyState } from '@nemo/common/src/components/TableEmptyState';
import { useStudioDataViewState } from '@nemo/common/src/hooks/useStudioDataViewState';
import { useToast } from '@nemo/common/src/providers/toast/useToast';
import { useModelsDeletePrompt, useModelsListPrompts } from '@nemo/sdk/generated/platform/api';
import type { Prompt } from '@nemo/sdk/generated/platform/schema';
import { Button, Stack, Text } from '@nvidia/foundations-react-core';
import { getErrorMessage } from '@studio/api/common/utils';
import { DeleteConfirmationModal } from '@studio/components/DeleteConfirmationModal';
import { ErrorPanel } from '@studio/components/ErrorPanel';
import { PromptIconFc } from '@studio/constants/constants';
import { keepPreviousData } from '@tanstack/react-query';
import { Trash } from 'lucide-react';
import { ComponentProps, FC, useCallback, useMemo, useState } from 'react';

export interface PromptsDataViewProps {
workspace: string;
emptyStateActions?: React.ReactNode;
attributes?: {
Stack?: React.ComponentProps<typeof Stack>;
};
}

type PromptWithId = Prompt & { id: string };

export const PromptsDataView: FC<PromptsDataViewProps> = ({
workspace,
emptyStateActions,
attributes,
}) => {
const toast = useToast();

const dataViewState = useStudioDataViewState({
defaultSort: { id: 'created_at', desc: true },
});

const [promptToDelete, setPromptToDelete] = useState<Prompt>();

const { data, refetch, isFetching, error } = useModelsListPrompts(
workspace,
{
page: dataViewState.pagination.state.pageIndex + 1,
page_size: dataViewState.pagination.state.pageSize,
},
{
query: {
placeholderData: keepPreviousData,
},
}
);

const deletePromptMutation = useModelsDeletePrompt();

const prompts = useMemo(() => data?.data ?? [], [data?.data]);
const pagination = data?.pagination;

const searchBar = dataViewState.searchBar.state;
const filteredPrompts = useMemo(() => {
if (!searchBar) return prompts;
return prompts.filter((prompt: Prompt) =>
prompt.name?.toLowerCase().includes(searchBar.toLowerCase())
);
}, [prompts, searchBar]);
Comment on lines +65 to +70

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# Inspect the component structure and the pagination/search flow around the flagged lines.
git ls-files 'web/packages/studio/src/components/dataViews/PromptsDataView/*' && \
wc -l web/packages/studio/src/components/dataViews/PromptsDataView/index.tsx && \
ast-grep outline web/packages/studio/src/components/dataViews/PromptsDataView/index.tsx --view expanded

Repository: NVIDIA-NeMo/nemo-platform

Length of output: 624


🏁 Script executed:

sed -n '1,260p' web/packages/studio/src/components/dataViews/PromptsDataView/index.tsx

Repository: NVIDIA-NeMo/nemo-platform

Length of output: 7190


🏁 Script executed:

# Find where prompts are fetched and whether search is server-side or client-side.
rg -n "searchBar|page|pagination|useQuery|useInfiniteQuery|offset|limit|cursor|total" web/packages/studio/src/components/dataViews/PromptsDataView/index.tsx

Repository: NVIDIA-NeMo/nemo-platform

Length of output: 620


🏁 Script executed:

# Inspect nearby code if the previous output is large.
sed -n '1,220p' web/packages/studio/src/components/dataViews/PromptsDataView/index.tsx

Repository: NVIDIA-NeMo/nemo-platform

Length of output: 7025


🏁 Script executed:

sed -n '1,140p' web/packages/studio/src/components/dataViews/PromptsDataView/index.tsx

Repository: NVIDIA-NeMo/nemo-platform

Length of output: 4557


🏁 Script executed:

sed -n '140,240p' web/packages/studio/src/components/dataViews/PromptsDataView/index.tsx

Repository: NVIDIA-NeMo/nemo-platform

Length of output: 2808


🏁 Script executed:

sed -n '1,140p' web/packages/studio/src/components/dataViews/PromptsDataView/index.tsx

Repository: NVIDIA-NeMo/nemo-platform

Length of output: 4557


🏁 Script executed:

sed -n '140,240p' web/packages/studio/src/components/dataViews/PromptsDataView/index.tsx

Repository: NVIDIA-NeMo/nemo-platform

Length of output: 2808


🏁 Script executed:

# Inspect how StudioDataView uses data, searchField, and totalCount.
ast-grep outline web/packages/common/src/components/DataView/StudioDataView.tsx --view expanded

Repository: NVIDIA-NeMo/nemo-platform

Length of output: 1120


🏁 Script executed:

fd 'StudioDataView.tsx' web packages . -a

Repository: NVIDIA-NeMo/nemo-platform

Length of output: 481


🏁 Script executed:

fd -a 'StudioDataView.tsx' web . | head

Repository: NVIDIA-NeMo/nemo-platform

Length of output: 481


🏁 Script executed:

fd -a 'StudioDataView.tsx' . | head -20

Repository: NVIDIA-NeMo/nemo-platform

Length of output: 322


🏁 Script executed:

fd -a 'useStudioDataViewState.ts' . | head -20

Repository: NVIDIA-NeMo/nemo-platform

Length of output: 163


🏁 Script executed:

fd -a 'StudioDataView.tsx' . | head -20

Repository: NVIDIA-NeMo/nemo-platform

Length of output: 322


🏁 Script executed:

fd -a 'useStudioDataViewState.ts' . | head -20

Repository: NVIDIA-NeMo/nemo-platform

Length of output: 163


🏁 Script executed:

fd -a 'StudioDataView.tsx' . | head -20

Repository: NVIDIA-NeMo/nemo-platform

Length of output: 322


🏁 Script executed:

fd -a 'useStudioDataViewState.ts' . | head -20

Repository: NVIDIA-NeMo/nemo-platform

Length of output: 163


Move search before pagination

Search only filters the fetched page, while totalCount still uses pagination?.total_results. That makes matches on other pages invisible and leaves the pager showing unfiltered totals.

🤖 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/dataViews/PromptsDataView/index.tsx`
around lines 65 - 70, The current filtering in PromptsDataView only applies to
the already paginated `prompts` array, so search results outside the fetched
page remain hidden while `totalCount` still reflects the unfiltered server
total. Update the `useMemo`/search flow in `PromptsDataView` so the search term
is applied before pagination or at the data source level, and make sure the
count used by the pager reflects the filtered result set instead of
`pagination?.total_results`.


const promptsWithId = useMemo<PromptWithId[]>(
() =>
filteredPrompts.map((prompt: Prompt) => ({
...prompt,
id: `${prompt.workspace}/${prompt.name}`,
})),
[filteredPrompts]
);

const handleDeletePrompt = async () => {
if (!promptToDelete) return false;

try {
await deletePromptMutation.mutateAsync({
workspace,
name: promptToDelete.name,
});
Comment on lines +81 to +88

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Guard delete payload when name is missing.

mutateAsync is called with name: promptToDelete.name without validating name; this can send an invalid delete request.

Proposed patch
 const handleDeletePrompt = async () => {
-  if (!promptToDelete) return false;
+  if (!promptToDelete?.name) {
+    toast.error('Prompt name is missing');
+    return false;
+  }

   try {
     await deletePromptMutation.mutateAsync({
       workspace,
       name: promptToDelete.name,
     });
📝 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
const handleDeletePrompt = async () => {
if (!promptToDelete) return false;
try {
await deletePromptMutation.mutateAsync({
workspace,
name: promptToDelete.name,
});
const handleDeletePrompt = async () => {
if (!promptToDelete?.name) {
toast.error('Prompt name is missing');
return false;
}
try {
await deletePromptMutation.mutateAsync({
workspace,
name: promptToDelete.name,
});
🤖 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/dataViews/PromptsDataView/index.tsx`
around lines 81 - 88, The delete flow in handleDeletePrompt currently passes
promptToDelete.name directly into deletePromptMutation.mutateAsync without
checking that name exists. Add a guard in handleDeletePrompt to return early
when promptToDelete.name is missing, and only build the delete payload after
validating the prompt object. Keep the fix localized to handleDeletePrompt and
the deletePromptMutation call so invalid delete requests are never sent.

refetch();
return true;
} catch {
toast.error('Failed to delete prompt');
return false;
}
};

const makeColumns: ComponentProps<typeof StudioDataView<PromptWithId>>['makeColumns'] =
useCallback(
({ accessor }, { rowActionsColumn }) => [
accessor('name', {
header: 'Name',
enableSorting: false,
size: 200,
}),
accessor('description', {
header: 'Description',
cell({ row }) {
return (
<Text className="truncate" title={row.original.description}>
{row.original.description || '-'}
</Text>
);
},
}),
accessor('tags', {
header: 'Tags',
enableSorting: false,
size: 180,
cell({ row }) {
const tags = row.original.tags;
if (!tags?.length) return <Text>-</Text>;
return (
<Text className="truncate" title={tags.join(', ')}>
{tags.join(', ')}
</Text>
);
},
}),
accessor('created_at', {
header: 'Created',
enableSorting: true,
size: 150,
cell({ row }) {
return row.original.created_at ? (
<RelativeTime datetime={row.original.created_at} />
) : (
<Text>-</Text>
);
},
}),
accessor('updated_at', {
header: 'Updated',
enableSorting: true,
size: 150,
cell({ row }) {
return row.original.updated_at ? (
<RelativeTime datetime={row.original.updated_at} />
) : (
<Text>-</Text>
);
},
}),
rowActionsColumn({
size: ROW_ACTIONS_COLUMN_SIZE,
enableResizing: false,
rowActions: (prompt: PromptWithId) => [
{
slotLeft: <Trash />,
children: 'Delete',
danger: true,
onSelect: () => setPromptToDelete(prompt),
},
],
}),
],
[]
);

const hasActiveFilters = !!searchBar;

return (
<Stack gap="density-2xl" {...attributes?.Stack}>
<StudioDataView
dataViewState={dataViewState}
searchField="name"
makeColumns={makeColumns}
attributes={{
DataViewSearchBar: {
placeholder: 'Search Prompts...',
},
DataViewRoot: {
data: promptsWithId,
totalCount: pagination?.total_results,
requestStatus: error ? 'error' : isFetching ? 'loading' : undefined,
},
DataViewTableContent: {
renderEmptyState: () =>
hasActiveFilters ? (
<TableEmptyState
header="No Results Found"
emptyMessage="No prompts match your search"
actions={
<Button kind="tertiary" onClick={dataViewState.resetFilters}>
Clear Search
</Button>
}
/>
) : (
<TableEmptyState
icon={<PromptIconFc className="size-16" />}
header="No Prompts Yet"
emptyMessage="Reusable prompt templates will appear here once created."
actions={emptyStateActions}
/>
),
renderErrorState: () => (
<ErrorPanel
errorMessage={getErrorMessage(error ?? new Error('Failed to fetch prompts'))}
/>
),
},
}}
/>

{promptToDelete && (
<DeleteConfirmationModal
open
simpleConfirm
onDelete={handleDeletePrompt}
title={`Delete: ${promptToDelete.name}`}
description="Are you sure you want to delete this prompt?"
onClose={() => setPromptToDelete(undefined)}
/>
)}
</Stack>
);
};
Loading