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..d1321c39 100644 --- a/packages/core/src/field/getFieldInput/getFieldInput.test.ts +++ b/packages/core/src/field/getFieldInput/getFieldInput.test.ts @@ -102,4 +102,203 @@ describe('getFieldInput', () => { }); }); }); + + describe('with dirtyOnly', () => { + 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 })).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@example.com' } } + ); + store.children.email.input.value = 'b@example.com'; + store.children.email.isDirty.value = true; + expect(getFieldInput(store, { dirtyOnly: true })).toStrictEqual({ + email: 'b@example.com', + }); + }); + + 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 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 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@example.com', name: 'John' } } } + ); + 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', () => { + 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, + }); + }); + + 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 f031d42f..4ff101e4 100644 --- a/packages/core/src/field/getFieldInput/getFieldInput.ts +++ b/packages/core/src/field/getFieldInput/getFieldInput.ts @@ -1,4 +1,17 @@ import type { InternalFieldStore } from '../../types/index.ts'; +import { getFieldBool } from '../getFieldBool/getFieldBool.ts'; + +/** + * Options for retrieving field input. + */ +export interface GetFieldInputOptions { + /** + * 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; +} /** * Returns the current input of the field store. For arrays and objects, @@ -6,11 +19,26 @@ import type { InternalFieldStore } from '../../types/index.ts'; * 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 { + // 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 @@ -18,7 +46,9 @@ export function getFieldInput(internalFieldStore: InternalFieldStore): unknown { // 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; @@ -42,7 +72,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 || getFieldBool(child, 'isDirty')) { + 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..6d5b4d4f 100644 --- a/packages/methods/src/getInput/getInput.test.ts +++ b/packages/methods/src/getInput/getInput.test.ts @@ -76,4 +76,139 @@ describe('getInput', () => { expect(result).toBeUndefined(); }); + + describe('with dirtyOnly', () => { + 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 })).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@example.com' } } + ); + store.children.email.input.value = 'b@example.com'; + store.children.email.isDirty.value = true; + + expect(getInput(store, { dirtyOnly: true })).toStrictEqual({ + email: 'b@example.com', + }); + }); + + 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 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', () => { + const store = createTestStore( + v.object({ + user: v.object({ email: v.string(), name: v.string() }), + }), + { initialInput: { user: { email: 'a@example.com', name: 'John' } } } + ); + 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@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 d6e70087..4bbccb9a 100644 --- a/packages/methods/src/getInput/getInput.ts +++ b/packages/methods/src/getInput/getInput.ts @@ -19,6 +19,13 @@ 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 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; } /** @@ -32,6 +39,13 @@ export interface GetFieldInputConfig< * The path to the field to retrieve input from. */ readonly path: ValidPath, TFieldPath>; + /** + * 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; } /** @@ -75,6 +89,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..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. +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 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, + }, + }, };