Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix(forms): Field block error handling #2900

Merged
merged 3 commits into from
Nov 15, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 @@ -141,14 +141,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 @@ -166,6 +167,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 @@ -306,10 +317,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 @@ -338,20 +348,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 @@ -378,12 +385,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 @@ -401,15 +406,15 @@ export default function useDataValue<
forceUpdate()
},
[
id,
path,
elementPath,
iterateElementIndex,
continuousValidation,
onChange,
validateValue,
dataContextHandlePathChange,
setShowFieldBlockError,
showError,
hideError,
handleIterateElementChange,
fromInput,
forceUpdate,
Expand All @@ -422,6 +427,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 @@ -164,6 +164,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 @@ -162,6 +162,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
Loading