diff --git a/packages/form-core/tests/FieldApi/FieldApi.lib.spec.ts b/packages/form-core/tests/FieldApi/FieldApi.lib.spec.ts index 0cf633718e..a50f0e77d4 100644 --- a/packages/form-core/tests/FieldApi/FieldApi.lib.spec.ts +++ b/packages/form-core/tests/FieldApi/FieldApi.lib.spec.ts @@ -1,5 +1,6 @@ import { describe, expect, it, vi } from 'vitest' import { InternalFormApi } from '../../src/FormApi/FormApi.lib' +import { promiseWithResolvers } from '../test-utils' describe('getOrCreateFieldApi', () => { it('applies validators to a field whose value was set before registration', async () => { @@ -32,7 +33,402 @@ describe('getOrCreateFieldApi', () => { }) }) +describe('InternalFieldApi - name', () => { + it('reports names for nested object fields', () => { + const form = new InternalFormApi({ + defaultValues: { user: { profile: { name: 'Alice' } } }, + }) + const user = form._getOrCreateFieldApi({ name: 'user' }) + const profile = form._getOrCreateFieldApi({ name: 'user.profile' }) + const name = form._getOrCreateFieldApi({ name: 'user.profile.name' }) + + expect(user.name).toBe('user') + expect(profile.name).toBe('user.profile') + expect(name.name).toBe('user.profile.name') + }) + + it('reports names for array items and nested properties', () => { + const form = new InternalFormApi({ + defaultValues: { + items: [{ name: 'Alice' }], + user: { items: [{ name: 'Bob' }] }, + }, + }) + const item = form._getOrCreateFieldApi({ name: 'items[0]' }) + const name = form._getOrCreateFieldApi({ name: 'items[0].name' }) + const nestedName = form._getOrCreateFieldApi({ name: 'user.items[0].name' }) + + expect(item.name).toBe('items[0]') + expect(name.name).toBe('items[0].name') + expect(nestedName.name).toBe('user.items[0].name') + }) + + it('reports names with consecutive array indices', () => { + const form = new InternalFormApi({ + defaultValues: { items: [[['Alice'], ['Bob', 'Carol', 'Dan']]] }, + }) + const nestedArray = form._getOrCreateFieldApi({ name: 'items[0][1]' }) + const item = form._getOrCreateFieldApi({ name: 'items[0][1][2]' }) + + expect(nestedArray.name).toBe('items[0][1]') + expect(item.name).toBe('items[0][1][2]') + expect(item.value).toBe('Dan') + }) + + it('reports names inside a top-level array', () => { + const form = new InternalFormApi({ + defaultValues: [{ name: 'Alice' }], + }) + const item = form._getOrCreateFieldApi({ name: '[0]' }) + const name = form._getOrCreateFieldApi({ name: '[0].name' }) + + expect(item.name).toBe('[0]') + expect(name.name).toBe('[0].name') + expect(name.value).toBe('Alice') + }) +}) + describe('InternalFieldApi - _update', () => { + it('switches watched fields when the configured name changes', () => { + const listener = vi.fn() + const form = new InternalFormApi({ + defaultValues: { source: 'source', other: 'other', target: 'target' }, + }) + const target = form._getOrCreateFieldApi({ + name: 'target', + listeners: [ + { triggers: ['change'], watchFields: ['source'], run: listener }, + ], + }) + const source = form._getOrCreateFieldApi({ name: 'source' }) + const other = form._getOrCreateFieldApi({ name: 'other' }) + source._register() + other._register() + target._register() + + source.handleChange('before update') + expect(listener).toHaveBeenCalledWith( + expect.objectContaining({ value: 'target' }), + ) + listener.mockClear() + + target._update({ + listeners: [ + { triggers: ['change'], watchFields: ['other'], run: listener }, + ], + }) + source.handleChange('after update') + expect(listener).not.toHaveBeenCalled() + + other.handleChange('new source') + + expect(listener).toHaveBeenCalledOnce() + expect(listener).toHaveBeenCalledWith( + expect.objectContaining({ + value: 'target', + fieldApi: target, + formApi: form, + }), + ) + }) + + it('keeps a listener watching its source when another listener drops that watch', () => { + const retainedListener = vi.fn() + const detachedListener = vi.fn() + const form = new InternalFormApi({ + defaultValues: { source: 'source', target: 'target' }, + }) + const target = form._getOrCreateFieldApi({ + name: 'target', + listeners: [ + { + triggers: ['change'], + watchFields: ['source'], + run: retainedListener, + }, + { + triggers: ['change'], + watchFields: ['source'], + run: detachedListener, + }, + ], + }) + const source = form._getOrCreateFieldApi({ name: 'source' }) + source._register() + target._register() + + source.handleChange('before detaching') + expect(retainedListener).toHaveBeenCalledOnce() + expect(detachedListener).toHaveBeenCalledOnce() + retainedListener.mockClear() + detachedListener.mockClear() + + target._update({ + listeners: [ + { + triggers: ['change'], + watchFields: ['source'], + run: retainedListener, + }, + { triggers: ['change'], watchFields: [], run: detachedListener }, + ], + }) + source.handleChange('after detaching') + + expect(retainedListener).toHaveBeenCalledOnce() + expect(retainedListener).toHaveBeenCalledWith( + expect.objectContaining({ + value: 'target', + fieldApi: target, + formApi: form, + }), + ) + expect(detachedListener).not.toHaveBeenCalled() + + target.handleChange('direct edit') + + expect(detachedListener).toHaveBeenCalledOnce() + expect(detachedListener).toHaveBeenCalledWith( + expect.objectContaining({ + value: 'direct edit', + fieldApi: target, + formApi: form, + }), + ) + }) + + it('keeps other fields watching a source when one field stops watching', () => { + const detachedListener = vi.fn() + const retainedListener = vi.fn() + const form = new InternalFormApi({ + defaultValues: { source: 'source', first: 'first', second: 'second' }, + }) + const first = form._getOrCreateFieldApi({ + name: 'first', + listeners: [ + { + triggers: ['change'], + watchFields: ['source'], + run: detachedListener, + }, + ], + }) + const second = form._getOrCreateFieldApi({ + name: 'second', + listeners: [ + { + triggers: ['change'], + watchFields: ['source'], + run: retainedListener, + }, + ], + }) + const source = form._getOrCreateFieldApi({ name: 'source' }) + source._register() + first._register() + second._register() + + source.handleChange('before detaching') + expect(detachedListener).toHaveBeenCalledOnce() + expect(retainedListener).toHaveBeenCalledOnce() + detachedListener.mockClear() + retainedListener.mockClear() + + first._update({ + listeners: [ + { triggers: ['change'], watchFields: [], run: detachedListener }, + ], + }) + source.handleChange('after detaching') + + expect(detachedListener).not.toHaveBeenCalled() + expect(retainedListener).toHaveBeenCalledOnce() + expect(retainedListener).toHaveBeenCalledWith( + expect.objectContaining({ + value: 'second', + fieldApi: second, + formApi: form, + }), + ) + + first.handleChange('direct edit') + + expect(detachedListener).toHaveBeenCalledOnce() + expect(detachedListener).toHaveBeenCalledWith( + expect.objectContaining({ + value: 'direct edit', + fieldApi: first, + formApi: form, + }), + ) + }) + + it('preserves a watch when both the source and the watching field move', () => { + const listener = vi.fn() + const form = new InternalFormApi({ + defaultValues: { users: [{ name: 'First' }, { name: 'Second' }] }, + }) + const name0 = form._getOrCreateFieldApi({ name: 'users[0].name' }) + const name1 = form._getOrCreateFieldApi({ + name: 'users[1].name', + listeners: [ + { triggers: ['change'], watchFields: ['users[0].name'], run: listener }, + ], + }) + + name0.handleChange('before move') + expect(listener).toHaveBeenCalledOnce() + listener.mockClear() + + // Before: 0 1 + // After: 1 0 + // Fields: name1 name0 + form.swapFieldValues('users', 0, 1) + name0.handleChange('before confirmation') + + expect(listener).toHaveBeenCalledOnce() + expect(listener).toHaveBeenCalledWith( + expect.objectContaining({ + value: 'Second', + fieldApi: name1, + formApi: form, + }), + ) + listener.mockClear() + + name1._update({ + listeners: [ + { triggers: ['change'], watchFields: ['users[1].name'], run: listener }, + ], + }) + name0.handleChange('after confirmation') + + expect(listener).toHaveBeenCalledOnce() + expect(listener).toHaveBeenCalledWith( + expect.objectContaining({ + value: 'Second', + fieldApi: name1, + formApi: form, + }), + ) + expect(form.state.values.users).toEqual([ + { name: 'Second' }, + { name: 'after confirmation' }, + ]) + }) + + it('retargets a watch when an update rejects following the moved field', () => { + const listener = vi.fn() + const form = new InternalFormApi({ + defaultValues: { users: ['first', 'second'], target: 'target' }, + }) + const target = form._getOrCreateFieldApi({ + name: 'target', + listeners: [ + { triggers: ['change'], watchFields: ['users[0]'], run: listener }, + ], + }) + const user0 = form._getOrCreateFieldApi({ name: 'users[0]' }) + const user1 = form._getOrCreateFieldApi({ name: 'users[1]' }) + user0._register() + user1._register() + target._register() + + // Before: 0 1 + // After: 1 0 + // Fields: user1 user0 + form.swapFieldValues('users', 0, 1) + + user1.handleChange('first index before update') + expect(listener).not.toHaveBeenCalled() + + user0.handleChange('second index before update') + expect(listener).toHaveBeenCalledOnce() + listener.mockClear() + + // Keeping users[0] rejects following user0 to users[1]. + target._update({ + listeners: [ + { triggers: ['change'], watchFields: ['users[0]'], run: listener }, + ], + }) + user0.handleChange('second index after update') + expect(listener).not.toHaveBeenCalled() + + user1.handleChange('first index after update') + + expect(listener).toHaveBeenCalledOnce() + expect(listener).toHaveBeenCalledWith( + expect.objectContaining({ + value: 'target', + fieldApi: target, + formApi: form, + }), + ) + expect(form.state.values.users).toEqual([ + 'first index after update', + 'second index after update', + ]) + }) + + it('keeps following a moved field when an update confirms its new name', () => { + const listener = vi.fn() + const form = new InternalFormApi({ + defaultValues: { users: ['first', 'second'], target: 'target' }, + }) + const target = form._getOrCreateFieldApi({ + name: 'target', + listeners: [ + { triggers: ['change'], watchFields: ['users[0]'], run: listener }, + ], + }) + const user0 = form._getOrCreateFieldApi({ name: 'users[0]' }) + const user1 = form._getOrCreateFieldApi({ name: 'users[1]' }) + + user0.handleChange('first changed') + expect(listener).toHaveBeenCalledWith( + expect.objectContaining({ value: 'target' }), + ) + listener.mockClear() + + // Before: 0 1 + // After: 1 0 + // Fields: user1 user0 + form.swapFieldValues('users', 0, 1) + + // Follow the moved field before the next update confirms the watched name. + user1.handleChange('first index before update') + expect(listener).not.toHaveBeenCalled() + + user0.handleChange('second index before update') + expect(listener).toHaveBeenCalledOnce() + listener.mockClear() + + target._update({ + listeners: [ + { triggers: ['change'], watchFields: ['users[1]'], run: listener }, + ], + }) + + user1.handleChange('first index changed') + expect(listener).not.toHaveBeenCalled() + + user0.handleChange('second index changed') + + expect(listener).toHaveBeenCalledOnce() + expect(listener).toHaveBeenCalledWith( + expect.objectContaining({ + value: 'target', + fieldApi: target, + formApi: form, + }), + ) + expect(form.state.values).toEqual({ + users: ['first index changed', 'second index changed'], + target: 'target', + }) + }) + it('retains default listeners before incoming listeners during construction and updates', () => { const defaultListener = vi.fn() const incomingListener = vi.fn() @@ -153,11 +549,7 @@ describe('InternalFieldApi - _update', () => { expect(form.state.canSubmit).toBe(true) }) - it('uses updated listener callbacks and triggers, retains omitted listeners, and removes listeners with an empty array', ({ - onTestFinished, - }) => { - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - onTestFinished(() => warn.mockRestore()) + it('uses updated listener callbacks and triggers, retains omitted listeners, and disables listeners with empty triggers', () => { const initialListener = vi.fn() const updatedListener = vi.fn() const form = new InternalFormApi({ defaultValues: { name: '' } }) @@ -191,12 +583,12 @@ describe('InternalFieldApi - _update', () => { expect(updatedListener).toHaveBeenCalledOnce() updatedListener.mockClear() - field._update({ listeners: [] }) + field._update({ + listeners: [{ triggers: [], run: updatedListener }], + }) + field.handleChange('disabled') field.handleBlur() - expect(warn).toHaveBeenCalledWith( - expect.stringContaining('length of the listener array should not change'), - ) expect(updatedListener).not.toHaveBeenCalled() }) }) @@ -221,62 +613,454 @@ describe('InternalFieldApi - atom', () => { }) }) -describe('InternalFieldApi - reset', () => { - it('aborts active validation, ignores its late result, and allows validation to run again', async ({ - onTestFinished, - }) => { - const request = Promise.withResolvers() - onTestFinished(() => request.resolve('Late error')) - const onAbort = vi.fn() - const validator = vi.fn(() => request.promise) +describe('InternalFieldApi - _getIsDefaultValue', () => { + it('returns to the default value while remaining dirty after an edit is undone', () => { const form = new InternalFormApi({ defaultValues: { name: '' } }) - const field = form._getOrCreateFieldApi({ - name: 'name', - validators: [ - { - triggers: [], - run: ({ signal }: { signal: AbortSignal }) => { - signal.addEventListener('abort', onAbort, { once: true }) - return validator() - }, - }, - ], - }) - field._register() - - const submission = form.handleSubmit() - await expect.poll(() => validator).toHaveBeenCalledOnce() - expect(field.meta.isValidating).toBe(true) + const field = form._getOrCreateFieldApi({ name: 'name' }) - field.reset() + expect(field.meta.isDefaultValue).toBe(true) - expect(onAbort).toHaveBeenCalledOnce() - await expect(submission).resolves.toEqual([]) - request.resolve('Late error') - await request.promise + field.handleChange('Alice') - expect(field.errors).toEqual([]) - expect(field.meta.isValidating).toBe(false) + expect(field.value).toBe('Alice') + expect(field.meta.isDefaultValue).toBe(false) - validator.mockResolvedValue('Current error') - await form.handleSubmit() + field.handleChange('') - expect(field.errors).toEqual([{ message: 'Current error' }]) + expect(field.value).toBe('') + expect(field.meta).toMatchObject({ + isDefaultValue: true, + isDirty: true, + isPristine: false, + }) }) - it('cancels a pending listener callback and allows subsequent changes to notify it', async () => { - vi.useFakeTimers() - const listener = vi.fn() - const form = new InternalFormApi({ defaultValues: { name: '' } }) - const field = form._getOrCreateFieldApi({ - name: 'name', - listeners: [ - { run: listener, triggers: ['change'], triggerDebounceMs: 100 }, - ], + it('reports default-value state across nested fields', () => { + const form = new InternalFormApi({ + defaultValues: { user: { firstName: 'Tony', lastName: 'Hawk' } }, }) + const user = form._getOrCreateFieldApi({ name: 'user' }) - field.handleChange('initial') - await vi.advanceTimersByTimeAsync(100) + expect(user.meta.isDefaultValue).toBe(true) + + const firstName = form._getOrCreateFieldApi({ name: 'user.firstName' }) + const lastName = form._getOrCreateFieldApi({ name: 'user.lastName' }) + + expect(firstName.meta.isDefaultValue).toBe(true) + expect(lastName.meta.isDefaultValue).toBe(true) + + firstName.handleChange('Anthony') + + expect(user.value).toEqual({ firstName: 'Anthony', lastName: 'Hawk' }) + expect(user.meta.isDefaultValue).toBe(false) + expect(firstName.meta.isDefaultValue).toBe(false) + expect(lastName.meta.isDefaultValue).toBe(true) + + firstName.handleChange('Tony') + + expect(user.value).toEqual({ firstName: 'Tony', lastName: 'Hawk' }) + expect(user.meta.isDefaultValue).toBe(true) + expect(firstName.meta.isDefaultValue).toBe(true) + expect(lastName.meta.isDefaultValue).toBe(true) + }) + + it('keeps partially restored objects non-default', () => { + const form = new InternalFormApi({ + defaultValues: { user: { firstName: 'Tony', lastName: 'Hawk' } }, + }) + const user = form._getOrCreateFieldApi({ name: 'user' }) + const firstName = form._getOrCreateFieldApi({ name: 'user.firstName' }) + const lastName = form._getOrCreateFieldApi({ name: 'user.lastName' }) + + expect(form.state.isDefaultValue).toBe(true) + expect(user.meta.isDefaultValue).toBe(true) + expect(firstName.meta.isDefaultValue).toBe(true) + expect(lastName.meta.isDefaultValue).toBe(true) + + firstName.handleChange('Max') + lastName.handleChange('Mustermann') + + expect(form.state.isDefaultValue).toBe(false) + expect(user.meta.isDefaultValue).toBe(false) + expect(firstName.meta.isDefaultValue).toBe(false) + expect(lastName.meta.isDefaultValue).toBe(false) + + firstName.handleChange('Tony') + + expect(user.value).toEqual({ firstName: 'Tony', lastName: 'Mustermann' }) + expect(form.state.isDefaultValue).toBe(false) + expect(user.meta.isDefaultValue).toBe(false) + expect(firstName.meta.isDefaultValue).toBe(true) + expect(lastName.meta.isDefaultValue).toBe(false) + + lastName.handleChange('Hawk') + + expect(user.value).toEqual({ firstName: 'Tony', lastName: 'Hawk' }) + expect(form.state.isDefaultValue).toBe(true) + expect(user.meta.isDefaultValue).toBe(true) + expect(firstName.meta.isDefaultValue).toBe(true) + expect(lastName.meta.isDefaultValue).toBe(true) + }) + + it('reports nested defaults after object replacement', () => { + const form = new InternalFormApi({ + defaultValues: { user: { firstName: 'Tony', lastName: 'Hawk' } }, + }) + const user = form._getOrCreateFieldApi({ name: 'user' }) + const firstName = form._getOrCreateFieldApi({ name: 'user.firstName' }) + const lastName = form._getOrCreateFieldApi({ name: 'user.lastName' }) + + expect(form.state.isDefaultValue).toBe(true) + expect(user.meta.isDefaultValue).toBe(true) + expect(firstName.meta.isDefaultValue).toBe(true) + expect(lastName.meta.isDefaultValue).toBe(true) + + user.handleChange({ firstName: 'Max', lastName: 'Hawk' }) + + expect(firstName.value).toBe('Max') + expect(lastName.value).toBe('Hawk') + expect(form.state.isDefaultValue).toBe(false) + expect(user.meta.isDefaultValue).toBe(false) + expect(firstName.meta.isDefaultValue).toBe(false) + expect(lastName.meta.isDefaultValue).toBe(true) + + user.handleChange({ firstName: 'Tony', lastName: 'Hawk' }) + + expect(user.value).toEqual({ firstName: 'Tony', lastName: 'Hawk' }) + expect(form.state.isDefaultValue).toBe(true) + expect(user.meta.isDefaultValue).toBe(true) + expect(firstName.meta.isDefaultValue).toBe(true) + expect(lastName.meta.isDefaultValue).toBe(true) + }) + + it('reports nested state against updated defaults', () => { + const form = new InternalFormApi({ + defaultValues: { user: { firstName: 'Tony', lastName: 'Hawk' } }, + }) + const user = form._getOrCreateFieldApi({ name: 'user' }) + const firstName = form._getOrCreateFieldApi({ name: 'user.firstName' }) + const lastName = form._getOrCreateFieldApi({ name: 'user.lastName' }) + + firstName.handleChange('Max') + + expect(form.state.isDefaultValue).toBe(false) + expect(user.meta.isDefaultValue).toBe(false) + expect(firstName.meta.isDefaultValue).toBe(false) + expect(lastName.meta.isDefaultValue).toBe(true) + + form._update({ + defaultValues: { user: { firstName: 'Max', lastName: 'Hawk' } }, + }) + + expect(form.state.values).toEqual({ + user: { firstName: 'Max', lastName: 'Hawk' }, + }) + expect(form.state.isDefaultValue).toBe(true) + expect(user.meta.isDefaultValue).toBe(true) + expect(firstName.meta.isDefaultValue).toBe(true) + expect(lastName.meta.isDefaultValue).toBe(true) + + form._update({ + defaultValues: { user: { firstName: 'Antonio', lastName: 'Hawk' } }, + }) + + expect(form.state.values).toEqual({ + user: { firstName: 'Max', lastName: 'Hawk' }, + }) + expect(form.state.isDefaultValue).toBe(false) + expect(user.meta.isDefaultValue).toBe(false) + expect(firstName.meta.isDefaultValue).toBe(false) + expect(lastName.meta.isDefaultValue).toBe(true) + }) + + it('reports new defaults after a form reset', () => { + const form = new InternalFormApi({ + defaultValues: { user: { firstName: 'Tony', lastName: 'Hawk' } }, + }) + const previousUser = form._getOrCreateFieldApi({ name: 'user' }) + const previousFirstName = form._getOrCreateFieldApi({ + name: 'user.firstName', + }) + const previousLastName = form._getOrCreateFieldApi({ + name: 'user.lastName', + }) + + previousFirstName.handleChange('Max') + + expect(form.state.isDefaultValue).toBe(false) + expect(previousUser.meta.isDefaultValue).toBe(false) + expect(previousFirstName.meta.isDefaultValue).toBe(false) + expect(previousLastName.meta.isDefaultValue).toBe(true) + + form.reset({ user: { firstName: 'Rodney', lastName: 'Mullen' } }) + + const user = form._getOrCreateFieldApi({ name: 'user' }) + const firstName = form._getOrCreateFieldApi({ name: 'user.firstName' }) + const lastName = form._getOrCreateFieldApi({ name: 'user.lastName' }) + + previousFirstName.handleChange('stale edit') + + expect(form.state.values).toEqual({ + user: { firstName: 'Rodney', lastName: 'Mullen' }, + }) + expect(form.defaultValues).toEqual({ + user: { firstName: 'Rodney', lastName: 'Mullen' }, + }) + expect(form.state.isDefaultValue).toBe(true) + expect(user.meta.isDefaultValue).toBe(true) + expect(firstName.meta.isDefaultValue).toBe(true) + expect(lastName.meta.isDefaultValue).toBe(true) + + firstName.handleChange('Tony') + + expect(user.value).toEqual({ firstName: 'Tony', lastName: 'Mullen' }) + expect(form.state.isDefaultValue).toBe(false) + expect(user.meta.isDefaultValue).toBe(false) + expect(firstName.meta.isDefaultValue).toBe(false) + expect(lastName.meta.isDefaultValue).toBe(true) + + firstName.handleChange('Rodney') + + expect(user.value).toEqual({ firstName: 'Rodney', lastName: 'Mullen' }) + expect(form.state.isDefaultValue).toBe(true) + expect(user.meta.isDefaultValue).toBe(true) + expect(firstName.meta.isDefaultValue).toBe(true) + expect(lastName.meta.isDefaultValue).toBe(true) + }) + + it('reports preserved defaults after a form reset', () => { + const form = new InternalFormApi({ + defaultValues: { user: { firstName: 'Tony', lastName: 'Hawk' } }, + }) + const previousUser = form._getOrCreateFieldApi({ name: 'user' }) + const previousFirstName = form._getOrCreateFieldApi({ + name: 'user.firstName', + }) + const previousLastName = form._getOrCreateFieldApi({ + name: 'user.lastName', + }) + + previousFirstName.handleChange('edited') + + expect(form.state.isDefaultValue).toBe(false) + expect(previousUser.meta.isDefaultValue).toBe(false) + expect(previousFirstName.meta.isDefaultValue).toBe(false) + expect(previousLastName.meta.isDefaultValue).toBe(true) + + form.reset( + { user: { firstName: 'Max', lastName: 'Hawk' } }, + { updateDefaultValues: false }, + ) + + const user = form._getOrCreateFieldApi({ name: 'user' }) + const firstName = form._getOrCreateFieldApi({ name: 'user.firstName' }) + const lastName = form._getOrCreateFieldApi({ name: 'user.lastName' }) + + previousFirstName.handleChange('stale edit') + + expect(form.state.values).toEqual({ + user: { firstName: 'Max', lastName: 'Hawk' }, + }) + expect(form.defaultValues).toEqual({ + user: { firstName: 'Tony', lastName: 'Hawk' }, + }) + expect(form.state.isDirty).toBe(false) + expect(form.state.isDefaultValue).toBe(false) + expect(user.meta.isDefaultValue).toBe(false) + expect(firstName.meta.isDefaultValue).toBe(false) + expect(lastName.meta.isDefaultValue).toBe(true) + + firstName.handleChange('Tony') + + expect(user.value).toEqual({ firstName: 'Tony', lastName: 'Hawk' }) + expect(form.state.isDefaultValue).toBe(true) + expect(user.meta.isDefaultValue).toBe(true) + expect(firstName.meta.isDefaultValue).toBe(true) + expect(lastName.meta.isDefaultValue).toBe(true) + }) + + it('reports moved fields as non-default and returns them to default when moved back', () => { + const form = new InternalFormApi({ + defaultValues: { items: [{ name: 'a' }, { name: 'b' }] }, + }) + const items = form._getOrCreateFieldApi({ name: 'items' }) + const item0 = form._getOrCreateFieldApi({ name: 'items[0]' }) + const name0 = form._getOrCreateFieldApi({ name: 'items[0].name' }) + + expect(items.meta.isDefaultValue).toBe(true) + expect(item0.meta.isDefaultValue).toBe(true) + expect(name0.meta.isDefaultValue).toBe(true) + + // Before: 0 1 + // After: 1 0 + // Fields: - item0 + items.swapValues(0, 1) + + expect(items.value).toEqual([{ name: 'b' }, { name: 'a' }]) + expect(item0.name).toBe('items[1]') + expect(item0.value).toEqual({ name: 'a' }) + expect(name0.name).toBe('items[1].name') + expect(name0.value).toBe('a') + expect(items.meta.isDefaultValue).toBe(false) + expect(item0.meta.isDefaultValue).toBe(false) + expect(name0.meta.isDefaultValue).toBe(false) + + items.swapValues(0, 1) + + expect(items.value).toEqual([{ name: 'a' }, { name: 'b' }]) + expect(item0.name).toBe('items[0]') + expect(name0.name).toBe('items[0].name') + expect(items.meta.isDefaultValue).toBe(true) + expect(item0.meta.isDefaultValue).toBe(true) + expect(name0.meta.isDefaultValue).toBe(true) + + name0.handleChange('updated') + + expect(items.value).toEqual([{ name: 'updated' }, { name: 'b' }]) + expect(item0.value).toEqual({ name: 'updated' }) + expect(items.meta.isDefaultValue).toBe(false) + expect(item0.meta.isDefaultValue).toBe(false) + expect(name0.meta.isDefaultValue).toBe(false) + }) + + it('returns an array to default after reversing an item move', () => { + const form = new InternalFormApi({ + defaultValues: { items: ['a', 'b', 'c'] }, + }) + const items = form._getOrCreateFieldApi({ name: 'items' }) + + expect(items.meta.isDefaultValue).toBe(true) + + items.moveValue(0, 2) + + expect(items.value).toEqual(['b', 'c', 'a']) + expect(items.meta.isDefaultValue).toBe(false) + + items.moveValue(2, 0) + + expect(items.value).toEqual(['a', 'b', 'c']) + expect(items.meta.isDefaultValue).toBe(true) + }) + + it('returns an array to default after removing an inserted item', () => { + const form = new InternalFormApi({ + defaultValues: { items: ['a', 'b', 'c'] }, + }) + const items = form._getOrCreateFieldApi({ name: 'items' }) + + expect(items.meta.isDefaultValue).toBe(true) + + items.insertValue(1, 'x') + + expect(items.value).toEqual(['a', 'x', 'b', 'c']) + expect(items.meta.isDefaultValue).toBe(false) + + items.removeValue(1) + + expect(items.value).toEqual(['a', 'b', 'c']) + expect(items.meta.isDefaultValue).toBe(true) + }) + + it('returns an array to default after removing an appended item', () => { + const form = new InternalFormApi({ + defaultValues: { items: ['a', 'b', 'c'] }, + }) + const items = form._getOrCreateFieldApi({ name: 'items' }) + + expect(items.meta.isDefaultValue).toBe(true) + + items.pushValue('d') + + expect(items.value).toEqual(['a', 'b', 'c', 'd']) + expect(items.meta.isDefaultValue).toBe(false) + + items.removeValue(3) + + expect(items.value).toEqual(['a', 'b', 'c']) + expect(items.meta.isDefaultValue).toBe(true) + }) + + it('returns a cleared array to default after reset and reports later changes as non-default', () => { + const form = new InternalFormApi({ + defaultValues: { items: ['a', 'b', 'c'] }, + }) + const items = form._getOrCreateFieldApi({ name: 'items' }) + + expect(items.meta.isDefaultValue).toBe(true) + + items.clearValues() + + expect(items.value).toEqual([]) + expect(items.meta.isDefaultValue).toBe(false) + + items.reset() + + expect(items.value).toEqual(['a', 'b', 'c']) + expect(items.meta.isDefaultValue).toBe(true) + + items.pushValue('d') + + expect(items.value).toEqual(['a', 'b', 'c', 'd']) + expect(items.meta.isDefaultValue).toBe(false) + }) +}) + +describe('InternalFieldApi - reset', () => { + it('aborts active validation, ignores its late result, and allows validation to run again', async ({ + onTestFinished, + }) => { + const request = promiseWithResolvers() + onTestFinished(() => request.resolve('Late error')) + const onAbort = vi.fn() + const validator = vi.fn(() => request.promise) + const form = new InternalFormApi({ defaultValues: { name: '' } }) + const field = form._getOrCreateFieldApi({ + name: 'name', + validators: [ + { + triggers: [], + run: ({ signal }: { signal: AbortSignal }) => { + signal.addEventListener('abort', onAbort, { once: true }) + return validator() + }, + }, + ], + }) + field._register() + + const submission = form.handleSubmit() + await expect.poll(() => validator).toHaveBeenCalledOnce() + expect(field.meta.isValidating).toBe(true) + + field.reset() + + expect(onAbort).toHaveBeenCalledOnce() + await expect(submission).resolves.toEqual([]) + request.resolve('Late error') + await request.promise + + expect(field.errors).toEqual([]) + expect(field.meta.isValidating).toBe(false) + + validator.mockResolvedValue('Current error') + await form.handleSubmit() + + expect(field.errors).toEqual([{ message: 'Current error' }]) + }) + + it('cancels a pending listener callback and allows subsequent changes to notify it', async () => { + vi.useFakeTimers() + const listener = vi.fn() + const form = new InternalFormApi({ defaultValues: { name: '' } }) + const field = form._getOrCreateFieldApi({ + name: 'name', + listeners: [ + { run: listener, triggers: ['change'], triggerDebounceMs: 100 }, + ], + }) + + field.handleChange('initial') + await vi.advanceTimersByTimeAsync(100) expect(listener).toHaveBeenCalledWith( expect.objectContaining({ value: 'initial' }), ) @@ -395,6 +1179,8 @@ describe('InternalFieldApi - _kill', () => { expect(form.getFieldValue('items')).toEqual(beforeDelete) field.removeValue(0) expect(form.getFieldValue('items')).toEqual(beforeDelete) + field.replaceValue(0, 'ignored') + expect(form.getFieldValue('items')).toEqual(beforeDelete) field.filterValues(() => false) expect(form.getFieldValue('items')).toEqual(beforeDelete) }) @@ -423,7 +1209,7 @@ describe('InternalFieldApi - _kill', () => { it('aborts active validation when deleted and ignores late results and subsequent interactions', async ({ onTestFinished, }) => { - const request = Promise.withResolvers() + const request = promiseWithResolvers() onTestFinished(() => request.resolve('Late error')) const onAbort = vi.fn() const validator = vi.fn(() => request.promise) @@ -519,6 +1305,27 @@ describe('InternalFieldApi - _register', () => { }) describe('InternalFieldApi - _unregister', () => { + it('notifies unmount listeners with the last field value', async () => { + vi.useFakeTimers() + const listener = vi.fn() + const form = new InternalFormApi({ defaultValues: { name: 'Alice' } }) + const field = form._getOrCreateFieldApi({ + name: 'name', + listeners: [{ triggers: ['unmount'], run: listener }], + }) + const unregister = field._register() + field.handleChange('Bob') + expect(listener).not.toHaveBeenCalled() + + unregister() + + expect(listener).toHaveBeenCalledOnce() + expect(listener).toHaveBeenCalledWith( + expect.objectContaining({ value: 'Bob', fieldApi: field, formApi: form }), + ) + await vi.runOnlyPendingTimersAsync() + }) + it('continues validating a field while another consumer remains registered', async () => { vi.useFakeTimers() const form = new InternalFormApi({ defaultValues: { name: '' } }) @@ -643,6 +1450,532 @@ describe('InternalFieldApi - _unregister', () => { }) }) +describe('InternalFieldApi - pushValue', () => { + it('appends an item through pushFieldValue', ({ onTestFinished }) => { + const form = new InternalFormApi({ + defaultValues: { items: ['a'] }, + }) + const field = form._getOrCreateFieldApi({ name: 'items' }) + const pushFieldValue = vi.spyOn(form, 'pushFieldValue') + onTestFinished(() => pushFieldValue.mockRestore()) + + field.pushValue('b') + + expect(pushFieldValue).toHaveBeenCalledOnce() + expect(field.value).toEqual(['a', 'b']) + }) +}) + +describe('InternalFieldApi - insertValue', () => { + it('inserts an item through insertFieldValue', ({ onTestFinished }) => { + const form = new InternalFormApi({ + defaultValues: { items: ['a', 'b'] }, + }) + const field = form._getOrCreateFieldApi({ name: 'items' }) + const insertFieldValue = vi.spyOn(form, 'insertFieldValue') + onTestFinished(() => insertFieldValue.mockRestore()) + + field.insertValue(1, 'x') + + expect(insertFieldValue).toHaveBeenCalledOnce() + expect(field.value).toEqual(['a', 'x', 'b']) + }) +}) + +describe('InternalFieldApi - clearValues', () => { + it('empties the array through clearFieldValues', ({ onTestFinished }) => { + const form = new InternalFormApi({ + defaultValues: { items: ['a', 'b', 'c'] }, + }) + const field = form._getOrCreateFieldApi({ name: 'items' }) + const clearFieldValues = vi.spyOn(form, 'clearFieldValues') + onTestFinished(() => clearFieldValues.mockRestore()) + + field.clearValues() + + expect(clearFieldValues).toHaveBeenCalledOnce() + expect(field.value).toEqual([]) + }) +}) + +describe('InternalFieldApi - swapValues', () => { + it('swaps two items through swapFieldValues', ({ onTestFinished }) => { + const form = new InternalFormApi({ + defaultValues: { items: ['a', 'b', 'c'] }, + }) + const field = form._getOrCreateFieldApi({ name: 'items' }) + const swapFieldValues = vi.spyOn(form, 'swapFieldValues') + onTestFinished(() => swapFieldValues.mockRestore()) + + field.swapValues(0, 2) + + expect(swapFieldValues).toHaveBeenCalledOnce() + expect(field.value).toEqual(['c', 'b', 'a']) + }) +}) + +describe('InternalFieldApi - moveValue', () => { + it('moves an item through moveFieldValue', ({ onTestFinished }) => { + const form = new InternalFormApi({ + defaultValues: { items: ['a', 'b', 'c', 'd'] }, + }) + const field = form._getOrCreateFieldApi({ name: 'items' }) + const moveFieldValue = vi.spyOn(form, 'moveFieldValue') + onTestFinished(() => moveFieldValue.mockRestore()) + + field.moveValue(0, 2) + + expect(moveFieldValue).toHaveBeenCalledOnce() + expect(field.value).toEqual(['b', 'c', 'a', 'd']) + }) +}) + +describe('InternalFieldApi - removeValue', () => { + it('removes an item through removeFieldValue', ({ onTestFinished }) => { + const form = new InternalFormApi({ + defaultValues: { items: ['a', 'b', 'c'] }, + }) + const field = form._getOrCreateFieldApi({ name: 'items' }) + const removeFieldValue = vi.spyOn(form, 'removeFieldValue') + onTestFinished(() => removeFieldValue.mockRestore()) + + field.removeValue(1) + + expect(removeFieldValue).toHaveBeenCalledOnce() + expect(field.value).toEqual(['a', 'c']) + }) +}) + +describe('InternalFieldApi - replaceValue', () => { + it('replaces an item through replaceFieldValue', ({ onTestFinished }) => { + const form = new InternalFormApi({ + defaultValues: { items: ['a', 'b', 'c'] }, + }) + const field = form._getOrCreateFieldApi({ name: 'items' }) + const replaceFieldValue = vi.spyOn(form, 'replaceFieldValue') + onTestFinished(() => replaceFieldValue.mockRestore()) + + field.replaceValue(1, 'x') + + expect(replaceFieldValue).toHaveBeenCalledOnce() + expect(field.value).toEqual(['a', 'x', 'c']) + }) +}) + +describe('InternalFieldApi - filterValues', () => { + it('filters items with the supplied predicate context through filterFieldValues', ({ + onTestFinished, + }) => { + const form = new InternalFormApi({ + defaultValues: { items: ['a', 'b', 'c'] }, + }) + const field = form._getOrCreateFieldApi({ name: 'items' }) + const filterFieldValues = vi.spyOn(form, 'filterFieldValues') + onTestFinished(() => filterFieldValues.mockRestore()) + const thisArg = { excluded: 'b' } + + field.filterValues( + function (this: { excluded: string }, value) { + return value !== this.excluded + }, + { thisArg }, + ) + + expect(filterFieldValues).toHaveBeenCalledOnce() + expect(field.value).toEqual(['a', 'c']) + }) +}) + +describe('InternalFieldApi - handleChange', () => { + it('sets the value through setFieldValue and marks the field and form touched and dirty', ({ + onTestFinished, + }) => { + const form = new InternalFormApi({ defaultValues: { name: '' } }) + const field = form._getOrCreateFieldApi({ name: 'name' }) + const setFieldValue = vi.spyOn(form, 'setFieldValue') + onTestFinished(() => setFieldValue.mockRestore()) + + field.handleChange('Alice') + + expect(setFieldValue).toHaveBeenCalledOnce() + expect(field.value).toBe('Alice') + expect(field.meta).toMatchObject({ + isTouched: true, + isDirty: true, + }) + expect(form.state).toMatchObject({ + values: { name: 'Alice' }, + isTouched: true, + isDirty: true, + }) + }) + + it('updates the value through setFieldValue with an updater function', ({ + onTestFinished, + }) => { + const form = new InternalFormApi({ defaultValues: { count: 1 } }) + const field = form._getOrCreateFieldApi({ name: 'count' }) + const setFieldValue = vi.spyOn(form, 'setFieldValue') + onTestFinished(() => setFieldValue.mockRestore()) + + field.handleChange((count: number) => count + 1) + + expect(setFieldValue).toHaveBeenCalledOnce() + expect(field.value).toBe(2) + }) + + it('updates the value and touched state while keeping the field and form pristine when markAsDirty is false', ({ + onTestFinished, + }) => { + const form = new InternalFormApi({ defaultValues: { name: '' } }) + const field = form._getOrCreateFieldApi({ name: 'name' }) + const setFieldValue = vi.spyOn(form, 'setFieldValue') + onTestFinished(() => setFieldValue.mockRestore()) + + field.handleChange('Alice', { markAsDirty: false }) + + expect(setFieldValue).toHaveBeenCalledOnce() + expect(field.value).toBe('Alice') + expect(field.meta).toMatchObject({ + isTouched: true, + isDirty: false, + isPristine: true, + }) + expect(form.state).toMatchObject({ + values: { name: 'Alice' }, + isTouched: true, + isDirty: false, + isPristine: true, + }) + }) + + it('updates the value and dirty state while keeping the field and form untouched when markAsTouched is false', ({ + onTestFinished, + }) => { + const form = new InternalFormApi({ defaultValues: { name: '' } }) + const field = form._getOrCreateFieldApi({ name: 'name' }) + const setFieldValue = vi.spyOn(form, 'setFieldValue') + onTestFinished(() => setFieldValue.mockRestore()) + + field.handleChange('Alice', { markAsTouched: false }) + + expect(setFieldValue).toHaveBeenCalledOnce() + expect(field.value).toBe('Alice') + expect(field.meta).toMatchObject({ + isTouched: false, + isDirty: true, + }) + expect(form.state).toMatchObject({ + values: { name: 'Alice' }, + isTouched: false, + isDirty: true, + }) + }) +}) + +describe('InternalFieldApi - _notifyListener', () => { + it('notifies a listener when either of its watched fields changes', () => { + const listener = vi.fn() + const form = new InternalFormApi({ + defaultValues: { source: 'source', other: 'other', target: 'target' }, + }) + const target = form._getOrCreateFieldApi({ + name: 'target', + listeners: [ + { + triggers: ['change'], + watchFields: ['source', 'other'], + run: listener, + }, + ], + }) + const source = form._getOrCreateFieldApi({ name: 'source' }) + const other = form._getOrCreateFieldApi({ name: 'other' }) + + source.handleChange('source changed') + + expect(listener).toHaveBeenCalledOnce() + expect(listener).toHaveBeenCalledWith( + expect.objectContaining({ + value: 'target', + fieldApi: target, + formApi: form, + }), + ) + listener.mockClear() + + other.handleChange('other changed') + + expect(listener).toHaveBeenCalledOnce() + expect(listener).toHaveBeenCalledWith( + expect.objectContaining({ + value: 'target', + fieldApi: target, + formApi: form, + }), + ) + }) + + it('warns about a field watching itself and runs its listener only once', ({ + onTestFinished, + }) => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + onTestFinished(() => warn.mockRestore()) + const listener = vi.fn() + const form = new InternalFormApi({ defaultValues: { name: '' } }) + const field = form._getOrCreateFieldApi({ + name: 'name', + listeners: [ + { triggers: ['change'], watchFields: ['name'], run: listener }, + ], + }) + + field.handleChange('Alice') + + expect(listener).toHaveBeenCalledOnce() + expect(listener).toHaveBeenCalledWith( + expect.objectContaining({ + value: 'Alice', + fieldApi: field, + formApi: form, + }), + ) + expect(warn.mock.calls).toContainEqual( + expect.arrayContaining([ + expect.stringContaining('cyclical listener cycle detected'), + ]), + ) + expect(field.value).toBe('Alice') + }) + + it('runs every listener watching a source without running direct-only listeners', () => { + const firstListener = vi.fn() + const directListener = vi.fn() + const secondListener = vi.fn() + const form = new InternalFormApi({ + defaultValues: { source: 'source', target: 'target' }, + }) + const target = form._getOrCreateFieldApi({ + name: 'target', + listeners: [ + { triggers: ['change'], watchFields: ['source'], run: firstListener }, + { triggers: ['change'], run: directListener }, + { triggers: ['change'], watchFields: ['source'], run: secondListener }, + ], + }) + const source = form._getOrCreateFieldApi({ name: 'source' }) + + source.handleChange('changed source') + + expect(firstListener).toHaveBeenCalledOnce() + expect(firstListener).toHaveBeenCalledWith( + expect.objectContaining({ + value: 'target', + fieldApi: target, + formApi: form, + }), + ) + expect(secondListener).toHaveBeenCalledOnce() + expect(secondListener).toHaveBeenCalledWith( + expect.objectContaining({ + value: 'target', + fieldApi: target, + formApi: form, + }), + ) + expect(directListener).not.toHaveBeenCalled() + + target.handleChange('direct edit') + + expect(directListener).toHaveBeenCalledOnce() + expect(directListener).toHaveBeenCalledWith( + expect.objectContaining({ + value: 'direct edit', + fieldApi: target, + formApi: form, + }), + ) + }) + + it('debounces watched listeners independently', async () => { + vi.useFakeTimers() + const firstListener = vi.fn() + const secondListener = vi.fn() + const form = new InternalFormApi({ + defaultValues: { firstSource: '', secondSource: '', target: 'target' }, + }) + const target = form._getOrCreateFieldApi({ + name: 'target', + listeners: [ + { + triggers: ['change'], + watchFields: ['firstSource'], + triggerDebounceMs: 100, + run: firstListener, + }, + { + triggers: ['change'], + watchFields: ['secondSource'], + triggerDebounceMs: 100, + run: secondListener, + }, + ], + }) + const firstSource = form._getOrCreateFieldApi({ name: 'firstSource' }) + const secondSource = form._getOrCreateFieldApi({ name: 'secondSource' }) + + secondSource.handleChange('second') + await vi.advanceTimersByTimeAsync(50) + firstSource.handleChange('first') + await vi.advanceTimersByTimeAsync(49) + + expect(firstListener).not.toHaveBeenCalled() + expect(secondListener).not.toHaveBeenCalled() + + await vi.advanceTimersByTimeAsync(1) + + expect(firstListener).not.toHaveBeenCalled() + expect(secondListener).toHaveBeenCalledOnce() + expect(secondListener).toHaveBeenCalledWith( + expect.objectContaining({ + value: 'target', + fieldApi: target, + formApi: form, + }), + ) + + await vi.advanceTimersByTimeAsync(50) + + expect(firstListener).toHaveBeenCalledOnce() + expect(firstListener).toHaveBeenCalledWith( + expect.objectContaining({ + value: 'target', + fieldApi: target, + formApi: form, + }), + ) + expect(secondListener).toHaveBeenCalledOnce() + }) + + it('notifies only matching change and blur listeners watching the affected field', () => { + const changeListener = vi.fn() + const blurListener = vi.fn() + const unrelatedListener = vi.fn() + const form = new InternalFormApi({ + defaultValues: { source: 'source', other: 'other', target: 'target' }, + }) + const target = form._getOrCreateFieldApi({ + name: 'target', + listeners: [ + { triggers: ['change'], watchFields: ['source'], run: changeListener }, + { triggers: ['blur'], watchFields: ['source'], run: blurListener }, + { + triggers: ['change', 'blur'], + watchFields: ['other'], + run: unrelatedListener, + }, + ], + }) + const source = form._getOrCreateFieldApi({ name: 'source' }) + target._register() + source._register() + + source.handleChange('updated source') + + expect(changeListener).toHaveBeenCalledOnce() + expect(changeListener).toHaveBeenCalledWith( + expect.objectContaining({ + value: 'target', + fieldApi: target, + formApi: form, + }), + ) + expect(blurListener).not.toHaveBeenCalled() + expect(unrelatedListener).not.toHaveBeenCalled() + + source.handleBlur() + + expect(changeListener).toHaveBeenCalledOnce() + expect(blurListener).toHaveBeenCalledOnce() + expect(blurListener).toHaveBeenCalledWith( + expect.objectContaining({ + value: 'target', + fieldApi: target, + formApi: form, + }), + ) + expect(unrelatedListener).not.toHaveBeenCalled() + }) + + it('notifies only listeners watching a field being registered', () => { + const listener = vi.fn() + const unrelatedListener = vi.fn() + const form = new InternalFormApi({ + defaultValues: { source: 'source', other: 'other', target: 'target' }, + }) + const target = form._getOrCreateFieldApi({ + name: 'target', + listeners: [ + { triggers: ['mount'], watchFields: ['source'], run: listener }, + { triggers: ['mount'], watchFields: ['other'], run: unrelatedListener }, + ], + }) + const source = form._getOrCreateFieldApi({ name: 'source' }) + target._register() + listener.mockClear() + unrelatedListener.mockClear() + + source._register() + + expect(listener).toHaveBeenCalledOnce() + expect(listener).toHaveBeenCalledWith( + expect.objectContaining({ + value: 'target', + fieldApi: target, + formApi: form, + }), + ) + expect(unrelatedListener).not.toHaveBeenCalled() + }) + + it('notifies only listeners watching a field being unregistered', async () => { + vi.useFakeTimers() + const listener = vi.fn() + const unrelatedListener = vi.fn() + const form = new InternalFormApi({ + defaultValues: { source: 'source', other: 'other', target: 'target' }, + }) + const target = form._getOrCreateFieldApi({ + name: 'target', + listeners: [ + { triggers: ['unmount'], watchFields: ['source'], run: listener }, + { + triggers: ['unmount'], + watchFields: ['other'], + run: unrelatedListener, + }, + ], + }) + const source = form._getOrCreateFieldApi({ name: 'source' }) + target._register() + const unregisterSource = source._register() + expect(listener).not.toHaveBeenCalled() + + unregisterSource() + + expect(listener).toHaveBeenCalledOnce() + expect(listener).toHaveBeenCalledWith( + expect.objectContaining({ + value: 'target', + fieldApi: target, + formApi: form, + }), + ) + expect(unrelatedListener).not.toHaveBeenCalled() + await vi.runOnlyPendingTimersAsync() + }) +}) + describe('InternalFieldApi - _clearEventErrors', () => { it('clears submit-only validator errors only for the field that changes', async () => { const form = new InternalFormApi({ diff --git a/packages/form-core/tests/FieldApi/array-methods.spec.ts b/packages/form-core/tests/FieldApi/array-methods.spec.ts deleted file mode 100644 index 01b29f8b6c..0000000000 --- a/packages/form-core/tests/FieldApi/array-methods.spec.ts +++ /dev/null @@ -1,555 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import { InternalFormApi } from '../../src/FormApi/FormApi.lib' - -describe('field - array methods', () => { - describe('pushValue', () => { - it('appends a value to an array field', () => { - const form = new InternalFormApi({ defaultValues: { items: ['a'] } }) - const field = form._getOrCreateFieldApi({ name: 'items' }) - field.pushValue('b') - expect(field.value).toEqual(['a', 'b']) - }) - - it('marks the field as dirty and touched', () => { - const form = new InternalFormApi({ - defaultValues: { items: [] as Array }, - }) - const field = form._getOrCreateFieldApi({ name: 'items' }) - field.pushValue('a') - expect(field.meta.isDirty).toBe(true) - expect(field.meta.isTouched).toBe(true) - }) - - it('warns when called on a non-array field', () => { - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - const form = new InternalFormApi({ defaultValues: { name: '' } }) - const field = form._getOrCreateFieldApi({ name: 'name' }) - field.pushValue('x') - expect(warn).toHaveBeenCalled() - warn.mockRestore() - }) - }) - - describe('insertFieldValue', () => { - it('inserts a value at the specified index', () => { - const form = new InternalFormApi({ defaultValues: { items: ['a', 'b'] } }) - const field = form._getOrCreateFieldApi({ name: 'items' }) - field.insertValue(1, 'x') - expect(field.value).toEqual(['a', 'x', 'b']) - field.insertValue(0, 'y') - expect(field.value).toEqual(['y', 'a', 'x', 'b']) - field.insertValue(3, 'z') - expect(field.value).toEqual(['y', 'a', 'x', 'z', 'b']) - }) - - it('inserts a value in an empty array', () => { - const form = new InternalFormApi({ - defaultValues: { items: [] as Array }, - }) - const field = form._getOrCreateFieldApi({ name: 'items' }) - field.insertValue(0, 'x') - expect(field.value).toEqual(['x']) - }) - - it('warns when called on a non-array field', () => { - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - const form = new InternalFormApi({ defaultValues: { name: '' } }) - const field = form._getOrCreateFieldApi({ name: 'name' }) - field.insertValue(0, 'x') - expect(warn).toHaveBeenCalled() - warn.mockRestore() - }) - - it('warns when index is negative', () => { - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - const form = new InternalFormApi({ defaultValues: { items: ['a', 'b'] } }) - const field = form._getOrCreateFieldApi({ name: 'items' }) - field.insertValue(-1, 'x') - expect(warn).toHaveBeenCalled() - expect(form.getFieldValue('items')).toEqual(['a', 'b']) - warn.mockRestore() - }) - - it('warns when index is out of bounds', () => { - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - const form = new InternalFormApi({ defaultValues: { items: ['a', 'b'] } }) - const field = form._getOrCreateFieldApi({ name: 'items' }) - field.insertValue(3, 'x') - expect(warn).toHaveBeenCalled() - expect(form.getFieldValue('items')).toEqual(['a', 'b']) - warn.mockRestore() - }) - - it('shifts child field segments after insert', () => { - const form = new InternalFormApi({ - defaultValues: { items: ['a', 'b', 'c'] }, - }) - const arrayField = form._getOrCreateFieldApi({ name: 'items' }) - const field0 = form._getOrCreateFieldApi({ name: 'items[0]' }) - const field1 = form._getOrCreateFieldApi({ name: 'items[1]' }) - const field2 = form._getOrCreateFieldApi({ name: 'items[2]' }) - arrayField.insertValue(1, 'x') - expect(field0._segment).toBe(0) - expect(field1._segment).toBe(2) - expect(field2._segment).toBe(3) - - expect(form._getOrCreateFieldApi({ name: 'items[0]' })).toBe(field0) - expect(form._getOrCreateFieldApi({ name: 'items[1]' })).not.toBe(field1) - expect(form._getOrCreateFieldApi({ name: 'items[2]' })).toBe(field1) - expect(form._getOrCreateFieldApi({ name: 'items[3]' })).toBe(field2) - }) - - it("updates the array field's meta", () => { - const form = new InternalFormApi({ - defaultValues: { items: ['a', 'b'] }, - }) - const field = form._getOrCreateFieldApi({ name: 'items' }) - field.insertValue(1, 'x') - expect(field.meta.isDirty).toBe(true) - expect(field.meta.isTouched).toBe(true) - }) - }) - - describe('clearValues', () => { - it('empties an array field', () => { - const form = new InternalFormApi({ - defaultValues: { items: ['a', 'b', 'c'] }, - }) - const field = form._getOrCreateFieldApi({ name: 'items' }) - field.clearValues() - expect(field.value).toEqual([]) - }) - - it('warns when called on a non-array field', () => { - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - const form = new InternalFormApi({ defaultValues: { name: '' } }) - const field = form._getOrCreateFieldApi({ name: 'name' }) - field.clearValues() - expect(warn).toHaveBeenCalled() - warn.mockRestore() - }) - - it('kills child fields', () => { - const form = new InternalFormApi({ defaultValues: { items: ['a', 'b'] } }) - const arrayField = form._getOrCreateFieldApi({ name: 'items' }) - form._getOrCreateFieldApi({ name: 'items[0]' }) - form._getOrCreateFieldApi({ name: 'items[1]' }) - - arrayField.clearValues() - - expect(form._tryGetFieldApi('items[0]')).toBeNull() - expect(form._tryGetFieldApi('items[1]')).toBeNull() - expect(arrayField._children).toEqual([]) - }) - }) - - describe('swapValues', () => { - it('swaps two elements in an array field', () => { - const form = new InternalFormApi({ - defaultValues: { items: ['a', 'b', 'c'] }, - }) - const field = form._getOrCreateFieldApi({ name: 'items' }) - field.swapValues(0, 2) - expect(field.value).toEqual(['c', 'b', 'a']) - }) - - it('swaps child field segments', () => { - const form = new InternalFormApi({ defaultValues: { items: ['a', 'b'] } }) - const field0 = form._getOrCreateFieldApi({ name: 'items[0]' }) - const field1 = form._getOrCreateFieldApi({ name: 'items[1]' }) - const arrayField = form._getOrCreateFieldApi({ name: 'items' }) - arrayField.swapValues(0, 1) - expect(field0._segment).toBe(1) - expect(field1._segment).toBe(0) - }) - - it('does nothing when indexA === indexB', () => { - const form = new InternalFormApi({ defaultValues: { items: ['a', 'b'] } }) - const field = form._getOrCreateFieldApi({ name: 'items' }) - field.swapValues(0, 0) - expect(field.value).toEqual(['a', 'b']) - }) - - it('warns when called on a non-array field', () => { - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - const form = new InternalFormApi({ defaultValues: { name: '' } }) - const field = form._getOrCreateFieldApi({ name: 'name' }) - field.swapValues(0, 1) - expect(warn).toHaveBeenCalled() - warn.mockRestore() - }) - }) - - describe('moveValue', () => { - it('moves an element forward in an array field', () => { - const form = new InternalFormApi({ - defaultValues: { items: ['a', 'b', 'c', 'd'] }, - }) - const field = form._getOrCreateFieldApi({ name: 'items' }) - field.moveValue(0, 2) - expect(field.value).toEqual(['b', 'c', 'a', 'd']) - }) - - it('moves an element backward in an array field', () => { - const form = new InternalFormApi({ - defaultValues: { items: ['a', 'b', 'c', 'd'] }, - }) - const field = form._getOrCreateFieldApi({ name: 'items' }) - field.moveValue(2, 0) - expect(field.value).toEqual(['c', 'a', 'b', 'd']) - }) - - it('moves child field segments forward', () => { - const form = new InternalFormApi({ - defaultValues: { items: ['a', 'b', 'c', 'd'] }, - }) - const arrayField = form._getOrCreateFieldApi({ name: 'items' }) - const field0 = form._getOrCreateFieldApi({ name: 'items[0]' }) - const field1 = form._getOrCreateFieldApi({ name: 'items[1]' }) - const field2 = form._getOrCreateFieldApi({ name: 'items[2]' }) - const field3 = form._getOrCreateFieldApi({ name: 'items[3]' }) - - arrayField.moveValue(0, 2) - - expect(field0._segment).toBe(2) - expect(field1._segment).toBe(0) - expect(field2._segment).toBe(1) - expect(field3._segment).toBe(3) - expect(form._tryGetFieldApi('items[0]')).toBe(field1) - expect(form._tryGetFieldApi('items[1]')).toBe(field2) - expect(form._tryGetFieldApi('items[2]')).toBe(field0) - expect(form._tryGetFieldApi('items[3]')).toBe(field3) - }) - - it('moves child field segments backward', () => { - const form = new InternalFormApi({ - defaultValues: { items: ['a', 'b', 'c', 'd'] }, - }) - const arrayField = form._getOrCreateFieldApi({ name: 'items' }) - const field0 = form._getOrCreateFieldApi({ name: 'items[0]' }) - const field1 = form._getOrCreateFieldApi({ name: 'items[1]' }) - const field2 = form._getOrCreateFieldApi({ name: 'items[2]' }) - const field3 = form._getOrCreateFieldApi({ name: 'items[3]' }) - - arrayField.moveValue(2, 0) - - expect(field0._segment).toBe(1) - expect(field1._segment).toBe(2) - expect(field2._segment).toBe(0) - expect(field3._segment).toBe(3) - expect(form._tryGetFieldApi('items[0]')).toBe(field2) - expect(form._tryGetFieldApi('items[1]')).toBe(field0) - expect(form._tryGetFieldApi('items[2]')).toBe(field1) - expect(form._tryGetFieldApi('items[3]')).toBe(field3) - }) - - it('does nothing when fromIndex === toIndex', () => { - const form = new InternalFormApi({ defaultValues: { items: ['a', 'b'] } }) - const field = form._getOrCreateFieldApi({ name: 'items' }) - field.moveValue(1, 1) - expect(field.value).toEqual(['a', 'b']) - }) - - it('warns when called on a non-array field', () => { - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - const form = new InternalFormApi({ defaultValues: { name: '' } }) - const field = form._getOrCreateFieldApi({ name: 'name' }) - field.moveValue(0, 1) - expect(warn).toHaveBeenCalled() - warn.mockRestore() - }) - }) - - describe('removeValue', () => { - it('removes an element at the specified index', () => { - const form = new InternalFormApi({ - defaultValues: { items: ['a', 'b', 'c', 'd', 'e', 'f'] }, - }) - const field = form._getOrCreateFieldApi({ name: 'items' }) - - field.removeValue(1) - expect(field.value).toEqual(['a', 'c', 'd', 'e', 'f']) - field.removeValue(0) - expect(field.value).toEqual(['c', 'd', 'e', 'f']) - field.removeValue(3) - expect(field.value).toEqual(['c', 'd', 'e']) - }) - - it('warns when called on a non-array field', () => { - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - const form = new InternalFormApi({ defaultValues: { name: '' } }) - const field = form._getOrCreateFieldApi({ name: 'name' }) - - field.removeValue(0) - expect(warn).toHaveBeenCalled() - warn.mockRestore() - }) - - it('warns when index is negative', () => { - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - const form = new InternalFormApi({ defaultValues: { items: ['a', 'b'] } }) - const field = form._getOrCreateFieldApi({ name: 'items' }) - - field.removeValue(-1) - expect(warn).toHaveBeenCalled() - expect(field.value).toEqual(['a', 'b']) - warn.mockRestore() - }) - - it('warns when index is out of bounds', () => { - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - const form = new InternalFormApi({ defaultValues: { items: ['a', 'b'] } }) - const field = form._getOrCreateFieldApi({ name: 'items' }) - - field.removeValue(2) - expect(warn).toHaveBeenCalled() - expect(field.value).toEqual(['a', 'b']) - warn.mockRestore() - }) - - it('kills the removed child field and shifts remaining children', () => { - const form = new InternalFormApi({ - defaultValues: { items: ['a', 'b', 'c'] }, - }) - const arrayField = form._getOrCreateFieldApi({ name: 'items' }) - - const field0 = form._getOrCreateFieldApi({ name: 'items[0]' }) - form._getOrCreateFieldApi({ name: 'items[1]' }) - const field2 = form._getOrCreateFieldApi({ name: 'items[2]' }) - - arrayField.removeValue(1) - - expect(form._tryGetFieldApi('items[0]')).toBe(field0) - expect(form._tryGetFieldApi('items[1]')).toBe(field2) - expect(form._tryGetFieldApi('items[2]')).toBeNull() - expect(field0._segment).toBe(0) - expect(field2._segment).toBe(1) - }) - }) - - describe('replaceValue', () => { - it('replaces the element at the specified index', () => { - const form = new InternalFormApi({ - defaultValues: { items: ['a', 'b', 'c'] }, - }) - const field = form._getOrCreateFieldApi({ name: 'items' }) - - field.replaceValue(1, 'x') - expect(field.value).toEqual(['a', 'x', 'c']) - field.replaceValue(0, 'y') - expect(field.value).toEqual(['y', 'x', 'c']) - field.replaceValue(2, 'z') - expect(field.value).toEqual(['y', 'x', 'z']) - }) - - it('marks the field as dirty and touched', () => { - const form = new InternalFormApi({ defaultValues: { items: ['a'] } }) - const field = form._getOrCreateFieldApi({ name: 'items' }) - - field.replaceValue(0, 'b') - expect(field.meta.isDirty).toBe(true) - expect(field.meta.isTouched).toBe(true) - }) - - it('warns when called on a non-array field', () => { - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - const form = new InternalFormApi({ defaultValues: { name: '' } }) - const field = form._getOrCreateFieldApi({ name: 'name' }) - - field.replaceValue(0, 'x') - expect(warn).toHaveBeenCalled() - expect(field.value).toBe('') - warn.mockRestore() - }) - - it('warns when index is out of bounds', () => { - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - const form = new InternalFormApi({ defaultValues: { items: ['a', 'b'] } }) - const field = form._getOrCreateFieldApi({ name: 'items' }) - - field.replaceValue(-1, 'x') - field.replaceValue(2, 'x') - expect(warn).toHaveBeenCalledTimes(2) - expect(field.value).toEqual(['a', 'b']) - warn.mockRestore() - }) - - it('keeps child fields in place', () => { - const form = new InternalFormApi({ - defaultValues: { items: ['a', 'b', 'c'] }, - }) - const arrayField = form._getOrCreateFieldApi({ name: 'items' }) - const field0 = form._getOrCreateFieldApi({ name: 'items[0]' }) - const field1 = form._getOrCreateFieldApi({ name: 'items[1]' }) - const field2 = form._getOrCreateFieldApi({ name: 'items[2]' }) - - arrayField.replaceValue(1, 'x') - - expect(form._tryGetFieldApi('items[0]')).toBe(field0) - expect(form._tryGetFieldApi('items[1]')).toBe(field1) - expect(form._tryGetFieldApi('items[2]')).toBe(field2) - expect(field1.value).toBe('x') - }) - }) - - describe('filterFieldValues', () => { - it('filters array elements based on predicate', () => { - const form = new InternalFormApi({ - defaultValues: { items: ['a', 'b', 'c', 'd'] }, - }) - const field = form._getOrCreateFieldApi({ name: 'items' }) - field.filterValues((value) => value !== 'b' && value !== 'd') - expect(form.getFieldValue('items')).toEqual(['a', 'c']) - }) - - it('filters with index-aware predicate', () => { - const form = new InternalFormApi({ - defaultValues: { items: ['a', 'b', 'c', 'd'] }, - }) - const field = form._getOrCreateFieldApi({ name: 'items' }) - field.filterValues((_, index) => index % 2 === 0) - expect(form.getFieldValue('items')).toEqual(['a', 'c']) - }) - - it('filters with array-aware predicate', () => { - const form = new InternalFormApi({ - defaultValues: { items: ['a', 'b', 'c'] }, - }) - const field = form._getOrCreateFieldApi({ name: 'items' }) - field.filterValues((_, index, array) => { - return index === array.length - 1 - }) - expect(form.getFieldValue('items')).toEqual(['c']) - }) - - it('uses thisArg when provided', () => { - const form = new InternalFormApi({ - defaultValues: { items: ['a', 'b', 'c'] }, - }) - const field = form._getOrCreateFieldApi({ name: 'items' }) - const thisObj = { threshold: 1 } - field.filterValues( - function (_, index) { - // @ts-expect-error - Who tf uses thisArg anyways - return index >= this.threshold - }, - { thisArg: thisObj }, - ) - expect(form.getFieldValue('items')).toEqual(['b', 'c']) - }) - - it('filters out all elements', () => { - const form = new InternalFormApi({ - defaultValues: { items: ['a', 'b', 'c'] }, - }) - const field = form._getOrCreateFieldApi({ name: 'items' }) - field.filterValues(() => false) - expect(form.getFieldValue('items')).toEqual([]) - }) - - it('keeps all elements when predicate always returns true', () => { - const form = new InternalFormApi({ - defaultValues: { items: ['a', 'b', 'c'] }, - }) - const field = form._getOrCreateFieldApi({ name: 'items' }) - field.filterValues(() => true) - expect(form.getFieldValue('items')).toEqual(['a', 'b', 'c']) - }) - - it('filters an empty array', () => { - const form = new InternalFormApi({ - defaultValues: { items: [] as Array }, - }) - const field = form._getOrCreateFieldApi({ name: 'items' }) - field.filterValues(() => true) - expect(form.getFieldValue('items')).toEqual([]) - }) - - it('warns when called on a non-array field', () => { - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - const form = new InternalFormApi({ defaultValues: { name: '' } }) - const field = form._getOrCreateFieldApi({ name: 'name' }) - field.filterValues(() => true) - expect(warn).toHaveBeenCalled() - warn.mockRestore() - }) - - it('reorganizes child field segments after filtering', () => { - const form = new InternalFormApi({ - defaultValues: { items: ['a', 'b', 'c', 'd'] }, - }) - const arrayField = form._getOrCreateFieldApi({ name: 'items' }) - const field0 = form._getOrCreateFieldApi({ name: 'items[0]' }) - form._getOrCreateFieldApi({ name: 'items[1]' }) - const field2 = form._getOrCreateFieldApi({ name: 'items[2]' }) - form._getOrCreateFieldApi({ name: 'items[3]' }) - - // Keep only indices 0 and 2 - arrayField.filterValues((_, index) => index === 0 || index === 2) - - expect(field0._segment).toBe(0) - expect(field2._segment).toBe(1) - expect(form._tryGetFieldApi('items[0]')).toBe(field0) - expect(form._tryGetFieldApi('items[1]')).toBe(field2) - expect(form._tryGetFieldApi('items[2]')).toBeNull() - expect(form._tryGetFieldApi('items[3]')).toBeNull() - }) - - it('kills removed child fields', () => { - const form = new InternalFormApi({ - defaultValues: { items: ['a', 'b', 'c'] }, - }) - const arrayField = form._getOrCreateFieldApi({ name: 'items' }) - - const field0 = form._getOrCreateFieldApi({ name: 'items[0]' }) - form._getOrCreateFieldApi({ name: 'items[1]' }) - const field2 = form._getOrCreateFieldApi({ name: 'items[2]' }) - - arrayField.filterValues((_, index) => index !== 1) - - expect(form._tryGetFieldApi('items[0]')).toBe(field0) - expect(form._tryGetFieldApi('items[1]')).toBe(field2) - }) - - it('updates array field meta when filtered', () => { - const form = new InternalFormApi({ - defaultValues: { items: ['a', 'b', 'c'] }, - }) - const field = form._getOrCreateFieldApi({ name: 'items' }) - field.filterValues(() => true) - expect(field.meta.isDirty).toBe(true) - expect(field.meta.isTouched).toBe(true) - }) - - it('does not set value when no elements are filtered out', () => { - const form = new InternalFormApi({ - defaultValues: { items: ['a', 'b', 'c'] }, - }) - const field = form._getOrCreateFieldApi({ name: 'items' }) - - const originalArray = field.value - field.filterValues(() => true) - expect(field.value).toBe(originalArray) - }) - - it('handles complex objects in array', () => { - const form = new InternalFormApi({ - defaultValues: { - items: [ - { name: 'Alice', age: 30 }, - { name: 'Bob', age: 25 }, - { name: 'Charlie', age: 35 }, - ], - }, - }) - const field = form._getOrCreateFieldApi({ name: 'items' }) - field.filterValues((person: any) => person.age > 28) - expect(form.getFieldValue('items')).toEqual([ - { name: 'Alice', age: 30 }, - { name: 'Charlie', age: 35 }, - ]) - }) - }) - - // End of Field array methods -}) diff --git a/packages/form-core/tests/FieldApi/default-value.spec.ts b/packages/form-core/tests/FieldApi/default-value.spec.ts deleted file mode 100644 index 9ac21fd3ce..0000000000 --- a/packages/form-core/tests/FieldApi/default-value.spec.ts +++ /dev/null @@ -1,123 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { InternalFormApi } from '../../src/FormApi/FormApi.lib' - -describe('field - isDefaultValue', () => { - it('tracks leaf fields independently from dirty state', () => { - const form = new InternalFormApi({ defaultValues: { name: '' } }) - const field = form._getOrCreateFieldApi({ name: 'name' }) - - expect(field.meta.isDefaultValue).toBe(true) - - field.handleChange('Alice') - expect(field.meta.isDefaultValue).toBe(false) - - field.handleChange('') - expect(field.meta.isDefaultValue).toBe(true) - expect(field.meta.isDirty).toBe(true) - expect(field.meta.isPristine).toBe(false) - }) - - it('does not cache primitive field comparisons', () => { - const form = new InternalFormApi({ defaultValues: { user: { name: '' } } }) - const user = form._getOrCreateFieldApi({ name: 'user' }) - const name = form._getOrCreateFieldApi({ name: 'user.name' }) - - expect(name.meta.isDefaultValue).toBe(true) - expect(name._defaultValueCache).toBeNull() - - name.handleChange('Tony') - expect(name.meta.isDefaultValue).toBe(false) - expect(name._defaultValueCache).toBeNull() - expect(user._defaultValueCache?.isDefaultValue).toBe(false) - }) - - it('derives parent fields from descendant values', () => { - const form = new InternalFormApi({ - defaultValues: { - user: { firstName: 'Tony', lastName: 'Hawk' }, - }, - }) - const user = form._getOrCreateFieldApi({ name: 'user' }) - const firstName = form._getOrCreateFieldApi({ name: 'user.firstName' }) - const lastName = form._getOrCreateFieldApi({ name: 'user.lastName' }) - - expect(user.meta.isDefaultValue).toBe(true) - expect(firstName.meta.isDefaultValue).toBe(true) - expect(lastName.meta.isDefaultValue).toBe(true) - - firstName.handleChange('Anthony') - expect(firstName.meta.isDefaultValue).toBe(false) - expect(user.meta.isDefaultValue).toBe(false) - expect(lastName.meta.isDefaultValue).toBe(true) - - firstName.handleChange('Tony') - expect(firstName.meta.isDefaultValue).toBe(true) - expect(user.meta.isDefaultValue).toBe(true) - }) - - it('keeps descendants default when an ancestor cache reports default', () => { - const form = new InternalFormApi({ - defaultValues: { user: { name: 'Tony' } }, - }) - const user = form._getOrCreateFieldApi({ name: 'user' }) - - expect(user.meta.isDefaultValue).toBe(true) - - const name = form._getOrCreateFieldApi({ name: 'user.name' }) - expect(name.meta.isDefaultValue).toBe(true) - }) - - it('handles array mutations and path cache invalidation', () => { - const form = new InternalFormApi({ - defaultValues: { - items: [{ name: 'a' }, { name: 'b' }], - }, - }) - const items = form._getOrCreateFieldApi({ name: 'items' }) - const firstName = form._getOrCreateFieldApi({ name: 'items[0].name' }) - - expect(items.meta.isDefaultValue).toBe(true) - expect(firstName.meta.isDefaultValue).toBe(true) - - items.swapValues(0, 1) - expect(firstName.name).toBe('items[1].name') - expect(firstName.meta.isDefaultValue).toBe(false) - expect(items.meta.isDefaultValue).toBe(false) - - items.swapValues(0, 1) - expect(firstName.name).toBe('items[0].name') - expect(firstName.meta.isDefaultValue).toBe(true) - expect(items.meta.isDefaultValue).toBe(true) - }) - - it('returns arrays to default after inverse operations', () => { - const form = new InternalFormApi({ - defaultValues: { items: ['a', 'b', 'c'] }, - }) - const items = form._getOrCreateFieldApi({ name: 'items' }) - - items.moveValue(0, 2) - expect(items.meta.isDefaultValue).toBe(false) - - items.moveValue(2, 0) - expect(items.meta.isDefaultValue).toBe(true) - - items.insertValue(1, 'x') - expect(items.meta.isDefaultValue).toBe(false) - - items.removeValue(1) - expect(items.meta.isDefaultValue).toBe(true) - - items.pushValue('d') - expect(items.meta.isDefaultValue).toBe(false) - - items.removeValue(3) - expect(items.meta.isDefaultValue).toBe(true) - - items.clearValues() - expect(items.meta.isDefaultValue).toBe(false) - - items.reset() - expect(items.meta.isDefaultValue).toBe(true) - }) -}) diff --git a/packages/form-core/tests/FieldApi/fieldTraversal.lib.spec.ts b/packages/form-core/tests/FieldApi/fieldTraversal.lib.spec.ts new file mode 100644 index 0000000000..cd01e9d28b --- /dev/null +++ b/packages/form-core/tests/FieldApi/fieldTraversal.lib.spec.ts @@ -0,0 +1,136 @@ +import { describe, expect, it } from 'vitest' +import { InternalFormApi } from '../../src/FormApi/FormApi.lib' +import { + collectFieldSubtree, + visitAllFormFields, + visitFieldAndAncestors, + visitFieldSubtree, +} from '../../src/FieldApi/fieldTraversal.lib' + +function createFieldTree() { + const form = new InternalFormApi({ + defaultValues: { + section: { first: { deep: '' }, second: '' }, + other: '', + }, + }) + const section = form._getOrCreateFieldApi({ name: 'section' }) + form._getOrCreateFieldApi({ name: 'section.first' }) + const deep = form._getOrCreateFieldApi({ name: 'section.first.deep' }) + form._getOrCreateFieldApi({ name: 'section.second' }) + form._getOrCreateFieldApi({ name: 'other' }) + + return { form, section, deep } +} + +describe('visitFieldAndAncestors', () => { + it('visits a field and its ancestors without visiting the synthetic root', () => { + const { deep } = createFieldTree() + const names: Array = [] + + visitFieldAndAncestors(deep, (field) => { + names.push(field.name) + }) + + expect(names).toEqual(['section.first.deep', 'section.first', 'section']) + }) + + it('stops before visiting further ancestors when the visitor returns stop', () => { + const { deep } = createFieldTree() + const names: Array = [] + + visitFieldAndAncestors(deep, (field, stop) => { + names.push(field.name) + if (field.name === 'section.first') return stop + return undefined + }) + + expect(names).toEqual(['section.first.deep', 'section.first']) + }) +}) + +describe('visitFieldSubtree', () => { + it('visits the field and its descendants, parents before children', () => { + const { section } = createFieldTree() + const names: Array = [] + + visitFieldSubtree(section, (field) => { + names.push(field.name) + }) + + const firstIndex = names.indexOf('section.first') + const deepIndex = names.indexOf('section.first.deep') + + expect([...names].sort()).toEqual([ + 'section', + 'section.first', + 'section.first.deep', + 'section.second', + ]) + expect(names[0]).toBe('section') + expect(firstIndex).toBeLessThan(deepIndex) + }) + + it('stops before visiting any more fields when the visitor returns stop', () => { + const { section } = createFieldTree() + const names: Array = [] + + visitFieldSubtree(section, (field, stop) => { + names.push(field.name) + if (field.name !== 'section') return stop + return undefined + }) + + expect(names).toEqual([ + 'section', + expect.stringMatching(/^section\.(first|second)$/), + ]) + }) +}) + +describe('visitAllFormFields', () => { + it('visits every field, parents before children, without visiting the synthetic root', () => { + const { form } = createFieldTree() + const names: Array = [] + + visitAllFormFields(form._fieldRootNode, (field) => { + names.push(field.name) + }) + + const sectionIndex = names.indexOf('section') + const firstIndex = names.indexOf('section.first') + const deepIndex = names.indexOf('section.first.deep') + const secondIndex = names.indexOf('section.second') + + expect([...names].sort()).toEqual([ + 'other', + 'section', + 'section.first', + 'section.first.deep', + 'section.second', + ]) + expect(sectionIndex).toBeLessThan(firstIndex) + expect(sectionIndex).toBeLessThan(secondIndex) + expect(firstIndex).toBeLessThan(deepIndex) + }) +}) + +describe('collectFieldSubtree', () => { + it('collects the field and its descendants, parents before children', () => { + const { section } = createFieldTree() + + const fields = collectFieldSubtree(section) + const names = fields.map((field) => field.name) + const firstIndex = names.indexOf('section.first') + const deepIndex = names.indexOf('section.first.deep') + + expect([...names].sort()).toEqual([ + 'section', + 'section.first', + 'section.first.deep', + 'section.second', + ]) + expect(names[0]).toBe('section') + expect(firstIndex).toBeLessThan(deepIndex) + }) +}) diff --git a/packages/form-core/tests/FieldApi/fieldTraversal.spec.ts b/packages/form-core/tests/FieldApi/fieldTraversal.spec.ts deleted file mode 100644 index 1a1d9ae270..0000000000 --- a/packages/form-core/tests/FieldApi/fieldTraversal.spec.ts +++ /dev/null @@ -1,98 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { InternalFormApi } from '../../src/FormApi/FormApi.lib' -import { - collectFieldSubtree, - visitAllFormFields, - visitFieldAndAncestors, - visitFieldSubtree, -} from '../../src/FieldApi/fieldTraversal.lib' -import type { AnyInternalFieldApi } from '../../src/FieldApi/FieldApi.lib' - -function createFieldTree() { - const form = new InternalFormApi({ - defaultValues: { - section: { first: '', second: { deep: '' } }, - other: '', - }, - }) - const section = form._getOrCreateFieldApi({ name: 'section' }) - const first = form._getOrCreateFieldApi({ name: 'section.first' }) - const second = form._getOrCreateFieldApi({ name: 'section.second' }) - const deep = form._getOrCreateFieldApi({ name: 'section.second.deep' }) - const other = form._getOrCreateFieldApi({ name: 'other' }) - - return { form, section, first, second, deep, other } -} - -function collectNames( - visit: (visitor: (field: AnyInternalFieldApi) => void) => void, -): Array { - const names: Array = [] - visit((field) => names.push(field.name)) - return names -} - -describe('field traversal', () => { - it('visits a field and its ancestors without visiting the synthetic root', () => { - const { deep } = createFieldTree() - - expect( - collectNames((visitor) => visitFieldAndAncestors(deep, visitor)), - ).toEqual(['section.second.deep', 'section.second', 'section']) - }) - - it('visits subtrees and complete forms in insertion-order preorder', () => { - const { form, section } = createFieldTree() - - expect( - collectNames((visitor) => visitFieldSubtree(section, visitor)), - ).toEqual([ - 'section', - 'section.first', - 'section.second', - 'section.second.deep', - ]) - expect( - collectNames((visitor) => - visitAllFormFields(form._fieldRootNode, visitor), - ), - ).toEqual([ - 'section', - 'section.first', - 'section.second', - 'section.second.deep', - 'other', - ]) - }) - - it('stops ancestor and subtree traversal when the visitor returns stop', () => { - const { section, deep } = createFieldTree() - const ancestors: Array = [] - const subtree: Array = [] - - visitFieldAndAncestors(deep, (field, stop) => { - ancestors.push(field.name) - if (field.name === 'section.second') return stop - return undefined - }) - visitFieldSubtree(section, (field, stop) => { - subtree.push(field.name) - if (field.name === 'section.second') return stop - return undefined - }) - - expect(ancestors).toEqual(['section.second.deep', 'section.second']) - expect(subtree).toEqual(['section', 'section.first', 'section.second']) - }) - - it('collects the same ordered fields produced by subtree visitation', () => { - const { section } = createFieldTree() - const visited: Array = [] - - visitFieldSubtree(section, (field) => { - visited.push(field) - }) - - expect(collectFieldSubtree(section)).toEqual(visited) - }) -}) diff --git a/packages/form-core/tests/FieldApi/listeners.spec.ts b/packages/form-core/tests/FieldApi/listeners.spec.ts deleted file mode 100644 index 21abb2e0c1..0000000000 --- a/packages/form-core/tests/FieldApi/listeners.spec.ts +++ /dev/null @@ -1,786 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import { InternalFormApi } from '../../src/FormApi/FormApi.lib' - -describe('field - listeners', () => { - it('change listeners', () => { - const listener = vi.fn() - - const form = new InternalFormApi({ defaultValues: { x: '' } }) - const field = form._getOrCreateFieldApi({ - name: 'x', - listeners: [{ triggers: ['change'], run: listener }], - }) - - field.handleChange('x') - expect(listener).toHaveBeenCalledOnce() - }) - - it('blur listeners', () => { - const listener = vi.fn() - - const form = new InternalFormApi({ defaultValues: { x: '' } }) - const field = form._getOrCreateFieldApi({ - name: 'x', - listeners: [{ triggers: ['blur'], run: listener }], - }) - - field.handleBlur() - expect(listener).toHaveBeenCalledOnce() - }) - - it('skips a field listener when a boolean predicate disables the trigger', () => { - const listener = vi.fn() - - const form = new InternalFormApi({ defaultValues: { x: '' } }) - const field = form._getOrCreateFieldApi({ - name: 'x', - listeners: [ - { - triggers: [{ trigger: 'change', when: false }], - run: listener, - }, - ], - }) - - field.handleChange('x') - - expect(listener).not.toHaveBeenCalled() - }) - - it('passes field context to listener predicates', () => { - const listener = vi.fn() - const when = vi.fn(({ triggerFieldApi, value }) => { - expect(triggerFieldApi.name).toBe('x') - return value === 'run' - }) - - const form = new InternalFormApi({ defaultValues: { x: '' } }) - const field = form._getOrCreateFieldApi({ - name: 'x', - listeners: [ - { - triggers: [{ trigger: 'change', when }], - run: listener, - }, - ], - }) - - field.handleChange('skip') - field.handleChange('run') - - expect(when).toHaveBeenCalledTimes(2) - expect(listener).toHaveBeenCalledOnce() - }) - - it('does not run field listeners with empty triggers', () => { - const listener = vi.fn() - - const form = new InternalFormApi({ defaultValues: { x: '' } }) - const field = form._getOrCreateFieldApi({ - name: 'x', - listeners: [{ triggers: [], run: listener }], - }) - - field.handleChange('x') - - expect(listener).not.toHaveBeenCalled() - }) - - it('reset listeners', () => { - const listener = vi.fn() - - const form = new InternalFormApi({ defaultValues: { x: '' } }) - const field = form._getOrCreateFieldApi({ - name: 'x', - listeners: [{ triggers: ['reset'], run: listener }], - }) - - field.reset() - expect(listener).toHaveBeenCalledOnce() - }) - - it('notifies reset listeners for a reset field and its descendants', () => { - const parentListener = vi.fn() - const childListener = vi.fn() - - const form = new InternalFormApi({ defaultValues: { a: { b: '' } } }) - const parent = form._getOrCreateFieldApi({ - name: 'a', - listeners: [{ triggers: ['reset'], run: parentListener }], - }) - const child = form._getOrCreateFieldApi({ - name: 'a.b', - listeners: [{ triggers: ['reset'], run: childListener }], - }) - - parent.reset() - - expect(parentListener).toHaveBeenCalledOnce() - expect(childListener).toHaveBeenCalledOnce() - expect(form._tryGetFieldApi('a')).toBe(parent) - expect(form._tryGetFieldApi('a.b')).toBe(child) - }) - - it('notifies reset listeners when a form reset happens', () => { - const parentListener = vi.fn() - const childListener = vi.fn() - - const form = new InternalFormApi({ defaultValues: { a: { b: '' } } }) - form._getOrCreateFieldApi({ - name: 'a', - listeners: [{ triggers: ['reset'], run: parentListener }], - }) - form._getOrCreateFieldApi({ - name: 'a.b', - listeners: [{ triggers: ['reset'], run: childListener }], - }) - - form.reset() - expect(parentListener).toHaveBeenCalledOnce() - expect(childListener).toHaveBeenCalledOnce() - }) - - it('mount listeners', () => { - const listener = vi.fn() - - const form = new InternalFormApi({ defaultValues: { x: '' } }) - const field = form._getOrCreateFieldApi({ - name: 'x', - listeners: [{ triggers: ['mount'], run: listener }], - }) - - field._register() - expect(listener).toHaveBeenCalledOnce() - }) - - it('unmount listeners', () => { - const listener = vi.fn() - - const form = new InternalFormApi({ defaultValues: { x: '' } }) - const field = form._getOrCreateFieldApi({ - name: 'x', - listeners: [{ triggers: ['unmount'], run: listener }], - }) - - field._register() - field._unregister() - expect(listener).toHaveBeenCalledOnce() - }) - - it('supports dynamic debounce for field listeners', async () => { - vi.useFakeTimers() - const listener = vi.fn() - const triggerDebounceMs = vi.fn(({ formApi, triggerFieldApi, value }) => { - expect(formApi.state.values).toEqual({ name: 'Alice' }) - expect(triggerFieldApi?.name).toBe('name') - expect(value).toBe('Alice') - return 100 - }) - - const form = new InternalFormApi({ defaultValues: { name: '' } }) - const field = form._getOrCreateFieldApi({ - name: 'name', - listeners: [ - { - triggers: ['change'], - triggerDebounceMs, - run: listener, - }, - ], - }) - - field.handleChange('Alice') - - expect(triggerDebounceMs).toHaveBeenCalledOnce() - expect(listener).not.toHaveBeenCalled() - - await vi.advanceTimersByTimeAsync(99) - expect(listener).not.toHaveBeenCalled() - - await vi.advanceTimersByTimeAsync(1) - expect(listener).toHaveBeenCalledOnce() - expect(listener).toHaveBeenCalledWith({ - value: 'Alice', - fieldApi: field, - formApi: form, - }) - - vi.useRealTimers() - }) - - describe('field - linked listeners ', () => { - it('change listeners', () => { - const watchedFieldListener = vi.fn() - const unrelatedListener = vi.fn() - - const form = new InternalFormApi({ - defaultValues: { source: '', other: '', target: '' }, - }) - const targetField = form._getOrCreateFieldApi({ - name: 'target', - listeners: [ - { - triggers: ['change'], - watchFields: ['source'], - run: watchedFieldListener, - }, - { - triggers: ['change'], - watchFields: ['other'], - run: unrelatedListener, - }, - ], - }) - const sourceField = form._getOrCreateFieldApi({ name: 'source' }) - - sourceField.handleChange('source') - expect(watchedFieldListener).toHaveBeenCalledOnce() - expect(watchedFieldListener).toHaveBeenCalledWith({ - value: '', - fieldApi: targetField, - formApi: form, - }) - expect(unrelatedListener).not.toHaveBeenCalled() - }) - - it('blur listeners', () => { - const watchedFieldListener = vi.fn() - const unrelatedListener = vi.fn() - - const form = new InternalFormApi({ - defaultValues: { source: '', other: '', target: '' }, - }) - const targetField = form._getOrCreateFieldApi({ - name: 'target', - listeners: [ - { - triggers: ['blur'], - watchFields: ['source'], - run: watchedFieldListener, - }, - { - triggers: ['blur'], - watchFields: ['other'], - run: unrelatedListener, - }, - ], - }) - const sourceField = form._getOrCreateFieldApi({ name: 'source' }) - - sourceField.handleBlur() - expect(watchedFieldListener).toHaveBeenCalledOnce() - expect(watchedFieldListener).toHaveBeenCalledWith({ - value: '', - fieldApi: targetField, - formApi: form, - }) - expect(unrelatedListener).not.toHaveBeenCalled() - }) - - it('reset listeners', () => { - const watchedFieldListener = vi.fn() - const unrelatedListener = vi.fn() - - const form = new InternalFormApi({ - defaultValues: { source: '', other: '', target: '' }, - }) - const targetField = form._getOrCreateFieldApi({ - name: 'target', - listeners: [ - { - triggers: ['reset'], - watchFields: ['source'], - run: watchedFieldListener, - }, - { - triggers: ['reset'], - watchFields: ['other'], - run: unrelatedListener, - }, - ], - }) - const sourceField = form._getOrCreateFieldApi({ name: 'source' }) - - sourceField.reset() - expect(watchedFieldListener).toHaveBeenCalledOnce() - expect(watchedFieldListener).toHaveBeenCalledWith({ - value: '', - fieldApi: targetField, - formApi: form, - }) - expect(unrelatedListener).not.toHaveBeenCalled() - }) - - it('mount listeners', () => { - const watchedFieldListener = vi.fn() - const unrelatedListener = vi.fn() - - const form = new InternalFormApi({ - defaultValues: { source: '', other: '', target: '' }, - }) - const targetField = form._getOrCreateFieldApi({ - name: 'target', - listeners: [ - { - triggers: ['mount'], - watchFields: ['source'], - run: watchedFieldListener, - }, - { - triggers: ['mount'], - watchFields: ['other'], - run: unrelatedListener, - }, - ], - }) - const sourceField = form._getOrCreateFieldApi({ name: 'source' }) - - sourceField._register() - expect(watchedFieldListener).toHaveBeenCalledOnce() - expect(watchedFieldListener).toHaveBeenCalledWith({ - value: '', - fieldApi: targetField, - formApi: form, - }) - expect(unrelatedListener).not.toHaveBeenCalled() - }) - - it('unmount listeners', () => { - const watchedFieldListener = vi.fn() - const unrelatedListener = vi.fn() - - const form = new InternalFormApi({ - defaultValues: { source: '', other: '', target: '' }, - }) - const targetField = form._getOrCreateFieldApi({ - name: 'target', - listeners: [ - { - triggers: ['unmount'], - watchFields: ['source'], - run: watchedFieldListener, - }, - { - triggers: ['unmount'], - watchFields: ['other'], - run: unrelatedListener, - }, - ], - }) - const sourceField = form._getOrCreateFieldApi({ name: 'source' }) - - sourceField._register() - sourceField._unregister() - expect(watchedFieldListener).toHaveBeenCalledOnce() - expect(watchedFieldListener).toHaveBeenCalledWith({ - value: '', - fieldApi: targetField, - formApi: form, - }) - expect(unrelatedListener).not.toHaveBeenCalled() - }) - - it('updates a dynamic watched field name after its field moves', () => { - const listener = vi.fn() - - const form = new InternalFormApi({ - defaultValues: { users: ['first', 'second'], target: '' }, - }) - const targetField = form._getOrCreateFieldApi({ - name: 'target', - listeners: [ - { - triggers: ['change'], - watchFields: ['users[0]'], - run: listener, - }, - ], - }) - const fieldAt0Before = form._getOrCreateFieldApi({ name: 'users[0]' }) - const fieldAt1Before = form._getOrCreateFieldApi({ name: 'users[1]' }) - - form.swapFieldValues('users', 0, 1) - expect(form._getOrCreateFieldApi({ name: 'users[1]' })).toBe( - fieldAt0Before, - ) - - targetField._update({ - listeners: [ - { - triggers: ['change'], - watchFields: ['users[1]'], - run: listener, - }, - ], - }) - - fieldAt1Before.handleChange('first index changed') - expect(listener).not.toHaveBeenCalled() - - fieldAt0Before.handleChange('second index changed') - expect(listener).toHaveBeenCalledOnce() - expect(listener).toHaveBeenCalledWith({ - value: '', - fieldApi: targetField, - formApi: form, - }) - }) - - it('updates watched fields when the watched name changes without moving a field', () => { - const listener = vi.fn() - - const form = new InternalFormApi({ - defaultValues: { source: '', other: '', target: '' }, - }) - const targetField = form._getOrCreateFieldApi({ - name: 'target', - listeners: [ - { - triggers: ['change'], - watchFields: ['source'], - run: listener, - }, - ], - }) - const sourceField = form._getOrCreateFieldApi({ name: 'source' }) - const otherField = form._getOrCreateFieldApi({ name: 'other' }) - - targetField._update({ - listeners: [ - { - triggers: ['change'], - watchFields: ['other'], - run: listener, - }, - ], - }) - - sourceField.handleChange('source changed') - expect(listener).not.toHaveBeenCalled() - - otherField.handleChange('other changed') - expect(listener).toHaveBeenCalledOnce() - expect(listener).toHaveBeenCalledWith({ - value: '', - fieldApi: targetField, - formApi: form, - }) - }) - - it('keeps remaining listener watches when one watched listener is removed', () => { - const firstListener = vi.fn() - const secondListener = vi.fn() - - const form = new InternalFormApi({ - defaultValues: { source: '', target: '' }, - }) - const targetField = form._getOrCreateFieldApi({ - name: 'target', - listeners: [ - { - triggers: ['change'], - watchFields: ['source'], - run: firstListener, - }, - { - triggers: ['change'], - watchFields: ['source'], - run: secondListener, - }, - ], - }) - const sourceField = form._getOrCreateFieldApi({ name: 'source' }) - - targetField._update({ - listeners: [ - { - triggers: ['change'], - watchFields: ['source'], - run: firstListener, - }, - ], - }) - sourceField.handleChange('source') - - expect(firstListener).toHaveBeenCalledOnce() - expect(secondListener).not.toHaveBeenCalled() - }) - - it('keeps other watching fields attached when one field stops listening', () => { - const firstListener = vi.fn() - const secondListener = vi.fn() - - const form = new InternalFormApi({ - defaultValues: { source: '', first: '', second: '' }, - }) - const firstField = form._getOrCreateFieldApi({ - name: 'first', - listeners: [ - { - triggers: ['change'], - watchFields: ['source'], - run: firstListener, - }, - ], - }) - form._getOrCreateFieldApi({ - name: 'second', - listeners: [ - { - triggers: ['change'], - watchFields: ['source'], - run: secondListener, - }, - ], - }) - const sourceField = form._getOrCreateFieldApi({ name: 'source' }) - - firstField._update({ listeners: [] }) - sourceField.handleChange('source') - - expect(firstListener).not.toHaveBeenCalled() - expect(secondListener).toHaveBeenCalledOnce() - }) - - it("does not run a watched field's listener when the trigger does not match", () => { - const listener = vi.fn() - - const form = new InternalFormApi({ - defaultValues: { source: '', target: '' }, - }) - form._getOrCreateFieldApi({ - name: 'target', - listeners: [ - { - triggers: ['blur'], - watchFields: ['source'], - run: listener, - }, - ], - }) - const sourceField = form._getOrCreateFieldApi({ name: 'source' }) - - sourceField.handleChange('source') - - expect(listener).not.toHaveBeenCalled() - }) - - it('runs a linked listener when any of multiple watched fields changes', () => { - const listener = vi.fn() - - const form = new InternalFormApi({ - defaultValues: { source: '', other: '', target: '' }, - }) - const targetField = form._getOrCreateFieldApi({ - name: 'target', - listeners: [ - { - triggers: ['change'], - watchFields: ['source', 'other'], - run: listener, - }, - ], - }) - const sourceField = form._getOrCreateFieldApi({ name: 'source' }) - const otherField = form._getOrCreateFieldApi({ name: 'other' }) - - sourceField.handleChange('source') - otherField.handleChange('other') - - expect(listener).toHaveBeenCalledTimes(2) - expect(listener).toHaveBeenNthCalledWith(1, { - value: '', - fieldApi: targetField, - formApi: form, - }) - expect(listener).toHaveBeenNthCalledWith(2, { - value: '', - fieldApi: targetField, - formApi: form, - }) - }) - - it('follows fields across dynamic names', () => { - const listener = vi.fn() - const form = new InternalFormApi({ - defaultValues: { users: [{ name: 'First' }, { name: 'Second' }] }, - }) - - const firstField = form._getOrCreateFieldApi({ name: 'users[0]' }) - const secondField = form._getOrCreateFieldApi({ - name: 'users[1]', - listeners: [ - { run: listener, watchFields: ['users[0]'], triggers: ['change'] }, - ], - }) - - // Confirm that SecondField is listening to firstField -> firstField change - firstField.handleChange('New name') - expect(listener).toHaveBeenCalledOnce() - - // swapFieldValues - form.swapFieldValues('users', 0, 1) - - secondField._update({ - listeners: [ - { run: listener, watchFields: ['users[1]'], triggers: ['change'] }, - ], - }) - firstField.handleChange('New new name') - expect(listener).toHaveBeenCalledTimes(2) - }) - - it('handles mismatched field references when field name resolves to different instance', () => { - const listener = vi.fn() - const form = new InternalFormApi({ - defaultValues: { - users: [{ name: 'First' }, { name: 'Second' }, { name: 'Third' }], - }, - }) - - const oldField = form._getOrCreateFieldApi({ name: 'users[0]' }) - const watchingField = form._getOrCreateFieldApi({ - name: 'users[1]', - listeners: [ - { run: listener, watchFields: ['users[0]'], triggers: ['change'] }, - ], - }) - - oldField.handleChange('First change') - expect(listener).toHaveBeenCalledOnce() - listener.mockClear() - - // Swap the field values - now users[0] resolves to what was users[2] - form.swapFieldValues('users', 0, 2) - - // Update listener to still watch 'users[0]', but now that name resolves to thirdField - watchingField._update({ - listeners: [ - { run: listener, watchFields: ['users[0]'], triggers: ['change'] }, - ], - }) - - oldField.handleChange('Second change') - // old firstField change should NOT trigger listener - // because we're no longer watching it - expect(listener).not.toHaveBeenCalled() - - const newField = form._getOrCreateFieldApi({ name: 'users[0]' }) - newField.handleChange('Third change') - expect(listener).toHaveBeenCalledOnce() - }) - - it('should warn if a field watches itself', async () => { - const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) - const listener = vi.fn() - const form = new InternalFormApi({ - defaultValues: { name: '' }, - }) - - const firstField = form._getOrCreateFieldApi({ - name: 'name', - listeners: [ - { run: listener, watchFields: ['name'], triggers: ['change'] }, - ], - }) - - firstField.handleChange('Change') - await vi.waitFor(() => { - expect(consoleSpy).toHaveBeenCalled() - expect(listener).toHaveBeenCalledOnce() - }) - - consoleSpy.mockRestore() - }) - - it('should skip listeners that do not watch the linked field', () => { - const listener1 = vi.fn() - const listener2 = vi.fn() - const listener3 = vi.fn() - - const form = new InternalFormApi({ - defaultValues: { source: '', target: '' }, - }) - - form._getOrCreateFieldApi({ - name: 'target', - listeners: [ - { run: listener1, triggers: ['change'], watchFields: ['source'] }, - { run: listener2, triggers: ['change'] }, - { run: listener3, triggers: ['change'], watchFields: ['source'] }, - ], - }) - - const sourceField = form._getOrCreateFieldApi({ name: 'source' }) - - sourceField.handleChange('new value') - - expect(listener1).toHaveBeenCalledOnce() - expect(listener2).not.toHaveBeenCalled() - expect(listener3).toHaveBeenCalledOnce() - }) - - it('keeps debounced watched listeners isolated by stable instance', async () => { - vi.useFakeTimers() - const firstListener = vi.fn() - const secondListener = vi.fn() - const form = new InternalFormApi({ - defaultValues: { firstSource: '', secondSource: '', target: '' }, - }) - form._getOrCreateFieldApi({ - name: 'target', - listeners: [ - { - run: firstListener, - triggers: ['change'], - watchFields: ['firstSource'], - triggerDebounceMs: 100, - }, - { - run: secondListener, - triggers: ['change'], - watchFields: ['secondSource'], - triggerDebounceMs: 100, - }, - ], - }) - const firstSource = form._getOrCreateFieldApi({ name: 'firstSource' }) - const secondSource = form._getOrCreateFieldApi({ name: 'secondSource' }) - - secondSource.handleChange('second') - firstSource.handleChange('first') - await vi.advanceTimersByTimeAsync(100) - - expect(firstListener).toHaveBeenCalledOnce() - expect(secondListener).toHaveBeenCalledOnce() - vi.useRealTimers() - }) - - it('clears watched listener links when reset kills fields', () => { - const form = new InternalFormApi({ - defaultValues: { source: '', target: '' }, - }) - const targetField = form._getOrCreateFieldApi({ - name: 'target', - listeners: [ - { run: vi.fn(), watchFields: ['source'], triggers: ['change'] }, - ], - }) - const sourceField = form._getOrCreateFieldApi({ name: 'source' }) - - expect(sourceField._watchingListenerFields?.has(targetField)).toBe(true) - expect( - targetField._listenerInstances?.[0]?.resolvedWatchFields?.get('source'), - ).toBe(sourceField) - - form.reset() - - expect(sourceField._watchingListenerFields).toBeNull() - expect(targetField._listenerInstances).toBeNull() - expect(sourceField._isKilled).toBe(true) - expect(targetField._isKilled).toBe(true) - expect(form._tryGetFieldApi('source')).toBeNull() - expect(form._tryGetFieldApi('target')).toBeNull() - }) - }) -}) diff --git a/packages/form-core/tests/FieldApi/state.spec.ts b/packages/form-core/tests/FieldApi/state.spec.ts deleted file mode 100644 index cf3d421e92..0000000000 --- a/packages/form-core/tests/FieldApi/state.spec.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { InternalFormApi } from '../../src/FormApi/FormApi.lib' - -describe('field - state', () => { - describe('handleChange', () => { - it('updates the field value', () => { - const form = new InternalFormApi({ defaultValues: { name: '' } }) - const field = form._getOrCreateFieldApi({ name: 'name' }) - field.handleChange('Alice') - expect(field.value).toBe('Alice') - }) - - it('accepts an updater function', () => { - const form = new InternalFormApi({ defaultValues: { count: 1 } }) - const field = form._getOrCreateFieldApi({ name: 'count' }) - field.handleChange((prev: number) => prev + 1) - expect(field.value).toBe(2) - }) - - it('marks the field as dirty and touched by default', () => { - const form = new InternalFormApi({ defaultValues: { name: '' } }) - const field = form._getOrCreateFieldApi({ name: 'name' }) - field.handleChange('Alice') - expect(field.meta.isDirty).toBe(true) - expect(field.meta.isTouched).toBe(true) - }) - - it('respects markAsDirty: false', () => { - const form = new InternalFormApi({ defaultValues: { name: '' } }) - const field = form._getOrCreateFieldApi({ name: 'name' }) - field.handleChange('Alice', { markAsDirty: false }) - expect(field.meta.isDirty).toBe(false) - }) - - it('respects markAsTouched: false', () => { - const form = new InternalFormApi({ defaultValues: { name: '' } }) - const field = form._getOrCreateFieldApi({ name: 'name' }) - field.handleChange('Alice', { markAsTouched: false }) - expect(field.meta.isTouched).toBe(false) - }) - }) -}) diff --git a/packages/form-core/tests/FieldApi/utils.spec.ts b/packages/form-core/tests/FieldApi/utils.spec.ts deleted file mode 100644 index 5576d4f925..0000000000 --- a/packages/form-core/tests/FieldApi/utils.spec.ts +++ /dev/null @@ -1,179 +0,0 @@ -import { describe, expect, it } from 'vitest' - -import { InternalFormApi, nameToFieldNodeSegments } from '../../src/internals' - -describe('nameToFieldNodeSegments', () => { - it('splits dot-separated field names', () => { - expect(nameToFieldNodeSegments('foo.bar')).toEqual(['foo', 'bar']) - }) - - it('splits multiple dot-separated field names', () => { - expect(nameToFieldNodeSegments('foo.bar.foobar')).toEqual([ - 'foo', - 'bar', - 'foobar', - ]) - }) - - it('splits bracket array access into segments', () => { - expect(nameToFieldNodeSegments('foo[0].bar')).toEqual(['foo', 0, 'bar']) - }) - - it('splits chained bracket access into segments', () => { - expect(nameToFieldNodeSegments('foo[0][1].bar')).toEqual([ - 'foo', - 0, - 1, - 'bar', - ]) - }) - - it('handles empty string', () => { - expect(nameToFieldNodeSegments('')).toEqual([]) - }) - - it('handles single segment', () => { - expect(nameToFieldNodeSegments('foo')).toEqual(['foo']) - }) - - it('handles array syntax without dot', () => { - expect(nameToFieldNodeSegments('foo[0]')).toEqual(['foo', 0]) - }) - - it('handles mixed brackets and dots', () => { - expect(nameToFieldNodeSegments('foo.bar[0].baz')).toEqual([ - 'foo', - 'bar', - 0, - 'baz', - ]) - }) - - it('returns a shallow copy of array input', () => { - // A dev may want to reuse segments for multiple calls, so it's essential - // that we don't mutate the input array. - const segments = ['foo', 'bar'] - const result = nameToFieldNodeSegments(segments) - expect(result).toEqual(segments) - expect(result).not.toBe(segments) - }) -}) - -describe('Form Field names', () => { - it('returns the correct name for a top-level field', () => { - const form = new InternalFormApi({ - defaultValues: { foo: '' }, - }) - const field = form._getOrCreateFieldApi({ name: 'foo' }) - expect(field.name).toBe('foo') - }) - - it('returns the correct name for nested fields with dot notation', () => { - const form = new InternalFormApi({ - defaultValues: { foo: { bar: { baz: '' } } }, - }) - const field = form._getOrCreateFieldApi({ name: 'foo.bar.baz' }) - expect(field.name).toBe('foo.bar.baz') - }) - - it('returns the correct name for array fields with bracket notation', () => { - const form = new InternalFormApi({ - defaultValues: { arr: [''] }, - }) - const field = form._getOrCreateFieldApi({ name: 'arr[0]' }) - expect(field.name).toBe('arr[0]') - }) - - it('returns the correct name for nested array fields', () => { - const form = new InternalFormApi({ - defaultValues: { arr: [{ nested: '' }] }, - }) - const field = form._getOrCreateFieldApi({ name: 'arr[0].nested' }) - expect(field.name).toBe('arr[0].nested') - }) - - it('returns the correct name for deeply nested array fields', () => { - const form = new InternalFormApi({ - defaultValues: { foo: { arr: [{ bar: '' }] } }, - }) - const field = form._getOrCreateFieldApi({ name: 'foo.arr[0].bar' }) - expect(field.name).toBe('foo.arr[0].bar') - }) - - it('returns the correct name for chained array access', () => { - const form = new InternalFormApi({ - defaultValues: { arr: [[['']]] }, - }) - const field = form._getOrCreateFieldApi({ name: 'arr[0][1][2]' }) - expect(field.name).toBe('arr[0][1][2]') - }) - - it('updates child fields when the parent name changes', () => { - const form = new InternalFormApi({ - defaultValues: { arr: [{ foo: '' }, { bar: '' }] }, - }) - const field = form._getOrCreateFieldApi({ name: 'arr[0]' }) - const subField = form._getOrCreateFieldApi({ name: 'arr[0].bar' }) - - expect(field.name).toBe('arr[0]') - expect(subField.name).toBe('arr[0].bar') - - field._moveTo(1) - - expect(field.name).toBe('arr[1]') - expect(subField.name).toBe('arr[1].bar') - }) - - it('builds name correctly from parent chain', () => { - const form = new InternalFormApi({ - defaultValues: { a: { b: { c: '' } } }, - }) - const fieldC = form._getOrCreateFieldApi({ name: 'a.b.c' }) - - expect(fieldC.name).toBe('a.b.c') - expect(fieldC._parent.name).toBe('a.b') - // Root node doesn't have parent, so it complains about the types - expect((fieldC._parent as any)._parent.name).toBe('a') - expect((fieldC._parent as any)._parent._parent.name).toBe('') - }) - - it('uses bracket notation for array parent segments', () => { - const form = new InternalFormApi({ - defaultValues: { arr: [{ nested: '' }] }, - }) - const field = form._getOrCreateFieldApi({ name: 'arr[0].nested' }) - - expect(field.name).toBe('arr[0].nested') - expect(field._parent.name).toBe('arr[0]') - }) - - it('allows top-level arrays', () => { - const form = new InternalFormApi({ - defaultValues: [{ name: '' }], - }) - - const field = form._getOrCreateFieldApi({ name: '[0].name' }) - expect(field.name).toBe('[0].name') - }) - - it('updates field lookup after form.swapFieldValues', () => { - const form = new InternalFormApi({ defaultValues: { items: ['a', 'b'] } }) - - const fieldAt0Before = form._getOrCreateFieldApi({ name: 'items[0]' }) - const fieldAt1Before = form._getOrCreateFieldApi({ name: 'items[1]' }) - - expect(fieldAt0Before.name).toBe('items[0]') - expect(fieldAt1Before.name).toBe('items[1]') - - form.swapFieldValues('items', 0, 1) - - const fieldAt0After = form._getOrCreateFieldApi({ name: 'items[0]' }) - const fieldAt1After = form._getOrCreateFieldApi({ name: 'items[1]' }) - - expect(fieldAt0After).toBe(fieldAt1Before) - expect(fieldAt0After.name).toBe('items[0]') - - expect(fieldAt1After).toBe(fieldAt0Before) - expect(fieldAt1After.name).toBe('items[1]') - }) -}) diff --git a/packages/form-core/tests/FormApi/FormApi.lib.spec.ts b/packages/form-core/tests/FormApi/FormApi.lib.spec.ts index af5c60051b..3dc9393ff7 100644 --- a/packages/form-core/tests/FormApi/FormApi.lib.spec.ts +++ b/packages/form-core/tests/FormApi/FormApi.lib.spec.ts @@ -1,6 +1,7 @@ import { describe, expect, it, vi } from 'vitest' import { InternalFormApi } from '../../src/FormApi/FormApi.lib' import { formOptions } from '../../src' +import { promiseWithResolvers } from '../test-utils' describe('InternalFormApi - constructor', () => { it('initializes default values with untouched, submittable state', () => { @@ -25,6 +26,28 @@ describe('InternalFormApi - constructor', () => { }) }) +describe('InternalFormApi - mount', () => { + it('notifies mount listeners with the form values', ({ onTestFinished }) => { + const listener = vi.fn() + const form = new InternalFormApi({ + defaultValues: { name: '' }, + listeners: [{ triggers: ['mount'], run: listener }], + }) + + const unmount = form.mount() + onTestFinished(unmount) + + expect(listener).toHaveBeenCalledOnce() + expect(listener).toHaveBeenCalledWith( + expect.objectContaining({ + formApi: form, + triggerFieldApi: undefined, + value: { name: '' }, + }), + ) + }) +}) + describe('InternalFormApi - _update', () => { it('changes a supplied formId', () => { const form = new InternalFormApi({ @@ -159,6 +182,53 @@ describe('InternalFormApi - _update', () => { expect(initialListener).not.toHaveBeenCalled() }) + it('uses the updated listener when a pending debounce completes', async () => { + vi.useFakeTimers() + const initialListener = vi.fn() + const updatedListener = vi.fn() + const form = new InternalFormApi({ + defaultValues: { name: '' }, + listeners: [ + { + triggers: ['change'], + triggerDebounceMs: 300, + run: initialListener, + }, + ], + }) + const field = form._getOrCreateFieldApi({ name: 'name' }) + + field.handleChange('Alice') + await vi.advanceTimersByTimeAsync(100) + + form._update({ + defaultValues: { name: '' }, + listeners: [ + { + triggers: ['change'], + triggerDebounceMs: 300, + run: updatedListener, + }, + ], + }) + + await vi.advanceTimersByTimeAsync(199) + expect(initialListener).not.toHaveBeenCalled() + expect(updatedListener).not.toHaveBeenCalled() + + await vi.advanceTimersByTimeAsync(1) + + expect(initialListener).not.toHaveBeenCalled() + expect(updatedListener).toHaveBeenCalledOnce() + expect(updatedListener).toHaveBeenCalledWith( + expect.objectContaining({ + formApi: form, + triggerFieldApi: field, + value: { name: 'Alice' }, + }), + ) + }) + it('uses the updated onSubmit callback and replaces its previous errors', async () => { const form = new InternalFormApi({ defaultValues: { name: '' }, @@ -269,6 +339,50 @@ describe('InternalFormApi - _getOrCreateFieldApi', () => { }) }) +describe('InternalFormApi - _getIsDefaultValue', () => { + it('reports restored values as default while the form remains dirty', () => { + const form = new InternalFormApi({ defaultValues: { name: '' } }) + + expect(form.state.isDefaultValue).toBe(true) + + form.setFieldValue('name', 'Alice') + + expect(form.state).toMatchObject({ + values: { name: 'Alice' }, + isDefaultValue: false, + }) + + form.setFieldValue('name', '') + + expect(form.state).toMatchObject({ + values: { name: '' }, + isDefaultValue: true, + isDirty: true, + isPristine: false, + }) + }) + + it('reports deeply equal objects and arrays as default values', () => { + const form = new InternalFormApi({ + defaultValues: { profile: { name: 'Tony', tags: ['skater'] } }, + }) + + expect(form.state.isDefaultValue).toBe(true) + + form.setFieldValue('profile', { name: 'Tony', tags: ['skater'] }) + + expect(form.state.isDefaultValue).toBe(true) + + form.setFieldValue('profile.tags', ['skater', 'legend']) + + expect(form.state.isDefaultValue).toBe(false) + + form.setFieldValue('profile.tags', ['skater']) + + expect(form.state.isDefaultValue).toBe(true) + }) +}) + describe('InternalFormApi - getFieldValue', () => { it('reads values through top-level, nested object, and array paths', () => { const form = new InternalFormApi({ @@ -300,6 +414,25 @@ describe('InternalFormApi - getFieldValue', () => { }) describe('InternalFormApi - setFieldValue', () => { + it('notifies change listeners with the updated values and triggering field', () => { + const listener = vi.fn() + const form = new InternalFormApi({ + defaultValues: { name: '' }, + listeners: [{ triggers: ['change'], run: listener }], + }) + + form.setFieldValue('name', 'Alice') + + expect(listener).toHaveBeenCalledOnce() + expect(listener).toHaveBeenCalledWith( + expect.objectContaining({ + formApi: form, + triggerFieldApi: expect.objectContaining({ name: 'name' }), + value: { name: 'Alice' }, + }), + ) + }) + it('updates a nested value without changing unrelated values or defaults', () => { const form = new InternalFormApi({ defaultValues: { @@ -612,43 +745,93 @@ describe('InternalFormApi - setFieldValue', () => { }) describe('InternalFormApi - reset', () => { - it('notifies parent and child reset listeners with their values before removing the fields', () => { - const parentListener = vi.fn() - const childListener = vi.fn() + it('discards old watched fields and allows rebuilt fields to establish new listeners', () => { + const discardedListener = vi.fn() + const currentListener = vi.fn() const form = new InternalFormApi({ - defaultValues: { user: { name: 'Alice' } }, - }) - const parent = form._getOrCreateFieldApi({ - name: 'user', - listeners: [{ triggers: ['reset'], run: parentListener }], + defaultValues: { source: 'source', target: 'target' }, }) - const child = form._getOrCreateFieldApi({ - name: 'user.name', - listeners: [{ triggers: ['reset'], run: childListener }], + const oldTarget = form._getOrCreateFieldApi({ + name: 'target', + listeners: [ + { + triggers: ['change'], + watchFields: ['source'], + run: discardedListener, + }, + ], }) - parent._register() - child._register() - child.handleChange('Bob') + const oldSource = form._getOrCreateFieldApi({ name: 'source' }) + oldSource._register() + oldTarget._register() + + oldSource.handleChange('before reset') + expect(discardedListener).toHaveBeenCalledWith( + expect.objectContaining({ value: 'target' }), + ) + discardedListener.mockClear() form.reset() - expect(parentListener).toHaveBeenCalledOnce() - expect(parentListener).toHaveBeenCalledWith( - expect.objectContaining({ value: { name: 'Bob' } }), + const target = form._getOrCreateFieldApi({ + name: 'target', + listeners: [ + { triggers: ['change'], watchFields: ['source'], run: currentListener }, + ], + }) + const source = form._getOrCreateFieldApi({ name: 'source' }) + source._register() + target._register() + + oldSource.handleChange('discarded source') + oldTarget.handleChange('discarded target') + + expect(discardedListener).not.toHaveBeenCalled() + expect(currentListener).not.toHaveBeenCalled() + expect(form.state.values).toEqual({ source: 'source', target: 'target' }) + + source.handleChange('after reset') + + expect(currentListener).toHaveBeenCalledOnce() + expect(currentListener).toHaveBeenCalledWith( + expect.objectContaining({ + value: 'target', + fieldApi: target, + formApi: form, + }), ) - expect(childListener).toHaveBeenCalledOnce() - expect(childListener).toHaveBeenCalledWith( - expect.objectContaining({ value: 'Bob' }), + expect(discardedListener).not.toHaveBeenCalled() + expect(form.state.values).toEqual({ + source: 'after reset', + target: 'target', + }) + }) + + it('notifies form reset listeners after restoring the default values', () => { + const listener = vi.fn() + const form = new InternalFormApi({ + defaultValues: { name: '' }, + listeners: [{ triggers: ['reset'], run: listener }], + }) + const field = form._getOrCreateFieldApi({ name: 'name' }) + field.handleChange('Alice') + + form.reset() + + expect(listener).toHaveBeenCalledOnce() + expect(listener).toHaveBeenCalledWith( + expect.objectContaining({ + formApi: form, + triggerFieldApi: undefined, + value: { name: '' }, + }), ) - expect(form.state.values).toEqual({ user: { name: 'Alice' } }) - expect(form.getFieldMeta('user')).toBeUndefined() - expect(form.getFieldMeta('user.name')).toBeUndefined() }) it('aborts active validation on reset and allows validation to run again', async ({ onTestFinished, }) => { - const request = Promise.withResolvers() + const request = promiseWithResolvers() onTestFinished(() => request.resolve('Late error')) const onAbort = vi.fn() const validator = vi.fn(() => request.promise) @@ -689,8 +872,7 @@ describe('InternalFormApi - reset', () => { it('ignores the result of a submission that finishes after reset', async ({ onTestFinished, }) => { - const { promise: request, resolve: finishRequest } = - Promise.withResolvers() + const { promise: request, resolve: finishRequest } = promiseWithResolvers() onTestFinished(() => finishRequest()) const options = formOptions({ defaultValues: { name: '' }, @@ -736,6 +918,7 @@ describe('InternalFormApi - reset', () => { expect(form.state).toMatchObject({ values: { name: 'dirty' }, + isDefaultValue: false, isDirty: true, isTouched: true, errors: [{ message: 'Validation error' }], @@ -746,6 +929,7 @@ describe('InternalFormApi - reset', () => { expect(form.state).toMatchObject({ values: { name: '' }, + isDefaultValue: true, isDirty: false, isTouched: false, errors: [], @@ -828,6 +1012,7 @@ describe('InternalFormApi - resetField', () => { expect(field.value).toBe('bye') expect(field.meta.isTouched).toBe(true) expect(field.errors).toEqual([{ message: 'Reset me' }]) + expect(form.state.isDefaultValue).toBe(false) expect(form.state.isValid).toBe(false) form.resetField('name') @@ -836,6 +1021,7 @@ describe('InternalFormApi - resetField', () => { expect(field.meta.isTouched).toBe(false) expect(field.errors).toEqual([]) expect(form.state.isTouched).toBe(false) + expect(form.state.isDefaultValue).toBe(true) expect(form.state.isValid).toBe(true) }) @@ -1006,6 +1192,68 @@ describe('InternalFormApi - deleteField', () => { }) }) +describe('InternalFormApi - _notifyFormListener', () => { + it('notifies the matching form listeners as a field changes and blurs', () => { + const changeListener = vi.fn() + const blurListener = vi.fn() + const form = new InternalFormApi({ + defaultValues: { name: '' }, + listeners: [ + { triggers: ['change'], run: changeListener }, + { triggers: ['blur'], run: blurListener }, + ], + }) + const field = form._getOrCreateFieldApi({ name: 'name' }) + + field.handleChange('Alice') + + expect(changeListener).toHaveBeenCalledOnce() + expect(changeListener).toHaveBeenCalledWith( + expect.objectContaining({ + formApi: form, + triggerFieldApi: field, + value: { name: 'Alice' }, + }), + ) + expect(blurListener).not.toHaveBeenCalled() + changeListener.mockClear() + + field.handleBlur() + + expect(blurListener).toHaveBeenCalledOnce() + expect(blurListener).toHaveBeenCalledWith( + expect.objectContaining({ + formApi: form, + triggerFieldApi: field, + value: { name: 'Alice' }, + }), + ) + expect(changeListener).not.toHaveBeenCalled() + }) +}) + +describe('InternalFormApi - handleSubmit', () => { + it('notifies submit listeners when submission starts without a triggering field', async () => { + const listener = vi.fn() + const form = new InternalFormApi({ + defaultValues: { name: '' }, + listeners: [{ triggers: ['submit'], run: listener }], + }) + + const submission = form.handleSubmit() + + expect(listener).toHaveBeenCalledOnce() + expect(listener).toHaveBeenCalledWith( + expect.objectContaining({ + formApi: form, + triggerFieldApi: undefined, + value: { name: '' }, + }), + ) + await expect(submission).resolves.toEqual([]) + }) +}) + describe('InternalFormApi - _clearEventErrors', () => { it('clears submit errors when change validation is disabled by its predicate', async () => { const validator = vi.fn(() => 'Submit validator failed') diff --git a/packages/form-core/tests/FormApi/array-methods.lib.spec.ts b/packages/form-core/tests/FormApi/array-methods.lib.spec.ts index 582ee86dbc..a69b17c9b6 100644 --- a/packages/form-core/tests/FormApi/array-methods.lib.spec.ts +++ b/packages/form-core/tests/FormApi/array-methods.lib.spec.ts @@ -19,11 +19,19 @@ describe('InternalFormApi - swapFieldValues', () => { expect(form.state.values).toEqual({ items: [{ name: 'Carol' }, { name: 'Bob' }, { name: 'Alice' }], }) + expect(item0.name).toBe('items[2]') + expect(item2.name).toBe('items[0]') expect(name0.name).toBe('items[2].name') expect(name0.value).toBe('Alice') expect(name2.name).toBe('items[0].name') expect(name2.value).toBe('Carol') + const firstItem = form._getOrCreateFieldApi({ name: 'items[0]' }) + const lastItem = form._getOrCreateFieldApi({ name: 'items[2]' }) + + expect(firstItem.name).toBe('items[0]') + expect(lastItem.name).toBe('items[2]') + expect(arrayField.meta).toMatchObject({ isTouched: true, isDirty: true, @@ -41,6 +49,8 @@ describe('InternalFormApi - swapFieldValues', () => { expect(form.state.values).toEqual({ items: [{ name: 'Caroline' }, { name: 'Bob' }, { name: 'Alicia' }], }) + expect(firstItem.value).toEqual({ name: 'Caroline' }) + expect(lastItem.value).toEqual({ name: 'Alicia' }) }) it('leaves values and interaction state unchanged when both indices are equal', () => { diff --git a/packages/form-core/tests/FormApi/default-value.spec.ts b/packages/form-core/tests/FormApi/default-value.spec.ts deleted file mode 100644 index 6a93102fae..0000000000 --- a/packages/form-core/tests/FormApi/default-value.spec.ts +++ /dev/null @@ -1,101 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import { InternalFormApi } from '../../src/FormApi/FormApi.lib' - -describe('form - isDefaultValue', () => { - it('tracks whether current values deeply equal default values', () => { - const form = new InternalFormApi({ defaultValues: { name: '' } }) - - expect(form.state.isDefaultValue).toBe(true) - - form.setFieldValue('name', 'Alice') - expect(form.state.isDefaultValue).toBe(false) - - form.setFieldValue('name', '') - expect(form.state.isDefaultValue).toBe(true) - expect(form.state.isDirty).toBe(true) - expect(form.state.isPristine).toBe(false) - }) - - it('uses deep equality rather than reference equality', () => { - const form = new InternalFormApi({ - defaultValues: { profile: { name: 'Tony', tags: ['skater'] } }, - }) - - form.setFieldValue('profile', { name: 'Tony', tags: ['skater'] }) - expect(form.state.isDefaultValue).toBe(true) - - form.setFieldValue('profile.tags', ['skater', 'legend']) - expect(form.state.isDefaultValue).toBe(false) - }) - - it('returns to true after field and form resets', () => { - const form = new InternalFormApi({ - defaultValues: { name: '', items: ['a', 'b'] }, - }) - - form.setFieldValue('name', 'Alice') - expect(form.state.isDefaultValue).toBe(false) - - form.resetField('name') - expect(form.state.isDefaultValue).toBe(true) - - form.pushFieldValue('items', 'c') - expect(form.state.isDefaultValue).toBe(false) - - form.reset() - expect(form.state.isDefaultValue).toBe(true) - }) - - it('keeps isDefaultValue lazy for eager form state snapshots', () => { - const form = new InternalFormApi({ defaultValues: { name: '' } }) - const getIsDefaultValue = vi.spyOn(form, '_getIsDefaultValue') - - const state = form.atom.get() - - expect(getIsDefaultValue).not.toHaveBeenCalled() - expect(state.isDirty).toBe(false) - expect(getIsDefaultValue).not.toHaveBeenCalled() - expect(Object.keys(state)).toContain('isDefaultValue') - expect(getIsDefaultValue).not.toHaveBeenCalled() - - expect(state.isDefaultValue).toBe(true) - expect(getIsDefaultValue).toHaveBeenCalledOnce() - }) - - it('recomputes when defaultValues update', () => { - const form = new InternalFormApi({ defaultValues: { name: 'initial' } }) - - form.setFieldValue('name', 'current') - expect(form.state.isDefaultValue).toBe(false) - - form._update({ defaultValues: { name: 'current' } }) - expect(form.state.isDefaultValue).toBe(true) - }) - - it('updates default values by default when resetting to new values', () => { - const form = new InternalFormApi({ defaultValues: { name: '' } }) - - form.setFieldValue('name', 'dirty') - form.reset({ name: 'reset default' }) - - expect(form.state.values).toEqual({ name: 'reset default' }) - expect(form.defaultValues).toEqual({ name: 'reset default' }) - expect(form.state.isDefaultValue).toBe(true) - }) - - it('can reset values without updating default values', () => { - const form = new InternalFormApi({ defaultValues: { name: '' } }) - - form.setFieldValue('name', 'dirty') - form.reset({ name: 'reset value' }, { updateDefaultValues: false }) - - expect(form.state.values).toEqual({ name: 'reset value' }) - expect(form.defaultValues).toEqual({ name: '' }) - expect(form.state.isDirty).toBe(false) - expect(form.state.isDefaultValue).toBe(false) - - form.reset() - expect(form.state.values).toEqual({ name: '' }) - expect(form.state.isDefaultValue).toBe(true) - }) -}) diff --git a/packages/form-core/tests/FormApi/listeners.spec.ts b/packages/form-core/tests/FormApi/listeners.spec.ts deleted file mode 100644 index 863c23de17..0000000000 --- a/packages/form-core/tests/FormApi/listeners.spec.ts +++ /dev/null @@ -1,284 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import { InternalFormApi } from '../../src/FormApi/FormApi.lib' - -describe('form - listeners', () => { - it('should run change listeners when a field changes', () => { - const listener = vi.fn() - const form = new InternalFormApi({ - defaultValues: { name: '' }, - listeners: [{ triggers: ['change'], run: listener }], - }) - const field = form._getOrCreateFieldApi({ name: 'name' }) - - field.handleChange('Alice') - - expect(listener).toHaveBeenCalledOnce() - expect(listener).toHaveBeenCalledWith({ - formApi: form, - triggerFieldApi: field, - value: { name: 'Alice' }, - }) - }) - - it('should run change listeners when form.setFieldValue is called', () => { - const listener = vi.fn() - const form = new InternalFormApi({ - defaultValues: { name: '' }, - listeners: [{ triggers: ['change'], run: listener }], - }) - - form.setFieldValue('name', 'Alice') - - expect(listener).toHaveBeenCalledOnce() - expect(listener).toHaveBeenCalledWith({ - formApi: form, - triggerFieldApi: form._tryGetFieldApi('name') ?? undefined, - value: { name: 'Alice' }, - }) - }) - - it('should run field blur listeners when a field blurs', () => { - const listener = vi.fn() - const form = new InternalFormApi({ - defaultValues: { name: '' }, - listeners: [{ triggers: ['blur'], run: listener }], - }) - const field = form._getOrCreateFieldApi({ name: 'name' }) - - field.handleBlur() - - expect(listener).toHaveBeenCalledOnce() - expect(listener).toHaveBeenCalledWith({ - formApi: form, - triggerFieldApi: field, - value: { name: '' }, - }) - }) - - it('should run form submit listeners when a submission starts', async () => { - const listener = vi.fn() - const form = new InternalFormApi({ - defaultValues: { name: '' }, - listeners: [{ triggers: ['submit'], run: listener }], - }) - - await form.handleSubmit() - - expect(listener).toHaveBeenCalledOnce() - expect(listener).toHaveBeenCalledWith({ - formApi: form, - triggerFieldApi: undefined, - value: { name: '' }, - }) - }) - - it('should run form mount listeners when form.mount() is called', () => { - const listener = vi.fn() - const form = new InternalFormApi({ - defaultValues: { name: '' }, - listeners: [{ triggers: ['mount'], run: listener }], - }) - - form.mount() - - expect(listener).toHaveBeenCalledOnce() - expect(listener).toHaveBeenCalledWith({ - formApi: form, - triggerFieldApi: undefined, - value: { name: '' }, - }) - }) - - it('should run form reset listeners when the form resets', () => { - const listener = vi.fn() - const form = new InternalFormApi({ - defaultValues: { name: '' }, - listeners: [{ triggers: ['reset'], run: listener }], - }) - const field = form._getOrCreateFieldApi({ name: 'name' }) - - field.handleChange('Alice') - form.reset() - - expect(listener).toHaveBeenCalledOnce() - expect(listener).toHaveBeenCalledWith({ - formApi: form, - triggerFieldApi: undefined, - value: { name: '' }, - }) - }) - - it('should only run listeners matching the form event', () => { - const changeListener = vi.fn() - const blurListener = vi.fn() - const form = new InternalFormApi({ - defaultValues: { name: '' }, - listeners: [ - { triggers: ['change'], run: changeListener }, - { triggers: ['blur'], run: blurListener }, - ], - }) - const field = form._getOrCreateFieldApi({ name: 'name' }) - - field.handleChange('Alice') - - expect(changeListener).toHaveBeenCalledOnce() - expect(blurListener).not.toHaveBeenCalled() - }) - - it('should not run object triggers when the form event does not match', () => { - const listener = vi.fn() - const when = vi.fn(() => true) - const form = new InternalFormApi({ - defaultValues: { name: '' }, - listeners: [ - { - triggers: [{ trigger: 'blur', when }], - run: listener, - }, - ], - }) - const field = form._getOrCreateFieldApi({ name: 'name' }) - - field.handleChange('Alice') - - expect(listener).not.toHaveBeenCalled() - expect(when).not.toHaveBeenCalled() - }) - - it('supports listener predicates for form events', () => { - const listener = vi.fn() - const form = new InternalFormApi({ - defaultValues: { name: '' }, - listeners: [ - { - triggers: [ - { - trigger: 'change', - when: ({ value }) => value.name === 'Alice', - }, - ], - run: listener, - }, - ], - }) - const field = form._getOrCreateFieldApi({ name: 'name' }) - - field.handleChange('Bob') - field.handleChange('Alice') - - expect(listener).toHaveBeenCalledOnce() - }) - - it('supports dynamic debounce for form listeners', async () => { - vi.useFakeTimers() - const listener = vi.fn() - const triggerDebounceMs = vi.fn(({ formApi, triggerFieldApi, value }) => { - expect(formApi.state.values).toEqual({ name: 'Alice' }) - expect(triggerFieldApi?.name).toBe('name') - expect(value).toEqual({ name: 'Alice' }) - return 100 - }) - const form = new InternalFormApi({ - defaultValues: { name: '' }, - listeners: [ - { - triggers: ['change'], - triggerDebounceMs, - run: listener, - }, - ], - }) - const field = form._getOrCreateFieldApi({ name: 'name' }) - - field.handleChange('Alice') - - expect(triggerDebounceMs).toHaveBeenCalledOnce() - expect(listener).not.toHaveBeenCalled() - - await vi.advanceTimersByTimeAsync(99) - expect(listener).not.toHaveBeenCalled() - - await vi.advanceTimersByTimeAsync(1) - expect(listener).toHaveBeenCalledOnce() - expect(listener).toHaveBeenCalledWith({ - formApi: form, - triggerFieldApi: field, - value: { name: 'Alice' }, - }) - - vi.useRealTimers() - }) - - it('uses the updated listener definition when a pending debounce executes', async () => { - vi.useFakeTimers() - const listenerA = vi.fn() - const listenerB = vi.fn() - const form = new InternalFormApi({ - defaultValues: { name: '' }, - listeners: [ - { - triggers: ['change'], - triggerDebounceMs: 300, - run: listenerA, - }, - ], - }) - const field = form._getOrCreateFieldApi({ name: 'name' }) - const listenerInstance = form._listenerInstances?.[0] - - field.handleChange('Alice') - await vi.advanceTimersByTimeAsync(100) - - form._update({ - defaultValues: { name: '' }, - listeners: [ - { - triggers: ['change'], - triggerDebounceMs: 300, - run: listenerB, - }, - ], - }) - - expect(form._listenerInstances?.[0]).toBe(listenerInstance) - - await vi.advanceTimersByTimeAsync(199) - expect(listenerA).not.toHaveBeenCalled() - expect(listenerB).not.toHaveBeenCalled() - - await vi.advanceTimersByTimeAsync(1) - expect(listenerA).not.toHaveBeenCalled() - expect(listenerB).toHaveBeenCalledOnce() - expect(listenerB).toHaveBeenCalledWith({ - formApi: form, - triggerFieldApi: field, - value: { name: 'Alice' }, - }) - - vi.useRealTimers() - }) - - it('logs rejected async form listener errors', async () => { - const error = new Error('listener failed') - const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) - const form = new InternalFormApi({ - defaultValues: { name: '' }, - listeners: [ - { - triggers: ['change'], - run: () => Promise.reject(error), - }, - ], - }) - const field = form._getOrCreateFieldApi({ name: 'name' }) - - field.handleChange('Alice') - - await vi.waitFor(() => { - expect(consoleSpy).toHaveBeenCalled() - }) - - consoleSpy.mockRestore() - }) -}) diff --git a/packages/form-core/tests/listeners.lib.spec.ts b/packages/form-core/tests/listeners.lib.spec.ts new file mode 100644 index 0000000000..60f7d77df9 --- /dev/null +++ b/packages/form-core/tests/listeners.lib.spec.ts @@ -0,0 +1,222 @@ +import { describe, expect, it, vi } from 'vitest' +import { InternalFormApi } from '../src/FormApi/FormApi.lib' + +describe('runFormListenerPipeline', () => { + it('runs a blur listener only when the field blurs', () => { + const listener = vi.fn() + const form = new InternalFormApi({ + defaultValues: { name: '' }, + listeners: [ + { + triggers: [{ trigger: 'blur', when: () => true }], + run: listener, + }, + ], + }) + const field = form._getOrCreateFieldApi({ name: 'name' }) + + field.handleChange('Alice') + + expect(listener).not.toHaveBeenCalled() + + field.handleBlur() + + expect(listener).toHaveBeenCalledOnce() + expect(listener).toHaveBeenCalledWith( + expect.objectContaining({ + formApi: form, + triggerFieldApi: field, + value: { name: 'Alice' }, + }), + ) + }) + + it('runs a form listener only when its predicate allows the current values', () => { + const listener = vi.fn() + const form = new InternalFormApi({ + defaultValues: { name: '' }, + listeners: [ + { + triggers: [ + { + trigger: 'change', + when: ({ value }) => value.name === 'Alice', + }, + ], + run: listener, + }, + ], + }) + const field = form._getOrCreateFieldApi({ name: 'name' }) + + field.handleChange('Bob') + expect(listener).not.toHaveBeenCalled() + + field.handleChange('Alice') + + expect(listener).toHaveBeenCalledOnce() + expect(listener).toHaveBeenCalledWith( + expect.objectContaining({ + formApi: form, + triggerFieldApi: field, + value: { name: 'Alice' }, + }), + ) + }) + + it('delays a listener according to the current form values', async () => { + vi.useFakeTimers() + const listener = vi.fn() + const form = new InternalFormApi({ + defaultValues: { name: '' }, + listeners: [ + { + triggers: ['change'], + triggerDebounceMs: ({ value }) => value.name.length * 20, + run: listener, + }, + ], + }) + const field = form._getOrCreateFieldApi({ name: 'name' }) + + field.handleChange('Alice') + + expect(listener).not.toHaveBeenCalled() + + await vi.advanceTimersByTimeAsync(99) + expect(listener).not.toHaveBeenCalled() + + await vi.advanceTimersByTimeAsync(1) + + expect(listener).toHaveBeenCalledOnce() + expect(listener).toHaveBeenCalledWith( + expect.objectContaining({ + formApi: form, + triggerFieldApi: field, + value: { name: 'Alice' }, + }), + ) + }) + + it('reports the error rejected by an async form listener', async ({ + onTestFinished, + }) => { + const error = new Error('listener failed') + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}) + onTestFinished(() => consoleError.mockRestore()) + const form = new InternalFormApi({ + defaultValues: { name: '' }, + listeners: [ + { + triggers: ['change'], + run: () => Promise.reject(error), + }, + ], + }) + const field = form._getOrCreateFieldApi({ name: 'name' }) + + field.handleChange('Alice') + + await expect + .poll(() => consoleError.mock.calls) + .toContainEqual(expect.arrayContaining([error])) + }) +}) + +describe('runFieldListenerPipeline', () => { + it('skips disabled listeners and listeners with no triggers', () => { + const disabledListener = vi.fn() + const inactiveListener = vi.fn() + const form = new InternalFormApi({ defaultValues: { name: '' } }) + const field = form._getOrCreateFieldApi({ + name: 'name', + listeners: [ + { + triggers: [{ trigger: 'change', when: false }], + run: disabledListener, + }, + { triggers: [], run: inactiveListener }, + ], + }) + + field.handleChange('Alice') + + expect(field.value).toBe('Alice') + expect(disabledListener).not.toHaveBeenCalled() + expect(inactiveListener).not.toHaveBeenCalled() + }) + + it('runs a listener only when its predicate allows the current field context', () => { + const listener = vi.fn() + const form = new InternalFormApi({ defaultValues: { name: '' } }) + const field = form._getOrCreateFieldApi({ + name: 'name', + listeners: [ + { + triggers: [ + { + trigger: 'change', + when: ({ triggerFieldApi, value }) => + triggerFieldApi?.name === 'name' && value === 'Alice', + }, + ], + run: listener, + }, + ], + }) + + field.handleChange('Bob') + expect(listener).not.toHaveBeenCalled() + + field.handleChange('Alice') + + expect(listener).toHaveBeenCalledOnce() + expect(listener).toHaveBeenCalledWith( + expect.objectContaining({ + value: 'Alice', + fieldApi: field, + formApi: form, + }), + ) + }) + + it('delays a listener according to the current field context', async () => { + vi.useFakeTimers() + const listener = vi.fn() + const form = new InternalFormApi({ defaultValues: { name: '' } }) + const field = form._getOrCreateFieldApi({ + name: 'name', + listeners: [ + { + triggers: ['change'], + triggerDebounceMs: ({ formApi, triggerFieldApi, value }) => { + // These make the unit test fail if true. Checks that + // the params agree with each other + if (triggerFieldApi?.name !== 'name') return 0 + if (formApi.state.values.name !== value) return 0 + return value.length * 20 + }, + run: listener, + }, + ], + }) + + field.handleChange('Alice') + + expect(listener).not.toHaveBeenCalled() + + await vi.advanceTimersByTimeAsync(99) + expect(listener).not.toHaveBeenCalled() + + await vi.advanceTimersByTimeAsync(1) + + expect(listener).toHaveBeenCalledOnce() + expect(listener).toHaveBeenCalledWith( + expect.objectContaining({ + value: 'Alice', + fieldApi: field, + formApi: form, + }), + ) + }) +}) diff --git a/packages/form-core/tests/test-utils.ts b/packages/form-core/tests/test-utils.ts new file mode 100644 index 0000000000..b7767a448f --- /dev/null +++ b/packages/form-core/tests/test-utils.ts @@ -0,0 +1,24 @@ +/** + * The same as `Promise.withResolvers`, but compatible with node 18. + * + * @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/withResolvers + */ +export function promiseWithResolvers(): { + promise: Promise + resolve: (value: T) => void + reject: (reason?: any) => void +} { + let resolve: ((value: T) => void) | undefined = undefined + let reject: ((reason?: any) => void) | undefined = undefined + + const promise = new Promise((res, rej) => { + resolve = res + reject = rej + }) + + return { + promise, + resolve: resolve!, + reject: reject!, + } +} diff --git a/packages/form-core/tests/utils.lib.spec.ts b/packages/form-core/tests/utils.lib.spec.ts new file mode 100644 index 0000000000..16eabd8a1e --- /dev/null +++ b/packages/form-core/tests/utils.lib.spec.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from 'vitest' +import { nameToFieldNodeSegments } from '../src/utils.lib' + +describe('nameToFieldNodeSegments', () => { + it('returns no segments for the root name', () => { + const segments = nameToFieldNodeSegments('') + + expect(segments).toEqual([]) + }) + + it('splits object field names into segments', () => { + const topLevel = nameToFieldNodeSegments('foo') + const nested = nameToFieldNodeSegments('foo.bar') + const deeplyNested = nameToFieldNodeSegments('foo.bar.foobar') + + expect(topLevel).toEqual(['foo']) + expect(nested).toEqual(['foo', 'bar']) + expect(deeplyNested).toEqual(['foo', 'bar', 'foobar']) + }) + + it('keeps array indices numeric in mixed object and array paths', () => { + const item = nameToFieldNodeSegments('foo[0]') + const property = nameToFieldNodeSegments('foo[0].bar') + const nestedArray = nameToFieldNodeSegments('foo.bar[0].baz') + + expect(item).toEqual(['foo', 0]) + expect(property).toEqual(['foo', 0, 'bar']) + expect(nestedArray).toEqual(['foo', 'bar', 0, 'baz']) + }) + + it('splits consecutive array indices', () => { + const segments = nameToFieldNodeSegments('foo[0][1].bar') + + expect(segments).toEqual(['foo', 0, 1, 'bar']) + }) + + it('keeps supplied segments reusable after changing a parsed path', () => { + const supplied = ['items', 0, 'name'] + const firstPath = nameToFieldNodeSegments(supplied) + + firstPath[1] = 1 + const nextPath = nameToFieldNodeSegments(supplied) + + expect(supplied).toEqual(['items', 0, 'name']) + expect(nextPath).toEqual(['items', 0, 'name']) + }) +}) diff --git a/packages/form-devtools/src/bridge/fields/debug/serverErrorOnUnmountedField.ts b/packages/form-devtools/src/bridge/fields/debug/serverErrorOnUnmountedField.ts index 5a6c1a5791..dd9a1505e2 100644 --- a/packages/form-devtools/src/bridge/fields/debug/serverErrorOnUnmountedField.ts +++ b/packages/form-devtools/src/bridge/fields/debug/serverErrorOnUnmountedField.ts @@ -12,7 +12,7 @@ export const serverErrorOnUnmountedField = { const validator = field.form._validatorInstances?.[error.source.validatorIndex]?.definition - const hasServerTrigger = validator?.triggers.includes('server') + const hasServerTrigger = validator?.triggers?.includes('server') if (!hasServerTrigger) return undefined diff --git a/packages/form-devtools/tests/fieldActionsBridge.test.ts b/packages/form-devtools/tests/fieldActionsBridge.test.ts index a6a74aabc0..61ded176c2 100644 --- a/packages/form-devtools/tests/fieldActionsBridge.test.ts +++ b/packages/form-devtools/tests/fieldActionsBridge.test.ts @@ -14,7 +14,6 @@ describe('field actions bridge', () => { const fields = createFieldsController(mountedForms) const changeListener = vi.fn() const blurListener = vi.fn() - const resetListener = vi.fn() const details: Array = [] const cleanupDetails = formDevtoolsEventClient.on( 'field-detail-changed', @@ -26,7 +25,6 @@ describe('field actions bridge', () => { listeners: [ { triggers: ['change'], run: changeListener }, { triggers: ['blur'], run: blurListener }, - { triggers: ['reset'], run: resetListener }, ], }) @@ -61,7 +59,6 @@ describe('field actions bridge', () => { field.handleChange('Grace') changeListener.mockClear() formDevtoolsEventClient.emit('field-reset-request', request) - expect(resetListener).toHaveBeenCalledOnce() expect(changeListener).not.toHaveBeenCalled() expect(field.value).toBe('Ada') expect(field.meta).toMatchObject({