From 7fd1cca19eed76af6fc253edc1c9b35275a8d1a5 Mon Sep 17 00:00:00 2001 From: Alex Ray Date: Wed, 5 Aug 2026 15:25:05 -0700 Subject: [PATCH 1/8] feat(studio): add guardrail creation flow Adds a "Create Guardrail" button to the guardrails list header that navigates to /guardrails/new, where a modal lets the user name the guardrail and POST it to the API before redirecting to the detail route. Client-side validation matches the backend name pattern (^[a-z](?!.*--)[a-z0-9\-@.+_]{1,62}(? --- web/packages/studio/src/constants/routes.ts | 1 + .../studio/src/mocks/handlers/guardrails.ts | 22 +++++ .../src/routes/groups/guardrailsRoutes.tsx | 12 +++ .../guardrails/GuardrailNewRoute/index.tsx | 84 +++++++++++++++++++ .../guardrails/GuardrailsRoute/index.tsx | 17 +++- web/packages/studio/src/routes/utils.ts | 4 + 6 files changed, 137 insertions(+), 3 deletions(-) create mode 100644 web/packages/studio/src/routes/guardrails/GuardrailNewRoute/index.tsx diff --git a/web/packages/studio/src/constants/routes.ts b/web/packages/studio/src/constants/routes.ts index 5a15820945..fbc622b6ed 100644 --- a/web/packages/studio/src/constants/routes.ts +++ b/web/packages/studio/src/constants/routes.ts @@ -117,6 +117,7 @@ export const ROUTES = { anonymizerJob: `/workspaces/:${P.workspace}/anonymizer/:${P.anonymizerJobName}`, secrets: `/workspaces/:${P.workspace}/secrets`, guardrails: `/workspaces/:${P.workspace}/guardrails`, + guardrailNew: `/workspaces/:${P.workspace}/guardrails/new`, optimizer: `/workspaces/:${P.workspace}/optimizer`, optimizerInsight: `/workspaces/:${P.workspace}/optimizer/:${P.insightId}`, guardrailDetail: `/workspaces/:${P.workspace}/guardrails/:${P.guardrailConfigName}`, diff --git a/web/packages/studio/src/mocks/handlers/guardrails.ts b/web/packages/studio/src/mocks/handlers/guardrails.ts index 2ba97b9137..c487bcd41c 100644 --- a/web/packages/studio/src/mocks/handlers/guardrails.ts +++ b/web/packages/studio/src/mocks/handlers/guardrails.ts @@ -143,6 +143,28 @@ export const guardrailsHandlers = [ return HttpResponse.json(config); } ), + http.post( + `${PLATFORM_BASE_URL}/apis/guardrails/v2/workspaces/:workspace/configs`, + async ({ params, request }) => { + const body = (await request.json()) as { name: string; description?: string }; + const config: GuardrailConfig = { + id: `cfg-${Date.now()}`, + entity_id: `cfg-${Date.now()}`, + parent: `ws-${params.workspace}`, + db_version: 1, + name: body.name, + workspace: params.workspace as string, + description: body.description, + created_at: new Date().toISOString(), + created_by: 'user@example.com', + updated_at: new Date().toISOString(), + updated_by: 'user@example.com', + data: undefined, + }; + mockGuardrailConfigs.push(config); + return HttpResponse.json(config, { status: 201 }); + } + ), 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 135f12b6a0..b47e132c6f 100644 --- a/web/packages/studio/src/routes/groups/guardrailsRoutes.tsx +++ b/web/packages/studio/src/routes/groups/guardrailsRoutes.tsx @@ -14,6 +14,12 @@ const GuardrailsRoute = lazy(() => })) ); +const GuardrailNewRoute = lazy(() => + import('@studio/routes/guardrails/GuardrailNewRoute').then((m) => ({ + default: m.GuardrailNewRoute, + })) +); + const GuardrailDetailRoute = lazy(() => import('@studio/routes/guardrails/GuardrailDetailRoute').then((m) => ({ default: m.GuardrailDetailRoute, @@ -37,6 +43,12 @@ export const guardrailsRoutes: RouteObject[] = gateGuardrailsRoutes([ path: ROUTES.workspace.guardrails, element: , errorElement: , + children: [ + { + path: ROUTES.workspace.guardrailNew, + element: , + }, + ], }, { path: ROUTES.workspace.guardrailDetail, diff --git a/web/packages/studio/src/routes/guardrails/GuardrailNewRoute/index.tsx b/web/packages/studio/src/routes/guardrails/GuardrailNewRoute/index.tsx new file mode 100644 index 0000000000..966a54acd7 --- /dev/null +++ b/web/packages/studio/src/routes/guardrails/GuardrailNewRoute/index.tsx @@ -0,0 +1,84 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { zodResolver } from '@hookform/resolvers/zod'; +import { ControlledTextInput } from '@nemo/common/src/components/form/ControlledTextInput'; +import { FormModal } from '@nemo/common/src/components/FormModal'; +import { useGuardrailsCreateConfig } from '@nemo/sdk/generated/platform/api'; +import { getErrorMessage } from '@studio/api/common/utils'; +import { useWorkspaceFromPath } from '@studio/hooks/useWorkspaceFromPath'; +import { getGuardrailDetailRoute, getGuardrailsRoute } from '@studio/routes/utils'; +import { type FC } from 'react'; +import { useForm } from 'react-hook-form'; +import { useNavigate } from 'react-router'; +import { z } from 'zod'; + +const NAME_PATTERN = /^[a-z](?!.*--)[a-z0-9\-@.+_]{1,62}(?; + +export const GuardrailNewRoute: FC = () => { + const workspace = useWorkspaceFromPath(); + const navigate = useNavigate(); + + const { + control, + handleSubmit, + reset, + formState: { isValid }, + } = useForm({ + resolver: zodResolver(schema), + defaultValues: { name: '' }, + mode: 'onChange', + }); + + const { mutateAsync: createConfig, isPending, error } = useGuardrailsCreateConfig(); + + const handleClose = () => { + reset(); + navigate(getGuardrailsRoute(workspace)); + }; + + const onSubmit = async (data: FormData) => { + const config = await createConfig({ workspace, data: { name: data.name } }); + navigate(getGuardrailDetailRoute(workspace, config.name ?? data.name)); + }; + + return ( + + + + ); +}; diff --git a/web/packages/studio/src/routes/guardrails/GuardrailsRoute/index.tsx b/web/packages/studio/src/routes/guardrails/GuardrailsRoute/index.tsx index 05621f8f39..16a72558f1 100644 --- a/web/packages/studio/src/routes/guardrails/GuardrailsRoute/index.tsx +++ b/web/packages/studio/src/routes/guardrails/GuardrailsRoute/index.tsx @@ -15,16 +15,20 @@ import { 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 { Button, PageHeader, Stack } from '@nvidia/foundations-react-core'; import { AccessibleTitle } from '@studio/components/AccessibleTitle'; import { GuardrailsDataView } from '@studio/components/dataViews/GuardrailsDataView'; import { DeleteConfirmationModal } from '@studio/components/DeleteConfirmationModal'; import { useWorkspaceFromPath } from '@studio/hooks/useWorkspaceFromPath'; import { useBreadcrumbs } from '@studio/providers/breadcrumbs/useBreadcrumbs'; -import { getGuardrailDetailRoute, getGuardrailsRoute } from '@studio/routes/utils'; +import { + getGuardrailDetailRoute, + getGuardrailNewRoute, + getGuardrailsRoute, +} from '@studio/routes/utils'; import { useQueryClient } from '@tanstack/react-query'; import { type FC, useCallback, useState } from 'react'; -import { useNavigate } from 'react-router'; +import { Outlet, useNavigate } from 'react-router'; export const GuardrailsRoute: FC = () => { const workspace = useWorkspaceFromPath(); @@ -60,6 +64,11 @@ export const GuardrailsRoute: FC = () => { className="p-0" slotHeading="Guardrail Configs" slotDescription="Manage NeMo Guardrails configurations for your workspace." + slotActions={ + + } /> { onClose={() => setConfigToDelete(null)} /> ) : null} + + ); }; diff --git a/web/packages/studio/src/routes/utils.ts b/web/packages/studio/src/routes/utils.ts index a26e415d3c..325fc29b13 100644 --- a/web/packages/studio/src/routes/utils.ts +++ b/web/packages/studio/src/routes/utils.ts @@ -398,6 +398,10 @@ export const getGuardrailsRoute = (workspace: string) => { return generatePath(ROUTES.workspace.guardrails, { workspace }); }; +export const getGuardrailNewRoute = (workspace: string) => { + return generatePath(ROUTES.workspace.guardrailNew, { workspace }); +}; + export const getOptimizerRoute = (workspace: string) => { return generatePath(ROUTES.workspace.optimizer, { workspace }); }; From 6c6f40873236cf6d971933b128decd1ef91a7623 Mon Sep 17 00:00:00 2001 From: Alex Ray Date: Wed, 5 Aug 2026 15:36:13 -0700 Subject: [PATCH 2/8] fix(studio): address PR feedback on guardrail creation flow - Rename body to input in POST mock handler to avoid shadowing the PATCH handler's body variable in the same file scope - Add disabled={isPending} to FormModal so the user cannot close or resubmit the modal while the create request is in flight Signed-off-by: Alex Ray --- web/packages/studio/src/mocks/handlers/guardrails.ts | 6 +++--- .../src/routes/guardrails/GuardrailNewRoute/index.tsx | 1 + 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/web/packages/studio/src/mocks/handlers/guardrails.ts b/web/packages/studio/src/mocks/handlers/guardrails.ts index c487bcd41c..e85be3033d 100644 --- a/web/packages/studio/src/mocks/handlers/guardrails.ts +++ b/web/packages/studio/src/mocks/handlers/guardrails.ts @@ -146,15 +146,15 @@ export const guardrailsHandlers = [ http.post( `${PLATFORM_BASE_URL}/apis/guardrails/v2/workspaces/:workspace/configs`, async ({ params, request }) => { - const body = (await request.json()) as { name: string; description?: string }; + const input = (await request.json()) as { name: string; description?: string }; const config: GuardrailConfig = { id: `cfg-${Date.now()}`, entity_id: `cfg-${Date.now()}`, parent: `ws-${params.workspace}`, db_version: 1, - name: body.name, + name: input.name, workspace: params.workspace as string, - description: body.description, + description: input.description, created_at: new Date().toISOString(), created_by: 'user@example.com', updated_at: new Date().toISOString(), diff --git a/web/packages/studio/src/routes/guardrails/GuardrailNewRoute/index.tsx b/web/packages/studio/src/routes/guardrails/GuardrailNewRoute/index.tsx index 966a54acd7..22e6393a5c 100644 --- a/web/packages/studio/src/routes/guardrails/GuardrailNewRoute/index.tsx +++ b/web/packages/studio/src/routes/guardrails/GuardrailNewRoute/index.tsx @@ -59,6 +59,7 @@ export const GuardrailNewRoute: FC = () => { open title="Create Guardrail" submitButtonText="Create" + disabled={isPending} loading={isPending} submitDisabled={!isValid} errorText={error ? getErrorMessage(error) : null} From 236b828d609ee07e11c1ee7d89608aabadc3147f Mon Sep 17 00:00:00 2001 From: Alex Ray Date: Thu, 6 Aug 2026 10:17:06 -0700 Subject: [PATCH 3/8] refactor(studio): use /~new sub-path for guardrail creation route Avoids a naming conflict where a guardrail named 'new' would be unreachable via the UI. The ~ character is outside the backend name pattern so /~new can never collide with a real guardrail name. Signed-off-by: Alex Ray --- web/packages/studio/src/constants/routes.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/packages/studio/src/constants/routes.ts b/web/packages/studio/src/constants/routes.ts index fbc622b6ed..f9d3008a03 100644 --- a/web/packages/studio/src/constants/routes.ts +++ b/web/packages/studio/src/constants/routes.ts @@ -117,7 +117,7 @@ export const ROUTES = { anonymizerJob: `/workspaces/:${P.workspace}/anonymizer/:${P.anonymizerJobName}`, secrets: `/workspaces/:${P.workspace}/secrets`, guardrails: `/workspaces/:${P.workspace}/guardrails`, - guardrailNew: `/workspaces/:${P.workspace}/guardrails/new`, + guardrailNew: `/workspaces/:${P.workspace}/guardrails/~new`, optimizer: `/workspaces/:${P.workspace}/optimizer`, optimizerInsight: `/workspaces/:${P.workspace}/optimizer/:${P.insightId}`, guardrailDetail: `/workspaces/:${P.workspace}/guardrails/:${P.guardrailConfigName}`, From 3ceaaf759e732fef5f1a89ae872bd165949f6b7c Mon Sep 17 00:00:00 2001 From: Alex Ray Date: Thu, 6 Aug 2026 12:20:35 -0700 Subject: [PATCH 4/8] refactor(studio): make guardrail creation a local-state modal Drops the /~new sub-path entirely. The creation modal is now opened by local state in GuardrailsRoute rather than a child route, so there is no URL segment that can collide with a guardrail name and no history entry to skip past when navigating back from the detail page. Moves the component to GuardrailsRoute/CreateGuardrailModal, matching the CreateSecretModal placement under SecretsListRoute. Signed-off-by: Alex Ray --- web/packages/studio/src/constants/routes.ts | 1 - .../src/routes/groups/guardrailsRoutes.tsx | 12 ------ .../CreateGuardrailModal}/index.tsx | 39 ++++++++++++------- .../guardrails/GuardrailsRoute/index.tsx | 16 ++++---- web/packages/studio/src/routes/utils.ts | 4 -- 5 files changed, 32 insertions(+), 40 deletions(-) rename web/packages/studio/src/routes/guardrails/{GuardrailNewRoute => GuardrailsRoute/CreateGuardrailModal}/index.tsx (71%) diff --git a/web/packages/studio/src/constants/routes.ts b/web/packages/studio/src/constants/routes.ts index f9d3008a03..5a15820945 100644 --- a/web/packages/studio/src/constants/routes.ts +++ b/web/packages/studio/src/constants/routes.ts @@ -117,7 +117,6 @@ export const ROUTES = { anonymizerJob: `/workspaces/:${P.workspace}/anonymizer/:${P.anonymizerJobName}`, secrets: `/workspaces/:${P.workspace}/secrets`, guardrails: `/workspaces/:${P.workspace}/guardrails`, - guardrailNew: `/workspaces/:${P.workspace}/guardrails/~new`, optimizer: `/workspaces/:${P.workspace}/optimizer`, optimizerInsight: `/workspaces/:${P.workspace}/optimizer/:${P.insightId}`, guardrailDetail: `/workspaces/:${P.workspace}/guardrails/:${P.guardrailConfigName}`, diff --git a/web/packages/studio/src/routes/groups/guardrailsRoutes.tsx b/web/packages/studio/src/routes/groups/guardrailsRoutes.tsx index b47e132c6f..135f12b6a0 100644 --- a/web/packages/studio/src/routes/groups/guardrailsRoutes.tsx +++ b/web/packages/studio/src/routes/groups/guardrailsRoutes.tsx @@ -14,12 +14,6 @@ const GuardrailsRoute = lazy(() => })) ); -const GuardrailNewRoute = lazy(() => - import('@studio/routes/guardrails/GuardrailNewRoute').then((m) => ({ - default: m.GuardrailNewRoute, - })) -); - const GuardrailDetailRoute = lazy(() => import('@studio/routes/guardrails/GuardrailDetailRoute').then((m) => ({ default: m.GuardrailDetailRoute, @@ -43,12 +37,6 @@ export const guardrailsRoutes: RouteObject[] = gateGuardrailsRoutes([ path: ROUTES.workspace.guardrails, element: , errorElement: , - children: [ - { - path: ROUTES.workspace.guardrailNew, - element: , - }, - ], }, { path: ROUTES.workspace.guardrailDetail, diff --git a/web/packages/studio/src/routes/guardrails/GuardrailNewRoute/index.tsx b/web/packages/studio/src/routes/guardrails/GuardrailsRoute/CreateGuardrailModal/index.tsx similarity index 71% rename from web/packages/studio/src/routes/guardrails/GuardrailNewRoute/index.tsx rename to web/packages/studio/src/routes/guardrails/GuardrailsRoute/CreateGuardrailModal/index.tsx index 22e6393a5c..e38b929495 100644 --- a/web/packages/studio/src/routes/guardrails/GuardrailNewRoute/index.tsx +++ b/web/packages/studio/src/routes/guardrails/GuardrailsRoute/CreateGuardrailModal/index.tsx @@ -4,32 +4,32 @@ import { zodResolver } from '@hookform/resolvers/zod'; import { ControlledTextInput } from '@nemo/common/src/components/form/ControlledTextInput'; import { FormModal } from '@nemo/common/src/components/FormModal'; +import { ENTITY_NAME_HELP, entityNameSchema } from '@nemo/common/src/utils/entityName'; import { useGuardrailsCreateConfig } from '@nemo/sdk/generated/platform/api'; import { getErrorMessage } from '@studio/api/common/utils'; import { useWorkspaceFromPath } from '@studio/hooks/useWorkspaceFromPath'; -import { getGuardrailDetailRoute, getGuardrailsRoute } from '@studio/routes/utils'; +import { getGuardrailDetailRoute } from '@studio/routes/utils'; +import { useQueryClient } from '@tanstack/react-query'; import { type FC } from 'react'; import { useForm } from 'react-hook-form'; import { useNavigate } from 'react-router'; import { z } from 'zod'; -const NAME_PATTERN = /^[a-z](?!.*--)[a-z0-9\-@.+_]{1,62}(?; -export const GuardrailNewRoute: FC = () => { +interface Props { + open: boolean; + onClose: () => void; +} + +export const CreateGuardrailModal: FC = ({ open, onClose }) => { const workspace = useWorkspaceFromPath(); const navigate = useNavigate(); + const queryClient = useQueryClient(); const { control, @@ -42,21 +42,31 @@ export const GuardrailNewRoute: FC = () => { mode: 'onChange', }); - const { mutateAsync: createConfig, isPending, error } = useGuardrailsCreateConfig(); + const { + mutateAsync: createConfig, + isPending, + error, + reset: resetMutation, + } = useGuardrailsCreateConfig(); const handleClose = () => { reset(); - navigate(getGuardrailsRoute(workspace)); + resetMutation(); + onClose(); }; const onSubmit = async (data: FormData) => { const config = await createConfig({ workspace, data: { name: data.name } }); + await queryClient.invalidateQueries({ + queryKey: [`/apis/guardrails/v2/workspaces/${workspace}/configs`], + }); + handleClose(); navigate(getGuardrailDetailRoute(workspace, config.name ?? data.name)); }; return ( { autoFocus disabled={isPending} useControllerProps={{ name: 'name', control }} + formFieldProps={{ slotInfo: ENTITY_NAME_HELP }} attributes={{ Input: { autoComplete: 'off', diff --git a/web/packages/studio/src/routes/guardrails/GuardrailsRoute/index.tsx b/web/packages/studio/src/routes/guardrails/GuardrailsRoute/index.tsx index 16a72558f1..da585fb368 100644 --- a/web/packages/studio/src/routes/guardrails/GuardrailsRoute/index.tsx +++ b/web/packages/studio/src/routes/guardrails/GuardrailsRoute/index.tsx @@ -21,20 +21,18 @@ 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 { - getGuardrailDetailRoute, - getGuardrailNewRoute, - getGuardrailsRoute, -} from '@studio/routes/utils'; +import { CreateGuardrailModal } from '@studio/routes/guardrails/GuardrailsRoute/CreateGuardrailModal'; +import { getGuardrailDetailRoute, getGuardrailsRoute } from '@studio/routes/utils'; import { useQueryClient } from '@tanstack/react-query'; import { type FC, useCallback, useState } from 'react'; -import { Outlet, useNavigate } from 'react-router'; +import { useNavigate } from 'react-router'; export const GuardrailsRoute: FC = () => { const workspace = useWorkspaceFromPath(); const queryClient = useQueryClient(); const navigate = useNavigate(); + const [isCreateOpen, setIsCreateOpen] = useState(false); const [configToDelete, setConfigToDelete] = useState(null); const { mutateAsync: deleteConfig } = useGuardrailsDeleteConfig(); @@ -65,7 +63,7 @@ export const GuardrailsRoute: FC = () => { slotHeading="Guardrail Configs" slotDescription="Manage NeMo Guardrails configurations for your workspace." slotActions={ - } @@ -84,6 +82,8 @@ export const GuardrailsRoute: FC = () => { /> + setIsCreateOpen(false)} /> + {configToDelete ? ( { onClose={() => setConfigToDelete(null)} /> ) : null} - - ); }; diff --git a/web/packages/studio/src/routes/utils.ts b/web/packages/studio/src/routes/utils.ts index 325fc29b13..a26e415d3c 100644 --- a/web/packages/studio/src/routes/utils.ts +++ b/web/packages/studio/src/routes/utils.ts @@ -398,10 +398,6 @@ export const getGuardrailsRoute = (workspace: string) => { return generatePath(ROUTES.workspace.guardrails, { workspace }); }; -export const getGuardrailNewRoute = (workspace: string) => { - return generatePath(ROUTES.workspace.guardrailNew, { workspace }); -}; - export const getOptimizerRoute = (workspace: string) => { return generatePath(ROUTES.workspace.optimizer, { workspace }); }; From 6a636230c3fa659e1a5338b671f4000a9c17d624 Mon Sep 17 00:00:00 2001 From: Alex Ray Date: Thu, 6 Aug 2026 12:35:10 -0700 Subject: [PATCH 5/8] fix(studio): handle a failed guardrail create without an escaping rejection react-hook-form re-throws from its submit handler and FormModal drops the returned promise, so a rejected POST surfaced as an unhandled rejection. The user already saw the failure via errorText, but the rejection escaped and turned the test run red. Catch it and return, matching CreateSecretModal. Also renames the zod schema to createGuardrailFormSchema to match the naming used by sibling form modals, and adds tests covering the success path, the failed-create path, and client-side name validation. Signed-off-by: Alex Ray --- .../CreateGuardrailModal/index.test.tsx | 85 +++++++++++++++++++ .../CreateGuardrailModal/index.tsx | 60 ++++++++----- 2 files changed, 125 insertions(+), 20 deletions(-) create mode 100644 web/packages/studio/src/routes/guardrails/GuardrailsRoute/CreateGuardrailModal/index.test.tsx diff --git a/web/packages/studio/src/routes/guardrails/GuardrailsRoute/CreateGuardrailModal/index.test.tsx b/web/packages/studio/src/routes/guardrails/GuardrailsRoute/CreateGuardrailModal/index.test.tsx new file mode 100644 index 0000000000..37c347b93d --- /dev/null +++ b/web/packages/studio/src/routes/guardrails/GuardrailsRoute/CreateGuardrailModal/index.test.tsx @@ -0,0 +1,85 @@ +// 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 { ROUTE_PARAMS } from '@studio/constants/routes'; +import { server } from '@studio/mocks/node'; +import { CreateGuardrailModal } from '@studio/routes/guardrails/GuardrailsRoute/CreateGuardrailModal'; +import { mockUseNavigate, mockUseParams } from '@studio/tests/util/mockUseParams'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { http, HttpResponse } from 'msw'; +import { BrowserRouter } from 'react-router'; +import type { Mock } from 'vitest'; + +const WORKSPACE = 'test-workspace'; +const CONFIGS_URL = `${PLATFORM_BASE_URL}/apis/guardrails/v2/workspaces/:workspace/configs`; + +const renderModal = (onClose = vi.fn()) => { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + render( + + + + + + ); + return { onClose }; +}; + +describe('CreateGuardrailModal', () => { + let navigate: Mock; + + beforeEach(() => { + mockUseParams({ [ROUTE_PARAMS.workspace]: WORKSPACE }); + navigate = vi.fn(); + mockUseNavigate(navigate); + }); + + it('creates the guardrail and navigates to its detail page', async () => { + server.use( + http.post(CONFIGS_URL, () => HttpResponse.json({ name: 'my-rail' }, { status: 201 })) + ); + const user = userEvent.setup(); + const { onClose } = renderModal(); + + await user.type(screen.getByRole('textbox'), 'my-rail'); + await user.click(screen.getByRole('button', { name: 'Create' })); + + await waitFor(() => { + expect(navigate).toHaveBeenCalledWith(`/workspaces/${WORKSPACE}/guardrails/my-rail`); + }); + expect(onClose).toHaveBeenCalled(); + }); + + it('keeps the modal open and does not navigate when creation fails', async () => { + server.use( + http.post(CONFIGS_URL, () => + HttpResponse.json({ detail: 'Name already in use' }, { status: 409 }) + ) + ); + const user = userEvent.setup(); + const { onClose } = renderModal(); + + await user.type(screen.getByRole('textbox'), 'my-rail'); + await user.click(screen.getByRole('button', { name: 'Create' })); + + await waitFor(() => { + expect(screen.getByText('Name already in use')).toBeInTheDocument(); + }); + expect(navigate).not.toHaveBeenCalled(); + expect(onClose).not.toHaveBeenCalled(); + }); + + it('rejects a name that does not match the entity name pattern', async () => { + const user = userEvent.setup(); + renderModal(); + + await user.type(screen.getByRole('textbox'), 'Not Valid'); + + await waitFor(() => { + expect(screen.getByRole('button', { name: 'Create' })).toBeDisabled(); + }); + }); +}); diff --git a/web/packages/studio/src/routes/guardrails/GuardrailsRoute/CreateGuardrailModal/index.tsx b/web/packages/studio/src/routes/guardrails/GuardrailsRoute/CreateGuardrailModal/index.tsx index e38b929495..b217a7b637 100644 --- a/web/packages/studio/src/routes/guardrails/GuardrailsRoute/CreateGuardrailModal/index.tsx +++ b/web/packages/studio/src/routes/guardrails/GuardrailsRoute/CreateGuardrailModal/index.tsx @@ -6,20 +6,21 @@ import { ControlledTextInput } from '@nemo/common/src/components/form/Controlled import { FormModal } from '@nemo/common/src/components/FormModal'; import { ENTITY_NAME_HELP, entityNameSchema } from '@nemo/common/src/utils/entityName'; import { useGuardrailsCreateConfig } from '@nemo/sdk/generated/platform/api'; +import type { GuardrailConfig } from '@nemo/sdk/generated/platform/schema'; import { getErrorMessage } from '@studio/api/common/utils'; import { useWorkspaceFromPath } from '@studio/hooks/useWorkspaceFromPath'; import { getGuardrailDetailRoute } from '@studio/routes/utils'; import { useQueryClient } from '@tanstack/react-query'; -import { type FC } from 'react'; +import { type FC, useEffect, useRef } from 'react'; import { useForm } from 'react-hook-form'; import { useNavigate } from 'react-router'; import { z } from 'zod'; -const schema = z.object({ +const createGuardrailFormSchema = z.object({ name: entityNameSchema(), }); -type FormData = z.infer; +type FormData = z.infer; interface Props { open: boolean; @@ -37,7 +38,7 @@ export const CreateGuardrailModal: FC = ({ open, onClose }) => { reset, formState: { isValid }, } = useForm({ - resolver: zodResolver(schema), + resolver: zodResolver(createGuardrailFormSchema), defaultValues: { name: '' }, mode: 'onChange', }); @@ -49,6 +50,18 @@ export const CreateGuardrailModal: FC = ({ open, onClose }) => { reset: resetMutation, } = useGuardrailsCreateConfig(); + const containerRef = useRef(null); + + useEffect(() => { + if (!open) return; + // setTimeout 0 lets the dialog's built-in focus management run first (it would otherwise + // focus the slotInfo icon button, which appears before the input in DOM order). + const id = setTimeout(() => { + containerRef.current?.querySelector('input')?.focus(); + }, 0); + return () => clearTimeout(id); + }, [open]); + const handleClose = () => { reset(); resetMutation(); @@ -56,7 +69,13 @@ export const CreateGuardrailModal: FC = ({ open, onClose }) => { }; const onSubmit = async (data: FormData) => { - const config = await createConfig({ workspace, data: { name: data.name } }); + let config: GuardrailConfig; + try { + config = await createConfig({ workspace, data: { name: data.name } }); + } catch { + // The modal stays open and surfaces the failure via `errorText` below. + return; + } await queryClient.invalidateQueries({ queryKey: [`/apis/guardrails/v2/workspaces/${workspace}/configs`], }); @@ -76,21 +95,22 @@ export const CreateGuardrailModal: FC = ({ open, onClose }) => { onSubmit={handleSubmit(onSubmit)} onClose={handleClose} > - +
+ +
); }; From acb4e28e25ad2767b4b74ae2b19504439c6b3974 Mon Sep 17 00:00:00 2001 From: Alex Ray Date: Thu, 6 Aug 2026 15:31:59 -0700 Subject: [PATCH 6/8] chore: trigger CI Signed-off-by: Alex Ray From 7dd37ff66c4e33286b69fde7d21c458de897fa91 Mon Sep 17 00:00:00 2001 From: Alex Ray Date: Thu, 6 Aug 2026 18:46:31 -0700 Subject: [PATCH 7/8] feat(studio): create duplicate guardrail Signed-off-by: Alex Ray --- .../dataViews/GuardrailsDataView/index.tsx | 11 +++- .../CreateGuardrailModal/index.test.tsx | 52 +++++++++++++++++-- .../CreateGuardrailModal/index.tsx | 47 +++++++++++++---- .../GuardrailDetailActions.tsx | 13 +++++ .../guardrails/GuardrailsRoute/index.tsx | 12 ++++- 5 files changed, 120 insertions(+), 15 deletions(-) rename web/packages/studio/src/routes/guardrails/{GuardrailsRoute => }/CreateGuardrailModal/index.test.tsx (62%) rename web/packages/studio/src/routes/guardrails/{GuardrailsRoute => }/CreateGuardrailModal/index.tsx (66%) diff --git a/web/packages/studio/src/components/dataViews/GuardrailsDataView/index.tsx b/web/packages/studio/src/components/dataViews/GuardrailsDataView/index.tsx index b56dcf2054..5824138c11 100644 --- a/web/packages/studio/src/components/dataViews/GuardrailsDataView/index.tsx +++ b/web/packages/studio/src/components/dataViews/GuardrailsDataView/index.tsx @@ -18,12 +18,13 @@ import { getErrorMessage } from '@studio/api/common/utils'; import { countRails } from '@studio/components/dataViews/GuardrailsDataView/guardrailUtils'; import { ErrorPanel } from '@studio/components/ErrorPanel'; import { keepPreviousData } from '@tanstack/react-query'; -import { ShieldCheck, Trash } from 'lucide-react'; +import { Copy, ShieldCheck, Trash } from 'lucide-react'; import { type ComponentProps, type FC, useCallback } from 'react'; export interface GuardrailsDataViewProps { workspace: string; onRowClick: (config: GuardrailConfig) => void; + onRequestDuplicate?: (config: GuardrailConfig) => void; onRequestDelete?: (config: GuardrailConfig) => void; emptyStateActions?: React.ReactNode; } @@ -31,6 +32,7 @@ export interface GuardrailsDataViewProps { export const GuardrailsDataView: FC = ({ workspace, onRowClick, + onRequestDuplicate, onRequestDelete, emptyStateActions, }) => { @@ -117,6 +119,11 @@ export const GuardrailsDataView: FC = ({ size: ROW_ACTIONS_COLUMN_SIZE, enableResizing: false, rowActions: (config: GuardrailConfig) => [ + { + slotLeft: , + children: 'Duplicate', + onSelect: () => onRequestDuplicate?.(config), + }, { slotLeft: , children: 'Delete', @@ -126,7 +133,7 @@ export const GuardrailsDataView: FC = ({ ], }), ], - [onRequestDelete] + [onRequestDuplicate, onRequestDelete] ); return ( diff --git a/web/packages/studio/src/routes/guardrails/GuardrailsRoute/CreateGuardrailModal/index.test.tsx b/web/packages/studio/src/routes/guardrails/CreateGuardrailModal/index.test.tsx similarity index 62% rename from web/packages/studio/src/routes/guardrails/GuardrailsRoute/CreateGuardrailModal/index.test.tsx rename to web/packages/studio/src/routes/guardrails/CreateGuardrailModal/index.test.tsx index 37c347b93d..3e2c088cef 100644 --- a/web/packages/studio/src/routes/guardrails/GuardrailsRoute/CreateGuardrailModal/index.test.tsx +++ b/web/packages/studio/src/routes/guardrails/CreateGuardrailModal/index.test.tsx @@ -1,10 +1,11 @@ // SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +import type { GuardrailConfig } from '@nemo/sdk/generated/platform/schema'; import { PLATFORM_BASE_URL } from '@studio/constants/environment'; import { ROUTE_PARAMS } from '@studio/constants/routes'; import { server } from '@studio/mocks/node'; -import { CreateGuardrailModal } from '@studio/routes/guardrails/GuardrailsRoute/CreateGuardrailModal'; +import { CreateGuardrailModal } from '@studio/routes/guardrails/CreateGuardrailModal'; import { mockUseNavigate, mockUseParams } from '@studio/tests/util/mockUseParams'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { render, screen, waitFor } from '@testing-library/react'; @@ -16,12 +17,22 @@ import type { Mock } from 'vitest'; const WORKSPACE = 'test-workspace'; const CONFIGS_URL = `${PLATFORM_BASE_URL}/apis/guardrails/v2/workspaces/:workspace/configs`; -const renderModal = (onClose = vi.fn()) => { +const SOURCE_CONFIG = { + name: 'my-rail', + workspace: WORKSPACE, + description: 'Blocks unsafe content', + data: { models: [{ engine: 'nim', model: 'meta/llama-3.1-8b-instruct', type: 'main' }] }, +} as unknown as GuardrailConfig; + +const renderModal = ({ + onClose = vi.fn(), + sourceConfig, +}: { onClose?: Mock; sourceConfig?: GuardrailConfig } = {}) => { const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); render( - + ); @@ -82,4 +93,39 @@ describe('CreateGuardrailModal', () => { expect(screen.getByRole('button', { name: 'Create' })).toBeDisabled(); }); }); + + describe('with a source config', () => { + it('defaults the name to -copy', async () => { + renderModal({ sourceConfig: SOURCE_CONFIG }); + + await waitFor(() => { + expect(screen.getByRole('textbox')).toHaveValue('my-rail-copy'); + }); + expect(screen.getByText('Duplicate Guardrail')).toBeInTheDocument(); + }); + + it('creates a copy carrying the source description and data', async () => { + let body: unknown; + server.use( + http.post(CONFIGS_URL, async ({ request }) => { + body = await request.json(); + return HttpResponse.json({ name: 'my-rail-copy' }, { status: 201 }); + }) + ); + const user = userEvent.setup(); + const { onClose } = renderModal({ sourceConfig: SOURCE_CONFIG }); + + await user.click(screen.getByRole('button', { name: 'Create' })); + + await waitFor(() => { + expect(navigate).toHaveBeenCalledWith(`/workspaces/${WORKSPACE}/guardrails/my-rail-copy`); + }); + expect(body).toEqual({ + name: 'my-rail-copy', + description: SOURCE_CONFIG.description, + data: SOURCE_CONFIG.data, + }); + expect(onClose).toHaveBeenCalled(); + }); + }); }); diff --git a/web/packages/studio/src/routes/guardrails/GuardrailsRoute/CreateGuardrailModal/index.tsx b/web/packages/studio/src/routes/guardrails/CreateGuardrailModal/index.tsx similarity index 66% rename from web/packages/studio/src/routes/guardrails/GuardrailsRoute/CreateGuardrailModal/index.tsx rename to web/packages/studio/src/routes/guardrails/CreateGuardrailModal/index.tsx index b217a7b637..b3e8054a7b 100644 --- a/web/packages/studio/src/routes/guardrails/GuardrailsRoute/CreateGuardrailModal/index.tsx +++ b/web/packages/studio/src/routes/guardrails/CreateGuardrailModal/index.tsx @@ -4,9 +4,17 @@ import { zodResolver } from '@hookform/resolvers/zod'; import { ControlledTextInput } from '@nemo/common/src/components/form/ControlledTextInput'; import { FormModal } from '@nemo/common/src/components/FormModal'; -import { ENTITY_NAME_HELP, entityNameSchema } from '@nemo/common/src/utils/entityName'; +import { + ENTITY_NAME_HELP, + ENTITY_NAME_MAX_LENGTH, + entityNameSchema, +} from '@nemo/common/src/utils/entityName'; import { useGuardrailsCreateConfig } from '@nemo/sdk/generated/platform/api'; -import type { GuardrailConfig } from '@nemo/sdk/generated/platform/schema'; +import type { + GuardrailConfig, + GuardrailConfigInput, + GuardrailConfigInputData, +} from '@nemo/sdk/generated/platform/schema'; import { getErrorMessage } from '@studio/api/common/utils'; import { useWorkspaceFromPath } from '@studio/hooks/useWorkspaceFromPath'; import { getGuardrailDetailRoute } from '@studio/routes/utils'; @@ -22,16 +30,27 @@ const createGuardrailFormSchema = z.object({ type FormData = z.infer; +const COPY_SUFFIX = '-copy'; + +/** `-copy`, trimmed so the suffix still fits within the entity name limit. */ +const getCopyName = (name: string): string => + `${name.slice(0, ENTITY_NAME_MAX_LENGTH - COPY_SUFFIX.length)}${COPY_SUFFIX}`; + interface Props { open: boolean; onClose: () => void; + /** When set, the modal duplicates this config instead of creating an empty one. */ + sourceConfig?: GuardrailConfig; } -export const CreateGuardrailModal: FC = ({ open, onClose }) => { +export const CreateGuardrailModal: FC = ({ open, onClose, sourceConfig }) => { const workspace = useWorkspaceFromPath(); const navigate = useNavigate(); const queryClient = useQueryClient(); + const isDuplicate = Boolean(sourceConfig); + const defaultName = sourceConfig?.name ? getCopyName(sourceConfig.name) : ''; + const { control, handleSubmit, @@ -39,7 +58,7 @@ export const CreateGuardrailModal: FC = ({ open, onClose }) => { formState: { isValid }, } = useForm({ resolver: zodResolver(createGuardrailFormSchema), - defaultValues: { name: '' }, + defaultValues: { name: defaultName }, mode: 'onChange', }); @@ -54,24 +73,34 @@ export const CreateGuardrailModal: FC = ({ open, onClose }) => { useEffect(() => { if (!open) return; + // The modal can stay mounted across opens, so seed the name for this open. + reset({ name: defaultName }); // setTimeout 0 lets the dialog's built-in focus management run first (it would otherwise // focus the slotInfo icon button, which appears before the input in DOM order). const id = setTimeout(() => { - containerRef.current?.querySelector('input')?.focus(); + const input = containerRef.current?.querySelector('input'); + input?.focus(); + input?.select(); }, 0); return () => clearTimeout(id); - }, [open]); + }, [open, defaultName, reset]); const handleClose = () => { - reset(); + reset({ name: defaultName }); resetMutation(); onClose(); }; const onSubmit = async (data: FormData) => { + const payload: GuardrailConfigInput = { name: data.name }; + if (sourceConfig) { + if (sourceConfig.description) payload.description = sourceConfig.description; + if (sourceConfig.data) payload.data = { ...sourceConfig.data } as GuardrailConfigInputData; + } + let config: GuardrailConfig; try { - config = await createConfig({ workspace, data: { name: data.name } }); + config = await createConfig({ workspace, data: payload }); } catch { // The modal stays open and surfaces the failure via `errorText` below. return; @@ -86,7 +115,7 @@ export const CreateGuardrailModal: FC = ({ open, onClose }) => { return ( = ({ config const workspace = useWorkspaceFromPath(); const navigate = useNavigate(); const queryClient = useQueryClient(); + const [showDuplicateModal, setShowDuplicateModal] = useState(false); const [showDeleteModal, setShowDeleteModal] = useState(false); const { mutateAsync: deleteConfig } = useGuardrailsDeleteConfig(); @@ -42,6 +44,10 @@ export const GuardrailDetailActions: FC = ({ config const actions = useMemo( () => [ + { + label: 'Duplicate', + onSelect: () => setShowDuplicateModal(true), + }, { label: 'Delete', onSelect: () => setShowDeleteModal(true), @@ -54,6 +60,13 @@ export const GuardrailDetailActions: FC = ({ config return ( <> + {showDuplicateModal ? ( + setShowDuplicateModal(false)} + /> + ) : null} {showDeleteModal ? ( { const navigate = useNavigate(); const [isCreateOpen, setIsCreateOpen] = useState(false); + const [configToDuplicate, setConfigToDuplicate] = useState(null); const [configToDelete, setConfigToDelete] = useState(null); const { mutateAsync: deleteConfig } = useGuardrailsDeleteConfig(); @@ -78,12 +79,21 @@ export const GuardrailsRoute: FC = () => { ); navigate(getGuardrailDetailRoute(workspace, config.name)); }} + onRequestDuplicate={setConfigToDuplicate} onRequestDelete={setConfigToDelete} /> setIsCreateOpen(false)} /> + {configToDuplicate ? ( + setConfigToDuplicate(null)} + /> + ) : null} + {configToDelete ? ( Date: Mon, 10 Aug 2026 10:38:04 -0700 Subject: [PATCH 8/8] refactor(studio): share entity copy-name helper Move the guardrail duplicate-name logic out of CreateGuardrailModal into `toCopyName` in @nemo/common/src/utils/entityName, alongside the ENTITY_NAME_MAX_LENGTH constant and name sanitizers it depends on. Two fixes fall out of sharing it: - Truncating at MAX - len('-copy') could land just after a hyphen and produce `my-guardrail--copy`, which ENTITY_NAME_REGEXP rejects for consecutive hyphens. Trailing hyphens are now stripped first. - The Data Designer clone path had its own CLONE_NAME_SUFFIX and never truncated, so a 60-character job name cloned to 65 and failed validation. buildClonedJobRequest now uses the shared helper. Also type the CreateGuardrailModal test fixture as a full GuardrailConfig instead of casting through `unknown`, and use an NVIDIA NemoGuard model in the example rails config. Signed-off-by: Alex Ray --- .../common/src/utils/entityName.test.ts | 27 +++++++++++++++++++ web/packages/common/src/utils/entityName.ts | 15 +++++++++++ .../NewDataDesignerJobForm/utils.test.ts | 4 +-- .../NewDataDesignerJobForm/utils.ts | 6 ++--- .../CreateGuardrailModal/index.test.tsx | 18 ++++++++++--- .../guardrails/CreateGuardrailModal/index.tsx | 14 ++-------- 6 files changed, 63 insertions(+), 21 deletions(-) diff --git a/web/packages/common/src/utils/entityName.test.ts b/web/packages/common/src/utils/entityName.test.ts index e010f1cfd9..f9816f093e 100644 --- a/web/packages/common/src/utils/entityName.test.ts +++ b/web/packages/common/src/utils/entityName.test.ts @@ -7,6 +7,7 @@ import { entityNameSchema, getEntityNameError, sanitizeEntityName, + toCopyName, toValidEntityName, } from '@nemo/common/src/utils/entityName'; import { entitiesCreateEntityBodyNameRegExp } from '@nemo/sdk/generated/platform/zod/entity-store'; @@ -111,6 +112,32 @@ describe('toValidEntityName', () => { }); }); +describe('toCopyName', () => { + it('appends the suffix', () => { + expect(toCopyName('my-rail')).toBe('my-rail-copy'); + }); + + it('keeps the result within the entity name limit', () => { + const result = toCopyName('a'.repeat(ENTITY_NAME_MAX_LENGTH)); + expect(result).toHaveLength(ENTITY_NAME_MAX_LENGTH); + expect(ENTITY_NAME_REGEXP.test(result)).toBe(true); + }); + + it('does not produce consecutive hyphens when the cut lands on one', () => { + // Truncation would otherwise leave a trailing hyphen, which the regexp rejects. + const name = `${'a'.repeat(ENTITY_NAME_MAX_LENGTH - 6)}-${'b'.repeat(5)}`; + const result = toCopyName(name); + expect(result).not.toContain('--'); + expect(ENTITY_NAME_REGEXP.test(result)).toBe(true); + }); + + it('stays valid when applied twice', () => { + const result = toCopyName(toCopyName('my-rail')); + expect(result).toBe('my-rail-copy-copy'); + expect(ENTITY_NAME_REGEXP.test(result)).toBe(true); + }); +}); + describe('entityNameSchema', () => { it('surfaces the rule-specific message', () => { const result = entityNameSchema('Provider name').safeParse('Sparl'); diff --git a/web/packages/common/src/utils/entityName.ts b/web/packages/common/src/utils/entityName.ts index 5e9a589e5b..8a9d676684 100644 --- a/web/packages/common/src/utils/entityName.ts +++ b/web/packages/common/src/utils/entityName.ts @@ -39,6 +39,21 @@ export function toValidEntityName(input: string, fallback: string): string { return sanitizeEntityName(input) ?? fallback; } +/** Suffix appended to a duplicated entity's name to distinguish it from the original. */ +export const COPY_NAME_SUFFIX = '-copy'; + +/** + * `-copy`, trimmed so the suffix still fits within `ENTITY_NAME_MAX_LENGTH`. + * Trailing hyphens are stripped first, since truncating mid-name can land on one and + * `ENTITY_NAME_REGEXP` forbids consecutive hyphens. + */ +export function toCopyName(name: string): string { + const base = name + .slice(0, ENTITY_NAME_MAX_LENGTH - COPY_NAME_SUFFIX.length) + .replace(STRIP_TRAILING_DASH, ''); + return `${base}${COPY_NAME_SUFFIX}`; +} + function listInvalidChars(value: string): string[] { const found = value.replace(/[A-Z]/g, '').match(INVALID_BODY_CHAR) ?? []; return [...new Set(found)].map((char) => (char === ' ' ? 'spaces' : `"${char}"`)); diff --git a/web/packages/studio/src/components/NewDataDesignerJobForm/utils.test.ts b/web/packages/studio/src/components/NewDataDesignerJobForm/utils.test.ts index 604721ce6b..28b1c5a00f 100644 --- a/web/packages/studio/src/components/NewDataDesignerJobForm/utils.test.ts +++ b/web/packages/studio/src/components/NewDataDesignerJobForm/utils.test.ts @@ -1,6 +1,7 @@ // SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +import { COPY_NAME_SUFFIX } from '@nemo/common/src/utils/entityName'; import type { CreateJob as DataDesignerJob, CreateJobRequest as DataDesignerJobRequest, @@ -8,7 +9,6 @@ import type { import { applyFormModelToJobRequest, buildClonedJobRequest, - CLONE_NAME_SUFFIX, getCloneJobRequestFromState, getErrorMessage, getWorkspaceAndModel, @@ -229,7 +229,7 @@ describe('buildClonedJobRequest', () => { it('copies the job config into a request with a -copy name', () => { const result = buildClonedJobRequest(makeJob()); expect(result).toEqual({ - name: `reviews${CLONE_NAME_SUFFIX}`, + name: `reviews${COPY_NAME_SUFFIX}`, description: 'Synthetic product reviews', spec: { num_records: 250, diff --git a/web/packages/studio/src/components/NewDataDesignerJobForm/utils.ts b/web/packages/studio/src/components/NewDataDesignerJobForm/utils.ts index 1be5693a42..64a66aee8b 100644 --- a/web/packages/studio/src/components/NewDataDesignerJobForm/utils.ts +++ b/web/packages/studio/src/components/NewDataDesignerJobForm/utils.ts @@ -2,6 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import { getPartsFromReference } from '@nemo/common/src/namedEntity'; +import { toCopyName } from '@nemo/common/src/utils/entityName'; import type { CreateJob as DataDesignerJob, CreateJobRequest as DataDesignerJobRequest, @@ -130,9 +131,6 @@ export function parseJsonContentToJobRequest(content: string): ParseJsonContentR return { jobRequest: sanitizeJobRequestName(parsed), error: null }; } -/** Suffix appended to a cloned job's name to distinguish it from the original. */ -export const CLONE_NAME_SUFFIX = '-copy'; - /** * Build a create-job request from an existing job so it can pre-fill the new-job form. * A job's `spec.job_config` is already the `DataDesignerJobConfig` shape a request expects, @@ -143,7 +141,7 @@ export function buildClonedJobRequest(job: DataDesignerJob): DataDesignerJobRequ const jobConfig = job.spec?.job_config; if (!jobConfig?.config) return null; return { - name: job.name ? `${job.name}${CLONE_NAME_SUFFIX}` : undefined, + name: job.name ? toCopyName(job.name) : undefined, description: job.description, spec: jobConfig, }; diff --git a/web/packages/studio/src/routes/guardrails/CreateGuardrailModal/index.test.tsx b/web/packages/studio/src/routes/guardrails/CreateGuardrailModal/index.test.tsx index 3e2c088cef..3605b5cc41 100644 --- a/web/packages/studio/src/routes/guardrails/CreateGuardrailModal/index.test.tsx +++ b/web/packages/studio/src/routes/guardrails/CreateGuardrailModal/index.test.tsx @@ -17,12 +17,24 @@ import type { Mock } from 'vitest'; const WORKSPACE = 'test-workspace'; const CONFIGS_URL = `${PLATFORM_BASE_URL}/apis/guardrails/v2/workspaces/:workspace/configs`; -const SOURCE_CONFIG = { +const SOURCE_CONFIG: GuardrailConfig = { name: 'my-rail', workspace: WORKSPACE, description: 'Blocks unsafe content', - data: { models: [{ engine: 'nim', model: 'meta/llama-3.1-8b-instruct', type: 'main' }] }, -} as unknown as GuardrailConfig; + data: { + models: [ + { engine: 'nim', model: 'nvidia/llama-3.1-nemoguard-8b-content-safety', type: 'main' }, + ], + }, + id: 'guardrail-config-1', + entity_id: 'guardrail-config-1', + parent: `workspace-${WORKSPACE}`, + created_at: '2026-01-01T00:00:00Z', + created_by: null, + updated_at: '2026-01-01T00:00:00Z', + updated_by: null, + db_version: 1, +}; const renderModal = ({ onClose = vi.fn(), diff --git a/web/packages/studio/src/routes/guardrails/CreateGuardrailModal/index.tsx b/web/packages/studio/src/routes/guardrails/CreateGuardrailModal/index.tsx index b3e8054a7b..5988ffdff7 100644 --- a/web/packages/studio/src/routes/guardrails/CreateGuardrailModal/index.tsx +++ b/web/packages/studio/src/routes/guardrails/CreateGuardrailModal/index.tsx @@ -4,11 +4,7 @@ import { zodResolver } from '@hookform/resolvers/zod'; import { ControlledTextInput } from '@nemo/common/src/components/form/ControlledTextInput'; import { FormModal } from '@nemo/common/src/components/FormModal'; -import { - ENTITY_NAME_HELP, - ENTITY_NAME_MAX_LENGTH, - entityNameSchema, -} from '@nemo/common/src/utils/entityName'; +import { ENTITY_NAME_HELP, entityNameSchema, toCopyName } from '@nemo/common/src/utils/entityName'; import { useGuardrailsCreateConfig } from '@nemo/sdk/generated/platform/api'; import type { GuardrailConfig, @@ -30,12 +26,6 @@ const createGuardrailFormSchema = z.object({ type FormData = z.infer; -const COPY_SUFFIX = '-copy'; - -/** `-copy`, trimmed so the suffix still fits within the entity name limit. */ -const getCopyName = (name: string): string => - `${name.slice(0, ENTITY_NAME_MAX_LENGTH - COPY_SUFFIX.length)}${COPY_SUFFIX}`; - interface Props { open: boolean; onClose: () => void; @@ -49,7 +39,7 @@ export const CreateGuardrailModal: FC = ({ open, onClose, sourceConfig }) const queryClient = useQueryClient(); const isDuplicate = Boolean(sourceConfig); - const defaultName = sourceConfig?.name ? getCopyName(sourceConfig.name) : ''; + const defaultName = sourceConfig?.name ? toCopyName(sourceConfig.name) : ''; const { control,