diff --git a/.vscode/settings.json b/.vscode/settings.json index eaf6a64d..eb5a19a1 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -5,7 +5,7 @@ "typescript", "typescriptreact" ], - "prettier.configPath": "./.prettierrc.js", + "prettier.configPath": "./prettier.config.cjs", "editor.formatOnSave": true, "editor.codeActionsOnSave": { "source.fixAll.eslint": true diff --git a/.prettierrc.js b/prettier.config.cjs similarity index 100% rename from .prettierrc.js rename to prettier.config.cjs diff --git a/src/__tests__/__snapshots__/toNestObject.ts.snap b/src/__tests__/__snapshots__/toNestError.ts.snap similarity index 100% rename from src/__tests__/__snapshots__/toNestObject.ts.snap rename to src/__tests__/__snapshots__/toNestError.ts.snap diff --git a/src/__tests__/toNestError.ts b/src/__tests__/toNestError.ts new file mode 100644 index 00000000..9b081a5f --- /dev/null +++ b/src/__tests__/toNestError.ts @@ -0,0 +1,206 @@ +import { Field, FieldError, InternalFieldName } from 'react-hook-form'; +import { toNestError } from '../toNestError'; + +const flatObject: Record = { + name: { type: 'st', message: 'first message' }, + 'test.0.name': { type: 'nd', message: 'second message' }, +}; + +const fields = { + name: { + ref: { + reportValidity: vi.fn(), + setCustomValidity: vi.fn(), + }, + }, + unused: { + ref: { name: 'unusedRef' }, + }, +} as any as Record; + +test('transforms flat object to nested object', () => { + expect( + toNestError(flatObject, { fields, shouldUseNativeValidation: false }), + ).toMatchSnapshot(); +}); + +test('transforms flat object to nested object and shouldUseNativeValidation: true', () => { + expect( + toNestError(flatObject, { fields, shouldUseNativeValidation: true }), + ).toMatchSnapshot(); + expect( + (fields.name.ref as HTMLInputElement).reportValidity, + ).toHaveBeenCalledTimes(1); + expect( + (fields.name.ref as HTMLInputElement).setCustomValidity, + ).toHaveBeenCalledTimes(1); + expect( + (fields.name.ref as HTMLInputElement).setCustomValidity, + ).toHaveBeenCalledWith(flatObject.name.message); +}); + +test('transforms flat object to nested object with root error for field array', () => { + const result = toNestError( + { + username: { type: 'username', message: 'username is required' }, + 'fieldArrayWithRootError.0.name': { + type: 'first', + message: 'first message', + }, + 'fieldArrayWithRootError.0.nestFieldArrayWithoutRootError.0.title': { + type: 'title', + message: 'title', + }, + 'fieldArrayWithRootError.0.nestFieldArrayWithRootError': { + type: 'nested-root-title', + message: 'nested root errors', + }, + 'fieldArrayWithRootError.0.nestFieldArrayWithRootError.0.title': { + type: 'nestFieldArrayWithRootError-title', + message: 'nestFieldArrayWithRootError-title', + }, + 'fieldArrayWithRootError.1.name': { + type: 'second', + message: 'second message', + }, + fieldArrayWithRootError: { type: 'root-error', message: 'root message' }, + 'fieldArrayWithoutRootError.0.name': { + type: 'first', + message: 'first message', + }, + 'fieldArrayWithoutRootError.1.name': { + type: 'second', + message: 'second message', + }, + }, + { + fields: { + username: { name: 'username', ref: { name: 'username' } }, + fieldArrayWithRootError: { + name: 'fieldArrayWithRootError', + ref: { name: 'fieldArrayWithRootError' }, + }, + 'fieldArrayWithRootError.0.name': { + name: 'fieldArrayWithRootError.0.name', + ref: { name: 'fieldArrayWithRootError.0.name' }, + }, + 'fieldArrayWithRootError.0.nestFieldArrayWithoutRootError.0.title': { + name: 'fieldArrayWithRootError.0.nestFieldArrayWithoutRootError.0.title', + ref: { + name: 'fieldArrayWithRootError.0.nestFieldArrayWithoutRootError.0.title', + }, + }, + 'fieldArrayWithRootError.0.nestFieldArrayWithRootError': { + name: 'fieldArrayWithRootError.0.nestFieldArrayWithRootError', + ref: { + name: 'fieldArrayWithRootError.0.nestFieldArrayWithRootError', + }, + }, + 'fieldArrayWithRootError.0.nestFieldArrayWithRootError.0.title': { + name: 'fieldArrayWithRootError.0.nestFieldArrayWithRootError.0.title', + ref: { + name: 'fieldArrayWithRootError.0.nestFieldArrayWithRootError.0.title', + }, + }, + 'fieldArrayWithRootError.1.name': { + name: 'fieldArrayWithRootError.1.name', + ref: { name: 'fieldArrayWithRootError.1.name' }, + }, + 'fieldArrayWithoutRootError.0.name': { + name: 'fieldArrayWithoutRootError.0.name', + ref: { name: 'fieldArrayWithoutRootError.0.name' }, + }, + 'fieldArrayWithoutRootError.1.name': { + name: 'fieldArrayWithoutRootError.1.name', + ref: { name: 'fieldArrayWithoutRootError.1.name' }, + }, + }, + names: [ + 'username', + 'fieldArrayWithRootError', + 'fieldArrayWithRootError.0.name', + 'fieldArrayWithRootError.0.nestFieldArrayWithoutRootError.0.title', + 'fieldArrayWithRootError.1.name', + 'fieldArrayWithoutRootError.0.name', + 'fieldArrayWithoutRootError.1.name', + 'fieldArrayWithRootError.0.nestFieldArrayWithRootError', + 'fieldArrayWithRootError.0.nestFieldArrayWithRootError.0.title', + ], + shouldUseNativeValidation: false, + }, + ); + + expect(result).toEqual({ + username: { + type: 'username', + message: 'username is required', + ref: { name: 'username' }, + }, + fieldArrayWithRootError: { + '0': { + name: { + type: 'first', + message: 'first message', + ref: { name: 'fieldArrayWithRootError.0.name' }, + }, + nestFieldArrayWithoutRootError: [ + { + title: { + type: 'title', + message: 'title', + ref: { + name: 'fieldArrayWithRootError.0.nestFieldArrayWithoutRootError.0.title', + }, + }, + }, + ], + nestFieldArrayWithRootError: { + '0': { + title: { + type: 'nestFieldArrayWithRootError-title', + message: 'nestFieldArrayWithRootError-title', + ref: { + name: 'fieldArrayWithRootError.0.nestFieldArrayWithRootError.0.title', + }, + }, + }, + root: { + type: 'nested-root-title', + message: 'nested root errors', + ref: { + name: 'fieldArrayWithRootError.0.nestFieldArrayWithRootError', + }, + }, + }, + }, + '1': { + name: { + type: 'second', + message: 'second message', + ref: { name: 'fieldArrayWithRootError.1.name' }, + }, + }, + root: { + type: 'root-error', + message: 'root message', + ref: { name: 'fieldArrayWithRootError' }, + }, + }, + fieldArrayWithoutRootError: [ + { + name: { + type: 'first', + message: 'first message', + ref: { name: 'fieldArrayWithoutRootError.0.name' }, + }, + }, + { + name: { + type: 'second', + message: 'second message', + ref: { name: 'fieldArrayWithoutRootError.1.name' }, + }, + }, + ], + }); +}); diff --git a/src/__tests__/toNestObject.ts b/src/__tests__/toNestObject.ts deleted file mode 100644 index e416e8b7..00000000 --- a/src/__tests__/toNestObject.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { Field, FieldError, InternalFieldName } from 'react-hook-form'; -import { toNestError } from '../toNestError'; - -const flatObject: Record = { - name: { type: 'st', message: 'first message' }, - 'test.0.name': { type: 'nd', message: 'second message' }, -}; - -const fields = { - name: { - ref: { - reportValidity: vi.fn(), - setCustomValidity: vi.fn(), - }, - }, - unused: { - ref: { name: 'unusedRef' }, - }, -} as any as Record; - -test('transforms flat object to nested object', () => { - expect( - toNestError(flatObject, { fields, shouldUseNativeValidation: false }), - ).toMatchSnapshot(); -}); - -test('transforms flat object to nested object and shouldUseNativeValidation: true', () => { - expect( - toNestError(flatObject, { fields, shouldUseNativeValidation: true }), - ).toMatchSnapshot(); - expect( - (fields.name.ref as HTMLInputElement).reportValidity, - ).toHaveBeenCalledTimes(1); - expect( - (fields.name.ref as HTMLInputElement).setCustomValidity, - ).toHaveBeenCalledTimes(1); - expect( - (fields.name.ref as HTMLInputElement).setCustomValidity, - ).toHaveBeenCalledWith(flatObject.name.message); -}); diff --git a/src/toNestError.ts b/src/toNestError.ts index 21617695..edb6e5e4 100644 --- a/src/toNestError.ts +++ b/src/toNestError.ts @@ -5,6 +5,7 @@ import { Field, ResolverOptions, FieldValues, + InternalFieldName, } from 'react-hook-form'; import { validateFieldsNatively } from './validateFieldsNatively'; @@ -17,13 +18,37 @@ export const toNestError = ( const fieldErrors = {} as FieldErrors; for (const path in errors) { const field = get(options.fields, path) as Field['_f'] | undefined; + const error = Object.assign(errors[path] || {}, { + ref: field && field.ref, + }); - set( - fieldErrors, - path, - Object.assign(errors[path] || {}, { ref: field && field.ref }), - ); + if (isNameInFieldArray(options.names || Object.keys(errors), path)) { + updateFieldArrayRootError(fieldErrors, error, path); + } else { + set(fieldErrors, path, error); + } } return fieldErrors; }; + +const updateFieldArrayRootError = ( + errors: FieldErrors, + error: unknown, + name: InternalFieldName, +): FieldErrors => { + const fieldArrayErrors = Object.assign({}, compact(get(errors, name))); + + set(fieldArrayErrors, 'root', error); + set(errors, name, fieldArrayErrors); + + return errors; +}; + +const compact = (value: TValue[]) => + Array.isArray(value) ? value.filter(Boolean) : []; + +const isNameInFieldArray = ( + names: InternalFieldName[], + name: InternalFieldName, +) => names.some((n) => n.startsWith(name + '.'));