From f4cf06ffa53a5467ce323c6dc7b279c531079134 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tobias=20H=C3=B8egh?= Date: Fri, 6 Sep 2024 13:09:22 +0200 Subject: [PATCH] feat(Forms): provide `connectWithPath` in the validator and onBlurValidator to get values from other fields/paths (#3895) --- .../forms/Form/error-messages/info.mdx | 14 + .../NationalIdentityNumber/Examples.tsx | 2 +- .../extensions/forms/getting-started.mdx | 52 +- .../extensions/forms/DataContext/Context.ts | 5 +- .../forms/DataContext/Provider/Provider.tsx | 27 +- .../NationalIdentityNumber.tsx | 21 +- .../__tests__/NationalIdentityNumber.test.tsx | 19 +- .../Field/Number/__tests__/Number.test.tsx | 57 +- .../Field/Number/stories/Number.stories.tsx | 46 +- .../__tests__/PhoneNumber.test.tsx | 13 +- .../extensions/forms/Field/Slider/Slider.tsx | 14 +- .../Form/Handler/__tests__/Handler.test.tsx | 20 +- .../extensions/forms/Iterate/Array/Array.tsx | 8 +- .../extensions/forms/hooks/DataValueDocs.ts | 8 +- .../hooks/__tests__/useDataValue.test.tsx | 10 +- .../hooks/__tests__/useFieldProps.test.tsx | 674 +++++++++++++++++- .../extensions/forms/hooks/useDataValue.ts | 15 +- .../extensions/forms/hooks/useFieldProps.ts | 405 ++++++++--- .../dnb-eufemia/src/extensions/forms/types.ts | 37 +- 19 files changed, 1277 insertions(+), 170 deletions(-) diff --git a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/error-messages/info.mdx b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/error-messages/info.mdx index 1a14ba77cab..e46d9f0d522 100644 --- a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/error-messages/info.mdx +++ b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/error-messages/info.mdx @@ -24,6 +24,20 @@ const validator = (value) => { render() ``` +## Reuse existing error messages in a validator function + +You can reuse existing error messages in a validator function. The types of error messages available depend on the field type. + +For example, you can reuse the `required` error message in a validator function: + +```tsx +const validator = (value, { errorMessages }) => { + // Your validation logic + return new Error(errorMessages.required) +} +render() +``` + ### FormError object You can use the JavaScript `Error` object to display a custom error message: diff --git a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/feature-fields/NationalIdentityNumber/Examples.tsx b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/feature-fields/NationalIdentityNumber/Examples.tsx index b96e3f62225..21f9d6af699 100644 --- a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/feature-fields/NationalIdentityNumber/Examples.tsx +++ b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/feature-fields/NationalIdentityNumber/Examples.tsx @@ -141,7 +141,7 @@ export const ValidationFunction = () => { const fnr = (value: string) => value.length >= 11 ? { status: 'valid' } : { status: 'invalid' } - const validator = (value, errorMessages) => { + const validator = (value, { errorMessages }) => { const result = fnr(value) return result.status === 'invalid' ? new Error(errorMessages.pattern) diff --git a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/getting-started.mdx b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/getting-started.mdx index 87929c54d26..a547591fccd 100644 --- a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/getting-started.mdx +++ b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/getting-started.mdx @@ -350,22 +350,58 @@ const schema = { ``` -#### validator +#### onBlurValidator and validator -The `validator` (including `onBlurValidator`) property is a function that takes the current value of the field as an argument and returns an error message if the value is invalid: +The `onBlurValidator` and `validator` properties accepts a function that takes the current value of the field as an argument and returns an error message if the value is invalid: ```tsx -const validator = (value) => { +const onChangeValidator = (value) => { const isInvalid = new RegExp('Your RegExp').test(value) if (isInvalid) { return new Error('Invalid value message') } } -render() +render() ``` You can find more info about error messages in the [Error messages](/uilib/extensions/forms/Form/error-messages/) docs. +##### Connect with another field + +You can also use the `connectWithPath` function to connect the validator to another field. This allows you to rerun the validator function once the value of the connected field changes: + +```tsx +import { Form, Field } from '@dnb/eufemia/extensions/forms' + +const onChangeValidator = (value, { connectWithPath }) => { + const { getValue } = connectWithPath('/myReference') + const amount = getValue() + if (amount >= value) { + return new Error(`The amount should be greater than ${amount}`) + } +} + +render( + + + + + , +) +``` + +By default, the validator function will only run when the "/withValidator" field is changed. When the error message is shown, it will update the message with the new value of the "/myReference" field. + +You can also change this behavior by using the following props: + +- `validateInitially` will run the validation initially. +- `continuousValidation` will run the validation on every change, including when the connected field changes. +- `validateUnchanged` will validate without any changes made by the user, including when the connected field changes. + ##### Async validation Async validation is also supported. The validator function can return a promise (async/await) that resolves to an error message. @@ -373,7 +409,7 @@ Async validation is also supported. The validator function can return a promise In this example we use `onBlurValidator` to only validate the field when the user leaves the field: ```tsx -const validator = async (value) => { +const onChangeValidator = async (value) => { try { const isInvalid = await makeRequest(value) if (isInvalid) { @@ -383,7 +419,7 @@ const validator = async (value) => { return error } } -render() +render() ``` ##### Async validator with debounce @@ -393,7 +429,7 @@ While when using async validation on every keystroke, it's a good idea to deboun ```tsx import { debounceAsync } from '@dnb/eufemia/shared/helpers' -const validator = debounceAsync(async function myValidator(value) { +const onChangeValidator = debounceAsync(async function myValidator(value) { try { const isInvalid = await makeRequest(value) if (isInvalid) { @@ -403,7 +439,7 @@ const validator = debounceAsync(async function myValidator(value) { return error } }) -render() +render() ``` ### Localization and translation diff --git a/packages/dnb-eufemia/src/extensions/forms/DataContext/Context.ts b/packages/dnb-eufemia/src/extensions/forms/DataContext/Context.ts index 98981c850a1..460417e9b67 100644 --- a/packages/dnb-eufemia/src/extensions/forms/DataContext/Context.ts +++ b/packages/dnb-eufemia/src/extensions/forms/DataContext/Context.ts @@ -21,8 +21,8 @@ type HandleSubmitProps = { export type EventListenerCall = { path?: Path - type?: 'onSubmit' - callback: () => void + type?: 'onSubmit' | 'onPathChange' + callback: (params?: { value: unknown }) => void | Promise } export type FilterDataHandler = ( @@ -66,6 +66,7 @@ export interface ContextState { hasContext: boolean /** The dataset for the form / form wizard */ data: any + internalDataRef?: React.MutableRefObject /** Should the form validate data before submitting? */ errors?: Record /** Will set autoComplete="on" on each nested Field.String and Field.Number */ diff --git a/packages/dnb-eufemia/src/extensions/forms/DataContext/Provider/Provider.tsx b/packages/dnb-eufemia/src/extensions/forms/DataContext/Provider/Provider.tsx index 659cf238012..ab8e71cc69b 100644 --- a/packages/dnb-eufemia/src/extensions/forms/DataContext/Provider/Provider.tsx +++ b/packages/dnb-eufemia/src/extensions/forms/DataContext/Provider/Provider.tsx @@ -749,6 +749,17 @@ export default function Provider( } else { onPathChange?.(path, value) } + + for (const itm of fieldEventListenersRef.current) { + if (itm.type === 'onPathChange' && itm.path === path) { + const { callback } = itm + if (isAsync(callback)) { + await callback({ value }) + } else { + callback({ value }) + } + } + } }, [onPathChange, updateDataValue] ) @@ -870,11 +881,8 @@ export default function Provider( // Just call the submit listeners "once", and not on the retry/recall if (!skipFieldValidation) { - for (const { - path, - type, - callback, - } of fieldEventListenersRef.current) { + for (const item of fieldEventListenersRef.current) { + const { path, type, callback } = item if ( type === 'onSubmit' && mountedFieldPathsRef.current.includes(path) @@ -1073,9 +1081,11 @@ export default function Provider( callback: EventListenerCall['callback'] ) => { fieldEventListenersRef.current = - fieldEventListenersRef.current.filter(({ path: p, type: t }) => { - return !(p === path && t === type) - }) + fieldEventListenersRef.current.filter( + ({ path: p, type: t, callback: c }) => { + return !(p === path && t === type && c === callback) + } + ) fieldEventListenersRef.current.push({ path, type, callback }) }, [] @@ -1182,6 +1192,7 @@ export default function Provider( /** Additional */ id, data: internalDataRef.current, + internalDataRef, props, ...rest, }} diff --git a/packages/dnb-eufemia/src/extensions/forms/Field/NationalIdentityNumber/NationalIdentityNumber.tsx b/packages/dnb-eufemia/src/extensions/forms/Field/NationalIdentityNumber/NationalIdentityNumber.tsx index 2f09f81749b..9252e43e553 100644 --- a/packages/dnb-eufemia/src/extensions/forms/Field/NationalIdentityNumber/NationalIdentityNumber.tsx +++ b/packages/dnb-eufemia/src/extensions/forms/Field/NationalIdentityNumber/NationalIdentityNumber.tsx @@ -11,14 +11,15 @@ export type Props = StringFieldProps & { } function NationalIdentityNumber(props: Props) { - const translations = useTranslation().NationalIdentityNumber - const errorMessage = translations.errorRequired - const { validate = true, omitMask } = props + const translations = useTranslation().NationalIdentityNumber + const { label, errorRequired, errorFnr, errorDnr } = translations const errorMessages = useErrorMessage(props.path, props.errorMessages, { - required: errorMessage, - pattern: errorMessage, + required: errorRequired, + pattern: errorRequired, + errorFnr, + errorDnr, }) const mask = useMemo( @@ -49,11 +50,11 @@ function NationalIdentityNumber(props: Props) { new RegExp(validationPattern).test(value) && fnr(value).status === 'invalid' ) { - return Error(translations.errorFnr) + return Error(errorFnr) } return undefined }, - [translations.errorFnr] + [errorFnr] ) const dnrValidator = useCallback( @@ -63,11 +64,11 @@ function NationalIdentityNumber(props: Props) { new RegExp(validationPattern).test(value) && dnr(value).status === 'invalid' ) { - return Error(translations.errorDnr) + return Error(errorDnr) } return undefined }, - [translations.errorDnr] + [errorDnr] ) const dnrAndFnrValidator = useCallback( @@ -85,7 +86,7 @@ function NationalIdentityNumber(props: Props) { : validate && !props.validator ? validationPattern : undefined, - label: props.label ?? translations.label, + label: props.label ?? label, errorMessages, mask, width: props.width ?? 'medium', diff --git a/packages/dnb-eufemia/src/extensions/forms/Field/NationalIdentityNumber/__tests__/NationalIdentityNumber.test.tsx b/packages/dnb-eufemia/src/extensions/forms/Field/NationalIdentityNumber/__tests__/NationalIdentityNumber.test.tsx index eb05b2b7697..1b41434aaef 100644 --- a/packages/dnb-eufemia/src/extensions/forms/Field/NationalIdentityNumber/__tests__/NationalIdentityNumber.test.tsx +++ b/packages/dnb-eufemia/src/extensions/forms/Field/NationalIdentityNumber/__tests__/NationalIdentityNumber.test.tsx @@ -105,12 +105,19 @@ describe('Field.NationalIdentityNumber', () => { ) expect(validator).toHaveBeenCalledTimes(1) - expect(validator).toHaveBeenCalledWith('123', { - maxLength: expect.stringContaining('{maxLength}'), - minLength: expect.stringContaining('{minLength}'), - pattern: expect.stringContaining('11'), - required: expect.stringContaining('11'), - }) + expect(validator).toHaveBeenCalledWith( + '123', + expect.objectContaining({ + errorMessages: expect.objectContaining({ + maxLength: expect.stringContaining('{maxLength}'), + minLength: expect.stringContaining('{minLength}'), + pattern: expect.stringContaining('11'), + required: expect.stringContaining('11'), + errorDnr: expect.stringContaining('d-nummer'), + errorFnr: expect.stringContaining('fødselsnummer'), + }), + }) + ) }) it('should have numeric input mode', () => { diff --git a/packages/dnb-eufemia/src/extensions/forms/Field/Number/__tests__/Number.test.tsx b/packages/dnb-eufemia/src/extensions/forms/Field/Number/__tests__/Number.test.tsx index cc238a0e9f6..1e8afc1c1b1 100644 --- a/packages/dnb-eufemia/src/extensions/forms/Field/Number/__tests__/Number.test.tsx +++ b/packages/dnb-eufemia/src/extensions/forms/Field/Number/__tests__/Number.test.tsx @@ -1,6 +1,12 @@ import React from 'react' import { axeComponent, wait } from '../../../../../core/jest/jestSetup' -import { screen, render, fireEvent, act } from '@testing-library/react' +import { + screen, + render, + fireEvent, + act, + waitFor, +} from '@testing-library/react' import userEvent from '@testing-library/user-event' import { Field, FieldBlock, Form, JSONSchema } from '../../..' import { Provider } from '../../../../../shared' @@ -467,8 +473,7 @@ describe('Field.Number', () => { ) - const form = document.querySelector('form') - fireEvent.submit(form) + fireEvent.submit(document.querySelector('form')) expect(onSubmit).toHaveBeenCalledTimes(1) expect(onSubmit).toHaveBeenCalledWith( @@ -519,6 +524,52 @@ describe('Field.Number', () => { expect(screen.queryByRole('alert')).toBeInTheDocument() }) + it('should call validator with validateInitially', async () => { + const validator = jest.fn(() => { + return new Error('Validator message') + }) + + render( + + ) + + expect(validator).toHaveBeenCalledTimes(1) + expect(validator).toHaveBeenCalledWith(123, expect.anything()) + + await waitFor(() => { + expect(screen.queryByRole('alert')).toBeInTheDocument() + }) + }) + + it('should call validator on form submit', async () => { + const validator = jest.fn(() => { + return new Error('Validator message') + }) + + render( + + + + ) + + fireEvent.submit(document.querySelector('form')) + + expect(validator).toHaveBeenCalledTimes(1) + expect(validator).toHaveBeenCalledWith(123, expect.anything()) + + await waitFor(() => { + expect(screen.queryByRole('alert')).toBeInTheDocument() + }) + }) + describe('validation based on required-prop', () => { it('should show error for empty value', async () => { render() diff --git a/packages/dnb-eufemia/src/extensions/forms/Field/Number/stories/Number.stories.tsx b/packages/dnb-eufemia/src/extensions/forms/Field/Number/stories/Number.stories.tsx index 25de54498cb..87b383f3810 100644 --- a/packages/dnb-eufemia/src/extensions/forms/Field/Number/stories/Number.stories.tsx +++ b/packages/dnb-eufemia/src/extensions/forms/Field/Number/stories/Number.stories.tsx @@ -1,4 +1,5 @@ -import { Field } from '../../..' +import { useCallback } from 'react' +import { Field, Form, UseFieldProps } from '../../..' import { Flex } from '../../../../../components' export default { @@ -42,3 +43,46 @@ export const Number = () => { ) } + +export const WithFreshValidator = () => { + const validator: UseFieldProps['validator'] = useCallback( + (num, { connectWithPath }) => { + const { getValue } = connectWithPath('/refValue') + const amount = getValue() + // console.log('amount', amount, amount >= num) + if (amount >= num) { + return new Error(`The amount should be greater than ${amount}`) + } + if (num === undefined) { + return new Error(`No amount was given`) + } + }, + [] + ) + + return ( + { + console.log('onSubmit 🍏') + }} + > + + + + + + + + + ) +} diff --git a/packages/dnb-eufemia/src/extensions/forms/Field/PhoneNumber/__tests__/PhoneNumber.test.tsx b/packages/dnb-eufemia/src/extensions/forms/Field/PhoneNumber/__tests__/PhoneNumber.test.tsx index 1b480acb152..ecbcfb68cd3 100644 --- a/packages/dnb-eufemia/src/extensions/forms/Field/PhoneNumber/__tests__/PhoneNumber.test.tsx +++ b/packages/dnb-eufemia/src/extensions/forms/Field/PhoneNumber/__tests__/PhoneNumber.test.tsx @@ -649,10 +649,15 @@ describe('Field.PhoneNumber', () => { ) expect(validator).toHaveBeenCalledTimes(1) - expect(validator).toHaveBeenCalledWith('+41 9999', { - pattern: enGB.PhoneNumber.errorRequired, - required: enGB.PhoneNumber.errorRequired, - }) + expect(validator).toHaveBeenCalledWith( + '+41 9999', + expect.objectContaining({ + errorMessages: expect.objectContaining({ + pattern: enGB.PhoneNumber.errorRequired, + required: enGB.PhoneNumber.errorRequired, + }), + }) + ) await waitFor(() => { expect(document.querySelector('[role="alert"]')).toBeInTheDocument() diff --git a/packages/dnb-eufemia/src/extensions/forms/Field/Slider/Slider.tsx b/packages/dnb-eufemia/src/extensions/forms/Field/Slider/Slider.tsx index 7f9b909622a..b2612e42ad6 100644 --- a/packages/dnb-eufemia/src/extensions/forms/Field/Slider/Slider.tsx +++ b/packages/dnb-eufemia/src/extensions/forms/Field/Slider/Slider.tsx @@ -47,24 +47,24 @@ function SliderComponent(props: Props) { const dataContextRef = useRef() dataContextRef.current = useContext(DataContext) - const { getValue } = useDataValue() + const { getSourceValue } = useDataValue() const getValues = useCallback( (source: SliderValue | Path | Array) => { if (Array.isArray(source)) { - return source.map((s) => getValue(s) || 0) + return source.map((s) => getSourceValue(s) || 0) } - return getValue(source) || 0 + return getSourceValue(source) || 0 }, - [getValue] + [getSourceValue] ) const value = getValues(props.paths ?? props.path ?? props.value) const preparedProps = { ...props, - step: getValue(props.step), - min: getValue(props.min), - max: getValue(props.max), + step: getSourceValue(props.step), + min: getSourceValue(props.min), + max: getSourceValue(props.max), } const { diff --git a/packages/dnb-eufemia/src/extensions/forms/Form/Handler/__tests__/Handler.test.tsx b/packages/dnb-eufemia/src/extensions/forms/Form/Handler/__tests__/Handler.test.tsx index e8243b2c7f3..9955e8b3161 100644 --- a/packages/dnb-eufemia/src/extensions/forms/Form/Handler/__tests__/Handler.test.tsx +++ b/packages/dnb-eufemia/src/extensions/forms/Form/Handler/__tests__/Handler.test.tsx @@ -810,7 +810,6 @@ describe('Form.Handler', () => { ) const buttonElement = document.querySelector('button') - fireEvent.click(buttonElement) await waitFor(() => { @@ -823,15 +822,20 @@ describe('Form.Handler', () => { filterData: expect.any(Function), } ) + }) - expect(asyncValidator).toHaveBeenCalledTimes(1) - expect(asyncValidator).toHaveBeenCalledWith('bar', { - maxLength: expect.any(String), - minLength: expect.any(String), - pattern: expect.any(String), - required: expect.any(String), + expect(asyncValidator).toHaveBeenCalledTimes(1) + expect(asyncValidator).toHaveBeenCalledWith( + 'bar', + expect.objectContaining({ + errorMessages: expect.objectContaining({ + maxLength: expect.any(String), + minLength: expect.any(String), + pattern: expect.any(String), + required: expect.any(String), + }), }) - }) + ) }) it('should accept custom minimumAsyncBehaviorTimevalue', async () => { diff --git a/packages/dnb-eufemia/src/extensions/forms/Iterate/Array/Array.tsx b/packages/dnb-eufemia/src/extensions/forms/Iterate/Array/Array.tsx index 08acfe75af4..8bab72afc3b 100644 --- a/packages/dnb-eufemia/src/extensions/forms/Iterate/Array/Array.tsx +++ b/packages/dnb-eufemia/src/extensions/forms/Iterate/Array/Array.tsx @@ -43,7 +43,7 @@ function ArrayComponent(props: Props) { const summaryListContext = useContext(SummaryListContext) const valueBlockContext = useContext(ValueBlockContext) - const { getValue } = useDataValue() + const { getValueByPath } = useDataValue() const preparedProps = useMemo(() => { const { path, @@ -53,8 +53,8 @@ function ArrayComponent(props: Props) { } = props if (countPath) { - const arrayValue = getValue(path) - let countValue = parseFloat(getValue(countPath)) + const arrayValue = getValueByPath(path) + let countValue = parseFloat(getValueByPath(countPath)) if (!(countValue >= 0)) { countValue = 0 } @@ -76,7 +76,7 @@ function ArrayComponent(props: Props) { } return props - }, [getValue, props]) + }, [getValueByPath, props]) const { path, diff --git a/packages/dnb-eufemia/src/extensions/forms/hooks/DataValueDocs.ts b/packages/dnb-eufemia/src/extensions/forms/hooks/DataValueDocs.ts index 98ee894b8e0..93f90356b25 100644 --- a/packages/dnb-eufemia/src/extensions/forms/hooks/DataValueDocs.ts +++ b/packages/dnb-eufemia/src/extensions/forms/hooks/DataValueDocs.ts @@ -72,22 +72,22 @@ export const dataValueProperties: PropertiesTableProps = { status: 'optional', }, validator: { - doc: 'Custom validator function that will be called for every change done by the user. Can be asynchronous or synchronous.', + 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 }.', type: 'function', status: 'optional', }, onBlurValidator: { - doc: 'Custom validator function that will be called when the user leaves the field (blurring a text input, closing a dropdown etc). Can be asynchronous or synchronous.', + 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 }.', type: 'function', status: 'optional', }, transformIn: { - doc: 'transforms the `value` before its displayed in the field (e.g. input).', + doc: 'Transforms the `value` before its displayed in the field (e.g. input).', type: 'function', status: 'optional', }, transformOut: { - doc: 'transforms the value before it gets forwarded to the form data object or returned as the `onChange` value parameter.', + doc: 'Transforms the value before it gets forwarded to the form data object or returned as the `onChange` value parameter.', type: 'function', status: 'optional', }, diff --git a/packages/dnb-eufemia/src/extensions/forms/hooks/__tests__/useDataValue.test.tsx b/packages/dnb-eufemia/src/extensions/forms/hooks/__tests__/useDataValue.test.tsx index 68bd85b71a3..b1dd020b467 100644 --- a/packages/dnb-eufemia/src/extensions/forms/hooks/__tests__/useDataValue.test.tsx +++ b/packages/dnb-eufemia/src/extensions/forms/hooks/__tests__/useDataValue.test.tsx @@ -56,7 +56,9 @@ describe('useDataValue', () => { ), }) - expect(result.current.getValue('/example/path')).toBe('Test Value') + expect(result.current.getSourceValue('/example/path')).toBe( + 'Test Value' + ) }) it('should return the whole data set when the path is one level like "/"', () => { @@ -69,7 +71,7 @@ describe('useDataValue', () => { ), }) - expect(result.current.getValue('/')).toEqual({ + expect(result.current.getSourceValue('/')).toEqual({ example: { path: 'Test Value' }, }) }) @@ -77,7 +79,9 @@ describe('useDataValue', () => { it('should return the given value when source is not a path', () => { const { result } = renderHook(() => useDataValue()) - expect(result.current.getValue('Test Value')).toBe('Test Value') + expect(result.current.getSourceValue('Test Value')).toBe( + 'Test Value' + ) }) }) diff --git a/packages/dnb-eufemia/src/extensions/forms/hooks/__tests__/useFieldProps.test.tsx b/packages/dnb-eufemia/src/extensions/forms/hooks/__tests__/useFieldProps.test.tsx index 72672319054..1c8eace90b1 100644 --- a/packages/dnb-eufemia/src/extensions/forms/hooks/__tests__/useFieldProps.test.tsx +++ b/packages/dnb-eufemia/src/extensions/forms/hooks/__tests__/useFieldProps.test.tsx @@ -1,14 +1,23 @@ import React from 'react' -import { act, render, renderHook, waitFor } from '@testing-library/react' +import { + act, + fireEvent, + render, + renderHook, + waitFor, + screen, +} from '@testing-library/react' +import userEvent from '@testing-library/user-event' import useFieldProps from '../useFieldProps' import { Provider } from '../../DataContext' -import { +import Field, { FieldBlock, Form, FormError, JSONSchema, OnChange, SubmitState, + UseFieldProps, } from '../../Forms' import { wait } from '../../../../core/jest/jestSetup' import { useSharedState } from '../../../../shared/helpers/useSharedState' @@ -2721,4 +2730,665 @@ describe('useFieldProps', () => { ) expect(result.current.error).toBeInstanceOf(Error) }) + + describe('validator', () => { + describe('connectWithPath', () => { + const validatorFn: UseFieldProps['validator'] = ( + num, + { connectWithPath } + ) => { + const amount = connectWithPath('/refValue').getValue() + + if (amount >= num) { + return new Error(`The amount should be greater than ${amount}`) + } + } + + it('should show validator error on form submit', async () => { + const validator = jest.fn(validatorFn) + + render( + + + + + + ) + + expect(screen.queryByRole('alert')).not.toBeInTheDocument() + + fireEvent.submit(document.querySelector('form')) + + await waitFor(() => { + expect(screen.queryByRole('alert')).toBeInTheDocument() + expect(screen.queryByRole('alert')).toHaveTextContent( + 'The amount should be greater than 2' + ) + }) + expect(validator).toHaveBeenCalledTimes(1) + expect(validator).toHaveBeenLastCalledWith( + 2, + expect.objectContaining({ + connectWithPath: expect.any(Function), + }) + ) + }) + + it('should update error message on input change', async () => { + const validator = jest.fn(validatorFn) + + render( + + + + + + ) + + const [inputWithRefValue] = Array.from( + document.querySelectorAll('input') + ) + + expect(screen.queryByRole('alert')).not.toBeInTheDocument() + + // Show error message + fireEvent.submit(document.querySelector('form')) + + await waitFor(() => { + expect(screen.queryByRole('alert')).toBeInTheDocument() + expect(screen.queryByRole('alert')).toHaveTextContent( + 'The amount should be greater than 2' + ) + }) + + // Make a change to the ref input + await userEvent.type(inputWithRefValue, '2') + + expect(screen.queryByRole('alert')).toHaveTextContent( + 'The amount should be greater than 22' + ) + }) + + it('should hide error message when validation is successful', async () => { + const validator = jest.fn(validatorFn) + + render( + + + + + + ) + + const [inputWithRefValue] = Array.from( + document.querySelectorAll('input') + ) + + expect(screen.queryByRole('alert')).not.toBeInTheDocument() + + // Show error message + fireEvent.submit(document.querySelector('form')) + + await waitFor(() => { + expect(screen.queryByRole('alert')).toBeInTheDocument() + expect(screen.queryByRole('alert')).toHaveTextContent( + 'The amount should be greater than 2' + ) + }) + + await userEvent.type(inputWithRefValue, '{Backspace}') + + expect(screen.queryByRole('alert')).not.toBeInTheDocument() + expect(validator).toHaveBeenCalledTimes(2) + expect(validator).toHaveBeenLastCalledWith( + 2, + expect.objectContaining({ + connectWithPath: expect.any(Function), + }) + ) + }) + + it('should keep error message hidden after validation is successful and another input change', async () => { + const validator = jest.fn(validatorFn) + + render( + + + + + + ) + + const [inputWithRefValue] = Array.from( + document.querySelectorAll('input') + ) + + expect(screen.queryByRole('alert')).not.toBeInTheDocument() + + // Show error message + fireEvent.submit(document.querySelector('form')) + + await waitFor(() => { + expect(screen.queryByRole('alert')).toBeInTheDocument() + expect(screen.queryByRole('alert')).toHaveTextContent( + 'The amount should be greater than 2' + ) + }) + + await userEvent.type(inputWithRefValue, '{Backspace}') + + expect(screen.queryByRole('alert')).not.toBeInTheDocument() + expect(validator).toHaveBeenCalledTimes(2) + + await userEvent.type(inputWithRefValue, '2') + + expect(screen.queryByRole('alert')).not.toBeInTheDocument() + }) + + describe('validateInitially', () => { + it('should show error message initially', async () => { + const validator = jest.fn(validatorFn) + + render( + + + + + + ) + + await waitFor(() => { + expect(screen.queryByRole('alert')).toBeInTheDocument() + expect(screen.queryByRole('alert')).toHaveTextContent( + 'The amount should be greater than 2' + ) + }) + }) + + it('should not show error message after it was hidden while typing', async () => { + const validator = jest.fn(validatorFn) + + render( + + + + + + ) + + const [inputWithRefValue] = Array.from( + document.querySelectorAll('input') + ) + + await waitFor(() => { + expect(screen.queryByRole('alert')).toBeInTheDocument() + expect(screen.queryByRole('alert')).toHaveTextContent( + 'The amount should be greater than 2' + ) + }) + + await userEvent.type(inputWithRefValue, '{Backspace}') + + expect(screen.queryByRole('alert')).not.toBeInTheDocument() + + await userEvent.type(inputWithRefValue, '3') + + expect(screen.queryByRole('alert')).not.toBeInTheDocument() + }) + }) + + describe('validateUnchanged', () => { + it('should show error message initially', async () => { + const validator = jest.fn(validatorFn) + + render( + + + + + + ) + + await waitFor(() => { + expect(screen.queryByRole('alert')).toBeInTheDocument() + expect(screen.queryByRole('alert')).toHaveTextContent( + 'The amount should be greater than 2' + ) + }) + }) + + it('should hide and show error message while typing', async () => { + const validator = jest.fn(validatorFn) + + render( + + + + + + ) + + const [inputWithRefValue] = Array.from( + document.querySelectorAll('input') + ) + + await waitFor(() => { + expect(screen.queryByRole('alert')).toBeInTheDocument() + expect(screen.queryByRole('alert')).toHaveTextContent( + 'The amount should be greater than 2' + ) + }) + + await userEvent.type(inputWithRefValue, '{Backspace}') + + expect(screen.queryByRole('alert')).not.toBeInTheDocument() + + await userEvent.type(inputWithRefValue, '3') + + await waitFor(() => { + expect(screen.queryByRole('alert')).toBeInTheDocument() + expect(screen.queryByRole('alert')).toHaveTextContent( + 'The amount should be greater than 3' + ) + }) + }) + }) + + describe('continuousValidation', () => { + it('should show not show error message initially', async () => { + const validator = jest.fn(validatorFn) + + render( + + + + + + ) + + expect(screen.queryByRole('alert')).not.toBeInTheDocument() + }) + + it('should hide and show error message while typing', async () => { + const validator = jest.fn(validatorFn) + + render( + + + + + + ) + + const [inputWithRefValue] = Array.from( + document.querySelectorAll('input') + ) + + // Show error message + fireEvent.submit(document.querySelector('form')) + + await waitFor(() => { + expect(screen.queryByRole('alert')).toBeInTheDocument() + expect(screen.queryByRole('alert')).toHaveTextContent( + 'The amount should be greater than 2' + ) + }) + + await userEvent.type(inputWithRefValue, '{Backspace}') + + expect(screen.queryByRole('alert')).not.toBeInTheDocument() + + await userEvent.type(inputWithRefValue, '3') + + await waitFor(() => { + expect(screen.queryByRole('alert')).toBeInTheDocument() + expect(screen.queryByRole('alert')).toHaveTextContent( + 'The amount should be greater than 3' + ) + }) + }) + }) + }) + }) + + describe('onBlurValidator', () => { + describe('connectWithPath', () => { + const validatorFn: UseFieldProps['validator'] = ( + num, + { connectWithPath } + ) => { + const amount = connectWithPath('/refValue').getValue() + + if (amount >= num) { + return new Error(`The amount should be greater than ${amount}`) + } + } + + it('should show validator error on form submit', async () => { + const validator = jest.fn(validatorFn) + + render( + + + + + + ) + + expect(screen.queryByRole('alert')).not.toBeInTheDocument() + + fireEvent.submit(document.querySelector('form')) + + await waitFor(() => { + expect(screen.queryByRole('alert')).toBeInTheDocument() + expect(screen.queryByRole('alert')).toHaveTextContent( + 'The amount should be greater than 2' + ) + }) + + expect(validator).toHaveBeenCalledTimes(1) + expect(validator).toHaveBeenLastCalledWith( + 2, + expect.objectContaining({ + connectWithPath: expect.any(Function), + }) + ) + }) + + it('should update error message on input change', async () => { + const validator = jest.fn(validatorFn) + + render( + + + + + + ) + + const [inputWithRefValue, inputWithValidator] = Array.from( + document.querySelectorAll('input') + ) + + expect(screen.queryByRole('alert')).not.toBeInTheDocument() + + // Make a change to the input with the validator + await userEvent.type(inputWithValidator, '2') + fireEvent.blur(inputWithValidator) + + await waitFor(() => { + expect(screen.queryByRole('alert')).toBeInTheDocument() + expect(screen.queryByRole('alert')).toHaveTextContent( + 'The amount should be greater than 12' + ) + }) + + // Make a change to the ref input + await userEvent.type(inputWithRefValue, '3') + + expect(screen.queryByRole('alert')).toHaveTextContent( + 'The amount should be greater than 123' + ) + }) + + it('should hide error message when validation is successful', async () => { + const validator = jest.fn(validatorFn) + + render( + + + + + + ) + + const [inputWithRefValue] = Array.from( + document.querySelectorAll('input') + ) + + expect(screen.queryByRole('alert')).not.toBeInTheDocument() + + // Show error message + fireEvent.submit(document.querySelector('form')) + + await waitFor(() => { + expect(screen.queryByRole('alert')).toBeInTheDocument() + }) + + await userEvent.type(inputWithRefValue, '{Backspace}') + + expect(screen.queryByRole('alert')).not.toBeInTheDocument() + expect(validator).toHaveBeenCalledTimes(2) + expect(validator).toHaveBeenLastCalledWith( + 2, + expect.objectContaining({ + connectWithPath: expect.any(Function), + }) + ) + }) + + it('should keep error message hidden during ref input change', async () => { + const validator = jest.fn(validatorFn) + + render( + + + + + + ) + + const [inputWithRefValue] = Array.from( + document.querySelectorAll('input') + ) + + expect(screen.queryByRole('alert')).not.toBeInTheDocument() + + // Show error message + fireEvent.submit(document.querySelector('form')) + + await waitFor(() => { + expect(screen.queryByRole('alert')).toBeInTheDocument() + }) + + await userEvent.type(inputWithRefValue, '{Backspace}') + + expect(screen.queryByRole('alert')).not.toBeInTheDocument() + expect(validator).toHaveBeenCalledTimes(2) + + await userEvent.type(inputWithRefValue, '2') + + expect(screen.queryByRole('alert')).not.toBeInTheDocument() + }) + + describe('validateInitially', () => { + it('should not show error message initially', async () => { + const validator = jest.fn(validatorFn) + + render( + + + + + + ) + + expect(screen.queryByRole('alert')).not.toBeInTheDocument() + }) + + it('should not show error message while typing', async () => { + const validator = jest.fn(validatorFn) + + render( + + + + + + ) + + const [inputWithRefValue] = Array.from( + document.querySelectorAll('input') + ) + + expect(screen.queryByRole('alert')).not.toBeInTheDocument() + + await userEvent.type(inputWithRefValue, '{Backspace}') + + expect(screen.queryByRole('alert')).not.toBeInTheDocument() + + await userEvent.type(inputWithRefValue, '3') + + expect(screen.queryByRole('alert')).not.toBeInTheDocument() + }) + }) + + describe('validateUnchanged', () => { + it('should not show error message initially', async () => { + const validator = jest.fn(validatorFn) + + render( + + + + + + ) + + expect(screen.queryByRole('alert')).not.toBeInTheDocument() + }) + + it('should not show error message while typing', async () => { + const validator = jest.fn(validatorFn) + + render( + + + + + + ) + + const [inputWithRefValue] = Array.from( + document.querySelectorAll('input') + ) + + expect(screen.queryByRole('alert')).not.toBeInTheDocument() + + await userEvent.type(inputWithRefValue, '{Backspace}') + + expect(screen.queryByRole('alert')).not.toBeInTheDocument() + + await userEvent.type(inputWithRefValue, '3') + + await waitFor(() => { + expect(screen.queryByRole('alert')).not.toBeInTheDocument() + }) + }) + }) + + describe('continuousValidation', () => { + it('should show not show error message initially', async () => { + const validator = jest.fn(validatorFn) + + render( + + + + + + ) + + expect(screen.queryByRole('alert')).not.toBeInTheDocument() + }) + }) + }) + }) }) diff --git a/packages/dnb-eufemia/src/extensions/forms/hooks/useDataValue.ts b/packages/dnb-eufemia/src/extensions/forms/hooks/useDataValue.ts index 9eb7d4e2d4e..17d5b34b36e 100644 --- a/packages/dnb-eufemia/src/extensions/forms/hooks/useDataValue.ts +++ b/packages/dnb-eufemia/src/extensions/forms/hooks/useDataValue.ts @@ -9,6 +9,8 @@ export type Props = { value?: Value } +export type GetValueByPath = (path: Path) => T + export default function useDataValue({ path: pathProp, value, @@ -19,11 +21,12 @@ export default function useDataValue({ const { makePath, makeIteratePath } = usePath() const get = useCallback((selector: Path) => { + const data = dataContextRef.current?.internalDataRef?.current if (selector === '/') { - return dataContextRef.current?.data + return data } - return pointer.has(dataContextRef.current?.data, selector) - ? pointer.get(dataContextRef.current.data, selector) + return pointer.has(data, selector) + ? pointer.get(data, selector) : undefined }, []) @@ -70,7 +73,7 @@ export default function useDataValue({ [getValueByPath, moveValueToPath] ) - const getValue = useCallback( + const getSourceValue = useCallback( (source: Path | Value) => { if (typeof source === 'string' && isPath(source)) { return getValueByPath(source) @@ -82,11 +85,11 @@ export default function useDataValue({ ) if (pathProp) { - value = getValue(pathProp) + value = getSourceValue(pathProp) } return { - getValue, + getSourceValue, getValueByPath, getValueByIteratePath, moveValueToPath, diff --git a/packages/dnb-eufemia/src/extensions/forms/hooks/useFieldProps.ts b/packages/dnb-eufemia/src/extensions/forms/hooks/useFieldProps.ts index 945144ef145..c8af861f0cc 100644 --- a/packages/dnb-eufemia/src/extensions/forms/hooks/useFieldProps.ts +++ b/packages/dnb-eufemia/src/extensions/forms/hooks/useFieldProps.ts @@ -18,6 +18,8 @@ import { SubmitState, EventReturnWithStateObjectAndSuccess, EventStateObjectWithSuccess, + ValidatorAdditionalArgs, + Validator, } from '../types' import { Context as DataContext, ContextState } from '../DataContext' import { clearedData } from '../DataContext/Provider/Provider' @@ -38,6 +40,7 @@ import { import { isAsync } from '../../../shared/helpers/isAsync' import useTranslation from './useTranslation' import useExternalValue from './useExternalValue' +import useDataValue from './useDataValue' type SubmitStateWithValidating = SubmitState | 'validating' type AsyncProcesses = @@ -119,7 +122,16 @@ export default function useFieldProps( }, } = props - const [, forceUpdate] = useReducer(() => ({}), {}) + const [salt, forceUpdate] = useReducer(() => ({}), {}) + const isInternalRerenderRef = useRef(undefined) + useMemo(() => { + /** + * This is currently not used, but we keep it here for future use. + * It lets you check for isInternalRerenderRef.current !== undefined + * inside a useUpdateEffect or useEffect to know if the component is rerendering from inside or outside. + */ + isInternalRerenderRef.current = salt + }, [salt]) const { startProcess } = useProcessManager() const id = useId(props.id) const dataContext = useContext(DataContext) @@ -194,8 +206,8 @@ export default function useFieldProps( // Hold an internal copy of the input value in case the input component is used uncontrolled, // and to handle errors in Eufemia on components that does not take updated callback functions into account. const valueRef = useRef(externalValue) - const changedRef = useRef(false) - const hasFocusRef = useRef(false) + const changedRef = useRef() + const hasFocusRef = useRef() const required = useMemo(() => { if (requiredProp) { @@ -251,9 +263,9 @@ export default function useFieldProps( dataContextError ) - const validatorRef = useRef(validator) + const onChangeValidatorRef = useRef(validator) useUpdateEffect(() => { - validatorRef.current = validator + onChangeValidatorRef.current = validator }, [validator]) const onBlurValidatorRef = useRef(onBlurValidator) useUpdateEffect(() => { @@ -354,20 +366,20 @@ export default function useFieldProps( ) const revealError = useCallback(() => { - revealErrorRef.current = true - showFieldErrorFieldBlock?.(identifier, true) - if (localErrorRef.current) { - setHasVisibleErrorDataContext?.(identifier, true) - } else { - setHasVisibleErrorDataContext?.(identifier, false) + if (!revealErrorRef.current) { + revealErrorRef.current = true + showFieldErrorFieldBlock?.(identifier, true) + setHasVisibleErrorDataContext?.(identifier, !!localErrorRef.current) } - }, [showFieldErrorFieldBlock, identifier, setHasVisibleErrorDataContext]) + }, [identifier, setHasVisibleErrorDataContext, showFieldErrorFieldBlock]) const hideError = useCallback(() => { - revealErrorRef.current = false - showFieldErrorFieldBlock?.(identifier, false) - setHasVisibleErrorDataContext?.(identifier, false) - }, [setHasVisibleErrorDataContext, identifier, showFieldErrorFieldBlock]) + if (revealErrorRef.current) { + revealErrorRef.current = false + showFieldErrorFieldBlock?.(identifier, false) + setHasVisibleErrorDataContext?.(identifier, false) + } + }, [identifier, setHasVisibleErrorDataContext, showFieldErrorFieldBlock]) /** * Prepare error from validation logic with correct error messages based on props @@ -426,6 +438,81 @@ export default function useFieldProps( ) }, [errorProp]) + const connectWithPathListenerRef = useRef(async () => { + if ( + localErrorRef.current || + validateUnchanged || + continuousValidation + ) { + if (onChangeValidatorRef.current) { + runOnChangeValidator() + } + } + + if (localErrorRef.current && onBlurValidatorRef.current) { + runOnBlurValidator() + } + }) + + const { getValueByPath } = useDataValue() + const additionalArgs = useMemo(() => { + const errorMessages = { + ...contextErrorMessages, + ...errorMessagesRef.current, + } + const args: ValidatorAdditionalArgs = { + /** @deprecated – can be removed in v11 */ + ...errorMessages, + + errorMessages, + connectWithPath: (path) => { + setFieldEventListener?.( + path, + 'onPathChange', + connectWithPathListenerRef.current + ) + + return { + getValue: () => getValueByPath(path), + } + }, + } + + return args + }, [contextErrorMessages, getValueByPath, setFieldEventListener]) + + const callValidatorFnSync = useCallback( + ( + validator: Validator, + value: Value = valueRef.current + ): ReturnType> => { + if (typeof validator !== 'function') { + return undefined + } + + const result = validator(value, additionalArgs) + + return result + }, + [additionalArgs] + ) + + const callValidatorFnAsync = useCallback( + async ( + validator: Validator, + value: Value = valueRef.current + ): Promise>> => { + if (typeof validator !== 'function') { + return undefined + } + + const result = await validator(value, additionalArgs) + + return result + }, + [additionalArgs] + ) + /** * Based on validation, update error state, locally and relevant surrounding contexts */ @@ -494,103 +581,229 @@ export default function useFieldProps( hasLocalErrorRef.current = false }, [persistErrorState]) - const callValidator = useCallback(async () => { - if (typeof validatorRef.current !== 'function') { - return + const validatorCacheRef = useRef({ + onChangeValidator: null, + onBlurValidator: null, + }) + + const revealOnChangeValidatorResult = useCallback( + ({ result, unchangedValue }) => { + const runAsync = isAsync(onChangeValidatorRef.current) + + // Don't show the error if the value has changed in the meantime + if (unchangedValue) { + persistErrorState('gracefully', result as Error) + + if ( + (validateInitially && !changedRef.current) || + validateUnchanged || + continuousValidation || + runAsync // Because its a better UX to show the error when the validation is async/delayed + ) { + // Because we first need to throw the error to be able to display it, we delay the showError call + window.requestAnimationFrame(() => { + revealError() + forceUpdate() + }) + } + } + + if (runAsync) { + defineAsyncProcess(undefined) + + if (unchangedValue) { + setFieldState(result instanceof Error ? 'error' : 'complete') + } else { + setFieldState('pending') + } + } + }, + [ + continuousValidation, + defineAsyncProcess, + persistErrorState, + revealError, + setFieldState, + validateInitially, + validateUnchanged, + ] + ) + + const callOnChangeValidator = useCallback(async () => { + if (typeof onChangeValidatorRef.current !== 'function') { + return {} } - const runAsync = isAsync(validatorRef.current) + const tmpValue = valueRef.current + + let result = isAsync(onChangeValidatorRef.current) + ? await callValidatorFnAsync(onChangeValidatorRef.current) + : callValidatorFnSync(onChangeValidatorRef.current) + if (result instanceof Promise) { + result = await result + } - if (runAsync) { + const unchangedValue = tmpValue === valueRef.current + return { result, unchangedValue } + }, [callValidatorFnAsync, callValidatorFnSync]) + + const startOnChangeValidatorValidation = useCallback(async () => { + if (typeof onChangeValidatorRef.current !== 'function') { + return + } + + if (isAsync(onChangeValidatorRef.current)) { defineAsyncProcess('validator') setFieldState('validating') hideError() } - const opts = { - ...contextErrorMessages, - ...errorMessagesRef.current, - } - + // Ideally, we should rather call "callOnChangeValidator", but sadly it's not possible, + // because the we get an additional delay due to the async nature, which is too much. const tmpValue = valueRef.current - - // Run async regardless to support Promise based validators - const result = await validatorRef.current(valueRef.current, opts) - + let result = isAsync(onChangeValidatorRef.current) + ? await callValidatorFnAsync(onChangeValidatorRef.current) + : callValidatorFnSync(onChangeValidatorRef.current) + if (result instanceof Promise) { + result = await result + } const unchangedValue = tmpValue === valueRef.current - // Don't show the error if the value has changed in the meantime - if (unchangedValue) { - persistErrorState('gracefully', result as Error) + revealOnChangeValidatorResult({ result, unchangedValue }) - // Because its a better UX to show the error when the validation is async/delayed - if (continuousValidation || runAsync) { - // Because we first need to throw the error to be able to display it, we delay the showError call - window.requestAnimationFrame(() => { - revealError() - forceUpdate() - }) - } + return { result } + }, [ + callValidatorFnAsync, + callValidatorFnSync, + defineAsyncProcess, + hideError, + revealOnChangeValidatorResult, + setFieldState, + ]) + + const runOnChangeValidator = useCallback(async () => { + if (!onChangeValidatorRef.current) { + return // stop here } - if (runAsync) { - defineAsyncProcess(undefined) + const { result, unchangedValue } = await callOnChangeValidator() - if (unchangedValue) { - setFieldState(result instanceof Error ? 'error' : 'complete') + if ( + String(result) !== + String(validatorCacheRef.current.onChangeValidator) + ) { + if (result) { + revealOnChangeValidatorResult({ result, unchangedValue }) } else { - setFieldState('pending') + hideError() + clearErrorState() } } - return result + validatorCacheRef.current.onChangeValidator = result || null }, [ - contextErrorMessages, - continuousValidation, + callOnChangeValidator, + clearErrorState, hideError, - persistErrorState, - defineAsyncProcess, - setFieldState, - revealError, + revealOnChangeValidatorResult, ]) const callOnBlurValidator = useCallback( - async ({ valueOverride = null } = {}) => { + async ({ + overrideValue = null, + }: { + overrideValue?: Value + } = {}) => { if (typeof onBlurValidatorRef.current !== 'function') { - return + return {} } - // External blur validators makes it possible to validate values but not on every character change in case of - // expensive validation calling external services etc. // Since the validator can return either a synchronous result or an asynchronous const value = transformers.current.toEvent( - valueOverride ?? valueRef.current, + overrideValue ?? valueRef.current, 'onBlurValidator' ) - const runAsync = isAsync(onBlurValidatorRef.current) - - if (runAsync) { - defineAsyncProcess('onBlurValidator') - setFieldState('validating') + let result = isAsync(onBlurValidatorRef.current) + ? await callValidatorFnAsync(onBlurValidatorRef.current, value) + : callValidatorFnSync(onBlurValidatorRef.current, value) + if (result instanceof Promise) { + result = await result } - // Run async regardless to support Promise based validators - const result = await onBlurValidatorRef.current(value) + return { result } + }, + [callValidatorFnAsync, callValidatorFnSync] + ) + const revealOnBlurValidatorResult = useCallback( + ({ result }) => { persistErrorState('gracefully', result as Error) - if (runAsync) { + if (isAsync(onBlurValidatorRef.current)) { defineAsyncProcess(undefined) setFieldState(result instanceof Error ? 'error' : 'complete') } revealError() - forceUpdate() }, - [persistErrorState, defineAsyncProcess, setFieldState, revealError] + [defineAsyncProcess, persistErrorState, revealError, setFieldState] + ) + + const startOnBlurValidatorProcess = useCallback( + async ({ + overrideValue = null, + }: { + overrideValue?: Value + } = {}) => { + if (typeof onBlurValidatorRef.current !== 'function') { + return + } + + if (isAsync(onBlurValidatorRef.current)) { + defineAsyncProcess('onBlurValidator') + setFieldState('validating') + } + + const { result } = await callOnBlurValidator({ overrideValue }) + revealOnBlurValidatorResult({ result }) + }, + [ + callOnBlurValidator, + defineAsyncProcess, + revealOnBlurValidatorResult, + setFieldState, + ] ) + const runOnBlurValidator = useCallback(async () => { + if (!onBlurValidatorRef.current) { + return // stop here + } + + const { result } = await callOnBlurValidator() + + if ( + String(result) !== + String(validatorCacheRef.current.onBlurValidator) && + revealErrorRef.current + ) { + if (result) { + revealOnBlurValidatorResult({ result }) + } else { + hideError() + clearErrorState() + } + } + + validatorCacheRef.current.onBlurValidator = result || null + }, [ + callOnBlurValidator, + clearErrorState, + hideError, + revealOnBlurValidatorResult, + ]) + const prioritizeContextSchema = useMemo(() => { if (errorPrioritization) { const schemaPath = identifier.split('/').join('/properties/') @@ -653,10 +866,10 @@ export default function useFieldProps( // Validate by provided derivative validator if ( - validatorRef.current && - (changedRef.current || validateInitially) + onChangeValidatorRef.current && + (changedRef.current || validateInitially || validateUnchanged) ) { - const result = await callValidator() + const { result } = await startOnChangeValidatorValidation() if (result instanceof Error) { throw result @@ -685,7 +898,8 @@ export default function useFieldProps( required, prioritizeContextSchema, validateInitially, - callValidator, + validateUnchanged, + startOnChangeValidatorValidation, persistErrorState, ]) @@ -707,14 +921,14 @@ export default function useFieldProps( const setHasFocus = useCallback( async ( hasFocus: boolean, - valueOverride?: Value, + overrideValue?: Value, additionalArgs?: AdditionalEventArgs ) => { const getArgs = ( type: Parameters[1] ) => { const value = transformers.current.toEvent( - valueOverride ?? valueRef.current, + overrideValue ?? valueRef.current, type ) const transformedAdditionalArgs = @@ -747,7 +961,7 @@ export default function useFieldProps( addToPool( 'onBlurValidator', - async () => await callOnBlurValidator({ valueOverride }), + async () => await startOnBlurValidatorProcess({ overrideValue }), isAsync(onBlurValidatorRef.current) ) @@ -759,13 +973,13 @@ export default function useFieldProps( } }, [ - addToPool, - callOnBlurValidator, - onBlur, onFocus, + onBlur, + validateUnchanged, + addToPool, runPool, + startOnBlurValidatorProcess, revealError, - validateUnchanged, ] ) @@ -944,7 +1158,11 @@ export default function useFieldProps( handlePathChangeUnvalidatedDataContext(identifier, newValue) } - addToPool('validator', validateValue, isAsync(validatorRef.current)) + addToPool( + 'validator', + validateValue, + isAsync(onChangeValidatorRef.current) + ) addToPool( 'onChangeContext', @@ -1249,7 +1467,7 @@ export default function useFieldProps( useEffect(() => { if ( dataContext.formState === 'pending' && - (validatorRef.current || onBlurValidatorRef.current) + (onChangeValidatorRef.current || onBlurValidatorRef.current) ) { hideError() forceUpdate() @@ -1261,15 +1479,26 @@ export default function useFieldProps( return // stop here } - addToPool('validator', callValidator, isAsync(validatorRef.current)) + addToPool( + 'validator', + startOnChangeValidatorValidation, + isAsync(onChangeValidatorRef.current) + ) + addToPool( 'onBlurValidator', - callOnBlurValidator, + startOnBlurValidatorProcess, isAsync(onBlurValidatorRef.current) ) await runPool() - }, [addToPool, callOnBlurValidator, callValidator, hasError, runPool]) + }, [ + addToPool, + startOnBlurValidatorProcess, + hasError, + runPool, + startOnChangeValidatorValidation, + ]) // Validate/call validator functions during submit of the form useEffect(() => { @@ -1400,6 +1629,10 @@ export default function useFieldProps( const sharedData = useSharedState('field-block-props-' + id) sharedData.set(fieldBlockProps) + useEffect(() => { + isInternalRerenderRef.current = undefined + }) + return { ...props, ...fieldBlockProps, @@ -1416,7 +1649,7 @@ export default function useFieldProps( transformers.current.toInput(valueRef.current) ), hasError: hasVisibleError, - isChanged: changedRef.current, + isChanged: Boolean(changedRef.current), props, htmlAttributes, setHasFocus, @@ -1438,7 +1671,7 @@ export interface ReturnAdditional { htmlAttributes: AriaAttributes | DataAttributes setHasFocus: ( hasFocus: boolean, - valueOverride?: unknown, + overrideValue?: Value, additionalArgs?: AdditionalEventArgs ) => void handleFocus: () => void diff --git a/packages/dnb-eufemia/src/extensions/forms/types.ts b/packages/dnb-eufemia/src/extensions/forms/types.ts index f32e3ddd374..a033374d570 100644 --- a/packages/dnb-eufemia/src/extensions/forms/types.ts +++ b/packages/dnb-eufemia/src/extensions/forms/types.ts @@ -23,6 +23,34 @@ export { JSONSchemaType } type ValidationRule = 'type' | 'pattern' | 'required' | string type MessageValues = Record +export type ValidatorReturnSync = Error | undefined | void +export type ValidatorReturnAsync = + | ValidatorReturnSync + | Promise +export type Validator = ( + value: Value, + additionalArgs?: ValidatorAdditionalArgs +) => ValidatorReturnAsync +export type ValidatorAdditionalArgs< + Value, + ErrorMessages = DefaultErrorMessages, +> = { + /** + * Returns the error messages from the { errorMessages } object. + */ + errorMessages: ErrorMessages + + /** + * Connects the validator to another field. + * This allows you to rerun the validator function once the value of the connected field changes. + */ + connectWithPath: (path: Path) => { getValue: () => Value } +} & { + /** @deprecated use the error messages from the { errorMessages } object instead. */ + pattern: string + /** @deprecated use the error messages from the { errorMessages } object instead. */ + required: string +} interface IFormErrorOptions { validationRule?: ValidationRule @@ -265,13 +293,8 @@ export interface UseFieldProps< // - Validation required?: boolean schema?: AllJSONSchemaVersions - validator?: ( - value: Value | EmptyValue, - errorMessages?: ErrorMessages - ) => Error | undefined | void | Promise - onBlurValidator?: ( - value: Value | EmptyValue - ) => Error | undefined | void | Promise + validator?: Validator + onBlurValidator?: Validator validateRequired?: ( internal: Value, {