fix: array-field mode and utilities

This commit is contained in:
Tanner Linsley
2023-05-03 15:16:56 -07:00
parent ab651b4c2b
commit 62a82fe48f
12 changed files with 265 additions and 124 deletions

View File

@@ -138,7 +138,7 @@ A class representing the Form API. It handles the logic and interactions with th
``` ```
- Inserts a value into an array field at the specified index. - Inserts a value into an array field at the specified index.
- ```tsx - ```tsx
spliceFieldValue<TField extends DeepKeys<TFormData>>(field: TField, index: number, opts?: { touch?: boolean }) removeFieldValue<TField extends DeepKeys<TFormData>>(field: TField, index: number, opts?: { touch?: boolean })
``` ```
- Removes a value from an array field at the specified index. - Removes a value from an array field at the specified index.
- ```tsx - ```tsx

View File

@@ -11,6 +11,7 @@ type Person = {
type Hobby = { type Hobby = {
name: string; name: string;
description: string; description: string;
yearsOfExperience: number;
}; };
const formFactory = createFormFactory<Person>({ const formFactory = createFormFactory<Person>({
@@ -61,8 +62,7 @@ export default function App() {
children={(field) => ( children={(field) => (
// Avoid hasty abstractions. Render props are great! // Avoid hasty abstractions. Render props are great!
<> <>
<label htmlFor={field.name}>First Name:</label> <input {...field.getInputProps()} />
<input name={field.name} {...field.getInputProps()} />
<FieldInfo field={field} /> <FieldInfo field={field} />
</> </>
)} )}
@@ -73,8 +73,7 @@ export default function App() {
name="lastName" name="lastName"
children={(field) => ( children={(field) => (
<> <>
<label htmlFor={field.name}>Last Name:</label> <input {...field.getInputProps()} />
<input name={field.name} {...field.getInputProps()} />
<FieldInfo field={field} /> <FieldInfo field={field} />
</> </>
)} )}
@@ -83,12 +82,84 @@ export default function App() {
<div> <div>
<form.Field <form.Field
name="hobbies" name="hobbies"
children={(field) => ( mode="array"
<> children={(hobbiesField) => (
<label htmlFor={field.name}>Last Name:</label> <div>
<input name={field.name} {...field.getInputProps()} /> Hobbies
<FieldInfo field={field} /> <div
</> style={{
paddingLeft: "1rem",
display: "flex",
flexDirection: "column",
gap: "1rem",
}}
>
{!hobbiesField.state.value.length
? "No hobbies found."
: hobbiesField.state.value.map((value, i) => (
<div
key={i}
style={{
borderLeft: "2px solid gray",
paddingLeft: ".5rem",
}}
>
<hobbiesField.Field
index={i}
name="name"
children={(field) => {
return (
<div>
<label htmlFor={field.name}>Name:</label>
<input
name={field.name}
{...field.getInputProps()}
/>
<button
type="button"
onClick={() => hobbiesField.removeValue(i)}
>
X
</button>
<FieldInfo field={field} />
</div>
);
}}
/>
<hobbiesField.Field
index={i}
name="description"
children={(field) => {
return (
<div>
<label htmlFor={field.name}>
Description:
</label>
<input
name={field.name}
{...field.getInputProps()}
/>
<FieldInfo field={field} />
</div>
);
}}
/>
</div>
))}
</div>
<button
type="button"
onClick={() =>
hobbiesField.pushValue({
name: "",
description: "",
yearsOfExperience: 0,
})
}
>
Add hobby
</button>
</div>
)} )}
/> />
</div> </div>

View File

@@ -7,6 +7,7 @@ export type ValidationCause = 'change' | 'blur' | 'submit'
export interface FieldOptions<TData, TFormData> { export interface FieldOptions<TData, TFormData> {
name: unknown extends TFormData ? string : DeepKeys<TFormData> name: unknown extends TFormData ? string : DeepKeys<TFormData>
index?: TData extends any[] ? number : never
defaultValue?: TData defaultValue?: TData
validate?: ( validate?: (
value: TData, value: TData,
@@ -84,12 +85,12 @@ export class FieldApi<TData, TFormData> {
this.form = opts.form this.form = opts.form
this.uid = uid++ this.uid = uid++
// Support field prefixing from FieldScope // Support field prefixing from FieldScope
let fieldPrefix = '' // let fieldPrefix = ''
if (this.form.fieldName) { // if (this.form.fieldName) {
fieldPrefix = `${this.form.fieldName}.` // fieldPrefix = `${this.form.fieldName}.`
} // }
this.name = (fieldPrefix + opts.name) as any this.name = opts.name as any
this.store = new Store<FieldState<TData>>( this.store = new Store<FieldState<TData>>(
{ {
@@ -113,6 +114,7 @@ export class FieldApi<TData, TFormData> {
if (next.value !== prevState.value) { if (next.value !== prevState.value) {
this.validate('change', next.value) this.validate('change', next.value)
} }
console.log(this)
}, },
}, },
) )
@@ -178,7 +180,9 @@ export class FieldApi<TData, TFormData> {
} }
} }
getValue = (): TData => this.form.getFieldValue(this.name) getValue = (): TData => {
return this.form.getFieldValue(this.name)
}
setValue = ( setValue = (
updater: Updater<TData>, updater: Updater<TData>,
options?: { touch?: boolean; notify?: boolean }, options?: { touch?: boolean; notify?: boolean },
@@ -190,11 +194,11 @@ export class FieldApi<TData, TFormData> {
getInfo = () => this.form.getFieldInfo(this.name) getInfo = () => this.form.getFieldInfo(this.name)
pushValue = (value: TData) => pushValue = (value: TData extends any[] ? TData[number] : never) =>
this.form.pushFieldValue(this.name, value as any) this.form.pushFieldValue(this.name, value as any)
insertValue = (index: number, value: TData) => insertValue = (index: number, value: TData) =>
this.form.insertFieldValue(this.name, index, value as any) this.form.insertFieldValue(this.name, index, value as any)
removeValue = (index: number) => this.form.spliceFieldValue(this.name, index) removeValue = (index: number) => this.form.removeFieldValue(this.name, index)
swapValues = (aIndex: number, bIndex: number) => swapValues = (aIndex: number, bIndex: number) =>
this.form.swapFieldValues(this.name, aIndex, bIndex) this.form.swapFieldValues(this.name, aIndex, bIndex)

View File

@@ -138,6 +138,7 @@ export class FormApi<TFormData> {
// Write it back to the store // Write it back to the store
this.store.state = next this.store.state = next
this.state = next this.state = next
console.log(this.state)
}, },
}, },
) )
@@ -402,7 +403,7 @@ export class FormApi<TFormData> {
) )
} }
spliceFieldValue = <TField extends DeepKeys<TFormData>>( removeFieldValue = <TField extends DeepKeys<TFormData>>(
field: TField, field: TField,
index: number, index: number,
opts?: { touch?: boolean }, opts?: { touch?: boolean },

View File

@@ -130,7 +130,7 @@ export type DeepKeys<T> = unknown extends T
: T extends readonly any[] & IsTuple<T> : T extends readonly any[] & IsTuple<T>
? AllowedIndexes<T> | DeepKeysPrefix<T, AllowedIndexes<T>> ? AllowedIndexes<T> | DeepKeysPrefix<T, AllowedIndexes<T>>
: T extends any[] : T extends any[]
? never & 'Dynamic length array indexing is not supported' ? DeepKeys<T[number]>
: T extends Date : T extends Date
? never ? never
: T extends object : T extends object
@@ -146,3 +146,18 @@ export type DeepValue<T, TProp> = T extends Record<string | number, any>
? DeepValue<T[TBranch], TDeepProp> ? DeepValue<T[TBranch], TDeepProp>
: T[TProp & string] : T[TProp & string]
: never : never
type Narrowable = string | number | bigint | boolean
type NarrowRaw<A> =
| (A extends [] ? [] : never)
| (A extends Narrowable ? A : never)
| {
[K in keyof A]: A[K] extends Function ? A[K] : NarrowRaw<A[K]>
}
export type Narrow<A extends any> = Try<A, [], NarrowRaw<A>>
type Try<A1 extends any, A2 extends any, Catch = never> = A1 extends A2
? A1
: Catch

View File

@@ -1,36 +0,0 @@
import * as React from 'react'
import {
functionalUpdate,
type DeepKeys,
type DeepValue,
type FieldApi,
type FieldOptions,
} from '@tanstack/form-core'
import { useField } from './useField'
//
export type FieldComponent<TFormData> = <TField extends DeepKeys<TFormData>>({
children,
...fieldOptions
}: {
children: (fieldApi: FieldApi<DeepValue<TFormData, TField>, TFormData>) => any
name: TField
} & Omit<FieldOptions<DeepValue<TFormData, TField>, TFormData>, 'name'>) => any
export function createFieldComponent<TFormData>() {
const ConnectedField: FieldComponent<TFormData> = (props) => (
<Field {...(props as any)} />
)
return ConnectedField
}
export function Field<TData, TFormData>({
children,
...fieldOptions
}: {
children: (fieldApi: FieldApi<TData, TFormData>) => any
} & FieldOptions<TData, TFormData>) {
const fieldApi = useField(fieldOptions as any)
return functionalUpdate(children, fieldApi as any)
}

View File

@@ -1,12 +1,11 @@
import type { FormApi, FormOptions } from '@tanstack/form-core' import type { FormApi, FormOptions } from '@tanstack/form-core'
import { createUseField, type UseField } from './useField' import { type UseField, type FieldComponent, Field, useField } from './useField'
import { useForm } from './useForm' import { useForm } from './useForm'
import { createFieldComponent, type FieldComponent } from './Field'
export type FormFactory<TFormData> = { export type FormFactory<TFormData> = {
useForm: (opts?: FormOptions<TFormData>) => FormApi<TFormData> useForm: (opts?: FormOptions<TFormData>) => FormApi<TFormData>
useField: UseField<TFormData> useField: UseField<TFormData>
Field: FieldComponent<TFormData> Field: FieldComponent<TFormData, TFormData>
} }
export function createFormFactory<TFormData>( export function createFormFactory<TFormData>(
@@ -16,7 +15,7 @@ export function createFormFactory<TFormData>(
useForm: (opts) => { useForm: (opts) => {
return useForm<TFormData>({ ...defaultOpts, ...opts } as any) as any return useForm<TFormData>({ ...defaultOpts, ...opts } as any) as any
}, },
useField: createUseField<TFormData>(), useField: useField as any,
Field: createFieldComponent<TFormData>(), Field: Field as any,
} }
} }

View File

@@ -1,7 +1,10 @@
import type { FormApi } from '@tanstack/form-core' import type { FormApi } from '@tanstack/form-core'
import * as React from 'react' import * as React from 'react'
export const formContext = React.createContext<FormApi<any> | null>(null) export const formContext = React.createContext<{
formApi: FormApi<any>
parentFieldName?: string
} | null>(null!)
export function useFormContext() { export function useFormContext() {
const formApi = React.useContext(formContext) const formApi = React.useContext(formContext)

View File

@@ -25,11 +25,8 @@ export { FormApi, FieldApi, functionalUpdate } from '@tanstack/form-core'
export type { FormComponent, FormProps } from './useForm' export type { FormComponent, FormProps } from './useForm'
export { useForm } from './useForm' export { useForm } from './useForm'
export type { FieldComponent } from './Field' export type { UseField, FieldComponent } from './useField'
export { Field } from './Field' export { useField, Field } from './useField'
export type { UseField } from './useField'
export { useField } from './useField'
export type { FormFactory } from './createFormFactory' export type { FormFactory } from './createFormFactory'
export { createFormFactory } from './createFormFactory' export { createFormFactory } from './createFormFactory'

View File

@@ -1,50 +0,0 @@
import * as React from 'react'
//
import { useStore } from '@tanstack/react-store'
import type { DeepKeys, DeepValue, FieldOptions } from '@tanstack/form-core'
import { FieldApi } from '@tanstack/form-core'
import { useFormContext } from './formContext'
import type { FormFactory } from './createFormFactory'
declare module '@tanstack/form-core' {
// eslint-disable-next-line no-shadow
interface FieldOptions<TData, TFormData> {
formFactory?: FormFactory<TFormData>
}
}
export type UseField<TFormData> = <TField extends DeepKeys<TFormData>>(
opts?: { name: TField } & FieldOptions<
DeepValue<TFormData, TField>,
TFormData
>,
) => FieldApi<DeepValue<TFormData, TField>, TFormData>
export function createUseField<TFormData>(): UseField<TFormData> {
return (opts) => {
return useField(opts as any)
}
}
export function useField<TData, TFormData>(
opts: FieldOptions<TData, TFormData> & {
// selector: (state: FieldApi<TData, TFormData>) => TSelected
},
): FieldApi<TData, TFormData> {
// Get the form API either manually or from context
const formApi = useFormContext()
const [fieldApi] = React.useState<FieldApi<TData, TFormData>>(
() => new FieldApi({ ...opts, form: formApi }),
)
// Keep options up to date as they are rendered
fieldApi.update({ ...opts, form: formApi })
useStore(fieldApi.store)
// Instantiates field meta and removes it when unrendered
React.useEffect(() => fieldApi.mount(), [fieldApi])
return fieldApi
}

View File

@@ -0,0 +1,138 @@
import * as React from 'react'
//
import { useStore } from '@tanstack/react-store'
import type {
DeepKeys,
DeepValue,
FieldOptions,
Narrow,
} from '@tanstack/form-core'
import { FieldApi, functionalUpdate } from '@tanstack/form-core'
import { useFormContext, formContext } from './formContext'
declare module '@tanstack/form-core' {
// eslint-disable-next-line no-shadow
interface FieldApi<TData, TFormData> {
Field: FieldComponent<TData, TFormData>
}
}
export type UseFieldOptions<TData, TFormData> = FieldOptions<
TData,
TFormData
> & {
mode?: 'value' | 'array'
}
export type UseField<TFormData> = <TField extends DeepKeys<TFormData>>(
opts?: { name: Narrow<TField> } & UseFieldOptions<
DeepValue<TFormData, TField>,
TFormData
>,
) => FieldApi<DeepValue<TFormData, TField>, TFormData>
export function useField<TData, TFormData>(
opts: UseFieldOptions<TData, TFormData>,
): FieldApi<TData, TFormData> {
// Get the form API either manually or from context
const { formApi, parentFieldName } = useFormContext()
const [fieldApi] = React.useState<FieldApi<TData, TFormData>>(() => {
const name = (
typeof opts.index === 'number'
? [parentFieldName, opts.index, opts.name]
: [parentFieldName, opts.name]
)
.filter((d) => d !== undefined)
.join('.')
const api = new FieldApi({ ...opts, form: formApi, name: name as any })
api.Field = Field as any
return api
})
// Keep options up to date as they are rendered
fieldApi.update({ ...opts, form: formApi })
useStore(
fieldApi.store,
opts.mode === 'array'
? (state: any) => {
return [state.meta, Object.keys(state.value || []).length]
}
: undefined,
)
// Instantiates field meta and removes it when unrendered
React.useEffect(() => fieldApi.mount(), [fieldApi])
return fieldApi
}
// export type FieldValue<TFormData, TField> = TFormData extends any[]
// ? TField extends `[${infer TIndex extends number | 'i'}].${infer TRest}`
// ? DeepValue<TFormData[TIndex extends 'i' ? number : TIndex], TRest>
// : TField extends `[${infer TIndex extends number | 'i'}]`
// ? TFormData[TIndex extends 'i' ? number : TIndex]
// : never
// : TField extends `${infer TPrefix}[${infer TIndex extends
// | number
// | 'i'}].${infer TRest}`
// ? DeepValue<
// DeepValue<TFormData, TPrefix>[TIndex extends 'i' ? number : TIndex],
// TRest
// >
// : TField extends `${infer TPrefix}[${infer TIndex extends number | 'i'}]`
// ? DeepValue<TFormData, TPrefix>[TIndex extends 'i' ? number : TIndex]
// : DeepValue<TFormData, TField>
export type FieldValue<TFormData, TField> = TFormData extends any[]
? unknown extends TField
? TFormData[number]
: DeepValue<TFormData[number], TField>
: DeepValue<TFormData, TField>
// type Test1 = FieldValue<{ foo: { bar: string }[] }, 'foo'>
// // ^?
// type Test2 = FieldValue<{ foo: { bar: string }[] }, 'foo[i]'>
// // ^?
// type Test3 = FieldValue<{ foo: { bar: string }[] }, 'foo[2].bar'>
// // ^?
export type FieldComponent<TParentData, TFormData> = <TField>({
children,
...fieldOptions
}: {
children: (
fieldApi: FieldApi<FieldValue<TParentData, TField>, TFormData>,
) => any
} & Omit<
UseFieldOptions<FieldValue<TParentData, TField>, TFormData>,
'name' | 'index'
> &
(TParentData extends any[]
? {
name?: TField extends undefined ? TField : DeepKeys<TParentData>
index: number
}
: {
name: TField extends undefined ? TField : DeepKeys<TParentData>
index?: never
})) => any
export function Field<TData, TFormData>({
children,
...fieldOptions
}: {
children: (fieldApi: FieldApi<TData, TFormData>) => any
} & UseFieldOptions<TData, TFormData>) {
const fieldApi = useField(fieldOptions as any)
return (
<formContext.Provider
value={{ formApi: fieldApi.form, parentFieldName: fieldApi.name }}
children={functionalUpdate(children, fieldApi as any)}
/>
)
}

View File

@@ -3,8 +3,7 @@ import { FormApi, functionalUpdate } from '@tanstack/form-core'
import type { NoInfer } from '@tanstack/react-store' import type { NoInfer } from '@tanstack/react-store'
import { useStore } from '@tanstack/react-store' import { useStore } from '@tanstack/react-store'
import React from 'react' import React from 'react'
import { createFieldComponent, type FieldComponent } from './Field' import { type UseField, type FieldComponent, Field, useField } from './useField'
import { createUseField, type UseField } from './useField'
import { formContext } from './formContext' import { formContext } from './formContext'
declare module '@tanstack/form-core' { declare module '@tanstack/form-core' {
@@ -15,7 +14,7 @@ declare module '@tanstack/form-core' {
// eslint-disable-next-line no-shadow // eslint-disable-next-line no-shadow
interface FormApi<TFormData> { interface FormApi<TFormData> {
Form: FormComponent Form: FormComponent
Field: FieldComponent<TFormData> Field: FieldComponent<TFormData, TFormData>
useField: UseField<TFormData> useField: UseField<TFormData>
useStore: <TSelected = NoInfer<FormState<TFormData>>>( useStore: <TSelected = NoInfer<FormState<TFormData>>>(
selector?: (state: NoInfer<FormState<TFormData>>) => TSelected, selector?: (state: NoInfer<FormState<TFormData>>) => TSelected,
@@ -35,8 +34,8 @@ export function useForm<TData>(opts?: FormOptions<TData>): FormApi<TData> {
const api = new FormApi<TData>(opts) const api = new FormApi<TData>(opts)
api.Form = createFormComponent(api) api.Form = createFormComponent(api)
api.Field = createFieldComponent<TData>() api.Field = Field as any
api.useField = createUseField<TData>() api.useField = useField as any
api.useStore = ( api.useStore = (
// @ts-ignore // @ts-ignore
selector, selector,
@@ -73,7 +72,7 @@ function createFormComponent(formApi: FormApi<any>) {
const isSubmitting = formApi.useStore((state) => state.isSubmitting) const isSubmitting = formApi.useStore((state) => state.isSubmitting)
return ( return (
<formContext.Provider value={formApi}> <formContext.Provider value={{ formApi }}>
{noFormElement ? ( {noFormElement ? (
children children
) : ( ) : (