From d8dc54e76ea5dd78ecfecd1440d7625866852249 Mon Sep 17 00:00:00 2001 From: Alex Ray Date: Wed, 8 Jul 2026 10:30:41 -0700 Subject: [PATCH 1/2] feat(studio): replace guardrails side panel with dedicated detail route Promotes the guardrails config detail from a slide-out SidePanel into a full-page route (/guardrails/:guardrailConfigName), removing the GUARDRAILS_ENABLED feature flag gate and aligning with the pattern used by other resource types in Studio. - Delete GuardrailsDetailPanel (side panel component + test) - Add GuardrailDetailRoute (full-page detail view with breadcrumbs, delete) - Add guardrailDetail route constant and getGuardrailDetailRoute helper - Register both routes in guardrailsRoutes (list + detail) - Add mock GET /configs/:name handler for Storybook/test MSW - Fix optional chaining crash in GuardrailsDataView empty-state check Signed-off-by: Alex Ray --- .../dataViews/GuardrailsDataView/index.tsx | 2 +- web/packages/studio/src/constants/routes.ts | 2 + .../studio/src/mocks/handlers/guardrails.ts | 8 + .../src/routes/groups/guardrailsRoutes.tsx | 38 ++-- .../GuardrailDetailRoute/index.test.tsx | 92 +++++++++ .../guardrails/GuardrailDetailRoute/index.tsx | 178 ++++++++++++++++++ .../GuardrailsDetailPanel/index.test.tsx | 97 ---------- .../GuardrailsDetailPanel/index.tsx | 118 ------------ .../guardrails/GuardrailsRoute/index.test.tsx | 46 +++++ .../guardrails/GuardrailsRoute/index.tsx | 26 +-- web/packages/studio/src/routes/utils.ts | 7 + .../studio/src/tests/title-change.test.tsx | 1 + 12 files changed, 364 insertions(+), 251 deletions(-) create mode 100644 web/packages/studio/src/routes/guardrails/GuardrailDetailRoute/index.test.tsx create mode 100644 web/packages/studio/src/routes/guardrails/GuardrailDetailRoute/index.tsx delete mode 100644 web/packages/studio/src/routes/guardrails/GuardrailsDetailPanel/index.test.tsx delete mode 100644 web/packages/studio/src/routes/guardrails/GuardrailsDetailPanel/index.tsx create mode 100644 web/packages/studio/src/routes/guardrails/GuardrailsRoute/index.test.tsx diff --git a/web/packages/studio/src/components/dataViews/GuardrailsDataView/index.tsx b/web/packages/studio/src/components/dataViews/GuardrailsDataView/index.tsx index c1fed78fb3..4c0a61aaca 100644 --- a/web/packages/studio/src/components/dataViews/GuardrailsDataView/index.tsx +++ b/web/packages/studio/src/components/dataViews/GuardrailsDataView/index.tsx @@ -141,7 +141,7 @@ export const GuardrailsDataView: FC = ({ }, DataViewTableContent: { renderEmptyState: () => { - if (data?.data.length === 0 && !isFetching && !hasSearchOrFilters) { + if (data?.data?.length === 0 && !isFetching && !hasSearchOrFilters) { return ( } diff --git a/web/packages/studio/src/constants/routes.ts b/web/packages/studio/src/constants/routes.ts index 7e46ef3353..cdc30c28e8 100644 --- a/web/packages/studio/src/constants/routes.ts +++ b/web/packages/studio/src/constants/routes.ts @@ -40,6 +40,7 @@ export const ROUTE_PARAMS = { benchmarkName: 'benchmarkName', experimentGroupName: 'experimentGroupName', experimentName: 'experimentName', + guardrailConfigName: 'guardrailConfigName', } as const; // Just an alias to make the routes more readable @@ -107,6 +108,7 @@ export const ROUTES = { dataDesignerJobNew: `/workspaces/:${P.workspace}/data-designer/new`, secrets: `/workspaces/:${P.workspace}/secrets`, guardrails: `/workspaces/:${P.workspace}/guardrails`, + guardrailDetail: `/workspaces/:${P.workspace}/guardrails/:${P.guardrailConfigName}`, settings: `/workspaces/:${P.workspace}/settings`, /** Workspace members and role-based access (Entities role bindings) */ members: `/workspaces/:${P.workspace}/members`, diff --git a/web/packages/studio/src/mocks/handlers/guardrails.ts b/web/packages/studio/src/mocks/handlers/guardrails.ts index a0eb8884a4..4e98f09706 100644 --- a/web/packages/studio/src/mocks/handlers/guardrails.ts +++ b/web/packages/studio/src/mocks/handlers/guardrails.ts @@ -62,6 +62,14 @@ export const guardrailsHandlers = [ }, }) ), + http.get( + `${PLATFORM_BASE_URL}/apis/guardrails/v2/workspaces/:workspace/configs/:name`, + ({ params }) => { + const config = mockGuardrailConfigs.find((c) => c.name === params.name); + if (!config) return new HttpResponse(null, { status: 404 }); + return HttpResponse.json(config); + } + ), http.delete( `${PLATFORM_BASE_URL}/apis/guardrails/v2/workspaces/:workspace/configs/:name`, () => new HttpResponse(null, { status: 200 }) diff --git a/web/packages/studio/src/routes/groups/guardrailsRoutes.tsx b/web/packages/studio/src/routes/groups/guardrailsRoutes.tsx index 0ec5b19ea4..fc9f89d772 100644 --- a/web/packages/studio/src/routes/groups/guardrailsRoutes.tsx +++ b/web/packages/studio/src/routes/groups/guardrailsRoutes.tsx @@ -2,26 +2,32 @@ // SPDX-License-Identifier: Apache-2.0 import { ErrorPanel } from '@studio/components/ErrorPanel'; -import { GUARDRAILS_ENABLED } from '@studio/constants/environment'; import { ROUTES } from '@studio/constants/routes'; import { gateGuardrailsRoutes } from '@studio/routes/utils'; import { lazy } from 'react'; import type { RouteObject } from 'react-router-dom'; -const GuardrailsRoute = - GUARDRAILS_ENABLED && - lazy(() => - import('@studio/routes/guardrails/GuardrailsRoute').then((m) => ({ - default: m.GuardrailsRoute, - })) - ); +const GuardrailsRoute = lazy(() => + import('@studio/routes/guardrails/GuardrailsRoute').then((m) => ({ + default: m.GuardrailsRoute, + })) +); -export const guardrailsRoutes: RouteObject[] = gateGuardrailsRoutes( - GuardrailsRoute - ? { - path: ROUTES.workspace.guardrails, - element: , - errorElement: , - } - : [] +const GuardrailDetailRoute = lazy(() => + import('@studio/routes/guardrails/GuardrailDetailRoute').then((m) => ({ + default: m.GuardrailDetailRoute, + })) ); + +export const guardrailsRoutes: RouteObject[] = gateGuardrailsRoutes([ + { + path: ROUTES.workspace.guardrails, + element: , + errorElement: , + }, + { + path: ROUTES.workspace.guardrailDetail, + element: , + errorElement: , + }, +]); diff --git a/web/packages/studio/src/routes/guardrails/GuardrailDetailRoute/index.test.tsx b/web/packages/studio/src/routes/guardrails/GuardrailDetailRoute/index.test.tsx new file mode 100644 index 0000000000..5e7fc2b93a --- /dev/null +++ b/web/packages/studio/src/routes/guardrails/GuardrailDetailRoute/index.test.tsx @@ -0,0 +1,92 @@ +// 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 { mockGuardrailConfigs } from '@studio/mocks/handlers/guardrails'; +import { server } from '@studio/mocks/node'; +import { GuardrailDetailRoute } from '@studio/routes/guardrails/GuardrailDetailRoute'; +import { getGuardrailDetailRoute } from '@studio/routes/utils'; +import { XL_SELECTOR_TIMEOUT } from '@studio/tests/util/constants'; +import { renderRoute, screen } from '@studio/tests/util/render'; +import { within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { delay, http, HttpResponse } from 'msw'; + +const WORKSPACE = 'default'; + +const routes = [ + { + path: ROUTES.workspace.guardrailDetail, + element: , + }, + { + path: ROUTES.workspace.guardrails, + element:
LIST
, + }, +]; + +const renderDetail = (name: string) => + renderRoute(undefined, { + history: getGuardrailDetailRoute(WORKSPACE, name), + routes, + }); + +describe('GuardrailDetailRoute', () => { + it('renders the config details from the detail endpoint', async () => { + renderDetail('pii-filter'); + + expect( + await screen.findByText('pii-filter', undefined, { timeout: XL_SELECTOR_TIMEOUT }) + ).toBeInTheDocument(); + expect(screen.getByText('Blocks PII in user inputs and outputs')).toBeInTheDocument(); + // pii-filter has 2 models and 4 rail flows (2 input + 2 output). + expect(screen.getAllByText('2').length).toBeGreaterThanOrEqual(1); + expect(screen.getAllByText('4').length).toBeGreaterThanOrEqual(1); + // Raw config block. + expect(screen.getByText('Config')).toBeInTheDocument(); + }); + + it('shows the Edit button disabled', async () => { + renderDetail('pii-filter'); + await screen.findByText('pii-filter', undefined, { timeout: XL_SELECTOR_TIMEOUT }); + expect(screen.getByRole('button', { name: 'Edit' })).toBeDisabled(); + }); + + it('shows a loading state while fetching', async () => { + server.use( + http.get( + `${PLATFORM_BASE_URL}/apis/guardrails/v2/workspaces/:workspace/configs/:name`, + async () => { + await delay(); + return HttpResponse.json(mockGuardrailConfigs[0]); + } + ) + ); + renderDetail('pii-filter'); + expect(await screen.findByText('Loading guardrail config...')).toBeInTheDocument(); + }); + + it('shows an error state when the config cannot be loaded', async () => { + renderDetail('does-not-exist'); + expect( + await screen.findByText('Failed to load guardrail config.', undefined, { + timeout: XL_SELECTOR_TIMEOUT, + }) + ).toBeInTheDocument(); + }); + + it('deletes the config and navigates back to the list', async () => { + const user = userEvent.setup(); + renderDetail('pii-filter'); + await screen.findByText('pii-filter', undefined, { timeout: XL_SELECTOR_TIMEOUT }); + + await user.click(screen.getByRole('button', { name: 'Delete' })); + const dialog = await screen.findByRole('dialog'); + await user.click(within(dialog).getByRole('button', { name: 'Delete' })); + + expect( + await screen.findByTestId('guardrails-list', undefined, { timeout: XL_SELECTOR_TIMEOUT }) + ).toBeInTheDocument(); + }); +}); diff --git a/web/packages/studio/src/routes/guardrails/GuardrailDetailRoute/index.tsx b/web/packages/studio/src/routes/guardrails/GuardrailDetailRoute/index.tsx new file mode 100644 index 0000000000..6178cbf2d3 --- /dev/null +++ b/web/packages/studio/src/routes/guardrails/GuardrailDetailRoute/index.tsx @@ -0,0 +1,178 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { KVPair } from '@nemo/common/src/components/KVPair'; +import { RelativeTime } from '@nemo/common/src/components/RelativeTime'; +import { + useGuardrailsDeleteConfig, + useGuardrailsGetGuardrailConfig, +} from '@nemo/sdk/generated/platform/api'; +import { Button, Flex, PageHeader, Stack, Text } from '@nvidia/foundations-react-core'; +import { AccessibleTitle } from '@studio/components/AccessibleTitle'; +import { countRails } from '@studio/components/dataViews/GuardrailsDataView/guardrailUtils'; +import { DeleteConfirmationModal } from '@studio/components/DeleteConfirmationModal'; +import { Loading } from '@studio/components/Layouts/Loading'; +import { ROUTE_PARAMS } from '@studio/constants/routes'; +import { useWorkspaceFromPath } from '@studio/hooks/useWorkspaceFromPath'; +import { useBreadcrumbs } from '@studio/providers/breadcrumbs/useBreadcrumbs'; +import { getGuardrailsRoute } from '@studio/routes/utils'; +import { useRequiredPathParams } from '@studio/util/hooks/useRequiredPathParams'; +import { useQueryClient } from '@tanstack/react-query'; +import { type FC, useCallback, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; + +export const GuardrailDetailRoute: FC = () => { + const workspace = useWorkspaceFromPath(); + const navigate = useNavigate(); + const queryClient = useQueryClient(); + const { guardrailConfigName } = useRequiredPathParams([ROUTE_PARAMS.guardrailConfigName]); + + const [showDelete, setShowDelete] = useState(false); + + useBreadcrumbs({ + items: [ + { href: getGuardrailsRoute(workspace), slotLabel: 'Guardrails' }, + { slotLabel: guardrailConfigName }, + ], + }); + + const queryEnabled = Boolean(workspace && guardrailConfigName); + const { + data: config, + isPending, + isError, + } = useGuardrailsGetGuardrailConfig(workspace, guardrailConfigName, { + query: { enabled: queryEnabled }, + }); + + const { mutateAsync: deleteConfig } = useGuardrailsDeleteConfig(); + + const handleDelete = useCallback(async (): Promise => { + try { + await deleteConfig({ workspace, name: guardrailConfigName }); + // Invalidate by URL prefix — matches all pages/sorts for this workspace. + await queryClient.invalidateQueries({ + queryKey: [`/apis/guardrails/v2/workspaces/${workspace}/configs`], + }); + navigate(getGuardrailsRoute(workspace)); + return true; + } catch { + return false; + } + }, [deleteConfig, guardrailConfigName, navigate, queryClient, workspace]); + + if (isPending) { + return ; + } + + if (isError || !config) { + return ( + + + + Failed to load guardrail config. + + + ); + } + + const modelCount = config.data?.models?.length ?? 0; + const railCount = countRails(config.data); + + return ( + + + + + {config.name} + + + + + + + } + /> + + + + {config.description ? ( + + ) : null} + + + + ) : ( + '—' + ) + } + /> + + ) : ( + '—' + ) + } + /> + + + {config.data ? ( + + Config +
+                {JSON.stringify(config.data, null, 2)}
+              
+
+ ) : null} +
+
+ + {showDelete ? ( + setShowDelete(false)} + /> + ) : null} +
+ ); +}; diff --git a/web/packages/studio/src/routes/guardrails/GuardrailsDetailPanel/index.test.tsx b/web/packages/studio/src/routes/guardrails/GuardrailsDetailPanel/index.test.tsx deleted file mode 100644 index dbc748c267..0000000000 --- a/web/packages/studio/src/routes/guardrails/GuardrailsDetailPanel/index.test.tsx +++ /dev/null @@ -1,97 +0,0 @@ -/* - * SPDX-FileCopyrightText: Copyright (c) 2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved. - * SPDX-License-Identifier: Apache-2.0 - * - * NVIDIA CORPORATION, its affiliates and licensors retain all intellectual - * property and proprietary rights in and to this material, related - * documentation and any modifications thereto. Any use, reproduction, - * disclosure or distribution of this material and related documentation - * without an express license agreement from NVIDIA CORPORATION or - * its affiliates is strictly prohibited. - */ - -import type { GuardrailConfig } from '@nemo/sdk/generated/platform/schema'; -import { GuardrailsDetailPanel } from '@studio/routes/guardrails/GuardrailsDetailPanel'; -import { render, screen } from '@studio/tests/util/render'; -import userEvent from '@testing-library/user-event'; - -const mockConfig: GuardrailConfig = { - id: 'cfg-1', - entity_id: 'cfg-1', - parent: 'ws-default', - name: 'pii-filter', - workspace: 'default', - description: 'Blocks PII in user inputs and outputs', - created_at: '2026-04-12T10:00:00.000Z', - created_by: 'user@example.com', - updated_at: '2026-04-12T10:00:00.000Z', - updated_by: 'user@example.com', - data: { - models: [{ type: 'main', engine: 'openai', model: 'gpt-4' }], - rails: { - input: { flows: ['check pii'] }, - output: { flows: ['mask pii output'] }, - }, - }, -}; - -describe('GuardrailsDetailPanel', () => { - it('renders the config name as heading when open', async () => { - render( - - ); - expect(await screen.findByRole('dialog')).toBeInTheDocument(); - expect(screen.getByText('pii-filter')).toBeInTheDocument(); - }); - - it('renders description, model count, and rail count', async () => { - render( - - ); - await screen.findByRole('dialog'); - expect(screen.getByText('Blocks PII in user inputs and outputs')).toBeInTheDocument(); - expect(screen.getByText('1')).toBeInTheDocument(); // 1 model - expect(screen.getByText('2')).toBeInTheDocument(); // 2 rail flows - }); - - it('calls onRequestDelete with the config when Delete is clicked', async () => { - const onRequestDelete = vi.fn(); - render( - - ); - await screen.findByRole('dialog'); - screen.getByRole('button', { name: /delete/i }).click(); - expect(onRequestDelete).toHaveBeenCalledWith(mockConfig); - }); - - it('calls onClose when the panel close button is clicked', async () => { - const user = userEvent.setup(); - const onClose = vi.fn(); - render( - - ); - await screen.findByRole('dialog'); - // SidePanel close button has accessible label from KUI - const closeButton = screen.getByRole('button', { name: /close/i }); - await user.click(closeButton); - expect(onClose).toHaveBeenCalled(); - }); - - it('does not show content when closed', () => { - render( - - ); - // When closed, the config content should not be visible - expect(screen.queryByText('pii-filter')).not.toBeInTheDocument(); - }); -}); diff --git a/web/packages/studio/src/routes/guardrails/GuardrailsDetailPanel/index.tsx b/web/packages/studio/src/routes/guardrails/GuardrailsDetailPanel/index.tsx deleted file mode 100644 index 539d6e7d46..0000000000 --- a/web/packages/studio/src/routes/guardrails/GuardrailsDetailPanel/index.tsx +++ /dev/null @@ -1,118 +0,0 @@ -/* - * SPDX-FileCopyrightText: Copyright (c) 2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved. - * SPDX-License-Identifier: Apache-2.0 - * - * NVIDIA CORPORATION, its affiliates and licensors retain all intellectual - * property and proprietary rights in and to this material, related - * documentation and any modifications thereto. Any use, reproduction, - * disclosure or distribution of this material and related documentation - * without an express license agreement from NVIDIA CORPORATION or - * its affiliates is strictly prohibited. - */ - -import { KVPair } from '@nemo/common/src/components/KVPair'; -import { RelativeTime } from '@nemo/common/src/components/RelativeTime'; -import type { GuardrailConfig } from '@nemo/sdk/generated/platform/schema'; -import { Button, Flex, SidePanel, Stack, Text } from '@nvidia/foundations-react-core'; -import { countRails } from '@studio/components/dataViews/GuardrailsDataView/guardrailUtils'; -import type { FC } from 'react'; - -export interface GuardrailsDetailPanelProps { - open: boolean; - config: GuardrailConfig; - onClose: () => void; - onRequestDelete: (config: GuardrailConfig) => void; -} - -export const GuardrailsDetailPanel: FC = ({ - open, - config, - onClose, - onRequestDelete, -}) => { - const modelCount = config.data?.models?.length ?? 0; - const railCount = countRails(config.data); - - return ( - - {config.name} - - } - slotFooter={ - - - - - } - onOpenChange={(nextOpen) => { - if (!nextOpen) onClose(); - }} - > - - {/* Details */} - - {config.description ? ( - - ) : null} - - - - ) : ( - '—' - ) - } - /> - - ) : ( - '—' - ) - } - /> - - - {/* Raw config block */} - {config.data ? ( - - Config -
-              {JSON.stringify(config.data, null, 2)}
-            
-
- ) : null} -
-
- ); -}; diff --git a/web/packages/studio/src/routes/guardrails/GuardrailsRoute/index.test.tsx b/web/packages/studio/src/routes/guardrails/GuardrailsRoute/index.test.tsx new file mode 100644 index 0000000000..1cabd82ef4 --- /dev/null +++ b/web/packages/studio/src/routes/guardrails/GuardrailsRoute/index.test.tsx @@ -0,0 +1,46 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { ROUTES } from '@studio/constants/routes'; +import { GuardrailsRoute } from '@studio/routes/guardrails/GuardrailsRoute'; +import { getGuardrailDetailRoute, getGuardrailsRoute } from '@studio/routes/utils'; +import { XL_SELECTOR_TIMEOUT } from '@studio/tests/util/constants'; +import { renderRoute, screen } from '@studio/tests/util/render'; +import userEvent from '@testing-library/user-event'; +import { useLocation } from 'react-router-dom'; + +const WORKSPACE = 'default'; + +const LocationProbe = () => { + const location = useLocation(); + return
{location.pathname}
; +}; + +const renderList = () => + renderRoute(undefined, { + history: getGuardrailsRoute(WORKSPACE), + routes: [ + { + path: ROUTES.workspace.guardrails, + element: , + }, + { + path: ROUTES.workspace.guardrailDetail, + element: , + }, + ], + }); + +describe('GuardrailsRoute', () => { + it('navigates to the detail route when a row is clicked', async () => { + const user = userEvent.setup(); + renderList(); + + const row = await screen.findByText('pii-filter', undefined, { timeout: XL_SELECTOR_TIMEOUT }); + await user.click(row); + + expect(await screen.findByTestId('detail-location')).toHaveTextContent( + getGuardrailDetailRoute(WORKSPACE, 'pii-filter') + ); + }); +}); diff --git a/web/packages/studio/src/routes/guardrails/GuardrailsRoute/index.tsx b/web/packages/studio/src/routes/guardrails/GuardrailsRoute/index.tsx index e1283620e1..6e8e15d1a6 100644 --- a/web/packages/studio/src/routes/guardrails/GuardrailsRoute/index.tsx +++ b/web/packages/studio/src/routes/guardrails/GuardrailsRoute/index.tsx @@ -18,16 +18,16 @@ import { GuardrailsDataView } from '@studio/components/dataViews/GuardrailsDataV import { DeleteConfirmationModal } from '@studio/components/DeleteConfirmationModal'; import { useWorkspaceFromPath } from '@studio/hooks/useWorkspaceFromPath'; import { useBreadcrumbs } from '@studio/providers/breadcrumbs/useBreadcrumbs'; -import { GuardrailsDetailPanel } from '@studio/routes/guardrails/GuardrailsDetailPanel'; -import { getGuardrailsRoute } from '@studio/routes/utils'; +import { getGuardrailDetailRoute, getGuardrailsRoute } from '@studio/routes/utils'; import { useQueryClient } from '@tanstack/react-query'; import { type FC, useCallback, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; export const GuardrailsRoute: FC = () => { const workspace = useWorkspaceFromPath(); const queryClient = useQueryClient(); + const navigate = useNavigate(); - const [selectedConfig, setSelectedConfig] = useState(null); const [configToDelete, setConfigToDelete] = useState(null); const { mutateAsync: deleteConfig } = useGuardrailsDeleteConfig(); @@ -44,14 +44,11 @@ export const GuardrailsRoute: FC = () => { await queryClient.invalidateQueries({ queryKey: [`/apis/guardrails/v2/workspaces/${workspace}/configs`], }); - if (selectedConfig?.name === configToDelete.name) { - setSelectedConfig(null); - } return true; } catch { return false; } - }, [configToDelete, deleteConfig, queryClient, selectedConfig, workspace]); + }, [configToDelete, deleteConfig, queryClient, workspace]); return ( @@ -63,22 +60,13 @@ export const GuardrailsRoute: FC = () => { /> { + if (config.name) navigate(getGuardrailDetailRoute(workspace, config.name)); + }} onRequestDelete={setConfigToDelete} /> - {selectedConfig ? ( - setSelectedConfig(null)} - onRequestDelete={(config) => { - setConfigToDelete(config); - }} - /> - ) : null} - {configToDelete ? ( { return generatePath(ROUTES.workspace.guardrails, { workspace }); }; +export const getGuardrailDetailRoute = (workspace: string, guardrailConfigName: string) => { + return generatePath(ROUTES.workspace.guardrailDetail, { + workspace, + guardrailConfigName, + }); +}; + export const getWorkspaceSettingsRoute = (workspace: string) => { return generatePath(ROUTES.workspace.settings, { workspace }); }; diff --git a/web/packages/studio/src/tests/title-change.test.tsx b/web/packages/studio/src/tests/title-change.test.tsx index 2814eb964d..c0ee5e249c 100644 --- a/web/packages/studio/src/tests/title-change.test.tsx +++ b/web/packages/studio/src/tests/title-change.test.tsx @@ -36,6 +36,7 @@ const pathParams = { [RP.benchmarkName]: 'test-benchmark', [RP.experimentGroupName]: 'test-experiment-group', [RP.experimentName]: 'test-experiment', + [RP.guardrailConfigName]: 'test-guardrail-config', }; describe('AccessibleTitleE2E', () => { From 9c0502a256bb0c2b4b2b8692e575481d8666ef9e Mon Sep 17 00:00:00 2001 From: Alex Ray Date: Wed, 8 Jul 2026 11:25:19 -0700 Subject: [PATCH 2/2] feat(studio): seed detail query cache on guardrail row click for instant navigation Signed-off-by: Alex Ray --- .../src/routes/guardrails/GuardrailsRoute/index.tsx | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/web/packages/studio/src/routes/guardrails/GuardrailsRoute/index.tsx b/web/packages/studio/src/routes/guardrails/GuardrailsRoute/index.tsx index 6e8e15d1a6..d8e7dac541 100644 --- a/web/packages/studio/src/routes/guardrails/GuardrailsRoute/index.tsx +++ b/web/packages/studio/src/routes/guardrails/GuardrailsRoute/index.tsx @@ -10,7 +10,10 @@ * its affiliates is strictly prohibited. */ -import { useGuardrailsDeleteConfig } from '@nemo/sdk/generated/platform/api'; +import { + getGuardrailsGetGuardrailConfigQueryKey, + useGuardrailsDeleteConfig, +} from '@nemo/sdk/generated/platform/api'; import type { GuardrailConfig } from '@nemo/sdk/generated/platform/schema'; import { PageHeader, Stack } from '@nvidia/foundations-react-core'; import { AccessibleTitle } from '@studio/components/AccessibleTitle'; @@ -61,7 +64,12 @@ export const GuardrailsRoute: FC = () => { { - if (config.name) navigate(getGuardrailDetailRoute(workspace, config.name)); + if (!config.name) return; + queryClient.setQueryData( + getGuardrailsGetGuardrailConfigQueryKey(workspace, config.name), + config + ); + navigate(getGuardrailDetailRoute(workspace, config.name)); }} onRequestDelete={setConfigToDelete} />