Skip to content

Commit

Permalink
feat(Forms): provide connectWithPath in the validator and onBlurVal…
Browse files Browse the repository at this point in the history
…idator to get values from other fields/paths (#3895)
  • Loading branch information
tujoworker authored Sep 6, 2024
1 parent eb32f99 commit f4cf06f
Show file tree
Hide file tree
Showing 19 changed files with 1,277 additions and 170 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,20 @@ const validator = (value) => {
render(<Field.PhoneNumber validator={validator} />)
```

## Reuse existing error messages in a validator function

You can reuse existing error messages in a validator function. The types of error messages available depend on the field type.

For example, you can reuse the `required` error message in a validator function:

```tsx
const validator = (value, { errorMessages }) => {
// Your validation logic
return new Error(errorMessages.required)
}
render(<Field.String validator={validator} />)
```

### FormError object

You can use the JavaScript `Error` object to display a custom error message:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,7 @@ export const ValidationFunction = () => {
const fnr = (value: string) =>
value.length >= 11 ? { status: 'valid' } : { status: 'invalid' }

const validator = (value, errorMessages) => {
const validator = (value, { errorMessages }) => {
const result = fnr(value)
return result.status === 'invalid'
? new Error(errorMessages.pattern)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -350,30 +350,66 @@ const schema = {
<Field.PhoneNumber schema={schema} />
```

#### validator
#### onBlurValidator and validator

The `validator` (including `onBlurValidator`) property is a function that takes the current value of the field as an argument and returns an error message if the value is invalid:
The `onBlurValidator` and `validator` properties accepts a function that takes the current value of the field as an argument and returns an error message if the value is invalid:

```tsx
const validator = (value) => {
const onChangeValidator = (value) => {
const isInvalid = new RegExp('Your RegExp').test(value)
if (isInvalid) {
return new Error('Invalid value message')
}
}
render(<Field.PhoneNumber validator={validator} />)
render(<Field.PhoneNumber validator={onChangeValidator} />)
```

You can find more info about error messages in the [Error messages](/uilib/extensions/forms/Form/error-messages/) docs.

##### Connect with another field

You can also use the `connectWithPath` function to connect the validator to another field. This allows you to rerun the validator function once the value of the connected field changes:

```tsx
import { Form, Field } from '@dnb/eufemia/extensions/forms'

const onChangeValidator = (value, { connectWithPath }) => {
const { getValue } = connectWithPath('/myReference')
const amount = getValue()
if (amount >= value) {
return new Error(`The amount should be greater than ${amount}`)
}
}

render(
<Form.Handler>
<Field.Number path="/myReference" defaultValue={2} />

<Field.Number
path="/withValidator"
defaultValue={2}
validator={onChangeValidator} // NB: You may use "onBlurValidator" depending on use case.
/>
</Form.Handler>,
)
```

By default, the validator function will only run when the "/withValidator" field is changed. When the error message is shown, it will update the message with the new value of the "/myReference" field.

You can also change this behavior by using the following props:

- `validateInitially` will run the validation initially.
- `continuousValidation` will run the validation on every change, including when the connected field changes.
- `validateUnchanged` will validate without any changes made by the user, including when the connected field changes.

##### Async validation

Async validation is also supported. The validator function can return a promise (async/await) that resolves to an error message.

In this example we use `onBlurValidator` to only validate the field when the user leaves the field:

```tsx
const validator = async (value) => {
const onChangeValidator = async (value) => {
try {
const isInvalid = await makeRequest(value)
if (isInvalid) {
Expand All @@ -383,7 +419,7 @@ const validator = async (value) => {
return error
}
}
render(<Field.PhoneNumber onBlurValidator={validator} />)
render(<Field.PhoneNumber onBlurValidator={onChangeValidator} />)
```

##### Async validator with debounce
Expand All @@ -393,7 +429,7 @@ While when using async validation on every keystroke, it's a good idea to deboun
```tsx
import { debounceAsync } from '@dnb/eufemia/shared/helpers'

const validator = debounceAsync(async function myValidator(value) {
const onChangeValidator = debounceAsync(async function myValidator(value) {
try {
const isInvalid = await makeRequest(value)
if (isInvalid) {
Expand All @@ -403,7 +439,7 @@ const validator = debounceAsync(async function myValidator(value) {
return error
}
})
render(<Field.PhoneNumber validator={validator} />)
render(<Field.PhoneNumber validator={onChangeValidator} />)
```

### Localization and translation
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,8 @@ type HandleSubmitProps = {

export type EventListenerCall = {
path?: Path
type?: 'onSubmit'
callback: () => void
type?: 'onSubmit' | 'onPathChange'
callback: (params?: { value: unknown }) => void | Promise<void | Error>
}

export type FilterDataHandler<Data> = (
Expand Down Expand Up @@ -66,6 +66,7 @@ export interface ContextState {
hasContext: boolean
/** The dataset for the form / form wizard */
data: any
internalDataRef?: React.MutableRefObject<any>
/** Should the form validate data before submitting? */
errors?: Record<string, Error>
/** Will set autoComplete="on" on each nested Field.String and Field.Number */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -749,6 +749,17 @@ export default function Provider<Data extends JsonObject>(
} else {
onPathChange?.(path, value)
}

for (const itm of fieldEventListenersRef.current) {
if (itm.type === 'onPathChange' && itm.path === path) {
const { callback } = itm
if (isAsync(callback)) {
await callback({ value })
} else {
callback({ value })
}
}
}
},
[onPathChange, updateDataValue]
)
Expand Down Expand Up @@ -870,11 +881,8 @@ export default function Provider<Data extends JsonObject>(

// Just call the submit listeners "once", and not on the retry/recall
if (!skipFieldValidation) {
for (const {
path,
type,
callback,
} of fieldEventListenersRef.current) {
for (const item of fieldEventListenersRef.current) {
const { path, type, callback } = item
if (
type === 'onSubmit' &&
mountedFieldPathsRef.current.includes(path)
Expand Down Expand Up @@ -1073,9 +1081,11 @@ export default function Provider<Data extends JsonObject>(
callback: EventListenerCall['callback']
) => {
fieldEventListenersRef.current =
fieldEventListenersRef.current.filter(({ path: p, type: t }) => {
return !(p === path && t === type)
})
fieldEventListenersRef.current.filter(
({ path: p, type: t, callback: c }) => {
return !(p === path && t === type && c === callback)
}
)
fieldEventListenersRef.current.push({ path, type, callback })
},
[]
Expand Down Expand Up @@ -1182,6 +1192,7 @@ export default function Provider<Data extends JsonObject>(
/** Additional */
id,
data: internalDataRef.current,
internalDataRef,
props,
...rest,
}}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,15 @@ export type Props = StringFieldProps & {
}

function NationalIdentityNumber(props: Props) {
const translations = useTranslation().NationalIdentityNumber
const errorMessage = translations.errorRequired

const { validate = true, omitMask } = props

const translations = useTranslation().NationalIdentityNumber
const { label, errorRequired, errorFnr, errorDnr } = translations
const errorMessages = useErrorMessage(props.path, props.errorMessages, {
required: errorMessage,
pattern: errorMessage,
required: errorRequired,
pattern: errorRequired,
errorFnr,
errorDnr,
})

const mask = useMemo(
Expand Down Expand Up @@ -49,11 +50,11 @@ function NationalIdentityNumber(props: Props) {
new RegExp(validationPattern).test(value) &&
fnr(value).status === 'invalid'
) {
return Error(translations.errorFnr)
return Error(errorFnr)
}
return undefined
},
[translations.errorFnr]
[errorFnr]
)

const dnrValidator = useCallback(
Expand All @@ -63,11 +64,11 @@ function NationalIdentityNumber(props: Props) {
new RegExp(validationPattern).test(value) &&
dnr(value).status === 'invalid'
) {
return Error(translations.errorDnr)
return Error(errorDnr)
}
return undefined
},
[translations.errorDnr]
[errorDnr]
)

const dnrAndFnrValidator = useCallback(
Expand All @@ -85,7 +86,7 @@ function NationalIdentityNumber(props: Props) {
: validate && !props.validator
? validationPattern
: undefined,
label: props.label ?? translations.label,
label: props.label ?? label,
errorMessages,
mask,
width: props.width ?? 'medium',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -105,12 +105,19 @@ describe('Field.NationalIdentityNumber', () => {
)

expect(validator).toHaveBeenCalledTimes(1)
expect(validator).toHaveBeenCalledWith('123', {
maxLength: expect.stringContaining('{maxLength}'),
minLength: expect.stringContaining('{minLength}'),
pattern: expect.stringContaining('11'),
required: expect.stringContaining('11'),
})
expect(validator).toHaveBeenCalledWith(
'123',
expect.objectContaining({
errorMessages: expect.objectContaining({
maxLength: expect.stringContaining('{maxLength}'),
minLength: expect.stringContaining('{minLength}'),
pattern: expect.stringContaining('11'),
required: expect.stringContaining('11'),
errorDnr: expect.stringContaining('d-nummer'),
errorFnr: expect.stringContaining('fødselsnummer'),
}),
})
)
})

it('should have numeric input mode', () => {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
import React from 'react'
import { axeComponent, wait } from '../../../../../core/jest/jestSetup'
import { screen, render, fireEvent, act } from '@testing-library/react'
import {
screen,
render,
fireEvent,
act,
waitFor,
} from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { Field, FieldBlock, Form, JSONSchema } from '../../..'
import { Provider } from '../../../../../shared'
Expand Down Expand Up @@ -467,8 +473,7 @@ describe('Field.Number', () => {
</Form.Handler>
)

const form = document.querySelector('form')
fireEvent.submit(form)
fireEvent.submit(document.querySelector('form'))

expect(onSubmit).toHaveBeenCalledTimes(1)
expect(onSubmit).toHaveBeenCalledWith(
Expand Down Expand Up @@ -519,6 +524,52 @@ describe('Field.Number', () => {
expect(screen.queryByRole('alert')).toBeInTheDocument()
})

it('should call validator with validateInitially', async () => {
const validator = jest.fn(() => {
return new Error('Validator message')
})

render(
<Field.Number
validator={validator}
defaultValue={123}
validateInitially
/>
)

expect(validator).toHaveBeenCalledTimes(1)
expect(validator).toHaveBeenCalledWith(123, expect.anything())

await waitFor(() => {
expect(screen.queryByRole('alert')).toBeInTheDocument()
})
})

it('should call validator on form submit', async () => {
const validator = jest.fn(() => {
return new Error('Validator message')
})

render(
<Form.Handler>
<Field.Number
path="/myNumber"
validator={validator}
defaultValue={123}
/>
</Form.Handler>
)

fireEvent.submit(document.querySelector('form'))

expect(validator).toHaveBeenCalledTimes(1)
expect(validator).toHaveBeenCalledWith(123, expect.anything())

await waitFor(() => {
expect(screen.queryByRole('alert')).toBeInTheDocument()
})
})

describe('validation based on required-prop', () => {
it('should show error for empty value', async () => {
render(<Field.Number value={1} required />)
Expand Down
Loading

0 comments on commit f4cf06f

Please sign in to comment.