Skip to content

Commit

Permalink
feat: add onSubmit error handling, run onBlur/onChange validation on …
Browse files Browse the repository at this point in the history
…form submit

* fix(form-core): run onChange/onBlur field validation on form submit

* clear the onsubmit error when user enters a valid value

* remove space

* fix formatting issue

* chore: allow for both onChange and onBlur to run on submit

* feat: add onSubmit error validation to field

* test: add test for onSubmit validation errors on field

* chore: fix formatting

---------

Co-authored-by: Corbin Crutchley <[email protected]>
  • Loading branch information
vikaskumar89 and crutchcorn authored Dec 3, 2023
1 parent 20376eb commit 344743b
Show file tree
Hide file tree
Showing 3 changed files with 164 additions and 10 deletions.
63 changes: 53 additions & 10 deletions packages/form-core/src/FieldApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,13 @@ export interface FieldOptions<
TData
>
onBlurAsyncDebounceMs?: number
onSubmit?: ValidateOrFn<
TParentData,
TName,
ValidatorType,
FormValidator,
TData
>
onSubmitAsync?: AsyncValidateOrFn<
TParentData,
TName,
Expand Down Expand Up @@ -330,18 +337,26 @@ export class FieldApi<
}) as any

validateSync = (value = this.state.value, cause: ValidationCause) => {
const { onChange, onBlur } = this.options
const validate =
cause === 'submit' ? undefined : cause === 'change' ? onChange : onBlur
const { onChange, onBlur, onSubmit } = this.options

if (!validate) return
const validates =
// https://github.com/TanStack/form/issues/490
cause === 'submit'
? ([
{ cause: 'change', validate: onChange },
{ cause: 'blur', validate: onBlur },
{ cause: 'submit', validate: onSubmit },
] as const)
: cause === 'change'
? ([{ cause: 'change', validate: onChange }] as const)
: ([{ cause: 'blur', validate: onBlur }] as const)

// Use the validationCount for all field instances to
// track freshness of the validation
const validationCount = (this.getInfo().validationCount || 0) + 1
this.getInfo().validationCount = validationCount

const doValidate = () => {
const doValidate = (validate: (typeof validates)[number]['validate']) => {
if (this.options.validator && typeof validate !== 'function') {
return (this.options.validator as Validator<TData>)().validate(
value,
Expand All @@ -362,20 +377,48 @@ export class FieldApi<
)
}

const error = normalizeError(doValidate())
const errorMapKey = getErrorMapKey(cause)
if (this.state.meta.errorMap[errorMapKey] !== error) {
// Needs type cast as eslint errantly believes this is always falsy
let hasError = false as boolean

this.form.store.batch(() => {
for (const validateObj of validates) {
if (!validateObj.validate) continue
const error = normalizeError(doValidate(validateObj.validate))
const errorMapKey = getErrorMapKey(validateObj.cause)
if (this.state.meta.errorMap[errorMapKey] !== error) {
this.setMeta((prev) => ({
...prev,
errorMap: {
...prev.errorMap,
[getErrorMapKey(validateObj.cause)]: error,
},
}))
hasError = true
}
}
})

/**
* when we have an error for onSubmit in the state, we want
* to clear the error as soon as the user enters a valid value in the field
*/
const submitErrKey = getErrorMapKey('submit')
if (
this.state.meta.errorMap[submitErrKey] &&
cause !== 'submit' &&
!hasError
) {
this.setMeta((prev) => ({
...prev,
errorMap: {
...prev.errorMap,
[getErrorMapKey(cause)]: error,
[submitErrKey]: undefined,
},
}))
}

// If a sync error is encountered for the errorMapKey (eg. onChange), cancel any async validation
if (this.state.meta.errorMap[errorMapKey]) {
if (hasError) {
this.cancelValidateAsync()
}
}
Expand Down
19 changes: 19 additions & 0 deletions packages/form-core/src/tests/FieldApi.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -614,4 +614,23 @@ describe('field api', () => {
expect(form.store.state.values.name).toBeUndefined()
expect(form.store.state.fieldMeta.name).toBeUndefined()
})

it('should show onSubmit errors', async () => {
const form = new FormApi({
defaultValues: {
firstName: '',
},
})

const field = new FieldApi({
form,
name: 'firstName',
onSubmit: (v) => (v.length > 0 ? undefined : 'first name is required'),
})

field.mount()

await form.handleSubmit()
expect(field.getMeta().errors).toStrictEqual(['first name is required'])
})
})
92 changes: 92 additions & 0 deletions packages/form-core/src/tests/FormApi.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -668,4 +668,96 @@ describe('form api', () => {
onMount: 'Please enter a different value',
})
})

it('should validate fields during submit', async () => {
const form = new FormApi({
defaultValues: {
firstName: '',
lastName: '',
},
})

const field = new FieldApi({
form,
name: 'firstName',
onChange: (v) => (v.length > 0 ? undefined : 'first name is required'),
})

const lastNameField = new FieldApi({
form,
name: 'lastName',
onChange: (v) => (v.length > 0 ? undefined : 'last name is required'),
})

field.mount()
lastNameField.mount()

await form.handleSubmit()
expect(form.state.isFieldsValid).toEqual(false)
expect(form.state.canSubmit).toEqual(false)
expect(form.state.fieldMeta['firstName'].errors).toEqual([
'first name is required',
])
expect(form.state.fieldMeta['lastName'].errors).toEqual([
'last name is required',
])
})

it('should run all types of validation on fields during submit', async () => {
const form = new FormApi({
defaultValues: {
firstName: '',
lastName: '',
},
})

const field = new FieldApi({
form,
name: 'firstName',
onChange: (v) => (v.length > 0 ? undefined : 'first name is required'),
onBlur: (v) =>
v.length > 3
? undefined
: 'first name must be longer than 3 characters',
})

field.mount()

await form.handleSubmit()
expect(form.state.isFieldsValid).toEqual(false)
expect(form.state.canSubmit).toEqual(false)
expect(form.state.fieldMeta['firstName'].errors).toEqual([
'first name is required',
'first name must be longer than 3 characters',
])
})

it('should clear onSubmit error when a valid value is entered', async () => {
const form = new FormApi({
defaultValues: {
firstName: '',
},
})

const field = new FieldApi({
form,
name: 'firstName',
onSubmit: (v) => (v.length > 0 ? undefined : 'first name is required'),
})

field.mount()

await form.handleSubmit()
expect(form.state.isFieldsValid).toEqual(false)
expect(form.state.canSubmit).toEqual(false)
expect(form.state.fieldMeta['firstName'].errorMap['onSubmit']).toEqual(
'first name is required',
)
field.handleChange('test')
expect(form.state.isFieldsValid).toEqual(true)
expect(form.state.canSubmit).toEqual(true)
expect(
form.state.fieldMeta['firstName'].errorMap['onSubmit'],
).toBeUndefined()
})
})

0 comments on commit 344743b

Please sign in to comment.