From 1bab742c5857e709b45ccac1e08ce8a62c631dce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tobias=20H=C3=B8egh?= Date: Tue, 28 Jan 2025 22:29:39 +0100 Subject: [PATCH] feat(Forms): add support for arrays with errors for `onChangeValidator` and `onBlurValidator` --- .../FieldBlock/__tests__/FieldBlock.test.tsx | 262 +++++++++++++----- .../forms/hooks/DataValueWritePropsDocs.ts | 4 +- .../extensions/forms/hooks/useFieldProps.ts | 44 ++- .../dnb-eufemia/src/extensions/forms/types.ts | 2 + 4 files changed, 222 insertions(+), 90 deletions(-) diff --git a/packages/dnb-eufemia/src/extensions/forms/FieldBlock/__tests__/FieldBlock.test.tsx b/packages/dnb-eufemia/src/extensions/forms/FieldBlock/__tests__/FieldBlock.test.tsx index 5f881d0fb44..a288b9745b0 100644 --- a/packages/dnb-eufemia/src/extensions/forms/FieldBlock/__tests__/FieldBlock.test.tsx +++ b/packages/dnb-eufemia/src/extensions/forms/FieldBlock/__tests__/FieldBlock.test.tsx @@ -10,7 +10,7 @@ import { runAnimation, simulateAnimationEnd, } from '../../../../components/height-animation/__tests__/HeightAnimationUtils' -import { Field, Form } from '../..' +import { Field, Form, Validator } from '../..' import nbNO from '../../constants/locales/nb-NO' import enGB from '../../constants/locales/en-GB' @@ -775,6 +775,191 @@ describe('FieldBlock', () => { }) }) + describe('summarize errors', () => { + it('should summarize errors in one FormStatus components', () => { + const MockComponent = () => { + useFieldProps({ + required: true, + validateInitially: true, + }) + + return null + } + + render( + + + + ) + + expect(document.querySelectorAll('.dnb-form-status')).toHaveLength( + 1 + ) + expect( + document.querySelector('.dnb-form-status').textContent + ).toBe( + nb.Field.errorSummary + 'Error message' + nb.Field.errorRequired + ) + }) + + it('should summarize errors for nested FieldBlocks', () => { + const nested = new Error('Nested') + const outer = new Error('Outer') + + const MockComponent = () => { + useFieldProps({ + id: 'unique', + error: nested, + }) + + return content + } + + render( + + + + ) + + expect(document.querySelectorAll('.dnb-form-status')).toHaveLength( + 1 + ) + expect( + document.querySelector('.dnb-form-status').textContent + ).toBe(nb.Field.errorSummary + 'Outer' + 'Nested') + }) + + it('should not summarize errors when "disableStatusSummary" is true', () => { + const nested = new Error('Nested') + const outer = new Error('Outer') + + const MockComponent = () => { + useFieldProps({ + id: 'unique', + error: nested, + }) + + return content + } + + render( + + + + ) + + expect(document.querySelectorAll('.dnb-form-status')).toHaveLength( + 2 + ) + expect( + document.querySelectorAll('.dnb-form-status')[0].textContent + ).toBe('Outer') + expect( + document.querySelectorAll('.dnb-form-status')[1].textContent + ).toBe('Nested') + }) + + it('should summarize errors when returned in onChangeValidator', () => { + const onChangeValidator: Validator = jest.fn(() => { + return [ + new Error('Error message one'), + new Error('Error message two'), + ] + }) + + render( + + ) + + expect( + document.querySelector('.dnb-form-status').textContent + ).toBe( + nb.Field.errorSummary + 'Error message one' + 'Error message two' + ) + }) + + it('should summarize errors when returned in onBlurValidator', () => { + const onBlurValidator: Validator = jest.fn(() => { + return [ + new Error('Error message one'), + new Error('Error message two'), + ] + }) + + render( + + ) + + expect( + document.querySelector('.dnb-form-status').textContent + ).toBe( + nb.Field.errorSummary + 'Error message one' + 'Error message two' + ) + }) + + it('should summarize errors when returned in async onChangeValidator', async () => { + const onChangeValidator: Validator = jest.fn(async () => { + return [ + new Error('Error message one'), + new Error('Error message two'), + ] + }) + + render( + + ) + + await waitFor(() => { + expect( + document.querySelector('.dnb-form-status').textContent + ).toBe( + nb.Field.errorSummary + + 'Error message one' + + 'Error message two' + ) + }) + }) + + it('should summarize errors when returned in async onBlurValidator', async () => { + const onBlurValidator: Validator = jest.fn(async () => { + return [ + new Error('Error message one'), + new Error('Error message two'), + ] + }) + + render( + + ) + + await waitFor(() => { + expect( + document.querySelector('.dnb-form-status').textContent + ).toBe( + nb.Field.errorSummary + + 'Error message one' + + 'Error message two' + ) + }) + }) + }) + describe('FormStatus with animation', () => { initializeTestSetup() @@ -883,81 +1068,6 @@ describe('FieldBlock', () => { }) }) - it('should summarize errors in one FormStatus components', () => { - const MockComponent = () => { - useFieldProps({ - required: true, - validateInitially: true, - }) - - return null - } - - render( - - - - ) - - expect(document.querySelectorAll('.dnb-form-status')).toHaveLength(1) - expect(document.querySelector('.dnb-form-status').textContent).toBe( - nb.Field.errorSummary + 'Error message' + nb.Field.errorRequired - ) - }) - - it('should summarize errors for nested FieldBlocks', () => { - const nested = new Error('Nested') - const outer = new Error('Outer') - - const MockComponent = () => { - useFieldProps({ - id: 'unique', - error: nested, - }) - - return content - } - - render( - - - - ) - - expect(document.querySelectorAll('.dnb-form-status')).toHaveLength(1) - expect(document.querySelector('.dnb-form-status').textContent).toBe( - nb.Field.errorSummary + 'Outer' + 'Nested' - ) - }) - - it('should not summarize errors when "disableStatusSummary" is true', () => { - const nested = new Error('Nested') - const outer = new Error('Outer') - - const MockComponent = () => { - useFieldProps({ - id: 'unique', - error: nested, - }) - - return content - } - - render( - - - - ) - - expect(document.querySelectorAll('.dnb-form-status')).toHaveLength(2) - expect( - document.querySelectorAll('.dnb-form-status')[0].textContent - ).toBe('Outer') - expect( - document.querySelectorAll('.dnb-form-status')[1].textContent - ).toBe('Nested') - }) - describe('fieldState', () => { it('should show indicator when fieldState is set to pending', async () => { render( diff --git a/packages/dnb-eufemia/src/extensions/forms/hooks/DataValueWritePropsDocs.ts b/packages/dnb-eufemia/src/extensions/forms/hooks/DataValueWritePropsDocs.ts index 5e43b45ec6f..0f764bb5e4b 100644 --- a/packages/dnb-eufemia/src/extensions/forms/hooks/DataValueWritePropsDocs.ts +++ b/packages/dnb-eufemia/src/extensions/forms/hooks/DataValueWritePropsDocs.ts @@ -77,12 +77,12 @@ export const DataValueWritePropsProperties: PropertiesTableProps = { status: 'optional', }, onChangeValidator: { - doc: 'Custom validator function that is triggered on every change done by the user. The function can be either asynchronous or synchronous. The first parameter is the value, and the second parameter returns an object containing { errorMessages, connectWithPath, validators }.', + doc: 'Custom validator function where you can return `undefined`, `Error`, `FormError` or an Array with either several other validators or several `Error` or `FormError`. It is triggered on every change done by the user. The function can be either asynchronous or synchronous. The first parameter is the value, and the second parameter returns an object containing { errorMessages, connectWithPath, validators }.', type: 'function', status: 'optional', }, onBlurValidator: { - doc: 'Custom validator function that is triggered when the user leaves a field (e.g., blurring a text input or closing a dropdown). The function can be either asynchronous or synchronous. The first parameter is the value, and the second parameter returns an object containing { errorMessages, connectWithPath, validators }.', + doc: 'Custom validator function where you can return `undefined`, `Error`, `FormError` or an Array with either several other validators or several `Error` or `FormError`. It is triggered when the user leaves a field (e.g., blurring a text input or closing a dropdown). The function can be either asynchronous or synchronous. The first parameter is the value, and the second parameter returns an object containing { errorMessages, connectWithPath, validators }.', type: 'function', status: 'optional', }, diff --git a/packages/dnb-eufemia/src/extensions/forms/hooks/useFieldProps.ts b/packages/dnb-eufemia/src/extensions/forms/hooks/useFieldProps.ts index 364b9cf636b..72cdc1d2d1d 100644 --- a/packages/dnb-eufemia/src/extensions/forms/hooks/useFieldProps.ts +++ b/packages/dnb-eufemia/src/extensions/forms/hooks/useFieldProps.ts @@ -788,8 +788,12 @@ export default function useFieldProps( const result = await validator(value, additionalArgs) if (Array.isArray(result)) { - for (const validator of result) { - if (!hasBeenCalledRef(validator)) { + const errors = [] + + for (const validatorOrError of result) { + if (validatorOrError instanceof Error) { + errors.push(validatorOrError) + } else if (!hasBeenCalledRef(validatorOrError)) { const result = await callValidatorFnAsync(validator, value) if (result instanceof Error) { callStackRef.current = [] @@ -798,6 +802,12 @@ export default function useFieldProps( } } + if (errors.length > 0) { + return new FormError('Error', { + errors, + }) + } + callStackRef.current = [] } else { return result @@ -829,9 +839,13 @@ export default function useFieldProps( }) } - for (const validator of result) { - if (!hasBeenCalledRef(validator)) { - const result = callValidatorFnSync(validator, value) + const errors = [] + + for (const validatorOrError of result) { + if (validatorOrError instanceof Error) { + errors.push(validatorOrError) + } else if (!hasBeenCalledRef(validatorOrError)) { + const result = callValidatorFnSync(validatorOrError, value) if (result instanceof Error) { callStackRef.current = [] return result @@ -839,6 +853,12 @@ export default function useFieldProps( } } + if (errors.length > 0) { + return new FormError('Error', { + errors, + }) + } + callStackRef.current = [] } else { return result @@ -855,7 +875,11 @@ export default function useFieldProps( ( method: PersistErrorStateMethod, initiator: ErrorInitiator, - errorArg: Error | FormError | undefined = undefined + errorArg: + | Error + | FormError + | Array + | undefined = undefined ) => { const error = prepareError(errorArg) @@ -944,11 +968,7 @@ export default function useFieldProps( // Don't show the error if the value has changed in the meantime if (unchangedValue) { - persistErrorState( - 'gracefully', - 'onChangeValidator', - result as Error - ) + persistErrorState('gracefully', 'onChangeValidator', result) if ( (validateInitially && !changedRef.current) || @@ -1095,7 +1115,7 @@ export default function useFieldProps( const revealOnBlurValidatorResult = useCallback( ({ result }) => { - persistErrorState('gracefully', 'onBlurValidator', result as Error) + persistErrorState('gracefully', 'onBlurValidator', result) if (isAsync(onBlurValidatorRef.current)) { defineAsyncProcess(undefined) diff --git a/packages/dnb-eufemia/src/extensions/forms/types.ts b/packages/dnb-eufemia/src/extensions/forms/types.ts index 4c941d98000..cfcfcbdb336 100644 --- a/packages/dnb-eufemia/src/extensions/forms/types.ts +++ b/packages/dnb-eufemia/src/extensions/forms/types.ts @@ -33,9 +33,11 @@ export { JSONSchemaType } export type ValidatorReturnSync = | Error + | FormError | undefined | void | Array> + | Array export type ValidatorReturnAsync = | ValidatorReturnSync