Skip to content

Commit 72c0b37

Browse files
committed
fix: ensure fieldName is passed to custom validation logic functions
This was always part of the types for custom validation functions, it just wasn't wired up. This update is pretty straightforward - it just wires up the field, and adds a test to prove that custom field-level dynamic validation is now possible. The example in the test mostly follows a basic version of what's outlined in #1861. Fixes #1861
1 parent 254f157 commit 72c0b37

File tree

5 files changed

+184
-3
lines changed

5 files changed

+184
-3
lines changed

.changeset/whole-views-wear.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@tanstack/form-core': patch
3+
---
4+
5+
Ensure fieldName is passed to custom validation logic functions

packages/form-core/src/FieldApi.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1676,6 +1676,7 @@ export class FieldApi<
16761676
const validates = getSyncValidatorArray(cause, {
16771677
...this.options,
16781678
form: this.form,
1679+
fieldName: this.name,
16791680
validationLogic:
16801681
this.form.options.validationLogic || defaultValidationLogic,
16811682
})
@@ -1686,6 +1687,7 @@ export class FieldApi<
16861687
const fieldValidates = getSyncValidatorArray(cause, {
16871688
...field.options,
16881689
form: field.form,
1690+
fieldName: field.name,
16891691
validationLogic:
16901692
field.form.options.validationLogic || defaultValidationLogic,
16911693
})
@@ -1812,6 +1814,7 @@ export class FieldApi<
18121814
const validates = getAsyncValidatorArray(cause, {
18131815
...this.options,
18141816
form: this.form,
1817+
fieldName: this.name,
18151818
validationLogic:
18161819
this.form.options.validationLogic || defaultValidationLogic,
18171820
})
@@ -1825,6 +1828,7 @@ export class FieldApi<
18251828
const fieldValidates = getAsyncValidatorArray(cause, {
18261829
...field.options,
18271830
form: field.form,
1831+
fieldName: field.name,
18281832
validationLogic:
18291833
field.form.options.validationLogic || defaultValidationLogic,
18301834
})

packages/form-core/src/ValidationLogic.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import type { AnyFormApi, FormValidators } from './FormApi'
22

3-
interface ValidationLogicValidatorsFn {
3+
export interface ValidationLogicValidatorsFn {
44
// TODO: Type this properly
55
fn: FormValidators<
66
any,

packages/form-core/src/utils.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -251,6 +251,7 @@ export function getSyncValidatorArray<T>(
251251
options: SyncValidatorArrayPartialOptions<T> & {
252252
validationLogic?: any
253253
form?: any
254+
fieldName?: string
254255
},
255256
): T extends FieldValidators<
256257
any,
@@ -300,7 +301,7 @@ export function getSyncValidatorArray<T>(
300301
return options.validationLogic({
301302
form: options.form,
302303
validators: options.validators,
303-
event: { type: cause, async: false },
304+
event: { type: cause, fieldName: options.fieldName, async: false },
304305
runValidation,
305306
})
306307
}
@@ -313,6 +314,7 @@ export function getAsyncValidatorArray<T>(
313314
options: AsyncValidatorArrayPartialOptions<T> & {
314315
validationLogic?: any
315316
form?: any
317+
fieldName?: string
316318
},
317319
): T extends FieldValidators<
318320
any,
@@ -410,7 +412,7 @@ export function getAsyncValidatorArray<T>(
410412
return options.validationLogic({
411413
form: options.form,
412414
validators: options.validators,
413-
event: { type: cause, async: true },
415+
event: { type: cause, fieldName: options.fieldName, async: true },
414416
runValidation,
415417
})
416418
}

packages/form-core/tests/DynamicValidation.spec.ts

Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ import { describe, expect, it, vi } from 'vitest'
22
import { z } from 'zod'
33
import { FieldApi, FormApi } from '../src/index'
44
import { defaultValidationLogic, revalidateLogic } from '../src/ValidationLogic'
5+
import { sleep } from './utils'
6+
import type { ValidationLogicFn, ValidationLogicProps, ValidationLogicValidatorsFn } from '../src/ValidationLogic'
57

68
describe('custom validation', () => {
79
it('should handle default validation logic', async () => {
@@ -233,4 +235,172 @@ describe('custom validation', () => {
233235

234236
expect(field.state.meta.errorMap.onDynamic).toBe(undefined)
235237
})
238+
239+
describe('customised field-level validation logic', () => {
240+
const validationLogic: ValidationLogicFn = (props) => {
241+
const validatorNames = Object.keys(props.validators ?? {})
242+
if (validatorNames.length === 0) {
243+
// No validators is a valid case, just return
244+
return props.runValidation({
245+
validators: [],
246+
form: props.form,
247+
})
248+
}
249+
250+
let validators: ValidationLogicValidatorsFn[] = []
251+
defaultValidationLogic({
252+
...props,
253+
runValidation: (vProps) => {
254+
validators = vProps.validators.slice() as ValidationLogicValidatorsFn[]
255+
}
256+
})
257+
258+
let addDynamicValidator = props.event.type === 'submit'
259+
if (!addDynamicValidator) {
260+
const hasFormSubmitted = props.form.state.submissionAttempts > 0
261+
const modesToWatch: ValidationLogicProps['event']['type'][] = hasFormSubmitted ? ['change'] : (props.event.fieldName ? (
262+
props.form.state.fieldMeta[props.event.fieldName]?.isBlurred ? ['change', 'blur'] : ['blur']
263+
) : ['blur'])
264+
addDynamicValidator = modesToWatch.includes(props.event.type)
265+
}
266+
if (addDynamicValidator) {
267+
validators.push({
268+
fn: props.event.async
269+
? props.validators!['onDynamicAsync']
270+
: props.validators!['onDynamic'],
271+
cause: 'dynamic',
272+
})
273+
}
274+
275+
return props.runValidation({
276+
validators,
277+
form: props.form,
278+
})
279+
}
280+
281+
it('should support sync validation', async () => {
282+
const form = new FormApi({
283+
defaultValues: {
284+
firstName: '',
285+
lastName: '',
286+
},
287+
validationLogic,
288+
})
289+
form.mount()
290+
291+
const fieldFirstName = new FieldApi({
292+
form,
293+
name: 'firstName',
294+
validators: {
295+
onDynamic: ({ value }) => value.length >= 3 ? undefined : 'First name must be at least 3 characters long'
296+
},
297+
})
298+
fieldFirstName.mount()
299+
300+
const fieldLastName = new FieldApi({
301+
form,
302+
name: 'lastName',
303+
validators: {
304+
onDynamic: ({ value }) => value.length >= 3 ? undefined : 'Last name must be at least 3 characters long'
305+
},
306+
})
307+
fieldLastName.mount()
308+
309+
expect(fieldFirstName.state.value).toBe('')
310+
expect(fieldFirstName.state.meta.errorMap.onDynamic).toBe(undefined)
311+
312+
// Should not validate on change initially
313+
fieldFirstName.handleChange('Jo')
314+
expect(fieldFirstName.state.meta.errorMap.onDynamic).toBe(undefined)
315+
316+
// But validation should occur immediately on blur
317+
fieldFirstName.handleBlur()
318+
expect(fieldFirstName.state.meta.errorMap.onDynamic).toBe('First name must be at least 3 characters long')
319+
320+
// And after that point, validation should occur on change
321+
fieldFirstName.handleChange('Matt')
322+
expect(fieldFirstName.state.meta.errorMap.onDynamic).toBe(undefined)
323+
324+
// This field hasn't been touched, so it shouldn't have an error
325+
expect(fieldLastName.state.value).toBe('')
326+
expect(fieldLastName.state.meta.errorMap.onDynamic).toBe(undefined)
327+
328+
// But after form submission, it should immediately have an error
329+
await form.handleSubmit()
330+
expect(fieldLastName.state.meta.errorMap.onDynamic).toBe('Last name must be at least 3 characters long')
331+
332+
// And it should immediately validate on change now
333+
fieldLastName.handleChange('Smith')
334+
expect(fieldLastName.state.meta.errorMap.onDynamic).toBe(undefined)
335+
})
336+
337+
it('should support async validation', async () => {
338+
vi.useFakeTimers()
339+
340+
const form = new FormApi({
341+
defaultValues: {
342+
firstName: '',
343+
lastName: '',
344+
},
345+
validationLogic,
346+
})
347+
form.mount()
348+
349+
const fieldFirstName = new FieldApi({
350+
form,
351+
name: 'firstName',
352+
validators: {
353+
onDynamicAsync: async ({ value }) => {
354+
await sleep(100)
355+
return value.length >= 3 ? undefined : 'First name must be at least 3 characters long'
356+
},
357+
},
358+
})
359+
fieldFirstName.mount()
360+
361+
const fieldLastName = new FieldApi({
362+
form,
363+
name: 'lastName',
364+
validators: {
365+
onDynamicAsync: async ({ value }) => {
366+
await sleep(100)
367+
return value.length >= 3 ? undefined : 'Last name must be at least 3 characters long'
368+
},
369+
},
370+
})
371+
fieldLastName.mount()
372+
373+
expect(fieldFirstName.state.value).toBe('')
374+
expect(fieldFirstName.state.meta.errorMap.onDynamic).toBe(undefined)
375+
376+
// Should not validate on change initially
377+
fieldFirstName.handleChange('Jo')
378+
expect(fieldFirstName.state.meta.errorMap.onDynamic).toBe(undefined)
379+
380+
// But validation should occur immediately on blur
381+
fieldFirstName.handleBlur()
382+
await vi.runAllTimersAsync()
383+
expect(fieldFirstName.state.meta.errorMap.onDynamic).toBe('First name must be at least 3 characters long')
384+
385+
// And after that point, validation should occur on change
386+
fieldFirstName.handleChange('Matt')
387+
await vi.runAllTimersAsync()
388+
expect(fieldFirstName.state.meta.errorMap.onDynamic).toBe(undefined)
389+
390+
// This field hasn't been touched, so it shouldn't have an error
391+
expect(fieldLastName.state.value).toBe('')
392+
expect(fieldLastName.state.meta.errorMap.onDynamic).toBe(undefined)
393+
394+
// But after form submission, it should immediately have an error
395+
const submitPromise = form.handleSubmit()
396+
await vi.runAllTimersAsync()
397+
await submitPromise
398+
expect(fieldLastName.state.meta.errorMap.onDynamic).toBe('Last name must be at least 3 characters long')
399+
400+
// And it should immediately validate on change now
401+
fieldLastName.handleChange('Smith')
402+
await vi.runAllTimersAsync()
403+
expect(fieldLastName.state.meta.errorMap.onDynamic).toBe(undefined)
404+
})
405+
});
236406
})

0 commit comments

Comments
 (0)