Skip to content

Commit

Permalink
fix(forms): Field block error handling (#2900)
Browse files Browse the repository at this point in the history
FieldBlock did not receive errors from initial renders of inner field components, only after change callbacks to the value.
  • Loading branch information
henit authored Nov 15, 2023
1 parent af5aa61 commit 9582c64
Show file tree
Hide file tree
Showing 7 changed files with 165 additions and 44 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,19 @@ export const GroupMultipleFields = () => {
)
}

export const CombineErrorMessages = () => {
return (
<ComponentBox>
<FieldBlock>
<Flex.Horizontal>
<Field.Number width="small" label="Num" minimum={100} />
<Field.String width="medium" label="Txt" minLength={5} />
</Flex.Horizontal>
</FieldBlock>
</ComponentBox>
)
}

export const HorizontalAutoSize = () => {
return (
<ComponentBox>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,12 @@ import * as Examples from './Examples'

<Examples.GroupMultipleFields />

### Combine error messages

Error messages from all fields inside the FieldBlock are combined as one message below the whole block

<Examples.CombineErrorMessages />

### Responsive forms

<Examples.HorizontalAutoSize />
108 changes: 81 additions & 27 deletions packages/dnb-eufemia/src/extensions/forms/Field/Number/Number.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import React, { useMemo } from 'react'
import React, { useContext, useMemo, useCallback } from 'react'
import { JSONSchema7 } from 'json-schema'
import { InputMasked, HelpButton } from '../../../../components'
import { InputMaskedProps } from '../../../../components/InputMasked'
import SharedContext from '../../../../shared/Context'
import classnames from 'classnames'
import FieldBlock from '../../FieldBlock'
import { useDataValue } from '../../hooks'
Expand Down Expand Up @@ -41,6 +43,9 @@ export type Props = FieldHelpProps &
}

function NumberComponent(props: Props) {
const sharedContext = useContext(SharedContext)
const tr = sharedContext?.translation.Forms

const {
currency,
percent,
Expand All @@ -53,6 +58,77 @@ function NumberComponent(props: Props) {
rightAligned,
} = props

const errorMessages = useMemo(
() => ({
required: tr.inputErrorRequired,
minimum: tr.numberFieldErrorMinimum.replace(
'{minimum}',
props.minimum?.toString()
),
maximum: tr.numberFieldErrorMaximum.replace(
'{maximum}',
props.maximum?.toString()
),
exclusiveMinimum: tr.numberFieldErrorExclusiveMinimum.replace(
'{exclusiveMinimum}',
props.exclusiveMinimum?.toString()
),
exclusiveMaximum: tr.numberFieldErrorExclusiveMaximum.replace(
'{exclusiveMaximum}',
props.exclusiveMaximum?.toString()
),
multipleOf: tr.numberFieldErrorMultipleOf.replace(
'{multipleOf}',
props.multipleOf?.toString()
),
...props.errorMessages,
}),
[
tr,
props.errorMessages,
props.minimum,
props.maximum,
props.exclusiveMinimum,
props.exclusiveMaximum,
props.multipleOf,
]
)
const schema = useMemo<JSONSchema7>(
() =>
props.schema ?? {
type: 'number',
minimum: props.minimum,
maximum: props.maximum,
exclusiveMinimum: props.exclusiveMinimum,
exclusiveMaximum: props.exclusiveMaximum,
multipleOf: props.multipleOf,
},
[
props.schema,
props.minimum,
props.maximum,
props.exclusiveMinimum,
props.exclusiveMaximum,
props.multipleOf,
]
)

const toInput = useCallback((external: number | undefined) => {
if (external === undefined) {
return ''
}
return external
}, [])
const fromInput = useCallback(
({ value, numberValue }: { value: string; numberValue: number }) => {
if (value === '') {
return emptyValue
}
return numberValue
},
[]
)

const maskProps: Partial<InputMaskedProps> = useMemo(() => {
if (currency) {
return {
Expand Down Expand Up @@ -91,32 +167,10 @@ function NumberComponent(props: Props) {

const preparedProps: Props = {
...props,
schema: props.schema ?? {
type: 'number',
minimum: props.minimum,
maximum: props.maximum,
exclusiveMinimum: props.exclusiveMinimum,
exclusiveMaximum: props.exclusiveMaximum,
multipleOf: props.multipleOf,
},
toInput: (external: number | undefined) => {
if (external === undefined) {
return ''
}
return external
},
fromInput: ({
value,
numberValue,
}: {
value: string
numberValue: number
}) => {
if (value === '') {
return emptyValue
}
return numberValue
},
errorMessages,
schema,
toInput,
fromInput,
width: props.width ?? 'medium',
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import React from 'react'
import { screen, render, waitFor } from '@testing-library/react'
import { Field } from '../..'
import FieldBlockContext from '../FieldBlockContext'

describe('FieldBlockContext', () => {
it('should receive the error instead of inner components rendering it', async () => {
const setError = jest.fn()
const setShowError = jest.fn()

render(
<FieldBlockContext.Provider value={{ setError, setShowError }}>
<Field.String value="ab" minLength={5} validateInitially />
</FieldBlockContext.Provider>
)

await waitFor(() => {
expect(setError).toHaveBeenCalledTimes(1)
expect(setShowError).toHaveBeenCalledTimes(1)
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
})
})
})
43 changes: 26 additions & 17 deletions packages/dnb-eufemia/src/extensions/forms/hooks/useDataValue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -139,14 +139,15 @@ export default function useDataValue<
const hasFocusRef = useRef<boolean>(false)

// 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 showErrorInitially = validateInitially || errorProp
// - Local errors are errors based on validation instructions received by
const localErrorRef = useRef<Error | FormError | undefined>()
// - Context errors are from outer contexts, like validation for this field as part of the whole data set
const contextErrorRef = useRef<Error | FormError | undefined>()

const showErrorRef = useRef<boolean>(
Boolean(validateInitially || errorProp)
)
const showErrorRef = useRef<boolean>(Boolean(showErrorInitially))
const errorMessagesRef = useRef(errorMessages)
useEffect(() => {
errorMessagesRef.current = errorMessages
Expand All @@ -164,6 +165,16 @@ export default function useDataValue<
schema ? ajv.compile(schema) : undefined
)

const showError = useCallback(() => {
showErrorRef.current = true
setShowFieldBlockError?.(path ?? id, true)
}, [path, id, setShowFieldBlockError])

const hideError = useCallback(() => {
showErrorRef.current = false
setShowFieldBlockError?.(path ?? id, false)
}, [path, id, setShowFieldBlockError])

/**
* Prepare error from validation logic with correct error messages based on props
*/
Expand Down Expand Up @@ -304,10 +315,9 @@ export default function useDataValue<
if (dataContext.showAllErrors) {
// If showError on a surrounding data context was changed and set to true, it is because the user clicked next, submit or
// something else that should lead to showing the user all errors.
showErrorRef.current = true
setShowFieldBlockError?.(path ?? id, true)
showError()
}
}, [id, path, dataContext.showAllErrors, setShowFieldBlockError])
}, [dataContext.showAllErrors, showError])

const setHasFocus = useCallback(
(hasFocus: boolean, valueOverride?: Value) => {
Expand Down Expand Up @@ -336,20 +346,17 @@ export default function useDataValue<
}

// Since the user left the field, show error (if any)
showErrorRef.current = true
setShowFieldBlockError?.(path ?? id, true)
showError()
forceUpdate()
}
},
[
id,
path,
validateUnchanged,
onFocus,
onBlur,
onBlurValidator,
persistErrorState,
setShowFieldBlockError,
showError,
forceUpdate,
]
)
Expand All @@ -376,12 +383,10 @@ export default function useDataValue<
// 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).
showErrorRef.current = true
setShowFieldBlockError?.(path ?? id, true)
showError()
} else {
// When changing the value, hide errors to avoid annoying the user before they are finished filling in that value
showErrorRef.current = false
setShowFieldBlockError?.(path ?? id, false)
hideError()
}
// Always validate the value immediately when it is changed
validateValue()
Expand All @@ -399,15 +404,15 @@ export default function useDataValue<
forceUpdate()
},
[
id,
path,
elementPath,
iterateElementIndex,
continuousValidation,
onChange,
validateValue,
dataContextHandlePathChange,
setShowFieldBlockError,
showError,
hideError,
handleIterateElementChange,
fromInput,
forceUpdate,
Expand All @@ -420,6 +425,10 @@ export default function useDataValue<
}
validateValue()

if (showErrorInitially) {
showError()
}

return () => {
// Unmount procedure
if (path) {
Expand Down
8 changes: 8 additions & 0 deletions packages/dnb-eufemia/src/shared/locales/en-GB.js
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,14 @@ export default {
'The value cannot be shorter than {minLength} characters',
stringInputErrorMaxLength:
'The value cannot be longer than {maxLength} characters',
numberFieldErrorMinimum: 'The value must be at lest {minimum}',
numberFieldErrorMaximum: 'The value must be a maximum of {maximum}',
numberFieldErrorExclusiveMinimum:
'The value must be greater than {exclusiveMinimum}',
numberFieldErrorExclusiveMaximum:
'The value must be less than {exclusiveMaximum}',
numberFieldErrorMultipleOf:
'The value must be a multiple of {multipleOf}',
selectionClearSelected: 'Clear the selected value',
countryCodeLabel: 'Country code',
dateLabel: 'Date',
Expand Down
8 changes: 8 additions & 0 deletions packages/dnb-eufemia/src/shared/locales/nb-NO.js
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,14 @@ export default {
'Verdien kan ikke være kortere enn {minLength} tegn',
stringInputErrorMaxLength:
'Verdien kan ikke være lengre enn {maxLength} tegn',
numberFieldErrorMinimum: 'Verdien må være minst {minimum}',
numberFieldErrorMaximum: 'Verdien må være maksimalt {maximum}',
numberFieldErrorExclusiveMinimum:
'Verdien må være større enn {exclusiveMinimum}',
numberFieldErrorExclusiveMaximum:
'Verdien må være mindre enn {exclusiveMaximum}',
numberFieldErrorMultipleOf:
'Verdien må være et multiplum av {multipleOf}',
selectionClearSelected: 'Fjern valgt verdi',
countryCodeLabel: 'Landskode',
dateLabel: 'Dato',
Expand Down

0 comments on commit 9582c64

Please sign in to comment.