diff --git a/docs/framework/react/guides/form-composition.md b/docs/framework/react/guides/form-composition.md index d5b4a305e..8afab4438 100644 --- a/docs/framework/react/guides/form-composition.md +++ b/docs/framework/react/guides/form-composition.md @@ -248,6 +248,238 @@ 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 `withFieldGroup` 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 `withFieldGroup` would look like this: + +```tsx +const { useAppForm, withForm, withFieldGroup } = createFormHook({ + fieldComponents: { + TextField, + ErrorInfo, + }, + formComponents: { + SubscribeButton, + }, + fieldContext, + formContext, +}) + +type PasswordFields = { + password: string + confirm_password: string +} + +// These default values are not used at runtime, but the keys are needed for mapping purposes. +// This allows you to spread `formOptions` without needing to redeclare it. +const defaultValues: PasswordFields = { + password: '', + confirm_password: '', +} + +const FieldGroupPasswordField = withFieldGroup({ + defaultValues, + // You may also restrict the group 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 `group` instead of a `form` + render: function Render({ group, title }) { + // access reactive values using the group store + const password = useStore(group.store, (state) => state.values.password) + // or the form itself + const isSubmitting = useStore( + group.form.store, + (state) => state.isSubmitting, + ) + + return ( +
+

{title}

+ {/* Groups 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 group methods instead + if (value !== group.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 group fields as long as the +// existing properties remain unchanged +type Account = PasswordFields & { + provider: string + username: string +} + +// You may nest the group 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 group 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) => ( + + )) + } + + + ) +} +``` + +### Mapping field group values to a different field + +You may want to keep the password fields on the top level of your form, or rename the properties for clarity. You can map field group values +to their true location by changing the `field` property: + +> [!IMPORTANT] +> Due to TypeScript limitations, field mapping is only allowed for objects. You can use records or arrays at the top level of a field group, but you will not be able to map the fields. + +```tsx +// To have an easier form, you can keep the fields on the top level +type FormValues = { + name: string + age: number + password: string + confirm_password: string +} + +const defaultValues: FormValues = { + name: '', + age: 0, + password: '', + confirm_password: '', +} + +function App() { + const form = useAppForm({ + defaultValues, + }) + + return ( + + + + ) +} +``` + +If you expect your fields to always be at the top level of your form, you can create a quick map +of your field groups using a helper function: + +```tsx +const defaultValues: PasswordFields = { + password: '', + confirm_password: '', +} + +const passwordFields = createFieldMap(defaultValues) +/* This generates the following map: + { + 'password': 'password', + 'confirm_password': 'confirm_password' + } +*/ + +// Usage: + +``` + ## 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/examples/react/large-form/src/features/people/emergency-contact.tsx b/examples/react/large-form/src/features/people/emergency-contact.tsx new file mode 100644 index 000000000..69cc88160 --- /dev/null +++ b/examples/react/large-form/src/features/people/emergency-contact.tsx @@ -0,0 +1,22 @@ +import { withFieldGroup } from '../../hooks/form' + +export const FieldGroupEmergencyContact = withFieldGroup({ + defaultValues: { + phone: '', + fullName: '', + }, + render: function Render({ group }) { + return ( + <> + } + /> + } + /> + + ) + }, +}) diff --git a/examples/react/large-form/src/features/people/page.tsx b/examples/react/large-form/src/features/people/page.tsx index b36c2ad7e..3743662bd 100644 --- a/examples/react/large-form/src/features/people/page.tsx +++ b/examples/react/large-form/src/features/people/page.tsx @@ -1,5 +1,6 @@ import { useAppForm } from '../../hooks/form.tsx' import { AddressFields } from './address-fields.tsx' +import { FieldGroupEmergencyContact } from './emergency-contact.tsx' import { peopleFormOpts } from './shared-form.tsx' export const PeoplePage = () => { @@ -57,14 +58,8 @@ export const PeoplePage = () => { />

Emergency Contact

- } - /> - } - /> + + diff --git a/examples/react/large-form/src/hooks/form.tsx b/examples/react/large-form/src/hooks/form.tsx index 129664662..f1a9ed2c5 100644 --- a/examples/react/large-form/src/hooks/form.tsx +++ b/examples/react/large-form/src/hooks/form.tsx @@ -13,7 +13,7 @@ function SubscribeButton({ label }: { label: string }) { ) } -export const { useAppForm, withForm } = createFormHook({ +export const { useAppForm, withForm, withFieldGroup } = createFormHook({ fieldComponents: { TextField, }, diff --git a/packages/form-core/src/FieldGroupApi.ts b/packages/form-core/src/FieldGroupApi.ts new file mode 100644 index 000000000..41db79a81 --- /dev/null +++ b/packages/form-core/src/FieldGroupApi.ts @@ -0,0 +1,463 @@ +import { Derived } from '@tanstack/store' +import { concatenatePaths, getBy, makePathArray } from './utils' +import type { Updater } from './utils' +import type { + FormApi, + FormAsyncValidateOrFn, + FormValidateOrFn, +} from './FormApi' +import type { AnyFieldMeta, AnyFieldMetaBase } from './FieldApi' +import type { + DeepKeys, + DeepKeysOfType, + DeepValue, + FieldsMap, +} from './util-types' +import type { + FieldManipulator, + UpdateMetaOptions, + ValidationCause, +} from './types' + +export type AnyFieldGroupApi = FieldGroupApi< + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any +> + +export interface FieldGroupState { + /** + * The current values of the field group + */ + values: TFieldGroupData +} + +/** + * An object representing the options for a field group. + */ +export interface FieldGroupOptions< + in out TFormData, + in out TFieldGroupData, + in out TFields extends + | DeepKeysOfType + | FieldsMap, + 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 + > + | FieldGroupApi< + any, + TFormData, + any, + any, + any, + any, + any, + any, + any, + any, + any, + TSubmitMeta + > + /** + * The path to the field group data. + */ + fields: TFields + /** + * The expected subsetValues that the form must provide. + */ + defaultValues?: TFieldGroupData + /** + * onSubmitMeta, the data passed from the handleSubmit handler, to the onSubmit function props + */ + onSubmitMeta?: TSubmitMeta +} + +export class FieldGroupApi< + in out TFormData, + in out TFieldGroupData, + in out TFields extends + | DeepKeysOfType + | FieldsMap, + 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, +> implements FieldManipulator +{ + /** + * The form that called this field group. + */ + readonly form: FormApi< + TFormData, + TOnMount, + TOnChange, + TOnChangeAsync, + TOnBlur, + TOnBlurAsync, + TOnSubmit, + TOnSubmitAsync, + TOnServer, + TSubmitMeta + > + + readonly fieldsMap: TFields + + /** + * Get the true name of the field. Not required within `Field` or `AppField`. + * @private + */ + getFormFieldName = >( + subfield: TField, + ): DeepKeys => { + if (typeof this.fieldsMap === 'string') { + return concatenatePaths(this.fieldsMap, subfield) + } + + const firstAccessor = makePathArray(subfield)[0] + if (typeof firstAccessor !== 'string') { + // top-level arrays cannot be mapped + return '' + } + + const restOfPath = subfield.slice(firstAccessor.length) + const formMappedPath = + // TFields is either a string or this. See guard above. + (this.fieldsMap as FieldsMap)[ + firstAccessor as keyof TFieldGroupData + ] + + return concatenatePaths(formMappedPath, restOfPath) + } + + store: Derived> + + get state() { + return this.store.state + } + + /** + * Constructs a new `FieldGroupApi` instance with the given form options. + */ + constructor( + opts: FieldGroupOptions< + TFormData, + TFieldGroupData, + TFields, + TOnMount, + TOnChange, + TOnChangeAsync, + TOnBlur, + TOnBlurAsync, + TOnSubmit, + TOnSubmitAsync, + TOnServer, + TSubmitMeta + >, + ) { + if (opts.form instanceof FieldGroupApi) { + const group = opts.form + this.form = group.form as never + + // the DeepKey is already namespaced, so we need to ensure that we reference + // the form and not the group + if (typeof opts.fields === 'string') { + this.fieldsMap = group.getFormFieldName(opts.fields) as TFields + } else { + // TypeScript has a tough time with generics being a union for some reason + const fields = { + ...(opts.fields as FieldsMap), + } + for (const key in fields) { + fields[key] = group.getFormFieldName(fields[key]) as never + } + this.fieldsMap = fields as never + } + } else { + this.form = opts.form + this.fieldsMap = opts.fields + } + + this.store = new Derived({ + deps: [this.form.store], + fn: ({ currDepVals }) => { + const currFormStore = currDepVals[0] + let values: TFieldGroupData + if (typeof this.fieldsMap === 'string') { + // all values live at that name, so we can directly fetch it + values = getBy(currFormStore.values, this.fieldsMap) + } else { + // we need to fetch the values from all places where they were mapped from + values = {} as never + const fields: Record = this + .fieldsMap as never + for (const key in fields) { + values[key] = getBy(currFormStore.values, fields[key]) + } + } + + return { + values, + } + }, + }) + } + + /** + * Mounts the field group instance to listen to value changes. + */ + mount = () => { + const cleanup = this.store.mount() + + return cleanup + } + + /** + * 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, + ) => { + return 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, + ) => { + return 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< + TFieldGroupData, + TField + > + } + + /** + * Gets the metadata of the specified field. + */ + getFieldMeta = >(field: TField) => { + return this.form.getFieldMeta(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 < + TField extends DeepKeysOfType, + >( + 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 < + TField extends DeepKeysOfType, + >( + 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 < + TField extends DeepKeysOfType, + >( + 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, + ) + } + + clearFieldValues = >( + field: TField, + opts?: UpdateMetaOptions, + ) => { + return this.form.clearFieldValues(this.getFormFieldName(field), opts) + } + + /** + * Resets the field value and meta to default state + */ + resetField = >(field: TField) => { + return this.form.resetField(this.getFormFieldName(field)) + } + + reset = () => this.form.reset() + + validateAllFields = (cause: ValidationCause) => + this.form.validateAllFields(cause) +} diff --git a/packages/form-core/src/FormApi.ts b/packages/form-core/src/FormApi.ts index 611c55a03..1dfb98e97 100644 --- a/packages/form-core/src/FormApi.ts +++ b/packages/form-core/src/FormApi.ts @@ -30,6 +30,7 @@ import type { } from './FieldApi' import type { ExtractGlobalFormError, + FieldManipulator, FormValidationError, FormValidationErrorMap, UpdateMetaOptions, @@ -316,6 +317,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. */ @@ -330,11 +345,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. */ @@ -377,11 +388,6 @@ export interface FormOptions< TOnSubmitAsync > - /** - * onSubmitMeta, the data passed from the handleSubmit handler, to the onSubmit function props - */ - onSubmitMeta?: TSubmitMeta - /** * form level listeners */ @@ -780,7 +786,8 @@ export class FormApi< in out TOnSubmitAsync extends undefined | FormAsyncValidateOrFn, in out TOnServer extends undefined | FormAsyncValidateOrFn, in out TSubmitMeta = never, -> { +> implements FieldManipulator +{ /** * The options for the form. */ diff --git a/packages/form-core/src/index.ts b/packages/form-core/src/index.ts index e74499d9d..87f2ad071 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 './FieldGroupApi' diff --git a/packages/form-core/src/types.ts b/packages/form-core/src/types.ts index b2a61ac1c..bdfece8d6 100644 --- a/packages/form-core/src/types.ts +++ b/packages/form-core/src/types.ts @@ -1,4 +1,6 @@ -import type { DeepKeys } from './util-types' +import type { AnyFieldMeta, AnyFieldMetaBase } from './FieldApi' +import type { DeepKeys, DeepKeysOfType, DeepValue } from './util-types' +import type { Updater } from './utils' export type ValidationError = unknown @@ -117,3 +119,152 @@ export interface UpdateMetaOptions { */ dontUpdateMeta?: boolean } + +/** + * @private + * A list of field manipulation methods that a form-like API must implement. + */ +export interface FieldManipulator { + /** + * 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: < + TField extends DeepKeysOfType, + >( + field: TField, + index: number, + cause: ValidationCause, + ) => Promise + + /** + * Validates a specified field in the form using the correct handlers for a given validation type. + */ + validateField: >( + field: TField, + cause: ValidationCause, + ) => unknown[] | Promise + + /** + * Handles the form submission, performs validation, and calls the appropriate onSubmit or onSubmitInvalid callbacks. + */ + handleSubmit(): Promise + handleSubmit(submitMeta: TSubmitMeta): Promise + + /** + * Gets the value of the specified field. + */ + getFieldValue: >( + field: TField, + ) => DeepValue + + /** + * Gets the metadata of the specified field. + */ + getFieldMeta: >( + field: TField, + ) => AnyFieldMeta | undefined + + /** + * Updates the metadata of the specified field. + */ + setFieldMeta: >( + field: TField, + updater: Updater, + ) => void + + /** + * Sets the value of the specified field and optionally updates the touched state. + */ + setFieldValue: >( + field: TField, + updater: Updater>, + opts?: UpdateMetaOptions, + ) => void + + /** + * Delete the specified field. Also deletes all subfields if there are any. + */ + deleteField: >(field: TField) => void + + /** + * Pushes a value into an array field. + */ + pushFieldValue: >( + field: TField, + value: DeepValue extends any[] + ? DeepValue[number] + : never, + opts?: UpdateMetaOptions, + ) => void + + /** + * Insert a value into an array field at the specified index. + */ + insertFieldValue: >( + field: TField, + index: number, + value: DeepValue extends any[] + ? DeepValue[number] + : never, + opts?: UpdateMetaOptions, + ) => Promise + + /** + * Replaces a value into an array field at the specified index. + */ + replaceFieldValue: >( + field: TField, + index: number, + value: DeepValue extends any[] + ? DeepValue[number] + : never, + opts?: UpdateMetaOptions, + ) => Promise + + /** + * Removes a value from an array field at the specified index. + */ + removeFieldValue: >( + field: TField, + index: number, + opts?: UpdateMetaOptions, + ) => Promise + + /** + * Swaps the values at the specified indices within an array field. + */ + swapFieldValues: >( + field: TField, + index1: number, + index2: number, + opts?: UpdateMetaOptions, + ) => void + + /** + * 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, + ) => void + + /** + * Clear all values within an array field. + */ + clearFieldValues: >( + field: TField, + opts?: UpdateMetaOptions, + ) => void + + /** + * Resets the field value and meta to default state + */ + resetField: >(field: TField) => void +} diff --git a/packages/form-core/src/util-types.ts b/packages/form-core/src/util-types.ts index 4049e1cbe..836b7b49b 100644 --- a/packages/form-core/src/util-types.ts +++ b/packages/form-core/src/util-types.ts @@ -177,3 +177,19 @@ export type DeepKeysOfType = Extract< DeepKeysAndValues, AnyDeepKeyAndValue >['key'] + +/** + * Maps the deep keys of TFormData to the shallow keys of TFieldGroupData. + * Since using template strings as keys is impractical, it relies on shallow keys only. + */ +export type FieldsMap = + TFieldGroupData extends any[] + ? never + : string extends keyof TFieldGroupData + ? never + : { + [K in keyof TFieldGroupData]: DeepKeysOfType< + TFormData, + TFieldGroupData[K] + > + } diff --git a/packages/form-core/src/utils.ts b/packages/form-core/src/utils.ts index 99ecaf2a4..3b4ebd249 100644 --- a/packages/form-core/src/utils.ts +++ b/packages/form-core/src/utils.ts @@ -179,6 +179,20 @@ export function makePathArray(str: string | Array) { ) } +/** + * @private + */ +export function concatenatePaths(path1: string, path2: string): string { + if (path1.length === 0) return path2 + if (path2.length === 0) return path1 + + if (path2.startsWith('[')) { + return path1 + path2 + } + + return `${path1}.${path2}` +} + /** * @private */ @@ -454,3 +468,13 @@ export const determineFieldLevelErrorSourceAndValue = ({ return { newErrorValue: undefined, newSource: undefined } } + +export function createFieldMap(values: Readonly): { [K in keyof T]: K } { + const output: { [K in keyof T]: K } = {} as any + + for (const key in values) { + output[key] = key + } + + return output +} diff --git a/packages/form-core/tests/FieldGroupApi.spec.ts b/packages/form-core/tests/FieldGroupApi.spec.ts new file mode 100644 index 000000000..1d7d160de --- /dev/null +++ b/packages/form-core/tests/FieldGroupApi.spec.ts @@ -0,0 +1,891 @@ +import { describe, expect, it, vi } from 'vitest' +import { FieldApi, FieldGroupApi, FormApi } from '../src/index' +import { defaultFieldMeta } from '../src/metaHelper' + +describe('field group 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: 'fieldGroup one', + age: 1, + }, + { + name: 'fieldGroup two', + age: 2, + }, + ], + relatives: { + father: { + name: 'fieldGroup three', + age: 3, + }, + }, + } + + const form = new FormApi({ + defaultValues, + }) + form.mount() + + const fieldGroup1 = new FieldGroupApi({ + form, + fields: 'people[0]', + defaultValues: {} as Person, + }) + const fieldGroup2 = new FieldGroupApi({ + form, + fields: 'people[1]', + defaultValues: {} as Person, + }) + const fieldGroup3 = new FieldGroupApi({ + form, + fields: 'relatives.father', + defaultValues: {} as Person, + }) + fieldGroup1.mount() + fieldGroup2.mount() + fieldGroup3.mount() + + expect(fieldGroup1.state).toMatchObject({ + values: { + name: 'fieldGroup one', + age: 1, + }, + }) + expect(fieldGroup2.state).toMatchObject({ + values: { + name: 'fieldGroup two', + age: 2, + }, + }) + expect(fieldGroup3.state).toMatchObject({ + values: { + name: 'fieldGroup three', + age: 3, + }, + }) + }) + + 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 fieldGroup = new FieldGroupApi({ + form, + defaultValues: {} as Person, + fields: 'relatives.father', + }) + fieldGroup.mount() + + expect(fieldGroup.state.values).toEqual(form.state.values.relatives.father) + + form.setFieldValue('relatives.father.name', 'New name') + form.setFieldValue('relatives.father.age', 50) + + expect(fieldGroup.state.values).toEqual(form.state.values.relatives.father) + + fieldGroup.setFieldValue('name', 'Second new name') + fieldGroup.setFieldValue('age', 100) + + expect(fieldGroup.state.values).toEqual(form.state.values.relatives.father) + fieldGroup.reset() + + expect(fieldGroup.state.values).toEqual(form.state.values.relatives.father) + }) + + it('should validate the right field from the form', () => { + const defaultValues: FormValues = { + name: '', + age: 0, + people: [ + { + name: 'fieldGroup one', + age: 1, + }, + { + name: 'fieldGroup two', + age: 2, + }, + ], + relatives: { + father: { + name: 'fieldGroup 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 fieldGroup1 = new FieldGroupApi({ + form, + fields: 'people[0]', + defaultValues: {} as Person, + }) + const fieldGroup2 = new FieldGroupApi({ + form, + fields: 'people[1]', + defaultValues: {} as Person, + }) + const fieldGroup3 = new FieldGroupApi({ + form, + fields: 'relatives.father', + defaultValues: {} as Person, + }) + fieldGroup1.mount() + fieldGroup2.mount() + fieldGroup3.mount() + + fieldGroup1.validateField('age', 'change') + + expect(field1.state.meta.errors).toEqual(['Field 1']) + expect(field2.state.meta.errors).toEqual([]) + expect(field3.state.meta.errors).toEqual([]) + + fieldGroup2.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([]) + + fieldGroup3.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: 'fieldGroup one', + age: 1, + }, + { + name: 'fieldGroup two', + age: 2, + }, + ], + relatives: { + father: { + name: 'fieldGroup three', + age: 3, + }, + }, + } + + const form = new FormApi({ + defaultValues, + }) + form.mount() + + const fieldGroup1 = new FieldGroupApi({ + form, + fields: 'people[0]', + defaultValues: {} as Person, + }) + const fieldGroup2 = new FieldGroupApi({ + form, + fields: 'people[1]', + defaultValues: {} as Person, + }) + const fieldGroup3 = new FieldGroupApi({ + form, + fields: 'relatives.father', + defaultValues: {} as Person, + }) + fieldGroup1.mount() + fieldGroup2.mount() + fieldGroup3.mount() + + expect(fieldGroup1.getFieldValue('age')).toBe(1) + expect(fieldGroup1.getFieldValue('name')).toBe('fieldGroup one') + + expect(fieldGroup2.getFieldValue('age')).toBe(2) + expect(fieldGroup2.getFieldValue('name')).toBe('fieldGroup two') + + expect(fieldGroup3.getFieldValue('age')).toBe(3) + expect(fieldGroup3.getFieldValue('name')).toBe('fieldGroup three') + }) + + it('should get the correct field Meta from the nested field', () => { + const defaultValues: FormValues = { + name: '', + age: 0, + people: [ + { + name: 'fieldGroup one', + age: 1, + }, + { + name: 'fieldGroup two', + age: 2, + }, + ], + relatives: { + father: { + name: 'fieldGroup 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 fieldGroup1 = new FieldGroupApi({ + form, + fields: 'people[0]', + defaultValues: {} as Person, + }) + const fieldGroup2 = new FieldGroupApi({ + form, + fields: 'people[1]', + defaultValues: {} as Person, + }) + const fieldGroup3 = new FieldGroupApi({ + form, + fields: 'relatives.father', + defaultValues: {} as Person, + }) + fieldGroup1.mount() + fieldGroup2.mount() + fieldGroup3.mount() + + expect(fieldGroup1.getFieldMeta('age')?.isValid).toBe(true) + expect(fieldGroup2.getFieldMeta('age')?.isValid).toBe(true) + expect(fieldGroup3.getFieldMeta('age')?.isValid).toBe(false) + + expect(fieldGroup1.getFieldMeta('age')?.isDirty).toBe(true) + expect(fieldGroup2.getFieldMeta('age')?.isDirty).toBe(false) + expect(fieldGroup3.getFieldMeta('age')?.isDirty).toBe(false) + + expect(fieldGroup1.getFieldMeta('age')?.isBlurred).toBe(false) + expect(fieldGroup2.getFieldMeta('age')?.isBlurred).toBe(true) + expect(fieldGroup3.getFieldMeta('age')?.isBlurred).toBe(false) + }) + + it('should be compliant with top level array defaultValues', () => { + const form = new FormApi({ + defaultValues: { people: [{ name: 'Default' }, { name: 'Default' }] }, + }) + form.mount() + + const fieldGroup = new FieldGroupApi({ + form, + defaultValues: [{ name: '' }], + fields: 'people', + }) + fieldGroup.mount() + + fieldGroup.setFieldValue('[0]', { name: 'Override One' }) + fieldGroup.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 fieldGroup = new FieldGroupApi({ + form, + defaultValues: { + names: [{ name: '' }], + }, + fields: 'people', + }) + fieldGroup.mount() + + fieldGroup.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 fieldGroup = new FieldGroupApi({ + defaultValues: { name: '' }, + form, + fields: 'person', + }) + fieldGroup.mount() + + fieldGroup.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 fieldGroup = new FieldGroupApi({ + defaultValues: { name: '' }, + form, + fields: 'nested.field', + }) + fieldGroup.mount() + + fieldGroup.setFieldValue('name', 'Nested') + + expect(form.state.values.nested.field.name).toEqual('Nested') + + fieldGroup.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 fieldGroup = new FieldGroupApi({ + defaultValues: { name: '' }, + form, + fields: 'nested.field', + }) + fieldGroup.mount() + + fieldGroup.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 fieldGroup = new FieldGroupApi({ + defaultValues: { names: [{ name: '' }] }, + form, + fields: 'people', + }) + fieldGroup.mount() + + fieldGroup.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) + + fieldGroup.pushFieldValue('names', { name: 'Push' }) + + expect(form.getFieldValue('people.names')).toEqual([ + { + name: '', + }, + { + name: '', + }, + { + name: '', + }, + { + name: 'Push', + }, + ]) + + fieldGroup.insertFieldValue('names', 1, { name: 'Insert' }) + + expect(form.getFieldValue('people.names')).toEqual([ + { + name: '', + }, + { + name: 'Insert', + }, + { + name: '', + }, + { + name: '', + }, + { + name: 'Push', + }, + ]) + + fieldGroup.replaceFieldValue('names', 2, { name: 'Replace' }) + + expect(form.getFieldValue('people.names')).toEqual([ + { + name: '', + }, + { + name: 'Insert', + }, + { + name: 'Replace', + }, + { + name: '', + }, + { + name: 'Push', + }, + ]) + + fieldGroup.removeFieldValue('names', 3) + + expect(form.getFieldValue('people.names')).toEqual([ + { + name: '', + }, + { + name: 'Insert', + }, + { + name: 'Replace', + }, + { + name: 'Push', + }, + ]) + + fieldGroup.swapFieldValues('names', 2, 3) + + expect(form.getFieldValue('people.names')).toEqual([ + { + name: '', + }, + { + name: 'Insert', + }, + { + name: 'Push', + }, + { + name: 'Replace', + }, + ]) + + fieldGroup.moveFieldValues('names', 0, 2) + + expect(form.getFieldValue('people.names')).toEqual([ + { + name: 'Insert', + }, + { + name: 'Push', + }, + { + name: '', + }, + { + name: 'Replace', + }, + ]) + + fieldGroup.clearFieldValues('names') + + expect(form.getFieldValue('people.names')).toEqual([]) + }) + + it('should allow nesting form fieldGroupes 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, + }) + form.mount() + + const fieldGroupWrap = new FieldGroupApi({ + defaultValues: defaultValues.form, + form, + fields: 'form', + }) + fieldGroupWrap.mount() + + const fieldGroupNested = new FieldGroupApi({ + defaultValues: defaultValues.form.field, + form: fieldGroupWrap, + fields: 'field', + }) + fieldGroupNested.mount() + + expect(fieldGroupNested.form).toEqual(fieldGroupWrap.form) + expect(fieldGroupNested.state.values).toEqual( + fieldGroupWrap.state.values.field, + ) + expect(fieldGroupNested.state.values).toEqual(form.state.values.form.field) + }) + + it('should allow remapping values for fieldGroups', () => { + type FormVals = { + a: string + b: string + } + + const defaultValues: FormVals = { + a: 'A', + b: 'B', + } + const group = { + firstName: '', + lastName: '', + } + + const form = new FormApi({ + defaultValues, + }) + form.mount() + + const fieldGroup = new FieldGroupApi({ + form, + fields: { + firstName: 'a', + lastName: 'b', + }, + defaultValues: group, + }) + fieldGroup.mount() + + expect(fieldGroup.state.values.firstName).toBe('A') + expect(fieldGroup.state.values.lastName).toBe('B') + expect(fieldGroup.getFormFieldName('firstName')).toBe('a') + expect(fieldGroup.getFormFieldName('lastName')).toBe('b') + }) + + it('should not crash on top-level array defaultValues', () => { + const defaultValues = { + firstName: '', + lastName: '', + } + + const form = new FormApi({ + defaultValues: { a: '', b: '' }, + }) + form.mount() + + const fieldGroup = new FieldGroupApi({ + defaultValues: [defaultValues], + form, + // @ts-expect-error Typing saves us here, but this edge case needs to be guarded either way + fields: {}, + }) + fieldGroup.mount() + + expect(() => + fieldGroup.getFormFieldName('[0].firstName'), + ).not.toThrowError() + expect(fieldGroup.getFormFieldName('[0].firstName')).toBeDefined() + }) + + it('should allow remapping with nested field groups', () => { + const formValues = { + a: 'A', + b: 'B', + } + + const form = new FormApi({ + defaultValues: formValues, + }) + form.mount() + + const groupWrapper = new FieldGroupApi({ + form, + defaultValues: { foo: '', bar: '' }, + fields: { + bar: 'b', + foo: 'a', + }, + }) + groupWrapper.mount() + + const groupNested = new FieldGroupApi({ + form: groupWrapper, + defaultValues: { shouldBeA: '', shouldBeB: '' }, + fields: { + shouldBeA: 'foo', + shouldBeB: 'bar', + }, + }) + groupNested.mount() + + expect(groupNested.state.values.shouldBeA).toBe('A') + expect(groupNested.state.values.shouldBeB).toBe('B') + expect(groupNested.getFormFieldName('shouldBeA')).toBe('a') + expect(groupNested.getFormFieldName('shouldBeB')).toBe('b') + }) + + it('should allow setting and resetting field meta in field groups', () => { + const form = new FormApi({ + defaultValues: { + person: { + firstName: '', + }, + }, + }) + form.mount() + + const group = new FieldGroupApi({ + defaultValues: { firstName: '' }, + form, + fields: 'person', + }) + group.mount() + + group.setFieldMeta('firstName', (p) => ({ ...p, isTouched: true })) + + expect(form.getFieldMeta('person.firstName')?.isTouched).toBe(true) + }) + + it('should forward validateAllFields to the form', async () => { + vi.useFakeTimers() + const form = new FormApi({ + defaultValues: { + person: { + firstName: '', + }, + }, + }) + form.mount() + + const field = new FieldApi({ + form, + name: 'person.firstName', + validators: { + onChange: () => 'Error', + }, + }) + field.mount() + + const group = new FieldGroupApi({ + defaultValues: { firstName: '' }, + form, + fields: 'person', + }) + group.mount() + + group.validateAllFields('change') + + await vi.runAllTimersAsync() + + expect(form.state.isValid).toBe(false) + expect(field.state.meta.isValid).toBe(false) + expect(form.getAllErrors().fields['person.firstName'].errors).toEqual([ + 'Error', + ]) + }) +}) diff --git a/packages/form-core/tests/FieldGroupApi.test-d.ts b/packages/form-core/tests/FieldGroupApi.test-d.ts new file mode 100644 index 000000000..1e0ed9e49 --- /dev/null +++ b/packages/form-core/tests/FieldGroupApi.test-d.ts @@ -0,0 +1,204 @@ +import { describe, expectTypeOf, it } from 'vitest' +import { FieldGroupApi, FormApi } from '../src' + +describe('fieldGroupApi', () => { + it('should have the correct properties based on defaultValues', () => { + const form = new FormApi({ + defaultValues: { + a: '', + b: '', + }, + }) + + const group = new FieldGroupApi({ + form, + defaultValues: { foo: '', bar: '' }, + fields: { + foo: 'a', + bar: 'b', + }, + }) + + expectTypeOf(group.state.values).toEqualTypeOf<{ + foo: string + bar: string + }>() + expectTypeOf(group.getFieldValue) + .parameter(0) + .toEqualTypeOf<'foo' | 'bar'>() + }) + + it('should have strict typing for meta if specified', () => { + const defaultValues = { + a: '', + b: '', + } + const groupValues = { + foo: '', + } + const fields = { + foo: 'a', + } as const + + const correctMeta = { + action: '', + } + const wrongMeta = { + action: 0, + } + + const formNoMeta = new FormApi({ + defaultValues, + }) + + const formWithMeta = new FormApi({ + defaultValues, + onSubmitMeta: correctMeta, + }) + + const formWithWrongMeta = new FormApi({ + defaultValues, + onSubmitMeta: wrongMeta, + }) + + // When no meta is specified, any meta should do + const correctGroup1 = new FieldGroupApi({ + form: formNoMeta, + defaultValues: groupValues, + fields, + }) + const correctGroup2 = new FieldGroupApi({ + form: formWithMeta, + defaultValues: groupValues, + fields, + }) + const correctGroup3 = new FieldGroupApi({ + form: formWithWrongMeta, + defaultValues: groupValues, + fields, + }) + + const wrongGroup1 = new FieldGroupApi({ + // @ts-expect-error + form: formNoMeta, + defaultValues: groupValues, + fields, + onSubmitMeta: correctMeta, + }) + const correctGroup4 = new FieldGroupApi({ + form: formWithMeta, + defaultValues: groupValues, + fields, + onSubmitMeta: correctMeta, + }) + const wrongGroup2 = new FieldGroupApi({ + form: formWithWrongMeta, + defaultValues: groupValues, + fields, + // @ts-expect-error + onSubmitMeta: correctMeta, + }) + }) + + it('should allow wrapping groups in other groups', () => { + const defaultValues = { + a: '', + b: '', + } + + const groupWrapperValues = { + foo: '', + } + + const groupNestedValues = { + bar: '', + } + + const form = new FormApi({ + defaultValues, + }) + + const fieldGroupWrapper = new FieldGroupApi({ + defaultValues: groupWrapperValues, + form, + fields: { + foo: 'a', + }, + }) + + const fieldGroupNested = new FieldGroupApi({ + defaultValues: groupNestedValues, + form: fieldGroupWrapper, + fields: { + bar: 'foo', + }, + }) + }) + + it('should allow mapping fields to field groups', () => { + const defaultValues = { + a: '', + b: '', + c: 0, + d: { e: '', f: 0 }, + } + + const form = new FormApi({ + defaultValues, + }) + + const group = new FieldGroupApi({ + form, + defaultValues: { canBeA: '', orB: '', notC: '', butE: '', notF: '' }, + fields: { + canBeA: 'a', + orB: 'b', + // @ts-expect-error + notC: 'c', + butE: 'd.e', + // @ts-expect-error + notF: 'f', + }, + }) + + const prefixGroup = new FieldGroupApi({ + form, + defaultValues: { e: '', f: 0 }, + fields: 'd', + }) + }) + + it('should allow null and undefined for fields when string', () => { + type FormValues = { + foo: + | { + bar: string + } + | null + | undefined + } + + const defaultValues: FormValues = { + foo: { bar: '' }, + } + + const form = new FormApi({ + defaultValues, + }) + + const group = new FieldGroupApi({ + form, + defaultValues: { bar: '' }, + fields: 'foo', + }) + + const wrongGroup = new FieldGroupApi({ + form, + defaultValues: { bar: '' }, + fields: { + // @ts-expect-error + bar: 'foo.bar', + }, + }) + }) +}) diff --git a/packages/form-core/tests/FormApi.spec.ts b/packages/form-core/tests/FormApi.spec.ts index b59bb1755..4259e3c78 100644 --- a/packages/form-core/tests/FormApi.spec.ts +++ b/packages/form-core/tests/FormApi.spec.ts @@ -3821,3 +3821,59 @@ it('should preserve nested fields on resetField if defaultValues is not provided form.resetField('nested.field.name') expect(form.state.values.nested.field.name).toEqual('Nested') }) + +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/util-types.test-d.ts b/packages/form-core/tests/util-types.test-d.ts index 67086c745..2df6827e1 100644 --- a/packages/form-core/tests/util-types.test-d.ts +++ b/packages/form-core/tests/util-types.test-d.ts @@ -1,5 +1,10 @@ -import { expectTypeOf } from 'vitest' -import type { DeepKeys, DeepKeysOfType, DeepValue } from '../src/index' +import { describe, expectTypeOf, it } from 'vitest' +import type { + DeepKeys, + DeepKeysOfType, + DeepValue, + FieldsMap, +} from '../src/index' /** * Properly recognizes that `0` is not an object and should not have subkeys @@ -446,3 +451,72 @@ type AnyObjectExample4 = DeepValue expectTypeOf(0 as never as AnyObjectExample4).toEqualTypeOf() type AnyObjectExample5 = DeepValue expectTypeOf(0 as never as AnyObjectExample5).toEqualTypeOf() + +describe('FieldsMap', () => { + it('should map to all available types', () => { + type FormData = { + user: { + name: string + accounts: { + provider: string + id: number + }[] + } + metadata: { + created: string + tags: string[] + } + matrix: { values: number[][] }[] + } + + type FieldGroup = { + stringField1: string + stringField2: string + stringArray: string[] + numberField: number + } + + type Result = FieldsMap + + expectTypeOf().toEqualTypeOf<{ + stringField1: + | 'user.name' + | `user.accounts[${number}].provider` + | 'metadata.created' + | `metadata.tags[${number}]` + stringField2: + | 'user.name' + | `user.accounts[${number}].provider` + | 'metadata.created' + | `metadata.tags[${number}]` + stringArray: 'metadata.tags' + numberField: + | `user.accounts[${number}].id` + | `matrix[${number}].values[${number}][${number}]` + }>() + }) + + it('should return never if no path matches the target type', () => { + type FormData = { + id: string + } + + type FieldGroup = { + shouldNotExist: number + } + + type Result = FieldsMap + + expectTypeOf().toEqualTypeOf<{ + shouldNotExist: never + }>() + }) + + it('should return nevr for non-indexable types', () => { + type TopLevelArray = FieldsMap + type TopLevelObject = FieldsMap> + + expectTypeOf().toBeNever() + expectTypeOf().toBeNever() + }) +}) diff --git a/packages/form-core/tests/utils.spec.ts b/packages/form-core/tests/utils.spec.ts index 27464cac9..7c867d302 100644 --- a/packages/form-core/tests/utils.spec.ts +++ b/packages/form-core/tests/utils.spec.ts @@ -1,5 +1,7 @@ -import { describe, expect, it } from 'vitest' +import { describe, expect, expectTypeOf, it } from 'vitest' import { + concatenatePaths, + createFieldMap, deleteBy, determineFieldLevelErrorSourceAndValue, determineFormLevelErrorSourceAndValue, @@ -628,3 +630,75 @@ describe('evaluate', () => { expect(objComplexTrue).toEqual(true) }) }) + +describe('concatenatePaths', () => { + it('should concatenate two object accessors with dot', () => { + expect(concatenatePaths('user', 'name')).toBe('user.name') + }) + + it('should join array accessor and object path directly', () => { + expect(concatenatePaths('users', '[0]')).toBe('users[0]') + }) + + it('should join object accessor after array accessor with dot', () => { + expect(concatenatePaths('users[0]', 'name')).toBe('users[0].name') + }) + + it('should append array accessor after array accessor directly', () => { + expect(concatenatePaths('users[0]', '[1]')).toBe('users[0][1]') + }) + + it('should join object accessor after object accessor with dot', () => { + expect(concatenatePaths('profile', 'settings.theme')).toBe( + 'profile.settings.theme', + ) + expect(concatenatePaths('settings.theme', 'profile')).toBe( + 'settings.theme.profile', + ) + }) + + it('should handle empty paths', () => { + expect(concatenatePaths('', 'name')).toBe('name') + expect(concatenatePaths('user', '')).toBe('user') + expect(concatenatePaths('', '')).toBe('') + }) + + it('should handle complex nesting with array and object accessors', () => { + expect(concatenatePaths('data[0].items[2]', 'value')).toBe( + 'data[0].items[2].value', + ) + expect(concatenatePaths('data', '[1].value')).toBe('data[1].value') + }) +}) + +describe('createFieldMap', () => { + it('should return an empty object when given an empty object', () => { + const result = createFieldMap({}) + expect(result).toEqual({}) + expectTypeOf(result).toEqualTypeOf<{}>() + }) + + it('should map each key to its own name as a string', () => { + const input = { a: 1, b: 2 } + const result = createFieldMap(input) + expect(result).toEqual({ a: 'a', b: 'b' }) + expectTypeOf(result).toEqualTypeOf<{ a: 'a'; b: 'b' }>() + }) + + it('should handle keys with special characters or numbers', () => { + const input = { '1key': 42, 'space key': 'x' } + const result = createFieldMap(input) + expect(result).toEqual({ '1key': '1key', 'space key': 'space key' }) + expectTypeOf(result).toEqualTypeOf<{ + '1key': '1key' + 'space key': 'space key' + }>() + }) + + it('should not mutate the input object', () => { + const input = { a: 1 } + const copy = { ...input } + createFieldMap(input) + expect(input).toEqual(copy) + }) +}) diff --git a/packages/react-form/src/createFormHook.tsx b/packages/react-form/src/createFormHook.tsx index 6a4140768..2ce98675d 100644 --- a/packages/react-form/src/createFormHook.tsx +++ b/packages/react-form/src/createFormHook.tsx @@ -1,10 +1,14 @@ +/* eslint-disable @eslint-react/no-context-provider */ import { createContext, useContext, useMemo } from 'react' import { useForm } from './useForm' +import { useFieldGroup } from './useFieldGroup' import type { AnyFieldApi, AnyFormApi, + BaseFormOptions, + DeepKeysOfType, FieldApi, - FormApi, + FieldsMap, FormAsyncValidateOrFn, FormOptions, FormValidateOrFn, @@ -12,6 +16,7 @@ import type { import type { ComponentType, Context, JSX, PropsWithChildren } from 'react' import type { FieldComponent } from './useField' import type { ReactFormExtendedApi } from './useForm' +import type { AppFieldExtendedReactFieldGroupApi } from './useFieldGroup' /** * TypeScript inferencing is weird. @@ -128,7 +133,10 @@ interface CreateFormHookProps< formContext: Context } -type AppFieldExtendedReactFormApi< +/** + * @private + */ +export type AppFieldExtendedReactFormApi< TFormData, TOnMount extends undefined | FormValidateOrFn, TOnChange extends undefined | FormValidateOrFn, @@ -220,6 +228,41 @@ export interface WithFormProps< ) => JSX.Element } +export interface WithFieldGroupProps< + TFieldGroupData, + 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 & { + group: AppFieldExtendedReactFieldGroupApi< + unknown, + TFieldGroupData, + string | FieldsMap, + 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>, @@ -362,8 +405,92 @@ export function createFormHook< return (innerProps) => render({ ...props, ...innerProps }) } + function withFieldGroup< + TFieldGroupData, + TSubmitMeta, + TRenderProps extends Record = {}, + >({ + render, + props, + defaultValues, + }: WithFieldGroupProps< + TFieldGroupData, + TComponents, + TFormComponents, + TSubmitMeta, + TRenderProps + >): < + TFormData, + TFields extends + | DeepKeysOfType + | FieldsMap, + 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 + > + | AppFieldExtendedReactFieldGroupApi< + // Since this only occurs if you nest it within other field groups, it can be more + // lenient with the types. + unknown, + TFormData, + string | FieldsMap, + any, + any, + any, + any, + any, + any, + any, + any, + unknown extends TSubmitMeta ? TFormSubmitMeta : TSubmitMeta, + TComponents, + TFormComponents + > + fields: TFields + } + >, + ) => JSX.Element { + return function Render(innerProps) { + const fieldGroupProps = useMemo(() => { + return { + form: innerProps.form, + fields: innerProps.fields, + defaultValues, + formComponents, + } + }, [innerProps.form, innerProps.fields]) + const fieldGroupApi = useFieldGroup(fieldGroupProps as any) + + return render({ ...props, ...innerProps, group: fieldGroupApi as any }) + } + } + return { useAppForm, withForm, + withFieldGroup, } } diff --git a/packages/react-form/src/index.ts b/packages/react-form/src/index.ts index 5f30ccd43..3177b2f53 100644 --- a/packages/react-form/src/index.ts +++ b/packages/react-form/src/index.ts @@ -10,5 +10,8 @@ export { useField, Field } from './useField' export { useTransform } from './useTransform' -export type { WithFormProps } from './createFormHook' +export type { + WithFormProps, + WithFieldGroupProps as 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/useFieldGroup.tsx b/packages/react-form/src/useFieldGroup.tsx new file mode 100644 index 000000000..2966947a4 --- /dev/null +++ b/packages/react-form/src/useFieldGroup.tsx @@ -0,0 +1,248 @@ +import { useState } from 'react' +import { useStore } from '@tanstack/react-store' +import { FieldGroupApi, functionalUpdate } from '@tanstack/form-core' +import { useIsomorphicLayoutEffect } from './useIsomorphicLayoutEffect' +import type { + AnyFieldGroupApi, + DeepKeysOfType, + FieldGroupState, + FieldsMap, + FormAsyncValidateOrFn, + FormValidateOrFn, +} from '@tanstack/form-core' +import type { AppFieldExtendedReactFormApi } from './createFormHook' +import type { ComponentType, PropsWithChildren, ReactNode } from 'react' +import type { LensFieldComponent } from './useField' + +function LocalSubscribe({ + lens, + selector, + children, +}: PropsWithChildren<{ + lens: AnyFieldGroupApi + selector: (state: FieldGroupState) => FieldGroupState +}>) { + const data = useStore(lens.store, selector) + + return functionalUpdate(children, data) +} + +/** + * @private + */ +export type AppFieldExtendedReactFieldGroupApi< + TFormData, + TFieldGroupData, + TFields extends + | DeepKeysOfType + | FieldsMap, + 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>, +> = FieldGroupApi< + TFormData, + TFieldGroupData, + TFields, + TOnMount, + TOnChange, + TOnChangeAsync, + TOnBlur, + TOnBlurAsync, + TOnSubmit, + TOnSubmitAsync, + TOnServer, + TSubmitMeta +> & + NoInfer & { + AppField: LensFieldComponent< + TFieldGroupData, + 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: >>(props: { + selector?: (state: NoInfer>) => TSelected + children: ((state: NoInfer) => ReactNode) | ReactNode + }) => ReactNode + } + +export function useFieldGroup< + TFormData, + TFieldGroupData, + TFields extends + | DeepKeysOfType + | FieldsMap, + 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, + TComponents extends Record>, + TFormComponents extends Record>, + TSubmitMeta = never, +>(opts: { + form: + | AppFieldExtendedReactFormApi< + TFormData, + TOnMount, + TOnChange, + TOnChangeAsync, + TOnBlur, + TOnBlurAsync, + TOnSubmit, + TOnSubmitAsync, + TOnServer, + TSubmitMeta, + TComponents, + TFormComponents + > + | AppFieldExtendedReactFieldGroupApi< + // Since this only occurs if you nest it within other form lenses, it can be more + // lenient with the types. + unknown, + TFormData, + string | FieldsMap, + any, + any, + any, + any, + any, + any, + any, + any, + TSubmitMeta, + TComponents, + TFormComponents + > + fields: TFields + defaultValues?: TFieldGroupData + onSubmitMeta?: TSubmitMeta + formComponents: TFormComponents +}): AppFieldExtendedReactFieldGroupApi< + TFormData, + TFieldGroupData, + TFields, + TOnMount, + TOnChange, + TOnChangeAsync, + TOnBlur, + TOnBlurAsync, + TOnSubmit, + TOnSubmitAsync, + TOnServer, + TSubmitMeta, + TComponents, + TFormComponents +> { + const [formLensApi] = useState(() => { + const api = new FieldGroupApi(opts) + const form = + opts.form instanceof FieldGroupApi + ? (opts.form.form as AppFieldExtendedReactFormApi< + TFormData, + TOnMount, + TOnChange, + TOnChangeAsync, + TOnBlur, + TOnBlurAsync, + TOnSubmit, + TOnSubmitAsync, + TOnServer, + TSubmitMeta, + TComponents, + TFormComponents + >) + : opts.form + + const extendedApi: AppFieldExtendedReactFieldGroupApi< + TFormData, + TFieldGroupData, + TFields, + 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 ( + + ) as never + } + + extendedApi.Field = function Field({ name, ...fieldProps }) { + return ( + + ) as never + } + + extendedApi.Subscribe = function Subscribe(props: any) { + return ( + + ) + } + + return Object.assign(extendedApi, { + ...opts.formComponents, + }) as AppFieldExtendedReactFieldGroupApi< + TFormData, + TFieldGroupData, + TFields, + TOnMount, + TOnChange, + TOnChangeAsync, + TOnBlur, + TOnBlurAsync, + TOnSubmit, + TOnSubmitAsync, + TOnServer, + TSubmitMeta, + TComponents, + TFormComponents + > + }) + + useIsomorphicLayoutEffect(formLensApi.mount, [formLensApi]) + + return formLensApi +} 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..3549253a2 100644 --- a/packages/react-form/tests/createFormHook.test-d.tsx +++ b/packages/react-form/tests/createFormHook.test-d.tsx @@ -1,5 +1,4 @@ import { describe, expectTypeOf, it } from 'vitest' -import { render } from '@testing-library/react' import { formOptions } from '@tanstack/form-core' import { createFormHook, createFormHookContexts } from '../src' @@ -10,7 +9,7 @@ function Test() { return null } -const { useAppForm, withForm } = createFormHook({ +const { useAppForm, withForm, withFieldGroup } = createFormHook({ fieldComponents: { Test, }, @@ -101,6 +100,26 @@ describe('createFormHook', () => { ) }, }) + + const ExampleUsage2 = withFieldGroup({ + defaultValues: {} as EditorValues, + render: ({ group }) => { + const test = group.state.values.key + return ( +
+ + {(field) => { + expectTypeOf(field.state.value).toExtend() + return null + }} + + + + +
+ ) + }, + }) }) it('types should be properly inferred when using formOptions', () => { @@ -196,12 +215,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 +269,554 @@ describe('createFormHook', () => { ) }) + + it('should infer subset values and props when calling withFieldGroup', () => { + type Person = { + firstName: string + lastName: string + } + type ComponentProps = { + prop1: string + prop2: number + } + + const defaultValues: Person = { + firstName: 'FirstName', + lastName: 'LastName', + } + + const FormGroupComponent = withFieldGroup({ + defaultValues, + render: function Render({ group, children, ...props }) { + // Existing types may be inferred + expectTypeOf(group.state.values.firstName).toEqualTypeOf() + expectTypeOf(group.state.values.lastName).toEqualTypeOf() + + expectTypeOf(group.state.values).toEqualTypeOf() + expectTypeOf(children).toEqualTypeOf() + expectTypeOf(props).toEqualTypeOf<{}>() + return + }, + }) + + const FormGroupComponentWithProps = withFieldGroup({ + ...defaultValues, + props: {} as ComponentProps, + render: ({ group, children, ...props }) => { + expectTypeOf(props).toEqualTypeOf<{ + prop1: string + prop2: number + }>() + return + }, + }) + }) + + it('should allow spreading formOptions when calling withFieldGroup', () => { + 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 = withFieldGroup({ + ...formOpts, + render: function Render({ group }) { + // Existing types may be inferred + expectTypeOf(group.state.values.firstName).toEqualTypeOf() + expectTypeOf(group.state.values.lastName).toEqualTypeOf() + return + }, + }) + + const noDefaultValuesFormOpts = formOptions({ + onSubmitMeta: { foo: '' }, + }) + + const UnknownFormGroupComponent = withFieldGroup({ + ...noDefaultValuesFormOpts, + render: function Render({ group }) { + // group.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(group.handleSubmit).parameters.toEqualTypeOf< + [] | [{ foo: string }] + >() + return + }, + }) + }) + + it('should allow passing compatible forms to withFieldGroup', () => { + type Person = { + firstName: string + lastName: string + } + type ComponentProps = { + prop1: string + prop2: number + } + + const defaultValues: Person = { + firstName: 'FirstName', + lastName: 'LastName', + } + + const FormGroup = withFieldGroup({ + 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 = ( + + ) + + // ----------------- + // 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 = ( + + ) + }) + + it('should require strict equal submitMeta if it is set in withFieldGroup', () => { + 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 = withFieldGroup({ + defaultValues: {} as Person, + render: function Render({ group }) { + // Since handleSubmit always allows to submit without meta, this is okay + group.handleSubmit() + + // To prevent unwanted meta behaviour, handleSubmit's meta should be never if not set. + expectTypeOf(group.handleSubmit).parameters.toEqualTypeOf< + [] | [submitMeta: never] + >() + + return + }, + }) + + const FormGroupWithMeta = withFieldGroup({ + defaultValues: {} as Person, + onSubmitMeta, + render: function Render({ group }) { + // Since handleSubmit always allows to submit without meta, this is okay + group.handleSubmit() + + // This matches the value + group.handleSubmit({ correct: '' }) + + // This does not. + // @ts-expect-error + group.handleSubmit({ wrong: 'Meta' }) + + return + }, + }) + + const noMetaForm = useAppForm({ + defaultValues, + }) + + const CorrectComponent1 = ( + + ) + + const WrongComponent1 = ( + + ) + + const metaForm = useAppForm({ + defaultValues, + onSubmitMeta, + }) + + const CorrectComponent2 = + const CorrectComponent3 = ( + + ) + + const diffMetaForm = useAppForm({ + defaultValues, + onSubmitMeta: { ...onSubmitMeta, something: 'else' }, + }) + + const CorrectComponent4 = ( + + ) + const WrongComponent2 = ( + + ) + }) + + it('should accept any validators for withFieldGroup', () => { + 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 = withFieldGroup({ + defaultValues: defaultValues.person, + render: function Render({ group }) { + return + }, + }) + + const CorrectComponent1 = + const CorrectComponent2 = + }) + + it('should allow nesting withFieldGroup in other withFieldGroups', () => { + 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 = withFieldGroup({ + defaultValues: defaultValues.form.field, + render: function Render() { + return <> + }, + }) + const LensWrapper = withFieldGroup({ + defaultValues: defaultValues.form, + render: function Render({ group }) { + return ( +
+ +
+ ) + }, + }) + + const Component = + }) + + it('should not allow withFieldGroups 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 = withFieldGroup({ + defaultValues: defaultValues.form.field, + render: function Render() { + return <> + }, + }) + const LensNestedWithMeta = withFieldGroup({ + defaultValues: defaultValues.form.field, + onSubmitMeta: { meta: '' }, + render: function Render() { + return <> + }, + }) + const LensWrapper = withFieldGroup({ + defaultValues: defaultValues.form, + render: function Render({ group }) { + return ( +
+ + +
+ ) + }, + }) + + it('should allow mapping withFieldGroup to different fields', () => { + const defaultValues = { + firstName: '', + lastName: '', + age: 0, + relatives: [{ firstName: '', lastName: '', age: 0 }], + } + const defaultFields = { + first: '', + last: '', + } + + const form = useAppForm({ + defaultValues, + }) + + const FieldGroup = withFieldGroup({ + defaultValues: defaultFields, + render: function Render() { + return <> + }, + }) + + const Component1 = ( + + ) + + const Component2 = ( + + ) + }) + + it('should not allow fields mapping if the top level is an array', () => { + const defaultValues = { + firstName: '', + lastName: '', + age: 0, + relatives: [{ firstName: '', lastName: '', age: 0 }], + relativesRecord: { + something: { firstName: '', lastName: '', age: 0 }, + } as Record, + } + const defaultFields = { + firstName: '', + lastName: '', + } + + const form = useAppForm({ + defaultValues, + }) + + const FieldGroupRecord = withFieldGroup({ + defaultValues: { anything: defaultFields } as Record< + string, + typeof defaultFields + >, + render: function Render() { + return <> + }, + }) + const FieldGroupArray = withFieldGroup({ + defaultValues: [defaultFields], + render: function Render() { + return <> + }, + }) + + const CorrectComponent1 = ( + + ) + const WrongComponent1 = ( + + ) + const CorrectComponent3 = ( + + ) + const WrongComponent2 = ( + + ) + }) + }) + + it('should allow mapping field groups to optional fields', () => { + const groupFields = { + name: '', + } + + type WrapperValues = { + namespace: { name: string } | undefined + namespace2: { name: string } | null + namespace3: { name: string } | null | undefined + nope: null | undefined + nope2: { lastName: string } | null | undefined + } + + const defaultValues: WrapperValues = { + namespace: undefined, + namespace2: null, + namespace3: null, + nope: null, + nope2: null, + } + + const FieldGroup = withFieldGroup({ + defaultValues: groupFields, + render: function Render() { + return <> + }, + }) + + const form = useAppForm({ + defaultValues, + }) + + const Component = + const Component2 = + const Component3 = + // @ts-expect-error because it doesn't ever evaluate to the expected values + const Component4 = + // @ts-expect-error because the types don't match properly + const Component5 = + }) }) diff --git a/packages/react-form/tests/createFormHook.test.tsx b/packages/react-form/tests/createFormHook.test.tsx index b535e626e..083ba211e 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, withFieldGroup } = createFormHook({ fieldComponents: { TextField, }, @@ -112,4 +115,339 @@ describe('createFormHook', () => { expect(input).toHaveValue('John') expect(getByText('Testing')).toBeInTheDocument() }) + + it('should handle withFieldGroup types properly', () => { + const formOpts = formOptions({ + defaultValues: { + person: { + firstName: 'John', + lastName: 'Doe', + }, + }, + }) + + const ChildForm = withFieldGroup({ + defaultValues: formOpts.defaultValues.person, + // Optional, but adds props to the `render` function outside of `form` + props: { + title: 'Child Form', + }, + render: ({ group, 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 withFieldGroup', () => { + const formOpts = formOptions({ + defaultValues: { + person: { + firstName: 'John', + lastName: 'Doe', + }, + people: [ + { + firstName: 'Jane', + lastName: 'Doe', + }, + { + firstName: 'Robert', + lastName: 'Doe', + }, + ], + }, + }) + + const ChildFormAsField = withFieldGroup({ + defaultValues: formOpts.defaultValues.person, + render: ({ group }) => { + return ( +
+ } + /> + + + +
+ ) + }, + }) + const ChildFormAsArray = withFieldGroup({ + defaultValues: [formOpts.defaultValues.person], + props: { + title: '', + }, + render: ({ group, 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 = withFieldGroup({ + defaultValues: formOpts.defaultValues.person, + render: ({ group }) => { + return ( +
+ ( + + )} + /> + 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 withFieldGroup', async () => { + const formOpts = formOptions({ + defaultValues: { + person: { + firstName: 'John', + lastName: 'Doe', + }, + }, + }) + + const ChildForm = withFieldGroup({ + defaultValues: formOpts.defaultValues.person, + render: function Render({ group }) { + const firstName = useStore( + group.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 withFieldGroup in other withFieldGroups', () => { + 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 = withFieldGroup({ + defaultValues: defaultValues.form.field, + render: function Render({ group }) { + return ( + + {(field) =>

{field.name}

} +
+ ) + }, + }) + const LensWrapper = withFieldGroup({ + defaultValues: defaultValues.form, + render: function Render({ group }) { + return ( +
+ +
+ ) + }, + }) + + const Parent = () => { + const form = useAppForm({ + defaultValues, + }) + return + } + + const { getByText } = render() + + expect(getByText('form.field.firstName')).toBeInTheDocument() + }) + + it('should allow mapping withFieldGroup to different values', () => { + const formOpts = formOptions({ + defaultValues: { + unrelated: 'John', + values: '', + }, + }) + + const ChildFormAsField = withFieldGroup({ + defaultValues: { firstName: '', lastName: '' }, + render: ({ group }) => { + return ( +
+ } + /> +
+ ) + }, + }) + + const Parent = () => { + const form = useAppForm({ + ...formOpts, + }) + + return ( + + ) + } + + const { getByLabelText } = render() + const inputField1 = getByLabelText('unrelated') + expect(inputField1).toHaveValue('John') + }) })