diff --git a/.changeset/bumpy-boats-roll.md b/.changeset/bumpy-boats-roll.md new file mode 100644 index 000000000..dc87841b4 --- /dev/null +++ b/.changeset/bumpy-boats-roll.md @@ -0,0 +1,5 @@ +--- +'@tanstack/form-core': patch +--- + +- Make `fieldMeta` record type `Partial<>` to reflect runtime behaviour diff --git a/packages/form-core/src/FormApi.ts b/packages/form-core/src/FormApi.ts index f18a348d3..80b14f58b 100644 --- a/packages/form-core/src/FormApi.ts +++ b/packages/form-core/src/FormApi.ts @@ -623,7 +623,7 @@ export type BaseFormState< /** * A record of field metadata for each field in the form, not including the derived properties, like `errors` and such */ - fieldMetaBase: Record, AnyFieldMetaBase> + fieldMetaBase: Partial, AnyFieldMetaBase>> /** * A boolean indicating if the form is currently in the process of being submitted after `handleSubmit` is called. * @@ -738,7 +738,7 @@ export type DerivedFormState< /** * A record of field metadata for each field in the form. */ - fieldMeta: Record, AnyFieldMeta> + fieldMeta: Partial, AnyFieldMeta>> } export interface FormState< @@ -929,8 +929,22 @@ export class FormApi< TOnServer > > - fieldMetaDerived!: Derived, AnyFieldMeta>> - store!: Derived< + fieldMetaDerived: Derived< + FormState< + TFormData, + TOnMount, + TOnChange, + TOnChangeAsync, + TOnBlur, + TOnBlurAsync, + TOnSubmit, + TOnSubmitAsync, + TOnDynamic, + TOnDynamicAsync, + TOnServer + >['fieldMeta'] + > + store: Derived< FormState< TFormData, TOnMount, @@ -1024,7 +1038,7 @@ export class FormApi< let originalMetaCount = 0 - const fieldMeta = {} as FormState< + const fieldMeta: FormState< TFormData, TOnMount, TOnChange, @@ -1036,7 +1050,7 @@ export class FormApi< TOnDynamic, TOnDynamicAsync, TOnServer - >['fieldMeta'] + >['fieldMeta'] = {} for (const fieldName of Object.keys( currBaseStore.fieldMetaBase, @@ -1642,7 +1656,6 @@ export class FormApi< for (const field of Object.keys( this.state.fieldMeta, ) as DeepKeys[]) { - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition if (this.baseStore.state.fieldMetaBase[field] === undefined) { continue } @@ -1850,7 +1863,6 @@ export class FormApi< for (const field of Object.keys( this.state.fieldMeta, ) as DeepKeys[]) { - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition if (this.baseStore.state.fieldMetaBase[field] === undefined) { continue } @@ -2205,15 +2217,15 @@ export class FormApi< * resets every field's meta */ resetFieldMeta = >( - fieldMeta: Record, - ): Record => { + fieldMeta: Partial>, + ): Partial> => { return Object.keys(fieldMeta).reduce( - (acc: Record, key) => { + (acc, key) => { const fieldKey = key as TField acc[fieldKey] = defaultFieldMeta return acc }, - {} as Record, + {} as Partial>, ) } diff --git a/packages/form-core/tests/FormApi.spec.ts b/packages/form-core/tests/FormApi.spec.ts index 901aa6dee..b3a8e695b 100644 --- a/packages/form-core/tests/FormApi.spec.ts +++ b/packages/form-core/tests/FormApi.spec.ts @@ -169,6 +169,35 @@ describe('form api', () => { expect(form.state.values).toEqual({ name: 'initial' }) }) + it('should handle multiple fields with mixed mount states', () => { + const form = new FormApi({ + defaultValues: { + firstName: '', + lastName: '', + email: '', + }, + }) + + const firstNameField = new FieldApi({ + form, + name: 'firstName', + }) + + firstNameField.mount() + + expect(form.state.fieldMeta.firstName).toBeDefined() + + expect(form.state.fieldMeta.email).toBeUndefined() + + const lastNameField = new FieldApi({ + form, + name: 'lastName', + }) + lastNameField.mount() + + expect(form.state.fieldMeta.lastName).toBeDefined() + }) + it("should get a field's value", () => { const form = new FormApi({ defaultValues: { @@ -1691,10 +1720,10 @@ describe('form api', () => { await form.handleSubmit() expect(form.state.isFieldsValid).toEqual(false) expect(form.state.canSubmit).toEqual(false) - expect(form.state.fieldMeta['firstName'].errors).toEqual([ + expect(form.state.fieldMeta['firstName']!.errors).toEqual([ 'first name is required', ]) - expect(form.state.fieldMeta['lastName'].errors).toEqual([ + expect(form.state.fieldMeta['lastName']!.errors).toEqual([ 'last name is required', ]) }) @@ -1730,10 +1759,10 @@ describe('form api', () => { await form.handleSubmit() expect(form.state.isFieldsValid).toEqual(false) expect(form.state.canSubmit).toEqual(false) - expect(form.state.fieldMeta['person.firstName'].errors).toEqual([ + expect(form.state.fieldMeta['person.firstName']!.errors).toEqual([ 'first name is required', ]) - expect(form.state.fieldMeta['person.lastName'].errors).toEqual([ + expect(form.state.fieldMeta['person.lastName']!.errors).toEqual([ 'last name is required', ]) }) @@ -1764,7 +1793,7 @@ describe('form api', () => { await form.handleSubmit() expect(form.state.isFieldsValid).toEqual(false) expect(form.state.canSubmit).toEqual(false) - expect(form.state.fieldMeta['firstName'].errors).toEqual([ + expect(form.state.fieldMeta['firstName']!.errors).toEqual([ 'first name is required', 'first name must be longer than 3 characters', ]) @@ -1873,7 +1902,7 @@ describe('form api', () => { await vi.runAllTimersAsync() expect(form.state.isFieldsValid).toEqual(false) expect(form.state.canSubmit).toEqual(false) - expect(form.state.fieldMeta['firstName'].errorMap).toEqual({ + expect(form.state.fieldMeta['firstName']!.errorMap).toEqual({ onChange: 'first name is required', onBlur: 'first name must be longer than 3 characters', }) @@ -1900,14 +1929,14 @@ describe('form api', () => { await form.handleSubmit() expect(form.state.isFieldsValid).toEqual(false) expect(form.state.canSubmit).toEqual(false) - expect(form.state.fieldMeta['firstName'].errorMap['onSubmit']).toEqual( + expect(form.state.fieldMeta['firstName']!.errorMap['onSubmit']).toEqual( 'first name is required', ) field.handleChange('test') expect(form.state.isFieldsValid).toEqual(true) expect(form.state.canSubmit).toEqual(true) expect( - form.state.fieldMeta['firstName'].errorMap['onSubmit'], + form.state.fieldMeta['firstName']!.errorMap['onSubmit'], ).toBeUndefined() }) diff --git a/packages/form-core/tests/fieldMeta.spec.ts b/packages/form-core/tests/fieldMeta.spec.ts new file mode 100644 index 000000000..33b4b96df --- /dev/null +++ b/packages/form-core/tests/fieldMeta.spec.ts @@ -0,0 +1,186 @@ +import { describe, expect, it } from 'vitest' +import { FieldApi, FormApi } from '../src/index' + +describe('fieldMeta accessing', () => { + it('should return undefined for unmounted fields', () => { + const form = new FormApi({ + defaultValues: { + name: '', + email: '', + }, + }) + + expect(form.state.fieldMeta.name).toBeUndefined() + expect(form.state.fieldMeta.email).toBeUndefined() + }) + + it('should have defined fieldMeta after field is mounted', () => { + const form = new FormApi({ + defaultValues: { + name: '', + }, + }) + + const field = new FieldApi({ + form, + name: 'name', + }) + + field.mount() + + expect(form.state.fieldMeta.name).toBeDefined() + expect(form.state.fieldMeta.name?.isValid).toBe(true) + expect(form.state.fieldMeta.name?.isTouched).toBe(false) + expect(form.state.fieldMeta.name?.isDirty).toBe(false) + }) + + it('should handle nested field paths', () => { + const form = new FormApi({ + defaultValues: { + user: { + profile: { + firstName: '', + lastName: '', + }, + }, + }, + }) + + expect(form.state.fieldMeta['user.profile.firstName']).toBeUndefined() + expect(form.state.fieldMeta['user.profile.lastName']).toBeUndefined() + + const firstNameField = new FieldApi({ + form, + name: 'user.profile.firstName', + }) + + firstNameField.mount() + + expect(form.state.fieldMeta['user.profile.firstName']).toBeDefined() + + expect(form.state.fieldMeta['user.profile.lastName']).toBeUndefined() + }) + + it('should handle array fields', () => { + const form = new FormApi({ + defaultValues: { + items: ['item1', 'item2'], + }, + }) + + expect(form.state.fieldMeta['items[0]']).toBeUndefined() + expect(form.state.fieldMeta['items[1]']).toBeUndefined() + + const field0 = new FieldApi({ + form, + name: 'items[0]', + }) + + field0.mount() + + expect(form.state.fieldMeta['items[0]']).toBeDefined() + expect(form.state.fieldMeta['items[1]']).toBeUndefined() + }) + + it('should handle getFieldMeta returning undefined', () => { + const form = new FormApi({ + defaultValues: { + name: '', + }, + }) + + const fieldMeta = form.getFieldMeta('name') + expect(fieldMeta).toBeUndefined() + + const field = new FieldApi({ + form, + name: 'name', + }) + + field.mount() + + const fieldMetaAfterMount = form.getFieldMeta('name') + expect(fieldMetaAfterMount).toBeDefined() + expect(fieldMetaAfterMount?.isValid).toBe(true) + }) + + it('should handle multiple fields with mixed mount states', () => { + const form = new FormApi({ + defaultValues: { + firstName: '', + lastName: '', + email: '', + }, + }) + + const firstNameField = new FieldApi({ + form, + name: 'firstName', + }) + + const lastNameField = new FieldApi({ + form, + name: 'lastName', + }) + + firstNameField.mount() + + expect(form.state.fieldMeta.firstName).toBeDefined() + expect(form.state.fieldMeta.email).toBeUndefined() + }) + + it('should preserve fieldMeta after unmounting and remounting', () => { + const form = new FormApi({ + defaultValues: { + name: '', + }, + }) + + const field = new FieldApi({ + form, + name: 'name', + }) + + const cleanup = field.mount() + + field.setValue('test') + expect(form.state.fieldMeta.name?.isTouched).toBe(true) + expect(form.state.fieldMeta.name?.isDirty).toBe(true) + + cleanup() + + const metaAfterCleanup = form.state.fieldMeta.name + + expect(metaAfterCleanup).toBeDefined() + }) + + it('should work with form validation that accesses fieldMeta', () => { + const form = new FormApi({ + defaultValues: { + password: '', + confirmPassword: '', + }, + validators: { + onChange: ({ value }) => { + if (value.password !== value.confirmPassword) { + return 'Passwords must match' + } + return undefined + }, + }, + }) + + form.mount() + + const passwordField = new FieldApi({ + form, + name: 'password', + }) + + passwordField.mount() + + expect(() => { + passwordField.setValue('test123') + }).not.toThrow() + }) +})