diff --git a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/create-component/useDataValue/info.mdx b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/create-component/useDataValue/info.mdx index 7dcf9842c62..5e5e6c736e3 100644 --- a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/create-component/useDataValue/info.mdx +++ b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/create-component/useDataValue/info.mdx @@ -173,25 +173,12 @@ 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: +- `toInput` transforms the value before it gets returned as the `value`: -```ts -const { value } = useDataValue(props) -``` +- `fromInput` transforms the value given by `handleChange` before it is used in the further process flow. Use it to destruct the value form the original event object. -- `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`. +- `toEvent` transforms the internal value before it gets returned by even callbacks such as `onChange`, `onFocus` and `onBlur`. The second parameter returns the event type: `onChange`, `onFocus`, `onBlur` or `onBlurValidator`. - `fromExternal` transforms the given props `value` before any other step gets entered. -#### Additional features - -| Property | Type | Description | -| ------------ | --------- | -------------------------------------------------------------------------------------------------------------------------- | -| `capitalize` | `boolean` | _(optional)_ When set to `true`, it will capitalize the first letter of every word, transforming the rest to lowercase. | -| `trim` | `boolean` | _(optional)_ When `true`, it will trim leading and trailing whitespaces on blur, triggering onChange if the value changes. | +- `transformValue` transforms the value given by `handleChange` after `fromInput` and before `updateValue` and `toEvent`. The second parameter returns the current value diff --git a/packages/dnb-eufemia/src/extensions/forms/Field/String/String.tsx b/packages/dnb-eufemia/src/extensions/forms/Field/String/String.tsx index 9bb0e87ac7a..d4b5bac40e6 100644 --- a/packages/dnb-eufemia/src/extensions/forms/Field/String/String.tsx +++ b/packages/dnb-eufemia/src/extensions/forms/Field/String/String.tsx @@ -11,6 +11,7 @@ import FieldBlock from '../../FieldBlock' import { useDataValue } from '../../hooks' import { FieldProps, FieldHelpProps } from '../../types' import { pickSpacingProps } from '../../../../components/flex/utils' +import { toCapitalized } from '../../../../shared/component-helper' interface ErrorMessages { required?: string @@ -79,12 +80,40 @@ function StringComponent(props: Props) { }, [props.emptyValue] ) + const toEvent = useCallback( + (value: string, type: string) => { + if (props.trim && type === 'onBlur') { + const spaces = '[\\s ]' + if (new RegExp(`^${spaces}|${spaces}$`).test(value)) { + value = value.replace( + new RegExp(`^${spaces}+|${spaces}+$`, 'g'), + '' + ) + handleChange(value) + } + } + return value + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [props.trim] + ) + const transformValue = useCallback( + (value: string) => { + if (props.capitalize) { + value = toCapitalized(String(value || '')) + } + return value + }, + [props.capitalize] + ) const preparedProps: Props = { ...props, errorMessages, schema, fromInput, + toEvent, + transformValue, width: props.width ?? 'large', } @@ -123,6 +152,11 @@ function StringComponent(props: Props) { handleChange, } = useDataValue(preparedProps) + const transformInstantly = useCallback( + (value: string) => (props.capitalize ? toCapitalized(value) : value), + [props.capitalize] + ) + const characterCounterElement = characterCounter ? props.maxLength ? `${value?.length ?? '0'}/${props.maxLength}` @@ -147,7 +181,7 @@ function StringComponent(props: Props) { stretch: width !== undefined, inner_ref: innerRef, status: error || hasError ? 'error' : undefined, - value: value?.toString() ?? '', + value: transformInstantly(value?.toString() ?? ''), } return ( diff --git a/packages/dnb-eufemia/src/extensions/forms/Field/String/__tests__/String.test.tsx b/packages/dnb-eufemia/src/extensions/forms/Field/String/__tests__/String.test.tsx index 1b596e41827..6e4208922f1 100644 --- a/packages/dnb-eufemia/src/extensions/forms/Field/String/__tests__/String.test.tsx +++ b/packages/dnb-eufemia/src/extensions/forms/Field/String/__tests__/String.test.tsx @@ -88,6 +88,8 @@ describe('Field.String', () => { const input = document.querySelector('input') + expect(input).toHaveValue('First Word') + await userEvent.type(input, ' second') expect(input).toHaveValue('First Word Second') @@ -112,7 +114,7 @@ describe('Field.String', () => { render( @@ -120,11 +122,11 @@ describe('Field.String', () => { const input = document.querySelector('input') - expect(input).toHaveValue(' first') + expect(input).toHaveValue(' first') await userEvent.type(input, ' second ') - expect(onChange).toHaveBeenLastCalledWith(' first second ') + expect(onChange).toHaveBeenLastCalledWith(' first second ') fireEvent.blur(input) 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 0e7acac5ffb..85dcf15b809 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 @@ -469,7 +469,7 @@ describe('useDataValue', () => { }) expect(toEvent).toHaveBeenCalledTimes(3) - expect(toEvent).toHaveBeenLastCalledWith(2) + expect(toEvent).toHaveBeenLastCalledWith(2, 'onBlur') expect(onChange).toHaveBeenCalledTimes(1) expect(onChange).toHaveBeenLastCalledWith(3) @@ -485,7 +485,7 @@ describe('useDataValue', () => { }) expect(toEvent).toHaveBeenCalledTimes(6) - expect(toEvent).toHaveBeenLastCalledWith(4) + expect(toEvent).toHaveBeenLastCalledWith(4, 'onBlur') expect(onChange).toHaveBeenCalledTimes(2) expect(onChange).toHaveBeenLastCalledWith(5) @@ -546,54 +546,38 @@ describe('useDataValue', () => { expect(onChange).toHaveBeenCalledTimes(1) }) - }) - describe('manipulate string value', () => { - it('should capitalize value', () => { - const onBlur = jest.fn() - const onChange = jest.fn() + it('should call "transformValue"', () => { + const transformValue = jest.fn((v) => v + 1) const { result } = renderHook(() => useDataValue({ - value: 'foo', - capitalize: true, - onBlur, - onChange, + value: 1, + transformValue, }) ) - const { handleBlur, handleChange } = result.current + const { handleFocus, handleBlur, handleChange } = result.current + + expect(transformValue).toHaveBeenCalledTimes(0) act(() => { + handleFocus() + handleChange(2) handleBlur() - handleChange('bar') }) - expect(onBlur).toHaveBeenLastCalledWith('Foo') - expect(onChange).toHaveBeenLastCalledWith('Bar') - }) - - it('should trim value', () => { - const onBlur = jest.fn() - const onChange = jest.fn() - - const { result } = renderHook(() => - useDataValue({ - value: ' foo', - trim: true, - onBlur, - onChange, - }) - ) - - const { handleBlur } = result.current + expect(transformValue).toHaveBeenCalledTimes(1) + expect(transformValue).toHaveBeenLastCalledWith(2, 1) act(() => { + handleFocus() + handleChange(4) handleBlur() }) - expect(onBlur).toHaveBeenLastCalledWith('foo') - expect(onChange).toHaveBeenLastCalledWith('foo') + expect(transformValue).toHaveBeenCalledTimes(2) + expect(transformValue).toHaveBeenLastCalledWith(4, 3) }) }) diff --git a/packages/dnb-eufemia/src/extensions/forms/hooks/useDataValue.ts b/packages/dnb-eufemia/src/extensions/forms/hooks/useDataValue.ts index 873489ea25b..9a106d1521d 100644 --- a/packages/dnb-eufemia/src/extensions/forms/hooks/useDataValue.ts +++ b/packages/dnb-eufemia/src/extensions/forms/hooks/useDataValue.ts @@ -14,10 +14,7 @@ import { FormError, FieldProps, AdditionalEventArgs } from '../types' import { Context, ContextState } from '../DataContext' import FieldBlockContext from '../FieldBlock/FieldBlockContext' import IterateElementContext from '../Iterate/IterateElementContext' -import { - makeUniqueId, - toCapitalized, -} from '../../../shared/component-helper' +import { makeUniqueId } from '../../../shared/component-helper' import useMountEffect from './useMountEffect' import useUpdateEffect from './useUpdateEffect' import useProcessManager from './useProcessManager' @@ -59,6 +56,7 @@ export default function useDataValue< toInput = (value: Value) => value, fromInput = (value: Value) => value, toEvent = (value: Value) => value, + transformValue = (value: Value) => value, fromExternal = (value: Value) => value, validateRequired = (value: Value, { emptyValue, required }) => { const res = @@ -85,6 +83,7 @@ export default function useDataValue< fromInput, toEvent, fromExternal, + transformValue, validateRequired, }) @@ -130,14 +129,8 @@ export default function useDataValue< const externalValue = useMemo(() => { if (props.value !== undefined) { - let value = transformers.current.fromExternal(props.value) - - if (props.capitalize) { - value = toCapitalized(String(value || '')) as Value - } - // Value-prop sent directly to the field has highest priority, overriding any surrounding source - return value + return transformers.current.fromExternal(props.value) } if (inIterate && itemPath) { @@ -411,15 +404,19 @@ export default function useDataValue< if (hasFocus) { // Field was put in focus (like when clicking in a text field or opening a dropdown menu) hasFocusRef.current = true - onFocus?.( - transformers.current.toEvent(valueOverride ?? valueRef.current) + const value = transformers.current.toEvent( + valueOverride ?? valueRef.current, + 'onFocus' ) + onFocus?.(value) } else { // Field was removed from focus (like when tabbing out of a text field or closing a dropdown menu) hasFocusRef.current = false - onBlur?.( - transformers.current.toEvent(valueOverride ?? valueRef.current) + const value = transformers.current.toEvent( + valueOverride ?? valueRef.current, + 'onBlur' ) + onBlur?.(value) if (!changedRef.current && !validateUnchanged) { // Avoid showing errors when blurring without having changed the value, so tabbing through several @@ -431,13 +428,11 @@ export default function useDataValue< // expensive validation calling external services etc. if (typeof onBlurValidator === 'function') { // Since the validator can return either a synchronous result or an asynchronous - Promise.resolve( - onBlurValidator( - transformers.current.toEvent( - valueOverride ?? valueRef.current - ) - ) - ).then(persistErrorState) + const value = transformers.current.toEvent( + valueOverride ?? valueRef.current, + 'onBlurValidator' + ) + Promise.resolve(onBlurValidator(value)).then(persistErrorState) } // Since the user left the field, show error (if any) @@ -484,23 +479,25 @@ export default function useDataValue< argFromInput: Value, additionalArgs: AdditionalEventArgs = undefined ) => { + const currentValue = valueRef.current let newValue = transformers.current.fromInput(argFromInput) - if (newValue === valueRef.current) { + if (newValue === currentValue) { // Avoid triggering a change if the value was not actually changed. This may be caused by rendering components // calling onChange even if the actual value did not change. return } - if (props.capitalize) { - newValue = toCapitalized(String(newValue || '')) as Value - } + newValue = transformers.current.transformValue( + newValue, + currentValue + ) updateValue(newValue) changedRef.current = true - const value = transformers.current.toEvent(newValue) + const value = transformers.current.toEvent(newValue, 'onChange') onChange?.apply( this, typeof additionalArgs !== 'undefined' @@ -516,7 +513,6 @@ export default function useDataValue< } }, [ - props.capitalize, updateValue, onChange, itemPath, @@ -527,14 +523,7 @@ export default function useDataValue< const handleFocus = useCallback(() => setHasFocus(true), [setHasFocus]) - const handleBlur = useCallback(() => { - if (props.trim && /^\s|\s$/.test(String(valueRef.current))) { - const value = String(valueRef.current).trim() - handleChange(value as Value) - } - - setHasFocus(false) - }, [props.trim, setHasFocus, handleChange]) + const handleBlur = useCallback(() => setHasFocus(false), [setHasFocus]) useMountEffect(() => { dataContext?.handleMountField(identifier) diff --git a/packages/dnb-eufemia/src/extensions/forms/types.ts b/packages/dnb-eufemia/src/extensions/forms/types.ts index 755e3a870f6..9a82e3ab387 100644 --- a/packages/dnb-eufemia/src/extensions/forms/types.ts +++ b/packages/dnb-eufemia/src/extensions/forms/types.ts @@ -204,12 +204,16 @@ export interface FieldProps< continuousValidation?: boolean errorMessages?: ErrorMessages // Derivatives - toInput?: (external: Value | undefined) => any - fromInput?: (...args: any[]) => Value | undefined - toEvent?: (internal: Value | undefined) => any - fromExternal?: (...args: any[]) => Value | undefined + toInput?: (external: Value | unknown) => Value | unknown + fromInput?: (external: Value | unknown) => Value + toEvent?: ( + internal: Value, + type: 'onChange' | 'onFocus' | 'onBlur' | 'onBlurValidator' + ) => Value + fromExternal?: (external: Value) => Value + transformValue?: (value: Value, currentValue?: Value) => Value validateRequired?: ( - internal: Value | undefined, + internal: Value, { emptyValue, required,