From cf041259c49b18b40d31904ec6d545ea9c42dc5a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tobias=20H=C3=B8egh?= Date: Tue, 19 Dec 2023 17:41:42 +0100 Subject: [PATCH] feat(useDataValue): add toOutput, fromExternal and validateRequired (#3109) For data transformation, add these hooks: - `toOutput` - `fromExternal` For custom control about validating the required prop: - `validateRequired` Also, adding docs about the `useDataValue` hook. --- .../extensions/forms/create-component.mdx | 149 ++++++++++ .../hooks/__tests__/useDataValue.test.tsx | 271 +++++++++++++++++- .../extensions/forms/hooks/useDataValue.ts | 144 ++++++---- .../dnb-eufemia/src/extensions/forms/types.ts | 15 + 4 files changed, 522 insertions(+), 57 deletions(-) diff --git a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/create-component.mdx b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/create-component.mdx index 081a181bb88..f31d81401ed 100644 --- a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/create-component.mdx +++ b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/create-component.mdx @@ -88,3 +88,152 @@ You can also use a [FieldBlock](/uilib/extensions/forms/create-component/FieldBl ### useDataValue The `useDataValue` hook standardize handling of the value flow for a single consumer component representing one data point. It holds error state, hides it while the field is in focus, connects to surrounding `DataContext` (if present) and other things that all field or value components needs to do. By implementing custom field or value components and passing the received props through `useDataValue`, all these features work the same way as other field or value components, and you only need to implement the specific unique features of that component. + +How to use: + +```ts +const { value } = useDataValue(componentProps) +``` + +Advanced usage: + +```ts +const { + value, + onChange, + onFocus, + onBlur, + error, + hasError, + isChanged, + setHasFocus, +} = useDataValue({ + toEvent, + errorMessages, + ...componentProps, +}) +``` + +#### useDataValue return parameters + +- `error` the error object, in case an error is invoked. Will skip returning the error object, if the hook is used in a nested [FieldBlock](/uilib/extensions/forms/create-component/FieldBlock/). + +- `hasError` will return true in case an error, even if the hook is nested in a `FieldBlock`. + +- `isChanged` returns `true` if the value has changed with e.g. `handleChange`. + +- `setHasFocus` accepts a boolean as value. When called, it will update the internal logic - for event handling and validation. Will re-render the React Hook and its outer component. + +#### Validation + +- `validateRequired` does allow you to provide a custom logic for how the `required` prop should validate. + +```ts +const validateRequired = ( + value: Value, + { emptyValue, required, isChanged }, +) => { + return required && value === emptyValue + ? new FormError('The value is required', { + validationRule: 'required', + }) + : undefined +} + +const { error, hasError } = useDataValue({ + value: undefined, + required: true, + validateInitially: true, + validateRequired, + errorMessages: { + required: 'Show this when "required" fails!', + }, +}) +``` + +##### Validation order + +During validation, the different APIs do have a prioritization order and will stop processing further when they match: + +1. `require` prop +1. `schema` prop (including `pattern`) +1. `validator` prop + +#### Error handling + +Validation and error-handling is tight coupled together. When a validation fails, you may use the error-object to handle and show the failures/statuses. + +To generate the error-object, `FormError` is used. You can use it as well: + +```tsx +import { FormError } from '@dnb/eufemia/extensions/forms' +render( + , +) +``` + +But when you handle errors via `useDataValue`, you may rather provide an object with messages, which will be used to display the error: + +```ts +const { error, hasError } = useDataValue({ + required: true, + errorMessages: { + required: 'Show this when "required" fails!', + }, + ...componentProps, +}) +``` + +In order to invoke an error without a change and blur event, you can use `validateInitially`: + +```ts +const { error, hasError } = useDataValue({ + value: undefined, + required: true, + validateInitially: true, + errorMessages: { + required: 'Show this when "required" fails!', + }, +}) +``` + +#### Event handlers + +- `handleFocus()` to call the `onFocus` event. + +- `handleBlur()` to call the `onBlur` event. + +- `handleChange(value)` to call the `onChange` event. Will update/change the internal value and re-render the React Hook, so will the outer component too. + +```ts +handleChange(value, (additionalArgs = null)) +``` + +- `updateValue(value)` to update/change the internal value, without calling any events. + +- `forceUpdate()` to re-render the React Hook along with the outer component. + +#### Value transformers + +The transformers are hooks to transform the value on different stages. + +They should return a transformed value: `(value) => value` + +- `toInput` transforms the value before it gets returned by the hook: + +```ts +const { value } = useDataValue(props) +``` + +- `fromInput` transforms the value given by `handleChange` before it is used in the further process flow. + +```ts +handleChange(value) +``` + +- `toEvent` transforms the internal value before it gets returned by even callbacks such as `onChange`, `onFocus` and `onBlur`. + +- `fromExternal` transforms the given props `value` before any other step gets entered. 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 c155e4eab15..7d6b3e2d874 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 @@ -1,5 +1,8 @@ +import React from 'react' import { act, renderHook, waitFor } from '@testing-library/react' import useDataValue from '../useDataValue' +import { JSONSchema7 } from 'json-schema' +import { FieldBlock, FormError } from '../../Forms' describe('useDataValue', () => { it('should call external onChange based change callbacks', () => { @@ -15,6 +18,49 @@ describe('useDataValue', () => { expect(onChange).toHaveBeenNthCalledWith(1, 'new-value') }) + it('should return correct "hasError" state but no error object when nested in "FieldBlock"', async () => { + const wrapper = ({ children }) => {children} + + const { result } = renderHook( + () => + useDataValue({ + value: 'foo', + emptyValue: '', + required: true, + }), + { + wrapper, + } + ) + + const { handleFocus, handleBlur, handleChange } = result.current + + act(() => { + handleFocus() + handleChange('') + }) + expect(result.current.error).toBeUndefined() + expect(result.current.hasError).toBeFalsy() + + act(() => { + handleBlur() + }) + await waitFor(() => { + expect(result.current.error).toBeUndefined() + expect(result.current.hasError).toBeTruthy() + }) + + act(() => { + handleFocus() + handleChange('a') + handleBlur() + }) + await waitFor(() => { + expect(result.current.error).toBeUndefined() + expect(result.current.hasError).toBeFalsy() + }) + }) + describe('using focus callbacks', () => { it('should return the error only when the value is invalid AND it is not in focus', async () => { const { result } = renderHook(() => @@ -93,6 +139,7 @@ describe('useDataValue', () => { }, } ) + await waitFor(() => { expect(result.current.error).toBeInstanceOf(Error) }) @@ -109,6 +156,120 @@ describe('useDataValue', () => { }) }) + it('should validate schema', async () => { + const schema: JSONSchema7 = { + type: 'object', + properties: { + txt: { + type: 'string', + pattern: '^(valid)$', + }, + }, + } + + const { result } = renderHook(() => + useDataValue({ + value: 'valid', + schema, + path: '/txt', + }) + ) + + const { handleChange, handleFocus } = result.current + + await waitFor(() => { + expect(result.current.error).toBeUndefined() + }) + + act(() => { + handleChange('invalid') + }) + + await waitFor(() => { + expect(result.current.error).toBeInstanceOf(Error) + }) + + act(() => { + handleFocus() + handleChange('valid') + }) + + await waitFor(() => { + expect(result.current.error).toBeUndefined() + }) + }) + + it('should show error message', async () => { + const { result } = renderHook(() => + useDataValue({ + value: undefined, + required: true, + validateInitially: true, + errorMessages: { + required: 'Show this message', + }, + }) + ) + await waitFor(() => { + expect(result.current.error).toBeInstanceOf(Error) + expect(result.current.error.toString()).toBe( + 'Error: Show this message' + ) + }) + }) + + it('should validate "validateRequired"', async () => { + const validateRequired = jest.fn((v, { emptyValue, required }) => { + return required && emptyValue === 'empty' && v > 1 + ? new FormError('The value is required', { + validationRule: 'required', + }) + : undefined + }) + const onChange = jest.fn() + const onBlur = jest.fn() + + const { result } = renderHook(() => + useDataValue({ + value: 1, + required: true, + emptyValue: 'empty', + validateInitially: true, + validateRequired, + errorMessages: { + required: 'Show this message', + }, + onChange, + onBlur, + }) + ) + + const { handleChange } = result.current + + await waitFor(() => { + expect(result.current.error).toBeUndefined() + }) + + act(() => { + handleChange(2) + }) + + await waitFor(() => { + expect(result.current.error).toBeInstanceOf(Error) + expect(result.current.error.toString()).toBe( + 'Error: Show this message' + ) + }) + + act(() => { + handleChange(1) + }) + + await waitFor(() => { + expect(result.current.error).toBeUndefined() + }) + }) + it('should return error when required is set and the value is empty', async () => { const { result } = renderHook(() => useDataValue({ @@ -124,7 +285,7 @@ describe('useDataValue', () => { }) describe('value manipulation', () => { - it('should call fromInput and toInput', () => { + it('should call "fromInput" and "toInput"', () => { const fromInput = jest.fn((v) => v + 1) const toInput = jest.fn((v) => v - 1) const onChange = jest.fn() @@ -140,6 +301,9 @@ describe('useDataValue', () => { const { handleChange } = result.current + expect(fromInput).toHaveBeenCalledTimes(0) + expect(toInput).toHaveBeenCalledTimes(1) + act(() => { handleChange(2) }) @@ -162,6 +326,111 @@ describe('useDataValue', () => { * NB: "forceUpdate" is initiator that "toInput" is called more often. */ }) + + it('should call "toEvent"', () => { + const toEvent = jest.fn((v) => v + 1) + const onChange = jest.fn() + const onFocus = jest.fn() + const onBlur = jest.fn() + + const { result } = renderHook(() => + useDataValue({ + value: 1, + toEvent, + onChange, + onFocus, + onBlur, + }) + ) + + const { handleFocus, handleBlur, handleChange } = result.current + + expect(toEvent).toHaveBeenCalledTimes(0) + + act(() => { + handleFocus() + handleChange(2) + handleBlur() + }) + + expect(toEvent).toHaveBeenCalledTimes(3) + expect(toEvent).toHaveBeenLastCalledWith(2) + + expect(onChange).toHaveBeenCalledTimes(1) + expect(onChange).toHaveBeenLastCalledWith(3) + expect(onFocus).toHaveBeenCalledTimes(1) + expect(onFocus).toHaveBeenLastCalledWith(2) + expect(onBlur).toHaveBeenCalledTimes(1) + expect(onBlur).toHaveBeenLastCalledWith(3) + + act(() => { + handleFocus() + handleChange(4) + handleBlur() + }) + + expect(toEvent).toHaveBeenCalledTimes(6) + expect(toEvent).toHaveBeenLastCalledWith(4) + + expect(onChange).toHaveBeenCalledTimes(2) + expect(onChange).toHaveBeenLastCalledWith(5) + expect(onFocus).toHaveBeenCalledTimes(2) + expect(onFocus).toHaveBeenLastCalledWith(3) + expect(onBlur).toHaveBeenCalledTimes(2) + expect(onBlur).toHaveBeenLastCalledWith(5) + }) + + it('should call "fromExternal"', () => { + const fromExternal = jest.fn((v) => v + 1) + const onChange = jest.fn() + const onFocus = jest.fn() + const onBlur = jest.fn() + + const { result } = renderHook(() => + useDataValue({ + value: 1, + fromExternal, + onChange, + onFocus, + onBlur, + }) + ) + + const { handleFocus, handleBlur, handleChange } = result.current + + expect(fromExternal).toHaveBeenCalledTimes(1) + expect(fromExternal).toHaveBeenLastCalledWith(1) + + act(() => { + handleFocus() + handleChange(2) + handleBlur() + }) + + expect(fromExternal).toHaveBeenCalledTimes(1) + expect(fromExternal).toHaveBeenLastCalledWith(1) + + expect(onFocus).toHaveBeenCalledTimes(1) + expect(onFocus).toHaveBeenLastCalledWith(2) + expect(onBlur).toHaveBeenCalledTimes(1) + expect(onBlur).toHaveBeenLastCalledWith(2) + + act(() => { + handleFocus() + handleChange(4) + handleBlur() + }) + + expect(fromExternal).toHaveBeenCalledTimes(1) + expect(fromExternal).toHaveBeenLastCalledWith(1) + + expect(onFocus).toHaveBeenCalledTimes(2) + expect(onFocus).toHaveBeenLastCalledWith(2) + expect(onBlur).toHaveBeenCalledTimes(2) + expect(onBlur).toHaveBeenLastCalledWith(4) + + expect(onChange).toHaveBeenCalledTimes(1) + }) }) describe('updating internal value', () => { diff --git a/packages/dnb-eufemia/src/extensions/forms/hooks/useDataValue.ts b/packages/dnb-eufemia/src/extensions/forms/hooks/useDataValue.ts index b7a575f6fa3..47dbc665580 100644 --- a/packages/dnb-eufemia/src/extensions/forms/hooks/useDataValue.ts +++ b/packages/dnb-eufemia/src/extensions/forms/hooks/useDataValue.ts @@ -23,6 +23,7 @@ interface ReturnAdditional { id: string value: Value error: Error | FormError | undefined + hasError: boolean setHasFocus: (hasFocus: boolean, valueOverride?: unknown) => void handleFocus: () => void handleBlur: () => void @@ -41,19 +42,28 @@ export default function useDataValue< emptyValue, required, error: errorProp, + errorMessages, onFocus, onBlur, onChange, - validator, onBlurValidator, + validator, schema, - errorMessages, validateInitially, validateUnchanged, continuousValidation, toInput = (value: Value) => value, fromInput = (value: Value) => value, + toEvent = (value: Value) => value, + fromExternal = (value: Value) => value, + validateRequired = (value: Value, { emptyValue, required }) => + required && value === emptyValue + ? new FormError('The value is required', { + validationRule: 'required', + }) + : undefined, } = props + const [, forceUpdate] = useReducer(() => ({}), {}) const { startProcess } = useProcessManager() const id = useMemo(() => props.id ?? makeUniqueId(), [props.id]) @@ -61,6 +71,14 @@ export default function useDataValue< const fieldBlockContext = useContext(FieldBlockContext) const iterateElementContext = useContext(IterateElementContext) + const transformers = useRef({ + toInput, + fromInput, + toEvent, + fromExternal, + validateRequired, + }) + const { handlePathChange: dataContextHandlePathChange, setValueWithError: dataContextSetValueWithError, @@ -103,7 +121,7 @@ export default function useDataValue< const externalValue = useMemo(() => { if (props.value !== undefined) { // Value-prop sent directly to the field has highest priority, overriding any surrounding source - return props.value + return transformers.current.fromExternal(props.value) } if (inIterate && elementPath) { @@ -129,12 +147,12 @@ export default function useDataValue< } return undefined }, [ - path, - elementPath, - inIterate, - iterateElementValue, props.value, + inIterate, + elementPath, dataContext.data, + path, + iterateElementValue, ]) // Many variables are kept in refs to avoid triggering unnecessary update loops because updates using @@ -211,7 +229,6 @@ export default function useDataValue< return error }, - // eslint-disable-next-line react-hooks/exhaustive-deps [] ) @@ -260,10 +277,16 @@ export default function useDataValue< try { // Validate required - if (valueRef.current === emptyValue && required) { - throw new FormError('The value is required', { - validationRule: 'required', - }) + const requiredError = transformers.current.validateRequired( + valueRef.current, + { + emptyValue, + required, + isChanged: changedRef.current, + } + ) + if (requiredError instanceof Error) { + throw requiredError } // Validate by provided JSON Schema for this value @@ -277,11 +300,12 @@ export default function useDataValue< ) throw error } + // Validate by provided derivative validator if (validatorRef.current) { const res = await validatorRef.current?.( valueRef.current, - errorMessages + errorMessagesRef.current ) if (res instanceof Error) { throw res @@ -297,12 +321,11 @@ export default function useDataValue< } } }, [ + startProcess, emptyValue, required, - errorMessages, - startProcess, - persistErrorState, clearErrorState, + persistErrorState, ]) useUpdateEffect(() => { @@ -337,16 +360,35 @@ export default function useDataValue< } }, [dataContext.showAllErrors, showError]) + const handleError = useCallback(() => { + if ( + continuousValidation || + (continuousValidation !== false && !hasFocusRef.current) + ) { + // When there is a change to the value without there having been any focus callback beforehand, it is likely + // to believe that the blur callback will not be called either, which would trigger the display of the error. + // The error is therefore displayed immediately (unless instructed not to with continuousValidation set to false). + showError() + } else { + // When changing the value, hide errors to avoid annoying the user before they are finished filling in that value + hideError() + } + }, [continuousValidation, hideError, showError]) + const setHasFocus = useCallback( (hasFocus: boolean, valueOverride?: Value) => { if (hasFocus) { // Field was put in focus (like when clicking in a text field or opening a dropdown menu) hasFocusRef.current = true - onFocus?.(valueOverride ?? valueRef.current) + onFocus?.( + transformers.current.toEvent(valueOverride ?? valueRef.current) + ) } else { // Field was removed from focus (like when tabbing out of a text field or closing a dropdown menu) hasFocusRef.current = false - onBlur?.(valueOverride ?? valueRef.current) + onBlur?.( + transformers.current.toEvent(valueOverride ?? valueRef.current) + ) if (!changedRef.current && !validateUnchanged) { // Avoid showing errors when blurring without having changed the value, so tabbing through several @@ -359,7 +401,11 @@ export default function useDataValue< if (typeof onBlurValidator === 'function') { // Since the validator can return either a synchronous result or an asynchronous Promise.resolve( - onBlurValidator(valueOverride ?? valueRef.current) + onBlurValidator( + transformers.current.toEvent( + valueOverride ?? valueRef.current + ) + ) ).then(persistErrorState) } @@ -369,17 +415,17 @@ export default function useDataValue< } }, [ - validateUnchanged, - onFocus, onBlur, onBlurValidator, + onFocus, persistErrorState, showError, - forceUpdate, + validateUnchanged, ] ) const handleFocus = useCallback(() => setHasFocus(true), [setHasFocus]) + const handleBlur = useCallback(() => setHasFocus(false), [setHasFocus]) const updateValue = useCallback( @@ -392,36 +438,18 @@ export default function useDataValue< valueRef.current = newValue - if ( - continuousValidation || - (continuousValidation !== false && !hasFocusRef.current) - ) { - // When there is a change to the value without there having been any focus callback beforehand, it is likely - // to believe that the blur callback will not be called either, which would trigger the display of the error. - // The error is therefore displayed immediately (unless instructed not to with continuousValidation set to false). - showError() - } else { - // When changing the value, hide errors to avoid annoying the user before they are finished filling in that value - hideError() - } - // Always validate the value immediately when it is changed validateValue() + handleError() + if (path) { dataContextHandlePathChange?.(path, newValue) } forceUpdate() }, - [ - continuousValidation, - dataContextHandlePathChange, - hideError, - path, - showError, - validateValue, - ] + [dataContextHandlePathChange, handleError, path, validateValue] ) const handleChange = useCallback( @@ -429,7 +457,7 @@ export default function useDataValue< argFromInput: Value, additionalArgs: AdditionalEventArgs = undefined ) => { - const newValue = fromInput(argFromInput) + const newValue = transformers.current.fromInput(argFromInput) if (newValue === valueRef.current) { // Avoid triggering a change if the value was not actually changed. This may be caused by rendering components @@ -440,11 +468,13 @@ export default function useDataValue< updateValue(newValue) changedRef.current = true + + const value = transformers.current.toEvent(newValue) onChange?.apply( this, typeof additionalArgs !== 'undefined' - ? [newValue, additionalArgs] - : [newValue] + ? [value, additionalArgs] + : [value] ) if (elementPath) { @@ -456,11 +486,10 @@ export default function useDataValue< }, [ elementPath, - fromInput, - handleIterateElementChange, iterateElementIndex, - onChange, + handleIterateElementChange, updateValue, + onChange, ] ) @@ -478,18 +507,21 @@ export default function useDataValue< } }) + const error = showErrorRef.current + ? errorProp ?? localErrorRef.current ?? contextErrorRef.current + : undefined + return { ...props, - id, - name: props.name || props.path?.replace('/', '') || id, - value: toInput(valueRef.current), - error: - !inFieldBlock && showErrorRef.current - ? errorProp ?? localErrorRef.current ?? contextErrorRef.current - : undefined, autoComplete: props.autoComplete ?? (dataContext.autoComplete === true ? 'on' : 'off'), + id, + name: props.name || props.path?.replace('/', '') || id, + value: transformers.current.toInput(valueRef.current), + error: !inFieldBlock ? error : undefined, + hasError: Boolean(error), + isChanged: changedRef.current, setHasFocus, handleFocus, handleBlur, diff --git a/packages/dnb-eufemia/src/extensions/forms/types.ts b/packages/dnb-eufemia/src/extensions/forms/types.ts index 7b0e595c460..361697dc5f5 100644 --- a/packages/dnb-eufemia/src/extensions/forms/types.ts +++ b/packages/dnb-eufemia/src/extensions/forms/types.ts @@ -175,6 +175,7 @@ export interface FieldProps< info?: Error | FormError | string warning?: Error | FormError | string error?: Error | FormError + hasError?: boolean disabled?: boolean // Validation required?: boolean @@ -203,6 +204,20 @@ export interface FieldProps< // Derivatives toInput?: (external: Value | undefined) => any fromInput?: (...args: any[]) => Value | undefined + toEvent?: (internal: Value | undefined) => any + fromExternal?: (...args: any[]) => Value | undefined + validateRequired?: ( + internal: Value | undefined, + { + emptyValue, + required, + isChanged, + }: { + emptyValue: undefined | string | number + required: boolean + isChanged: boolean + } + ) => FormError | undefined } export interface FieldHelpProps {