Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions web/packages/common/src/utils/entityName.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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');
Expand Down
15 changes: 15 additions & 0 deletions web/packages/common/src/utils/entityName.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

/**
* `<name>-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}`;
}
Comment thread
aray12 marked this conversation as resolved.

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}"`));
Expand Down
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
// 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,
} from '@nemo/sdk/generated/data-designer/schema';
import {
applyFormModelToJobRequest,
buildClonedJobRequest,
CLONE_NAME_SUFFIX,
getCloneJobRequestFromState,
getErrorMessage,
getWorkspaceAndModel,
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -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,
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,19 +18,21 @@ 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;
Comment thread
aray12 marked this conversation as resolved.
emptyStateActions?: React.ReactNode;
}

export const GuardrailsDataView: FC<GuardrailsDataViewProps> = ({
workspace,
onRowClick,
onRequestDuplicate,
onRequestDelete,
emptyStateActions,
}) => {
Expand Down Expand Up @@ -117,6 +119,11 @@ export const GuardrailsDataView: FC<GuardrailsDataViewProps> = ({
size: ROW_ACTIONS_COLUMN_SIZE,
enableResizing: false,
rowActions: (config: GuardrailConfig) => [
{
slotLeft: <Copy />,
children: 'Duplicate',
onSelect: () => onRequestDuplicate?.(config),
},
{
slotLeft: <Trash />,
children: 'Delete',
Expand All @@ -126,7 +133,7 @@ export const GuardrailsDataView: FC<GuardrailsDataViewProps> = ({
],
}),
],
[onRequestDelete]
[onRequestDuplicate, onRequestDelete]
);

return (
Expand Down
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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(
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<CreateGuardrailModal open onClose={onClose} />
<CreateGuardrailModal open onClose={onClose} sourceConfig={sourceConfig} />
</BrowserRouter>
</QueryClientProvider>
);
Expand Down Expand Up @@ -82,4 +105,39 @@ describe('CreateGuardrailModal', () => {
expect(screen.getByRole('button', { name: 'Create' })).toBeDisabled();
});
});

describe('with a source config', () => {
it('defaults the name to <source>-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();
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -25,21 +29,26 @@ type FormData = z.infer<typeof createGuardrailFormSchema>;
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<Props> = ({ open, onClose }) => {
export const CreateGuardrailModal: FC<Props> = ({ 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,
reset,
formState: { isValid },
} = useForm<FormData>({
resolver: zodResolver(createGuardrailFormSchema),
defaultValues: { name: '' },
defaultValues: { name: defaultName },
mode: 'onChange',
});

Expand All @@ -54,24 +63,34 @@ export const CreateGuardrailModal: FC<Props> = ({ 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<HTMLInputElement>('input')?.focus();
const input = containerRef.current?.querySelector<HTMLInputElement>('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;
Expand All @@ -86,7 +105,7 @@ export const CreateGuardrailModal: FC<Props> = ({ open, onClose }) => {
return (
<FormModal
open={open}
title="Create Guardrail"
title={isDuplicate ? 'Duplicate Guardrail' : 'Create Guardrail'}
submitButtonText="Create"
disabled={isPending}
loading={isPending}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
QuickActionsMenuRoot,
} from '@studio/components/QuickActionsMenu/QuickActionsMenuRoot';
import { useWorkspaceFromPath } from '@studio/hooks/useWorkspaceFromPath';
import { CreateGuardrailModal } from '@studio/routes/guardrails/CreateGuardrailModal';
import { getGuardrailsRoute } from '@studio/routes/utils';
import { useQueryClient } from '@tanstack/react-query';
import { type FC, useCallback, useMemo, useState } from 'react';
Expand All @@ -22,6 +23,7 @@ export const GuardrailDetailActions: FC<GuardrailDetailActionsProps> = ({ config
const workspace = useWorkspaceFromPath();
const navigate = useNavigate();
const queryClient = useQueryClient();
const [showDuplicateModal, setShowDuplicateModal] = useState(false);
const [showDeleteModal, setShowDeleteModal] = useState(false);

const { mutateAsync: deleteConfig } = useGuardrailsDeleteConfig();
Expand All @@ -42,6 +44,10 @@ export const GuardrailDetailActions: FC<GuardrailDetailActionsProps> = ({ config

const actions = useMemo<QuickActionItem[]>(
() => [
{
label: 'Duplicate',
onSelect: () => setShowDuplicateModal(true),
},
{
label: 'Delete',
onSelect: () => setShowDeleteModal(true),
Expand All @@ -54,6 +60,13 @@ export const GuardrailDetailActions: FC<GuardrailDetailActionsProps> = ({ config
return (
<>
<QuickActionsMenuRoot actions={actions} />
{showDuplicateModal ? (
<CreateGuardrailModal
open
sourceConfig={config}
onClose={() => setShowDuplicateModal(false)}
/>
) : null}
{showDeleteModal ? (
<DeleteConfirmationModal
open
Expand Down
Loading
Loading