@tanstack/form-core 0.0.1

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/src/FormApi.ts ADDED
@@ -0,0 +1,424 @@
1
+ import type { FormEvent } from 'react'
2
+ import { Store } from '@tanstack/store'
3
+ //
4
+ import type { DeepKeys, DeepValue, Updater } from './utils'
5
+ import { functionalUpdate, getBy, setBy } from './utils'
6
+ import type { FieldApi, FieldMeta } from './FieldApi'
7
+
8
+ export type FormOptions<TData> = {
9
+ defaultValues?: TData
10
+ defaultState?: Partial<FormState<TData>>
11
+ onSubmit?: (values: TData, formApi: FormApi<TData>) => Promise<any>
12
+ onInvalidSubmit?: (values: TData, formApi: FormApi<TData>) => void
13
+ validate?: (values: TData, formApi: FormApi<TData>) => Promise<any>
14
+ debugForm?: boolean
15
+ validatePristine?: boolean
16
+ }
17
+
18
+ export type FieldInfo<TFormData> = {
19
+ instances: Record<string, FieldApi<any, TFormData>>
20
+ } & ValidationMeta
21
+
22
+ export type ValidationMeta = {
23
+ validationCount?: number
24
+ validationPromise?: Promise<ValidationError>
25
+ validationResolve?: (error: ValidationError) => void
26
+ validationReject?: (error: unknown) => void
27
+ }
28
+
29
+ export type ValidationError = undefined | false | null | string
30
+
31
+ export type FormState<TData> = {
32
+ values: TData
33
+ // Form Validation
34
+ isFormValidating: boolean
35
+ formValidationCount: number
36
+ isFormValid: boolean
37
+ formError?: ValidationError
38
+ // Fields
39
+ fieldMeta: Record<DeepKeys<TData>, FieldMeta>
40
+ isFieldsValidating: boolean
41
+ isFieldsValid: boolean
42
+ isSubmitting: boolean
43
+ // General
44
+ isTouched: boolean
45
+ isSubmitted: boolean
46
+ isValidating: boolean
47
+ isValid: boolean
48
+ canSubmit: boolean
49
+ submissionAttempts: number
50
+ }
51
+
52
+ export function getDefaultFormState<TData>(
53
+ defaultState: Partial<FormState<TData>>,
54
+ ): FormState<TData> {
55
+ return {
56
+ values: {} as any,
57
+ fieldMeta: {} as any,
58
+ canSubmit: true,
59
+ isFieldsValid: false,
60
+ isFieldsValidating: false,
61
+ isFormValid: false,
62
+ isFormValidating: false,
63
+ isSubmitted: false,
64
+ isSubmitting: false,
65
+ isTouched: false,
66
+ isValid: false,
67
+ isValidating: false,
68
+ submissionAttempts: 0,
69
+ formValidationCount: 0,
70
+ ...defaultState,
71
+ }
72
+ }
73
+
74
+ export class FormApi<TFormData> {
75
+ // // This carries the context for nested fields
76
+ options: FormOptions<TFormData> = {}
77
+ store!: Store<FormState<TFormData>>
78
+ // Do not use __state directly, as it is not reactive.
79
+ // Please use form.useStore() utility to subscribe to state
80
+ state!: FormState<TFormData>
81
+ fieldInfo: Record<DeepKeys<TFormData>, FieldInfo<TFormData>> = {} as any
82
+ fieldName?: string
83
+ validationMeta: ValidationMeta = {}
84
+
85
+ constructor(opts?: FormOptions<TFormData>) {
86
+ this.store = new Store<FormState<TFormData>>(
87
+ getDefaultFormState({
88
+ ...opts?.defaultState,
89
+ values: opts?.defaultValues ?? opts?.defaultState?.values,
90
+ isFormValid: !opts?.validate,
91
+ }),
92
+ {
93
+ onUpdate: (next) => {
94
+ // Computed state
95
+ const fieldMetaValues = Object.values(next.fieldMeta) as (
96
+ | FieldMeta
97
+ | undefined
98
+ )[]
99
+
100
+ const isFieldsValidating = fieldMetaValues.some(
101
+ (field) => field?.isValidating,
102
+ )
103
+
104
+ const isFieldsValid = !fieldMetaValues.some((field) => field?.error)
105
+
106
+ const isTouched = fieldMetaValues.some((field) => field?.isTouched)
107
+
108
+ const isValidating = isFieldsValidating || next.isFormValidating
109
+ const isFormValid = !next.formError
110
+ const isValid = isFieldsValid && isFormValid
111
+ const canSubmit =
112
+ (next.submissionAttempts === 0 && !isTouched) ||
113
+ (!isValidating && !next.isSubmitting && isValid)
114
+
115
+ next = {
116
+ ...next,
117
+ isFieldsValidating,
118
+ isFieldsValid,
119
+ isFormValid,
120
+ isValid,
121
+ canSubmit,
122
+ isTouched,
123
+ }
124
+
125
+ // Create a shortcut for the state
126
+ // Write it back to the store
127
+ this.store.state = next
128
+ this.state = next
129
+ },
130
+ },
131
+ )
132
+
133
+ this.state = this.store.state
134
+
135
+ this.update(opts || {})
136
+ }
137
+
138
+ update = (options: FormOptions<TFormData>) => {
139
+ this.store.batch(() => {
140
+ if (
141
+ options.defaultState &&
142
+ options.defaultState !== this.options.defaultState
143
+ ) {
144
+ this.store.setState((prev) => ({
145
+ ...prev,
146
+ ...options.defaultState,
147
+ }))
148
+ }
149
+
150
+ if (options.defaultValues !== this.options.defaultValues) {
151
+ this.store.setState((prev) => ({
152
+ ...prev,
153
+ values: options.defaultValues as TFormData,
154
+ }))
155
+ }
156
+ })
157
+
158
+ this.options = options
159
+ }
160
+
161
+ reset = () =>
162
+ this.store.setState(() => getDefaultFormState(this.options.defaultValues!))
163
+
164
+ validateAllFields = async () => {
165
+ const fieldValidationPromises: Promise<ValidationError>[] = [] as any
166
+
167
+ this.store.batch(() => {
168
+ void (Object.values(this.fieldInfo) as FieldInfo<any>[]).forEach(
169
+ (field) => {
170
+ Object.values(field.instances).forEach((instance) => {
171
+ // If any fields are not touched
172
+ if (!instance.state.meta.isTouched) {
173
+ // Mark them as touched
174
+ instance.setMeta((prev) => ({ ...prev, isTouched: true }))
175
+ // Validate the field
176
+ if (instance.options.validate) {
177
+ fieldValidationPromises.push(instance.validate())
178
+ }
179
+ }
180
+ })
181
+ },
182
+ )
183
+ })
184
+
185
+ return Promise.all(fieldValidationPromises)
186
+ }
187
+
188
+ validateForm = async () => {
189
+ const { validate } = this.options
190
+
191
+ if (!validate) {
192
+ return
193
+ }
194
+
195
+ // Use the formValidationCount for all field instances to
196
+ // track freshness of the validation
197
+ this.store.setState((prev) => ({
198
+ ...prev,
199
+ isValidating: true,
200
+ formValidationCount: prev.formValidationCount + 1,
201
+ }))
202
+
203
+ const formValidationCount = this.state.formValidationCount
204
+
205
+ const checkLatest = () =>
206
+ formValidationCount === this.state.formValidationCount
207
+
208
+ if (!this.validationMeta.validationPromise) {
209
+ this.validationMeta.validationPromise = new Promise((resolve, reject) => {
210
+ this.validationMeta.validationResolve = resolve
211
+ this.validationMeta.validationReject = reject
212
+ })
213
+ }
214
+
215
+ const doValidation = async () => {
216
+ try {
217
+ const error = await validate(this.state.values, this)
218
+
219
+ if (checkLatest()) {
220
+ this.store.setState((prev) => ({
221
+ ...prev,
222
+ isValidating: false,
223
+ error: error
224
+ ? typeof error === 'string'
225
+ ? error
226
+ : 'Invalid Form Values'
227
+ : null,
228
+ }))
229
+
230
+ this.validationMeta.validationResolve?.(error)
231
+ }
232
+ } catch (err) {
233
+ if (checkLatest()) {
234
+ this.validationMeta.validationReject?.(err)
235
+ }
236
+ } finally {
237
+ delete this.validationMeta.validationPromise
238
+ }
239
+ }
240
+
241
+ doValidation()
242
+
243
+ return this.validationMeta.validationPromise
244
+ }
245
+
246
+ handleSubmit = async (e: FormEvent & { __handled?: boolean }) => {
247
+ e.preventDefault()
248
+ e.stopPropagation()
249
+
250
+ // Check to see that the form and all fields have been touched
251
+ // If they have not, touch them all and run validation
252
+ // Run form validation
253
+ // Submit the form
254
+
255
+ this.store.setState((old) => ({
256
+ ...old,
257
+ // Submittion attempts mark the form as not submitted
258
+ isSubmitted: false,
259
+ // Count submission attempts
260
+ submissionAttempts: old.submissionAttempts + 1,
261
+ }))
262
+
263
+ // Don't let invalid forms submit
264
+ if (!this.state.canSubmit) return
265
+
266
+ this.store.setState((d) => ({ ...d, isSubmitting: true }))
267
+
268
+ const done = () => {
269
+ this.store.setState((prev) => ({ ...prev, isSubmitting: false }))
270
+ }
271
+
272
+ // Validate all fields
273
+ await this.validateAllFields()
274
+
275
+ // Fields are invalid, do not submit
276
+ if (!this.state.isFieldsValid) {
277
+ done()
278
+ this.options.onInvalidSubmit?.(this.state.values, this)
279
+ return
280
+ }
281
+
282
+ // Run validation for the form
283
+ await this.validateForm()
284
+
285
+ if (!this.state.isValid) {
286
+ done()
287
+ this.options.onInvalidSubmit?.(this.state.values, this)
288
+ return
289
+ }
290
+
291
+ try {
292
+ // Run the submit code
293
+ await this.options.onSubmit?.(this.state.values, this)
294
+
295
+ this.store.batch(() => {
296
+ this.store.setState((prev) => ({ ...prev, isSubmitted: true }))
297
+ done()
298
+ })
299
+ } catch (err) {
300
+ done()
301
+ throw err
302
+ }
303
+ }
304
+
305
+ getFieldValue = <TField extends DeepKeys<TFormData>>(
306
+ field: TField,
307
+ ): DeepValue<TFormData, TField> => getBy(this.state.values, field)
308
+
309
+ getFieldMeta = <TField extends DeepKeys<TFormData>>(
310
+ field: TField,
311
+ ): FieldMeta => {
312
+ return this.state.fieldMeta[field]
313
+ }
314
+
315
+ getFieldInfo = <TField extends DeepKeys<TFormData>>(field: TField) => {
316
+ return (this.fieldInfo[field] ||= {
317
+ instances: {},
318
+ })
319
+ }
320
+
321
+ setFieldMeta = <TField extends DeepKeys<TFormData>>(
322
+ field: TField,
323
+ updater: Updater<FieldMeta>,
324
+ ) => {
325
+ this.store.setState((prev) => {
326
+ return {
327
+ ...prev,
328
+ fieldMeta: {
329
+ ...prev.fieldMeta,
330
+ [field]: functionalUpdate(updater, prev.fieldMeta[field]),
331
+ },
332
+ }
333
+ })
334
+ }
335
+
336
+ setFieldValue = <TField extends DeepKeys<TFormData>>(
337
+ field: TField,
338
+ updater: Updater<DeepValue<TFormData, TField>>,
339
+ opts?: { touch?: boolean },
340
+ ) => {
341
+ const touch = opts?.touch ?? true
342
+
343
+ this.store.batch(() => {
344
+ this.store.setState((prev) => {
345
+ return {
346
+ ...prev,
347
+ values: setBy(prev.values, field, updater),
348
+ }
349
+ })
350
+
351
+ if (touch) {
352
+ this.setFieldMeta(field, (prev) => ({
353
+ ...prev,
354
+ isTouched: true,
355
+ }))
356
+ }
357
+ })
358
+ }
359
+
360
+ pushFieldValue = <TField extends DeepKeys<TFormData>>(
361
+ field: TField,
362
+ value: DeepValue<TFormData, TField>,
363
+ opts?: { touch?: boolean },
364
+ ) => {
365
+ return this.setFieldValue(
366
+ field,
367
+ (prev) => [...(Array.isArray(prev) ? prev : []), value] as any,
368
+ opts,
369
+ )
370
+ }
371
+
372
+ insertFieldValue = <TField extends DeepKeys<TFormData>>(
373
+ field: TField,
374
+ index: number,
375
+ value: DeepValue<TFormData, TField>,
376
+ opts?: { touch?: boolean },
377
+ ) => {
378
+ this.setFieldValue(
379
+ field,
380
+ (prev) => {
381
+ // invariant( // TODO: bring in invariant
382
+ // Array.isArray(prev),
383
+ // `Cannot insert a field value into a non-array field. Check that this field's existing value is an array: ${field}.`
384
+ // )
385
+ return (prev as DeepValue<TFormData, TField>[]).map((d, i) =>
386
+ i === index ? value : d,
387
+ ) as any
388
+ },
389
+ opts,
390
+ )
391
+ }
392
+
393
+ spliceFieldValue = <TField extends DeepKeys<TFormData>>(
394
+ field: TField,
395
+ index: number,
396
+ opts?: { touch?: boolean },
397
+ ) => {
398
+ this.setFieldValue(
399
+ field,
400
+ (prev) => {
401
+ // invariant( // TODO: bring in invariant
402
+ // Array.isArray(prev),
403
+ // `Cannot insert a field value into a non-array field. Check that this field's existing value is an array: ${field}.`
404
+ // )
405
+ return (prev as DeepValue<TFormData, TField>[]).filter(
406
+ (_d, i) => i !== index,
407
+ ) as any
408
+ },
409
+ opts,
410
+ )
411
+ }
412
+
413
+ swapFieldValues = <TField extends DeepKeys<TFormData>>(
414
+ field: TField,
415
+ index1: number,
416
+ index2: number,
417
+ ) => {
418
+ this.setFieldValue(field, (prev: any) => {
419
+ const prev1 = prev[index1]!
420
+ const prev2 = prev[index2]!
421
+ return setBy(setBy(prev, [index1], prev2), [index2], prev1)
422
+ })
423
+ }
424
+ }
package/src/index.ts ADDED
@@ -0,0 +1,3 @@
1
+ export * from './FormApi'
2
+ export * from './FieldApi'
3
+ export * from './utils'
@@ -0,0 +1,5 @@
1
+ describe('tests', () => {
2
+ it('should test', () => {
3
+ expect(true).toBe(true)
4
+ })
5
+ })
package/src/utils.ts ADDED
@@ -0,0 +1,144 @@
1
+ export type UpdaterFn<TInput, TOutput = TInput> = (input: TInput) => TOutput
2
+
3
+ export type Updater<TInput, TOutput = TInput> =
4
+ | TOutput
5
+ | UpdaterFn<TInput, TOutput>
6
+
7
+ export function functionalUpdate<TInput, TOutput = TInput>(
8
+ updater: Updater<TInput, TOutput>,
9
+ input: TInput,
10
+ ): TOutput {
11
+ return typeof updater === 'function'
12
+ ? (updater as UpdaterFn<TInput, TOutput>)(input)
13
+ : updater
14
+ }
15
+
16
+ export function getBy(obj: any, path: any) {
17
+ if (!path) {
18
+ throw new Error('A path string is required to use getBy')
19
+ }
20
+ const pathArray = makePathArray(path)
21
+ const pathObj = pathArray
22
+ return pathObj.reduce((current: any, pathPart: any) => {
23
+ if (typeof current !== 'undefined') {
24
+ return current[pathPart]
25
+ }
26
+ return undefined
27
+ }, obj)
28
+ }
29
+
30
+ export function setBy(obj: any, _path: any, updater: Updater<any>) {
31
+ const path = makePathArray(_path)
32
+
33
+ function doSet(parent?: any): any {
34
+ if (!path.length) {
35
+ return functionalUpdate(updater, parent)
36
+ }
37
+
38
+ const key = path.shift()
39
+
40
+ if (typeof key === 'string') {
41
+ if (typeof parent === 'object') {
42
+ return {
43
+ ...parent,
44
+ [key]: doSet(parent[key]),
45
+ }
46
+ }
47
+ return {
48
+ [key]: doSet(),
49
+ }
50
+ }
51
+
52
+ if (typeof key === 'number') {
53
+ if (Array.isArray(parent)) {
54
+ const prefix = parent.slice(0, key)
55
+ return [
56
+ ...(prefix.length ? prefix : new Array(key)),
57
+ doSet(parent[key]),
58
+ ...parent.slice(key + 1),
59
+ ]
60
+ }
61
+ return [...new Array(key), doSet()]
62
+ }
63
+
64
+ throw new Error('Uh oh!')
65
+ }
66
+
67
+ return doSet(obj)
68
+ }
69
+
70
+ const reFindNumbers0 = /^(\d*)$/gm
71
+ const reFindNumbers1 = /\.(\d*)\./gm
72
+ const reFindNumbers2 = /^(\d*)\./gm
73
+ const reFindNumbers3 = /\.(\d*$)/gm
74
+ const reFindMultiplePeriods = /\.{2,}/gm
75
+
76
+ function makePathArray(str: string) {
77
+ return str
78
+ .replace('[', '.')
79
+ .replace(']', '')
80
+ .replace(reFindNumbers0, '__int__$1')
81
+ .replace(reFindNumbers1, '.__int__$1.')
82
+ .replace(reFindNumbers2, '__int__$1.')
83
+ .replace(reFindNumbers3, '.__int__$1')
84
+ .replace(reFindMultiplePeriods, '.')
85
+ .split('.')
86
+ .map((d) => {
87
+ if (d.indexOf('__int__') === 0) {
88
+ return parseInt(d.substring('__int__'.length), 10)
89
+ }
90
+ return d
91
+ })
92
+ }
93
+
94
+ export type RequiredByKey<T, K extends keyof T> = Omit<T, K> &
95
+ Required<Pick<T, K>>
96
+
97
+ type ComputeRange<
98
+ N extends number,
99
+ Result extends Array<unknown> = [],
100
+ > = Result['length'] extends N
101
+ ? Result
102
+ : ComputeRange<N, [...Result, Result['length']]>
103
+ type Index40 = ComputeRange<40>[number]
104
+
105
+ // Is this type a tuple?
106
+ type IsTuple<T> = T extends readonly any[] & { length: infer Length }
107
+ ? Length extends Index40
108
+ ? T
109
+ : never
110
+ : never
111
+
112
+ // If this type is a tuple, what indices are allowed?
113
+ type AllowedIndexes<
114
+ Tuple extends ReadonlyArray<any>,
115
+ Keys extends number = never,
116
+ > = Tuple extends readonly []
117
+ ? Keys
118
+ : Tuple extends readonly [infer _, ...infer Tail]
119
+ ? AllowedIndexes<Tail, Keys | Tail['length']>
120
+ : Keys
121
+
122
+ export type DeepKeys<T> = unknown extends T
123
+ ? keyof T
124
+ : object extends T
125
+ ? string
126
+ : T extends readonly any[] & IsTuple<T>
127
+ ? AllowedIndexes<T> | DeepKeysPrefix<T, AllowedIndexes<T>>
128
+ : T extends any[]
129
+ ? never & 'Dynamic length array indexing is not supported'
130
+ : T extends Date
131
+ ? never
132
+ : T extends object
133
+ ? (keyof T & string) | DeepKeysPrefix<T, keyof T>
134
+ : never
135
+
136
+ type DeepKeysPrefix<T, TPrefix> = TPrefix extends keyof T & (number | string)
137
+ ? `${TPrefix}.${DeepKeys<T[TPrefix]> & string}`
138
+ : never
139
+
140
+ export type DeepValue<T, TProp> = T extends Record<string | number, any>
141
+ ? TProp extends `${infer TBranch}.${infer TDeepProp}`
142
+ ? DeepValue<T[TBranch], TDeepProp>
143
+ : T[TProp & string]
144
+ : never