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

feat(Forms): add support for arrays with errors for onChangeValidator and onBlurValidator #4511

Merged
merged 2 commits into from
Jan 29, 2025
Merged
Show file tree
Hide file tree
Changes from 1 commit
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 @@ -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 components', () => {
langz marked this conversation as resolved.
Show resolved Hide resolved
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
Loading