@tanstack/form-core 0.7.1 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/build/legacy/FieldApi.cjs +5 -1
- package/build/legacy/FieldApi.cjs.map +1 -1
- package/build/legacy/FieldApi.d.cts +1 -1
- package/build/legacy/FieldApi.d.ts +1 -1
- package/build/legacy/FieldApi.js +5 -1
- package/build/legacy/FieldApi.js.map +1 -1
- package/build/legacy/FormApi.cjs +176 -11
- package/build/legacy/FormApi.cjs.map +1 -1
- package/build/legacy/FormApi.js +176 -11
- package/build/legacy/FormApi.js.map +1 -1
- package/build/legacy/index.d.cts +11 -4
- package/build/legacy/index.d.ts +11 -4
- package/build/modern/FieldApi.cjs +5 -1
- package/build/modern/FieldApi.cjs.map +1 -1
- package/build/modern/FieldApi.d.cts +1 -1
- package/build/modern/FieldApi.d.ts +1 -1
- package/build/modern/FieldApi.js +5 -1
- package/build/modern/FieldApi.js.map +1 -1
- package/build/modern/FormApi.cjs +175 -11
- package/build/modern/FormApi.cjs.map +1 -1
- package/build/modern/FormApi.js +175 -11
- package/build/modern/FormApi.js.map +1 -1
- package/build/modern/index.d.cts +11 -4
- package/build/modern/index.d.ts +11 -4
- package/package.json +1 -1
- package/src/FieldApi.ts +8 -4
- package/src/FormApi.ts +229 -14
- package/src/tests/FieldApi.spec.ts +7 -1
- package/src/tests/FormApi.spec.ts +352 -0
package/src/FormApi.ts
CHANGED
|
@@ -21,6 +21,7 @@ type ValidateAsyncFn<TData, ValidatorType> = (
|
|
|
21
21
|
export type FormOptions<TData, ValidatorType> = {
|
|
22
22
|
defaultValues?: TData
|
|
23
23
|
defaultState?: Partial<FormState<TData>>
|
|
24
|
+
asyncAlways?: boolean
|
|
24
25
|
asyncDebounceMs?: number
|
|
25
26
|
validator?: ValidatorType
|
|
26
27
|
onMount?: ValidateOrFn<TData, ValidatorType>
|
|
@@ -47,8 +48,8 @@ export type FieldInfo<TFormData, ValidatorType> = {
|
|
|
47
48
|
export type ValidationMeta = {
|
|
48
49
|
validationCount?: number
|
|
49
50
|
validationAsyncCount?: number
|
|
50
|
-
validationPromise?: Promise<ValidationError[]>
|
|
51
|
-
validationResolve?: (errors: ValidationError[]) => void
|
|
51
|
+
validationPromise?: Promise<ValidationError[] | undefined>
|
|
52
|
+
validationResolve?: (errors: ValidationError[] | undefined) => void
|
|
52
53
|
validationReject?: (errors: unknown) => void
|
|
53
54
|
}
|
|
54
55
|
|
|
@@ -64,7 +65,8 @@ export type FormState<TData> = {
|
|
|
64
65
|
isFormValidating: boolean
|
|
65
66
|
formValidationCount: number
|
|
66
67
|
isFormValid: boolean
|
|
67
|
-
|
|
68
|
+
errors: ValidationError[]
|
|
69
|
+
errorMap: ValidationErrorMap
|
|
68
70
|
// Fields
|
|
69
71
|
fieldMeta: Record<DeepKeys<TData>, FieldMeta>
|
|
70
72
|
isFieldsValidating: boolean
|
|
@@ -84,6 +86,8 @@ function getDefaultFormState<TData>(
|
|
|
84
86
|
): FormState<TData> {
|
|
85
87
|
return {
|
|
86
88
|
values: defaultState.values ?? ({} as never),
|
|
89
|
+
errors: defaultState.errors ?? [],
|
|
90
|
+
errorMap: defaultState.errorMap ?? {},
|
|
87
91
|
fieldMeta: defaultState.fieldMeta ?? ({} as never),
|
|
88
92
|
canSubmit: defaultState.canSubmit ?? true,
|
|
89
93
|
isFieldsValid: defaultState.isFieldsValid ?? false,
|
|
@@ -141,7 +145,10 @@ export class FormApi<TFormData, ValidatorType> {
|
|
|
141
145
|
const isTouched = fieldMetaValues.some((field) => field?.isTouched)
|
|
142
146
|
|
|
143
147
|
const isValidating = isFieldsValidating || state.isFormValidating
|
|
144
|
-
|
|
148
|
+
state.errors = Object.values(state.errorMap).filter(
|
|
149
|
+
(val: unknown) => val !== undefined,
|
|
150
|
+
)
|
|
151
|
+
const isFormValid = state.errors.length === 0
|
|
145
152
|
const isValid = isFieldsValid && isFormValid
|
|
146
153
|
const canSubmit =
|
|
147
154
|
(state.submissionAttempts === 0 && !isTouched) ||
|
|
@@ -169,14 +176,23 @@ export class FormApi<TFormData, ValidatorType> {
|
|
|
169
176
|
}
|
|
170
177
|
|
|
171
178
|
mount = () => {
|
|
172
|
-
|
|
173
|
-
|
|
179
|
+
const doValidate = () => {
|
|
180
|
+
if (typeof this.options.onMount === 'function') {
|
|
181
|
+
return this.options.onMount(this.state.values, this)
|
|
182
|
+
}
|
|
183
|
+
if (this.options.validator) {
|
|
184
|
+
return (this.options.validator as Validator<TFormData>)().validate(
|
|
185
|
+
this.state.values,
|
|
186
|
+
this.options.onMount,
|
|
187
|
+
)
|
|
188
|
+
}
|
|
174
189
|
}
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
190
|
+
const error = doValidate()
|
|
191
|
+
if (error) {
|
|
192
|
+
this.store.setState((prev) => ({
|
|
193
|
+
...prev,
|
|
194
|
+
errorMap: { ...prev.errorMap, onMount: error },
|
|
195
|
+
}))
|
|
180
196
|
}
|
|
181
197
|
}
|
|
182
198
|
|
|
@@ -245,6 +261,177 @@ export class FormApi<TFormData, ValidatorType> {
|
|
|
245
261
|
return Promise.all(fieldValidationPromises)
|
|
246
262
|
}
|
|
247
263
|
|
|
264
|
+
validateSync = (cause: ValidationCause): void => {
|
|
265
|
+
const { onChange, onBlur } = this.options
|
|
266
|
+
const validate =
|
|
267
|
+
cause === 'change' ? onChange : cause === 'blur' ? onBlur : undefined
|
|
268
|
+
if (!validate) return
|
|
269
|
+
|
|
270
|
+
const errorMapKey = getErrorMapKey(cause)
|
|
271
|
+
const doValidate = () => {
|
|
272
|
+
if (typeof validate === 'function') {
|
|
273
|
+
return validate(this.state.values, this) as ValidationError
|
|
274
|
+
}
|
|
275
|
+
if (this.options.validator && typeof validate !== 'function') {
|
|
276
|
+
return (this.options.validator as Validator<TFormData>)().validate(
|
|
277
|
+
this.state.values,
|
|
278
|
+
validate,
|
|
279
|
+
)
|
|
280
|
+
}
|
|
281
|
+
throw new Error(
|
|
282
|
+
`Form validation for ${errorMapKey} failed. ${errorMapKey} should either be a function, or \`validator\` should be correct.`,
|
|
283
|
+
)
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
const error = normalizeError(doValidate())
|
|
287
|
+
if (this.state.errorMap[errorMapKey] !== error) {
|
|
288
|
+
this.store.setState((prev) => ({
|
|
289
|
+
...prev,
|
|
290
|
+
errorMap: {
|
|
291
|
+
...prev.errorMap,
|
|
292
|
+
[errorMapKey]: error,
|
|
293
|
+
},
|
|
294
|
+
}))
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
if (this.state.errorMap[errorMapKey]) {
|
|
298
|
+
this.cancelValidateAsync()
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
__leaseValidateAsync = () => {
|
|
303
|
+
const count = (this.validationMeta.validationAsyncCount || 0) + 1
|
|
304
|
+
this.validationMeta.validationAsyncCount = count
|
|
305
|
+
return count
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
cancelValidateAsync = () => {
|
|
309
|
+
// Lease a new validation count to ignore any pending validations
|
|
310
|
+
this.__leaseValidateAsync()
|
|
311
|
+
// Cancel any pending validation state
|
|
312
|
+
this.store.setState((prev) => ({
|
|
313
|
+
...prev,
|
|
314
|
+
isFormValidating: false,
|
|
315
|
+
}))
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
validateAsync = async (
|
|
319
|
+
cause: ValidationCause,
|
|
320
|
+
): Promise<ValidationError[]> => {
|
|
321
|
+
const {
|
|
322
|
+
onChangeAsync,
|
|
323
|
+
onBlurAsync,
|
|
324
|
+
asyncDebounceMs,
|
|
325
|
+
onBlurAsyncDebounceMs,
|
|
326
|
+
onChangeAsyncDebounceMs,
|
|
327
|
+
} = this.options
|
|
328
|
+
|
|
329
|
+
const validate =
|
|
330
|
+
cause === 'change'
|
|
331
|
+
? onChangeAsync
|
|
332
|
+
: cause === 'blur'
|
|
333
|
+
? onBlurAsync
|
|
334
|
+
: undefined
|
|
335
|
+
|
|
336
|
+
if (!validate) return []
|
|
337
|
+
const debounceMs =
|
|
338
|
+
(cause === 'change' ? onChangeAsyncDebounceMs : onBlurAsyncDebounceMs) ??
|
|
339
|
+
asyncDebounceMs ??
|
|
340
|
+
0
|
|
341
|
+
|
|
342
|
+
if (!this.state.isFormValidating) {
|
|
343
|
+
this.store.setState((prev) => ({ ...prev, isFormValidating: true }))
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// Use the validationCount for all field instances to
|
|
347
|
+
// track freshness of the validation
|
|
348
|
+
const validationAsyncCount = this.__leaseValidateAsync()
|
|
349
|
+
|
|
350
|
+
const checkLatest = () =>
|
|
351
|
+
validationAsyncCount === this.validationMeta.validationAsyncCount
|
|
352
|
+
|
|
353
|
+
if (!this.validationMeta.validationPromise) {
|
|
354
|
+
this.validationMeta.validationPromise = new Promise((resolve, reject) => {
|
|
355
|
+
this.validationMeta.validationResolve = resolve
|
|
356
|
+
this.validationMeta.validationReject = reject
|
|
357
|
+
})
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
if (debounceMs > 0) {
|
|
361
|
+
await new Promise((r) => setTimeout(r, debounceMs))
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
const doValidate = () => {
|
|
365
|
+
if (typeof validate === 'function') {
|
|
366
|
+
return validate(this.state.values, this) as ValidationError
|
|
367
|
+
}
|
|
368
|
+
if (this.options.validator && typeof validate !== 'function') {
|
|
369
|
+
return (this.options.validator as Validator<TFormData>)().validateAsync(
|
|
370
|
+
this.state.values,
|
|
371
|
+
validate,
|
|
372
|
+
)
|
|
373
|
+
}
|
|
374
|
+
const errorMapKey = getErrorMapKey(cause)
|
|
375
|
+
throw new Error(
|
|
376
|
+
`Form validation for ${errorMapKey}Async failed. ${errorMapKey}Async should either be a function, or \`validator\` should be correct.`,
|
|
377
|
+
)
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// Only kick off validation if this validation is the latest attempt
|
|
381
|
+
if (checkLatest()) {
|
|
382
|
+
const prevErrors = this.state.errors
|
|
383
|
+
try {
|
|
384
|
+
const rawError = await doValidate()
|
|
385
|
+
if (checkLatest()) {
|
|
386
|
+
const error = normalizeError(rawError)
|
|
387
|
+
this.store.setState((prev) => ({
|
|
388
|
+
...prev,
|
|
389
|
+
isFormValidating: false,
|
|
390
|
+
errorMap: {
|
|
391
|
+
...prev.errorMap,
|
|
392
|
+
[getErrorMapKey(cause)]: error,
|
|
393
|
+
},
|
|
394
|
+
}))
|
|
395
|
+
this.validationMeta.validationResolve?.([...prevErrors, error])
|
|
396
|
+
}
|
|
397
|
+
} catch (error) {
|
|
398
|
+
if (checkLatest()) {
|
|
399
|
+
this.validationMeta.validationReject?.([...prevErrors, error])
|
|
400
|
+
throw error
|
|
401
|
+
}
|
|
402
|
+
} finally {
|
|
403
|
+
if (checkLatest()) {
|
|
404
|
+
this.store.setState((prev) => ({ ...prev, isFormValidating: false }))
|
|
405
|
+
delete this.validationMeta.validationPromise
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
// Always return the latest validation promise to the caller
|
|
410
|
+
return (await this.validationMeta.validationPromise) ?? []
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
validate = (
|
|
414
|
+
cause: ValidationCause,
|
|
415
|
+
): ValidationError[] | Promise<ValidationError[]> => {
|
|
416
|
+
// Store the previous error for the errorMapKey (eg. onChange, onBlur, onSubmit)
|
|
417
|
+
const errorMapKey = getErrorMapKey(cause)
|
|
418
|
+
const prevError = this.state.errorMap[errorMapKey]
|
|
419
|
+
|
|
420
|
+
// Attempt to sync validate first
|
|
421
|
+
this.validateSync(cause)
|
|
422
|
+
|
|
423
|
+
const newError = this.state.errorMap[errorMapKey]
|
|
424
|
+
if (
|
|
425
|
+
prevError !== newError &&
|
|
426
|
+
!this.options.asyncAlways &&
|
|
427
|
+
!(newError === undefined && prevError !== undefined)
|
|
428
|
+
)
|
|
429
|
+
return this.state.errors
|
|
430
|
+
|
|
431
|
+
// No error? Attempt async validation
|
|
432
|
+
return this.validateAsync(cause)
|
|
433
|
+
}
|
|
434
|
+
|
|
248
435
|
handleSubmit = async () => {
|
|
249
436
|
// Check to see that the form and all fields have been touched
|
|
250
437
|
// If they have not, touch them all and run validation
|
|
@@ -279,7 +466,7 @@ export class FormApi<TFormData, ValidatorType> {
|
|
|
279
466
|
}
|
|
280
467
|
|
|
281
468
|
// Run validation for the form
|
|
282
|
-
|
|
469
|
+
await this.validate('submit')
|
|
283
470
|
|
|
284
471
|
if (!this.state.isValid) {
|
|
285
472
|
done()
|
|
@@ -360,8 +547,11 @@ export class FormApi<TFormData, ValidatorType> {
|
|
|
360
547
|
}
|
|
361
548
|
|
|
362
549
|
deleteField = <TField extends DeepKeys<TFormData>>(field: TField) => {
|
|
363
|
-
|
|
364
|
-
delete
|
|
550
|
+
const newState = { ...this.state }
|
|
551
|
+
delete newState.values[field as keyof TFormData]
|
|
552
|
+
delete newState.fieldMeta[field]
|
|
553
|
+
|
|
554
|
+
this.store.setState((_) => newState)
|
|
365
555
|
}
|
|
366
556
|
|
|
367
557
|
pushFieldValue = <TField extends DeepKeys<TFormData>>(
|
|
@@ -425,3 +615,28 @@ export class FormApi<TFormData, ValidatorType> {
|
|
|
425
615
|
})
|
|
426
616
|
}
|
|
427
617
|
}
|
|
618
|
+
|
|
619
|
+
function normalizeError(rawError?: ValidationError) {
|
|
620
|
+
if (rawError) {
|
|
621
|
+
if (typeof rawError !== 'string') {
|
|
622
|
+
return 'Invalid Form Values'
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
return rawError
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
return undefined
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
function getErrorMapKey(cause: ValidationCause) {
|
|
632
|
+
switch (cause) {
|
|
633
|
+
case 'submit':
|
|
634
|
+
return 'onSubmit'
|
|
635
|
+
case 'change':
|
|
636
|
+
return 'onChange'
|
|
637
|
+
case 'blur':
|
|
638
|
+
return 'onBlur'
|
|
639
|
+
case 'mount':
|
|
640
|
+
return 'onMount'
|
|
641
|
+
}
|
|
642
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { expect } from 'vitest'
|
|
1
|
+
import { expect, vitest } from 'vitest'
|
|
2
2
|
|
|
3
3
|
import { FormApi } from '../FormApi'
|
|
4
4
|
import { FieldApi } from '../FieldApi'
|
|
@@ -597,11 +597,17 @@ describe('field api', () => {
|
|
|
597
597
|
})
|
|
598
598
|
|
|
599
599
|
const unmount = field.mount()
|
|
600
|
+
const callback = vitest.fn()
|
|
601
|
+
const subscription = form.store.subscribe(callback)
|
|
600
602
|
unmount()
|
|
601
603
|
const info = form.getFieldInfo(field.name)
|
|
604
|
+
subscription()
|
|
602
605
|
expect(info.instances[field.uid]).toBeUndefined()
|
|
603
606
|
expect(Object.keys(info.instances).length).toBe(0)
|
|
604
607
|
|
|
608
|
+
// Check that form store has been updated
|
|
609
|
+
expect(callback).toHaveBeenCalledOnce()
|
|
610
|
+
|
|
605
611
|
// Field should have been removed from the form as well
|
|
606
612
|
expect(form.state.values.name).toBeUndefined()
|
|
607
613
|
expect(form.state.fieldMeta.name).toBeUndefined()
|