From 02fd3a7240b5089b08bb3ec5eb1249e5080a2661 Mon Sep 17 00:00:00 2001 From: Octavian Drulea Date: Mon, 3 Aug 2026 15:29:25 -0700 Subject: [PATCH 1/4] feat(studio): add CardSelect component; update DataView toolbar and tests - CardSelect: new common component for card-style option selection used by the eval config picker in SubmitEvaluationModal - StudioDataViewToolbar: adds support for row actions - entityName.test.ts: removes stale secrets SDK assertions after the secrets schema dropped named regex/max exports Co-Authored-By: Claude Sonnet 4.6 (1M context) Signed-off-by: Octavian Drulea --- .../src/components/CardSelect/index.tsx | 87 +++++++++++++++++++ .../DataView/StudioDataView.test.tsx | 4 +- .../DataView/StudioDataViewToolbar.tsx | 4 +- .../common/src/utils/entityName.test.ts | 12 +-- 4 files changed, 95 insertions(+), 12 deletions(-) create mode 100644 web/packages/common/src/components/CardSelect/index.tsx diff --git a/web/packages/common/src/components/CardSelect/index.tsx b/web/packages/common/src/components/CardSelect/index.tsx new file mode 100644 index 0000000000..294573a3f1 --- /dev/null +++ b/web/packages/common/src/components/CardSelect/index.tsx @@ -0,0 +1,87 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { Card, Flex, Text } from '@nvidia/foundations-react-core'; +import type { FC } from 'react'; + +export interface CardSelectOption { + /** Value reported to `onChange`; also the React key. */ + value: string; + /** Title text displayed prominently in the card. */ + title: string; + /** Optional description text displayed below the title. */ + description?: string; +} + +export interface CardSelectProps { + /** Card options to render. */ + options: CardSelectOption[]; + /** Currently selected option value. */ + value?: string; + /** Fired with the option's value when a card is chosen. */ + onChange: (value: string) => void; + /** Accessible name for the group of cards. */ + label?: string; + /** Direction to stack cards. Defaults to "row" (horizontal). */ + direction?: 'row' | 'col'; + className?: string; +} + +/** + * A row (or column) of selectable cards — a visual stand-in for a radio group when the + * choices deserve more explanation than a dropdown row affords. No radio dot is drawn; + * selection is conveyed by the card's own selected treatment. + * + * Each card renders `asChild` as a native ` + + ); + })} + +); diff --git a/web/packages/common/src/components/DataView/StudioDataView.test.tsx b/web/packages/common/src/components/DataView/StudioDataView.test.tsx index add894b9fc..92840efe97 100644 --- a/web/packages/common/src/components/DataView/StudioDataView.test.tsx +++ b/web/packages/common/src/components/DataView/StudioDataView.test.tsx @@ -80,7 +80,9 @@ function renderCellContent(col: Record, flatRow: FlatRow): Reac } vi.mock('@nemo/common/src/components/DataView/internal', () => ({ - useInnerDataViewContext: () => ({ table: { getAllLeafColumns: () => [] } }), + useInnerDataViewContext: () => ({ + table: { getAllLeafColumns: () => [], getSelectedRowModel: () => ({ flatRows: [] }) }, + }), Toolbar: ({ children, slotBulkActions, diff --git a/web/packages/common/src/components/DataView/StudioDataViewToolbar.tsx b/web/packages/common/src/components/DataView/StudioDataViewToolbar.tsx index 57dc84ba4d..518d413b2f 100644 --- a/web/packages/common/src/components/DataView/StudioDataViewToolbar.tsx +++ b/web/packages/common/src/components/DataView/StudioDataViewToolbar.tsx @@ -40,8 +40,10 @@ export function StudioDataViewToolbar({ }: StudioDataViewToolbarProps) { const { table } = useInnerDataViewContext(); const hasFilterableColumns = table.getAllLeafColumns().some((col) => col.getCanFilter()); + const hasSelectedRows = table.getSelectedRowModel().flatRows.length > 0; + const hostsBulkActions = Boolean(renderBulkActions) && hasSelectedRows; - if (!searchField && !hasFilterableColumns) return null; + if (!searchField && !hasFilterableColumns && !hostsBulkActions) return null; return ( <> diff --git a/web/packages/common/src/utils/entityName.test.ts b/web/packages/common/src/utils/entityName.test.ts index c94cc88777..e010f1cfd9 100644 --- a/web/packages/common/src/utils/entityName.test.ts +++ b/web/packages/common/src/utils/entityName.test.ts @@ -15,26 +15,18 @@ import { filesCreateFilesetBodyNameRegExp, } from '@nemo/sdk/generated/platform/zod/files'; import { modelsCreateProviderBodyNameRegExp } from '@nemo/sdk/generated/platform/zod/model-providers'; -import { - secretsCreateSecretBodyNameMax, - secretsCreateSecretBodyNameRegExp, -} from '@nemo/sdk/generated/platform/zod/secrets'; describe('generated schema agreement', () => { it.each([ ['entity', entitiesCreateEntityBodyNameRegExp], ['fileset', filesCreateFilesetBodyNameRegExp], - ['secret', secretsCreateSecretBodyNameRegExp], ['model provider', modelsCreateProviderBodyNameRegExp], ])('%s create schema uses the same name pattern', (_name, pattern) => { expect(pattern.source).toBe(ENTITY_NAME_REGEXP.source); }); - it.each([ - ['fileset', filesCreateFilesetBodyNameMax], - ['secret', secretsCreateSecretBodyNameMax], - ])('%s create schema agrees on the max length', (_name, max) => { - expect(max).toBe(ENTITY_NAME_MAX_LENGTH); + it('fileset create schema agrees on the max length', () => { + expect(filesCreateFilesetBodyNameMax).toBe(ENTITY_NAME_MAX_LENGTH); }); }); From 0aa0870813c36c9513c8523d9c6e50700c369b48 Mon Sep 17 00:00:00 2001 From: Octavian Drulea Date: Tue, 4 Aug 2026 11:02:13 -0700 Subject: [PATCH 2/4] fix(studio): drop CardSelect, update RadioCard to handle it Signed-off-by: Octavian Drulea --- .../src/components/CardSelect/index.tsx | 87 ------------------- .../RadioCard/RadioCard.stories.tsx | 32 ++++++- .../src/components/RadioCard/index.test.tsx | 43 +++++++++ .../common/src/components/RadioCard/index.tsx | 24 +++-- 4 files changed, 91 insertions(+), 95 deletions(-) delete mode 100644 web/packages/common/src/components/CardSelect/index.tsx create mode 100644 web/packages/common/src/components/RadioCard/index.test.tsx diff --git a/web/packages/common/src/components/CardSelect/index.tsx b/web/packages/common/src/components/CardSelect/index.tsx deleted file mode 100644 index 294573a3f1..0000000000 --- a/web/packages/common/src/components/CardSelect/index.tsx +++ /dev/null @@ -1,87 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -import { Card, Flex, Text } from '@nvidia/foundations-react-core'; -import type { FC } from 'react'; - -export interface CardSelectOption { - /** Value reported to `onChange`; also the React key. */ - value: string; - /** Title text displayed prominently in the card. */ - title: string; - /** Optional description text displayed below the title. */ - description?: string; -} - -export interface CardSelectProps { - /** Card options to render. */ - options: CardSelectOption[]; - /** Currently selected option value. */ - value?: string; - /** Fired with the option's value when a card is chosen. */ - onChange: (value: string) => void; - /** Accessible name for the group of cards. */ - label?: string; - /** Direction to stack cards. Defaults to "row" (horizontal). */ - direction?: 'row' | 'col'; - className?: string; -} - -/** - * A row (or column) of selectable cards — a visual stand-in for a radio group when the - * choices deserve more explanation than a dropdown row affords. No radio dot is drawn; - * selection is conveyed by the card's own selected treatment. - * - * Each card renders `asChild` as a native ` - - ); - })} - -); diff --git a/web/packages/common/src/components/RadioCard/RadioCard.stories.tsx b/web/packages/common/src/components/RadioCard/RadioCard.stories.tsx index f53d084892..75c9fa0b5c 100644 --- a/web/packages/common/src/components/RadioCard/RadioCard.stories.tsx +++ b/web/packages/common/src/components/RadioCard/RadioCard.stories.tsx @@ -10,7 +10,7 @@ * its affiliates is strictly prohibited. */ import { RadioCard } from '@nemo/common/src/components/RadioCard/index'; -import { RadioGroupRoot, Stack } from '@nvidia/foundations-react-core'; +import { Flex, RadioGroupRoot, Stack } from '@nvidia/foundations-react-core'; import type { Meta, StoryObj } from '@storybook/react'; import { Boxes } from 'lucide-react'; import { useState } from 'react'; @@ -108,6 +108,36 @@ export const LabelSideLeft: Story = { }, }; +export const HiddenIndicator: Story = { + render: function HiddenIndicatorStory() { + const [value, setValue] = useState('dataset'); + return ( + + + + + + + ); + }, +}; + export const RichDescription: Story = { render: function RichDescriptionStory() { const [value, setValue] = useState('option-1'); diff --git a/web/packages/common/src/components/RadioCard/index.test.tsx b/web/packages/common/src/components/RadioCard/index.test.tsx new file mode 100644 index 0000000000..c00f477f9b --- /dev/null +++ b/web/packages/common/src/components/RadioCard/index.test.tsx @@ -0,0 +1,43 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { RadioGroupRoot } from '@nvidia/foundations-react-core'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import { RadioCard } from '.'; + +const renderGroup = (onValueChange: () => void, showIndicator?: boolean) => { + render( + + + + + ); +}; + +describe('RadioCard', () => { + it('Renders a radio per card with its label and description by default', () => { + renderGroup(vi.fn()); + + expect(screen.getAllByRole('radio')).toHaveLength(2); + expect(screen.getByRole('radio', { name: 'Option A' })).toBeChecked(); + expect(screen.getByText('First')).toBeInTheDocument(); + }); + + // The hidden indicator must stay in the DOM: four studio suites locate these + // cards with getByRole('radio'). + it('Keeps the radio input queryable and operable when showIndicator is false', async () => { + const user = userEvent.setup(); + const onValueChange = vi.fn(); + renderGroup(onValueChange, false); + + const optionB = screen.getByRole('radio', { name: 'Option B' }); + expect(optionB).toBeInTheDocument(); + expect(optionB).not.toBeChecked(); + + await user.click(screen.getByText('Option B')); + + expect(onValueChange).toHaveBeenCalledWith('b'); + }); +}); diff --git a/web/packages/common/src/components/RadioCard/index.tsx b/web/packages/common/src/components/RadioCard/index.tsx index 096362cb8c..7dc5ef5310 100644 --- a/web/packages/common/src/components/RadioCard/index.tsx +++ b/web/packages/common/src/components/RadioCard/index.tsx @@ -27,6 +27,8 @@ export interface RadioCardProps extends Omit = ({ value, labelId, labelSide = 'right', + showIndicator = true, checked, disabled, className, @@ -64,16 +67,20 @@ export const RadioCard: FC = ({ const id = labelId ?? `${String(value).replace(/\s+/g, '-')}-label`; const hasDescription = isDefined(description); - const textStartClass = 'text-left ' + (labelSide === 'right' ? 'col-start-2' : 'col-start-1'); + const textStartClass = + 'text-left ' + (showIndicator && labelSide === 'right' ? 'col-start-2' : 'col-start-1'); const labelClass = `${textStartClass} row-start-1`; const descriptionClass = `${textStartClass} row-start-2`; - const colClass = - labelSide === 'right' + // The hidden input is absolutely positioned, so it leaves grid flow entirely. + const colClass = !showIndicator + ? '[&_.nv-card-content]:grid-cols-1' + : labelSide === 'right' ? '[&_.nv-card-content]:grid-cols-[auto_1fr]' : '[&_.nv-card-content]:grid-cols-[1fr_auto]'; - const inputClass = - labelSide === 'right' + const inputClass = !showIndicator + ? '' + : labelSide === 'right' ? '[&_.nv-radio-group-input]:col-start-1' : '[&_.nv-radio-group-input]:col-start-2'; const gapClass = hasDescription @@ -91,9 +98,11 @@ export const RadioCard: FC = ({ className={cn( 'cursor-pointer [&_*]:cursor-pointer', 'hover:bg-interaction-hover', - 'group-data-[state=checked]:border-interaction-selected', + // KUI's RadioGroupItem does not emit data-state/data-disabled, so key + // these off the real input via :has(). + 'has-[:checked]:border-interaction-selected', checked === true && 'border-interaction-selected', - 'group-data-[disabled]:pointer-events-none group-data-[disabled]:opacity-50', + 'has-[:disabled]:pointer-events-none has-[:disabled]:opacity-50', nvPanelContentClass, className )} @@ -102,6 +111,7 @@ export const RadioCard: FC = ({ From 123f10e7df80900ac30734c4cbc1017cf7119da8 Mon Sep 17 00:00:00 2001 From: Octavian Drulea Date: Tue, 4 Aug 2026 11:15:46 -0700 Subject: [PATCH 3/4] fix(studio): import fix, remove extra comments Signed-off-by: Octavian Drulea --- web/packages/common/src/components/RadioCard/index.test.tsx | 5 +---- web/packages/common/src/components/RadioCard/index.tsx | 2 +- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/web/packages/common/src/components/RadioCard/index.test.tsx b/web/packages/common/src/components/RadioCard/index.test.tsx index c00f477f9b..5e9d696b8a 100644 --- a/web/packages/common/src/components/RadioCard/index.test.tsx +++ b/web/packages/common/src/components/RadioCard/index.test.tsx @@ -1,12 +1,11 @@ // SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +import { RadioCard } from '@nemo/common/src/components/RadioCard/index'; import { RadioGroupRoot } from '@nvidia/foundations-react-core'; import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import { RadioCard } from '.'; - const renderGroup = (onValueChange: () => void, showIndicator?: boolean) => { render( @@ -25,8 +24,6 @@ describe('RadioCard', () => { expect(screen.getByText('First')).toBeInTheDocument(); }); - // The hidden indicator must stay in the DOM: four studio suites locate these - // cards with getByRole('radio'). it('Keeps the radio input queryable and operable when showIndicator is false', async () => { const user = userEvent.setup(); const onValueChange = vi.fn(); diff --git a/web/packages/common/src/components/RadioCard/index.tsx b/web/packages/common/src/components/RadioCard/index.tsx index 7dc5ef5310..c8af3ead4a 100644 --- a/web/packages/common/src/components/RadioCard/index.tsx +++ b/web/packages/common/src/components/RadioCard/index.tsx @@ -27,7 +27,7 @@ export interface RadioCardProps extends Omit