diff --git a/docs/framework/react/guides/form-composition.md b/docs/framework/react/guides/form-composition.md index d1de1ff86..d0c48ec7c 100644 --- a/docs/framework/react/guides/form-composition.md +++ b/docs/framework/react/guides/form-composition.md @@ -244,6 +244,162 @@ const ChildForm = withForm({ }) ``` +## Reusing groups of fields in multiple forms + +Sometimes, a pair of fields are so closely related that it makes sense to group and reuse them — like the password example listed in the [linked fields guide](./linked-fields.md). Instead of repeating this logic across multiple forms, you can utilize the `withFormLens` higher-order component. + +> Unlike `withForm`, validators cannot be specified and could be any value. +> Ensure that your fields can accept unknown error types. + +Rewriting the passwords example using `withFormLens` would look like this: + +```tsx +const { useAppForm, withForm, withFormLens } = createFormHook({ + fieldComponents: { + TextField, + ErrorInfo, + }, + formComponents: { + SubscribeButton, + }, + fieldContext, + formContext, +}) + +type PasswordFields = { + password: string + confirm_password: string +} + +// These values are only used for type-checking, and are not used at runtime +// This allows you to `...formOpts` from `formOptions` without needing to redeclare the options +const defaultValues: PasswordFields = { + password: '', + confirm_password: '', +} + +const PasswordFields = withFormLens({ + defaultValues, + // You may also restrict the lens to only use forms that implement this submit meta. + // If none is provided, any form with the right defaultValues may use it. + // onSubmitMeta: { action: '' } + + // Optional, but adds props to the `render` function in addition to `form` + props: { + // These default values are also for type-checking and are not used at runtime + title: 'Password', + }, + // Internally, you will have access to a `lens` instead of a `form` + render: function Render({ lens, title }) { + // access reactive values using the lens store + const password = useStore(lens.store, (state) => state.values.password) + const isSubmitting = useStore(lens.store, (state) => state.isSubmitting) + + return ( +
+

{title}

+ {/* Lenses also have access to Field, Subscribe, Field, AppField and AppForm */} + + {(field) => } + + { + // The form could be any values, so it is typed as 'unknown' + const values: unknown = fieldApi.form.state.values + // use the lens methods instead + if (value !== lens.getFieldValue('password')) { + return 'Passwords do not match' + } + return undefined + }, + }} + > + {(field) => ( +
+ + +
+ )} +
+
+ ) + }, +}) +``` + +We can now use these grouped fields in any form that implements the default values: + +```tsx +// You are allowed to extend the lens fields as long as the +// existing properties remain unchanged +type Account = PasswordFields & { + provider: string + username: string +} + +// You may nest the lens fields wherever you want +type FormValues = { + name: string + age: number + account_data: PasswordFields + linked_accounts: Account[] +} + +const defaultValues: FormValues = { + name: '', + age: 0, + account_data: { + password: '', + confirm_password: '', + }, + linked_accounts: [ + { + provider: 'TanStack', + username: '', + password: '', + confirm_password: '', + }, + ], +} + +function App() { + const form = useAppForm({ + defaultValues, + // If the lens didn't specify an `onSubmitMeta` property, + // the form may implement any meta it wants. + // Otherwise, the meta must be defined and match. + onSubmitMeta: { action: '' }, + }) + + return ( + + + + {(field) => + field.state.value.map((account, i) => ( + + )) + } + + + ) +} +``` + ## Tree-shaking form and field components While the above examples are great for getting started, they're not ideal for certain use-cases where you might have hundreds of form and field components. diff --git a/packages/form-core/src/FormApi.ts b/packages/form-core/src/FormApi.ts index 77174199c..18992a43c 100644 --- a/packages/form-core/src/FormApi.ts +++ b/packages/form-core/src/FormApi.ts @@ -315,6 +315,20 @@ export interface FormListeners< }) => void } +/** + * An object representing the base properties of a form, unrelated to any validators + */ +export interface BaseFormOptions { + /** + * Set initial values for your form. + */ + defaultValues?: TFormData + /** + * onSubmitMeta, the data passed from the handleSubmit handler, to the onSubmit function props + */ + onSubmitMeta?: TSubmitMeta +} + /** * An object representing the options for a form. */ @@ -329,11 +343,7 @@ export interface FormOptions< in out TOnSubmitAsync extends undefined | FormAsyncValidateOrFn, in out TOnServer extends undefined | FormAsyncValidateOrFn, in out TSubmitMeta = never, -> { - /** - * Set initial values for your form. - */ - defaultValues?: TFormData +> extends BaseFormOptions { /** * The default state for the form. */ @@ -376,11 +386,6 @@ export interface FormOptions< TOnSubmitAsync > - /** - * onSubmitMeta, the data passed from the handleSubmit handler, to the onSubmit function props - */ - onSubmitMeta?: TSubmitMeta - /** * form level listeners */ @@ -2136,12 +2141,9 @@ export class FormApi< ...prev.fieldMetaBase, [field]: defaultFieldMeta, }, - values: { - ...prev.values, - [field]: - this.options.defaultValues && - this.options.defaultValues[field as keyof TFormData], - }, + values: this.options.defaultValues + ? setBy(prev.values, field, getBy(this.options.defaultValues, field)) + : prev.values, } }) } diff --git a/packages/form-core/src/FormLensApi.ts b/packages/form-core/src/FormLensApi.ts new file mode 100644 index 000000000..231411f06 --- /dev/null +++ b/packages/form-core/src/FormLensApi.ts @@ -0,0 +1,758 @@ +import { Derived } from '@tanstack/store' +import { standardSchemaValidators } from './standardSchemaValidator' +import { defaultFieldMeta } from './metaHelper' +import type { Updater } from './utils' +import type { Store } from '@tanstack/store' +import type { + BaseFormState, + DerivedFormState, + FormApi, + FormAsyncValidateOrFn, + FormOptions, + FormValidateOrFn, +} from './FormApi' +import type { AnyFieldMeta, AnyFieldMetaBase } from './FieldApi' +import type { DeepKeys, DeepKeysOfType, DeepValue } from './util-types' +import type { + FormValidationErrorMap, + UpdateMetaOptions, + ValidationCause, + ValidationError, + ValidationErrorMap, +} from './types' +import type { StandardSchemaV1 } from './standardSchemaValidator' + +// What wasn't implemented from FormApi: +// - setErrorMap would require type-safe setting of the validators. Don't implement it. +// - resetFieldMeta would require you to pass the metas to the form instance. It's some work, but feel free to implement + +export type AnyFormLensApi = FormLensApi< + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any +> + +type BaseFormLensState< + TFormData, + TLensData, + TOnMount extends undefined | FormValidateOrFn, + TOnChange extends undefined | FormValidateOrFn, + TOnChangeAsync extends undefined | FormAsyncValidateOrFn, + TOnBlur extends undefined | FormValidateOrFn, + TOnBlurAsync extends undefined | FormAsyncValidateOrFn, + TOnSubmit extends undefined | FormValidateOrFn, + TOnSubmitAsync extends undefined | FormAsyncValidateOrFn, + TOnServer extends undefined | FormAsyncValidateOrFn, +> = Omit< + BaseFormState< + TFormData, + TOnMount, + TOnChange, + TOnChangeAsync, + TOnBlur, + TOnBlurAsync, + TOnSubmit, + TOnSubmitAsync, + TOnServer + >, + 'values' | 'errorMap' +> & { + /** + * The current values of the lens fields. + */ + values: TLensData + + formErrorMap: BaseFormState< + TFormData, + TOnMount, + TOnChange, + TOnChangeAsync, + TOnBlur, + TOnBlurAsync, + TOnSubmit, + TOnSubmitAsync, + TOnServer + >['errorMap'] + + lensErrorMap: ValidationErrorMap +} + +type DerivedFormLensState< + TFormData, + TLensData, + TOnMount extends undefined | FormValidateOrFn, + TOnChange extends undefined | FormValidateOrFn, + TOnChangeAsync extends undefined | FormAsyncValidateOrFn, + TOnBlur extends undefined | FormValidateOrFn, + TOnBlurAsync extends undefined | FormAsyncValidateOrFn, + TOnSubmit extends undefined | FormValidateOrFn, + TOnSubmitAsync extends undefined | FormAsyncValidateOrFn, + TOnServer extends undefined | FormAsyncValidateOrFn, +> = Omit< + DerivedFormState< + TFormData, + TOnMount, + TOnChange, + TOnChangeAsync, + TOnBlur, + TOnBlurAsync, + TOnSubmit, + TOnSubmitAsync, + TOnServer + >, + 'fieldMeta' | 'errors' +> & { + /** + * A record of field metadata for each field in the lens. + */ + fieldMeta: Record, AnyFieldMeta> + + formErrors: DerivedFormState< + TFormData, + TOnMount, + TOnChange, + TOnChangeAsync, + TOnBlur, + TOnBlurAsync, + TOnSubmit, + TOnSubmitAsync, + TOnServer + >['errors'] + + lensErrors: unknown[] +} + +export interface FormLensState< + in out TFormData, + in out TLensData, + in out TOnMount extends undefined | FormValidateOrFn, + in out TOnChange extends undefined | FormValidateOrFn, + in out TOnChangeAsync extends undefined | FormAsyncValidateOrFn, + in out TOnBlur extends undefined | FormValidateOrFn, + in out TOnBlurAsync extends undefined | FormAsyncValidateOrFn, + in out TOnSubmit extends undefined | FormValidateOrFn, + in out TOnSubmitAsync extends undefined | FormAsyncValidateOrFn, + in out TOnServer extends undefined | FormAsyncValidateOrFn, +> extends BaseFormLensState< + TFormData, + TLensData, + TOnMount, + TOnChange, + TOnChangeAsync, + TOnBlur, + TOnBlurAsync, + TOnSubmit, + TOnSubmitAsync, + TOnServer + >, + DerivedFormLensState< + TFormData, + TLensData, + TOnMount, + TOnChange, + TOnChangeAsync, + TOnBlur, + TOnBlurAsync, + TOnSubmit, + TOnSubmitAsync, + TOnServer + > {} + +export type AnyFormLensState = FormLensState< + any, + any, + any, + any, + any, + any, + any, + any, + any, + any +> + +/** + * An object representing the options for a form. + */ +export interface FormLensOptions< + in out TLensData, + in out TFormData, + in out TName extends DeepKeysOfType, + in out TOnMount extends undefined | FormValidateOrFn, + in out TOnChange extends undefined | FormValidateOrFn, + in out TOnChangeAsync extends undefined | FormAsyncValidateOrFn, + in out TOnBlur extends undefined | FormValidateOrFn, + in out TOnBlurAsync extends undefined | FormAsyncValidateOrFn, + in out TOnSubmit extends undefined | FormValidateOrFn, + in out TOnSubmitAsync extends undefined | FormAsyncValidateOrFn, + in out TOnServer extends undefined | FormAsyncValidateOrFn, + in out TSubmitMeta = never, +> { + form: + | FormApi< + TFormData, + TOnMount, + TOnChange, + TOnChangeAsync, + TOnBlur, + TOnBlurAsync, + TOnSubmit, + TOnSubmitAsync, + TOnServer, + TSubmitMeta + > + | FormLensApi< + any, + TFormData, + any, + any, + any, + any, + any, + any, + any, + any, + any, + TSubmitMeta + > + /** + * The path to the lens data. + */ + name: TName + /** + * The expected subsetValues that the form must provide. + */ + defaultValues?: TLensData + /** + * onSubmitMeta, the data passed from the handleSubmit handler, to the onSubmit function props + */ + onSubmitMeta?: TSubmitMeta +} + +export class FormLensApi< + in out TFormData, + in out TLensData, + in out TName extends DeepKeysOfType, + in out TOnMount extends undefined | FormValidateOrFn, + in out TOnChange extends undefined | FormValidateOrFn, + in out TOnChangeAsync extends undefined | FormAsyncValidateOrFn, + in out TOnBlur extends undefined | FormValidateOrFn, + in out TOnBlurAsync extends undefined | FormAsyncValidateOrFn, + in out TOnSubmit extends undefined | FormValidateOrFn, + in out TOnSubmitAsync extends undefined | FormAsyncValidateOrFn, + in out TOnServer extends undefined | FormAsyncValidateOrFn, + in out TSubmitMeta = never, +> { + /** + * The form that called this lens. + */ + readonly form: FormApi< + TFormData, + TOnMount, + TOnChange, + TOnChangeAsync, + TOnBlur, + TOnBlurAsync, + TOnSubmit, + TOnSubmitAsync, + TOnServer, + TSubmitMeta + > + + private readonly lensPrefix: TName + + /** + * Get the true name of the field. Not required within `Field` or `AppField`. + * @private + */ + getFormFieldName = >( + subfield: TField, + ): DeepKeys => { + // TODO find better solution to chaining names. This is prone to breaking if the syntax ever changes + + // If lensData is an array at top level, the name + // would be [i].name + // this would be incompatible with the form as it's expected + // to receive lens.prefix[i].name and NOT lens.prefix.[i].name + if (subfield.charAt(0) === '[') { + return this.lensPrefix + subfield + } + return `${this.lensPrefix}.${subfield}` + } + + /** + * The name of this form lens. + */ + get name(): string { + return this.lensPrefix + } + + /** + * The options for the form. + */ + get options(): FormOptions< + TFormData, + TOnMount, + TOnChange, + TOnChangeAsync, + TOnBlur, + TOnBlurAsync, + TOnSubmit, + TOnSubmitAsync, + TOnServer, + TSubmitMeta + > { + return this.form.options + } + + get baseStore(): Store< + BaseFormState< + TFormData, + TOnMount, + TOnChange, + TOnChangeAsync, + TOnBlur, + TOnBlurAsync, + TOnSubmit, + TOnSubmitAsync, + TOnServer + > + > { + return this.form.baseStore + } + + get fieldMetaDerived(): Derived> { + return this.form.fieldMetaDerived + } + + store: Derived< + FormLensState< + TFormData, + TLensData, + TOnMount, + TOnChange, + TOnChangeAsync, + TOnBlur, + TOnBlurAsync, + TOnSubmit, + TOnSubmitAsync, + TOnServer + > + > + + // fieldInfo doesn't exist as it would require to know the path beforehand + + get state() { + return this.store.state + } + + /** + * Constructs a new `FormLensApi` instance with the given form options. + */ + constructor( + opts: FormLensOptions< + TLensData, + TFormData, + TName, + TOnMount, + TOnChange, + TOnChangeAsync, + TOnBlur, + TOnBlurAsync, + TOnSubmit, + TOnSubmitAsync, + TOnServer, + TSubmitMeta + >, + ) { + if (opts.form instanceof FormLensApi) { + this.form = opts.form.form as never + this.lensPrefix = opts.form.getFormFieldName(opts.name) as TName + } else { + this.form = opts.form + this.lensPrefix = opts.name + } + + this.store = new Derived({ + deps: [this.form.store], + fn: ({ currDepVals }) => { + const currFormStore = currDepVals[0] + const { + errors: formErrors, + errorMap: formErrorMap, + values: _, + ...rest + } = currFormStore + + const { errorMap: lensErrorMap, errors: lensErrors } = + this.form.getFieldMeta(this.name) ?? { + ...defaultFieldMeta, + } + + return { + ...rest, + lensErrorMap, + lensErrors, + formErrorMap, + formErrors, + values: this.form.getFieldValue(this.name) as TLensData, + } + }, + }) + + this.validateAllFields = this.form.validateAllFields.bind(this.form) + this.update = this.form.update.bind(this.form) + this.reset = this.form.reset.bind(this.form) + } + + /** + * Updates the form options and form state. + */ + update: ( + options?: FormOptions< + TFormData, + TOnMount, + TOnChange, + TOnChangeAsync, + TOnBlur, + TOnBlurAsync, + TOnSubmit, + TOnSubmitAsync, + TOnServer, + TSubmitMeta + >, + ) => void + + /** + * Mounts the lens instance to the form. + */ + mount = () => { + const cleanup = this.store.mount() + + return cleanup + } + + /** + * Resets the form state to the default values. + * Values cannot be provided as the parent data is not known. + */ + reset: () => void + + /** + * Validates all fields using the correct handlers for a given validation cause. + */ + validateAllFields: (cause: ValidationCause) => Promise + + /** + * Validates the children of a specified array in the form starting from a given index until the end using the correct handlers for a given validation type. + */ + validateArrayFieldsStartingFrom = async < + TField extends DeepKeysOfType, + >( + field: TField, + index: number, + cause: ValidationCause, + ) => { + this.form.validateArrayFieldsStartingFrom( + this.getFormFieldName(field), + index, + cause, + ) + } + + /** + * Validates a specified field in the form using the correct handlers for a given validation type. + */ + validateField = >( + field: TField, + cause: ValidationCause, + ) => { + this.form.validateField(this.getFormFieldName(field), cause) + } + + /** + * Handles the form submission, performs validation, and calls the appropriate onSubmit or onSubmitInvalid callbacks. + */ + handleSubmit(): Promise + handleSubmit(submitMeta: TSubmitMeta): Promise + async handleSubmit(submitMeta?: TSubmitMeta): Promise { + // cast is required since the implementation isn't one of the two overloads + return this.form.handleSubmit(submitMeta as any) + } + + /** + * Gets the value of the specified field. + */ + getFieldValue = >( + field: TField, + ): DeepValue => { + return this.form.getFieldValue(this.getFormFieldName(field)) as DeepValue< + TLensData, + TField + > + } + + /** + * Gets the metadata of the specified field. + */ + getFieldMeta = >(field: TField) => { + return this.form.getFieldMeta(this.getFormFieldName(field)) + } + + /** + * Gets the field info of the specified field. + */ + getFieldInfo = >(field: TField) => { + return this.form.getFieldInfo(this.getFormFieldName(field)) + } + + /** + * Updates the metadata of the specified field. + */ + setFieldMeta = >( + field: TField, + updater: Updater, + ) => { + return this.form.setFieldMeta(this.getFormFieldName(field), updater) + } + + /** + * Sets the value of the specified field and optionally updates the touched state. + */ + setFieldValue = >( + field: TField, + updater: Updater>, + opts?: UpdateMetaOptions, + ) => { + return this.form.setFieldValue( + this.getFormFieldName(field) as never, + updater as never, + opts, + ) + } + + /** + * Delete a field and its subfields. + */ + deleteField = >(field: TField) => { + return this.form.deleteField(this.getFormFieldName(field)) + } + + /** + * Pushes a value into an array field. + */ + pushFieldValue = >( + field: TField, + value: DeepValue extends any[] + ? DeepValue[number] + : never, + opts?: UpdateMetaOptions, + ) => { + return this.form.pushFieldValue( + this.getFormFieldName(field), + // since unknown doesn't extend an array, it types `value` as never. + value as never, + opts, + ) + } + + /** + * Insert a value into an array field at the specified index. + */ + insertFieldValue = async >( + field: TField, + index: number, + value: DeepValue extends any[] + ? DeepValue[number] + : never, + opts?: UpdateMetaOptions, + ) => { + return this.form.insertFieldValue( + this.getFormFieldName(field), + index, + // since unknown doesn't extend an array, it types `value` as never. + value as never, + opts, + ) + } + + /** + * Replaces a value into an array field at the specified index. + */ + replaceFieldValue = async >( + field: TField, + index: number, + value: DeepValue extends any[] + ? DeepValue[number] + : never, + opts?: UpdateMetaOptions, + ) => { + return this.form.replaceFieldValue( + this.getFormFieldName(field), + index, + // since unknown doesn't extend an array, it types `value` as never. + value as never, + opts, + ) + } + + /** + * Removes a value from an array field at the specified index. + */ + removeFieldValue = async >( + field: TField, + index: number, + opts?: UpdateMetaOptions, + ) => { + return this.form.removeFieldValue(this.getFormFieldName(field), index, opts) + } + + /** + * Swaps the values at the specified indices within an array field. + */ + swapFieldValues = >( + field: TField, + index1: number, + index2: number, + opts?: UpdateMetaOptions, + ) => { + return this.form.swapFieldValues( + this.getFormFieldName(field), + index1, + index2, + opts, + ) + } + + /** + * Moves the value at the first specified index to the second specified index within an array field. + */ + moveFieldValues = >( + field: TField, + index1: number, + index2: number, + opts?: UpdateMetaOptions, + ) => { + return this.form.moveFieldValues( + this.getFormFieldName(field), + index1, + index2, + opts, + ) + } + + /** + * Resets the field value and meta to default state + */ + resetField = >(field: TField) => { + return this.form.resetField(this.getFormFieldName(field)) + } + + /** + * Returns form, lens and field level errors + */ + getAllErrors = (): { + form: { + errors: unknown[] + errorMap: FormValidationErrorMap + } + lens: { + errors: ValidationError[] + errorMap: FormValidationErrorMap + } + fields: Record< + DeepKeys, + { errors: ValidationError[]; errorMap: ValidationErrorMap } + > + } => { + const allErrors = this.form.getAllErrors() + return { + form: allErrors.form, + ...Object.entries( + allErrors.fields as Record< + string, + { + errors: ValidationError[] + errorMap: ValidationErrorMap + } + >, + ).reduce<{ + lens: { + errors: ValidationError[] + errorMap: FormValidationErrorMap + } + fields: Record< + DeepKeys, + { errors: ValidationError[]; errorMap: ValidationErrorMap } + > + }>( + (data, [fieldName, errorData]) => { + // the field is unrelated to this lens + if (!fieldName.startsWith(this.lensPrefix)) { + return data + } + // the field error is at the top level of the lens + if (fieldName === this.lensPrefix) { + data.lens = errorData + return data + } + // the field error is a subfield of the lens + let newFieldName = fieldName.replace( + this.lensPrefix, + '', + ) as DeepKeys + if (newFieldName.startsWith('.')) { + newFieldName = newFieldName.slice(1) + } + data.fields[newFieldName] = errorData + return data + }, + { + lens: { + errors: [], + errorMap: {}, + }, + fields: {} as Record< + DeepKeys, + { errors: ValidationError[]; errorMap: ValidationErrorMap } + >, + }, + ), + } + } + + /** + * Parses the lens values with a given standard schema and returns + * issues (if any). This method does NOT set any internal errors. + * @param schema The standard schema to parse the lens values with. + */ + parseValuesWithSchema = (schema: StandardSchemaV1) => { + return standardSchemaValidators.validate( + { value: this.state.values, validationSource: 'field' }, + schema, + ) + } + + /** + * Parses the lens values with a given standard schema and returns + * issues (if any). This method does NOT set any internal errors. + * @param schema The standard schema to parse the lens values with. + */ + parseValuesWithSchemaAsync = ( + schema: StandardSchemaV1, + ) => { + return standardSchemaValidators.validateAsync( + { value: this.state.values, validationSource: 'field' }, + schema, + ) + } +} diff --git a/packages/form-core/src/index.ts b/packages/form-core/src/index.ts index e74499d9d..6568b3bcb 100644 --- a/packages/form-core/src/index.ts +++ b/packages/form-core/src/index.ts @@ -6,3 +6,4 @@ export * from './types' export * from './mergeForm' export * from './formOptions' export * from './standardSchemaValidator' +export * from './FormLensApi' diff --git a/packages/form-core/tests/FormApi.spec.ts b/packages/form-core/tests/FormApi.spec.ts index d824bb6a8..0dedb9c03 100644 --- a/packages/form-core/tests/FormApi.spec.ts +++ b/packages/form-core/tests/FormApi.spec.ts @@ -3639,3 +3639,59 @@ it('should mark sourceMap as undefined when async field error is resolved', asyn expect(field.getMeta().errorSourceMap.onChange).toBeUndefined() }) + +it('should reset nested fields', () => { + const defaultValues = { + shallow: '', + nested: { + field: { + name: '', + }, + }, + } + + const form = new FormApi({ + defaultValues, + }) + form.mount() + + form.setFieldValue('shallow', 'Shallow') + form.setFieldValue('nested.field.name', 'Nested') + + expect(form.state.values.shallow).toEqual('Shallow') + expect(form.state.values.nested.field.name).toEqual('Nested') + + form.resetField('shallow') + expect(form.state.values.shallow).toEqual('') + + form.resetField('nested.field.name') + expect(form.state.values.nested.field.name).toEqual('') +}) + +it('should preserve nested fields on resetField if defaultValues is not provided', () => { + const state = { + shallow: '', + nested: { + field: { + name: '', + }, + }, + } + + const form = new FormApi({ + defaultState: { values: state }, + }) + form.mount() + + form.setFieldValue('shallow', 'Shallow') + form.setFieldValue('nested.field.name', 'Nested') + + expect(form.state.values.shallow).toEqual('Shallow') + expect(form.state.values.nested.field.name).toEqual('Nested') + + form.resetField('shallow') + expect(form.state.values.shallow).toEqual('Shallow') + + form.resetField('nested.field.name') + expect(form.state.values.nested.field.name).toEqual('Nested') +}) diff --git a/packages/form-core/tests/FormLensApi.spec.ts b/packages/form-core/tests/FormLensApi.spec.ts new file mode 100644 index 000000000..cd0d049d6 --- /dev/null +++ b/packages/form-core/tests/FormLensApi.spec.ts @@ -0,0 +1,1166 @@ +import { describe, expect, it, vi } from 'vitest' +import { z } from 'zod' +import { FieldApi, FormApi, FormLensApi } from '../src/index' +import { defaultFieldMeta } from '../src/metaHelper' + +describe('form lens api', () => { + type Person = { + name: string + age: number + } + type FormValues = { + people: Person[] + name: string + age: number + relatives: { + father: Person + } + } + + it('should inherit defaultValues from the form', () => { + const defaultValues: FormValues = { + name: 'Do not access', + age: -1, + people: [ + { + name: 'Lens one', + age: 1, + }, + { + name: 'Lens two', + age: 2, + }, + ], + relatives: { + father: { + name: 'Lens three', + age: 3, + }, + }, + } + + const form = new FormApi({ + defaultValues, + }) + form.mount() + + const lens1 = new FormLensApi({ + form, + name: 'people[0]', + defaultValues: {} as Person, + }) + const lens2 = new FormLensApi({ + form, + name: 'people[1]', + defaultValues: {} as Person, + }) + const lens3 = new FormLensApi({ + form, + name: 'relatives.father', + defaultValues: {} as Person, + }) + lens1.mount() + lens2.mount() + lens3.mount() + + expect(lens1.state).toMatchObject({ + values: { + name: 'Lens one', + age: 1, + }, + }) + expect(lens2.state).toMatchObject({ + values: { + name: 'Lens two', + age: 2, + }, + }) + expect(lens3.state).toMatchObject({ + values: { + name: 'Lens three', + age: 3, + }, + }) + }) + + it('should inherit the name from the constructor', () => { + const defaultValues: FormValues = { + name: 'Do not access', + age: -1, + people: [], + relatives: { + father: { + name: 'father', + age: 10, + }, + }, + } + + const form = new FormApi({ + defaultValues, + }) + form.mount() + + const lens = new FormLensApi({ + form, + defaultValues: {} as Person, + name: 'relatives.father', + }) + lens.mount() + + expect(lens.name).toEqual('relatives.father') + }) + + it("should expose the form's base properties", () => { + const defaultValues: FormValues = { + name: 'Do not access', + age: -1, + people: [], + relatives: { + father: { + name: 'father', + age: 10, + }, + }, + } + + const form = new FormApi({ + defaultValues, + }) + form.mount() + + const lens = new FormLensApi({ + form, + defaultValues: {} as Person, + name: 'relatives.father', + }) + lens.mount() + + expect(lens.baseStore).toStrictEqual(form.baseStore) + expect(lens.options).toStrictEqual(form.options) + expect(lens.fieldMetaDerived).toStrictEqual(form.fieldMetaDerived) + }) + + it('should have the state synced with the form', () => { + const defaultValues: FormValues = { + name: 'Do not access', + age: -1, + people: [], + relatives: { + father: { + name: 'father', + age: 10, + }, + }, + } + + const form = new FormApi({ + defaultValues, + }) + form.mount() + + const lens = new FormLensApi({ + form, + defaultValues: {} as Person, + name: 'relatives.father', + }) + lens.mount() + + function checkIfStateIsSynced() { + const { values: formValues, errors, errorMap, ...formState } = form.state + const { + values: lensValues, + lensErrors, + lensErrorMap, + formErrorMap, + formErrors, + ...lensState + } = lens.state + + expect(lensValues).toEqual(formValues.relatives.father) + expect(errors).toEqual(formErrors) + expect(errorMap).toEqual(formErrorMap) + expect(lensErrors).toEqual( + form.getFieldMeta('relatives.father')?.errors ?? + defaultFieldMeta.errors, + ) + expect(lensErrorMap).toEqual( + form.getFieldMeta('relatives.father')?.errorMap ?? + defaultFieldMeta.errorMap, + ) + expect(formState).toEqual(lensState) + } + + checkIfStateIsSynced() + + form.setFieldValue('relatives.father.name', 'New name') + form.setFieldValue('relatives.father.age', 50) + + checkIfStateIsSynced() + + lens.setFieldValue('name', 'Second new name') + lens.setFieldValue('age', 100) + + checkIfStateIsSynced() + lens.reset() + + checkIfStateIsSynced() + }) + + it('should validate the right field from the form', () => { + const defaultValues: FormValues = { + name: '', + age: 0, + people: [ + { + name: 'Lens one', + age: 1, + }, + { + name: 'Lens two', + age: 2, + }, + ], + relatives: { + father: { + name: 'Lens three', + age: 3, + }, + }, + } + + const form = new FormApi({ + defaultValues, + }) + form.mount() + + const field1 = new FieldApi({ + form, + name: 'people[0].age', + validators: { + onChange: () => 'Field 1', + }, + }) + const field2 = new FieldApi({ + form, + name: 'people[1].age', + validators: { + onChange: () => 'Field 2', + }, + }) + const field3 = new FieldApi({ + form, + name: 'relatives.father.age', + validators: { + onChange: () => 'Field 3', + }, + }) + + field1.mount() + field2.mount() + field3.mount() + + const lens1 = new FormLensApi({ + form, + name: 'people[0]', + defaultValues: {} as Person, + }) + const lens2 = new FormLensApi({ + form, + name: 'people[1]', + defaultValues: {} as Person, + }) + const lens3 = new FormLensApi({ + form, + name: 'relatives.father', + defaultValues: {} as Person, + }) + lens1.mount() + lens2.mount() + lens3.mount() + + lens1.validateField('age', 'change') + + expect(field1.state.meta.errors).toEqual(['Field 1']) + expect(field2.state.meta.errors).toEqual([]) + expect(field3.state.meta.errors).toEqual([]) + + lens2.validateField('age', 'change') + + expect(field1.state.meta.errors).toEqual(['Field 1']) + expect(field2.state.meta.errors).toEqual(['Field 2']) + expect(field3.state.meta.errors).toEqual([]) + + lens3.validateField('age', 'change') + + expect(field1.state.meta.errors).toEqual(['Field 1']) + expect(field2.state.meta.errors).toEqual(['Field 2']) + expect(field3.state.meta.errors).toEqual(['Field 3']) + }) + + it('should get the right field value from the nested field', () => { + const defaultValues: FormValues = { + name: '', + age: 0, + people: [ + { + name: 'Lens one', + age: 1, + }, + { + name: 'Lens two', + age: 2, + }, + ], + relatives: { + father: { + name: 'Lens three', + age: 3, + }, + }, + } + + const form = new FormApi({ + defaultValues, + }) + form.mount() + + const lens1 = new FormLensApi({ + form, + name: 'people[0]', + defaultValues: {} as Person, + }) + const lens2 = new FormLensApi({ + form, + name: 'people[1]', + defaultValues: {} as Person, + }) + const lens3 = new FormLensApi({ + form, + name: 'relatives.father', + defaultValues: {} as Person, + }) + lens1.mount() + lens2.mount() + lens3.mount() + + expect(lens1.getFieldValue('age')).toBe(1) + expect(lens1.getFieldValue('name')).toBe('Lens one') + + expect(lens2.getFieldValue('age')).toBe(2) + expect(lens2.getFieldValue('name')).toBe('Lens two') + + expect(lens3.getFieldValue('age')).toBe(3) + expect(lens3.getFieldValue('name')).toBe('Lens three') + }) + + it('should get the correct field Meta from the nested field', () => { + const defaultValues: FormValues = { + name: '', + age: 0, + people: [ + { + name: 'Lens one', + age: 1, + }, + { + name: 'Lens two', + age: 2, + }, + ], + relatives: { + father: { + name: 'Lens three', + age: 3, + }, + }, + } + + const form = new FormApi({ + defaultValues, + }) + form.mount() + + const field1 = new FieldApi({ + form, + name: 'people[0].age', + }) + const field2 = new FieldApi({ + form, + name: 'people[1].age', + }) + const field3 = new FieldApi({ + form, + name: 'relatives.father.age', + validators: { + onMount: () => 'Error', + }, + }) + + field1.mount() + field2.mount() + field3.mount() + + field1.handleChange(0) + field2.handleBlur() + + const lens1 = new FormLensApi({ + form, + name: 'people[0]', + defaultValues: {} as Person, + }) + const lens2 = new FormLensApi({ + form, + name: 'people[1]', + defaultValues: {} as Person, + }) + const lens3 = new FormLensApi({ + form, + name: 'relatives.father', + defaultValues: {} as Person, + }) + lens1.mount() + lens2.mount() + lens3.mount() + + expect(lens1.getFieldMeta('age')?.isValid).toBe(true) + expect(lens2.getFieldMeta('age')?.isValid).toBe(true) + expect(lens3.getFieldMeta('age')?.isValid).toBe(false) + + expect(lens1.getFieldMeta('age')?.isDirty).toBe(true) + expect(lens2.getFieldMeta('age')?.isDirty).toBe(false) + expect(lens3.getFieldMeta('age')?.isDirty).toBe(false) + + expect(lens1.getFieldMeta('age')?.isBlurred).toBe(false) + expect(lens2.getFieldMeta('age')?.isBlurred).toBe(true) + expect(lens3.getFieldMeta('age')?.isBlurred).toBe(false) + }) + + it('should get the correct field info from the nested field', () => { + const defaultValues: FormValues = { + name: '', + age: 0, + people: [ + { + name: 'Lens one', + age: 1, + }, + { + name: 'Lens two', + age: 2, + }, + ], + relatives: { + father: { + name: 'Lens three', + age: 3, + }, + }, + } + + const form = new FormApi({ + defaultValues, + }) + form.mount() + + const field1 = new FieldApi({ + form, + name: 'people[0].age', + }) + const field2 = new FieldApi({ + form, + name: 'people[1].age', + }) + + field1.mount() + field2.mount() + + const lens1 = new FormLensApi({ + form, + name: 'people[0]', + defaultValues: {} as Person, + }) + const lens2 = new FormLensApi({ + form, + name: 'people[1]', + defaultValues: {} as Person, + }) + const lens3 = new FormLensApi({ + form, + name: 'relatives.father', + defaultValues: {} as Person, + }) + lens1.mount() + lens2.mount() + lens3.mount() + + field2.handleChange(0) + + expect(lens1.getFieldInfo('age').instance).toEqual( + field1.getInfo().instance, + ) + expect(lens2.getFieldInfo('age').instance).toEqual( + field2.getInfo().instance, + ) + expect(lens3.getFieldInfo('age').instance).toBeNull() + }) + + it('should set the correct field meta from the nested field', () => { + const defaultValues: FormValues = { + name: '', + age: 0, + people: [ + { + name: 'Lens one', + age: 1, + }, + { + name: 'Lens two', + age: 2, + }, + ], + relatives: { + father: { + name: 'Lens three', + age: 3, + }, + }, + } + + const form = new FormApi({ + defaultValues, + }) + form.mount() + + const lens1 = new FormLensApi({ + form, + name: 'people[0]', + defaultValues: {} as Person, + }) + const lens2 = new FormLensApi({ + form, + name: 'people[1]', + defaultValues: {} as Person, + }) + const lens3 = new FormLensApi({ + form, + name: 'relatives.father', + defaultValues: {} as Person, + }) + lens1.mount() + lens2.mount() + lens3.mount() + + lens1.setFieldMeta('age', (p) => ({ ...p, isDirty: true })) + lens2.setFieldMeta('age', (p) => ({ ...p, isBlurred: true })) + lens3.setFieldMeta('age', (p) => ({ ...p, isTouched: true })) + + expect(lens1.getFieldInfo('age')).toEqual( + form.getFieldInfo('people[0].age'), + ) + expect(lens2.getFieldInfo('age')).toEqual( + form.getFieldInfo('people[1].age'), + ) + expect(lens3.getFieldInfo('age')).toEqual( + form.getFieldInfo('relatives.father.age'), + ) + }) + + it('should be compliant with top level array defaultValues', () => { + const form = new FormApi({ + defaultValues: { people: [{ name: 'Default' }, { name: 'Default' }] }, + }) + form.mount() + + const lens = new FormLensApi({ + form, + defaultValues: [{ name: '' }], + name: 'people', + }) + lens.mount() + + lens.setFieldValue('[0]', { name: 'Override One' }) + lens.setFieldValue('[1].name', 'Override Two') + + expect(form.getFieldValue('people[0].name')).toBe('Override One') + expect(form.getFieldValue('people[1].name')).toBe('Override Two') + }) + + it('should forward validateArrayFieldsStartingFrom to form', async () => { + vi.useFakeTimers() + const defaultValues = { + people: { + names: [ + { + name: '', + }, + { + name: '', + }, + { + name: '', + }, + ], + }, + } + + const form = new FormApi({ + defaultValues, + }) + form.mount() + + const field0 = new FieldApi({ + form, + name: 'people.names[0].name', + }) + const field1 = new FieldApi({ + form, + name: 'people.names[1].name', + }) + const field2 = new FieldApi({ + form, + name: 'people.names[2].name', + }) + field0.mount() + field1.mount() + field2.mount() + + const lens = new FormLensApi({ + form, + defaultValues: { + names: [{ name: '' }], + }, + name: 'people', + }) + lens.mount() + + lens.validateArrayFieldsStartingFrom('names', 1, 'change') + + await vi.runAllTimersAsync() + + expect(field0.getMeta().isTouched).toBe(false) + expect(field1.getMeta().isTouched).toBe(true) + expect(field2.getMeta().isTouched).toBe(true) + }) + + it('should forward handleSubmit to the form', async () => { + vi.useFakeTimers() + + const defaultValues = { + person: { + name: '', + }, + } + + const form = new FormApi({ + defaultValues, + }) + form.mount() + + const lens = new FormLensApi({ + defaultValues: { name: '' }, + form, + name: 'person', + }) + lens.mount() + + lens.handleSubmit() + + await vi.runAllTimersAsync() + + expect(form.state.isSubmitted).toBe(true) + expect(form.state.isSubmitSuccessful).toBe(true) + }) + + it('should forward resetField to the form', () => { + const defaultValues = { + nested: { + field: { + name: '', + }, + }, + } + + const form = new FormApi({ + defaultValues, + }) + form.mount() + + const lens = new FormLensApi({ + defaultValues: { name: '' }, + form, + name: 'nested.field', + }) + lens.mount() + + lens.setFieldValue('name', 'Nested') + + expect(form.state.values.nested.field.name).toEqual('Nested') + + lens.resetField('name') + expect(form.state.values.nested.field.name).toEqual('') + }) + + it('should forward deleteField to the form', () => { + const defaultValues = { + nested: { + field: { + name: '', + }, + }, + } + + const form = new FormApi({ + defaultValues, + }) + form.mount() + + const lens = new FormLensApi({ + defaultValues: { name: '' }, + form, + name: 'nested.field', + }) + lens.mount() + + lens.deleteField('name') + + expect(form.state.values.nested.field.name).toBeUndefined() + }) + + it('should forward array methods to the form', async () => { + vi.useFakeTimers() + const defaultValues = { + people: { + names: [ + { + name: '', + }, + { + name: '', + }, + { + name: '', + }, + ], + }, + } + + const form = new FormApi({ + defaultValues, + }) + form.mount() + + const field0 = new FieldApi({ + form, + name: 'people.names[0].name', + }) + const field1 = new FieldApi({ + form, + name: 'people.names[1].name', + }) + const field2 = new FieldApi({ + form, + name: 'people.names[2].name', + }) + field0.mount() + field1.mount() + field2.mount() + + const lens = new FormLensApi({ + defaultValues: { names: [{ name: '' }] }, + form, + name: 'people', + }) + lens.mount() + + lens.validateArrayFieldsStartingFrom('names', 1, 'change') + + await vi.runAllTimersAsync() + + expect(field0.getMeta().isTouched).toBe(false) + expect(field1.getMeta().isTouched).toBe(true) + expect(field2.getMeta().isTouched).toBe(true) + + lens.pushFieldValue('names', { name: 'Push' }) + + expect(form.getFieldValue('people.names')).toEqual([ + { + name: '', + }, + { + name: '', + }, + { + name: '', + }, + { + name: 'Push', + }, + ]) + + lens.insertFieldValue('names', 1, { name: 'Insert' }) + + expect(form.getFieldValue('people.names')).toEqual([ + { + name: '', + }, + { + name: 'Insert', + }, + { + name: '', + }, + { + name: '', + }, + { + name: 'Push', + }, + ]) + + lens.replaceFieldValue('names', 2, { name: 'Replace' }) + + expect(form.getFieldValue('people.names')).toEqual([ + { + name: '', + }, + { + name: 'Insert', + }, + { + name: 'Replace', + }, + { + name: '', + }, + { + name: 'Push', + }, + ]) + + lens.removeFieldValue('names', 3) + + expect(form.getFieldValue('people.names')).toEqual([ + { + name: '', + }, + { + name: 'Insert', + }, + { + name: 'Replace', + }, + { + name: 'Push', + }, + ]) + + lens.swapFieldValues('names', 2, 3) + + expect(form.getFieldValue('people.names')).toEqual([ + { + name: '', + }, + { + name: 'Insert', + }, + { + name: 'Push', + }, + { + name: 'Replace', + }, + ]) + + lens.moveFieldValues('names', 0, 2) + + expect(form.getFieldValue('people.names')).toEqual([ + { + name: 'Insert', + }, + { + name: 'Push', + }, + { + name: '', + }, + { + name: 'Replace', + }, + ]) + }) + + it('should parse getAllErrors to match subfield names', () => { + const emptyError = { + errors: [], + errorMap: {}, + } + function errorWith(value: string) { + return { + errors: [value], + errorMap: { + onMount: value, + }, + } + } + const defaultValues: FormValues = { + age: 0, + name: '', + people: [ + { age: 0, name: '' }, + { age: 0, name: '' }, + ], + relatives: { + father: { + name: '', + age: 0, + }, + }, + } + + const form = new FormApi({ + defaultValues, + }) + form.mount() + + const field0 = new FieldApi({ + form, + name: 'people[0].age', + validators: { + onMount: () => 'people[0].age', + }, + }) + const field1 = new FieldApi({ + form, + name: 'people[1].name', + validators: { + onMount: () => 'people[1].name', + }, + }) + const field2 = new FieldApi({ + form, + name: 'relatives.father', + validators: { + onMount: () => 'relatives.father', + }, + }) + field0.mount() + field1.mount() + field2.mount() + + // Test one: people[0].age -> age + const lens1 = new FormLensApi({ + defaultValues: {} as Person, + form, + name: 'people[0]', + }) + lens1.mount() + + expect(lens1.getAllErrors()).toEqual({ + form: emptyError, + lens: emptyError, + fields: { + age: errorWith('people[0].age'), + }, + }) + + // Test two: relatives.father -> Lens level error + const lens2 = new FormLensApi({ + defaultValues: {} as Person, + form, + name: 'relatives.father', + }) + lens2.mount() + + expect(lens2.getAllErrors()).toEqual({ + form: emptyError, + lens: errorWith('relatives.father'), + fields: {}, + }) + + // Test three: array at top level -> namespaced + const lens3 = new FormLensApi({ + defaultValues: [] as Person[], + form, + name: 'people', + }) + lens3.mount() + + expect(lens3.getAllErrors()).toEqual({ + form: emptyError, + lens: emptyError, + fields: { + '[0].age': errorWith('people[0].age'), + '[1].name': errorWith('people[1].name'), + }, + }) + }) + + it('should inherit form errors in getAllErrors', () => { + type ValueType = { value: string } + type FormType = { nested: ValueType } + + const defaultValues: FormType = { + nested: { value: '' }, + } + const form = new FormApi({ + defaultValues, + validators: { + onMount: () => 'Error', + }, + }) + form.mount() + + const lens = new FormLensApi({ + defaultValues: {} as ValueType, + form, + name: 'nested', + }) + lens.mount() + + expect(lens.getAllErrors()).toEqual({ + form: { errors: ['Error'], errorMap: { onMount: 'Error' } }, + lens: { + errors: [], + errorMap: {}, + }, + fields: {}, + }) + }) + + it('should parse values with a provided schema', async () => { + vi.useFakeTimers() + const schema = z.object({ + nested: z.object({ + value: z.string().min(1), + }), + }) + type FormType = z.input + + const defaultValues: FormType = { + nested: { + value: '', + }, + } + + const form = new FormApi({ + defaultValues, + }) + form.mount() + + const lens = new FormLensApi({ + defaultValues: { value: '' }, + form, + name: 'nested', + }) + lens.mount() + + const issuesSync = lens.parseValuesWithSchema(schema.shape.nested) + expect(issuesSync?.length).toBe(1) + expect(issuesSync?.[0]).toHaveProperty('message') + + const issuesPromise = lens.parseValuesWithSchemaAsync(schema.shape.nested) + expect(issuesPromise).toBeInstanceOf(Promise) + const issuesAsync = await issuesPromise + + expect(issuesAsync).toEqual(issuesSync) + }) + + it('should allow nesting form lenses within each other', () => { + type Nested = { + firstName: string + } + type Wrapper = { + field: Nested + } + type FormVals = { + form: Wrapper + unrelated: { something: { lastName: string } } + } + + const defaultValues: FormVals = { + form: { + field: { + firstName: 'Test', + }, + }, + unrelated: { + something: { + lastName: '', + }, + }, + } + + const form = new FormApi({ + defaultValues, + }) + + const lensWrap = new FormLensApi({ + defaultValues: defaultValues.form, + form, + name: 'form', + }) + lensWrap.mount() + + const lensNested = new FormLensApi({ + defaultValues: defaultValues.form.field, + form: lensWrap, + name: 'field', + }) + lensNested.mount() + + expect(lensNested.name).toBe('form.field') + expect(lensWrap.name).toBe('form') + expect(lensNested.form).toEqual(lensWrap.form) + expect(lensNested.state.values).toEqual(lensWrap.state.values.field) + expect(lensNested.state.values).toEqual(form.state.values.form.field) + }) +}) + +it('should inherit errors on lens level from FieldApis with the same name', async () => { + vi.useFakeTimers() + const defaultValues = { + test: { + field: { + value: '', + }, + }, + } + + const form = new FormApi({ + defaultValues, + validators: { + onChange: () => { + return { + fields: { + 'test.field': 'Error', + }, + } + }, + }, + }) + form.mount() + + const field = new FieldApi({ + form, + name: 'test.field', + }) + field.mount() + + const lens = new FormLensApi({ + defaultValues: defaultValues.test.field, + form, + name: 'test.field', + }) + lens.mount() + + expect(lens.state.lensErrors).toEqual([]) + expect(lens.state.lensErrorMap).toEqual({}) + + form.validate('change') + + await vi.runAllTimersAsync() + + expect(lens.state.lensErrors).toEqual(['Error']) + expect(lens.state.lensErrorMap).toEqual({ onChange: 'Error' }) +}) diff --git a/packages/react-form/src/createFormHook.tsx b/packages/react-form/src/createFormHook.tsx index 6a4140768..55073dddc 100644 --- a/packages/react-form/src/createFormHook.tsx +++ b/packages/react-form/src/createFormHook.tsx @@ -1,18 +1,45 @@ -import { createContext, useContext, useMemo } from 'react' +/* eslint-disable @eslint-react/no-context-provider */ +import { createContext, useContext, useMemo, useState } from 'react' +import { FormLensApi, functionalUpdate } from '@tanstack/form-core' +import { useStore } from '@tanstack/react-store' import { useForm } from './useForm' +import { useIsomorphicLayoutEffect } from './useIsomorphicLayoutEffect' import type { AnyFieldApi, AnyFormApi, + AnyFormLensApi, + AnyFormLensState, + BaseFormOptions, + DeepKeysOfType, FieldApi, - FormApi, FormAsyncValidateOrFn, + FormLensState, FormOptions, FormValidateOrFn, } from '@tanstack/form-core' -import type { ComponentType, Context, JSX, PropsWithChildren } from 'react' -import type { FieldComponent } from './useField' +import type { + ComponentType, + Context, + JSX, + PropsWithChildren, + ReactNode, +} from 'react' +import type { FieldComponent, LensFieldComponent } from './useField' import type { ReactFormExtendedApi } from './useForm' +function LocalSubscribe({ + lens, + selector, + children, +}: PropsWithChildren<{ + lens: AnyFormLensApi + selector: (state: AnyFormLensState) => AnyFormLensState +}>) { + const data = useStore(lens.store, selector) + + return functionalUpdate(children, data) +} + /** * TypeScript inferencing is weird. * @@ -170,6 +197,100 @@ type AppFieldExtendedReactFormApi< AppForm: ComponentType } +type AppFieldExtendedReactFormLensApi< + TFormData, + TName extends DeepKeysOfType, + TLensData, + TOnMount extends undefined | FormValidateOrFn, + TOnChange extends undefined | FormValidateOrFn, + TOnChangeAsync extends undefined | FormAsyncValidateOrFn, + TOnBlur extends undefined | FormValidateOrFn, + TOnBlurAsync extends undefined | FormAsyncValidateOrFn, + TOnSubmit extends undefined | FormValidateOrFn, + TOnSubmitAsync extends undefined | FormAsyncValidateOrFn, + TOnServer extends undefined | FormAsyncValidateOrFn, + TSubmitMeta, + TFieldComponents extends Record>, + TFormComponents extends Record>, +> = FormLensApi< + TFormData, + TLensData, + TName, + TOnMount, + TOnChange, + TOnChangeAsync, + TOnBlur, + TOnBlurAsync, + TOnSubmit, + TOnSubmitAsync, + TOnServer, + TSubmitMeta +> & + NoInfer & { + form: AppFieldExtendedReactFormApi< + TFormData, + TOnMount, + TOnChange, + TOnChangeAsync, + TOnBlur, + TOnBlurAsync, + TOnSubmit, + TOnSubmitAsync, + TOnServer, + TSubmitMeta, + TFieldComponents, + TFormComponents + > + AppField: LensFieldComponent< + TLensData, + TSubmitMeta, + NoInfer + > + AppForm: ComponentType + /** + * A React component to render form fields. With this, you can render and manage individual form fields. + */ + Field: LensFieldComponent + + /** + * A `Subscribe` function that allows you to listen and react to changes in the form's state. It's especially useful when you need to execute side effects or render specific components in response to state updates. + */ + Subscribe: < + TSelected = NoInfer< + FormLensState< + TFormData, + TLensData, + TOnMount, + TOnChange, + TOnChangeAsync, + TOnBlur, + TOnBlurAsync, + TOnSubmit, + TOnSubmitAsync, + TOnServer + > + >, + >(props: { + selector?: ( + state: NoInfer< + FormLensState< + TFormData, + TLensData, + TOnMount, + TOnChange, + TOnChangeAsync, + TOnBlur, + TOnBlurAsync, + TOnSubmit, + TOnSubmitAsync, + TOnServer + > + >, + ) => TSelected + children: ((state: NoInfer) => ReactNode) | ReactNode + }) => ReactNode + } + export interface WithFormProps< TFormData, TOnMount extends undefined | FormValidateOrFn, @@ -220,6 +341,41 @@ export interface WithFormProps< ) => JSX.Element } +export interface WithFormLensProps< + TLensData, + TFieldComponents extends Record>, + TFormComponents extends Record>, + TSubmitMeta, + TRenderProps extends Record = Record, +> extends BaseFormOptions { + // Optional, but adds props to the `render` function outside of `form` + props?: TRenderProps + render: ( + props: PropsWithChildren< + NoInfer & { + lens: AppFieldExtendedReactFormLensApi< + unknown, + string, + TLensData, + undefined | FormValidateOrFn, + undefined | FormValidateOrFn, + undefined | FormAsyncValidateOrFn, + undefined | FormValidateOrFn, + undefined | FormAsyncValidateOrFn, + undefined | FormValidateOrFn, + undefined | FormAsyncValidateOrFn, + undefined | FormAsyncValidateOrFn, + // this types it as 'never' in the render prop. It should prevent any + // untyped meta passed to the handleSubmit by accident. + unknown extends TSubmitMeta ? never : TSubmitMeta, + TFieldComponents, + TFormComponents + > + } + >, + ) => JSX.Element +} + export function createFormHook< const TComponents extends Record>, const TFormComponents extends Record>, @@ -229,6 +385,132 @@ export function createFormHook< formContext, formComponents, }: CreateFormHookProps) { + function useFormLens< + TFormData, + TName extends DeepKeysOfType, + TLensData, + TOnMount extends undefined | FormValidateOrFn, + TOnChange extends undefined | FormValidateOrFn, + TOnChangeAsync extends undefined | FormAsyncValidateOrFn, + TOnBlur extends undefined | FormValidateOrFn, + TOnBlurAsync extends undefined | FormAsyncValidateOrFn, + TOnSubmit extends undefined | FormValidateOrFn, + TOnSubmitAsync extends undefined | FormAsyncValidateOrFn, + TOnServer extends undefined | FormAsyncValidateOrFn, + TSubmitMeta = never, + >(opts: { + form: AppFieldExtendedReactFormApi< + TFormData, + TOnMount, + TOnChange, + TOnChangeAsync, + TOnBlur, + TOnBlurAsync, + TOnSubmit, + TOnSubmitAsync, + TOnServer, + TSubmitMeta, + TComponents, + TFormComponents + > + name: TName + defaultValues?: TLensData + onSubmitMeta?: TSubmitMeta + }): AppFieldExtendedReactFormLensApi< + TFormData, + TName, + TLensData, + TOnMount, + TOnChange, + TOnChangeAsync, + TOnBlur, + TOnBlurAsync, + TOnSubmit, + TOnSubmitAsync, + TOnServer, + TSubmitMeta, + TComponents, + TFormComponents + > { + const [formLensApi] = useState(() => { + const api = new FormLensApi(opts) + + const extendedApi: AppFieldExtendedReactFormLensApi< + TFormData, + TName, + TLensData, + TOnMount, + TOnChange, + TOnChangeAsync, + TOnBlur, + TOnBlurAsync, + TOnSubmit, + TOnSubmitAsync, + TOnServer, + TSubmitMeta, + TComponents, + TFormComponents + > = api as never + + extendedApi.AppForm = function AppForm(appFormProps) { + return + } + + extendedApi.AppField = function AppField({ name, ...appFieldProps }) { + return ( + // @ts-expect-error + + ) as never + } + + extendedApi.Field = function Field({ name, ...fieldProps }) { + return ( + // @ts-expect-error + + ) as never + } + + extendedApi.Subscribe = function Subscribe(props: any) { + return ( + + ) + } + + return Object.assign(extendedApi, { + ...formComponents, + }) as AppFieldExtendedReactFormLensApi< + TFormData, + TName, + TLensData, + TOnMount, + TOnChange, + TOnChangeAsync, + TOnBlur, + TOnBlurAsync, + TOnSubmit, + TOnSubmitAsync, + TOnServer, + TSubmitMeta, + TComponents, + TFormComponents + > + }) + + useIsomorphicLayoutEffect(formLensApi.mount, [formLensApi]) + + return formLensApi + } + function useAppForm< TFormData, TOnMount extends undefined | FormValidateOrFn, @@ -362,8 +644,114 @@ export function createFormHook< return (innerProps) => render({ ...props, ...innerProps }) } + function withFormLens< + TLensData, + TSubmitMeta, + TRenderProps extends Record = {}, + >({ + render, + props, + defaultValues, + }: WithFormLensProps< + TLensData, + TComponents, + TFormComponents, + TSubmitMeta, + TRenderProps + >): < + TFormData, + TName extends DeepKeysOfType, + TOnMount extends undefined | FormValidateOrFn, + TOnChange extends undefined | FormValidateOrFn, + TOnChangeAsync extends undefined | FormAsyncValidateOrFn, + TOnBlur extends undefined | FormValidateOrFn, + TOnBlurAsync extends undefined | FormAsyncValidateOrFn, + TOnSubmit extends undefined | FormValidateOrFn, + TOnSubmitAsync extends undefined | FormAsyncValidateOrFn, + TOnServer extends undefined | FormAsyncValidateOrFn, + TFormSubmitMeta, + >( + params: PropsWithChildren< + NoInfer & { + form: + | AppFieldExtendedReactFormApi< + TFormData, + TOnMount, + TOnChange, + TOnChangeAsync, + TOnBlur, + TOnBlurAsync, + TOnSubmit, + TOnSubmitAsync, + TOnServer, + unknown extends TSubmitMeta ? TFormSubmitMeta : TSubmitMeta, + TComponents, + TFormComponents + > + | AppFieldExtendedReactFormLensApi< + // Since this only occurs if you nest it within other form lenses, it can be more + // lenient with the types. + unknown, + string, + TFormData, + any, + any, + any, + any, + any, + any, + any, + any, + unknown extends TSubmitMeta ? TFormSubmitMeta : TSubmitMeta, + TComponents, + TFormComponents + > + name: TName + } + >, + ) => JSX.Element { + return function Render(innerProps) { + const lensProps = useMemo(() => { + if (innerProps.form instanceof FormLensApi) { + const lens = innerProps.form + return { + // ensure that nested lenses still receive data from the top form + // Since we don't create new generics in the return function and it's unused outside this function, + // this will just be an any cast for now. + form: lens.form as AppFieldExtendedReactFormApi< + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any + >, + name: lens.getFormFieldName(innerProps.name), + defaultValues, + } + } else { + return { + form: innerProps.form, + name: innerProps.name, + defaultValues, + } + } + }, [innerProps.form, innerProps.name]) + const lensApi = useFormLens(lensProps) + + return render({ ...props, ...innerProps, lens: lensApi as any }) + } + } + return { useAppForm, withForm, + withFormLens, } } diff --git a/packages/react-form/src/index.ts b/packages/react-form/src/index.ts index 5f30ccd43..dbc72038d 100644 --- a/packages/react-form/src/index.ts +++ b/packages/react-form/src/index.ts @@ -10,5 +10,5 @@ export { useField, Field } from './useField' export { useTransform } from './useTransform' -export type { WithFormProps } from './createFormHook' +export type { WithFormProps, WithFormLensProps } from './createFormHook' export { createFormHook, createFormHookContexts } from './createFormHook' diff --git a/packages/react-form/src/useField.tsx b/packages/react-form/src/useField.tsx index 38e771a1e..40733d12a 100644 --- a/packages/react-form/src/useField.tsx +++ b/packages/react-form/src/useField.tsx @@ -420,6 +420,55 @@ export type FieldComponent< ExtendedApi >) => ReactNode +/** + * A type alias representing a field component for a form lens data type. + */ +export type LensFieldComponent< + in out TLensData, + in out TParentSubmitMeta, + in out ExtendedApi = {}, +> = < + const TName extends DeepKeys, + TData extends DeepValue, + TOnMount extends undefined | FieldValidateOrFn, + TOnChange extends undefined | FieldValidateOrFn, + TOnChangeAsync extends + | undefined + | FieldAsyncValidateOrFn, + TOnBlur extends undefined | FieldValidateOrFn, + TOnBlurAsync extends + | undefined + | FieldAsyncValidateOrFn, + TOnSubmit extends undefined | FieldValidateOrFn, + TOnSubmitAsync extends + | undefined + | FieldAsyncValidateOrFn, +>({ + children, + ...fieldOptions +}: FieldComponentBoundProps< + unknown, + string, + TData, + TOnMount, + TOnChange, + TOnChangeAsync, + TOnBlur, + TOnBlurAsync, + TOnSubmit, + TOnSubmitAsync, + undefined | FormValidateOrFn, + undefined | FormValidateOrFn, + undefined | FormAsyncValidateOrFn, + undefined | FormValidateOrFn, + undefined | FormAsyncValidateOrFn, + undefined | FormValidateOrFn, + undefined | FormAsyncValidateOrFn, + undefined | FormAsyncValidateOrFn, + TParentSubmitMeta, + ExtendedApi +> & { name: TName }) => ReactNode + /** * A function component that takes field options and a render function as children and returns a React component. * diff --git a/packages/react-form/src/useForm.tsx b/packages/react-form/src/useForm.tsx index 729064aba..838cb48be 100644 --- a/packages/react-form/src/useForm.tsx +++ b/packages/react-form/src/useForm.tsx @@ -6,12 +6,13 @@ import { useIsomorphicLayoutEffect } from './useIsomorphicLayoutEffect' import type { AnyFormApi, AnyFormState, + BaseFormOptions, FormAsyncValidateOrFn, FormOptions, FormState, FormValidateOrFn, } from '@tanstack/form-core' -import type { PropsWithChildren, ReactNode } from 'react' +import type { ComponentType, JSX, PropsWithChildren, ReactNode } from 'react' import type { FieldComponent } from './useField' import type { NoInfer } from '@tanstack/react-store' diff --git a/packages/react-form/tests/createFormHook.test-d.tsx b/packages/react-form/tests/createFormHook.test-d.tsx index fb480d005..ea14d80f9 100644 --- a/packages/react-form/tests/createFormHook.test-d.tsx +++ b/packages/react-form/tests/createFormHook.test-d.tsx @@ -10,7 +10,7 @@ function Test() { return null } -const { useAppForm, withForm } = createFormHook({ +const { useAppForm, withForm, withFormLens } = createFormHook({ fieldComponents: { Test, }, @@ -101,6 +101,26 @@ describe('createFormHook', () => { ) }, }) + + const ExampleUsage2 = withFormLens({ + defaultValues: {} as EditorValues, + render: ({ lens }) => { + const test = lens.state.values.key + return ( +
+ + {(field) => { + expectTypeOf(field.state.value).toExtend() + return null + }} + + + + +
+ ) + }, + }) }) it('types should be properly inferred when using formOptions', () => { @@ -196,12 +216,13 @@ describe('createFormHook', () => { prop2: number children?: React.ReactNode }>() + return }, }) }) - it("component made from withForm should have it's props properly typed", () => { + it('component made from withForm should have its props properly typed', () => { const formOpts = formOptions({ defaultValues: { firstName: 'FirstName', @@ -249,4 +270,387 @@ describe('createFormHook', () => { ) }) + + it('should infer subset values and props when calling withFormLens', () => { + type Person = { + firstName: string + lastName: string + } + type ComponentProps = { + prop1: string + prop2: number + } + + const defaultValues: Person = { + firstName: 'FirstName', + lastName: 'LastName', + } + + const FormGroupComponent = withFormLens({ + defaultValues, + render: function Render({ lens, children, ...props }) { + // Existing types may be inferred + expectTypeOf(lens.state.values.firstName).toEqualTypeOf() + expectTypeOf(lens.state.values.lastName).toEqualTypeOf() + + expectTypeOf(lens.state.values).toEqualTypeOf() + expectTypeOf(children).toEqualTypeOf() + expectTypeOf(props).toEqualTypeOf<{}>() + return + }, + }) + + const FormGroupComponentWithProps = withFormLens({ + ...defaultValues, + props: {} as ComponentProps, + render: ({ lens, children, ...props }) => { + expectTypeOf(props).toEqualTypeOf<{ + prop1: string + prop2: number + }>() + return + }, + }) + }) + + it('should allow spreading formOptions when calling withFormLens', () => { + type Person = { + firstName: string + lastName: string + } + + const defaultValues: Person = { + firstName: '', + lastName: '', + } + const formOpts = formOptions({ + defaultValues, + validators: { + onChange: () => 'Error', + }, + listeners: { + onBlur: () => 'Something', + }, + asyncAlways: true, + asyncDebounceMs: 500, + }) + + // validators and listeners are ignored, only defaultValues is acknowledged + const FormGroupComponent = withFormLens({ + ...formOpts, + render: function Render({ lens }) { + // Existing types may be inferred + expectTypeOf(lens.state.values.firstName).toEqualTypeOf() + expectTypeOf(lens.state.values.lastName).toEqualTypeOf() + return + }, + }) + + const noDefaultValuesFormOpts = formOptions({ + onSubmitMeta: { foo: '' }, + }) + + const UnknownFormGroupComponent = withFormLens({ + ...noDefaultValuesFormOpts, + render: function Render({ lens }) { + // lens.state.values can be anything. + // note that T extends unknown !== unknown extends T. + expectTypeOf().toExtend() + + // either no submit meta or of the type in formOptions + expectTypeOf(lens.handleSubmit).parameters.toEqualTypeOf< + [] | [{ foo: string }] + >() + return + }, + }) + }) + + it('should allow passing compatible forms to withFormLens', () => { + type Person = { + firstName: string + lastName: string + } + type ComponentProps = { + prop1: string + prop2: number + } + + const defaultValues: Person = { + firstName: 'FirstName', + lastName: 'LastName', + } + + const FormGroup = withFormLens({ + defaultValues, + props: {} as ComponentProps, + render: () => { + return <> + }, + }) + + const equalAppForm = useAppForm({ + defaultValues, + }) + + // ----------------- + // Assert that an equal form is not compatible as you have no name to pass + const NoSubfield = ( + // @ts-expect-error + + ) + + // ----------------- + // Assert that a form extending Person in a property is allowed + + const extendedAppForm = useAppForm({ + defaultValues: { person: { ...defaultValues, address: '' }, address: '' }, + }) + // While it has other properties, it satisfies defaultValues + const CorrectComponent1 = ( + + ) + + const MissingProps = ( + // @ts-expect-error because prop1 and prop2 are not added + + ) + + // ----------------- + // Assert that a form not satisfying Person errors + const incompatibleAppForm = useAppForm({ + defaultValues: { person: { ...defaultValues, lastName: 0 } }, + }) + const IncompatibleComponent = ( + // @ts-expect-error because the required subset of properties is not compatible. + + ) + }) + + it('should require strict equal submitMeta if it is set in withFormLens', () => { + type Person = { + firstName: string + lastName: string + } + type SubmitMeta = { + correct: string + } + + const defaultValues = { + person: { firstName: 'FirstName', lastName: 'LastName' } as Person, + } + const onSubmitMeta: SubmitMeta = { + correct: 'Prop', + } + + const FormLensNoMeta = withFormLens({ + defaultValues: {} as Person, + render: function Render({ lens }) { + // Since handleSubmit always allows to submit without meta, this is okay + lens.handleSubmit() + + // To prevent unwanted meta behaviour, handleSubmit's meta should be never if not set. + expectTypeOf(lens.handleSubmit).parameters.toEqualTypeOf< + [] | [submitMeta: never] + >() + + return + }, + }) + + const FormGroupWithMeta = withFormLens({ + defaultValues: {} as Person, + onSubmitMeta, + render: function Render({ lens }) { + // Since handleSubmit always allows to submit without meta, this is okay + lens.handleSubmit() + + // This matches the value + lens.handleSubmit({ correct: '' }) + + // This does not. + // @ts-expect-error + lens.handleSubmit({ wrong: 'Meta' }) + + return + }, + }) + + const noMetaForm = useAppForm({ + defaultValues, + }) + + const CorrectComponent1 = + + const WrongComponent1 = ( + // @ts-expect-error because the meta is not existent + + ) + + const metaForm = useAppForm({ + defaultValues, + onSubmitMeta, + }) + + const CorrectComponent2 = + const CorrectComponent3 = ( + + ) + + const diffMetaForm = useAppForm({ + defaultValues, + onSubmitMeta: { ...onSubmitMeta, something: 'else' }, + }) + + const CorrectComponent4 = ( + + ) + const WrongComponent2 = ( + // @ts-expect-error because the metas do not align. + + ) + }) + + it('should accept any validators for withFormLens', () => { + type Person = { + firstName: string + lastName: string + } + + const defaultValues = { + person: { firstName: 'FirstName', lastName: 'LastName' } satisfies Person, + } + + const formA = useAppForm({ + defaultValues, + validators: { + onChange: () => 'A', + }, + listeners: { + onChange: () => 'A', + }, + }) + const formB = useAppForm({ + defaultValues, + validators: { + onChange: () => 'B', + }, + listeners: { + onChange: () => 'B', + }, + }) + + const FormGroup = withFormLens({ + defaultValues: defaultValues.person, + render: function Render({ lens }) { + return + }, + }) + + const CorrectComponent1 = + const CorrectComponent2 = + }) + + it('should allow nesting withFormLens in other withFormLenses', () => { + type Nested = { + firstName: string + } + type Wrapper = { + field: Nested + } + type FormValues = { + form: Wrapper + unrelated: { something: { lastName: string } } + } + + const defaultValues: FormValues = { + form: { + field: { + firstName: 'Test', + }, + }, + unrelated: { + something: { + lastName: '', + }, + }, + } + + const form = useAppForm({ + defaultValues, + }) + const LensNested = withFormLens({ + defaultValues: defaultValues.form.field, + render: function Render() { + return <> + }, + }) + const LensWrapper = withFormLens({ + defaultValues: defaultValues.form, + render: function Render({ lens }) { + return ( +
+ +
+ ) + }, + }) + + const Component = + }) + + it('should not allow withFormLenses with different metas to be nested', () => { + type Nested = { + firstName: string + } + type Wrapper = { + field: Nested + } + type FormValues = { + form: Wrapper + unrelated: { something: { lastName: string } } + } + + const defaultValues: FormValues = { + form: { + field: { + firstName: 'Test', + }, + }, + unrelated: { + something: { + lastName: '', + }, + }, + } + + const LensNestedNoMeta = withFormLens({ + defaultValues: defaultValues.form.field, + render: function Render() { + return <> + }, + }) + const LensNestedWithMeta = withFormLens({ + defaultValues: defaultValues.form.field, + onSubmitMeta: { meta: '' }, + render: function Render() { + return <> + }, + }) + const LensWrapper = withFormLens({ + defaultValues: defaultValues.form, + render: function Render({ lens }) { + return ( +
+ + +
+ ) + }, + }) + }) }) diff --git a/packages/react-form/tests/createFormHook.test.tsx b/packages/react-form/tests/createFormHook.test.tsx index b535e626e..be6bf6e02 100644 --- a/packages/react-form/tests/createFormHook.test.tsx +++ b/packages/react-form/tests/createFormHook.test.tsx @@ -1,7 +1,10 @@ import { describe, expect, it } from 'vitest' import { render } from '@testing-library/react' import { formOptions } from '@tanstack/form-core' -import { createFormHook, createFormHookContexts } from '../src' +import userEvent from '@testing-library/user-event' +import { createFormHook, createFormHookContexts, useStore } from '../src' + +const user = userEvent.setup() const { fieldContext, useFieldContext, formContext, useFormContext } = createFormHookContexts() @@ -28,7 +31,7 @@ function SubscribeButton({ label }: { label: string }) { ) } -const { useAppForm, withForm } = createFormHook({ +const { useAppForm, withForm, withFormLens } = createFormHook({ fieldComponents: { TextField, }, @@ -112,4 +115,298 @@ describe('createFormHook', () => { expect(input).toHaveValue('John') expect(getByText('Testing')).toBeInTheDocument() }) + + it('should handle withFormLens types properly', () => { + const formOpts = formOptions({ + defaultValues: { + person: { + firstName: 'John', + lastName: 'Doe', + }, + }, + }) + + const ChildForm = withFormLens({ + defaultValues: formOpts.defaultValues.person, + // Optional, but adds props to the `render` function outside of `form` + props: { + title: 'Child Form', + }, + render: ({ lens, title }) => { + return ( +
+

{title}

+ } + /> + + + +
+ ) + }, + }) + + const Parent = () => { + const form = useAppForm({ + ...formOpts, + }) + + return + } + + const { getByLabelText, getByText } = render() + const input = getByLabelText('First Name') + expect(input).toHaveValue('John') + expect(getByText('Testing')).toBeInTheDocument() + }) + + it('should use the correct field name in Field with withFormLens', () => { + const formOpts = formOptions({ + defaultValues: { + person: { + firstName: 'John', + lastName: 'Doe', + }, + people: [ + { + firstName: 'Jane', + lastName: 'Doe', + }, + { + firstName: 'Robert', + lastName: 'Doe', + }, + ], + }, + }) + + const ChildFormAsField = withFormLens({ + defaultValues: formOpts.defaultValues.person, + render: ({ lens }) => { + return ( +
+

{lens.name}

+ } + /> + + + +
+ ) + }, + }) + const ChildFormAsArray = withFormLens({ + defaultValues: [formOpts.defaultValues.person], + props: { + title: '', + }, + render: ({ lens, title }) => { + return ( +
+

{title}

+ } + /> + + + +
+ ) + }, + }) + + const Parent = () => { + const form = useAppForm({ + ...formOpts, + }) + + return ( + <> + + + + + ) + } + + const { getByLabelText, getByText } = render() + const inputField1 = getByLabelText('person.firstName') + const inputArray = getByLabelText('people[0].firstName') + const inputField2 = getByLabelText('people[1].firstName') + expect(inputField1).toHaveValue('John') + expect(inputArray).toHaveValue('Jane') + expect(inputField2).toHaveValue('Robert') + expect(getByText('Testing')).toBeInTheDocument() + }) + + it('should forward Field and Subscribe to the form', () => { + const formOpts = formOptions({ + defaultValues: { + person: { + firstName: 'John', + lastName: 'Doe', + }, + }, + }) + + const ChildFormAsField = withFormLens({ + defaultValues: formOpts.defaultValues.person, + render: ({ lens }) => { + return ( +
+

{lens.name}

+ ( + + )} + /> + state.values.lastName}> + {(lastName) =>

{lastName}

} +
+
+ ) + }, + }) + + const Parent = () => { + const form = useAppForm({ + ...formOpts, + }) + return + } + + const { getByLabelText, getByText } = render() + const input = getByLabelText('person.firstName') + expect(input).toHaveValue('John') + expect(getByText('Doe')).toBeInTheDocument() + }) + + it('should not lose focus on update with withFormLens', async () => { + const formOpts = formOptions({ + defaultValues: { + person: { + firstName: 'John', + lastName: 'Doe', + }, + }, + }) + + const ChildForm = withFormLens({ + defaultValues: formOpts.defaultValues.person, + render: function Render({ lens }) { + const firstName = useStore( + lens.store, + (state) => state.values.firstName, + ) + return ( +
+

{firstName}

+ ( + + )} + /> + state.values.lastName}> + {(lastName) =>

{lastName}

} +
+
+ ) + }, + }) + + const Parent = () => { + const form = useAppForm({ + ...formOpts, + }) + return + } + + const { getByLabelText } = render() + + const input = getByLabelText('person.firstName') + input.focus() + expect(input).toHaveFocus() + + await user.clear(input) + await user.type(input, 'Something') + + expect(input).toHaveFocus() + }) + + it('should allow nesting withFormLens in other withFormLenses', () => { + type Nested = { + firstName: string + } + type Wrapper = { + field: Nested + } + type FormValues = { + form: Wrapper + unrelated: { something: { lastName: string } } + } + + const defaultValues: FormValues = { + form: { + field: { + firstName: 'Test', + }, + }, + unrelated: { + something: { + lastName: '', + }, + }, + } + + const LensNested = withFormLens({ + defaultValues: defaultValues.form.field, + render: function Render({ lens }) { + return ( + + {(field) =>

{field.name}

} +
+ ) + }, + }) + const LensWrapper = withFormLens({ + defaultValues: defaultValues.form, + render: function Render({ lens }) { + return ( +
+ +
+ ) + }, + }) + + const Parent = () => { + const form = useAppForm({ + defaultValues, + }) + return + } + + const { getByText } = render() + + expect(getByText('form.field.firstName')).toBeInTheDocument() + }) })