Skip to content

Commit

Permalink
feat(Forms): add support for arrays with errors for `onChangeValidato…
Browse files Browse the repository at this point in the history
…r` and `onBlurValidator` (#4511)

To be used in #4469 and requested
[here](https://dnb-it.slack.com/archives/C04NL73S2Q5/p1737652409110949).

---------

Co-authored-by: Anders <anderslangseth@gmail.com>
  • Loading branch information
tujoworker and langz authored Jan 29, 2025
1 parent 3801d1e commit 2fe99aa
Show file tree
Hide file tree
Showing 4 changed files with 226 additions and 91 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import {
runAnimation,
simulateAnimationEnd,
} from '../../../../components/height-animation/__tests__/HeightAnimationUtils'
import { Field, Form } from '../..'
import { Field, Form, Validator } from '../..'
import nbNO from '../../constants/locales/nb-NO'
import enGB from '../../constants/locales/en-GB'

Expand Down Expand Up @@ -775,6 +775,191 @@ describe('FieldBlock', () => {
})
})

describe('summarize errors', () => {
it('should summarize errors in one FormStatus component', () => {
const MockComponent = () => {
useFieldProps({
required: true,
validateInitially: true,
})

return null
}

render(
<FieldBlock error={new Error('Error message')}>
<MockComponent />
</FieldBlock>
)

expect(document.querySelectorAll('.dnb-form-status')).toHaveLength(
1
)
expect(
document.querySelector('.dnb-form-status').textContent
).toBe(
nb.Field.errorSummary + 'Error message' + nb.Field.errorRequired
)
})

it('should summarize errors for nested FieldBlocks', () => {
const nested = new Error('Nested')
const outer = new Error('Outer')

const MockComponent = () => {
useFieldProps({
id: 'unique',
error: nested,
})

return <FieldBlock id="unique">content</FieldBlock>
}

render(
<FieldBlock error={outer}>
<MockComponent />
</FieldBlock>
)

expect(document.querySelectorAll('.dnb-form-status')).toHaveLength(
1
)
expect(
document.querySelector('.dnb-form-status').textContent
).toBe(nb.Field.errorSummary + 'Outer' + 'Nested')
})

it('should not summarize errors when "disableStatusSummary" is true', () => {
const nested = new Error('Nested')
const outer = new Error('Outer')

const MockComponent = () => {
useFieldProps({
id: 'unique',
error: nested,
})

return <FieldBlock id="unique">content</FieldBlock>
}

render(
<FieldBlock error={outer} disableStatusSummary>
<MockComponent />
</FieldBlock>
)

expect(document.querySelectorAll('.dnb-form-status')).toHaveLength(
2
)
expect(
document.querySelectorAll('.dnb-form-status')[0].textContent
).toBe('Outer')
expect(
document.querySelectorAll('.dnb-form-status')[1].textContent
).toBe('Nested')
})

it('should summarize errors when returned in onChangeValidator', () => {
const onChangeValidator: Validator<string> = jest.fn(() => {
return [
new Error('Error message one'),
new Error('Error message two'),
]
})

render(
<Field.String
value="abc"
onChangeValidator={onChangeValidator}
validateInitially
/>
)

expect(
document.querySelector('.dnb-form-status').textContent
).toBe(
nb.Field.errorSummary + 'Error message one' + 'Error message two'
)
})

it('should summarize errors when returned in onBlurValidator', () => {
const onBlurValidator: Validator<string> = jest.fn(() => {
return [
new Error('Error message one'),
new Error('Error message two'),
]
})

render(
<Field.String
value="abc"
onBlurValidator={onBlurValidator}
validateInitially
/>
)

expect(
document.querySelector('.dnb-form-status').textContent
).toBe(
nb.Field.errorSummary + 'Error message one' + 'Error message two'
)
})

it('should summarize errors when returned in async onChangeValidator', async () => {
const onChangeValidator: Validator<string> = jest.fn(async () => {
return [
new Error('Error message one'),
new Error('Error message two'),
]
})

render(
<Field.String
value="abc"
onChangeValidator={onChangeValidator}
validateInitially
/>
)

await waitFor(() => {
expect(
document.querySelector('.dnb-form-status').textContent
).toBe(
nb.Field.errorSummary +
'Error message one' +
'Error message two'
)
})
})

it('should summarize errors when returned in async onBlurValidator', async () => {
const onBlurValidator: Validator<string> = jest.fn(async () => {
return [
new Error('Error message one'),
new Error('Error message two'),
]
})

render(
<Field.String
value="abc"
onBlurValidator={onBlurValidator}
validateInitially
/>
)

await waitFor(() => {
expect(
document.querySelector('.dnb-form-status').textContent
).toBe(
nb.Field.errorSummary +
'Error message one' +
'Error message two'
)
})
})
})

describe('FormStatus with animation', () => {
initializeTestSetup()

Expand Down Expand Up @@ -883,81 +1068,6 @@ describe('FieldBlock', () => {
})
})

it('should summarize errors in one FormStatus components', () => {
const MockComponent = () => {
useFieldProps({
required: true,
validateInitially: true,
})

return null
}

render(
<FieldBlock error={new Error('Error message')}>
<MockComponent />
</FieldBlock>
)

expect(document.querySelectorAll('.dnb-form-status')).toHaveLength(1)
expect(document.querySelector('.dnb-form-status').textContent).toBe(
nb.Field.errorSummary + 'Error message' + nb.Field.errorRequired
)
})

it('should summarize errors for nested FieldBlocks', () => {
const nested = new Error('Nested')
const outer = new Error('Outer')

const MockComponent = () => {
useFieldProps({
id: 'unique',
error: nested,
})

return <FieldBlock id="unique">content</FieldBlock>
}

render(
<FieldBlock error={outer}>
<MockComponent />
</FieldBlock>
)

expect(document.querySelectorAll('.dnb-form-status')).toHaveLength(1)
expect(document.querySelector('.dnb-form-status').textContent).toBe(
nb.Field.errorSummary + 'Outer' + 'Nested'
)
})

it('should not summarize errors when "disableStatusSummary" is true', () => {
const nested = new Error('Nested')
const outer = new Error('Outer')

const MockComponent = () => {
useFieldProps({
id: 'unique',
error: nested,
})

return <FieldBlock id="unique">content</FieldBlock>
}

render(
<FieldBlock error={outer} disableStatusSummary>
<MockComponent />
</FieldBlock>
)

expect(document.querySelectorAll('.dnb-form-status')).toHaveLength(2)
expect(
document.querySelectorAll('.dnb-form-status')[0].textContent
).toBe('Outer')
expect(
document.querySelectorAll('.dnb-form-status')[1].textContent
).toBe('Nested')
})

describe('fieldState', () => {
it('should show indicator when fieldState is set to pending', async () => {
render(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -77,12 +77,12 @@ export const DataValueWritePropsProperties: PropertiesTableProps = {
status: 'optional',
},
onChangeValidator: {
doc: 'Custom validator function that is triggered on every change done by the user. The function can be either asynchronous or synchronous. The first parameter is the value, and the second parameter returns an object containing { errorMessages, connectWithPath, validators }.',
doc: 'Custom validator function where you can return `undefined`, `Error`, `FormError` or an Array with either several other validators or several `Error` or `FormError`. It is triggered on every change done by the user. The function can be either asynchronous or synchronous. The first parameter is the value, and the second parameter returns an object containing { errorMessages, connectWithPath, validators }.',
type: 'function',
status: 'optional',
},
onBlurValidator: {
doc: 'Custom validator function that is triggered when the user leaves a field (e.g., blurring a text input or closing a dropdown). The function can be either asynchronous or synchronous. The first parameter is the value, and the second parameter returns an object containing { errorMessages, connectWithPath, validators }.',
doc: 'Custom validator function where you can return `undefined`, `Error`, `FormError` or an Array with either several other validators or several `Error` or `FormError`. It is triggered when the user leaves a field (e.g., blurring a text input or closing a dropdown). The function can be either asynchronous or synchronous. The first parameter is the value, and the second parameter returns an object containing { errorMessages, connectWithPath, validators }.',
type: 'function',
status: 'optional',
},
Expand Down
49 changes: 36 additions & 13 deletions packages/dnb-eufemia/src/extensions/forms/hooks/useFieldProps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -789,16 +789,29 @@ export default function useFieldProps<Value, EmptyValue, Props>(
const result = await validator(value, additionalArgs)

if (Array.isArray(result)) {
for (const validator of result) {
if (!hasBeenCalledRef(validator)) {
const result = await callValidatorFnAsync(validator, value)
const errors = []

for (const validatorOrError of result) {
if (validatorOrError instanceof Error) {
errors.push(validatorOrError)
} else if (!hasBeenCalledRef(validatorOrError)) {
const result = await callValidatorFnAsync(
validatorOrError,
value
)
if (result instanceof Error) {
callStackRef.current = []
return result
}
}
}

if (errors.length > 0) {
return new FormError('Error', {
errors,
})
}

callStackRef.current = []
} else {
return result
Expand Down Expand Up @@ -830,16 +843,26 @@ export default function useFieldProps<Value, EmptyValue, Props>(
})
}

for (const validator of result) {
if (!hasBeenCalledRef(validator)) {
const result = callValidatorFnSync(validator, value)
const errors = []

for (const validatorOrError of result) {
if (validatorOrError instanceof Error) {
errors.push(validatorOrError)
} else if (!hasBeenCalledRef(validatorOrError)) {
const result = callValidatorFnSync(validatorOrError, value)
if (result instanceof Error) {
callStackRef.current = []
return result
}
}
}

if (errors.length > 0) {
return new FormError('Error', {
errors,
})
}

callStackRef.current = []
} else {
return result
Expand All @@ -856,7 +879,11 @@ export default function useFieldProps<Value, EmptyValue, Props>(
(
method: PersistErrorStateMethod,
initiator: ErrorInitiator,
errorArg: Error | FormError | undefined = undefined
errorArg:
| Error
| FormError
| Array<Error | FormError>
| undefined = undefined
) => {
const error = prepareError(errorArg)

Expand Down Expand Up @@ -945,11 +972,7 @@ export default function useFieldProps<Value, EmptyValue, Props>(

// Don't show the error if the value has changed in the meantime
if (unchangedValue) {
persistErrorState(
'gracefully',
'onChangeValidator',
result as Error
)
persistErrorState('gracefully', 'onChangeValidator', result)

if (
(validateInitially && !changedRef.current) ||
Expand Down Expand Up @@ -1096,7 +1119,7 @@ export default function useFieldProps<Value, EmptyValue, Props>(

const revealOnBlurValidatorResult = useCallback(
({ result }) => {
persistErrorState('gracefully', 'onBlurValidator', result as Error)
persistErrorState('gracefully', 'onBlurValidator', result)

if (isAsync(onBlurValidatorRef.current)) {
defineAsyncProcess(undefined)
Expand Down
Loading

0 comments on commit 2fe99aa

Please sign in to comment.