Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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 @@ -80,7 +80,9 @@ function renderCellContent(col: Record<string, unknown>, flatRow: FlatRow): Reac
}

vi.mock('@nemo/common/src/components/DataView/internal', () => ({
useInnerDataViewContext: () => ({ table: { getAllLeafColumns: () => [] } }),
useInnerDataViewContext: () => ({
table: { getAllLeafColumns: () => [], getSelectedRowModel: () => ({ flatRows: [] }) },
}),
Toolbar: ({
children,
slotBulkActions,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,10 @@ export function StudioDataViewToolbar<DataType = unknown>({
}: StudioDataViewToolbarProps<DataType>) {
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 (
<>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -108,6 +108,36 @@ export const LabelSideLeft: Story = {
},
};

export const HiddenIndicator: Story = {
render: function HiddenIndicatorStory() {
const [value, setValue] = useState<string>('dataset');
return (
<RadioGroupRoot
name="radio-card-hidden-indicator"
orientation="horizontal"
className="w-full"
value={value}
onValueChange={setValue}
>
<Flex gap="density-xl" className="w-full *:flex-1">
<RadioCard
value="dataset"
label="Dataset-driven"
description="Score a fixed dataset of prompts and expected answers."
showIndicator={false}
/>
<RadioCard
value="task"
label="Task-driven"
description="Score the agent against per-task metrics."
showIndicator={false}
/>
</Flex>
</RadioGroupRoot>
);
},
};

export const RichDescription: Story = {
render: function RichDescriptionStory() {
const [value, setValue] = useState<string>('option-1');
Expand Down
43 changes: 43 additions & 0 deletions web/packages/common/src/components/RadioCard/index.test.tsx
Original file line number Diff line number Diff line change
@@ -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 '.';
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

const renderGroup = (onValueChange: () => void, showIndicator?: boolean) => {
render(
<RadioGroupRoot name="test-group" value="a" onValueChange={onValueChange}>
<RadioCard value="a" label="Option A" description="First" showIndicator={showIndicator} />
<RadioCard value="b" label="Option B" description="Second" showIndicator={showIndicator} />
</RadioGroupRoot>
);
};

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');
});
});
24 changes: 17 additions & 7 deletions web/packages/common/src/components/RadioCard/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ export interface RadioCardProps extends Omit<ComponentProps<typeof RadioGroupIte
labelId?: string;
/** Which side of the card the radio input renders on. @default "right" */
labelSide?: 'left' | 'right';
/** Shows the radio indicator dot. When false the input stays in the DOM (still focusable and form-participating) but is visually hidden, so the card's selected border conveys state on its own. @default true */
showIndicator?: boolean;
/** When true, shows the card as selected. When used inside RadioGroupRoot, the group's value controls this; pass checked so the card reflects the active state (e.g. checked={value === 'this-option'}). */
checked?: boolean;
/** Whether the underlying radio input is disabled */
Expand Down Expand Up @@ -56,6 +58,7 @@ export const RadioCard: FC<RadioCardProps> = ({
value,
labelId,
labelSide = 'right',
showIndicator = true,
checked,
disabled,
className,
Expand All @@ -64,16 +67,20 @@ export const RadioCard: FC<RadioCardProps> = ({
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
Expand All @@ -91,9 +98,11 @@ export const RadioCard: FC<RadioCardProps> = ({
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',
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
nvPanelContentClass,
className
)}
Expand All @@ -102,6 +111,7 @@ export const RadioCard: FC<RadioCardProps> = ({
<RadioGroupInput
value={value}
disabled={disabled}
showIndicator={showIndicator}
aria-labelledby={id}
{...attributes?.RadioGroupInput}
/>
Expand Down
12 changes: 2 additions & 10 deletions web/packages/common/src/utils/entityName.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});

Expand Down
Loading