From a77b8e288d87ca112159a19556e9c50b00d17320 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piotr=20Po=C5=9Bpiech?= <37746259+piotrpospiech@users.noreply.github.com> Date: Fri, 24 May 2024 09:46:40 +0200 Subject: [PATCH] Improve Zod error messages (#1320) --- .../__tests__/ZodBridge.ts | 9 ++++--- packages/uniforms-bridge-zod/src/ZodBridge.ts | 27 ++++++++++++++++--- packages/uniforms/__suites__/ErrorsField.tsx | 4 ++- 3 files changed, 32 insertions(+), 8 deletions(-) diff --git a/packages/uniforms-bridge-zod/__tests__/ZodBridge.ts b/packages/uniforms-bridge-zod/__tests__/ZodBridge.ts index 561525e0d..5c530f95a 100644 --- a/packages/uniforms-bridge-zod/__tests__/ZodBridge.ts +++ b/packages/uniforms-bridge-zod/__tests__/ZodBridge.ts @@ -132,7 +132,7 @@ describe('ZodBridge', () => { const schema = object({ a: string(), b: number() }); const bridge = new ZodBridge({ schema }); const error = bridge.getValidator()({}); - const messages = error?.issues?.map(issue => issue.message); + const messages = ['A: Required', 'B: Required']; expect(bridge.getErrorMessages(error)).toEqual(messages); }); @@ -140,7 +140,10 @@ describe('ZodBridge', () => { const schema = object({ a: array(array(string())) }); const bridge = new ZodBridge({ schema }); const error = bridge.getValidator()({ a: [['x', 'y', 0], [1]] }); - const messages = error?.issues?.map(issue => issue.message); + const messages = [ + 'A (0, 2): Expected string, received number', + 'A (1, 0): Expected string, received number', + ]; expect(bridge.getErrorMessages(error)).toEqual(messages); }); @@ -148,7 +151,7 @@ describe('ZodBridge', () => { const schema = object({ a: object({ b: object({ c: string() }) }) }); const bridge = new ZodBridge({ schema }); const error = bridge.getValidator()({ a: { b: { c: 1 } } }); - const messages = error?.issues?.map(issue => issue.message); + const messages = ['C: Expected string, received number']; expect(bridge.getErrorMessages(error)).toEqual(messages); }); }); diff --git a/packages/uniforms-bridge-zod/src/ZodBridge.ts b/packages/uniforms-bridge-zod/src/ZodBridge.ts index c6a36667a..84bb972b4 100644 --- a/packages/uniforms-bridge-zod/src/ZodBridge.ts +++ b/packages/uniforms-bridge-zod/src/ZodBridge.ts @@ -10,6 +10,7 @@ import { ZodDefault, ZodEnum, ZodError, + ZodIssue, ZodNativeEnum, ZodNumber, ZodNumberDef, @@ -28,6 +29,23 @@ function isNativeEnumValue(value: unknown) { return typeof value !== 'string'; } +function getLabel(value: unknown) { + return upperFirst(lowerCase(joinName(null, value).slice(-1)[0])); +} + +function getFullLabel(path: ZodIssue['path'], indexes: number[] = []): string { + const lastElement = path[path.length - 1]; + + if (typeof lastElement === 'number') { + const slicedPath = path.slice(0, path.length - 1); + return getFullLabel(slicedPath, [lastElement, ...indexes]); + } + + return indexes.length > 0 + ? `${getLabel(path)} (${indexes.join(', ')})` + : getLabel(path); +} + /** Option type used in SelectField or RadioField */ type Option = { disabled?: boolean; @@ -73,9 +91,10 @@ export default class ZodBridge extends Bridge { getErrorMessages(error: unknown) { if (error instanceof ZodError) { - // TODO: There's no information which field caused which error. We could - // do some generic prefixing, e.g., `{name}: {message}`. - return error.issues.map(issue => issue.message); + return error.issues.map(issue => { + const name = getFullLabel(issue.path); + return `${name}: ${issue.message}`; + }); } if (error instanceof Error) { @@ -149,7 +168,7 @@ export default class ZodBridge extends Bridge { getProps(name: string) { const props: UnknownObject & { options?: Option[] } = { ...(this.provideDefaultLabelFromFieldName && { - label: upperFirst(lowerCase(joinName(null, name).slice(-1)[0])), + label: getLabel(name), }), required: true, }; diff --git a/packages/uniforms/__suites__/ErrorsField.tsx b/packages/uniforms/__suites__/ErrorsField.tsx index ef333fc4e..5aa31e884 100644 --- a/packages/uniforms/__suites__/ErrorsField.tsx +++ b/packages/uniforms/__suites__/ErrorsField.tsx @@ -15,7 +15,9 @@ export function testErrorsField(ErrorsField: ComponentType) { ]), schema: z.object({ x: z.boolean(), y: z.number(), z: z.string() }), }); - expect(screen.getAllByText(errorMessage)).toHaveLength(3); + expect(screen.getByText(`X: ${errorMessage}`)).toBeInTheDocument(); + expect(screen.getByText(`Y: ${errorMessage}`)).toBeInTheDocument(); + expect(screen.getByText(`Z: ${errorMessage}`)).toBeInTheDocument(); }); test(' - renders error from props', () => {