Skip to content

Commit 344743b

Browse files
feat: add onSubmit error handling, run onBlur/onChange validation on 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 <git@crutchcorn.dev>
1 parent 20376eb commit 344743b

File tree

3 files changed

+164
-10
lines changed

3 files changed

+164
-10
lines changed

packages/form-core/src/FieldApi.ts

Lines changed: 53 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,13 @@ export interface FieldOptions<
9898
TData
9999
>
100100
onBlurAsyncDebounceMs?: number
101+
onSubmit?: ValidateOrFn<
102+
TParentData,
103+
TName,
104+
ValidatorType,
105+
FormValidator,
106+
TData
107+
>
101108
onSubmitAsync?: AsyncValidateOrFn<
102109
TParentData,
103110
TName,
@@ -330,18 +337,26 @@ export class FieldApi<
330337
}) as any
331338

332339
validateSync = (value = this.state.value, cause: ValidationCause) => {
333-
const { onChange, onBlur } = this.options
334-
const validate =
335-
cause === 'submit' ? undefined : cause === 'change' ? onChange : onBlur
340+
const { onChange, onBlur, onSubmit } = this.options
336341

337-
if (!validate) return
342+
const validates =
343+
// https://github.com/TanStack/form/issues/490
344+
cause === 'submit'
345+
? ([
346+
{ cause: 'change', validate: onChange },
347+
{ cause: 'blur', validate: onBlur },
348+
{ cause: 'submit', validate: onSubmit },
349+
] as const)
350+
: cause === 'change'
351+
? ([{ cause: 'change', validate: onChange }] as const)
352+
: ([{ cause: 'blur', validate: onBlur }] as const)
338353

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

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

365-
const error = normalizeError(doValidate())
366-
const errorMapKey = getErrorMapKey(cause)
367-
if (this.state.meta.errorMap[errorMapKey] !== error) {
380+
// Needs type cast as eslint errantly believes this is always falsy
381+
let hasError = false as boolean
382+
383+
this.form.store.batch(() => {
384+
for (const validateObj of validates) {
385+
if (!validateObj.validate) continue
386+
const error = normalizeError(doValidate(validateObj.validate))
387+
const errorMapKey = getErrorMapKey(validateObj.cause)
388+
if (this.state.meta.errorMap[errorMapKey] !== error) {
389+
this.setMeta((prev) => ({
390+
...prev,
391+
errorMap: {
392+
...prev.errorMap,
393+
[getErrorMapKey(validateObj.cause)]: error,
394+
},
395+
}))
396+
hasError = true
397+
}
398+
}
399+
})
400+
401+
/**
402+
* when we have an error for onSubmit in the state, we want
403+
* to clear the error as soon as the user enters a valid value in the field
404+
*/
405+
const submitErrKey = getErrorMapKey('submit')
406+
if (
407+
this.state.meta.errorMap[submitErrKey] &&
408+
cause !== 'submit' &&
409+
!hasError
410+
) {
368411
this.setMeta((prev) => ({
369412
...prev,
370413
errorMap: {
371414
...prev.errorMap,
372-
[getErrorMapKey(cause)]: error,
415+
[submitErrKey]: undefined,
373416
},
374417
}))
375418
}
376419

377420
// If a sync error is encountered for the errorMapKey (eg. onChange), cancel any async validation
378-
if (this.state.meta.errorMap[errorMapKey]) {
421+
if (hasError) {
379422
this.cancelValidateAsync()
380423
}
381424
}

packages/form-core/src/tests/FieldApi.spec.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -614,4 +614,23 @@ describe('field api', () => {
614614
expect(form.store.state.values.name).toBeUndefined()
615615
expect(form.store.state.fieldMeta.name).toBeUndefined()
616616
})
617+
618+
it('should show onSubmit errors', async () => {
619+
const form = new FormApi({
620+
defaultValues: {
621+
firstName: '',
622+
},
623+
})
624+
625+
const field = new FieldApi({
626+
form,
627+
name: 'firstName',
628+
onSubmit: (v) => (v.length > 0 ? undefined : 'first name is required'),
629+
})
630+
631+
field.mount()
632+
633+
await form.handleSubmit()
634+
expect(field.getMeta().errors).toStrictEqual(['first name is required'])
635+
})
617636
})

packages/form-core/src/tests/FormApi.spec.ts

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -668,4 +668,96 @@ describe('form api', () => {
668668
onMount: 'Please enter a different value',
669669
})
670670
})
671+
672+
it('should validate fields during submit', async () => {
673+
const form = new FormApi({
674+
defaultValues: {
675+
firstName: '',
676+
lastName: '',
677+
},
678+
})
679+
680+
const field = new FieldApi({
681+
form,
682+
name: 'firstName',
683+
onChange: (v) => (v.length > 0 ? undefined : 'first name is required'),
684+
})
685+
686+
const lastNameField = new FieldApi({
687+
form,
688+
name: 'lastName',
689+
onChange: (v) => (v.length > 0 ? undefined : 'last name is required'),
690+
})
691+
692+
field.mount()
693+
lastNameField.mount()
694+
695+
await form.handleSubmit()
696+
expect(form.state.isFieldsValid).toEqual(false)
697+
expect(form.state.canSubmit).toEqual(false)
698+
expect(form.state.fieldMeta['firstName'].errors).toEqual([
699+
'first name is required',
700+
])
701+
expect(form.state.fieldMeta['lastName'].errors).toEqual([
702+
'last name is required',
703+
])
704+
})
705+
706+
it('should run all types of validation on fields during submit', async () => {
707+
const form = new FormApi({
708+
defaultValues: {
709+
firstName: '',
710+
lastName: '',
711+
},
712+
})
713+
714+
const field = new FieldApi({
715+
form,
716+
name: 'firstName',
717+
onChange: (v) => (v.length > 0 ? undefined : 'first name is required'),
718+
onBlur: (v) =>
719+
v.length > 3
720+
? undefined
721+
: 'first name must be longer than 3 characters',
722+
})
723+
724+
field.mount()
725+
726+
await form.handleSubmit()
727+
expect(form.state.isFieldsValid).toEqual(false)
728+
expect(form.state.canSubmit).toEqual(false)
729+
expect(form.state.fieldMeta['firstName'].errors).toEqual([
730+
'first name is required',
731+
'first name must be longer than 3 characters',
732+
])
733+
})
734+
735+
it('should clear onSubmit error when a valid value is entered', async () => {
736+
const form = new FormApi({
737+
defaultValues: {
738+
firstName: '',
739+
},
740+
})
741+
742+
const field = new FieldApi({
743+
form,
744+
name: 'firstName',
745+
onSubmit: (v) => (v.length > 0 ? undefined : 'first name is required'),
746+
})
747+
748+
field.mount()
749+
750+
await form.handleSubmit()
751+
expect(form.state.isFieldsValid).toEqual(false)
752+
expect(form.state.canSubmit).toEqual(false)
753+
expect(form.state.fieldMeta['firstName'].errorMap['onSubmit']).toEqual(
754+
'first name is required',
755+
)
756+
field.handleChange('test')
757+
expect(form.state.isFieldsValid).toEqual(true)
758+
expect(form.state.canSubmit).toEqual(true)
759+
expect(
760+
form.state.fieldMeta['firstName'].errorMap['onSubmit'],
761+
).toBeUndefined()
762+
})
671763
})

0 commit comments

Comments
 (0)