diff --git a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/base-fields/Number/Examples.tsx b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/base-fields/Number/Examples.tsx index f52023fe382..24c44ec47ae 100644 --- a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/base-fields/Number/Examples.tsx +++ b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/base-fields/Number/Examples.tsx @@ -1,7 +1,7 @@ -import ComponentBox from '../../../../../../shared/tags/ComponentBox' -import { Slider, Grid, Flex } from '@dnb/eufemia/src' -import { Field, Form } from '@dnb/eufemia/src/extensions/forms' import React from 'react' +import ComponentBox from '../../../../../../shared/tags/ComponentBox' +import { Slider, Grid, Flex, Anchor } from '@dnb/eufemia/src' +import { Field, Form, FormError } from '@dnb/eufemia/src/extensions/forms' export const Placeholder = () => { return ( @@ -421,3 +421,89 @@ export const WithSlider = () => ( }} ) + +export const ConditionalInfo = () => { + return ( + + {() => { + const conditionalInfo = ( + maximum, + { renderMode, getValueByPath, getFieldByPath }, + ) => { + renderMode('initially') // Your can also use 'continuously' or 'always' + + const amount = getValueByPath('/amount') + + if (maximum < amount) { + const { props } = getFieldByPath('/amount') + const anchor = ( + { + event.preventDefault() + const el = document.getElementById(props.id + '-label') + el?.scrollIntoView() + }} + > + {props?.label} + + ) + + return ( + <> + Remember to adjust the {anchor} to be {maximum} or lower. + + ) + } + } + const onBlurValidator = (amount, { connectWithPath }) => { + const { getValue: getMaximum } = connectWithPath('/maximum') + + if (amount > getMaximum()) { + return new FormError('NumberField.errorMaximum', { + messageValues: { + maximum: getMaximum(), + }, + }) + } + } + + return ( + + + +
+ Defines the maximum amount possible to be entered. + + } + path="/maximum" + info={conditionalInfo} + /> + +
+ Should be same or lower than maximum. + + } + path="/amount" + onBlurValidator={onBlurValidator} + /> +
+ + +
+ ) + }} +
+ ) +} diff --git a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/base-fields/Number/demos.mdx b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/base-fields/Number/demos.mdx index b1f88130e3d..a1c7c9e2f98 100644 --- a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/base-fields/Number/demos.mdx +++ b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/base-fields/Number/demos.mdx @@ -78,6 +78,12 @@ You can also use a function as a prefix or suffix. +### Validation - Conditional info message + +You can provide a function to the `info`, `warning` or `error` props that returns a message based on your conditions. + + + ### Percentage 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 d74f85e5e5f..366f76d3d50 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,6 +1,6 @@ import { useCallback } from 'react' -import { Field, Form, UseFieldProps } from '../../..' -import { Flex } from '../../../../../components' +import { Field, Form, FormError, UseFieldProps } from '../../..' +import { Anchor, Flex } from '../../../../../components' export default { title: 'Eufemia/Extensions/Forms/Number', @@ -84,3 +84,83 @@ export const WithFreshValidator = () => { ) } + +export const ConditionalInfo = () => { + const warningWhen = ( + maximum: number, + { renderMode, getValueByPath, getFieldByPath } + ) => { + renderMode('initially') + // renderMode('continuously') + // renderMode('always') + + const amount = getValueByPath('/amount') + + if (maximum < amount) { + const { props } = getFieldByPath('/amount') + const anchor = ( + ) => { + event.preventDefault() + const el = document.getElementById(`${props.id}-label`) + el?.scrollIntoView() + }} + > + {props.label} + + ) + + return ( + <> + Remember to adjust the {anchor} to be {maximum} or lower.{' '} + + ) + } + } + const onBlurValidator = (amount: number, { connectWithPath }) => { + const { getValue: getMaximum } = connectWithPath('/maximum') + + if (amount > getMaximum()) { + return new FormError('NumberField.errorMaximum', { + messageValues: { + maximum: getMaximum(), + }, + }) + } + } + return ( + + + + + + + + + ) +} diff --git a/packages/dnb-eufemia/src/extensions/forms/FieldBlock/FieldBlock.tsx b/packages/dnb-eufemia/src/extensions/forms/FieldBlock/FieldBlock.tsx index 932b73119dd..64b766a086a 100644 --- a/packages/dnb-eufemia/src/extensions/forms/FieldBlock/FieldBlock.tsx +++ b/packages/dnb-eufemia/src/extensions/forms/FieldBlock/FieldBlock.tsx @@ -498,6 +498,7 @@ function FieldBlock(props: Props) { }) const labelProps: FormLabelAllProps = { + id: `${id}-label`, className: 'dnb-forms-field-block__label', element: enableFieldset ? 'legend' : 'label', forId: enableFieldset ? undefined : forId, diff --git a/packages/dnb-eufemia/src/extensions/forms/hooks/useFieldProps.ts b/packages/dnb-eufemia/src/extensions/forms/hooks/useFieldProps.ts index f40748481d7..2829808228a 100644 --- a/packages/dnb-eufemia/src/extensions/forms/hooks/useFieldProps.ts +++ b/packages/dnb-eufemia/src/extensions/forms/hooks/useFieldProps.ts @@ -25,6 +25,10 @@ import { ValidatorAdditionalArgs, Validator, Identifier, + MessageProp, + MessageTypes, + MessagePropParams, + MessageRenderMode, } from '../types' import { Context as DataContext, ContextState } from '../DataContext' import { clearedData } from '../DataContext/Provider/Provider' @@ -113,8 +117,8 @@ export default function useFieldProps( emptyValue, required: requiredProp, disabled: disabledProp, - info, - warning, + info: infoProp, + warning: warningProp, error: errorProp, errorMessages, onFocus, @@ -174,6 +178,7 @@ export default function useFieldProps( useContext(SnapshotContext) || {} const { isVisible } = useContext(VisibilityContext) || {} + const { getValueByPath } = useDataValue() const translation = useTranslation() const { formatMessage } = translation const translationRef = useRef(translation) @@ -208,6 +213,7 @@ export default function useFieldProps( contextErrorMessages, fieldDisplayValueRef, existingFieldsRef, + fieldPropsRef, } = dataContext || {} const onChangeContext = dataContext?.props?.onChange @@ -324,11 +330,112 @@ export default function useFieldProps( sectionPath, ]) + const getFieldByPath: MessagePropParams['getFieldByPath'] = + useCallback( + (path) => { + const props = fieldPropsRef.current?.[path] + return { props } + }, + [fieldPropsRef] + ) + + const messageCacheRef = useRef<{ + isSet: boolean + message: MessageTypes + }>({ isSet: false, message: undefined }) + const executeMessage = useCallback( + >(message: MessageProp): T => { + if (typeof message === 'function') { + let currentMode: MessageRenderMode = undefined + const renderMode: MessagePropParams['renderMode'] = ( + mode + ) => { + currentMode = mode + } + + const msg = message(valueRef.current, { + renderMode, + getValueByPath, + getFieldByPath, + }) + + const isError = + msg instanceof Error || + msg instanceof FormError || + (Array.isArray(msg) && checkForError(msg)) + + if ( + // Remove the message if, if it gets update from outside to have no message anymore + !isInternalRerenderRef.current && + messageCacheRef.current.message && + !msg + ) { + currentMode = 'always' + } + + if ( + (!messageCacheRef.current.isSet && + currentMode === 'initially') || + currentMode === 'continuously' || + currentMode === 'always' || + hasFocusRef.current === false || + // Ensure we don't remove the message when the value is e.g. empty string + messageCacheRef.current.message + ) { + if ( + // Ensure to only update the message when component did re-render internally + isInternalRerenderRef.current || + currentMode === 'always' || + (!messageCacheRef.current.isSet && + (currentMode === 'initially' || + currentMode === 'continuously')) + ) { + if (msg) { + messageCacheRef.current.isSet = true + } + if ( + msg || + !hasFocusRef.current || + currentMode === 'continuously' || + currentMode === 'always' + ) { + messageCacheRef.current.message = msg + } + } + + message = messageCacheRef.current.message as T + + if (isError && message) { + revealErrorRef.current = true + } + + if (!isError && !message) { + return null // hide the message + } + } else { + return undefined // no message + } + } + + return message + }, + [getFieldByPath, getValueByPath] + ) + const error = executeMessage< + Error | FormError | Array + >(errorProp) + const warning = executeMessage>( + warningProp + ) + const info = executeMessage>( + infoProp + ) + // Error handling // - Should errors received through validation be shown initially. Assume that providing a direct prop to // the component means it is supposed to be shown initially. const revealErrorRef = useRef( - validateInitially ?? Boolean(errorProp) + validateInitially ?? Boolean(error) ) // - Local errors are errors based on validation instructions received by @@ -600,19 +707,20 @@ export default function useFieldProps( revealErrorRef.current = true } - const error = revealErrorRef.current - ? prepareError(errorProp) ?? + const bufferedError = revealErrorRef.current + ? prepareError(error) ?? localErrorRef.current ?? contextErrorRef.current : undefined const hasVisibleError = - Boolean(error) || (inFieldBlock && fieldBlockContext.hasErrorProp) + Boolean(bufferedError) || + (inFieldBlock && fieldBlockContext.hasErrorProp) const hasError = useCallback(() => { return Boolean( - errorProp ?? localErrorRef.current ?? contextErrorRef.current + error ?? localErrorRef.current ?? contextErrorRef.current ) - }, [errorProp]) + }, [error]) const connectWithPathListenerRef = useRef(async () => { if ( @@ -628,7 +736,6 @@ export default function useFieldProps( } }) - const { getValueByPath } = useDataValue() const exportValidatorsRef = useRef(exportValidators) exportValidatorsRef.current = exportValidators const additionalArgs = useMemo(() => { @@ -808,11 +915,15 @@ export default function useFieldProps( } }, [persistErrorState]) + const setChanged = useCallback((state: boolean) => { + changedRef.current = state + }, []) + const removeError = useCallback(() => { - changedRef.current = false + setChanged(false) hideError() clearErrorState() - }, [clearErrorState, hideError]) + }, [clearErrorState, hideError, setChanged]) const validatorCacheRef = useRef({ onChangeValidator: null, @@ -1524,10 +1635,6 @@ export default function useFieldProps( ] ) - const setChanged = (state: boolean) => { - changedRef.current = state - } - const setDisplayValue = useCallback( (path: Identifier, content: React.ReactNode) => { if (!path || !fieldDisplayValueRef?.current) { @@ -1556,7 +1663,7 @@ export default function useFieldProps( } // Must be set before validation - changedRef.current = true + setChanged(true) if (asyncBehaviorIsEnabled) { hideError() @@ -1619,17 +1726,18 @@ export default function useFieldProps( await runPool() }, [ + addToPool, asyncBehaviorIsEnabled, + defineAsyncProcess, + getEventArgs, + hasError, + hideError, onChange, runPool, - hideError, + setChanged, + setEventResult, updateValue, - addToPool, - getEventArgs, yieldAsyncProcess, - defineAsyncProcess, - hasError, - setEventResult, ] ) @@ -1637,6 +1745,7 @@ export default function useFieldProps( const handleBlur = useCallback(() => setHasFocus(false), [setHasFocus]) // Put props into the surrounding data context as early as possible + props.id = id setFieldPropsDataContext?.(identifier, props) const { activeIndex, activeIndexRef } = wizardContext || {} @@ -2003,11 +2112,11 @@ export default function useFieldProps( useLayoutEffect(() => { if (isEmptyData()) { defaultValueRef.current = defaultValue - changedRef.current = false + setChanged(false) hideError() clearErrorState() } - }, [clearErrorState, defaultValue, hideError, isEmptyData]) + }, [clearErrorState, defaultValue, hideError, isEmptyData, setChanged]) useMemo(() => { if (updateContextDataInSync && !isEmptyData()) { @@ -2098,7 +2207,7 @@ export default function useFieldProps( setBlockRecord?.({ identifier, type: 'error', - content: errorProp, + content: error, showInitially: true, show: true, }) @@ -2126,7 +2235,7 @@ export default function useFieldProps( } } }, [ - errorProp, + error, identifier, inFieldBlock, info, @@ -2137,11 +2246,12 @@ export default function useFieldProps( const infoRef = useRef(info) const warningRef = useRef(warning) - useUpdateEffect(() => { + if (typeof info !== 'undefined') { infoRef.current = info + } + if (typeof warning !== 'undefined') { warningRef.current = warning - forceUpdate() - }, [info, warning]) + } const connections = useMemo(() => { return { @@ -2163,8 +2273,8 @@ export default function useFieldProps( ) }, [props]) - if (error) { - htmlAttributes['aria-invalid'] = error ? 'true' : 'false' + if (bufferedError) { + htmlAttributes['aria-invalid'] = bufferedError ? 'true' : 'false' } if (required) { htmlAttributes['aria-required'] = 'true' @@ -2182,7 +2292,7 @@ export default function useFieldProps( htmlAttributes['aria-describedby'] = combineDescribedBy( htmlAttributes, [ - error && stateIds.error, + bufferedError && stateIds.error, warning && stateIds.warning, info && stateIds.info, ].filter(Boolean) @@ -2190,7 +2300,7 @@ export default function useFieldProps( } } else { const ids = [ - (error || errorProp) && `${id}-form-status--error`, + (bufferedError || error) && `${id}-form-status--error`, warning && `${id}-form-status--warning`, info && `${id}-form-status--info`, ].filter(Boolean) @@ -2215,7 +2325,7 @@ export default function useFieldProps( /** Documented APIs */ info: !inFieldBlock ? infoRef.current : undefined, warning: !inFieldBlock ? warningRef.current : undefined, - error: !inFieldBlock ? error : undefined, + error: !inFieldBlock ? bufferedError : undefined, required, label: props.label, labelDescription: props.labelDescription, diff --git a/packages/dnb-eufemia/src/extensions/forms/types.ts b/packages/dnb-eufemia/src/extensions/forms/types.ts index 89bbcf96a57..d335c6402dd 100644 --- a/packages/dnb-eufemia/src/extensions/forms/types.ts +++ b/packages/dnb-eufemia/src/extensions/forms/types.ts @@ -13,6 +13,7 @@ import { FormsTranslationFlat, FormsTranslationLocale, } from './hooks/useTranslation' +import { GetValueByPath } from './hooks/useDataValue' export type * from 'json-schema' export type JSONSchema = JSONSchema7 @@ -265,6 +266,22 @@ export type DataValueReadWriteComponentProps< DataValueReadProps & DataValueWriteProps +export type MessageRenderMode = 'initially' | 'continuously' | 'always' +export type MessagePropParams = { + renderMode: (mode: MessageRenderMode) => void + getValueByPath: GetValueByPath + getFieldByPath: (path: Path) => { + props: FieldProps + } +} +export type MessageProp = + | T + | ((value: Value, options: MessagePropParams) => T) +export type MessageTypes = + | UseFieldProps['info'] + | UseFieldProps['warning'] + | UseFieldProps['error'] + export interface UseFieldProps< Value = unknown, EmptyValue = undefined | unknown, @@ -300,9 +317,9 @@ export interface UseFieldProps< props?: Record // - Used by useFieldProps and FieldBlock - info?: React.ReactNode - warning?: React.ReactNode - error?: Error | FormError | Array + info?: MessageProp> + warning?: MessageProp> + error?: MessageProp> // - Validation required?: boolean