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/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 59% 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..3605b5cc41 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,34 @@ 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: GuardrailConfig = { + name: 'my-rail', + workspace: WORKSPACE, + description: 'Blocks unsafe content', + 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(), + sourceConfig, +}: { onClose?: Mock; sourceConfig?: GuardrailConfig } = {}) => { const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); render( - + ); @@ -82,4 +105,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 70% 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..5988ffdff7 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,13 @@ 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, entityNameSchema, toCopyName } 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'; @@ -25,13 +29,18 @@ type FormData = z.infer; 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 ? toCopyName(sourceConfig.name) : ''; + const { control, handleSubmit, @@ -39,7 +48,7 @@ export const CreateGuardrailModal: FC = ({ open, onClose }) => { formState: { isValid }, } = useForm({ resolver: zodResolver(createGuardrailFormSchema), - defaultValues: { name: '' }, + defaultValues: { name: defaultName }, mode: 'onChange', }); @@ -54,24 +63,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 +105,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 ? (