Files
form/packages/form-core/src/FieldApi.ts
Aadit Olkar 6050fea779 feat: solid.js form (#471)
* solid commit -a

* fix failing tests and formatting

* comments + removed unneeded computed

* updated changes

* prettierd

* chore: add Solid Form to script to be deployed

* fix: fix typing of solid's Subscribe data

* chore: remove errant createEffect

* chore: rename Solid's useForm and useField to createForm and createField

* chore: remove old mention of React's memoization

* chore: add Solid simple example

* chore: add Solid yup example

* chore: add Zod Solid example

* docs: add initial docs for Solid package

---------

Co-authored-by: Corbin Crutchley <git@crutchcorn.dev>
2023-10-30 00:17:49 -07:00

564 lines
15 KiB
TypeScript

import { type DeepKeys, type DeepValue, type Updater } from './utils'
import type { FormApi, ValidationErrorMap } from './FormApi'
import { Store } from '@tanstack/store'
import type { Validator, ValidationError } from './types'
export type ValidationCause = 'change' | 'blur' | 'submit' | 'mount'
type ValidateFn<
TParentData,
TName extends DeepKeys<TParentData>,
ValidatorType,
TData extends DeepValue<TParentData, TName> = DeepValue<TParentData, TName>,
> = (
value: TData,
fieldApi: FieldApi<TParentData, TName, ValidatorType, TData>,
) => ValidationError
type ValidateOrFn<
TParentData,
TName extends DeepKeys<TParentData>,
ValidatorType,
FormValidator,
TData extends DeepValue<TParentData, TName> = DeepValue<TParentData, TName>,
> = ValidatorType extends Validator<TData>
?
| Parameters<ReturnType<ValidatorType>['validate']>[1]
| ValidateFn<TParentData, TName, ValidatorType, TData>
: FormValidator extends Validator<TData>
?
| Parameters<ReturnType<FormValidator>['validate']>[1]
| ValidateFn<TParentData, TName, ValidatorType, TData>
: ValidateFn<TParentData, TName, ValidatorType, TData>
type ValidateAsyncFn<
TParentData,
TName extends DeepKeys<TParentData>,
ValidatorType,
TData extends DeepValue<TParentData, TName> = DeepValue<TParentData, TName>,
> = (
value: TData,
fieldApi: FieldApi<TParentData, TName, ValidatorType, TData>,
) => ValidationError | Promise<ValidationError>
type AsyncValidateOrFn<
TParentData,
TName extends DeepKeys<TParentData>,
ValidatorType,
FormValidator,
TData extends DeepValue<TParentData, TName> = DeepValue<TParentData, TName>,
> = ValidatorType extends Validator<TData>
?
| Parameters<ReturnType<ValidatorType>['validate']>[1]
| ValidateAsyncFn<TParentData, TName, ValidatorType, TData>
: FormValidator extends Validator<TData>
?
| Parameters<ReturnType<FormValidator>['validate']>[1]
| ValidateAsyncFn<TParentData, TName, ValidatorType, TData>
: ValidateAsyncFn<TParentData, TName, ValidatorType, TData>
export interface FieldOptions<
TParentData,
TName extends DeepKeys<TParentData>,
ValidatorType,
FormValidator,
TData extends DeepValue<TParentData, TName> = DeepValue<TParentData, TName>,
> {
name: TName
index?: TData extends any[] ? number : never
defaultValue?: TData
asyncDebounceMs?: number
asyncAlways?: boolean
preserveValue?: boolean
validator?: ValidatorType
onMount?: (
formApi: FieldApi<TParentData, TName, ValidatorType, TData>,
) => void
onChange?: ValidateOrFn<
TParentData,
TName,
ValidatorType,
FormValidator,
TData
>
onChangeAsync?: AsyncValidateOrFn<
TParentData,
TName,
ValidatorType,
FormValidator,
TData
>
onChangeAsyncDebounceMs?: number
onBlur?: ValidateOrFn<TParentData, TName, ValidatorType, FormValidator, TData>
onBlurAsync?: AsyncValidateOrFn<
TParentData,
TName,
ValidatorType,
FormValidator,
TData
>
onBlurAsyncDebounceMs?: number
onSubmitAsync?: AsyncValidateOrFn<
TParentData,
TName,
ValidatorType,
FormValidator,
TData
>
defaultMeta?: Partial<FieldMeta>
}
export interface FieldApiOptions<
TParentData,
TName extends DeepKeys<TParentData>,
ValidatorType,
FormValidator,
TData extends DeepValue<TParentData, TName> = DeepValue<TParentData, TName>,
> extends FieldOptions<
TParentData,
TName,
ValidatorType,
FormValidator,
TData
> {
form: FormApi<TParentData, FormValidator>
}
export type FieldMeta = {
isTouched: boolean
touchedErrors: ValidationError[]
errors: ValidationError[]
errorMap: ValidationErrorMap
isValidating: boolean
}
let uid = 0
export type FieldState<TData> = {
value: TData
meta: FieldMeta
}
export type ResolveName<TParentData> = unknown extends TParentData
? string
: DeepKeys<TParentData>
export class FieldApi<
TParentData,
TName extends DeepKeys<TParentData>,
ValidatorType,
FormValidator,
TData extends DeepValue<TParentData, TName> = DeepValue<TParentData, TName>,
> {
uid: number
form: FieldApiOptions<TParentData, TName, ValidatorType, TData>['form']
name!: DeepKeys<TParentData>
options: FieldApiOptions<TParentData, TName, ValidatorType, TData> = {} as any
store!: Store<FieldState<TData>>
state!: FieldState<TData>
prevState!: FieldState<TData>
constructor(
opts: FieldApiOptions<
TParentData,
TName,
ValidatorType,
FormValidator,
TData
>,
) {
this.form = opts.form as never
this.uid = uid++
// Support field prefixing from FieldScope
// let fieldPrefix = ''
// if (this.form.fieldName) {
// fieldPrefix = `${this.form.fieldName}.`
// }
this.name = opts.name as never
if (opts.defaultValue !== undefined) {
this.form.setFieldValue(this.name, opts.defaultValue as never)
}
this.store = new Store<FieldState<TData>>(
{
value: this.getValue(),
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
meta: this._getMeta() ?? {
isValidating: false,
isTouched: false,
touchedErrors: [],
errors: [],
errorMap: {},
...opts.defaultMeta,
},
},
{
onUpdate: () => {
const state = this.store.state
state.meta.errors = Object.values(state.meta.errorMap).filter(
(val: unknown) => val !== undefined,
)
state.meta.touchedErrors = state.meta.isTouched
? state.meta.errors
: []
this.prevState = state
this.state = state
},
},
)
this.state = this.store.state
this.prevState = this.state
this.options = opts as never
}
mount = () => {
const info = this.getInfo()
info.instances[this.uid] = this as never
const unsubscribe = this.form.store.subscribe(() => {
this.store.batch(() => {
const nextValue = this.getValue()
const nextMeta = this.getMeta()
if (nextValue !== this.state.value) {
this.store.setState((prev) => ({ ...prev, value: nextValue }))
}
if (nextMeta !== this.state.meta) {
this.store.setState((prev) => ({ ...prev, meta: nextMeta }))
}
})
})
this.update(this.options as never)
this.options.onMount?.(this as never)
return () => {
const preserveValue = this.options.preserveValue
unsubscribe()
if (!preserveValue) {
delete info.instances[this.uid]
}
if (!Object.keys(info.instances).length && !preserveValue) {
delete this.form.fieldInfo[this.name]
}
}
}
update = (
opts: FieldApiOptions<TParentData, TName, ValidatorType, TData>,
) => {
// Default Value
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (this.state.value === undefined) {
const formDefault =
opts.form.options.defaultValues?.[opts.name as keyof TParentData]
if (opts.defaultValue !== undefined) {
this.setValue(opts.defaultValue as never)
} else if (formDefault !== undefined) {
this.setValue(formDefault as never)
}
}
// Default Meta
if (this._getMeta() === undefined) {
this.setMeta(this.state.meta)
}
this.options = opts as never
}
getValue = (): TData => {
return this.form.getFieldValue(this.name) as any
}
setValue = (
updater: Updater<TData>,
options?: { touch?: boolean; notify?: boolean },
) => {
this.form.setFieldValue(this.name, updater as never, options)
this.validate('change', this.state.value)
}
_getMeta = () => this.form.getFieldMeta(this.name)
getMeta = () =>
this._getMeta() ??
({
isValidating: false,
isTouched: false,
touchedErrors: [],
errors: [],
errorMap: {},
...this.options.defaultMeta,
} as FieldMeta)
setMeta = (updater: Updater<FieldMeta>) =>
this.form.setFieldMeta(this.name, updater)
getInfo = () => this.form.getFieldInfo(this.name)
pushValue = (value: TData extends any[] ? TData[number] : never) =>
this.form.pushFieldValue(this.name, value as any)
insertValue = (
index: number,
value: TData extends any[] ? TData[number] : never,
) => this.form.insertFieldValue(this.name, index, value as any)
removeValue = (index: number) => this.form.removeFieldValue(this.name, index)
swapValues = (aIndex: number, bIndex: number) =>
this.form.swapFieldValues(this.name, aIndex, bIndex)
getSubField = <
TSubName extends DeepKeys<TData>,
TSubData extends DeepValue<TData, TSubName> = DeepValue<TData, TSubName>,
>(
name: TSubName,
): FieldApi<TData, TSubName, ValidatorType, TSubData> =>
new FieldApi({
name: `${this.name}.${name}` as never,
form: this.form,
}) as any
validateSync = (value = this.state.value, cause: ValidationCause) => {
const { onChange, onBlur } = this.options
const validate =
cause === 'submit' ? undefined : cause === 'change' ? onChange : onBlur
if (!validate) return
// Use the validationCount for all field instances to
// track freshness of the validation
const validationCount = (this.getInfo().validationCount || 0) + 1
this.getInfo().validationCount = validationCount
const doValidate = () => {
if (this.options.validator && typeof validate !== 'function') {
return (this.options.validator as Validator<TData>)().validate(
value,
validate,
)
}
if (this.form.options.validator && typeof validate !== 'function') {
return (this.form.options.validator as Validator<TData>)().validate(
value,
validate,
)
}
return (validate as ValidateFn<TParentData, TName, ValidatorType, TData>)(
value,
this as never,
)
}
const error = normalizeError(doValidate())
const errorMapKey = getErrorMapKey(cause)
if (this.state.meta.errorMap[errorMapKey] !== error) {
this.setMeta((prev) => ({
...prev,
errorMap: {
...prev.errorMap,
[getErrorMapKey(cause)]: error,
},
}))
}
// If a sync error is encountered for the errorMapKey (eg. onChange), cancel any async validation
if (this.state.meta.errorMap[errorMapKey]) {
this.cancelValidateAsync()
}
}
__leaseValidateAsync = () => {
const count = (this.getInfo().validationAsyncCount || 0) + 1
this.getInfo().validationAsyncCount = count
return count
}
cancelValidateAsync = () => {
// Lease a new validation count to ignore any pending validations
this.__leaseValidateAsync()
// Cancel any pending validation state
this.setMeta((prev) => ({
...prev,
isValidating: false,
}))
}
validateAsync = async (value = this.state.value, cause: ValidationCause) => {
const {
onChangeAsync,
onBlurAsync,
onSubmitAsync,
asyncDebounceMs,
onBlurAsyncDebounceMs,
onChangeAsyncDebounceMs,
} = this.options
const validate =
cause === 'change'
? onChangeAsync
: cause === 'submit'
? onSubmitAsync
: onBlurAsync
if (!validate) return []
const debounceMs =
cause === 'submit'
? 0
: (cause === 'change'
? onChangeAsyncDebounceMs
: onBlurAsyncDebounceMs) ??
asyncDebounceMs ??
0
if (this.state.meta.isValidating !== true) {
this.setMeta((prev) => ({ ...prev, isValidating: true }))
}
// Use the validationCount for all field instances to
// track freshness of the validation
const validationAsyncCount = this.__leaseValidateAsync()
const checkLatest = () =>
validationAsyncCount === this.getInfo().validationAsyncCount
if (!this.getInfo().validationPromise) {
this.getInfo().validationPromise = new Promise((resolve, reject) => {
this.getInfo().validationResolve = resolve
this.getInfo().validationReject = reject
})
}
if (debounceMs > 0) {
await new Promise((r) => setTimeout(r, debounceMs))
}
const doValidate = () => {
if (this.options.validator && typeof validate !== 'function') {
return (this.options.validator as Validator<TData>)().validateAsync(
value,
validate,
)
}
if (this.form.options.validator && typeof validate !== 'function') {
return (
this.form.options.validator as Validator<TData>
)().validateAsync(value, validate)
}
return (validate as ValidateFn<TParentData, TName, ValidatorType, TData>)(
value,
this as never,
)
}
// Only kick off validation if this validation is the latest attempt
if (checkLatest()) {
const prevErrors = this.getMeta().errors
try {
const rawError = await doValidate()
if (checkLatest()) {
const error = normalizeError(rawError)
this.setMeta((prev) => ({
...prev,
isValidating: false,
errorMap: {
...prev.errorMap,
[getErrorMapKey(cause)]: error,
},
}))
this.getInfo().validationResolve?.([...prevErrors, error])
}
} catch (error) {
if (checkLatest()) {
this.getInfo().validationReject?.([...prevErrors, error])
throw error
}
} finally {
if (checkLatest()) {
this.setMeta((prev) => ({ ...prev, isValidating: false }))
delete this.getInfo().validationPromise
}
}
}
// Always return the latest validation promise to the caller
return this.getInfo().validationPromise ?? []
}
validate = (
cause: ValidationCause,
value?: TData,
): ValidationError[] | Promise<ValidationError[]> => {
// If the field is pristine and validatePristine is false, do not validate
if (!this.state.meta.isTouched) return []
// Store the previous error for the errorMapKey (eg. onChange, onBlur, onSubmit)
const errorMapKey = getErrorMapKey(cause)
const prevError = this.getMeta().errorMap[errorMapKey]
// Attempt to sync validate first
this.validateSync(value, cause)
// If there is a new error mapped to the errorMapKey (eg. onChange, onBlur, onSubmit), return the errors array, do not attempt async validation
const newError = this.getMeta().errorMap[errorMapKey]
if (prevError !== newError) {
if (!this.options.asyncAlways) {
return this.state.meta.errors
}
}
// No error? Attempt async validation
return this.validateAsync(value, cause)
}
handleChange = (updater: Updater<TData>) => {
this.setValue(updater, { touch: true })
}
handleBlur = () => {
const prevTouched = this.state.meta.isTouched
if (!prevTouched) {
this.setMeta((prev) => ({ ...prev, isTouched: true }))
this.validate('change')
}
this.validate('blur')
}
}
function normalizeError(rawError?: ValidationError) {
if (rawError) {
if (typeof rawError !== 'string') {
return 'Invalid Form Values'
}
return rawError
}
return undefined
}
function getErrorMapKey(cause: ValidationCause) {
switch (cause) {
case 'submit':
return 'onSubmit'
case 'change':
return 'onChange'
case 'blur':
return 'onBlur'
case 'mount':
return 'onMount'
}
}