From 40ec540a7257756a35cd6c2b652c6f38d5952128 Mon Sep 17 00:00:00 2001 From: Nicholas Kolean Date: Thu, 13 Aug 2026 08:52:42 -0600 Subject: [PATCH 1/4] feat(studio): Improvements to the Guardrails DataView Signed-off-by: Nicholas Kolean --- .../GuardrailsDataView.test.tsx | 240 ++++++++++++++++-- .../GuardrailsDataView/guardrailUtils.test.ts | 49 ---- .../dataViews/GuardrailsDataView/index.tsx | 113 ++++++--- .../GuardrailsDataView/utils.test.ts | 114 +++++++++ .../{guardrailUtils.ts => utils.ts} | 19 ++ .../guardrails/GuardrailConfigTab/index.tsx | 2 +- .../guardrails/GuardrailsRoute/index.test.tsx | 81 +++++- .../guardrails/GuardrailsRoute/index.tsx | 30 +++ 8 files changed, 550 insertions(+), 98 deletions(-) delete mode 100644 web/packages/studio/src/components/dataViews/GuardrailsDataView/guardrailUtils.test.ts create mode 100644 web/packages/studio/src/components/dataViews/GuardrailsDataView/utils.test.ts rename web/packages/studio/src/components/dataViews/GuardrailsDataView/{guardrailUtils.ts => utils.ts} (56%) diff --git a/web/packages/studio/src/components/dataViews/GuardrailsDataView/GuardrailsDataView.test.tsx b/web/packages/studio/src/components/dataViews/GuardrailsDataView/GuardrailsDataView.test.tsx index 2581339968..e68251ae83 100644 --- a/web/packages/studio/src/components/dataViews/GuardrailsDataView/GuardrailsDataView.test.tsx +++ b/web/packages/studio/src/components/dataViews/GuardrailsDataView/GuardrailsDataView.test.tsx @@ -4,10 +4,11 @@ import type { GuardrailConfig } from '@nemo/sdk/generated/platform/schema'; import { GuardrailsDataView } from '@studio/components/dataViews/GuardrailsDataView'; import { PLATFORM_BASE_URL } from '@studio/constants/environment'; +import { mockGuardrailConfigs } from '@studio/mocks/handlers/guardrails'; import { server } from '@studio/mocks/node'; import { XL_SELECTOR_TIMEOUT } from '@studio/tests/util/constants'; import { TestProviders } from '@studio/tests/util/TestProviders'; -import { render, screen, waitFor } from '@testing-library/react'; +import { render, screen, waitFor, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { http, HttpResponse } from 'msw'; import { createMemoryRouter, RouterProvider } from 'react-router'; @@ -23,6 +24,7 @@ const renderComponent = ( onRowClick?: (config: GuardrailConfig) => void; onRequestDelete?: (config: GuardrailConfig) => void; onCreate?: () => void; + onRequestBulkDelete?: (configs: GuardrailConfig[]) => void; } = {} ) => { const router = createMemoryRouter([ @@ -34,6 +36,7 @@ const renderComponent = ( onRowClick={props.onRowClick ?? vi.fn()} onRequestDelete={props.onRequestDelete} onCreate={props.onCreate ?? vi.fn()} + onRequestBulkDelete={props.onRequestBulkDelete} /> ), }, @@ -46,6 +49,47 @@ const renderComponent = ( ); }; +/** Override the list handler to return data sorted by the `sort` URL param. */ +const mockSortedConfigs = (configs = mockGuardrailConfigs) => { + server.use( + http.get( + `${PLATFORM_BASE_URL}/apis/guardrails/v2/workspaces/:workspace/configs`, + ({ request }) => { + const url = new URL(request.url); + const sort = url.searchParams.get('sort') ?? '-created_at'; + const desc = sort.startsWith('-'); + const field = (desc ? sort.slice(1) : sort) as keyof GuardrailConfig; + const sorted = [...configs].sort((a, b) => { + const cmp = String(a[field] ?? '').localeCompare(String(b[field] ?? '')); + return desc ? -cmp : cmp; + }); + return HttpResponse.json({ + data: sorted, + pagination: { + page: 1, + page_size: 25, + current_page_size: sorted.length, + total_pages: 1, + total_results: sorted.length, + }, + }); + } + ) + ); +}; + +/** Wait for checkboxes to become enabled, then click one to select it. */ +const rowCheckboxAt = (index: number) => + screen.getAllByRole('checkbox', { name: /(De)?select row/i })[index]; + +const selectRow = async (user: ReturnType, index: number) => { + await waitFor(() => expect(rowCheckboxAt(index)).toBeEnabled()); + if (!(rowCheckboxAt(index) as HTMLInputElement).checked) { + await user.click(rowCheckboxAt(index)); + } + await waitFor(() => expect(rowCheckboxAt(index)).toBeChecked()); +}; + describe('GuardrailsDataView', () => { it('renders config names from the API', async () => { renderComponent(); @@ -53,26 +97,20 @@ describe('GuardrailsDataView', () => { expect(screen.getByText('toxicity-guard')).toBeInTheDocument(); }); - it('renders descriptions', async () => { - renderComponent(); - await findPiiFilterRow(); - expect(screen.getByText('Blocks PII in user inputs and outputs')).toBeInTheDocument(); - }); - - it('renders model count column', async () => { + it('renders the main model name column', async () => { renderComponent(); await findPiiFilterRow(); - // pii-filter has 2 models, toxicity-guard has 1 - const modelCells = screen.getAllByText('2'); - expect(modelCells.length).toBeGreaterThanOrEqual(1); + // pii-filter's main model is gpt-4; toxicity-guard also uses gpt-4 + expect(screen.getAllByText('gpt-4').length).toBeGreaterThanOrEqual(1); }); - it('renders rail count column', async () => { + it('renders a Flows column with Input/Output badges', async () => { renderComponent(); await findPiiFilterRow(); - // pii-filter has 4 rail flows (2 input + 2 output) - const railCells = screen.getAllByText('4'); - expect(railCells.length).toBeGreaterThanOrEqual(1); + expect(screen.getByRole('columnheader', { name: 'Flows' })).toBeInTheDocument(); + // Both configs have input and output flows configured + expect(screen.getAllByText('Input').length).toBeGreaterThanOrEqual(2); + expect(screen.getAllByText('Output').length).toBeGreaterThanOrEqual(2); }); it('calls onRowClick when a row is clicked', async () => { @@ -139,4 +177,176 @@ describe('GuardrailsDataView', () => { await screen.findByTestId('error-panel', undefined, { timeout: XL_SELECTOR_TIMEOUT }) ).toBeInTheDocument(); }); + + describe('sorting', () => { + it('defaults to sorting by created_at descending', async () => { + const seenSort: string[] = []; + server.use( + http.get( + `${PLATFORM_BASE_URL}/apis/guardrails/v2/workspaces/:workspace/configs`, + ({ request }) => { + seenSort.push(new URL(request.url).searchParams.get('sort') ?? ''); + return HttpResponse.json({ + data: [], + pagination: { + page: 1, + page_size: 25, + current_page_size: 0, + total_pages: 0, + total_results: 0, + }, + }); + } + ) + ); + renderComponent(); + await waitFor(() => expect(seenSort.length).toBeGreaterThan(0), { + timeout: XL_SELECTOR_TIMEOUT, + }); + expect(seenSort[0]).toBe('-created_at'); + }); + + it('sends sort=name when the Name column header is clicked', async () => { + const user = userEvent.setup(); + mockSortedConfigs(); + renderComponent(); + await findPiiFilterRow(); + + const nameHeader = screen.getByRole('columnheader', { name: 'Name' }); + await user.click(within(nameHeader).getByRole('button', { name: 'Name' })); + + await waitFor( + () => { + const cells = screen + .getAllByRole('cell') + .filter((c) => c.textContent === 'pii-filter' || c.textContent === 'toxicity-guard'); + // Alphabetical ascending: pii-filter < toxicity-guard + expect(cells[0].textContent).toBe('pii-filter'); + expect(cells[1].textContent).toBe('toxicity-guard'); + }, + { timeout: XL_SELECTOR_TIMEOUT } + ); + }); + + it('sends sort=updated_at when the Updated column header is clicked', async () => { + const user = userEvent.setup(); + const seenSorts: string[] = []; + server.use( + http.get( + `${PLATFORM_BASE_URL}/apis/guardrails/v2/workspaces/:workspace/configs`, + ({ request }) => { + seenSorts.push(new URL(request.url).searchParams.get('sort') ?? ''); + return HttpResponse.json({ + data: mockGuardrailConfigs, + pagination: { + page: 1, + page_size: 25, + current_page_size: 2, + total_pages: 1, + total_results: 2, + }, + }); + } + ) + ); + renderComponent(); + await findPiiFilterRow(); + + const updatedHeader = screen.getByRole('columnheader', { name: 'Updated' }); + await user.click(within(updatedHeader).getByRole('button', { name: 'Updated' })); + + await waitFor( + () => expect(seenSorts.some((s) => s === 'updated_at' || s === '-updated_at')).toBe(true), + { timeout: XL_SELECTOR_TIMEOUT } + ); + }); + }); + + describe('filter panel', () => { + it('has a filter toggle button', async () => { + renderComponent(); + await findPiiFilterRow(); + expect(screen.getByTestId('open-filters-button')).toBeInTheDocument(); + }); + + it('shows Updated At and Created At date range filters in the panel', async () => { + const user = userEvent.setup(); + renderComponent(); + await findPiiFilterRow(); + + await user.click(screen.getByTestId('open-filters-button')); + + expect( + await screen.findByTestId('column-filter-updated_at', undefined, { + timeout: XL_SELECTOR_TIMEOUT, + }) + ).toBeInTheDocument(); + expect(screen.getByTestId('column-filter-created_at')).toBeInTheDocument(); + }); + + it('shows "No Results Found" when a search matches nothing', async () => { + const user = userEvent.setup(); + server.use( + http.get(`${PLATFORM_BASE_URL}/apis/guardrails/v2/workspaces/:workspace/configs`, () => + HttpResponse.json({ + data: [], + pagination: { + page: 1, + page_size: 25, + current_page_size: 0, + total_pages: 0, + total_results: 0, + }, + }) + ) + ); + renderComponent(); + await user.type( + await screen.findByPlaceholderText('Search Guardrail Configs...', undefined, { + timeout: XL_SELECTOR_TIMEOUT, + }), + 'no-such-config' + ); + expect( + await screen.findByText('No Results Found', undefined, { timeout: XL_SELECTOR_TIMEOUT }) + ).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /Clear Filters/i })).toBeInTheDocument(); + }); + }); + + describe('bulk delete', () => { + it('calls onRequestBulkDelete with the selected configs when Delete is clicked', async () => { + const user = userEvent.setup(); + const onRequestBulkDelete = vi.fn(); + renderComponent({ onRequestBulkDelete }); + await findPiiFilterRow(); + + await selectRow(user, 0); + await selectRow(user, 1); + + await user.click(screen.getByRole('button', { name: 'Delete selected guardrails' })); + + expect(onRequestBulkDelete).toHaveBeenCalledWith( + expect.arrayContaining([ + expect.objectContaining({ name: 'pii-filter' }), + expect.objectContaining({ name: 'toxicity-guard' }), + ]) + ); + }); + + it('clears row selection after Delete is clicked', async () => { + const user = userEvent.setup(); + renderComponent({ onRequestBulkDelete: vi.fn() }); + await findPiiFilterRow(); + + await selectRow(user, 0); + + await user.click(screen.getByRole('button', { name: 'Delete selected guardrails' })); + + await waitFor(() => { + const checkboxes = screen.queryAllByRole('checkbox', { name: /(De)?select row/i }); + checkboxes.forEach((cb) => expect(cb).not.toBeChecked()); + }); + }); + }); }); diff --git a/web/packages/studio/src/components/dataViews/GuardrailsDataView/guardrailUtils.test.ts b/web/packages/studio/src/components/dataViews/GuardrailsDataView/guardrailUtils.test.ts deleted file mode 100644 index 45f229bc77..0000000000 --- a/web/packages/studio/src/components/dataViews/GuardrailsDataView/guardrailUtils.test.ts +++ /dev/null @@ -1,49 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -import type { RailsConfig } from '@nemo/sdk/generated/platform/schema'; -import { countRails } from '@studio/components/dataViews/GuardrailsDataView/guardrailUtils'; - -describe('countRails', () => { - it('returns 0 for undefined data', () => { - expect(countRails(undefined)).toBe(0); - }); - - it('returns 0 when data has no rails field', () => { - expect(countRails({})).toBe(0); - }); - - it('returns 0 when rails object is present but empty', () => { - const data: RailsConfig = { rails: {} }; - expect(countRails(data)).toBe(0); - }); - - it('counts input flows', () => { - const data: RailsConfig = { - rails: { input: { flows: ['check pii', 'check toxicity'] } }, - }; - expect(countRails(data)).toBe(2); - }); - - it('sums flows across input, output, and retrieval', () => { - const data: RailsConfig = { - rails: { - input: { flows: ['a', 'b'] }, - output: { flows: ['c'] }, - retrieval: { flows: ['d', 'e', 'f'] }, - }, - }; - expect(countRails(data)).toBe(6); - }); - - it('handles partial rails (some sections undefined) without throwing', () => { - const data: RailsConfig = { - rails: { - input: { flows: ['a'] }, - output: undefined, - retrieval: {}, - }, - }; - expect(countRails(data)).toBe(1); - }); -}); diff --git a/web/packages/studio/src/components/dataViews/GuardrailsDataView/index.tsx b/web/packages/studio/src/components/dataViews/GuardrailsDataView/index.tsx index b0535b2c88..45f595ba36 100644 --- a/web/packages/studio/src/components/dataViews/GuardrailsDataView/index.tsx +++ b/web/packages/studio/src/components/dataViews/GuardrailsDataView/index.tsx @@ -2,6 +2,8 @@ // SPDX-License-Identifier: Apache-2.0 import { getErrorMessage } from '@nemo/common/src/api/common/utils'; +import { withOperators } from '@nemo/common/src/api/filterOperators'; +import { dateTimeFilter } from '@nemo/common/src/components/DataView/dateTimeFilter'; import { ROW_ACTIONS_COLUMN_SIZE, StudioDataView, @@ -10,13 +12,18 @@ import { EntityEmptyState } from '@nemo/common/src/components/EntityEmptyState'; import { ErrorPanel } from '@nemo/common/src/components/ErrorPanel'; import { RelativeTime } from '@nemo/common/src/components/RelativeTime'; import { useStudioDataViewState } from '@nemo/common/src/hooks/useStudioDataViewState'; +import { getSortParam } from '@nemo/common/src/utils/query'; import { useGuardrailsListGuardrailConfigs } from '@nemo/sdk/generated/platform/api'; import type { GuardrailConfig, + GuardrailConfigFilter, GuardrailsListGuardrailConfigsParams, } from '@nemo/sdk/generated/platform/schema'; -import { Text } from '@nvidia/foundations-react-core'; -import { countRails } from '@studio/components/dataViews/GuardrailsDataView/guardrailUtils'; +import { Badge, Button, Flex, Text } from '@nvidia/foundations-react-core'; +import { + getMainModelName, + getRailCounts, +} from '@studio/components/dataViews/GuardrailsDataView/utils'; import { keepPreviousData } from '@tanstack/react-query'; import { Copy, Trash } from 'lucide-react'; import { type ComponentProps, type FC, useCallback } from 'react'; @@ -28,6 +35,7 @@ export interface GuardrailsDataViewProps { onRequestDelete?: (config: GuardrailConfig) => void; /** Opens the create-guardrail flow from the first-use empty state. */ onCreate?: () => void; + onRequestBulkDelete?: (configs: GuardrailConfig[]) => void; } export const GuardrailsDataView: FC = ({ @@ -36,25 +44,29 @@ export const GuardrailsDataView: FC = ({ onRequestDuplicate, onRequestDelete, onCreate, + onRequestBulkDelete, }) => { const dataViewState = useStudioDataViewState({ defaultSort: [{ id: 'created_at', desc: true }], + columnVisibility: { created_at: false }, }); - const sortState = dataViewState.sorting.state[0]; - const sortParam = ( - sortState ? `${sortState.desc ? '-' : ''}${sortState.id}` : 'created_at' - ) as GuardrailsListGuardrailConfigsParams['sort']; - const { data, isFetching, error } = useGuardrailsListGuardrailConfigs( workspace, { page: dataViewState.pagination.state.pageIndex + 1, page_size: dataViewState.pagination.state.pageSize, - sort: sortParam, - ...(dataViewState.debouncedSearchBar - ? { filter: { name: { $like: dataViewState.debouncedSearchBar } } } - : {}), + sort: getSortParam( + dataViewState.sorting.state + ) as GuardrailsListGuardrailConfigsParams['sort'], + filter: { + ...((dataViewState.apiFilter.filter ?? {}) as GuardrailConfigFilter), + ...(dataViewState.apiFilter.searchText + ? withOperators({ + name: { $like: dataViewState.apiFilter.searchText }, + }) + : {}), + }, }, { query: { placeholderData: keepPreviousData }, @@ -65,48 +77,58 @@ export const GuardrailsDataView: FC = ({ const makeColumns: ComponentProps>['makeColumns'] = useCallback( - ({ accessor }, { rowActionsColumn }) => [ + ({ accessor }, { rowSelectionColumn, rowActionsColumn }) => [ + rowSelectionColumn(), accessor('name', { header: 'Name', - enableSorting: false, + enableSorting: true, size: 180, cell({ row }) { return {row.original.name}; }, }), - accessor('description', { - header: 'Description', + accessor('data', { + id: 'models', + header: 'Main Model', enableSorting: false, cell({ row }) { + const name = getMainModelName(row.original.data); return ( - - {row.original.description ?? '—'} + + {name ?? ''} ); }, }), accessor('data', { - id: 'models', - header: 'Models', - enableSorting: false, - size: 80, - cell({ row }) { - return {row.original.data?.models?.length ?? 0}; - }, - }), - accessor('data', { - id: 'rails', - header: 'Rails', + id: 'flows', + header: 'Flows', enableSorting: false, - size: 80, + size: 140, cell({ row }) { - return {countRails(row.original.data)}; + const { input, output } = getRailCounts(row.original.data); + return ( + + {input > 0 && ( + + Input + + )} + {output > 0 && ( + + Output + + )} + + ); }, }), accessor('updated_at', { header: 'Updated', - enableSorting: false, - size: 140, + enableSorting: true, + meta: { + filter: dateTimeFilter('Updated At'), + }, cell({ row }) { return row.original.updated_at ? ( @@ -115,6 +137,21 @@ export const GuardrailsDataView: FC = ({ ); }, }), + accessor('created_at', { + id: 'created_at', + header: 'Created', + enableSorting: true, + meta: { + filter: dateTimeFilter('Created At'), + }, + cell({ row }) { + return row.original.created_at ? ( + + ) : ( + + ); + }, + }), rowActionsColumn({ size: ROW_ACTIONS_COLUMN_SIZE, enableResizing: false, @@ -141,6 +178,18 @@ export const GuardrailsDataView: FC = ({ dataViewState={dataViewState} searchField="name" makeColumns={makeColumns} + renderBulkActions={({ selectedRows, table }) => ( + + )} onRowClick={(row: GuardrailConfig) => onRowClick(row)} attributes={{ DataViewSearchBar: { placeholder: 'Search Guardrail Configs...' }, diff --git a/web/packages/studio/src/components/dataViews/GuardrailsDataView/utils.test.ts b/web/packages/studio/src/components/dataViews/GuardrailsDataView/utils.test.ts new file mode 100644 index 0000000000..c13aaea26f --- /dev/null +++ b/web/packages/studio/src/components/dataViews/GuardrailsDataView/utils.test.ts @@ -0,0 +1,114 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { RailsConfig } from '@nemo/sdk/generated/platform/schema'; +import { + countRails, + getMainModelName, + getRailCounts, +} from '@studio/components/dataViews/GuardrailsDataView/utils'; + +describe('countRails', () => { + it('returns 0 for undefined data', () => { + expect(countRails(undefined)).toBe(0); + }); + + it('returns 0 when data has no rails field', () => { + expect(countRails({})).toBe(0); + }); + + it('returns 0 when rails object is present but empty', () => { + const data: RailsConfig = { rails: {} }; + expect(countRails(data)).toBe(0); + }); + + it('counts input flows', () => { + const data: RailsConfig = { + rails: { input: { flows: ['check pii', 'check toxicity'] } }, + }; + expect(countRails(data)).toBe(2); + }); + + it('sums flows across input, output, and retrieval', () => { + const data: RailsConfig = { + rails: { + input: { flows: ['a', 'b'] }, + output: { flows: ['c'] }, + retrieval: { flows: ['d', 'e', 'f'] }, + }, + }; + expect(countRails(data)).toBe(6); + }); + + it('handles partial rails (some sections undefined) without throwing', () => { + const data: RailsConfig = { + rails: { + input: { flows: ['a'] }, + output: undefined, + retrieval: {}, + }, + }; + expect(countRails(data)).toBe(1); + }); +}); + +describe('getMainModelName', () => { + it('returns undefined for undefined data', () => { + expect(getMainModelName(undefined)).toBeUndefined(); + }); + + it('returns undefined when models array is absent', () => { + expect(getMainModelName({})).toBeUndefined(); + }); + + it('returns undefined when no model has type "main"', () => { + const data: RailsConfig = { + models: [{ type: 'embeddings', engine: 'openai', model: 'text-embedding-ada-002' }], + }; + expect(getMainModelName(data)).toBeUndefined(); + }); + + it('returns the model name of the main model', () => { + const data: RailsConfig = { + models: [ + { type: 'embeddings', engine: 'openai', model: 'text-embedding-ada-002' }, + { type: 'main', engine: 'openai', model: 'gpt-4' }, + ], + }; + expect(getMainModelName(data)).toBe('gpt-4'); + }); + + it('returns undefined when main model entry has no model field', () => { + const data: RailsConfig = { + models: [{ type: 'main', engine: 'openai' }], + }; + expect(getMainModelName(data)).toBeUndefined(); + }); +}); + +describe('getRailCounts', () => { + it('returns zeros for undefined data', () => { + expect(getRailCounts(undefined)).toEqual({ input: 0, output: 0 }); + }); + + it('returns zeros when data has no rails', () => { + expect(getRailCounts({})).toEqual({ input: 0, output: 0 }); + }); + + it('counts input and output flows independently', () => { + const data: RailsConfig = { + rails: { + input: { flows: ['check pii', 'check toxicity'] }, + output: { flows: ['mask pii output'] }, + }, + }; + expect(getRailCounts(data)).toEqual({ input: 2, output: 1 }); + }); + + it('returns zero for a side that has no flows', () => { + const data: RailsConfig = { + rails: { input: { flows: ['a'] } }, + }; + expect(getRailCounts(data)).toEqual({ input: 1, output: 0 }); + }); +}); diff --git a/web/packages/studio/src/components/dataViews/GuardrailsDataView/guardrailUtils.ts b/web/packages/studio/src/components/dataViews/GuardrailsDataView/utils.ts similarity index 56% rename from web/packages/studio/src/components/dataViews/GuardrailsDataView/guardrailUtils.ts rename to web/packages/studio/src/components/dataViews/GuardrailsDataView/utils.ts index 88ae7282b7..2807912ebe 100644 --- a/web/packages/studio/src/components/dataViews/GuardrailsDataView/guardrailUtils.ts +++ b/web/packages/studio/src/components/dataViews/GuardrailsDataView/utils.ts @@ -19,3 +19,22 @@ export function countRails(data?: RailsConfig): number { (rails.retrieval?.flows?.length ?? 0) ); } + +/** Return the `model` field of the first model entry with type "main", or undefined. */ +export function getMainModelName(data?: RailsConfig): string | undefined { + return data?.models?.find((m) => m.type === 'main')?.model; +} + +export interface RailCounts { + input: number; + output: number; +} + +/** Return the number of configured input and output rail flows. */ +export function getRailCounts(data?: RailsConfig): RailCounts { + const rails = data?.rails; + return { + input: rails?.input?.flows?.length ?? 0, + output: rails?.output?.flows?.length ?? 0, + }; +} diff --git a/web/packages/studio/src/routes/guardrails/GuardrailConfigTab/index.tsx b/web/packages/studio/src/routes/guardrails/GuardrailConfigTab/index.tsx index d4b6b96382..4911fd88ef 100644 --- a/web/packages/studio/src/routes/guardrails/GuardrailConfigTab/index.tsx +++ b/web/packages/studio/src/routes/guardrails/GuardrailConfigTab/index.tsx @@ -4,7 +4,7 @@ import { KVPair } from '@nemo/common/src/components/KVPair'; import { RelativeTime } from '@nemo/common/src/components/RelativeTime'; import { Badge, Flex, Panel, Stack, Text } from '@nvidia/foundations-react-core'; -import { countRails } from '@studio/components/dataViews/GuardrailsDataView/guardrailUtils'; +import { countRails } from '@studio/components/dataViews/GuardrailsDataView/utils'; import { BehaviorSection } from '@studio/routes/guardrails/GuardrailConfigTab/BehaviorSection'; import { listConfiguredDetectors } from '@studio/routes/guardrails/GuardrailConfigTab/detectors'; import { DetectorsSection } from '@studio/routes/guardrails/GuardrailConfigTab/DetectorsSection'; diff --git a/web/packages/studio/src/routes/guardrails/GuardrailsRoute/index.test.tsx b/web/packages/studio/src/routes/guardrails/GuardrailsRoute/index.test.tsx index c6e56f608f..c1bef6be86 100644 --- a/web/packages/studio/src/routes/guardrails/GuardrailsRoute/index.test.tsx +++ b/web/packages/studio/src/routes/guardrails/GuardrailsRoute/index.test.tsx @@ -4,9 +4,13 @@ import { ROUTES } from '@studio/constants/routes'; import { GuardrailsRoute } from '@studio/routes/guardrails/GuardrailsRoute'; import { getGuardrailDetailRoute, getGuardrailsRoute } from '@studio/routes/utils'; +import { PLATFORM_BASE_URL } from '@studio/constants/environment'; +import { server } from '@studio/mocks/node'; +import { resetGuardrailMocks } from '@studio/mocks/handlers/guardrails'; import { XL_SELECTOR_TIMEOUT } from '@studio/tests/util/constants'; -import { renderRoute, screen } from '@studio/tests/util/render'; +import { renderRoute, screen, waitFor, within } from '@studio/tests/util/render'; import userEvent from '@testing-library/user-event'; +import { http, HttpResponse } from 'msw'; import { useLocation } from 'react-router'; const WORKSPACE = 'default'; @@ -31,7 +35,22 @@ const renderList = () => ], }); +const rowCheckboxAt = (index: number) => + screen.getAllByRole('checkbox', { name: /(De)?select row/i })[index]; + +const selectRow = async (user: ReturnType, index: number) => { + await waitFor(() => expect(rowCheckboxAt(index)).toBeEnabled()); + if (!(rowCheckboxAt(index) as HTMLInputElement).checked) { + await user.click(rowCheckboxAt(index)); + } + await waitFor(() => expect(rowCheckboxAt(index)).toBeChecked()); +}; + describe('GuardrailsRoute', () => { + beforeEach(() => { + resetGuardrailMocks(); + }); + it('navigates to the detail route when a row is clicked', async () => { const user = userEvent.setup(); renderList(); @@ -43,4 +62,64 @@ describe('GuardrailsRoute', () => { getGuardrailDetailRoute(WORKSPACE, 'pii-filter') ); }); + + describe('bulk delete', () => { + it('shows a confirmation modal with the selected count when bulk Delete is clicked', async () => { + const user = userEvent.setup(); + renderList(); + + await screen.findByText('pii-filter', undefined, { timeout: XL_SELECTOR_TIMEOUT }); + await selectRow(user, 0); + await selectRow(user, 1); + + await user.click(screen.getByRole('button', { name: 'Delete selected guardrails' })); + + const dialog = await screen.findByRole('dialog'); + expect(within(dialog).getByText(/Delete 2 guardrail configs\?/i)).toBeInTheDocument(); + }); + + it('calls DELETE for each selected config and closes the modal on confirm', async () => { + const deletedNames: string[] = []; + server.use( + http.delete( + `${PLATFORM_BASE_URL}/apis/guardrails/v2/workspaces/:workspace/configs/:name`, + ({ params }) => { + deletedNames.push(String(params.name)); + return new HttpResponse(null, { status: 200 }); + } + ) + ); + + const user = userEvent.setup(); + renderList(); + + await screen.findByText('pii-filter', undefined, { timeout: XL_SELECTOR_TIMEOUT }); + await selectRow(user, 0); + await selectRow(user, 1); + + await user.click(screen.getByRole('button', { name: 'Delete selected guardrails' })); + const dialog = await screen.findByRole('dialog'); + await user.click(within(dialog).getByRole('button', { name: 'Delete' })); + + await waitFor(() => + expect([...deletedNames].sort()).toEqual(['pii-filter', 'toxicity-guard']) + ); + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument(), { + timeout: XL_SELECTOR_TIMEOUT, + }); + }); + + it('shows a singular title when only one config is selected', async () => { + const user = userEvent.setup(); + renderList(); + + await screen.findByText('pii-filter', undefined, { timeout: XL_SELECTOR_TIMEOUT }); + await selectRow(user, 0); + + await user.click(screen.getByRole('button', { name: 'Delete selected guardrails' })); + + const dialog = await screen.findByRole('dialog'); + expect(within(dialog).getByText(/Delete 1 guardrail config\?/i)).toBeInTheDocument(); + }); + }); }); diff --git a/web/packages/studio/src/routes/guardrails/GuardrailsRoute/index.tsx b/web/packages/studio/src/routes/guardrails/GuardrailsRoute/index.tsx index 078d81db61..47922706bd 100644 --- a/web/packages/studio/src/routes/guardrails/GuardrailsRoute/index.tsx +++ b/web/packages/studio/src/routes/guardrails/GuardrailsRoute/index.tsx @@ -35,6 +35,7 @@ export const GuardrailsRoute: FC = () => { const [isCreateOpen, setIsCreateOpen] = useState(false); const [configToDuplicate, setConfigToDuplicate] = useState(null); const [configToDelete, setConfigToDelete] = useState(null); + const [configsToDelete, setConfigsToDelete] = useState([]); const { mutateAsync: deleteConfig } = useGuardrailsDeleteConfig(); @@ -56,6 +57,21 @@ export const GuardrailsRoute: FC = () => { } }, [configToDelete, deleteConfig, queryClient, workspace]); + const handleBulkDelete = useCallback(async (): Promise => { + if (!configsToDelete.length) return false; + try { + await Promise.all( + configsToDelete.filter((c) => c.name).map((c) => deleteConfig({ workspace, name: c.name! })) + ); + await queryClient.invalidateQueries({ + queryKey: [`/apis/guardrails/v2/workspaces/${workspace}/configs`], + }); + return true; + } catch { + return false; + } + }, [configsToDelete, deleteConfig, queryClient, workspace]); + return ( @@ -82,6 +98,7 @@ export const GuardrailsRoute: FC = () => { onRequestDuplicate={setConfigToDuplicate} onRequestDelete={setConfigToDelete} onCreate={() => setIsCreateOpen(true)} + onRequestBulkDelete={setConfigsToDelete} /> @@ -106,6 +123,19 @@ export const GuardrailsRoute: FC = () => { onClose={() => setConfigToDelete(null)} /> ) : null} + + {configsToDelete.length > 0 ? ( + setConfigsToDelete([])} + /> + ) : null} ); }; From aa63921b5bdaa976b820aa240c995756efaae510 Mon Sep 17 00:00:00 2001 From: Nicholas Kolean Date: Thu, 13 Aug 2026 12:21:21 -0600 Subject: [PATCH 2/4] import order fixes Signed-off-by: Nicholas Kolean --- .../src/routes/guardrails/GuardrailsRoute/index.test.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/web/packages/studio/src/routes/guardrails/GuardrailsRoute/index.test.tsx b/web/packages/studio/src/routes/guardrails/GuardrailsRoute/index.test.tsx index c1bef6be86..0e4e4ca068 100644 --- a/web/packages/studio/src/routes/guardrails/GuardrailsRoute/index.test.tsx +++ b/web/packages/studio/src/routes/guardrails/GuardrailsRoute/index.test.tsx @@ -1,12 +1,12 @@ // SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +import { PLATFORM_BASE_URL } from '@studio/constants/environment'; import { ROUTES } from '@studio/constants/routes'; +import { resetGuardrailMocks } from '@studio/mocks/handlers/guardrails'; +import { server } from '@studio/mocks/node'; import { GuardrailsRoute } from '@studio/routes/guardrails/GuardrailsRoute'; import { getGuardrailDetailRoute, getGuardrailsRoute } from '@studio/routes/utils'; -import { PLATFORM_BASE_URL } from '@studio/constants/environment'; -import { server } from '@studio/mocks/node'; -import { resetGuardrailMocks } from '@studio/mocks/handlers/guardrails'; import { XL_SELECTOR_TIMEOUT } from '@studio/tests/util/constants'; import { renderRoute, screen, waitFor, within } from '@studio/tests/util/render'; import userEvent from '@testing-library/user-event'; From 478fbfce1c8126ba9ab9fe109a8d6c9e8774a5cf Mon Sep 17 00:00:00 2001 From: Nicholas Kolean Date: Thu, 13 Aug 2026 14:52:09 -0600 Subject: [PATCH 3/4] comment update Signed-off-by: Nicholas Kolean --- .../routes/guardrails/GuardrailsRoute/index.tsx | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/web/packages/studio/src/routes/guardrails/GuardrailsRoute/index.tsx b/web/packages/studio/src/routes/guardrails/GuardrailsRoute/index.tsx index 47922706bd..0889dde970 100644 --- a/web/packages/studio/src/routes/guardrails/GuardrailsRoute/index.tsx +++ b/web/packages/studio/src/routes/guardrails/GuardrailsRoute/index.tsx @@ -59,17 +59,19 @@ export const GuardrailsRoute: FC = () => { const handleBulkDelete = useCallback(async (): Promise => { if (!configsToDelete.length) return false; - try { - await Promise.all( - configsToDelete.filter((c) => c.name).map((c) => deleteConfig({ workspace, name: c.name! })) - ); + if (configsToDelete.some((c) => !c.name)) return false; + + const results = await Promise.allSettled( + configsToDelete.map((c) => deleteConfig({ workspace, name: c.name! })) + ); + + if (results.some((r) => r.status === 'fulfilled')) { await queryClient.invalidateQueries({ queryKey: [`/apis/guardrails/v2/workspaces/${workspace}/configs`], }); - return true; - } catch { - return false; } + + return results.every((r) => r.status === 'fulfilled'); }, [configsToDelete, deleteConfig, queryClient, workspace]); return ( From dd1519d5f39291552863ad4b4f0981a4269f2ec5 Mon Sep 17 00:00:00 2001 From: Nicholas Kolean Date: Fri, 14 Aug 2026 07:56:47 -0600 Subject: [PATCH 4/4] test fixes Signed-off-by: Nicholas Kolean --- .../GuardrailsDataView/GuardrailsDataView.test.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/web/packages/studio/src/components/dataViews/GuardrailsDataView/GuardrailsDataView.test.tsx b/web/packages/studio/src/components/dataViews/GuardrailsDataView/GuardrailsDataView.test.tsx index e68251ae83..391e36c611 100644 --- a/web/packages/studio/src/components/dataViews/GuardrailsDataView/GuardrailsDataView.test.tsx +++ b/web/packages/studio/src/components/dataViews/GuardrailsDataView/GuardrailsDataView.test.tsx @@ -308,9 +308,11 @@ describe('GuardrailsDataView', () => { 'no-such-config' ); expect( - await screen.findByText('No Results Found', undefined, { timeout: XL_SELECTOR_TIMEOUT }) + await screen.findByTestId('entity-empty-state-no-results', undefined, { + timeout: XL_SELECTOR_TIMEOUT, + }) ).toBeInTheDocument(); - expect(screen.getByRole('button', { name: /Clear Filters/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /Clear filters/i })).toBeInTheDocument(); }); });