Skip to content
Merged
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 @@ -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
40 changes: 40 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,40 @@
// 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';

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();
});

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 is only visually hidden and the selected border conveys state. @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-[.nv-radio-group-input:checked]:border-interaction-selected',
checked === true && 'border-interaction-selected',
'group-data-[disabled]:pointer-events-none group-data-[disabled]:opacity-50',
'has-[.nv-radio-group-input:disabled]:pointer-events-none has-[.nv-radio-group-input:disabled]:opacity-50',
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