From 6458822fa375110b9d1b6d4795364d87495efac4 Mon Sep 17 00:00:00 2001 From: Fabian Hiller Date: Sun, 17 May 2026 18:19:17 -0400 Subject: [PATCH 1/2] Add dirtyOnly option to getFieldInput and getInput for filtering dirty fields --- packages/core/CHANGELOG.md | 1 + .../field/getFieldInput/getFieldInput.test.ts | 63 +++++++++++++++++++ .../src/field/getFieldInput/getFieldInput.ts | 27 +++++++- packages/methods/CHANGELOG.md | 1 + .../methods/src/getInput/getInput.test.ts | 59 +++++++++++++++++ packages/methods/src/getInput/getInput.ts | 13 +++- .../methods/api/(methods)/getInput/index.mdx | 2 +- .../api/(types)/GetFieldInputConfig/index.mdx | 1 + .../(types)/GetFieldInputConfig/properties.ts | 7 +++ .../api/(types)/GetFormInputConfig/index.mdx | 1 + .../(types)/GetFormInputConfig/properties.ts | 7 +++ 11 files changed, 177 insertions(+), 5 deletions(-) diff --git a/packages/core/CHANGELOG.md b/packages/core/CHANGELOG.md index 662cd0e8..3b0f8101 100644 --- a/packages/core/CHANGELOG.md +++ b/packages/core/CHANGELOG.md @@ -4,6 +4,7 @@ All notable changes to the library will be documented in this file. ## vX.X.X (Month DD, YYYY) +- Add `GetFieldInputOptions` and a corresponding `config` parameter to `getFieldInput` to support filtering by `isDirty` - Fix `initializeFieldStore` to throw an error when `variant` or `union` branches initialize the same key with incompatible store kinds (pull request #94) - Fix `ValidArrayPath` type to accept array fields reachable through unions (`variant` options, optional/nullish intermediates, and unions that include primitives) (pull request #89) - Fix `PathValue` type to preserve `| undefined` when navigating to optional or nullish fields, so input types of methods like `setInput` are no longer narrowed away from `T | null | undefined` (issue #15, pull request #89) diff --git a/packages/core/src/field/getFieldInput/getFieldInput.test.ts b/packages/core/src/field/getFieldInput/getFieldInput.test.ts index dcc22e93..41b5323b 100644 --- a/packages/core/src/field/getFieldInput/getFieldInput.test.ts +++ b/packages/core/src/field/getFieldInput/getFieldInput.test.ts @@ -1,5 +1,6 @@ import * as v from 'valibot'; import { describe, expect, test } from 'vitest'; +import type { InternalArrayStore } from '../../types/index.ts'; import { createTestStore } from '../../vitest/index.ts'; import { getFieldInput } from './getFieldInput.ts'; @@ -102,4 +103,66 @@ describe('getFieldInput', () => { }); }); }); + + describe('with dirtyOnly', () => { + test('should return empty object when no field is dirty', () => { + const store = createTestStore( + v.object({ name: v.string(), age: v.number() }), + { initialInput: { name: 'John', age: 25 } } + ); + expect(getFieldInput(store, { dirtyOnly: true })).toStrictEqual({}); + }); + + test('should omit clean siblings of a dirty value', () => { + const store = createTestStore( + v.object({ name: v.string(), email: v.string() }), + { initialInput: { name: 'John', email: 'a@x.com' } } + ); + store.children.email.input.value = 'b@x.com'; + store.children.email.isDirty.value = true; + expect(getFieldInput(store, { dirtyOnly: true })).toStrictEqual({ + email: 'b@x.com', + }); + }); + + test('should include only dirty indices when items in an array are dirty', () => { + const store = createTestStore(v.object({ items: v.array(v.string()) }), { + initialInput: { items: ['a', 'b', 'c'] }, + }); + const itemsArray = store.children.items as InternalArrayStore; + itemsArray.children[1].input.value = 'B'; + itemsArray.children[1].isDirty.value = true; + itemsArray.isDirty.value = true; + const result = getFieldInput(store, { dirtyOnly: true }) as { + items: (string | undefined)[]; + }; + expect(result.items[1]).toBe('B'); + expect(result.items[0]).toBeUndefined(); + expect(result.items[2]).toBeUndefined(); + }); + + test('should miss dirty leaves under a clean object parent', () => { + const store = createTestStore( + v.object({ user: v.object({ email: v.string(), name: v.string() }) }), + { initialInput: { user: { email: 'a@x.com', name: 'John' } } } + ); + const user = store.children.user; + if (user.kind !== 'object') throw new Error('expected object'); + user.children.email.input.value = 'b@x.com'; + user.children.email.isDirty.value = true; + expect(getFieldInput(store, { dirtyOnly: true })).toStrictEqual({}); + }); + + test('should include a value field even when its dirty input is undefined', () => { + const store = createTestStore( + v.object({ name: v.optional(v.string()) }), + { initialInput: { name: 'John' } } + ); + store.children.name.input.value = undefined; + store.children.name.isDirty.value = true; + expect(getFieldInput(store, { dirtyOnly: true })).toStrictEqual({ + name: undefined, + }); + }); + }); }); diff --git a/packages/core/src/field/getFieldInput/getFieldInput.ts b/packages/core/src/field/getFieldInput/getFieldInput.ts index f031d42f..510beb12 100644 --- a/packages/core/src/field/getFieldInput/getFieldInput.ts +++ b/packages/core/src/field/getFieldInput/getFieldInput.ts @@ -1,16 +1,31 @@ import type { InternalFieldStore } from '../../types/index.ts'; +/** + * Options for retrieving field input. + */ +export interface GetFieldInputOptions { + /** + * Whether to include only fields whose `isDirty` flag is set. Clean children + * are skipped during the recursive walk. + */ + readonly dirtyOnly?: boolean; +} + /** * Returns the current input of the field store. For arrays and objects, * recursively collects input from all children. Returns `null` or `undefined` * for nullish array/object inputs, or the primitive value for value fields. * * @param internalFieldStore The field store to get input from. + * @param config Options to filter the collected input (e.g. `dirtyOnly`). * * @returns The field input. */ // @__NO_SIDE_EFFECTS__ -export function getFieldInput(internalFieldStore: InternalFieldStore): unknown { +export function getFieldInput( + internalFieldStore: InternalFieldStore, + config?: GetFieldInputOptions +): unknown { // If field store is array, collect input from children if (internalFieldStore.kind === 'array') { // If array input is not nullish, build array from children @@ -24,7 +39,10 @@ export function getFieldInput(internalFieldStore: InternalFieldStore): unknown { index < internalFieldStore.items.value.length; index++ ) { - value[index] = getFieldInput(internalFieldStore.children[index]); + const child = internalFieldStore.children[index]; + if (!config?.dirtyOnly || child.isDirty.value) { + value[index] = getFieldInput(child, config); + } } return value; } @@ -42,7 +60,10 @@ export function getFieldInput(internalFieldStore: InternalFieldStore): unknown { // Collect input from each object property for (const key in internalFieldStore.children) { - value[key] = getFieldInput(internalFieldStore.children[key]); + const child = internalFieldStore.children[key]; + if (!config?.dirtyOnly || child.isDirty.value) { + value[key] = getFieldInput(child, config); + } } return value; } diff --git a/packages/methods/CHANGELOG.md b/packages/methods/CHANGELOG.md index 9d971527..5bd5e92a 100644 --- a/packages/methods/CHANGELOG.md +++ b/packages/methods/CHANGELOG.md @@ -4,6 +4,7 @@ All notable changes to the library will be documented in this file. ## vX.X.X (Month DD, YYYY) +- Add `dirtyOnly` option to `getInput` to retrieve only fields whose `isDirty` flag is set (issue #21) - Fix `reset` method to apply falsy and explicit `undefined` values when resetting the initial input of a specific field (issue #78) ## v0.7.1 (April 16, 2026) diff --git a/packages/methods/src/getInput/getInput.test.ts b/packages/methods/src/getInput/getInput.test.ts index c570b83a..6be2f6bf 100644 --- a/packages/methods/src/getInput/getInput.test.ts +++ b/packages/methods/src/getInput/getInput.test.ts @@ -1,3 +1,4 @@ +import type { InternalArrayStore } from '@formisch/core'; import * as v from 'valibot'; import { describe, expect, test } from 'vitest'; import { createTestStore } from '../vitest/index.ts'; @@ -76,4 +77,62 @@ describe('getInput', () => { expect(result).toBeUndefined(); }); + + describe('with dirtyOnly', () => { + test('should return empty object for a clean form', () => { + const store = createTestStore( + v.object({ name: v.string(), age: v.number() }), + { initialInput: { name: 'John', age: 25 } } + ); + + expect(getInput(store, { dirtyOnly: true })).toStrictEqual({}); + }); + + test('should return only the dirty key from a flat object', () => { + const store = createTestStore( + v.object({ name: v.string(), email: v.string() }), + { initialInput: { name: 'John', email: 'a@x.com' } } + ); + store.children.email.input.value = 'b@x.com'; + store.children.email.isDirty.value = true; + + expect(getInput(store, { dirtyOnly: true })).toStrictEqual({ + email: 'b@x.com', + }); + }); + + test('should return only dirty indices when array items are dirty', () => { + const store = createTestStore(v.object({ items: v.array(v.string()) }), { + initialInput: { items: ['a', 'b', 'c'] }, + }); + const itemsArray = store.children.items as InternalArrayStore; + itemsArray.children[1].input.value = 'B'; + itemsArray.children[1].isDirty.value = true; + itemsArray.isDirty.value = true; + + const result = getInput(store, { dirtyOnly: true }) as { + items: (string | undefined)[]; + }; + expect(result.items[1]).toBe('B'); + expect(result.items[0]).toBeUndefined(); + expect(result.items[2]).toBeUndefined(); + }); + + test('should scope dirty filter to the given path', () => { + const store = createTestStore( + v.object({ + user: v.object({ email: v.string(), name: v.string() }), + }), + { initialInput: { user: { email: 'a@x.com', name: 'John' } } } + ); + const user = store.children.user; + if (user.kind !== 'object') throw new Error('expected object'); + user.children.email.input.value = 'b@x.com'; + user.children.email.isDirty.value = true; + + expect( + getInput(store, { path: ['user'], dirtyOnly: true }) + ).toStrictEqual({ email: 'b@x.com' }); + }); + }); }); diff --git a/packages/methods/src/getInput/getInput.ts b/packages/methods/src/getInput/getInput.ts index d6e70087..fca8c381 100644 --- a/packages/methods/src/getInput/getInput.ts +++ b/packages/methods/src/getInput/getInput.ts @@ -19,6 +19,11 @@ export interface GetFormInputConfig { * The path to a field. Leave undefined to get the entire form input. */ readonly path?: undefined; + /** + * Whether to include only fields whose `isDirty` flag is set. Useful for + * submitting only the values that changed since the start input. + */ + readonly dirtyOnly?: boolean; } /** @@ -32,6 +37,11 @@ export interface GetFieldInputConfig< * The path to the field to retrieve input from. */ readonly path: ValidPath, TFieldPath>; + /** + * Whether to include only fields whose `isDirty` flag is set. Useful for + * submitting only the values that changed since the start input. + */ + readonly dirtyOnly?: boolean; } /** @@ -75,6 +85,7 @@ export function getInput( config?: GetFormInputConfig | GetFieldInputConfig ): unknown { return getFieldInput( - config?.path ? getFieldStore(form[INTERNAL], config.path) : form[INTERNAL] + config?.path ? getFieldStore(form[INTERNAL], config.path) : form[INTERNAL], + config?.dirtyOnly ? { dirtyOnly: true } : undefined ); } diff --git a/website/src/routes/(docs)/methods/api/(methods)/getInput/index.mdx b/website/src/routes/(docs)/methods/api/(methods)/getInput/index.mdx index 813ef0d2..54977df3 100644 --- a/website/src/routes/(docs)/methods/api/(methods)/getInput/index.mdx +++ b/website/src/routes/(docs)/methods/api/(methods)/getInput/index.mdx @@ -30,7 +30,7 @@ const input = getInput(form, config); ### Explanation -The `form` parameter is the form store to retrieve input from. The optional `config` parameter specifies which field to get input from - if omitted, returns input from the entire form. +The `form` parameter is the form store to retrieve input from. The optional `config` parameter specifies which field to get input from - if omitted, returns input from the entire form. Set `dirtyOnly` to `true` to include only fields whose `isDirty` flag is `true`, which is useful for submitting only the values that changed since the start input. ## Returns diff --git a/website/src/routes/(docs)/methods/api/(types)/GetFieldInputConfig/index.mdx b/website/src/routes/(docs)/methods/api/(types)/GetFieldInputConfig/index.mdx index c853884e..a52f00c9 100644 --- a/website/src/routes/(docs)/methods/api/(types)/GetFieldInputConfig/index.mdx +++ b/website/src/routes/(docs)/methods/api/(types)/GetFieldInputConfig/index.mdx @@ -22,6 +22,7 @@ Configuration interface for retrieving field-specific input. Used by the `getInp - `GetFieldInputConfig` - `path` + - `dirtyOnly` ## Related diff --git a/website/src/routes/(docs)/methods/api/(types)/GetFieldInputConfig/properties.ts b/website/src/routes/(docs)/methods/api/(types)/GetFieldInputConfig/properties.ts index 56155efd..4e98e078 100644 --- a/website/src/routes/(docs)/methods/api/(types)/GetFieldInputConfig/properties.ts +++ b/website/src/routes/(docs)/methods/api/(types)/GetFieldInputConfig/properties.ts @@ -41,4 +41,11 @@ export const properties: Record = { ], }, }, + dirtyOnly: { + type: 'boolean', + default: { + type: 'boolean', + value: false, + }, + }, }; diff --git a/website/src/routes/(docs)/methods/api/(types)/GetFormInputConfig/index.mdx b/website/src/routes/(docs)/methods/api/(types)/GetFormInputConfig/index.mdx index 24f2072a..6d370817 100644 --- a/website/src/routes/(docs)/methods/api/(types)/GetFormInputConfig/index.mdx +++ b/website/src/routes/(docs)/methods/api/(types)/GetFormInputConfig/index.mdx @@ -17,6 +17,7 @@ Configuration interface for retrieving form-level input. Used by the `getInput` - `GetFormInputConfig` - `path` + - `dirtyOnly` ## Related diff --git a/website/src/routes/(docs)/methods/api/(types)/GetFormInputConfig/properties.ts b/website/src/routes/(docs)/methods/api/(types)/GetFormInputConfig/properties.ts index ed1e77e3..f32a97d8 100644 --- a/website/src/routes/(docs)/methods/api/(types)/GetFormInputConfig/properties.ts +++ b/website/src/routes/(docs)/methods/api/(types)/GetFormInputConfig/properties.ts @@ -5,4 +5,11 @@ export const properties: Record = { type: 'undefined', default: 'undefined', }, + dirtyOnly: { + type: 'boolean', + default: { + type: 'boolean', + value: false, + }, + }, }; From 0d8e53e92bff8214660ad9a615f3098378e275fa Mon Sep 17 00:00:00 2001 From: Fabian Hiller Date: Sun, 17 May 2026 21:21:11 -0400 Subject: [PATCH 2/2] Update dirtyOnly behavior in getFieldInput and getInput methods --- .../field/getFieldInput/getFieldInput.test.ts | 184 +++++++++++++++--- .../src/field/getFieldInput/getFieldInput.ts | 28 ++- .../methods/src/getInput/getInput.test.ts | 124 +++++++++--- packages/methods/src/getInput/getInput.ts | 12 +- .../methods/api/(methods)/getInput/index.mdx | 2 +- 5 files changed, 289 insertions(+), 61 deletions(-) diff --git a/packages/core/src/field/getFieldInput/getFieldInput.test.ts b/packages/core/src/field/getFieldInput/getFieldInput.test.ts index 41b5323b..d1321c39 100644 --- a/packages/core/src/field/getFieldInput/getFieldInput.test.ts +++ b/packages/core/src/field/getFieldInput/getFieldInput.test.ts @@ -1,6 +1,5 @@ import * as v from 'valibot'; import { describe, expect, test } from 'vitest'; -import type { InternalArrayStore } from '../../types/index.ts'; import { createTestStore } from '../../vitest/index.ts'; import { getFieldInput } from './getFieldInput.ts'; @@ -105,52 +104,55 @@ describe('getFieldInput', () => { }); describe('with dirtyOnly', () => { - test('should return empty object when no field is dirty', () => { + test('should return undefined when no field is dirty', () => { const store = createTestStore( v.object({ name: v.string(), age: v.number() }), { initialInput: { name: 'John', age: 25 } } ); - expect(getFieldInput(store, { dirtyOnly: true })).toStrictEqual({}); + expect(getFieldInput(store, { dirtyOnly: true })).toBeUndefined(); }); test('should omit clean siblings of a dirty value', () => { const store = createTestStore( v.object({ name: v.string(), email: v.string() }), - { initialInput: { name: 'John', email: 'a@x.com' } } + { initialInput: { name: 'John', email: 'a@example.com' } } ); - store.children.email.input.value = 'b@x.com'; + store.children.email.input.value = 'b@example.com'; store.children.email.isDirty.value = true; expect(getFieldInput(store, { dirtyOnly: true })).toStrictEqual({ - email: 'b@x.com', + email: 'b@example.com', }); }); - test('should include only dirty indices when items in an array are dirty', () => { + test('should return the full current array when any item is dirty', () => { const store = createTestStore(v.object({ items: v.array(v.string()) }), { initialInput: { items: ['a', 'b', 'c'] }, }); - const itemsArray = store.children.items as InternalArrayStore; - itemsArray.children[1].input.value = 'B'; - itemsArray.children[1].isDirty.value = true; - itemsArray.isDirty.value = true; - const result = getFieldInput(store, { dirtyOnly: true }) as { - items: (string | undefined)[]; - }; - expect(result.items[1]).toBe('B'); - expect(result.items[0]).toBeUndefined(); - expect(result.items[2]).toBeUndefined(); + const itemsStore = store.children.items; + expect(itemsStore.kind).toBe('array'); + if (itemsStore.kind === 'array') { + itemsStore.children[1].input.value = 'B'; + itemsStore.children[1].isDirty.value = true; + } + expect(getFieldInput(store, { dirtyOnly: true })).toStrictEqual({ + items: ['a', 'B', 'c'], + }); }); - test('should miss dirty leaves under a clean object parent', () => { + test('should include dirty leaves under a clean object parent', () => { const store = createTestStore( v.object({ user: v.object({ email: v.string(), name: v.string() }) }), - { initialInput: { user: { email: 'a@x.com', name: 'John' } } } + { initialInput: { user: { email: 'a@example.com', name: 'John' } } } ); - const user = store.children.user; - if (user.kind !== 'object') throw new Error('expected object'); - user.children.email.input.value = 'b@x.com'; - user.children.email.isDirty.value = true; - expect(getFieldInput(store, { dirtyOnly: true })).toStrictEqual({}); + const userStore = store.children.user; + expect(userStore.kind).toBe('object'); + if (userStore.kind === 'object') { + userStore.children.email.input.value = 'b@example.com'; + userStore.children.email.isDirty.value = true; + } + expect(getFieldInput(store, { dirtyOnly: true })).toStrictEqual({ + user: { email: 'b@example.com' }, + }); }); test('should include a value field even when its dirty input is undefined', () => { @@ -164,5 +166,139 @@ describe('getFieldInput', () => { name: undefined, }); }); + + test('should return all dirty branches across multiple keys and depths', () => { + const store = createTestStore( + v.object({ + name: v.string(), + email: v.string(), + user: v.object({ first: v.string(), last: v.string() }), + }), + { + initialInput: { + name: 'John', + email: 'a@example.com', + user: { first: 'A', last: 'B' }, + }, + } + ); + store.children.email.input.value = 'b@example.com'; + store.children.email.isDirty.value = true; + const userStore = store.children.user; + expect(userStore.kind).toBe('object'); + if (userStore.kind === 'object') { + userStore.children.first.input.value = 'C'; + userStore.children.first.isDirty.value = true; + } + expect(getFieldInput(store, { dirtyOnly: true })).toStrictEqual({ + email: 'b@example.com', + user: { first: 'C' }, + }); + }); + + test('should return full objects inside arrays when any item is dirty', () => { + const store = createTestStore( + v.object({ + users: v.array(v.object({ name: v.string(), age: v.number() })), + }), + { + initialInput: { + users: [ + { name: 'John', age: 25 }, + { name: 'Jane', age: 30 }, + ], + }, + } + ); + const usersStore = store.children.users; + expect(usersStore.kind).toBe('array'); + if (usersStore.kind === 'array') { + const user0 = usersStore.children[0]; + expect(user0.kind).toBe('object'); + if (user0.kind === 'object') { + user0.children.name.input.value = 'Johnny'; + user0.children.name.isDirty.value = true; + } + } + expect(getFieldInput(store, { dirtyOnly: true })).toStrictEqual({ + users: [ + { name: 'Johnny', age: 25 }, + { name: 'Jane', age: 30 }, + ], + }); + }); + + test('should include an object that transitioned from null to set', () => { + const store = createTestStore( + v.object({ user: v.nullish(v.object({ name: v.string() })) }), + { initialInput: { user: null } } + ); + const userStore = store.children.user; + expect(userStore.kind).toBe('object'); + if (userStore.kind === 'object') { + userStore.input.value = true; + userStore.isDirty.value = true; + userStore.children.name.input.value = 'John'; + userStore.children.name.isDirty.value = true; + } + expect(getFieldInput(store, { dirtyOnly: true })).toStrictEqual({ + user: { name: 'John' }, + }); + }); + + test('should include an object that transitioned from set to null', () => { + const store = createTestStore( + v.object({ user: v.nullish(v.object({ name: v.string() })) }), + { initialInput: { user: { name: 'John' } } } + ); + const userStore = store.children.user; + expect(userStore.kind).toBe('object'); + if (userStore.kind === 'object') { + userStore.input.value = null; + userStore.isDirty.value = true; + } + expect(getFieldInput(store, { dirtyOnly: true })).toStrictEqual({ + user: null, + }); + }); + + test('should return value for a dirty value field called directly', () => { + const store = createTestStore(v.object({ name: v.string() }), { + initialInput: { name: 'John' }, + }); + store.children.name.input.value = 'Jane'; + store.children.name.isDirty.value = true; + expect( + getFieldInput(store.children.name, { dirtyOnly: true }) + ).toBe('Jane'); + }); + + test('should return undefined when called directly on a clean value field', () => { + const store = createTestStore(v.object({ name: v.string() }), { + initialInput: { name: 'John' }, + }); + expect( + getFieldInput(store.children.name, { dirtyOnly: true }) + ).toBeUndefined(); + }); + + test('should return undefined when called directly on a clean array', () => { + const store = createTestStore(v.object({ items: v.array(v.string()) }), { + initialInput: { items: ['a', 'b', 'c'] }, + }); + expect( + getFieldInput(store.children.items, { dirtyOnly: true }) + ).toBeUndefined(); + }); + + test('should return undefined when called directly on a clean object subtree', () => { + const store = createTestStore( + v.object({ user: v.object({ name: v.string() }) }), + { initialInput: { user: { name: 'John' } } } + ); + expect( + getFieldInput(store.children.user, { dirtyOnly: true }) + ).toBeUndefined(); + }); }); }); diff --git a/packages/core/src/field/getFieldInput/getFieldInput.ts b/packages/core/src/field/getFieldInput/getFieldInput.ts index 510beb12..4ff101e4 100644 --- a/packages/core/src/field/getFieldInput/getFieldInput.ts +++ b/packages/core/src/field/getFieldInput/getFieldInput.ts @@ -1,12 +1,14 @@ import type { InternalFieldStore } from '../../types/index.ts'; +import { getFieldBool } from '../getFieldBool/getFieldBool.ts'; /** * Options for retrieving field input. */ export interface GetFieldInputOptions { /** - * Whether to include only fields whose `isDirty` flag is set. Clean children - * are skipped during the recursive walk. + * When true, fields whose subtree contains no dirty descendant return + * `undefined`. Object iterations omit clean keys; arrays are treated as + * atomic and are returned in full whenever any descendant is dirty. */ readonly dirtyOnly?: boolean; } @@ -26,6 +28,17 @@ export function getFieldInput( internalFieldStore: InternalFieldStore, config?: GetFieldInputOptions ): unknown { + // When `dirtyOnly`, bail with `undefined` for any subtree that contains + // no dirty descendant. This applies uniformly to values, arrays and + // objects. Inside arrays we still populate every item (atomic semantic) + // so undefined slots never appear in the output. + if ( + config?.dirtyOnly && + !getFieldBool(internalFieldStore, 'isDirty') + ) { + return undefined; + } + // If field store is array, collect input from children if (internalFieldStore.kind === 'array') { // If array input is not nullish, build array from children @@ -33,16 +46,15 @@ export function getFieldInput( // Create output array const value = []; - // Collect input from each array item + // Collect input from each array item. Once we enter an array, every + // item is fully populated regardless of dirty state — arrays are + // atomic in `dirtyOnly` mode. for ( let index = 0; index < internalFieldStore.items.value.length; index++ ) { - const child = internalFieldStore.children[index]; - if (!config?.dirtyOnly || child.isDirty.value) { - value[index] = getFieldInput(child, config); - } + value[index] = getFieldInput(internalFieldStore.children[index]); } return value; } @@ -61,7 +73,7 @@ export function getFieldInput( // Collect input from each object property for (const key in internalFieldStore.children) { const child = internalFieldStore.children[key]; - if (!config?.dirtyOnly || child.isDirty.value) { + if (!config?.dirtyOnly || getFieldBool(child, 'isDirty')) { value[key] = getFieldInput(child, config); } } diff --git a/packages/methods/src/getInput/getInput.test.ts b/packages/methods/src/getInput/getInput.test.ts index 6be2f6bf..6d5b4d4f 100644 --- a/packages/methods/src/getInput/getInput.test.ts +++ b/packages/methods/src/getInput/getInput.test.ts @@ -1,4 +1,3 @@ -import type { InternalArrayStore } from '@formisch/core'; import * as v from 'valibot'; import { describe, expect, test } from 'vitest'; import { createTestStore } from '../vitest/index.ts'; @@ -79,43 +78,42 @@ describe('getInput', () => { }); describe('with dirtyOnly', () => { - test('should return empty object for a clean form', () => { + test('should return undefined for a clean form', () => { const store = createTestStore( v.object({ name: v.string(), age: v.number() }), { initialInput: { name: 'John', age: 25 } } ); - expect(getInput(store, { dirtyOnly: true })).toStrictEqual({}); + expect(getInput(store, { dirtyOnly: true })).toBeUndefined(); }); test('should return only the dirty key from a flat object', () => { const store = createTestStore( v.object({ name: v.string(), email: v.string() }), - { initialInput: { name: 'John', email: 'a@x.com' } } + { initialInput: { name: 'John', email: 'a@example.com' } } ); - store.children.email.input.value = 'b@x.com'; + store.children.email.input.value = 'b@example.com'; store.children.email.isDirty.value = true; expect(getInput(store, { dirtyOnly: true })).toStrictEqual({ - email: 'b@x.com', + email: 'b@example.com', }); }); - test('should return only dirty indices when array items are dirty', () => { + test('should return the full current array when any item is dirty', () => { const store = createTestStore(v.object({ items: v.array(v.string()) }), { initialInput: { items: ['a', 'b', 'c'] }, }); - const itemsArray = store.children.items as InternalArrayStore; - itemsArray.children[1].input.value = 'B'; - itemsArray.children[1].isDirty.value = true; - itemsArray.isDirty.value = true; - - const result = getInput(store, { dirtyOnly: true }) as { - items: (string | undefined)[]; - }; - expect(result.items[1]).toBe('B'); - expect(result.items[0]).toBeUndefined(); - expect(result.items[2]).toBeUndefined(); + const itemsStore = store.children.items; + expect(itemsStore.kind).toBe('array'); + if (itemsStore.kind === 'array') { + itemsStore.children[1].input.value = 'B'; + itemsStore.children[1].isDirty.value = true; + } + + expect(getInput(store, { dirtyOnly: true })).toStrictEqual({ + items: ['a', 'B', 'c'], + }); }); test('should scope dirty filter to the given path', () => { @@ -123,16 +121,94 @@ describe('getInput', () => { v.object({ user: v.object({ email: v.string(), name: v.string() }), }), - { initialInput: { user: { email: 'a@x.com', name: 'John' } } } + { initialInput: { user: { email: 'a@example.com', name: 'John' } } } ); - const user = store.children.user; - if (user.kind !== 'object') throw new Error('expected object'); - user.children.email.input.value = 'b@x.com'; - user.children.email.isDirty.value = true; + const userStore = store.children.user; + expect(userStore.kind).toBe('object'); + if (userStore.kind === 'object') { + userStore.children.email.input.value = 'b@example.com'; + userStore.children.email.isDirty.value = true; + } expect( getInput(store, { path: ['user'], dirtyOnly: true }) - ).toStrictEqual({ email: 'b@x.com' }); + ).toStrictEqual({ email: 'b@example.com' }); + }); + + test('should return undefined for an object path with a fully clean subtree', () => { + const store = createTestStore( + v.object({ + user: v.object({ email: v.string(), name: v.string() }), + }), + { initialInput: { user: { email: 'a@example.com', name: 'John' } } } + ); + + expect( + getInput(store, { path: ['user'], dirtyOnly: true }) + ).toBeUndefined(); + }); + + test('should return the dirty value for a value path that is dirty', () => { + const store = createTestStore(v.object({ name: v.string() }), { + initialInput: { name: 'John' }, + }); + store.children.name.input.value = 'Jane'; + store.children.name.isDirty.value = true; + + expect(getInput(store, { path: ['name'], dirtyOnly: true })).toBe('Jane'); + }); + + test('should return undefined for a value path that is clean', () => { + const store = createTestStore(v.object({ name: v.string() }), { + initialInput: { name: 'John' }, + }); + + expect( + getInput(store, { path: ['name'], dirtyOnly: true }) + ).toBeUndefined(); + }); + + test('should return undefined for an array path that is fully clean', () => { + const store = createTestStore(v.object({ items: v.array(v.string()) }), { + initialInput: { items: ['a', 'b', 'c'] }, + }); + + expect( + getInput(store, { path: ['items'], dirtyOnly: true }) + ).toBeUndefined(); + }); + + test('should preserve clean fields of array items when an item is dirty', () => { + const store = createTestStore( + v.object({ + users: v.array(v.object({ name: v.string(), age: v.number() })), + }), + { + initialInput: { + users: [ + { name: 'John', age: 25 }, + { name: 'Jane', age: 30 }, + ], + }, + } + ); + const usersStore = store.children.users; + expect(usersStore.kind).toBe('array'); + if (usersStore.kind === 'array') { + const user0 = usersStore.children[0]; + expect(user0.kind).toBe('object'); + if (user0.kind === 'object') { + user0.children.name.input.value = 'Johnny'; + user0.children.name.isDirty.value = true; + } + } + + expect(getInput(store, { dirtyOnly: true })).toStrictEqual({ + users: [ + { name: 'Johnny', age: 25 }, + { name: 'Jane', age: 30 }, + ], + }); }); }); }); diff --git a/packages/methods/src/getInput/getInput.ts b/packages/methods/src/getInput/getInput.ts index fca8c381..4bbccb9a 100644 --- a/packages/methods/src/getInput/getInput.ts +++ b/packages/methods/src/getInput/getInput.ts @@ -20,8 +20,10 @@ export interface GetFormInputConfig { */ readonly path?: undefined; /** - * Whether to include only fields whose `isDirty` flag is set. Useful for - * submitting only the values that changed since the start input. + * Whether to include only fields whose subtree contains a dirty descendant. + * Object keys with no dirty descendant are omitted; arrays are returned in + * full whenever any descendant is dirty. Useful for submitting only the + * values that changed since the start input. */ readonly dirtyOnly?: boolean; } @@ -38,8 +40,10 @@ export interface GetFieldInputConfig< */ readonly path: ValidPath, TFieldPath>; /** - * Whether to include only fields whose `isDirty` flag is set. Useful for - * submitting only the values that changed since the start input. + * Whether to include only fields whose subtree contains a dirty descendant. + * Object keys with no dirty descendant are omitted; arrays are returned in + * full whenever any descendant is dirty. Useful for submitting only the + * values that changed since the start input. */ readonly dirtyOnly?: boolean; } diff --git a/website/src/routes/(docs)/methods/api/(methods)/getInput/index.mdx b/website/src/routes/(docs)/methods/api/(methods)/getInput/index.mdx index 54977df3..f6229dfc 100644 --- a/website/src/routes/(docs)/methods/api/(methods)/getInput/index.mdx +++ b/website/src/routes/(docs)/methods/api/(methods)/getInput/index.mdx @@ -30,7 +30,7 @@ const input = getInput(form, config); ### Explanation -The `form` parameter is the form store to retrieve input from. The optional `config` parameter specifies which field to get input from - if omitted, returns input from the entire form. Set `dirtyOnly` to `true` to include only fields whose `isDirty` flag is `true`, which is useful for submitting only the values that changed since the start input. +The `form` parameter is the form store to retrieve input from. The optional `config` parameter specifies which field to get input from - if omitted, returns input from the entire form. Set `dirtyOnly` to `true` to include only fields whose subtree contains a dirty descendant — object keys with no dirty descendant are omitted, while arrays are returned in full whenever any item is dirty. This is useful for submitting only the values that changed since the start input. ## Returns