Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Form persistence #561

Open
wants to merge 44 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
44 commits
Select commit Hold shift + click to select a range
b093cee
remove .forEach
aadito123 Dec 25, 2023
4386ce5
some react types
aadito123 Dec 25, 2023
68f6b03
add persist core + some changes to core
aadito123 Dec 26, 2023
8623f62
Merge branch 'main' into form-persistence
aadito123 Dec 29, 2023
778a30c
adding tests
aadito123 Dec 29, 2023
9ca3464
forgot to add files
aadito123 Jan 2, 2024
0974924
merge w/ main, move to vite, fix failing core tests
aadito123 Jan 4, 2024
5961a22
missing vite config for form persist
aadito123 Jan 4, 2024
311f311
misc stuff
aadito123 Jan 4, 2024
7cf6a47
Merge branch 'main' into form-persistence
aadito123 Jan 7, 2024
ce62fb2
persistence test case passing
aadito123 Jan 7, 2024
bf9b0d5
change types
aadito123 Jan 8, 2024
f9b248a
make persister a class
aadito123 Jan 8, 2024
8ebcda5
complete API revamp
aadito123 Jan 8, 2024
e647c6f
removed console.log
aadito123 Jan 8, 2024
1ff49e9
make prettier happy
aadito123 Jan 8, 2024
02636a2
add legacy tsconfig
aadito123 Jan 9, 2024
0028722
fix format + typecheck command
aadito123 Jan 9, 2024
4d3d3a4
Merge branch 'main' into form-persistence
aadito123 Jan 9, 2024
9da091c
Merge branch 'TanStack:main' into form-persistence
aadito123 Jan 26, 2024
2fd73f6
custom serializer
aadito123 Jan 26, 2024
4eff9b8
fine-grained persistense (omitKeys)
aadito123 Jan 26, 2024
6e5672a
Merge branch 'form-persistence' of https://github.com/aadito123/form …
aadito123 Jan 30, 2024
d0a53fe
Merge branch 'main' into form-persistence
aadito123 Feb 8, 2024
215335f
delete keys from .values
aadito123 Feb 8, 2024
72f2ede
form-persist react adapter
aadito123 Feb 9, 2024
118e5e5
format + eslint fixes
aadito123 Feb 9, 2024
b96a322
Merge branch 'form-persistence' of https://github.com/aadito123/form …
aadito123 Mar 11, 2024
ec8ca26
Merge branch 'main' into form-persistence
aadito123 Mar 11, 2024
4048f24
solid-form-persist
aadito123 Mar 11, 2024
71bd83d
add dependency
aadito123 Mar 11, 2024
13f6357
vue-form-persist
aadito123 Mar 11, 2024
908d88b
chore: fix config and package.json scripts
crutchcorn Mar 11, 2024
5e252ab
fix fast-forward
aadito123 Mar 12, 2024
f5dbddf
Merge branch 'main' into form-persistence
aadito123 Mar 15, 2024
1d5a3e7
pnpm-lock
aadito123 Mar 17, 2024
f2aadd2
Merge branch 'main' into form-persistence
aadito123 Mar 20, 2024
6d3dc8d
Merge branch 'form-persistence' of https://github.com/aadito123/form …
aadito123 Mar 21, 2024
b650bba
formatting + sherif fixes
aadito123 Mar 21, 2024
c324206
Revert "formatting + sherif fixes"
aadito123 Mar 21, 2024
995a7fd
remove .Provider + sherif + knip + prettier
aadito123 Mar 21, 2024
b552447
form-core test fix
aadito123 Mar 21, 2024
3124b10
format + test fixes
aadito123 Mar 21, 2024
0c24ff2
lockfile update
aadito123 Mar 21, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion packages/form-core/src/FieldApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -482,7 +482,6 @@ export class FieldApi<

validateSync = (value = this.state.value, cause: ValidationCause) => {
const validates = getSyncValidatorArray(cause, this.options)

// Needs type cast as eslint errantly believes this is always falsy
let hasErrored = false as boolean

Expand Down
94 changes: 71 additions & 23 deletions packages/form-core/src/FormApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import {
isNonEmptyArray,
setBy,
} from './utils'
import type { Updater } from './utils'
import type { MaybePromise, Updater } from './utils'
import type { DeepKeys, DeepValue } from './util-types'
import type { FieldApi, FieldMeta } from './FieldApi'
import type {
Expand Down Expand Up @@ -95,6 +95,14 @@ export interface FormOptions<
formApi: FormApi<TFormData, TFormValidator>
}) => void
transform?: FormTransform<TFormData, TFormValidator>
// checkout @tanstack/form-persistence-core
persister?: Persister<TFormData>
}

export type Persister<TFormData> = {
persistForm(formState: FormState<TFormData>): MaybePromise<void>
restoreForm(): MaybePromise<FormState<TFormData> | undefined>
deleteForm(): MaybePromise<void>
}

export type ValidationMeta = {
Expand Down Expand Up @@ -136,6 +144,9 @@ export type FormState<TFormData> = {
isValid: boolean
canSubmit: boolean
submissionAttempts: number
// Form Persistence
isRestored: boolean
isRestoring: boolean
}

function getDefaultFormState<TFormData>(
Expand Down Expand Up @@ -166,6 +177,8 @@ function getDefaultFormState<TFormData>(
onMount: undefined,
onServer: undefined,
},
isRestored: false,
isRestoring: false,
}
}

Expand All @@ -183,6 +196,7 @@ export class FormApi<
{} as any

prevTransformArray: unknown[] = []
restorePromise: Promise<void> = Promise.resolve()

constructor(opts?: FormOptions<TFormData, TFormValidator>) {
this.store = new Store<FormState<TFormData>>(
Expand All @@ -195,10 +209,9 @@ export class FormApi<
onUpdate: () => {
let { state } = this.store
// Computed state
const fieldMetaValues = Object.values(state.fieldMeta) as (
| FieldMeta
| undefined
)[]
const fieldMetaValues = Object.values<FieldMeta | undefined>(
state.fieldMeta,
)

const isFieldsValidating = fieldMetaValues.some(
(field) => field?.isValidating,
Expand Down Expand Up @@ -252,13 +265,44 @@ export class FormApi<
this.store.state = this.state
this.prevTransformArray = transformArray
}

if (opts?.persister && !this.state.isRestoring) {
opts.persister.persistForm(this.state)
}
},
},
)

this.state = this.store.state
this.update(opts)
}

this.update(opts || {})
restore = async (opts?: FormOptions<TFormData, TFormValidator>) => {
if (!opts?.persister) return
let restorePromiseResolve: () => void
this.restorePromise = new Promise<void>(
(res) => (restorePromiseResolve = res),
)
this.store.setState((oldState) => ({
...oldState,
isRestored: false,
isRestoring: true,
}))
const restoredState = await opts.persister.restoreForm()
if (!restoredState) {
return this.store.setState((oldState) => ({
...oldState,
isRestored: true,
isRestoring: false,
}))
}
this.state = restoredState
this.store.batch(() =>
this.store.setState(() => {
restorePromiseResolve()
return restoredState
}),
)
}

runValidator<
Expand Down Expand Up @@ -306,15 +350,19 @@ export class FormApi<
// Options need to be updated first so that when the store is updated, the state is correct for the derived state
this.options = options

this.restore(options)

this.store.batch(() => {
const shouldUpdateValues =
options.defaultValues &&
options.defaultValues !== oldOptions.defaultValues &&
!this.state.isTouched
!this.state.isTouched &&
!this.state.isRestoring

const shouldUpdateState =
options.defaultState !== oldOptions.defaultState &&
!this.state.isTouched
!this.state.isTouched &&
!this.state.isRestoring

this.store.setState(() =>
getDefaultFormState(
Expand All @@ -335,32 +383,35 @@ export class FormApi<
})
}

reset = () =>
reset = () => {
this.options.persister?.deleteForm()
this.store.setState(() =>
getDefaultFormState({
...(this.options.defaultState as any),
values: this.options.defaultValues ?? this.options.defaultState?.values,
}),
)
}

validateAllFields = async (cause: ValidationCause) => {
const fieldValidationPromises: Promise<ValidationError[]>[] = [] as any
const fieldValidationPromises: Promise<ValidationError[]>[] = []
this.store.batch(() => {
void (
Object.values(this.fieldInfo) as FieldInfo<any, TFormValidator>[]
).forEach((field) => {
if (!field.instance) return
const fieldInstance = field.instance
const fieldInfoValues = Object.values<FieldInfo<any, TFormValidator>>(
this.fieldInfo,
)
for (const field of fieldInfoValues) {
if (!field.instance) continue
const instance = field.instance
// Validate the field
fieldValidationPromises.push(
Promise.resolve().then(() => fieldInstance.validate(cause)),
Promise.resolve().then(() => instance.validate(cause)),
)
// If any fields are not touched
if (!field.instance.state.meta.isTouched) {
if (!instance.state.meta.isTouched) {
// Mark them as touched
field.instance.setMeta((prev) => ({ ...prev, isTouched: true }))
instance.setMeta((prev) => ({ ...prev, isTouched: true }))
}
})
}
})

const fieldErrorMapMap = await Promise.all(fieldValidationPromises)
Expand Down Expand Up @@ -492,10 +543,7 @@ export class FormApi<
)
}

let results: ValidationError[] = []
if (promises.length) {
results = await Promise.all(promises)
}
const results = promises.length > 0 ? await Promise.all(promises) : []

this.store.setState((prev) => ({
...prev,
Expand Down
3 changes: 3 additions & 0 deletions packages/form-core/src/tests/FieldApi.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -635,11 +635,14 @@ describe('field api', () => {
onSubmit: ({ value }) =>
value.length > 0 ? undefined : 'first name is required',
},
defaultMeta: { isTouched: true },
})

form.mount()
field.mount()

await form.handleSubmit()

expect(field.getMeta().errors).toStrictEqual(['first name is required'])
})

Expand Down
26 changes: 23 additions & 3 deletions packages/form-core/src/tests/FormApi.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,24 +12,27 @@ describe('form api', () => {
values: {},
fieldMeta: {},
canSubmit: true,
isFieldsValid: true,
isFieldsValid: false,
isFieldsValidating: false,
isFormValid: true,
isFormValidating: false,
isSubmitted: false,
errors: [],
errorMap: {},
isRestored: false,
isRestoring: false,
isSubmitting: false,
isTouched: false,
isPristine: true,
isDirty: false,
isValid: true,
isValid: false,
isValidating: false,
submissionAttempts: 0,
validationMetaMap: {
onChange: undefined,
onBlur: undefined,
onSubmit: undefined,
onServer: undefined,
onMount: undefined,
},
})
Expand Down Expand Up @@ -61,6 +64,8 @@ describe('form api', () => {
isDirty: false,
isValid: true,
isValidating: false,
isRestored: false,
isRestoring: false,
submissionAttempts: 0,
validationMetaMap: {
onChange: undefined,
Expand All @@ -87,6 +92,8 @@ describe('form api', () => {
isFieldsValid: true,
isFieldsValidating: false,
isFormValid: true,
isRestored: false,
isRestoring: false,
isFormValidating: false,
isSubmitted: false,
isSubmitting: false,
Expand All @@ -101,6 +108,7 @@ describe('form api', () => {
onBlur: undefined,
onSubmit: undefined,
onMount: undefined,
onServer: undefined,
},
})
})
Expand Down Expand Up @@ -139,6 +147,8 @@ describe('form api', () => {
isPristine: true,
isDirty: false,
isValid: true,
isRestored: false,
isRestoring: false,
isValidating: false,
submissionAttempts: 300,
validationMetaMap: {
Expand Down Expand Up @@ -182,10 +192,13 @@ describe('form api', () => {
isDirty: false,
isValid: true,
isValidating: false,
isRestored: false,
isRestoring: false,
submissionAttempts: 0,
validationMetaMap: {
onChange: undefined,
onBlur: undefined,
onServer: undefined,
onSubmit: undefined,
onMount: undefined,
},
Expand Down Expand Up @@ -834,6 +847,7 @@ describe('form api', () => {
onChange: ({ value }) =>
value.length > 0 ? undefined : 'first name is required',
},
defaultMeta: { isTouched: true },
})

const lastNameField = new FieldApi({
Expand All @@ -843,8 +857,10 @@ describe('form api', () => {
onChange: ({ value }) =>
value.length > 0 ? undefined : 'last name is required',
},
defaultMeta: { isTouched: true },
})

form.mount()
field.mount()
lastNameField.mount()

Expand Down Expand Up @@ -878,8 +894,10 @@ describe('form api', () => {
? undefined
: 'first name must be longer than 3 characters',
},
defaultMeta: { isTouched: true },
})

form.mount()
field.mount()

await form.handleSubmit()
Expand All @@ -905,8 +923,9 @@ describe('form api', () => {
onSubmit: ({ value }) =>
value.length > 0 ? undefined : 'first name is required',
},
defaultMeta: { isTouched: true },
})

form.mount()
field.mount()

await form.handleSubmit()
Expand Down Expand Up @@ -938,6 +957,7 @@ describe('form api', () => {
onChange: ({ value }) =>
value.length > 0 ? undefined : 'first name is required',
},
defaultMeta: { isTouched: true },
})

field.mount()
Expand Down
2 changes: 2 additions & 0 deletions packages/form-core/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import type { ValidationCause } from './types'
import type { FormValidators } from './FormApi'
import type { FieldValidators } from './FieldApi'

export type MaybePromise<T> = T | Promise<T>

export type UpdaterFn<TInput, TOutput = TInput> = (input: TInput) => TOutput

export type Updater<TInput, TOutput = TInput> =
Expand Down
11 changes: 11 additions & 0 deletions packages/form-persist-core/.eslintrc.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
// @ts-check

/** @type {import('eslint').Linter.Config} */
const config = {
parserOptions: {
tsconfigRootDir: __dirname,
project: './tsconfig.eslint.json',
},
}

module.exports = config
Loading
Loading