diff --git a/web/packages/common/src/utils/entityName.test.ts b/web/packages/common/src/utils/entityName.test.ts new file mode 100644 index 00000000000..c94cc88777f --- /dev/null +++ b/web/packages/common/src/utils/entityName.test.ts @@ -0,0 +1,134 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { + ENTITY_NAME_MAX_LENGTH, + ENTITY_NAME_REGEXP, + entityNameSchema, + getEntityNameError, + sanitizeEntityName, + toValidEntityName, +} from '@nemo/common/src/utils/entityName'; +import { entitiesCreateEntityBodyNameRegExp } from '@nemo/sdk/generated/platform/zod/entity-store'; +import { + filesCreateFilesetBodyNameMax, + filesCreateFilesetBodyNameRegExp, +} from '@nemo/sdk/generated/platform/zod/files'; +import { modelsCreateProviderBodyNameRegExp } from '@nemo/sdk/generated/platform/zod/model-providers'; +import { + secretsCreateSecretBodyNameMax, + secretsCreateSecretBodyNameRegExp, +} from '@nemo/sdk/generated/platform/zod/secrets'; + +describe('generated schema agreement', () => { + it.each([ + ['entity', entitiesCreateEntityBodyNameRegExp], + ['fileset', filesCreateFilesetBodyNameRegExp], + ['secret', secretsCreateSecretBodyNameRegExp], + ['model provider', modelsCreateProviderBodyNameRegExp], + ])('%s create schema uses the same name pattern', (_name, pattern) => { + expect(pattern.source).toBe(ENTITY_NAME_REGEXP.source); + }); + + it.each([ + ['fileset', filesCreateFilesetBodyNameMax], + ['secret', secretsCreateSecretBodyNameMax], + ])('%s create schema agrees on the max length', (_name, max) => { + expect(max).toBe(ENTITY_NAME_MAX_LENGTH); + }); +}); + +describe('getEntityNameError', () => { + it.each(['sparl', 'a1', 'my-provider', 'llama-3.2-3b-instruct@v1.0.0+A100'.toLowerCase(), 'a_b'])( + 'accepts %s', + (value) => { + expect(getEntityNameError(value)).toBeUndefined(); + } + ); + + it('reports a missing value', () => { + expect(getEntityNameError('')).toBe('Name is required.'); + }); + + it('reports uppercase with a lowercased suggestion', () => { + expect(getEntityNameError('Sparl')).toBe('Name must be lowercase. Try "sparl".'); + }); + + it('lists the disallowed characters', () => { + expect(getEntityNameError('invalid name!')).toContain('cannot contain spaces, "!"'); + }); + + it('reports a leading non-letter', () => { + expect(getEntityNameError('1provider')).toBe( + 'Name must start with a lowercase letter. Try "provider".' + ); + }); + + it('reports consecutive hyphens', () => { + expect(getEntityNameError('my--provider')).toBe( + 'Name cannot contain consecutive hyphens. Try "my-provider".' + ); + }); + + it('reports a trailing hyphen', () => { + expect(getEntityNameError('myprovider-')).toBe( + 'Name cannot end with a hyphen. Try "myprovider".' + ); + }); + + it('reports too-short values without a bogus suggestion', () => { + expect(getEntityNameError('a')).toBe('Name must be at least 2 characters.'); + }); + + it('reports too-long values with the current length', () => { + expect(getEntityNameError('a'.repeat(64))).toContain( + 'Name must be 63 characters or fewer (currently 64).' + ); + }); + + it('uses the supplied label', () => { + expect(getEntityNameError('', 'Provider name')).toBe('Provider name is required.'); + }); +}); + +describe('sanitizeEntityName', () => { + it.each([ + 'Qwen3.6-35B-A3B-MTP-GGUF', + 'mistralai/Mistral-7B-Instruct-v0.3', + 'hello world', + ' leading-trailing ', + '123-starts-with-digit', + 'has--double--dashes', + 'ends-with-dash-', + 'x'.repeat(200), + ])('produces a valid name for %s', (input) => { + const result = sanitizeEntityName(input); + expect(result).toBeDefined(); + expect(ENTITY_NAME_REGEXP.test(result as string)).toBe(true); + }); + + it('returns undefined when nothing valid remains', () => { + expect(sanitizeEntityName('!!!')).toBeUndefined(); + expect(sanitizeEntityName('')).toBeUndefined(); + }); +}); + +describe('toValidEntityName', () => { + it('falls back when nothing valid remains', () => { + expect(toValidEntityName('!!!', 'provider')).toBe('provider'); + }); +}); + +describe('entityNameSchema', () => { + it('surfaces the rule-specific message', () => { + const result = entityNameSchema('Provider name').safeParse('Sparl'); + expect(result.success).toBe(false); + expect(result.success === false && result.error.issues[0].message).toBe( + 'Provider name must be lowercase. Try "sparl".' + ); + }); + + it('passes valid names through', () => { + expect(entityNameSchema().safeParse('sparl')).toMatchObject({ success: true, data: 'sparl' }); + }); +}); diff --git a/web/packages/common/src/utils/entityName.ts b/web/packages/common/src/utils/entityName.ts new file mode 100644 index 00000000000..5e9a589e5bc --- /dev/null +++ b/web/packages/common/src/utils/entityName.ts @@ -0,0 +1,90 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { entitiesCreateEntityBodyNameRegExp } from '@nemo/sdk/generated/platform/zod/entity-store'; +import { z } from 'zod'; + +export const ENTITY_NAME_REGEXP = entitiesCreateEntityBodyNameRegExp; + +export const ENTITY_NAME_MIN_LENGTH = 2; +// Entity-store schema declares no maxLength; the test pins this against ones that do. +export const ENTITY_NAME_MAX_LENGTH = 63; + +export const ENTITY_NAME_HELP = + 'Must start with a lowercase letter and be 2–63 characters. Lowercase letters, numbers, and - _ . @ + only. No consecutive or trailing hyphens.'; + +const INVALID_BODY = /[^a-z0-9\-@.+_]+/g; +const INVALID_BODY_CHAR = /[^a-z0-9\-@.+_]/g; +const COLLAPSE_DASHES = /-{2,}/g; +const STRIP_LEADING_NON_LETTER = /^[^a-z]+/; +const STRIP_TRAILING_DASH = /-+$/; + +/** Rewrite input to satisfy `ENTITY_NAME_REGEXP`, or `undefined` if nothing valid remains. */ +export function sanitizeEntityName(input: string): string | undefined { + const sanitized = input + .trim() + .toLowerCase() + .replace(INVALID_BODY, '-') + .replace(COLLAPSE_DASHES, '-') + .replace(STRIP_LEADING_NON_LETTER, '') + .replace(STRIP_TRAILING_DASH, '') + .slice(0, ENTITY_NAME_MAX_LENGTH) + .replace(STRIP_TRAILING_DASH, ''); + + return sanitized.length >= ENTITY_NAME_MIN_LENGTH ? sanitized : undefined; +} + +/** Rewrite input to satisfy `ENTITY_NAME_REGEXP`, falling back when nothing valid remains. */ +export function toValidEntityName(input: string, fallback: string): string { + return sanitizeEntityName(input) ?? fallback; +} + +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}"`)); +} + +/** First rule the value breaks, phrased for a form field, or `undefined` if valid. */ +export function getEntityNameError(value: string, label = 'Name'): string | undefined { + if (!value) return `${label} is required.`; + if (ENTITY_NAME_REGEXP.test(value)) return undefined; + + const suggestion = sanitizeEntityName(value); + const hint = suggestion && suggestion !== value ? ` Try "${suggestion}".` : ''; + + if (value.length > ENTITY_NAME_MAX_LENGTH) { + return `${label} must be ${ENTITY_NAME_MAX_LENGTH} characters or fewer (currently ${value.length}).${hint}`; + } + if (value.length < ENTITY_NAME_MIN_LENGTH) { + return `${label} must be at least ${ENTITY_NAME_MIN_LENGTH} characters.`; + } + if (/[A-Z]/.test(value)) { + return `${label} must be lowercase.${hint}`; + } + + const invalidChars = listInvalidChars(value); + if (invalidChars.length > 0) { + return `${label} cannot contain ${invalidChars.join(', ')}. Use lowercase letters, numbers, and - _ . @ + only.${hint}`; + } + if (!/^[a-z]/.test(value)) { + return `${label} must start with a lowercase letter.${hint}`; + } + if (value.includes('--')) { + return `${label} cannot contain consecutive hyphens.${hint}`; + } + if (value.endsWith('-')) { + return `${label} cannot end with a hyphen.${hint}`; + } + + return `${label} is invalid. ${ENTITY_NAME_HELP}${hint}`; +} + +/** Zod string schema enforcing `ENTITY_NAME_REGEXP` with per-rule error messages. */ +export function entityNameSchema(label = 'Name'): z.ZodEffects { + return z.string().superRefine((value, ctx) => { + const message = getEntityNameError(value, label); + if (message) { + ctx.addIssue({ code: z.ZodIssueCode.custom, message }); + } + }); +} diff --git a/web/packages/common/src/utils/filesetName.test.ts b/web/packages/common/src/utils/filesetName.test.ts index 2e20ad22c22..1ad705fad5e 100644 --- a/web/packages/common/src/utils/filesetName.test.ts +++ b/web/packages/common/src/utils/filesetName.test.ts @@ -1,14 +1,11 @@ // SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { - FILESET_NAME_MAX_LENGTH, - FILESET_NAME_REGEXP, - toValidFilesetName, -} from '@nemo/common/src/utils/filesetName'; +import { ENTITY_NAME_REGEXP } from '@nemo/common/src/utils/entityName'; +import { FILESET_NAME_MAX_LENGTH, toValidFilesetName } from '@nemo/common/src/utils/filesetName'; describe('toValidFilesetName', () => { - describe('produces output that satisfies FILESET_NAME_REGEXP', () => { + describe('produces output that satisfies ENTITY_NAME_REGEXP', () => { const inputs = [ 'Qwen3.6-35B-A3B-MTP-GGUF', // HF slug with uppercase 'mistralai/Mistral-7B-Instruct-v0.3', @@ -25,7 +22,7 @@ describe('toValidFilesetName', () => { ]; it.each(inputs)('%s -> matches regex', (input) => { const out = toValidFilesetName(input); - expect(out).toMatch(FILESET_NAME_REGEXP); + expect(out).toMatch(ENTITY_NAME_REGEXP); }); }); diff --git a/web/packages/common/src/utils/filesetName.ts b/web/packages/common/src/utils/filesetName.ts index 785a04f455c..a21b14d3ea6 100644 --- a/web/packages/common/src/utils/filesetName.ts +++ b/web/packages/common/src/utils/filesetName.ts @@ -1,59 +1,14 @@ // SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -/** - * Mirrors the entity store's RFC-1035-ish name pattern from - * `packages/nmp_common/src/nmp/common/entities/constants.py` (`NAME_PATTERN`): - * - * ^[a-z](?!.*--)[a-z0-9\-@.+_]{1,62}(? { await user.type(nameInput, 'tiny-gpt2-A'); await user.tab(); - expect(await screen.findByText(/must start with a lowercase letter/i)).toBeInTheDocument(); + expect( + await screen.findByText(/Name must be lowercase\. Try "tiny-gpt2-a"\./) + ).toBeInTheDocument(); }); it('shows inline validation error when fileset name starts with a digit', async () => { diff --git a/web/packages/studio/src/routes/FilesetNewRoute/types.ts b/web/packages/studio/src/routes/FilesetNewRoute/types.ts index b370bce3bb4..fc6d9bd2ee4 100644 --- a/web/packages/studio/src/routes/FilesetNewRoute/types.ts +++ b/web/packages/studio/src/routes/FilesetNewRoute/types.ts @@ -1,30 +1,15 @@ // SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { FILESET_NAME_MAX_LENGTH, FILESET_NAME_REGEXP } from '@nemo/common/src/utils/filesetName'; +import { entityNameSchema } from '@nemo/common/src/utils/entityName'; import { FilesetPurpose } from '@nemo/sdk/generated/platform/schema'; import { FilesCreateFilesetBody } from '@nemo/sdk/generated/platform/zod/files'; -import { - DATASET_NAME_PATTERN_MESSAGE, - DATASET_NAME_REQUIRED_MESSAGE, - DATASET_TYPE_CUSTOM, - DATASET_TYPE_SAMPLE, -} from '@studio/routes/FilesetNewRoute/constants'; +import { DATASET_TYPE_CUSTOM, DATASET_TYPE_SAMPLE } from '@studio/routes/FilesetNewRoute/constants'; import { z } from 'zod'; -/** - * Override the SDK-generated name validation. The generated zod uses the Files - * service DTO's loose pattern (`^[\w\-.]+$`, max 255); the entity store - * downstream enforces a stricter RFC-1035-ish pattern. Validate against the - * strict pattern here so the user sees a useful inline error instead of a 422. - */ +// Same pattern as the generated zod, but reports which rule the value breaks. export const DatasetCreateFilesetFormSchema = FilesCreateFilesetBody.extend({ - name: z - .string() - .trim() - .min(1, DATASET_NAME_REQUIRED_MESSAGE) - .max(FILESET_NAME_MAX_LENGTH) - .regex(FILESET_NAME_REGEXP, DATASET_NAME_PATTERN_MESSAGE), + name: z.string().trim().pipe(entityNameSchema('Name')), purpose: z.nativeEnum(FilesetPurpose), }); diff --git a/web/packages/studio/src/routes/InferenceProvidersListRoute/CreateInferenceProviderSidePanel/index.test.tsx b/web/packages/studio/src/routes/InferenceProvidersListRoute/CreateInferenceProviderSidePanel/index.test.tsx index 5cfaf44042c..022f954194b 100644 --- a/web/packages/studio/src/routes/InferenceProvidersListRoute/CreateInferenceProviderSidePanel/index.test.tsx +++ b/web/packages/studio/src/routes/InferenceProvidersListRoute/CreateInferenceProviderSidePanel/index.test.tsx @@ -16,7 +16,7 @@ import { CreateInferenceProviderSidePanel } from '@studio/routes/InferenceProvid import { render } from '@studio/tests/util/render'; import { fireEvent, screen, waitFor, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import { http, HttpResponse } from 'msw'; +import { delay, http, HttpResponse } from 'msw'; const mockOnClose = vi.fn(); @@ -115,20 +115,94 @@ describe('CreateInferenceProviderSidePanel', () => { }); describe('Form validation', () => { - it('shows validation error for invalid name characters', async () => { + async function typeName(value: string) { const user = userEvent.setup(); render(); const dialog = await screen.findByTestId('nv-side-panel-content'); await openModelProviderSelect(user); await user.click(screen.getByRole('option', { name: /openai compatible endpoint/i })); const nameInput = within(dialog).getByRole('textbox', { name: 'Name' }); - fireEvent.change(nameInput, { target: { value: 'invalid name!' } }); + fireEvent.change(nameInput, { target: { value } }); fireEvent.blur(nameInput); + } + + it('shows validation error for invalid name characters', async () => { + await typeName('invalid name!'); + expect(await screen.findByText(/cannot contain spaces, "!"/)).toBeInTheDocument(); + }); + + it('shows a lowercase error with a suggestion for a capitalized name', async () => { + await typeName('Sparl'); + expect(await screen.findByText(/Name must be lowercase\. Try "sparl"\./)).toBeInTheDocument(); + }); + + it('shows a specific error for a name that does not start with a letter', async () => { + await typeName('1provider'); + expect(await screen.findByText(/must start with a lowercase letter/)).toBeInTheDocument(); + }); + + it('shows a specific error for consecutive hyphens', async () => { + await typeName('my--provider'); + expect(await screen.findByText(/cannot contain consecutive hyphens/)).toBeInTheDocument(); + }); + + it('shows a specific error for a trailing hyphen', async () => { + await typeName('myprovider-'); + expect(await screen.findByText(/cannot end with a hyphen/)).toBeInTheDocument(); + }); + + it('shows a specific error for a name that is too long', async () => { + await typeName('a'.repeat(64)); + expect(await screen.findByText(/must be 63 characters or fewer/)).toBeInTheDocument(); + }); + + it('shows an error when the name is already taken', async () => { + server.use( + http.get(`${PLATFORM_BASE_URL}/apis/models/v2/workspaces/:workspace/providers`, () => + HttpResponse.json({ + data: [{ name: 'taken', host_url: 'https://api.example.com/v1' }], + pagination: { + page: 1, + page_size: 100, + current_page_size: 1, + total_pages: 1, + total_results: 1, + }, + }) + ) + ); + await typeName('taken'); expect( - await screen.findByText(/Use only letters, numbers, hyphens, underscores, or dots/) + await screen.findByText(/A provider with this name already exists/) ).toBeInTheDocument(); }); + it('blocks submit until the existing providers have loaded', async () => { + server.use( + http.get( + `${PLATFORM_BASE_URL}/apis/models/v2/workspaces/:workspace/providers`, + async () => { + await delay(200); + return HttpResponse.json({ + data: [{ name: 'taken', host_url: 'https://api.example.com/v1' }], + pagination: { + page: 1, + page_size: 100, + current_page_size: 1, + total_pages: 1, + total_results: 1, + }, + }); + } + ) + ); + render(); + const dialog = await screen.findByTestId('nv-side-panel-content'); + const submit = within(dialog).getByRole('button', { name: 'Add Provider' }); + expect(submit).toBeDisabled(); + await waitFor(() => expect(submit).toBeEnabled()); + }); + it('shows validation error for invalid URL', async () => { const user = userEvent.setup(); render(); diff --git a/web/packages/studio/src/routes/InferenceProvidersListRoute/CreateInferenceProviderSidePanel/index.tsx b/web/packages/studio/src/routes/InferenceProvidersListRoute/CreateInferenceProviderSidePanel/index.tsx index b2e0feb7093..1580eff242d 100644 --- a/web/packages/studio/src/routes/InferenceProvidersListRoute/CreateInferenceProviderSidePanel/index.tsx +++ b/web/packages/studio/src/routes/InferenceProvidersListRoute/CreateInferenceProviderSidePanel/index.tsx @@ -14,12 +14,12 @@ import { zodResolver } from '@hookform/resolvers/zod'; import { ControlledTextInput } from '@nemo/common/src/components/form/ControlledTextInput'; import { LoadingButton } from '@nemo/common/src/components/LoadingButton'; import { useToast } from '@nemo/common/src/providers/toast/useToast'; +import { ENTITY_NAME_HELP, entityNameSchema } from '@nemo/common/src/utils/entityName'; import { getModelsListProvidersQueryKey, useModelsCreateProvider, useModelsListProviders, } from '@nemo/sdk/generated/platform/api'; -import { modelsCreateProviderBodyNameRegExp } from '@nemo/sdk/generated/platform/zod/model-providers'; import { Button, Flex, FormField, SidePanel, Stack, Text } from '@nvidia/foundations-react-core'; import { getErrorMessage } from '@studio/api/common/utils'; import { InferenceModelProviderSelect } from '@studio/routes/InferenceProvidersListRoute/CreateInferenceProviderSidePanel/InferenceModelProviderSelect'; @@ -37,14 +37,7 @@ import { z } from 'zod'; const PROVIDERS_PAGE_SIZE = 100; const createProviderFormSchema = z.object({ - name: z - .string() - .min(1, 'Name is required') - .max(255) - .regex( - modelsCreateProviderBodyNameRegExp, - 'Use only letters, numbers, hyphens, underscores, or dots.' - ), + name: entityNameSchema('Name'), host_url: z.string().min(1, 'Host URL is required').url('Enter a valid URL').max(2048), api_key_secret_name: z.string().max(255).optional().or(z.literal('')), }); @@ -87,7 +80,7 @@ export const CreateInferenceProviderSidePanel: FC(defaultPreset ?? 'custom'); const [createSecretModalOpen, setCreateSecretModalOpen] = useState(false); - const { data: providersData } = useModelsListProviders( + const { data: providersData, isLoading: isProvidersLoading } = useModelsListProviders( workspace, { page_size: PROVIDERS_PAGE_SIZE }, { query: { enabled: open && !!workspace } } @@ -130,6 +123,15 @@ export const CreateInferenceProviderSidePanel: FC + createProviderFormSchema.refine((data) => !existingNames.has(data.name), { + path: ['name'], + message: 'A provider with this name already exists.', + }), + [existingNames] + ); + const { control, reset: resetForm, @@ -137,7 +139,7 @@ export const CreateInferenceProviderSidePanel: FC Cancel - + Add Provider @@ -269,7 +275,7 @@ export const CreateInferenceProviderSidePanel: FC diff --git a/web/packages/studio/src/routes/SecretsListRoute/CreateSecretModal/constants.ts b/web/packages/studio/src/routes/SecretsListRoute/CreateSecretModal/constants.ts deleted file mode 100644 index 130098fbc85..00000000000 --- a/web/packages/studio/src/routes/SecretsListRoute/CreateSecretModal/constants.ts +++ /dev/null @@ -1,8 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -// TODO get from sdk once issue #4082 is resolved -export const SECRET_NAME_REGEXP = new RegExp('^[a-z](?!.*--)[a-z0-9\\-@.+_]{1,62}(? = ({ formFieldProps={{ slotInfo: 'Best practice: Use lowercase letters, numbers, and hyphens only to ensure compatibility with Kubernetes naming conventions.', - slotHelp: SECRET_NAME_HELP, + slotHelp: ENTITY_NAME_HELP, slotError: errors.name?.message, }} /> diff --git a/web/packages/studio/src/routes/agents/AgentEvaluationsRoute/components/SubmitEvaluationModal.tsx b/web/packages/studio/src/routes/agents/AgentEvaluationsRoute/components/SubmitEvaluationModal.tsx index 2e674eca1ee..effab6a3a8c 100644 --- a/web/packages/studio/src/routes/agents/AgentEvaluationsRoute/components/SubmitEvaluationModal.tsx +++ b/web/packages/studio/src/routes/agents/AgentEvaluationsRoute/components/SubmitEvaluationModal.tsx @@ -9,7 +9,7 @@ import { ControlledTextInput } from '@nemo/common/src/components/form/Controlled import { FormModal, type FormModalProps } from '@nemo/common/src/components/FormModal'; import { getURNFromNamedEntityRef } from '@nemo/common/src/namedEntity'; import { useToast } from '@nemo/common/src/providers/toast/useToast'; -import { FILESET_NAME_REGEXP } from '@nemo/common/src/utils/filesetName'; +import { getEntityNameError } from '@nemo/common/src/utils/entityName'; import { useAgentsListAgents } from '@nemo/sdk/generated/agents/api'; import type { AgentEvaluateJobRequest } from '@nemo/sdk/generated/evaluator/schema'; import { filesDownloadFile, filesListFilesetFiles } from '@nemo/sdk/generated/platform/api'; @@ -38,7 +38,6 @@ import { parsePersistedSpec, type PersistedEvalSpec, } from '@studio/routes/agents/AgentEvaluationsRoute/components/submitEvaluationSpec'; -import { DATASET_NAME_PATTERN_MESSAGE } from '@studio/routes/FilesetNewRoute/constants'; import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { type FC, useEffect, useRef, useState } from 'react'; import { FormProvider, type SubmitHandler, useForm, useWatch } from 'react-hook-form'; @@ -73,17 +72,11 @@ const makeSubmitEvaluationSchema = (requiresJudgeModel: () => boolean) => }); } if (data.mode === MODE_DEFAULT) { - const name = data.newName.trim(); - if (!name) { + const nameError = getEntityNameError(data.newName.trim()); + if (nameError) { ctx.addIssue({ code: z.ZodIssueCode.custom, - message: 'Name is required', - path: ['newName'], - }); - } else if (!FILESET_NAME_REGEXP.test(name)) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: DATASET_NAME_PATTERN_MESSAGE, + message: nameError, path: ['newName'], }); }