Skip to content
Merged
Show file tree
Hide file tree
Changes from 11 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
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,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' }] },
Comment thread
aray12 marked this conversation as resolved.
Outdated
} as unknown as GuardrailConfig;
Comment thread
aray12 marked this conversation as resolved.
Outdated

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 +93,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,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';
Expand All @@ -22,24 +30,35 @@ const createGuardrailFormSchema = z.object({

type FormData = z.infer<typeof createGuardrailFormSchema>;

const COPY_SUFFIX = '-copy';

/** `<name>-copy`, trimmed so the suffix still fits within the entity name limit. */
const getCopyName = (name: string): string =>
Comment thread
aray12 marked this conversation as resolved.
Outdated
`${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<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 ? getCopyName(sourceConfig.name) : '';

const {
control,
handleSubmit,
reset,
formState: { isValid },
} = useForm<FormData>({
resolver: zodResolver(createGuardrailFormSchema),
defaultValues: { name: '' },
defaultValues: { name: defaultName },
mode: 'onChange',
});

Expand All @@ -54,24 +73,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 +115,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
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ 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 { CreateGuardrailModal } from '@studio/routes/guardrails/GuardrailsRoute/CreateGuardrailModal';
import { CreateGuardrailModal } from '@studio/routes/guardrails/CreateGuardrailModal';
import { getGuardrailDetailRoute, getGuardrailsRoute } from '@studio/routes/utils';
import { useQueryClient } from '@tanstack/react-query';
import { type FC, useCallback, useState } from 'react';
Expand All @@ -33,6 +33,7 @@ export const GuardrailsRoute: FC = () => {
const navigate = useNavigate();

const [isCreateOpen, setIsCreateOpen] = useState(false);
const [configToDuplicate, setConfigToDuplicate] = useState<GuardrailConfig | null>(null);
const [configToDelete, setConfigToDelete] = useState<GuardrailConfig | null>(null);

const { mutateAsync: deleteConfig } = useGuardrailsDeleteConfig();
Expand Down Expand Up @@ -78,12 +79,21 @@ export const GuardrailsRoute: FC = () => {
);
navigate(getGuardrailDetailRoute(workspace, config.name));
}}
onRequestDuplicate={setConfigToDuplicate}
onRequestDelete={setConfigToDelete}
/>
</Stack>

<CreateGuardrailModal open={isCreateOpen} onClose={() => setIsCreateOpen(false)} />

{configToDuplicate ? (
<CreateGuardrailModal
open
sourceConfig={configToDuplicate}
onClose={() => setConfigToDuplicate(null)}
/>
) : null}

{configToDelete ? (
<DeleteConfirmationModal
open
Expand Down
Loading