diff --git a/packages/core/CHANGELOG.md b/packages/core/CHANGELOG.md index bb0f76a7..0f79d185 100644 --- a/packages/core/CHANGELOG.md +++ b/packages/core/CHANGELOG.md @@ -5,6 +5,7 @@ All notable changes to the library will be documented in this file. ## vX.X.X (Month DD, YYYY) - Add `FormSchema` type that constrains a form's root schema to object schemas (sync or async) and combinators (`intersect`, `union`, `variant`) +- Add `getDirtyFieldInput` to extract only the dirty input of a field store (issue #21, pull request #98) - Change `FormConfig`, `InternalFormStore`, `BaseFormStore`, `SubmitHandler` and `SubmitEventHandler` generic constraints from `Schema` to `FormSchema` ## v0.6.4 (May 17, 2026) diff --git a/packages/core/src/field/getDirtyFieldInput/getDirtyFieldInput.test.ts b/packages/core/src/field/getDirtyFieldInput/getDirtyFieldInput.test.ts new file mode 100644 index 00000000..ad6c592f --- /dev/null +++ b/packages/core/src/field/getDirtyFieldInput/getDirtyFieldInput.test.ts @@ -0,0 +1,73 @@ +import * as v from 'valibot'; +import { describe, expect, test } from 'vitest'; +import { createTestStore } from '../../vitest/index.ts'; +import { getDirtyFieldInput } from './getDirtyFieldInput.ts'; + +describe('getDirtyFieldInput', () => { + 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(getDirtyFieldInput(store)).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(getDirtyFieldInput(store)).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(getDirtyFieldInput(store)).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(getDirtyFieldInput(store)).toStrictEqual({ + user: { email: 'b@example.com' }, + }); + }); + + test('should return undefined when called directly on a clean value field', () => { + const store = createTestStore(v.object({ name: v.string() }), { + initialInput: { name: 'John' }, + }); + expect(getDirtyFieldInput(store.children.name)).toBeUndefined(); + }); + + test('should return the value when called directly on a dirty value field', () => { + const store = createTestStore(v.object({ name: v.string() }), { + initialInput: { name: 'John' }, + }); + store.children.name.input.value = 'Jane'; + store.children.name.isDirty.value = true; + expect(getDirtyFieldInput(store.children.name)).toBe('Jane'); + }); +}); diff --git a/packages/core/src/field/getDirtyFieldInput/getDirtyFieldInput.ts b/packages/core/src/field/getDirtyFieldInput/getDirtyFieldInput.ts new file mode 100644 index 00000000..1f0d20e2 --- /dev/null +++ b/packages/core/src/field/getDirtyFieldInput/getDirtyFieldInput.ts @@ -0,0 +1,73 @@ +import type { InternalFieldStore } from '../../types/index.ts'; +import { getFieldBool } from '../getFieldBool/getFieldBool.ts'; + +/** + * Returns only the dirty input of the field store. Arrays are treated as + * atomic and returned in full if any item is dirty, while object keys without + * a dirty descendant are omitted. Returns `undefined` if no descendant is + * dirty. + * + * @param internalFieldStore The field store to get dirty input from. + * @param dirtyOnly Whether to only include dirty fields. Defaults to `true`. + * + * @returns The dirty input, or `undefined` if no descendant is dirty. + */ +// @__NO_SIDE_EFFECTS__ +export function getDirtyFieldInput( + internalFieldStore: InternalFieldStore, + dirtyOnly: boolean = true +): unknown { + // If field has no dirty descendant, return undefined + if (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 full array from children + if (internalFieldStore.input.value) { + // Create output array + const value = []; + + // Collect input from each array item + for ( + let index = 0; + index < internalFieldStore.items.value.length; + index++ + ) { + value[index] = getDirtyFieldInput( + internalFieldStore.children[index], + false + ); + } + return value; + } + + // Otherwise, return nullish input as-is + return internalFieldStore.input.value; + } + + // If field store is object, recurse only into dirty children + if (internalFieldStore.kind === 'object') { + // If object input is not nullish, build object from children + if (internalFieldStore.input.value) { + // Create output object + const value: Record = {}; + + // Collect input from each dirty object property + for (const key in internalFieldStore.children) { + const child = internalFieldStore.children[key]; + if (!dirtyOnly || getFieldBool(child, 'isDirty')) { + value[key] = getDirtyFieldInput(child, dirtyOnly); + } + } + return value; + } + + // Otherwise, return nullish input as-is + return internalFieldStore.input.value; + } + + // Return primitive value input + return internalFieldStore.input.value; +} diff --git a/packages/core/src/field/getDirtyFieldInput/index.ts b/packages/core/src/field/getDirtyFieldInput/index.ts new file mode 100644 index 00000000..483cb317 --- /dev/null +++ b/packages/core/src/field/getDirtyFieldInput/index.ts @@ -0,0 +1 @@ +export * from './getDirtyFieldInput.ts'; diff --git a/packages/core/src/field/index.ts b/packages/core/src/field/index.ts index 48064b68..e99c215f 100644 --- a/packages/core/src/field/index.ts +++ b/packages/core/src/field/index.ts @@ -1,3 +1,4 @@ +export * from './getDirtyFieldInput/index.ts'; export * from './getElementInput/index.ts'; export * from './getFieldBool/index.ts'; export * from './getFieldInput/index.ts'; diff --git a/packages/core/src/types/path/index.ts b/packages/core/src/types/path/index.ts index 9bddca42..01d28615 100644 --- a/packages/core/src/types/path/index.ts +++ b/packages/core/src/types/path/index.ts @@ -1,4 +1,5 @@ export type { + DirtyPath, Path, PathValue, PathKey, diff --git a/packages/core/src/types/path/path.test-d.ts b/packages/core/src/types/path/path.test-d.ts index 0882c980..74fa9a1a 100644 --- a/packages/core/src/types/path/path.test-d.ts +++ b/packages/core/src/types/path/path.test-d.ts @@ -1,11 +1,15 @@ import { describe, expectTypeOf, test } from 'vitest'; import type { + DirtyPath, ExactKeysOf, ExactKeysOfArrayPath, ExactRequired, + Path, + PathKey, PathValue, PropertiesOf, PropertiesOfArrayPath, + RequiredPath, ValidArrayPath, ValidPath, } from './path.ts'; @@ -606,3 +610,97 @@ describe('ValidArrayPath', () => { >().toEqualTypeOf<['rows', number, 'tags']>(); }); }); + +describe('DirtyPath', () => { + test('should return the keys of a flat object as single-segment paths', () => { + expectTypeOf>().toEqualTypeOf< + readonly ['name'] | readonly ['age'] + >(); + }); + + test('should include both the parent path and nested paths for object children', () => { + expectTypeOf< + DirtyPath<{ user: { email: string; name: string } }> + >().toEqualTypeOf< + readonly ['user'] | readonly ['user', 'email'] | readonly ['user', 'name'] + >(); + }); + + test('should treat arrays and tuples of every shape as atomic', () => { + expectTypeOf< + DirtyPath<{ + tags: string[]; + users: { name: string }[]; + matrix: number[][]; + pairs: [number, number][]; + coords: [number, number]; + nodes: [{ x: number }, { y: number }]; + }> + >().toEqualTypeOf< + | readonly ['tags'] + | readonly ['users'] + | readonly ['matrix'] + | readonly ['pairs'] + | readonly ['coords'] + | readonly ['nodes'] + >(); + }); + + test('should recurse into object siblings while array and tuple siblings stay atomic', () => { + expectTypeOf< + DirtyPath<{ + items: string[]; + coords: [number, number]; + user: { name: string; address: { city: string } }; + }> + >().toEqualTypeOf< + | readonly ['items'] + | readonly ['coords'] + | readonly ['user'] + | readonly ['user', 'name'] + | readonly ['user', 'address'] + | readonly ['user', 'address', 'city'] + >(); + }); + + test('should return `never` for non-object roots', () => { + expectTypeOf>().toBeNever(); + expectTypeOf>().toBeNever(); + expectTypeOf>().toBeNever(); + }); + + test('should narrow paths up to the configured depth', () => { + expectTypeOf< + DirtyPath<{ a: { b: { c: { d: { e: string } } } } }> + >().toEqualTypeOf< + | readonly ['a'] + | readonly ['a', 'b'] + | readonly ['a', 'b', 'c'] + | readonly ['a', 'b', 'c', 'd'] + | readonly ['a', 'b', 'c', 'd', 'e'] + >(); + }); + + test('should fall back to RequiredPath for paths deeper than the depth limit', () => { + type Result = DirtyPath<{ + a: { b: { c: { d: { e: { f: string } } } } }; + }>; + // The path `['a', 'b', 'c', 'd', 'e', 'f']` is past depth 5, so the last + // segments collapse to `PathKey, ...Path` (RequiredPath catch-all). + expectTypeOf< + readonly ['a', 'b', 'c', 'd', 'e', PathKey, ...Path] + >().toMatchTypeOf(); + }); + + test('should merge keys across object union members', () => { + expectTypeOf< + DirtyPath<{ shared: string } & ({ a: string } | { b: number })> + >().toEqualTypeOf(); + }); + + test('should be a subtype of RequiredPath', () => { + expectTypeOf< + DirtyPath<{ name: string; user: { email: string } }> + >().toMatchTypeOf(); + }); +}); diff --git a/packages/core/src/types/path/path.ts b/packages/core/src/types/path/path.ts index 9d62e080..61f4a064 100644 --- a/packages/core/src/types/path/path.ts +++ b/packages/core/src/types/path/path.ts @@ -245,3 +245,45 @@ export type ValidArrayPath = TPath extends LazyArrayPath, TPath> ? TPath : LazyArrayPath, TPath>; + +/** + * Recursive helper for `DirtyPath` that prepends `TKey` to each deeper path, + * or falls through to `never` when the child is not an object. + */ +type DeepDirtyPath = + TChild extends Record + ? readonly [TKey, ...DirtyPath] + : never; + +/** + * Returns the union of all `RequiredPath`s that `getDirtyPaths` can emit + * for a given input type. Object fields contribute their own path and the + * paths of their descendants; arrays and tuples are atomic and contribute + * only their own path, because dirty arrays are returned as complete units. + * + * Narrowing is exact for the first 5 levels of nesting; deeper paths fall + * back to `RequiredPath` to keep the result a complete superset of any + * path the runtime can address. + * + * Hint: Arrays and tuples are atomic because they don't structurally + * extend `Record` and so fall through to `never` + * via `DeepDirtyPath` — no explicit array check is needed. `TDepth` is + * a tuple-length counter capped at 5 to bound TypeScript instantiation + * cost. + */ +export type DirtyPath< + TValue, + TDepth extends 0[] = [], +> = TDepth['length'] extends 5 + ? RequiredPath + : TValue extends Record + ? { + [TKey in ExactKeysOf]: + | readonly [TKey] + | DeepDirtyPath< + NonNullable[TKey]>, + TKey, + TDepth + >; + }[ExactKeysOf] + : never; diff --git a/packages/eslint-config/index.js b/packages/eslint-config/index.js index da6945be..9cc85f15 100644 --- a/packages/eslint-config/index.js +++ b/packages/eslint-config/index.js @@ -119,5 +119,29 @@ export function createSourceConfig(options = {}) { }; } +/** + * Creates a test file configuration object that relaxes rules which are + * impractical in tests (e.g. local `type` aliases for type assertions). + * + * @param options Configuration options. + * @param options.files The file patterns to match (default: src/**\/*.{test,test-d}.{ts,tsx}). + * @param options.extraRules Additional rules to merge. + * + * @returns The ESLint configuration object for test files. + */ +export function createTestConfig(options = {}) { + const { + files = ['src/**/*.{test,test-d}.{ts,tsx}'], + extraRules = {}, + } = options; + return { + files, + rules: { + '@typescript-eslint/consistent-type-definitions': 'off', + ...extraRules, + }, + }; +} + // Re-export plugins and tseslint for framework configs export { eslint, importPlugin, jsdoc, pluginSecurity, tseslint }; diff --git a/packages/methods/CHANGELOG.md b/packages/methods/CHANGELOG.md index ade496f6..6a6d031c 100644 --- a/packages/methods/CHANGELOG.md +++ b/packages/methods/CHANGELOG.md @@ -4,6 +4,9 @@ All notable changes to the library will be documented in this file. ## vX.X.X (Month DD, YYYY) +- Add `pickDirty` method to filter an externally-supplied value down to its dirty parts using the form's dirty mask (issue #21, pull request #98) +- Add `getDirtyPaths` method to list the paths of dirty fields in a form or specific field (issue #21, pull request #98) +- Add `getDirtyInput` method to retrieve only the dirty input values of a form or specific field (issue #21, pull request #98) - Change `@formisch/core` to vX.X.X - Change method generic constraints from `Schema` to `FormSchema` so the form root must be an object schema (sync or async) or a combinator (`intersect`, `union`, `variant`) diff --git a/packages/methods/eslint.config.js b/packages/methods/eslint.config.js index 5b4ec513..7bfc36cd 100644 --- a/packages/methods/eslint.config.js +++ b/packages/methods/eslint.config.js @@ -1,6 +1,7 @@ import { baseConfigs, createSourceConfig, + createTestConfig, tseslint, } from '@formisch/eslint-config'; @@ -14,5 +15,6 @@ export default tseslint.config( // Methods-specific rules '@typescript-eslint/no-empty-object-type': 'off', }, - }) + }), + createTestConfig({ files: ['src/**/*.{test,test-d}.ts'] }) ); diff --git a/packages/methods/package.json b/packages/methods/package.json index 9653a207..8d5d8a2a 100644 --- a/packages/methods/package.json +++ b/packages/methods/package.json @@ -53,7 +53,7 @@ "access": "public" }, "scripts": { - "test": "vitest run", + "test": "vitest run --typecheck", "coverage": "vitest run --coverage --isolate", "lint": "eslint \"src/**/*.ts*\" && tsc --noEmit", "lint.fix": "eslint \"src/**/*.ts*\" --fix", diff --git a/packages/methods/src/getDirtyInput/getDirtyInput.test.ts b/packages/methods/src/getDirtyInput/getDirtyInput.test.ts new file mode 100644 index 00000000..81c98978 --- /dev/null +++ b/packages/methods/src/getDirtyInput/getDirtyInput.test.ts @@ -0,0 +1,138 @@ +import * as v from 'valibot'; +import { describe, expect, test } from 'vitest'; +import { createTestStore } from '../vitest/index.ts'; +import { getDirtyInput } from './getDirtyInput.ts'; + +describe('getDirtyInput', () => { + 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(getDirtyInput(store)).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(getDirtyInput(store)).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(getDirtyInput(store)).toStrictEqual({ items: ['a', 'B', 'c'] }); + }); + + 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(getDirtyInput(store)).toStrictEqual({ + users: [ + { name: 'Johnny', age: 25 }, + { name: 'Jane', age: 30 }, + ], + }); + }); + + 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(getDirtyInput(store)).toStrictEqual({ + user: { email: 'b@example.com' }, + }); + }); + + test('should scope the dirty input 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(getDirtyInput(store, { path: ['user'] })).toStrictEqual({ + email: 'b@example.com', + }); + }); + + test('should return undefined for a 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(getDirtyInput(store, { path: ['user'] })).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(getDirtyInput(store, { path: ['name'] })).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(getDirtyInput(store, { path: ['name'] })).toBeUndefined(); + }); +}); diff --git a/packages/methods/src/getDirtyInput/getDirtyInput.ts b/packages/methods/src/getDirtyInput/getDirtyInput.ts new file mode 100644 index 00000000..c2a036a2 --- /dev/null +++ b/packages/methods/src/getDirtyInput/getDirtyInput.ts @@ -0,0 +1,89 @@ +import { + type BaseFormStore, + type DeepPartial, + type FormSchema, + getDirtyFieldInput, + getFieldStore, + INTERNAL, + type PathValue, + type RequiredPath, + type ValidPath, +} from '@formisch/core'; +import type * as v from 'valibot'; + +/** + * Get form dirty input config interface. + */ +export interface GetFormDirtyInputConfig { + /** + * The path to a field. Leave undefined to get the dirty input of the entire + * form. + */ + readonly path?: undefined; +} + +/** + * Get field dirty input config interface. + */ +export interface GetFieldDirtyInputConfig< + TSchema extends FormSchema, + TFieldPath extends RequiredPath, +> { + /** + * The path to the field to retrieve the dirty input from. + */ + readonly path: ValidPath, TFieldPath>; +} + +/** + * Retrieves only the dirty input values of a specific field or the entire + * form. Arrays are treated as atomic and returned in full if any item is + * dirty, while object keys without a dirty descendant are omitted. Returns + * `undefined` if no field in the inspected subtree is dirty. + * + * @param form The form store to retrieve dirty input from. + * + * @returns The dirty input of the form or specified field, or `undefined`. + */ +export function getDirtyInput( + form: BaseFormStore +): DeepPartial> | undefined; + +/** + * Retrieves only the dirty input values of a specific field or the entire + * form. Arrays are treated as atomic and returned in full if any item is + * dirty, while object keys without a dirty descendant are omitted. Returns + * `undefined` if no field in the inspected subtree is dirty. + * + * @param form The form store to retrieve dirty input from. + * @param config The get dirty input configuration. + * + * @returns The dirty input of the form or specified field, or `undefined`. + */ +export function getDirtyInput< + TSchema extends FormSchema, + TFieldPath extends RequiredPath | undefined = undefined, +>( + form: BaseFormStore, + config: TFieldPath extends RequiredPath + ? GetFieldDirtyInputConfig + : GetFormDirtyInputConfig +): + | DeepPartial< + TFieldPath extends RequiredPath + ? PathValue, TFieldPath> + : v.InferInput + > + | undefined; + +// @__NO_SIDE_EFFECTS__ +export function getDirtyInput( + form: BaseFormStore, + config?: + | GetFormDirtyInputConfig + | GetFieldDirtyInputConfig +): unknown { + return getDirtyFieldInput( + config?.path ? getFieldStore(form[INTERNAL], config.path) : form[INTERNAL] + ); +} diff --git a/packages/methods/src/getDirtyInput/index.ts b/packages/methods/src/getDirtyInput/index.ts new file mode 100644 index 00000000..e13024fc --- /dev/null +++ b/packages/methods/src/getDirtyInput/index.ts @@ -0,0 +1 @@ +export * from './getDirtyInput.ts'; diff --git a/packages/methods/src/getDirtyPaths/getDirtyPaths.test.ts b/packages/methods/src/getDirtyPaths/getDirtyPaths.test.ts new file mode 100644 index 00000000..52756364 --- /dev/null +++ b/packages/methods/src/getDirtyPaths/getDirtyPaths.test.ts @@ -0,0 +1,220 @@ +import * as v from 'valibot'; +import { describe, expect, test } from 'vitest'; +import { createTestStore } from '../vitest/index.ts'; +import { getDirtyPaths } from './getDirtyPaths.ts'; + +describe('getDirtyPaths', () => { + test('should return empty array for a clean form', () => { + const store = createTestStore( + v.object({ name: v.string(), age: v.number() }), + { initialInput: { name: 'John', age: 25 } } + ); + + expect(getDirtyPaths(store)).toStrictEqual([]); + }); + + test('should return path to a dirty top-level 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(getDirtyPaths(store)).toStrictEqual([['email']]); + }); + + test('should return paths to multiple dirty values', () => { + const store = createTestStore( + v.object({ name: v.string(), email: v.string(), age: v.number() }), + { initialInput: { name: 'John', email: 'a@example.com', age: 25 } } + ); + store.children.email.input.value = 'b@example.com'; + store.children.email.isDirty.value = true; + store.children.age.input.value = 26; + store.children.age.isDirty.value = true; + + expect(getDirtyPaths(store)).toStrictEqual([['email'], ['age']]); + }); + + test('should return path to a nested dirty value', () => { + 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(getDirtyPaths(store)).toStrictEqual([['user', 'email']]); + }); + + test('should return the array path 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(getDirtyPaths(store)).toStrictEqual([['items']]); + }); + + test('should return only the array path when a nested object field inside an array 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(getDirtyPaths(store)).toStrictEqual([['users']]); + }); + + test('should return the object path when an object was cleared 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(getDirtyPaths(store)).toStrictEqual([['user']]); + }); + + test('should return the object path when an object transitioned from nullish without a dirty descendant', () => { + 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') { + // Simulate `setInput(form, { path: ['user'], input: {} })`: the object's + // own dirty flag flips, but the child `name` stays at its initial value. + userStore.input.value = true; + userStore.isDirty.value = true; + } + + expect(getDirtyPaths(store)).toStrictEqual([['user']]); + }); + + test('should emit only the leaf path when both the object and a descendant are dirty', () => { + 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') { + // Simulate `setInput(form, { path: ['user'], input: { name: 'John' } })`: + // both the object itself (null → object) and the `name` child flip dirty. + userStore.input.value = true; + userStore.isDirty.value = true; + userStore.children.name.input.value = 'John'; + userStore.children.name.isDirty.value = true; + } + + // Only the leaf path is emitted — the parent's dirty state is implied by + // the descendant path and should not double-emit. + expect(getDirtyPaths(store)).toStrictEqual([['user', 'name']]); + }); + + test('should scope to the given path', () => { + const store = createTestStore( + v.object({ + user: v.object({ email: v.string(), name: v.string() }), + meta: v.object({ visits: v.number() }), + }), + { + initialInput: { + user: { email: 'a@example.com', name: 'John' }, + meta: { visits: 0 }, + }, + } + ); + 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; + } + const metaStore = store.children.meta; + expect(metaStore.kind).toBe('object'); + if (metaStore.kind === 'object') { + metaStore.children.visits.input.value = 1; + metaStore.children.visits.isDirty.value = true; + } + + expect(getDirtyPaths(store, { path: ['user'] })).toStrictEqual([ + ['user', 'email'], + ]); + }); + + test('should return empty array when scoped to a clean subtree', () => { + const store = createTestStore( + v.object({ + user: v.object({ email: v.string() }), + other: v.string(), + }), + { initialInput: { user: { email: 'a@example.com' }, other: 'x' } } + ); + store.children.other.input.value = 'y'; + store.children.other.isDirty.value = true; + + expect(getDirtyPaths(store, { path: ['user'] })).toStrictEqual([]); + }); + + test('should return the path when scoped to a dirty value field', () => { + const store = createTestStore(v.object({ name: v.string() }), { + initialInput: { name: 'John' }, + }); + store.children.name.input.value = 'Jane'; + store.children.name.isDirty.value = true; + + expect(getDirtyPaths(store, { path: ['name'] })).toStrictEqual([['name']]); + }); + + test('should return the path when scoped to an array with a dirty item', () => { + 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(getDirtyPaths(store, { path: ['items'] })).toStrictEqual([ + ['items'], + ]); + }); +}); diff --git a/packages/methods/src/getDirtyPaths/getDirtyPaths.ts b/packages/methods/src/getDirtyPaths/getDirtyPaths.ts new file mode 100644 index 00000000..ae8a843f --- /dev/null +++ b/packages/methods/src/getDirtyPaths/getDirtyPaths.ts @@ -0,0 +1,140 @@ +import { + type BaseFormStore, + type DirtyPath, + type FormSchema, + getFieldBool, + getFieldStore, + INTERNAL, + type InternalFieldStore, + type PathKey, + type RequiredPath, + type ValidPath, +} from '@formisch/core'; +import type * as v from 'valibot'; + +/** + * Get form dirty paths config interface. + */ +export interface GetFormDirtyPathsConfig { + /** + * The path to a field. Leave undefined to inspect the entire form. + */ + readonly path?: undefined; +} + +/** + * Get field dirty paths config interface. + */ +export interface GetFieldDirtyPathsConfig< + TSchema extends FormSchema, + TFieldPath extends RequiredPath, +> { + /** + * The path to the field to inspect. + */ + readonly path: ValidPath, TFieldPath>; +} + +/** + * Returns a list of paths to the dirty fields of a specific field or the + * entire form. Arrays are treated as atomic and contribute only their own + * path if any item is dirty, while object branches are recursed into. Returns + * an empty list if no field in the inspected subtree is dirty. + * + * @param form The form store to inspect. + * + * @returns The list of paths to the dirty fields. + */ +export function getDirtyPaths( + form: BaseFormStore +): DirtyPath>[]; + +/** + * Returns a list of paths to the dirty fields of a specific field or the + * entire form. Arrays are treated as atomic and contribute only their own + * path if any item is dirty, while object branches are recursed into. Returns + * an empty list if no field in the inspected subtree is dirty. + * + * @param form The form store to inspect. + * @param config The get dirty paths configuration. + * + * @returns The list of paths to the dirty fields. + */ +export function getDirtyPaths< + TSchema extends FormSchema, + TFieldPath extends RequiredPath | undefined = undefined, +>( + form: BaseFormStore, + config: TFieldPath extends RequiredPath + ? GetFieldDirtyPathsConfig + : GetFormDirtyPathsConfig +): DirtyPath>[]; + +// @__NO_SIDE_EFFECTS__ +export function getDirtyPaths( + form: BaseFormStore, + config?: + | GetFormDirtyPathsConfig + | GetFieldDirtyPathsConfig +): RequiredPath[] { + // Get field store of form or specified field + const internalFieldStore = config?.path + ? getFieldStore(form[INTERNAL], config.path) + : form[INTERNAL]; + + // Collect paths of dirty fields via a single recursive walk + const paths: RequiredPath[] = []; + collectDirtyPaths( + internalFieldStore, + config?.path ? [...config.path] : [], + paths + ); + + // Return collected paths + return paths; +} + +// @__NO_SIDE_EFFECTS__ +function collectDirtyPaths( + internalFieldStore: InternalFieldStore, + currentPath: PathKey[], + paths: RequiredPath[] +): void { + // If field store is object with non-nullish input, recurse into children + if (internalFieldStore.kind === 'object' && internalFieldStore.input.value) { + // Hint: We skip a per-child `getFieldBool` pre-check because the + // recursion already prunes clean subtrees. Pre-checking would walk + // every dirty subtree twice. + const lengthBefore = paths.length; + for (const key in internalFieldStore.children) { + currentPath.push(key); + collectDirtyPaths(internalFieldStore.children[key], currentPath, paths); + currentPath.pop(); + } + + // If no descendant emitted a path but the object itself flipped dirty + // (e.g. transitioned from nullish to a non-nullish object), emit the + // object's own path so the change isn't silently dropped. + if ( + paths.length === lengthBefore && + internalFieldStore.isDirty.value && + currentPath.length > 0 + ) { + paths.push([...currentPath] as unknown as RequiredPath); + } + + // Otherwise, if field store is a value, emit its path if dirty + } else if (internalFieldStore.kind === 'value') { + if (internalFieldStore.isDirty.value && currentPath.length > 0) { + paths.push([...currentPath] as unknown as RequiredPath); + } + + // Otherwise, field is atomic (array or cleared object) — emit its path + // if any dirty content exists + } else if ( + getFieldBool(internalFieldStore, 'isDirty') && + currentPath.length > 0 + ) { + paths.push([...currentPath] as unknown as RequiredPath); + } +} diff --git a/packages/methods/src/getDirtyPaths/index.ts b/packages/methods/src/getDirtyPaths/index.ts new file mode 100644 index 00000000..0f77fa9e --- /dev/null +++ b/packages/methods/src/getDirtyPaths/index.ts @@ -0,0 +1 @@ +export * from './getDirtyPaths.ts'; diff --git a/packages/methods/src/index.ts b/packages/methods/src/index.ts index 9b31bac4..3f42868c 100644 --- a/packages/methods/src/index.ts +++ b/packages/methods/src/index.ts @@ -1,10 +1,13 @@ export * from './focus/index.ts'; export * from './getAllErrors/index.ts'; +export * from './getDirtyInput/index.ts'; +export * from './getDirtyPaths/index.ts'; export * from './getErrors/index.ts'; export * from './getInput/index.ts'; export * from './handleSubmit/index.ts'; export * from './insert/index.ts'; export * from './move/index.ts'; +export * from './pickDirty/index.ts'; export * from './remove/index.ts'; export * from './replace/index.ts'; export * from './reset/index.ts'; diff --git a/packages/methods/src/pickDirty/index.ts b/packages/methods/src/pickDirty/index.ts new file mode 100644 index 00000000..e28ec6d9 --- /dev/null +++ b/packages/methods/src/pickDirty/index.ts @@ -0,0 +1 @@ +export * from './pickDirty.ts'; diff --git a/packages/methods/src/pickDirty/pickDirty.test-d.ts b/packages/methods/src/pickDirty/pickDirty.test-d.ts new file mode 100644 index 00000000..fb46ccc7 --- /dev/null +++ b/packages/methods/src/pickDirty/pickDirty.test-d.ts @@ -0,0 +1,52 @@ +import type { DeepPartial } from '@formisch/core'; +import * as v from 'valibot'; +import { describe, expectTypeOf, test } from 'vitest'; +import { createTestStore } from '../vitest/index.ts'; +import { pickDirty } from './pickDirty.ts'; + +describe('pickDirty', () => { + const store = createTestStore( + v.object({ + name: v.string(), + address: v.object({ street: v.string(), zip: v.number() }), + }) + ); + + test('should return a deep partial of the supplied value or undefined', () => { + type Value = { name: string; age: number }; + const from: Value = { name: 'John', age: 30 }; + + expectTypeOf(pickDirty(store, { from })).toEqualTypeOf< + DeepPartial | undefined + >(); + }); + + test('should deeply partialize nested objects and arrays', () => { + type Value = { user: { email: string; tags: string[] } }; + const from: Value = { user: { email: 'a@example.com', tags: ['x'] } }; + + expectTypeOf(pickDirty(store, { from })).toEqualTypeOf< + DeepPartial | undefined + >(); + }); + + test('should infer the value type from `from`, independent of the schema', () => { + type Value = { anything: boolean }; + const from: Value = { anything: true }; + + expectTypeOf(pickDirty(store, { from })).toEqualTypeOf< + DeepPartial | undefined + >(); + }); + + test('should require `from` to be an object', () => { + // @ts-expect-error string is not an object + pickDirty(store, { from: 'oops' }); + // @ts-expect-error number is not an object + pickDirty(store, { from: 42 }); + // @ts-expect-error null is not an object + pickDirty(store, { from: null }); + // @ts-expect-error undefined is not an object + pickDirty(store, { from: undefined }); + }); +}); diff --git a/packages/methods/src/pickDirty/pickDirty.test.ts b/packages/methods/src/pickDirty/pickDirty.test.ts new file mode 100644 index 00000000..bc5feed7 --- /dev/null +++ b/packages/methods/src/pickDirty/pickDirty.test.ts @@ -0,0 +1,228 @@ +import * as v from 'valibot'; +import { describe, expect, test } from 'vitest'; +import { createTestStore } from '../vitest/index.ts'; +import { pickDirty } from './pickDirty.ts'; + +describe('pickDirty', () => { + 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( + pickDirty(store, { from: { name: 'John', age: 25 } }) + ).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( + pickDirty(store, { + from: { name: 'John', email: 'b@example.com' }, + }) + ).toStrictEqual({ email: 'b@example.com' }); + }); + + test('should pull values from the supplied value, not the form', () => { + const store = createTestStore(v.object({ age: v.string() }), { + initialInput: { age: '25' }, + }); + store.children.age.input.value = '30'; + store.children.age.isDirty.value = true; + + expect(pickDirty(store, { from: { age: 30 } })).toStrictEqual({ + age: 30, + }); + }); + + 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( + pickDirty(store, { from: { items: ['a', 'B', 'c'] } }) + ).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( + pickDirty(store, { + from: { user: { email: 'b@example.com', name: 'John' } }, + }) + ).toStrictEqual({ user: { email: 'b@example.com' } }); + }); + + test('should include a dirty leaf whose value 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(pickDirty(store, { from: { name: undefined } })).toStrictEqual({ + name: undefined, + }); + }); + + test('should pass through the supplied value when an object was cleared 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(pickDirty(store, { from: { user: null } })).toStrictEqual({ + user: null, + }); + }); + + test('should skip a dirty key that is absent from the supplied value', () => { + const store = createTestStore( + v.object({ name: v.string(), email: v.string() }), + { initialInput: { name: 'John', email: 'a@example.com' } } + ); + store.children.name.input.value = 'Jane'; + store.children.name.isDirty.value = true; + store.children.email.input.value = 'b@example.com'; + store.children.email.isDirty.value = true; + + // `email` is dirty in the form but absent from `from` — it should be + // skipped rather than included as `undefined`. + expect(pickDirty(store, { from: { name: 'Jane' } })).toStrictEqual({ + name: 'Jane', + }); + }); + + test('should pass a diverging value through without throwing when an object is expected', () => { + const store = createTestStore( + v.object({ + name: v.string(), + user: v.object({ email: v.string() }), + }), + { initialInput: { name: 'John', user: { email: 'a@example.com' } } } + ); + store.children.name.input.value = 'Jane'; + store.children.name.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; + } + + // `user` expects an object, but a primitive, `null` or array is passed — + // the value is returned as-is rather than crashing on `key in value`. + expect( + pickDirty(store, { from: { name: 'Jane', user: 'reshaped' } }) + ).toStrictEqual({ name: 'Jane', user: 'reshaped' }); + expect( + pickDirty(store, { from: { name: 'Jane', user: null } }) + ).toStrictEqual({ name: 'Jane', user: null }); + expect( + pickDirty(store, { from: { name: 'Jane', user: ['reshaped'] } }) + ).toStrictEqual({ name: 'Jane', user: ['reshaped'] }); + }); + + test('should return undefined when all dirty keys are absent from the supplied 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; + + // The form is dirty, but the only dirty key (`email`) is absent from + // `from`, so the root result is empty and `undefined` is returned. + expect(pickDirty(store, { from: { name: 'John' } })).toBeUndefined(); + }); + + test('should keep an empty object for a nested object whose dirty key is absent', () => { + 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; + } + + // `user` is dirty via `email`, but `email` is absent from the supplied + // `user` object, so an empty object is kept rather than omitted. + expect( + pickDirty(store, { from: { user: { name: 'John' } } }) + ).toStrictEqual({ user: {} }); + }); + + test('should return the full array atomically when a nested item field is dirty', () => { + const store = createTestStore( + v.object({ users: v.array(v.object({ name: v.string() })) }), + { initialInput: { users: [{ name: 'John' }, { name: 'Jane' }] } } + ); + const usersStore = store.children.users; + expect(usersStore.kind).toBe('array'); + if (usersStore.kind === 'array') { + const firstItem = usersStore.children[0]; + if (firstItem.kind === 'object') { + firstItem.children.name.input.value = 'Johnny'; + firstItem.children.name.isDirty.value = true; + } + } + + // Arrays are atomic: the whole supplied array is returned, including the + // clean second item. + expect( + pickDirty(store, { + from: { users: [{ name: 'Johnny' }, { name: 'Jane' }] }, + }) + ).toStrictEqual({ users: [{ name: 'Johnny' }, { name: 'Jane' }] }); + }); + + test('should pass through an array that was cleared to nullish', () => { + const store = createTestStore( + v.object({ tags: v.nullish(v.array(v.string())) }), + { initialInput: { tags: ['a', 'b'] } } + ); + const tagsStore = store.children.tags; + expect(tagsStore.kind).toBe('array'); + if (tagsStore.kind === 'array') { + tagsStore.input.value = null; + tagsStore.isDirty.value = true; + } + + expect(pickDirty(store, { from: { tags: null } })).toStrictEqual({ + tags: null, + }); + }); +}); diff --git a/packages/methods/src/pickDirty/pickDirty.ts b/packages/methods/src/pickDirty/pickDirty.ts new file mode 100644 index 00000000..98a592f2 --- /dev/null +++ b/packages/methods/src/pickDirty/pickDirty.ts @@ -0,0 +1,95 @@ +import { + type BaseFormStore, + type DeepPartial, + type FormSchema, + getFieldBool, + INTERNAL, + type InternalFieldStore, +} from '@formisch/core'; + +/** + * Pick dirty config interface. + */ +export interface PickDirtyConfig { + /** + * The value to filter down to its dirty parts. Must be structurally + * compatible with the form's schema. + */ + readonly from: TValue; +} + +/** + * Picks only the dirty parts of the given value, using the form's dirty fields + * as a structural mask. Arrays are treated as atomic and object keys without a + * dirty descendant are omitted. Returns `undefined` if no field is dirty. + * Useful for filtering a validated output down to its changed parts before + * submitting. + * + * @param form The form store providing the dirty mask. + * @param config The pick dirty configuration. + * + * @returns The dirty parts of the value, or `undefined`. + */ +// @__NO_SIDE_EFFECTS__ +export function pickDirty( + form: BaseFormStore, + config: PickDirtyConfig +): DeepPartial | undefined { + // If no field is dirty, return undefined + if (!getFieldBool(form[INTERNAL], 'isDirty')) { + return undefined; + } + + // Pick the dirty parts of the value using the form as a mask + const result = pickFieldValue(form[INTERNAL], config.from); + + // Return undefined if no dirty property ended up in the result, which can + // happen when every dirty key is absent from the supplied value + return Object.keys(result as object).length + ? (result as DeepPartial) + : undefined; +} + +/** + * Recursively picks the dirty parts of a value using the field store as a + * structural mask, reading from the supplied value rather than the form's own + * input. Objects with non-nullish input recurse into their dirty children that + * are present in the value, while arrays, primitives, nullish-cleared fields + * and shape-diverging values are returned as-is. + * + * @param internalFieldStore The field store used as the dirty mask. + * @param value The value to pick the dirty parts from. + * + * @returns The dirty parts of the value. + */ +// @__NO_SIDE_EFFECTS__ +function pickFieldValue( + internalFieldStore: InternalFieldStore, + value: unknown +): unknown { + // If field store is object with non-nullish input and the value is a + // matching (non-array) object, recurse into children + if ( + internalFieldStore.kind === 'object' && + internalFieldStore.input.value && + value && + typeof value === 'object' && + !Array.isArray(value) + ) { + // Collect dirty parts from each dirty property present in value + const result: Record = {}; + for (const key in internalFieldStore.children) { + const child = internalFieldStore.children[key]; + if (getFieldBool(child, 'isDirty') && key in value) { + result[key] = pickFieldValue( + child, + (value as Record)[key] + ); + } + } + return result; + } + + // Otherwise, field is atomic or its shape diverges, so return as-is + return value; +} diff --git a/website/src/routes/(docs)/methods/api/(methods)/getDirtyInput/index.mdx b/website/src/routes/(docs)/methods/api/(methods)/getDirtyInput/index.mdx new file mode 100644 index 00000000..7cad46cc --- /dev/null +++ b/website/src/routes/(docs)/methods/api/(methods)/getDirtyInput/index.mdx @@ -0,0 +1,52 @@ +--- +title: getDirtyInput +description: Retrieves only the dirty input values of a specific field or the entire form. +source: /packages/methods/src/getDirtyInput/getDirtyInput.ts +contributors: + - fabian-hiller +--- + +import { ApiList, Property } from '~/components'; +import { properties } from './properties'; + +# getDirtyInput + +Retrieves only the dirty input values of a specific field or the entire form. Object keys whose subtree contains no dirty descendant are omitted; arrays are treated as atomic and returned in full whenever any descendant is dirty. Returns `undefined` if no field in the inspected subtree is dirty. + +```ts +const input = getDirtyInput(form); +const input = getDirtyInput(form, config); +``` + +## Generics + +- `TSchema` +- `TFieldPath` + +## Parameters + +- `form` +- `config` + +### Explanation + +The `form` parameter is the form store to retrieve dirty input from. The optional `config` parameter scopes the result to a specific field path - if omitted, returns dirty input from the entire form. The returned value is a partial of the inspected input shape containing only the fields whose `isDirty` flag is set, which is useful for submitting only the values that changed since the start input. Arrays are treated as atomic — when any descendant of an array is dirty, the full current array is returned. + +Internally, the function walks the form's field tree and asks `getFieldBool` whether each branch contains a dirty descendant. Because that check is itself recursive, the cost is effectively linear in field count for typical balanced forms (shallow and wide) and degrades toward `O(N²)` for deeply nested trees. Call this method from submit or blur handlers rather than from tight reactive loops on large forms. + +## Returns + +- `result` + +## Related + +### Methods + + diff --git a/website/src/routes/(docs)/methods/api/(methods)/getDirtyInput/properties.ts b/website/src/routes/(docs)/methods/api/(methods)/getDirtyInput/properties.ts new file mode 100644 index 00000000..66da643d --- /dev/null +++ b/website/src/routes/(docs)/methods/api/(methods)/getDirtyInput/properties.ts @@ -0,0 +1,94 @@ +import type { PropertyProps } from '~/components'; + +export const properties: Record = { + TSchema: { + modifier: 'extends', + type: { + type: 'custom', + name: 'FormSchema', + href: '/core/api/FormSchema/', + }, + }, + TFieldPath: { + modifier: 'extends', + type: { + type: 'union', + options: [ + { + type: 'custom', + name: 'RequiredPath', + href: '/core/api/RequiredPath/', + }, + 'undefined', + ], + }, + default: 'undefined', + }, + form: { + type: { + type: 'custom', + name: 'BaseFormStore', + href: '/core/api/BaseFormStore/', + generics: [ + { + type: 'custom', + name: 'TSchema', + }, + ], + }, + }, + config: { + type: { + type: 'union', + options: [ + { + type: 'custom', + name: 'GetFormDirtyInputConfig', + href: '../GetFormDirtyInputConfig/', + }, + { + type: 'custom', + name: 'GetFieldDirtyInputConfig', + href: '../GetFieldDirtyInputConfig/', + generics: [ + { + type: 'custom', + name: 'TSchema', + }, + { + type: 'custom', + name: 'RequiredPath', + }, + ], + }, + 'undefined', + ], + }, + }, + result: { + type: { + type: 'union', + options: [ + { + type: 'custom', + name: 'DeepPartial', + href: '/core/api/DeepPartial/', + generics: [ + { + type: 'custom', + name: 'v.InferInput', + href: 'https://valibot.dev/api/InferInput/', + generics: [ + { + type: 'custom', + name: 'TSchema', + }, + ], + }, + ], + }, + 'undefined', + ], + }, + }, +}; diff --git a/website/src/routes/(docs)/methods/api/(methods)/getDirtyPaths/index.mdx b/website/src/routes/(docs)/methods/api/(methods)/getDirtyPaths/index.mdx new file mode 100644 index 00000000..8003caf5 --- /dev/null +++ b/website/src/routes/(docs)/methods/api/(methods)/getDirtyPaths/index.mdx @@ -0,0 +1,51 @@ +--- +title: getDirtyPaths +description: Returns the paths of dirty fields in a form. +source: /packages/methods/src/getDirtyPaths/getDirtyPaths.ts +contributors: + - fabian-hiller +--- + +import { ApiList, Property } from '~/components'; +import { properties } from './properties'; + +# getDirtyPaths + +Returns a list of paths to dirty fields in the form. Object branches are recursed into; arrays are treated as atomic — when any descendant of an array is dirty, only the array's own path is returned. + +```ts +const paths = getDirtyPaths(form); +const paths = getDirtyPaths(form, config); +``` + +## Generics + +- `TSchema` +- `TFieldPath` + +## Parameters + +- `form` +- `config` + +### Explanation + +The `form` parameter is the form store to inspect. The optional `config` parameter scopes the search to a single subtree via its `path` property. Object branches are recursed into and clean keys are omitted; arrays are atomic, so when any descendant of an array is dirty, the result includes the array's own path rather than the paths of its individual items. Returns an empty array if the inspected subtree contains no dirty fields. When `path` targets a dirty array or value field, the result contains that path. + +Internally, the function walks the form's field tree and asks `getFieldBool` whether each branch contains a dirty descendant. Because that check is itself recursive, the cost is effectively linear in field count for typical balanced forms (shallow and wide) and degrades toward `O(N²)` for deeply nested trees. Call this method from submit or blur handlers rather than from tight reactive loops on large forms. + +## Returns + +- `result` + +## Related + +### Methods + + diff --git a/website/src/routes/(docs)/methods/api/(methods)/getDirtyPaths/properties.ts b/website/src/routes/(docs)/methods/api/(methods)/getDirtyPaths/properties.ts new file mode 100644 index 00000000..ec1e8014 --- /dev/null +++ b/website/src/routes/(docs)/methods/api/(methods)/getDirtyPaths/properties.ts @@ -0,0 +1,78 @@ +import type { PropertyProps } from '~/components'; + +export const properties: Record = { + TSchema: { + modifier: 'extends', + type: { + type: 'custom', + name: 'FormSchema', + href: '/core/api/FormSchema/', + }, + }, + TFieldPath: { + modifier: 'extends', + type: { + type: 'union', + options: [ + { + type: 'custom', + name: 'RequiredPath', + href: '/core/api/RequiredPath/', + }, + 'undefined', + ], + }, + default: 'undefined', + }, + form: { + type: { + type: 'custom', + name: 'BaseFormStore', + href: '/core/api/BaseFormStore/', + generics: [ + { + type: 'custom', + name: 'TSchema', + }, + ], + }, + }, + config: { + type: { + type: 'union', + options: [ + { + type: 'custom', + name: 'GetFormDirtyPathsConfig', + href: '../GetFormDirtyPathsConfig/', + }, + { + type: 'custom', + name: 'GetFieldDirtyPathsConfig', + href: '../GetFieldDirtyPathsConfig/', + generics: [ + { + type: 'custom', + name: 'TSchema', + }, + { + type: 'custom', + name: 'RequiredPath', + }, + ], + }, + 'undefined', + ], + }, + }, + result: { + type: { + type: 'array', + item: { + type: 'custom', + name: 'RequiredPath', + href: '/core/api/RequiredPath/', + }, + }, + }, +}; diff --git a/website/src/routes/(docs)/methods/api/(methods)/pickDirty/index.mdx b/website/src/routes/(docs)/methods/api/(methods)/pickDirty/index.mdx new file mode 100644 index 00000000..d4b92960 --- /dev/null +++ b/website/src/routes/(docs)/methods/api/(methods)/pickDirty/index.mdx @@ -0,0 +1,50 @@ +--- +title: pickDirty +description: Filters an externally-supplied value down to its dirty parts using the form's dirty mask. +source: /packages/methods/src/pickDirty/pickDirty.ts +contributors: + - fabian-hiller +--- + +import { ApiList, Property } from '~/components'; +import { properties } from './properties'; + +# pickDirty + +Picks only the dirty parts of the given value, using the form's dirty tree as a structural mask. Object keys whose subtree contains no dirty descendant are omitted; arrays are treated as atomic and returned in full whenever any descendant is dirty. Returns `undefined` if no field is dirty. Where the supplied value's shape diverges from the form's input shape — for example, a field expected to be an object holds a primitive, `null` or array — that branch is returned as-is. Useful for filtering a validated output to just the changed parts before submitting. + +```ts +const dirty = pickDirty(form, config); +``` + +## Generics + +- `TSchema` +- `TValue` + +## Parameters + +- `form` +- `config` + +### Explanation + +The `form` parameter is the form store providing the dirty mask. The `config` parameter must include a `from` value to filter. The value's shape is expected to match the form's input shape — wherever the shapes diverge (e.g. an object key in the form holds a primitive, `null` or array in the supplied value), that branch is returned as-is so the divergence is preserved in the result. Use this when a Valibot schema transformation produces a value of a different shape than the form's input and you want to ship only the dirty parts of that output. + +Internally, the function walks the form's field tree alongside the supplied value, using `getFieldBool` to skip clean subtrees and a constant-time check at each node to verify shape alignment. Because the dirty check is itself recursive, the cost is effectively linear in field count for typical balanced forms (shallow and wide) and degrades toward `O(N²)` for deeply nested trees. Call this method from submit or blur handlers rather than from tight reactive loops on large forms. + +## Returns + +- `result` + +## Related + +### Methods + + diff --git a/website/src/routes/(docs)/methods/api/(methods)/pickDirty/properties.ts b/website/src/routes/(docs)/methods/api/(methods)/pickDirty/properties.ts new file mode 100644 index 00000000..a58dfa57 --- /dev/null +++ b/website/src/routes/(docs)/methods/api/(methods)/pickDirty/properties.ts @@ -0,0 +1,63 @@ +import type { PropertyProps } from '~/components'; + +export const properties: Record = { + TSchema: { + modifier: 'extends', + type: { + type: 'custom', + name: 'FormSchema', + href: '/core/api/FormSchema/', + }, + }, + TValue: { + type: { + type: 'custom', + name: 'unknown', + }, + }, + form: { + type: { + type: 'custom', + name: 'BaseFormStore', + href: '/core/api/BaseFormStore/', + generics: [ + { + type: 'custom', + name: 'TSchema', + }, + ], + }, + }, + config: { + type: { + type: 'custom', + name: 'PickDirtyConfig', + href: '../PickDirtyConfig/', + generics: [ + { + type: 'custom', + name: 'TValue', + }, + ], + }, + }, + result: { + type: { + type: 'union', + options: [ + { + type: 'custom', + name: 'DeepPartial', + href: '/core/api/DeepPartial/', + generics: [ + { + type: 'custom', + name: 'TValue', + }, + ], + }, + 'undefined', + ], + }, + }, +}; diff --git a/website/src/routes/(docs)/methods/api/(types)/GetFieldDirtyInputConfig/index.mdx b/website/src/routes/(docs)/methods/api/(types)/GetFieldDirtyInputConfig/index.mdx new file mode 100644 index 00000000..efe44f76 --- /dev/null +++ b/website/src/routes/(docs)/methods/api/(types)/GetFieldDirtyInputConfig/index.mdx @@ -0,0 +1,30 @@ +--- +title: GetFieldDirtyInputConfig +description: Configuration interface for retrieving field-scoped dirty input. +source: /packages/methods/src/getDirtyInput/getDirtyInput.ts +contributors: + - fabian-hiller +--- + +import { ApiList, Property } from '~/components'; +import { properties } from './properties'; + +# GetFieldDirtyInputConfig + +Configuration interface for retrieving field-scoped dirty input. Used by the `getDirtyInput` method when a specific field path is provided. + +## Generics + +- `TSchema` +- `TFieldPath` + +## Definition + +- `GetFieldDirtyInputConfig` + - `path` + +## Related + +### Methods + + diff --git a/website/src/routes/(docs)/methods/api/(types)/GetFieldDirtyInputConfig/properties.ts b/website/src/routes/(docs)/methods/api/(types)/GetFieldDirtyInputConfig/properties.ts new file mode 100644 index 00000000..d09e9b07 --- /dev/null +++ b/website/src/routes/(docs)/methods/api/(types)/GetFieldDirtyInputConfig/properties.ts @@ -0,0 +1,44 @@ +import type { PropertyProps } from '~/components'; + +export const properties: Record = { + TSchema: { + modifier: 'extends', + type: { + type: 'custom', + name: 'FormSchema', + href: '/core/api/FormSchema/', + }, + }, + TFieldPath: { + modifier: 'extends', + type: { + type: 'custom', + name: 'RequiredPath', + href: '/core/api/RequiredPath/', + }, + }, + path: { + type: { + type: 'custom', + name: 'ValidPath', + href: '/core/api/ValidPath/', + generics: [ + { + type: 'custom', + name: 'v.InferInput', + href: 'https://valibot.dev/api/InferInput/', + generics: [ + { + type: 'custom', + name: 'TSchema', + }, + ], + }, + { + type: 'custom', + name: 'TFieldPath', + }, + ], + }, + }, +}; diff --git a/website/src/routes/(docs)/methods/api/(types)/GetFieldDirtyPathsConfig/index.mdx b/website/src/routes/(docs)/methods/api/(types)/GetFieldDirtyPathsConfig/index.mdx new file mode 100644 index 00000000..1de4bb92 --- /dev/null +++ b/website/src/routes/(docs)/methods/api/(types)/GetFieldDirtyPathsConfig/index.mdx @@ -0,0 +1,30 @@ +--- +title: GetFieldDirtyPathsConfig +description: Configuration interface for inspecting field-scoped dirty paths. +source: /packages/methods/src/getDirtyPaths/getDirtyPaths.ts +contributors: + - fabian-hiller +--- + +import { ApiList, Property } from '~/components'; +import { properties } from './properties'; + +# GetFieldDirtyPathsConfig + +Configuration interface for inspecting field-scoped dirty paths. Used by the `getDirtyPaths` method when a specific field path is provided. + +## Generics + +- `TSchema` +- `TFieldPath` + +## Definition + +- `GetFieldDirtyPathsConfig` + - `path` + +## Related + +### Methods + + diff --git a/website/src/routes/(docs)/methods/api/(types)/GetFieldDirtyPathsConfig/properties.ts b/website/src/routes/(docs)/methods/api/(types)/GetFieldDirtyPathsConfig/properties.ts new file mode 100644 index 00000000..d09e9b07 --- /dev/null +++ b/website/src/routes/(docs)/methods/api/(types)/GetFieldDirtyPathsConfig/properties.ts @@ -0,0 +1,44 @@ +import type { PropertyProps } from '~/components'; + +export const properties: Record = { + TSchema: { + modifier: 'extends', + type: { + type: 'custom', + name: 'FormSchema', + href: '/core/api/FormSchema/', + }, + }, + TFieldPath: { + modifier: 'extends', + type: { + type: 'custom', + name: 'RequiredPath', + href: '/core/api/RequiredPath/', + }, + }, + path: { + type: { + type: 'custom', + name: 'ValidPath', + href: '/core/api/ValidPath/', + generics: [ + { + type: 'custom', + name: 'v.InferInput', + href: 'https://valibot.dev/api/InferInput/', + generics: [ + { + type: 'custom', + name: 'TSchema', + }, + ], + }, + { + type: 'custom', + name: 'TFieldPath', + }, + ], + }, + }, +}; diff --git a/website/src/routes/(docs)/methods/api/(types)/GetFormDirtyInputConfig/index.mdx b/website/src/routes/(docs)/methods/api/(types)/GetFormDirtyInputConfig/index.mdx new file mode 100644 index 00000000..586d1486 --- /dev/null +++ b/website/src/routes/(docs)/methods/api/(types)/GetFormDirtyInputConfig/index.mdx @@ -0,0 +1,25 @@ +--- +title: GetFormDirtyInputConfig +description: Configuration interface for retrieving form-level dirty input. +source: /packages/methods/src/getDirtyInput/getDirtyInput.ts +contributors: + - fabian-hiller +--- + +import { ApiList, Property } from '~/components'; +import { properties } from './properties'; + +# GetFormDirtyInputConfig + +Configuration interface for retrieving form-level dirty input. Used by the `getDirtyInput` method when no specific field path is provided. + +## Definition + +- `GetFormDirtyInputConfig` + - `path` + +## Related + +### Methods + + diff --git a/website/src/routes/(docs)/methods/api/(types)/GetFormDirtyInputConfig/properties.ts b/website/src/routes/(docs)/methods/api/(types)/GetFormDirtyInputConfig/properties.ts new file mode 100644 index 00000000..ed1e77e3 --- /dev/null +++ b/website/src/routes/(docs)/methods/api/(types)/GetFormDirtyInputConfig/properties.ts @@ -0,0 +1,8 @@ +import type { PropertyProps } from '~/components'; + +export const properties: Record = { + path: { + type: 'undefined', + default: 'undefined', + }, +}; diff --git a/website/src/routes/(docs)/methods/api/(types)/GetFormDirtyPathsConfig/index.mdx b/website/src/routes/(docs)/methods/api/(types)/GetFormDirtyPathsConfig/index.mdx new file mode 100644 index 00000000..e9373457 --- /dev/null +++ b/website/src/routes/(docs)/methods/api/(types)/GetFormDirtyPathsConfig/index.mdx @@ -0,0 +1,25 @@ +--- +title: GetFormDirtyPathsConfig +description: Configuration interface for inspecting form-level dirty paths. +source: /packages/methods/src/getDirtyPaths/getDirtyPaths.ts +contributors: + - fabian-hiller +--- + +import { ApiList, Property } from '~/components'; +import { properties } from './properties'; + +# GetFormDirtyPathsConfig + +Configuration interface for inspecting form-level dirty paths. Used by the `getDirtyPaths` method when no specific field path is provided. + +## Definition + +- `GetFormDirtyPathsConfig` + - `path` + +## Related + +### Methods + + diff --git a/website/src/routes/(docs)/methods/api/(types)/GetFormDirtyPathsConfig/properties.ts b/website/src/routes/(docs)/methods/api/(types)/GetFormDirtyPathsConfig/properties.ts new file mode 100644 index 00000000..ed1e77e3 --- /dev/null +++ b/website/src/routes/(docs)/methods/api/(types)/GetFormDirtyPathsConfig/properties.ts @@ -0,0 +1,8 @@ +import type { PropertyProps } from '~/components'; + +export const properties: Record = { + path: { + type: 'undefined', + default: 'undefined', + }, +}; diff --git a/website/src/routes/(docs)/methods/api/(types)/PickDirtyConfig/index.mdx b/website/src/routes/(docs)/methods/api/(types)/PickDirtyConfig/index.mdx new file mode 100644 index 00000000..85164cfb --- /dev/null +++ b/website/src/routes/(docs)/methods/api/(types)/PickDirtyConfig/index.mdx @@ -0,0 +1,29 @@ +--- +title: PickDirtyConfig +description: Configuration interface for picking dirty parts of a value. +source: /packages/methods/src/pickDirty/pickDirty.ts +contributors: + - fabian-hiller +--- + +import { ApiList, Property } from '~/components'; +import { properties } from './properties'; + +# PickDirtyConfig + +Configuration interface for picking dirty parts of a value. Used by the `pickDirty` method. + +## Generics + +- `TValue` + +## Definition + +- `PickDirtyConfig` + - `from` + +## Related + +### Methods + + diff --git a/website/src/routes/(docs)/methods/api/(types)/PickDirtyConfig/properties.ts b/website/src/routes/(docs)/methods/api/(types)/PickDirtyConfig/properties.ts new file mode 100644 index 00000000..e98615b1 --- /dev/null +++ b/website/src/routes/(docs)/methods/api/(types)/PickDirtyConfig/properties.ts @@ -0,0 +1,16 @@ +import type { PropertyProps } from '~/components'; + +export const properties: Record = { + TValue: { + type: { + type: 'custom', + name: 'unknown', + }, + }, + from: { + type: { + type: 'custom', + name: 'TValue', + }, + }, +}; diff --git a/website/src/routes/(docs)/preact/api/menu.md b/website/src/routes/(docs)/preact/api/menu.md index 8a9eb521..9efe75f9 100644 --- a/website/src/routes/(docs)/preact/api/menu.md +++ b/website/src/routes/(docs)/preact/api/menu.md @@ -16,11 +16,14 @@ - [focus](/methods/api/focus/) - [getAllErrors](/methods/api/getAllErrors/) +- [getDirtyInput](/methods/api/getDirtyInput/) +- [getDirtyPaths](/methods/api/getDirtyPaths/) - [getErrors](/methods/api/getErrors/) - [getInput](/methods/api/getInput/) - [handleSubmit](/methods/api/handleSubmit/) - [insert](/methods/api/insert/) - [move](/methods/api/move/) +- [pickDirty](/methods/api/pickDirty/) - [remove](/methods/api/remove/) - [replace](/methods/api/replace/) - [reset](/methods/api/reset/) @@ -41,14 +44,19 @@ - [FormConfig](/preact/api/FormConfig/) - [FormSchema](/core/api/FormSchema/) - [FormStore](/preact/api/FormStore/) +- [GetFieldDirtyInputConfig](/methods/api/GetFieldDirtyInputConfig/) +- [GetFieldDirtyPathsConfig](/methods/api/GetFieldDirtyPathsConfig/) - [GetFieldErrorsConfig](/methods/api/GetFieldErrorsConfig/) - [GetFieldInputConfig](/methods/api/GetFieldInputConfig/) +- [GetFormDirtyInputConfig](/methods/api/GetFormDirtyInputConfig/) +- [GetFormDirtyPathsConfig](/methods/api/GetFormDirtyPathsConfig/) - [GetFormErrorsConfig](/methods/api/GetFormErrorsConfig/) - [GetFormInputConfig](/methods/api/GetFormInputConfig/) - [InsertConfig](/methods/api/InsertConfig/) - [PartialValues](/core/api/PartialValues/) - [Path](/core/api/Path/) - [PathKey](/core/api/PathKey/) +- [PickDirtyConfig](/methods/api/PickDirtyConfig/) - [MoveConfig](/methods/api/MoveConfig/) - [RemoveConfig](/methods/api/RemoveConfig/) - [ReplaceConfig](/methods/api/ReplaceConfig/) diff --git a/website/src/routes/(docs)/preact/guides/(advanced-guides)/dirty-fields/index.mdx b/website/src/routes/(docs)/preact/guides/(advanced-guides)/dirty-fields/index.mdx new file mode 100644 index 00000000..bb74327f --- /dev/null +++ b/website/src/routes/(docs)/preact/guides/(advanced-guides)/dirty-fields/index.mdx @@ -0,0 +1,182 @@ +--- +title: Dirty fields +description: >- + Learn how to work with dirty form state in Formisch. This guide covers the + three dirty-extraction methods, when to reach for each, and why pickDirty + exists alongside getDirtyInput. +contributors: + - fabian-hiller +--- + +import { Link } from '~/components'; + +# Dirty fields + +When a user edits a form, Formisch tracks which fields have changed since they were initialized. The library exposes three methods for working with that dirty state: `getDirtyInput`, `getDirtyPaths`, and `pickDirty`. This guide explains what each one is for, when to reach for it, and why `pickDirty` exists alongside the others. + +## What "dirty" means + +A field is dirty when its current input differs from its start input. The form starts clean. As the user types, only the fields they touch flip to dirty. Resetting a field (or resetting the form with a new `initialInput`) clears the dirty flag. + +> The dirty flag is per-field. Editing a deeply nested value does not flip its ancestors' flags. The dirty-extraction methods walk the tree internally to find dirty descendants, which is why all three methods do the same kind of work under the hood. + +## The three methods + +### `getDirtyInput` + +Returns the dirty subtree of the form's input. Use it when you want to ship the values the user typed — typically for a PATCH endpoint that accepts the same shape as your form input. + +```tsx +import { + Form, + getDirtyInput, + type SubmitHandler, + useForm, +} from '@formisch/preact'; +import * as v from 'valibot'; + +const UserSchema = v.object({ + name: v.pipe(v.string(), v.nonEmpty()), + email: v.pipe(v.string(), v.email()), +}); + +export default function EditProfile({ user }) { + const profileForm = useForm({ + schema: UserSchema, + initialInput: user, + }); + + const onSubmit: SubmitHandler = async () => { + const dirty = getDirtyInput(profileForm); + if (dirty) { + await api.patchUser(user.id, dirty); + } + }; + + return ( +
+ {/* fields */} +
+ ); +} +``` + +If only `email` was edited, `dirty` is `{ email: 'new@example.com' }`. If nothing was edited, it is `undefined`. + +Because dirty state is bound to the form input, `getDirtyInput` always returns the raw user input. Schema transformations such as `v.trim()` or `v.toNumber()` are not applied to the returned values. When you need the validated and transformed output instead, reach for `pickDirty` (see below). + +### `getDirtyPaths` + +Returns a list of paths to dirty fields. Use it for logging, telemetry, or driving a custom walker over the form's dirty state. + +```tsx +import { getDirtyPaths } from '@formisch/preact'; + +const dirtyPaths = getDirtyPaths(profileForm); +// e.g. [['email'], ['user', 'name']] +``` + +Arrays are atomic: only the array's own path is returned, never the paths of individual items. + +### `pickDirty` + +Filters a user-supplied value down to its dirty parts using the form's dirty mask. Use it when you have a separately-derived value — most commonly the validated and transformed output from a submit handler — and you want only its dirty parts. + +```tsx +import { pickDirty, type SubmitHandler } from '@formisch/preact'; + +const onSubmit: SubmitHandler = async (output) => { + const dirty = pickDirty(profileForm, { from: output }); + if (dirty) { + await api.update(dirty); + } +}; +``` + +## Why `pickDirty` exists + +Dirty state is tracked against the form **input** — the raw values the user typed. But Valibot schemas can transform that input into a different output shape before it reaches your submit handler: + +```tsx +import * as v from 'valibot'; + +const Schema = v.object({ + name: v.pipe(v.string(), v.trim()), + age: v.pipe(v.string(), v.toNumber()), +}); +``` + +After validation, `output.name` has whitespace trimmed and `output.age` is a number. `getDirtyInput` would give you the raw strings, because the dirty state is bound to the form input — not to the validated output. + +`pickDirty(form, { from: output })` is the bridge: it walks the form's dirty tree as a structural mask and reads the corresponding values from the output you supply. The result preserves the transformed types. + +```tsx +import { pickDirty, type SubmitHandler } from '@formisch/preact'; + +const onSubmit: SubmitHandler = async (output) => { + // output.age is a number — pickDirty preserves that. + const dirty = pickDirty(form, { from: output }); + if (dirty) { + await api.update(dirty); + } +}; +``` + +If the schema reshapes the output entirely — for example, by combining several input fields into a single output value — `pickDirty` returns `undefined` because the shape no longer aligns with the form's input shape. + +## Atomic arrays + +All three methods treat arrays as atomic. When any descendant of an array is dirty, the full array is returned (or its own path, for `getDirtyPaths`). The methods never produce sparse arrays. + +Sparse arrays don't round-trip safely. Serializers compact them, `JSON.stringify` writes `null` for holes, and indices lose positional meaning. Returning the full current array preserves order and lets the server treat the array as a complete replacement. + +This differs from objects, where clean keys are omitted. Objects model keyed dictionaries; arrays model ordered sequences. + +## Common patterns + +The snippets below assume `form` is your form store (the result of `useForm`). + +### Skip submission when nothing changed + +The form's `isDirty` flag is the cheapest way to ask "is anything dirty?" — it short-circuits on the first dirty field it finds and doesn't allocate. Use it when all you need is the yes/no answer. If you actually consume the output of `getDirtyInput`, `getDirtyPaths`, or `pickDirty`, call that method directly — its return value already signals "nothing dirty", so checking `form.isDirty` first would just walk the tree twice. + +```tsx +import type { SubmitHandler } from '@formisch/preact'; + +const onSubmit: SubmitHandler = async (output) => { + if (!form.isDirty) { + return; + } + await api.update(output); +}; +``` + +### Send only the dirty raw input + +```tsx +import { getDirtyInput } from '@formisch/preact'; + +const dirty = getDirtyInput(form); +if (dirty) { + await api.patch(dirty); +} +``` + +### Send only the dirty validated output + +```tsx +import { pickDirty, type SubmitHandler } from '@formisch/preact'; + +const onSubmit: SubmitHandler = async (output) => { + const dirty = pickDirty(form, { from: output }); + if (dirty) { + await api.update(dirty); + } +}; +``` + +## Performance + +All three methods walk the form's field tree and call `getFieldBool` recursively to skip clean subtrees. Cost is effectively linear in field count for typical balanced forms (shallow and wide) and degrades toward `O(N²)` for deeply nested trees with few siblings at each level. + +Call these methods from submit or blur handlers — not from tight reactive loops on every keystroke. For very large or deeply nested forms (thousands of fields), profile before binding them to high-frequency events. diff --git a/website/src/routes/(docs)/preact/guides/(main-concepts)/form-methods/index.mdx b/website/src/routes/(docs)/preact/guides/(main-concepts)/form-methods/index.mdx index fe1f6de3..3a11c8a2 100644 --- a/website/src/routes/(docs)/preact/guides/(main-concepts)/form-methods/index.mdx +++ b/website/src/routes/(docs)/preact/guides/(main-concepts)/form-methods/index.mdx @@ -27,6 +27,19 @@ To retrieve values from your form, you can use: Formisch uses Preact signals internally which means that reading values with these methods is reactive. When the form state changes, any component that uses these methods will automatically update to reflect the new state. +## Dirty state + +To work with the dirty state of your form, Formisch provides three methods: + +- `getDirtyInput`: Get the dirty + parts of the form input +- `getDirtyPaths`: Get the paths + of dirty fields +- `pickDirty`: Filter an + externally-supplied value down to its dirty parts using the form's dirty mask + +See the dirty fields guide for when to reach for each. + ## Setting values To manually update form values or errors, use: diff --git a/website/src/routes/(docs)/preact/guides/menu.md b/website/src/routes/(docs)/preact/guides/menu.md index b359313d..77868632 100644 --- a/website/src/routes/(docs)/preact/guides/menu.md +++ b/website/src/routes/(docs)/preact/guides/menu.md @@ -20,6 +20,7 @@ - [Special inputs](/preact/guides/special-inputs/) - [Controlled fields](/preact/guides/controlled-fields/) - [Nested fields](/preact/guides/nested-fields/) +- [Dirty fields](/preact/guides/dirty-fields/) - [Field arrays](/preact/guides/field-arrays/) - [TypeScript](/preact/guides/typescript/) - [Architecture](/preact/guides/architecture/) diff --git a/website/src/routes/(docs)/qwik/api/menu.md b/website/src/routes/(docs)/qwik/api/menu.md index 74ae2323..d47e835f 100644 --- a/website/src/routes/(docs)/qwik/api/menu.md +++ b/website/src/routes/(docs)/qwik/api/menu.md @@ -16,11 +16,14 @@ - [focus](/methods/api/focus/) - [getAllErrors](/methods/api/getAllErrors/) +- [getDirtyInput](/methods/api/getDirtyInput/) +- [getDirtyPaths](/methods/api/getDirtyPaths/) - [getErrors](/methods/api/getErrors/) - [getInput](/methods/api/getInput/) - [handleSubmit](/methods/api/handleSubmit/) - [insert](/methods/api/insert/) - [move](/methods/api/move/) +- [pickDirty](/methods/api/pickDirty/) - [remove](/methods/api/remove/) - [replace](/methods/api/replace/) - [reset](/methods/api/reset/) @@ -41,14 +44,19 @@ - [FormConfig](/qwik/api/FormConfig/) - [FormSchema](/core/api/FormSchema/) - [FormStore](/qwik/api/FormStore/) +- [GetFieldDirtyInputConfig](/methods/api/GetFieldDirtyInputConfig/) +- [GetFieldDirtyPathsConfig](/methods/api/GetFieldDirtyPathsConfig/) - [GetFieldErrorsConfig](/methods/api/GetFieldErrorsConfig/) - [GetFieldInputConfig](/methods/api/GetFieldInputConfig/) +- [GetFormDirtyInputConfig](/methods/api/GetFormDirtyInputConfig/) +- [GetFormDirtyPathsConfig](/methods/api/GetFormDirtyPathsConfig/) - [GetFormErrorsConfig](/methods/api/GetFormErrorsConfig/) - [GetFormInputConfig](/methods/api/GetFormInputConfig/) - [InsertConfig](/methods/api/InsertConfig/) - [PartialValues](/core/api/PartialValues/) - [Path](/core/api/Path/) - [PathKey](/core/api/PathKey/) +- [PickDirtyConfig](/methods/api/PickDirtyConfig/) - [MoveConfig](/methods/api/MoveConfig/) - [RemoveConfig](/methods/api/RemoveConfig/) - [ReplaceConfig](/methods/api/ReplaceConfig/) diff --git a/website/src/routes/(docs)/qwik/guides/(advanced-guides)/dirty-fields/index.mdx b/website/src/routes/(docs)/qwik/guides/(advanced-guides)/dirty-fields/index.mdx new file mode 100644 index 00000000..8ee4616d --- /dev/null +++ b/website/src/routes/(docs)/qwik/guides/(advanced-guides)/dirty-fields/index.mdx @@ -0,0 +1,182 @@ +--- +title: Dirty fields +description: >- + Learn how to work with dirty form state in Formisch. This guide covers the + three dirty-extraction methods, when to reach for each, and why pickDirty + exists alongside getDirtyInput. +contributors: + - fabian-hiller +--- + +import { Link } from '~/components'; + +# Dirty fields + +When a user edits a form, Formisch tracks which fields have changed since they were initialized. The library exposes three methods for working with that dirty state: `getDirtyInput`, `getDirtyPaths`, and `pickDirty`. This guide explains what each one is for, when to reach for it, and why `pickDirty` exists alongside the others. + +## What "dirty" means + +A field is dirty when its current input differs from its start input. The form starts clean. As the user types, only the fields they touch flip to dirty. Resetting a field (or resetting the form with a new `initialInput`) clears the dirty flag. + +> The dirty flag is per-field. Editing a deeply nested value does not flip its ancestors' flags. The dirty-extraction methods walk the tree internally to find dirty descendants, which is why all three methods do the same kind of work under the hood. + +## The three methods + +### `getDirtyInput` + +Returns the dirty subtree of the form's input. Use it when you want to ship the values the user typed — typically for a PATCH endpoint that accepts the same shape as your form input. + +```tsx +import { + Form, + getDirtyInput, + type SubmitHandler, + useForm$, +} from '@formisch/qwik'; +import * as v from 'valibot'; + +const UserSchema = v.object({ + name: v.pipe(v.string(), v.nonEmpty()), + email: v.pipe(v.string(), v.email()), +}); + +export default function EditProfile({ user }) { + const profileForm = useForm$({ + schema: UserSchema, + initialInput: user, + }); + + const onSubmit: SubmitHandler = async () => { + const dirty = getDirtyInput(profileForm); + if (dirty) { + await api.patchUser(user.id, dirty); + } + }; + + return ( +
+ {/* fields */} +
+ ); +} +``` + +If only `email` was edited, `dirty` is `{ email: 'new@example.com' }`. If nothing was edited, it is `undefined`. + +Because dirty state is bound to the form input, `getDirtyInput` always returns the raw user input. Schema transformations such as `v.trim()` or `v.toNumber()` are not applied to the returned values. When you need the validated and transformed output instead, reach for `pickDirty` (see below). + +### `getDirtyPaths` + +Returns a list of paths to dirty fields. Use it for logging, telemetry, or driving a custom walker over the form's dirty state. + +```tsx +import { getDirtyPaths } from '@formisch/qwik'; + +const dirtyPaths = getDirtyPaths(profileForm); +// e.g. [['email'], ['user', 'name']] +``` + +Arrays are atomic: only the array's own path is returned, never the paths of individual items. + +### `pickDirty` + +Filters a user-supplied value down to its dirty parts using the form's dirty mask. Use it when you have a separately-derived value — most commonly the validated and transformed output from a submit handler — and you want only its dirty parts. + +```tsx +import { pickDirty, type SubmitHandler } from '@formisch/qwik'; + +const onSubmit: SubmitHandler = async (output) => { + const dirty = pickDirty(profileForm, { from: output }); + if (dirty) { + await api.update(dirty); + } +}; +``` + +## Why `pickDirty` exists + +Dirty state is tracked against the form **input** — the raw values the user typed. But Valibot schemas can transform that input into a different output shape before it reaches your submit handler: + +```tsx +import * as v from 'valibot'; + +const Schema = v.object({ + name: v.pipe(v.string(), v.trim()), + age: v.pipe(v.string(), v.toNumber()), +}); +``` + +After validation, `output.name` has whitespace trimmed and `output.age` is a number. `getDirtyInput` would give you the raw strings, because the dirty state is bound to the form input — not to the validated output. + +`pickDirty(form, { from: output })` is the bridge: it walks the form's dirty tree as a structural mask and reads the corresponding values from the output you supply. The result preserves the transformed types. + +```tsx +import { pickDirty, type SubmitHandler } from '@formisch/qwik'; + +const onSubmit: SubmitHandler = async (output) => { + // output.age is a number — pickDirty preserves that. + const dirty = pickDirty(form, { from: output }); + if (dirty) { + await api.update(dirty); + } +}; +``` + +If the schema reshapes the output entirely — for example, by combining several input fields into a single output value — `pickDirty` returns `undefined` because the shape no longer aligns with the form's input shape. + +## Atomic arrays + +All three methods treat arrays as atomic. When any descendant of an array is dirty, the full array is returned (or its own path, for `getDirtyPaths`). The methods never produce sparse arrays. + +Sparse arrays don't round-trip safely. Serializers compact them, `JSON.stringify` writes `null` for holes, and indices lose positional meaning. Returning the full current array preserves order and lets the server treat the array as a complete replacement. + +This differs from objects, where clean keys are omitted. Objects model keyed dictionaries; arrays model ordered sequences. + +## Common patterns + +The snippets below assume `form` is your form store (the result of `useForm$`). + +### Skip submission when nothing changed + +The form's `isDirty` flag is the cheapest way to ask "is anything dirty?" — it short-circuits on the first dirty field it finds and doesn't allocate. Use it when all you need is the yes/no answer. If you actually consume the output of `getDirtyInput`, `getDirtyPaths`, or `pickDirty`, call that method directly — its return value already signals "nothing dirty", so checking `form.isDirty` first would just walk the tree twice. + +```tsx +import type { SubmitHandler } from '@formisch/qwik'; + +const onSubmit: SubmitHandler = async (output) => { + if (!form.isDirty) { + return; + } + await api.update(output); +}; +``` + +### Send only the dirty raw input + +```tsx +import { getDirtyInput } from '@formisch/qwik'; + +const dirty = getDirtyInput(form); +if (dirty) { + await api.patch(dirty); +} +``` + +### Send only the dirty validated output + +```tsx +import { pickDirty, type SubmitHandler } from '@formisch/qwik'; + +const onSubmit: SubmitHandler = async (output) => { + const dirty = pickDirty(form, { from: output }); + if (dirty) { + await api.update(dirty); + } +}; +``` + +## Performance + +All three methods walk the form's field tree and call `getFieldBool` recursively to skip clean subtrees. Cost is effectively linear in field count for typical balanced forms (shallow and wide) and degrades toward `O(N²)` for deeply nested trees with few siblings at each level. + +Call these methods from submit or blur handlers — not from tight reactive loops on every keystroke. For very large or deeply nested forms (thousands of fields), profile before binding them to high-frequency events. diff --git a/website/src/routes/(docs)/qwik/guides/(main-concepts)/form-methods/index.mdx b/website/src/routes/(docs)/qwik/guides/(main-concepts)/form-methods/index.mdx index 7e3ca04e..4868dbc5 100644 --- a/website/src/routes/(docs)/qwik/guides/(main-concepts)/form-methods/index.mdx +++ b/website/src/routes/(docs)/qwik/guides/(main-concepts)/form-methods/index.mdx @@ -27,6 +27,19 @@ To retrieve values from your form, you can use: Formisch uses Qwik's signals internally which means that reading values with these methods is reactive. When the form state changes, any component or computation that uses these methods will automatically update to reflect the new state. +## Dirty state + +To work with the dirty state of your form, Formisch provides three methods: + +- `getDirtyInput`: Get the dirty + parts of the form input +- `getDirtyPaths`: Get the paths + of dirty fields +- `pickDirty`: Filter an + externally-supplied value down to its dirty parts using the form's dirty mask + +See the dirty fields guide for when to reach for each. + ## Setting values To manually update form values or errors, use: diff --git a/website/src/routes/(docs)/qwik/guides/menu.md b/website/src/routes/(docs)/qwik/guides/menu.md index 7e593560..1dfe9768 100644 --- a/website/src/routes/(docs)/qwik/guides/menu.md +++ b/website/src/routes/(docs)/qwik/guides/menu.md @@ -20,6 +20,7 @@ - [Special inputs](/qwik/guides/special-inputs/) - [Controlled fields](/qwik/guides/controlled-fields/) - [Nested fields](/qwik/guides/nested-fields/) +- [Dirty fields](/qwik/guides/dirty-fields/) - [Field arrays](/qwik/guides/field-arrays/) - [TypeScript](/qwik/guides/typescript/) - [Architecture](/qwik/guides/architecture/) diff --git a/website/src/routes/(docs)/react/api/menu.md b/website/src/routes/(docs)/react/api/menu.md index 6641f6fb..e1811408 100644 --- a/website/src/routes/(docs)/react/api/menu.md +++ b/website/src/routes/(docs)/react/api/menu.md @@ -16,11 +16,14 @@ - [focus](/methods/api/focus/) - [getAllErrors](/methods/api/getAllErrors/) +- [getDirtyInput](/methods/api/getDirtyInput/) +- [getDirtyPaths](/methods/api/getDirtyPaths/) - [getErrors](/methods/api/getErrors/) - [getInput](/methods/api/getInput/) - [handleSubmit](/methods/api/handleSubmit/) - [insert](/methods/api/insert/) - [move](/methods/api/move/) +- [pickDirty](/methods/api/pickDirty/) - [remove](/methods/api/remove/) - [replace](/methods/api/replace/) - [reset](/methods/api/reset/) @@ -41,14 +44,19 @@ - [FormConfig](/react/api/FormConfig/) - [FormSchema](/core/api/FormSchema/) - [FormStore](/react/api/FormStore/) +- [GetFieldDirtyInputConfig](/methods/api/GetFieldDirtyInputConfig/) +- [GetFieldDirtyPathsConfig](/methods/api/GetFieldDirtyPathsConfig/) - [GetFieldErrorsConfig](/methods/api/GetFieldErrorsConfig/) - [GetFieldInputConfig](/methods/api/GetFieldInputConfig/) +- [GetFormDirtyInputConfig](/methods/api/GetFormDirtyInputConfig/) +- [GetFormDirtyPathsConfig](/methods/api/GetFormDirtyPathsConfig/) - [GetFormErrorsConfig](/methods/api/GetFormErrorsConfig/) - [GetFormInputConfig](/methods/api/GetFormInputConfig/) - [InsertConfig](/methods/api/InsertConfig/) - [PartialValues](/core/api/PartialValues/) - [Path](/core/api/Path/) - [PathKey](/core/api/PathKey/) +- [PickDirtyConfig](/methods/api/PickDirtyConfig/) - [MoveConfig](/methods/api/MoveConfig/) - [RemoveConfig](/methods/api/RemoveConfig/) - [ReplaceConfig](/methods/api/ReplaceConfig/) diff --git a/website/src/routes/(docs)/react/guides/(advanced-guides)/dirty-fields/index.mdx b/website/src/routes/(docs)/react/guides/(advanced-guides)/dirty-fields/index.mdx new file mode 100644 index 00000000..2093eac4 --- /dev/null +++ b/website/src/routes/(docs)/react/guides/(advanced-guides)/dirty-fields/index.mdx @@ -0,0 +1,182 @@ +--- +title: Dirty fields +description: >- + Learn how to work with dirty form state in Formisch. This guide covers the + three dirty-extraction methods, when to reach for each, and why pickDirty + exists alongside getDirtyInput. +contributors: + - fabian-hiller +--- + +import { Link } from '~/components'; + +# Dirty fields + +When a user edits a form, Formisch tracks which fields have changed since they were initialized. The library exposes three methods for working with that dirty state: `getDirtyInput`, `getDirtyPaths`, and `pickDirty`. This guide explains what each one is for, when to reach for it, and why `pickDirty` exists alongside the others. + +## What "dirty" means + +A field is dirty when its current input differs from its start input. The form starts clean. As the user types, only the fields they touch flip to dirty. Resetting a field (or resetting the form with a new `initialInput`) clears the dirty flag. + +> The dirty flag is per-field. Editing a deeply nested value does not flip its ancestors' flags. The dirty-extraction methods walk the tree internally to find dirty descendants, which is why all three methods do the same kind of work under the hood. + +## The three methods + +### `getDirtyInput` + +Returns the dirty subtree of the form's input. Use it when you want to ship the values the user typed — typically for a PATCH endpoint that accepts the same shape as your form input. + +```tsx +import { + Form, + getDirtyInput, + type SubmitHandler, + useForm, +} from '@formisch/react'; +import * as v from 'valibot'; + +const UserSchema = v.object({ + name: v.pipe(v.string(), v.nonEmpty()), + email: v.pipe(v.string(), v.email()), +}); + +export default function EditProfile({ user }) { + const profileForm = useForm({ + schema: UserSchema, + initialInput: user, + }); + + const onSubmit: SubmitHandler = async () => { + const dirty = getDirtyInput(profileForm); + if (dirty) { + await api.patchUser(user.id, dirty); + } + }; + + return ( +
+ {/* fields */} +
+ ); +} +``` + +If only `email` was edited, `dirty` is `{ email: 'new@example.com' }`. If nothing was edited, it is `undefined`. + +Because dirty state is bound to the form input, `getDirtyInput` always returns the raw user input. Schema transformations such as `v.trim()` or `v.toNumber()` are not applied to the returned values. When you need the validated and transformed output instead, reach for `pickDirty` (see below). + +### `getDirtyPaths` + +Returns a list of paths to dirty fields. Use it for logging, telemetry, or driving a custom walker over the form's dirty state. + +```tsx +import { getDirtyPaths } from '@formisch/react'; + +const dirtyPaths = getDirtyPaths(profileForm); +// e.g. [['email'], ['user', 'name']] +``` + +Arrays are atomic: only the array's own path is returned, never the paths of individual items. + +### `pickDirty` + +Filters a user-supplied value down to its dirty parts using the form's dirty mask. Use it when you have a separately-derived value — most commonly the validated and transformed output from a submit handler — and you want only its dirty parts. + +```tsx +import { pickDirty, type SubmitHandler } from '@formisch/react'; + +const onSubmit: SubmitHandler = async (output) => { + const dirty = pickDirty(profileForm, { from: output }); + if (dirty) { + await api.update(dirty); + } +}; +``` + +## Why `pickDirty` exists + +Dirty state is tracked against the form **input** — the raw values the user typed. But Valibot schemas can transform that input into a different output shape before it reaches your submit handler: + +```tsx +import * as v from 'valibot'; + +const Schema = v.object({ + name: v.pipe(v.string(), v.trim()), + age: v.pipe(v.string(), v.toNumber()), +}); +``` + +After validation, `output.name` has whitespace trimmed and `output.age` is a number. `getDirtyInput` would give you the raw strings, because the dirty state is bound to the form input — not to the validated output. + +`pickDirty(form, { from: output })` is the bridge: it walks the form's dirty tree as a structural mask and reads the corresponding values from the output you supply. The result preserves the transformed types. + +```tsx +import { pickDirty, type SubmitHandler } from '@formisch/react'; + +const onSubmit: SubmitHandler = async (output) => { + // output.age is a number — pickDirty preserves that. + const dirty = pickDirty(form, { from: output }); + if (dirty) { + await api.update(dirty); + } +}; +``` + +If the schema reshapes the output entirely — for example, by combining several input fields into a single output value — `pickDirty` returns `undefined` because the shape no longer aligns with the form's input shape. + +## Atomic arrays + +All three methods treat arrays as atomic. When any descendant of an array is dirty, the full array is returned (or its own path, for `getDirtyPaths`). The methods never produce sparse arrays. + +Sparse arrays don't round-trip safely. Serializers compact them, `JSON.stringify` writes `null` for holes, and indices lose positional meaning. Returning the full current array preserves order and lets the server treat the array as a complete replacement. + +This differs from objects, where clean keys are omitted. Objects model keyed dictionaries; arrays model ordered sequences. + +## Common patterns + +The snippets below assume `form` is your form store (the result of `useForm`). + +### Skip submission when nothing changed + +The form's `isDirty` flag is the cheapest way to ask "is anything dirty?" — it short-circuits on the first dirty field it finds and doesn't allocate. Use it when all you need is the yes/no answer. If you actually consume the output of `getDirtyInput`, `getDirtyPaths`, or `pickDirty`, call that method directly — its return value already signals "nothing dirty", so checking `form.isDirty` first would just walk the tree twice. + +```tsx +import type { SubmitHandler } from '@formisch/react'; + +const onSubmit: SubmitHandler = async (output) => { + if (!form.isDirty) { + return; + } + await api.update(output); +}; +``` + +### Send only the dirty raw input + +```tsx +import { getDirtyInput } from '@formisch/react'; + +const dirty = getDirtyInput(form); +if (dirty) { + await api.patch(dirty); +} +``` + +### Send only the dirty validated output + +```tsx +import { pickDirty, type SubmitHandler } from '@formisch/react'; + +const onSubmit: SubmitHandler = async (output) => { + const dirty = pickDirty(form, { from: output }); + if (dirty) { + await api.update(dirty); + } +}; +``` + +## Performance + +All three methods walk the form's field tree and call `getFieldBool` recursively to skip clean subtrees. Cost is effectively linear in field count for typical balanced forms (shallow and wide) and degrades toward `O(N²)` for deeply nested trees with few siblings at each level. + +Call these methods from submit or blur handlers — not from tight reactive loops on every keystroke. For very large or deeply nested forms (thousands of fields), profile before binding them to high-frequency events. diff --git a/website/src/routes/(docs)/react/guides/(main-concepts)/form-methods/index.mdx b/website/src/routes/(docs)/react/guides/(main-concepts)/form-methods/index.mdx index 1e0d1883..7b7cc369 100644 --- a/website/src/routes/(docs)/react/guides/(main-concepts)/form-methods/index.mdx +++ b/website/src/routes/(docs)/react/guides/(main-concepts)/form-methods/index.mdx @@ -27,6 +27,19 @@ To retrieve values from your form, you can use: Formisch plugs into React's render cycle, so reading values with these methods inside components stays reactive. When the form state changes, components that rely on these values automatically re-render without manual subscriptions. +## Dirty state + +To work with the dirty state of your form, Formisch provides three methods: + +- `getDirtyInput`: Get the dirty + parts of the form input +- `getDirtyPaths`: Get the paths + of dirty fields +- `pickDirty`: Filter an + externally-supplied value down to its dirty parts using the form's dirty mask + +See the dirty fields guide for when to reach for each. + ## Setting values To manually update form values or errors, use: diff --git a/website/src/routes/(docs)/react/guides/menu.md b/website/src/routes/(docs)/react/guides/menu.md index f8e10d9c..2e70687c 100644 --- a/website/src/routes/(docs)/react/guides/menu.md +++ b/website/src/routes/(docs)/react/guides/menu.md @@ -21,6 +21,7 @@ - [Special inputs](/react/guides/special-inputs/) - [Controlled fields](/react/guides/controlled-fields/) - [Nested fields](/react/guides/nested-fields/) +- [Dirty fields](/react/guides/dirty-fields/) - [Field arrays](/react/guides/field-arrays/) - [TypeScript](/react/guides/typescript/) - [Architecture](/react/guides/architecture/) diff --git a/website/src/routes/(docs)/solid/api/menu.md b/website/src/routes/(docs)/solid/api/menu.md index d14828e5..daaa9365 100644 --- a/website/src/routes/(docs)/solid/api/menu.md +++ b/website/src/routes/(docs)/solid/api/menu.md @@ -16,11 +16,14 @@ - [focus](/methods/api/focus/) - [getAllErrors](/methods/api/getAllErrors/) +- [getDirtyInput](/methods/api/getDirtyInput/) +- [getDirtyPaths](/methods/api/getDirtyPaths/) - [getErrors](/methods/api/getErrors/) - [getInput](/methods/api/getInput/) - [handleSubmit](/methods/api/handleSubmit/) - [insert](/methods/api/insert/) - [move](/methods/api/move/) +- [pickDirty](/methods/api/pickDirty/) - [remove](/methods/api/remove/) - [replace](/methods/api/replace/) - [reset](/methods/api/reset/) @@ -41,8 +44,12 @@ - [FormConfig](/solid/api/FormConfig/) - [FormSchema](/core/api/FormSchema/) - [FormStore](/solid/api/FormStore/) +- [GetFieldDirtyInputConfig](/methods/api/GetFieldDirtyInputConfig/) +- [GetFieldDirtyPathsConfig](/methods/api/GetFieldDirtyPathsConfig/) - [GetFieldErrorsConfig](/methods/api/GetFieldErrorsConfig/) - [GetFieldInputConfig](/methods/api/GetFieldInputConfig/) +- [GetFormDirtyInputConfig](/methods/api/GetFormDirtyInputConfig/) +- [GetFormDirtyPathsConfig](/methods/api/GetFormDirtyPathsConfig/) - [GetFormErrorsConfig](/methods/api/GetFormErrorsConfig/) - [GetFormInputConfig](/methods/api/GetFormInputConfig/) - [InsertConfig](/methods/api/InsertConfig/) @@ -50,6 +57,7 @@ - [PartialValues](/core/api/PartialValues/) - [Path](/core/api/Path/) - [PathKey](/core/api/PathKey/) +- [PickDirtyConfig](/methods/api/PickDirtyConfig/) - [MoveConfig](/methods/api/MoveConfig/) - [RemoveConfig](/methods/api/RemoveConfig/) - [ReplaceConfig](/methods/api/ReplaceConfig/) diff --git a/website/src/routes/(docs)/solid/guides/(advanced-guides)/dirty-fields/index.mdx b/website/src/routes/(docs)/solid/guides/(advanced-guides)/dirty-fields/index.mdx new file mode 100644 index 00000000..c410e932 --- /dev/null +++ b/website/src/routes/(docs)/solid/guides/(advanced-guides)/dirty-fields/index.mdx @@ -0,0 +1,182 @@ +--- +title: Dirty fields +description: >- + Learn how to work with dirty form state in Formisch. This guide covers the + three dirty-extraction methods, when to reach for each, and why pickDirty + exists alongside getDirtyInput. +contributors: + - fabian-hiller +--- + +import { Link } from '~/components'; + +# Dirty fields + +When a user edits a form, Formisch tracks which fields have changed since they were initialized. The library exposes three methods for working with that dirty state: `getDirtyInput`, `getDirtyPaths`, and `pickDirty`. This guide explains what each one is for, when to reach for it, and why `pickDirty` exists alongside the others. + +## What "dirty" means + +A field is dirty when its current input differs from its start input. The form starts clean. As the user types, only the fields they touch flip to dirty. Resetting a field (or resetting the form with a new `initialInput`) clears the dirty flag. + +> The dirty flag is per-field. Editing a deeply nested value does not flip its ancestors' flags. The dirty-extraction methods walk the tree internally to find dirty descendants, which is why all three methods do the same kind of work under the hood. + +## The three methods + +### `getDirtyInput` + +Returns the dirty subtree of the form's input. Use it when you want to ship the values the user typed — typically for a PATCH endpoint that accepts the same shape as your form input. + +```tsx +import { + createForm, + Form, + getDirtyInput, + type SubmitHandler, +} from '@formisch/solid'; +import * as v from 'valibot'; + +const UserSchema = v.object({ + name: v.pipe(v.string(), v.nonEmpty()), + email: v.pipe(v.string(), v.email()), +}); + +export default function EditProfile(props) { + const profileForm = createForm({ + schema: UserSchema, + initialInput: props.user, + }); + + const onSubmit: SubmitHandler = async () => { + const dirty = getDirtyInput(profileForm); + if (dirty) { + await api.patchUser(props.user.id, dirty); + } + }; + + return ( +
+ {/* fields */} +
+ ); +} +``` + +If only `email` was edited, `dirty` is `{ email: 'new@example.com' }`. If nothing was edited, it is `undefined`. + +Because dirty state is bound to the form input, `getDirtyInput` always returns the raw user input. Schema transformations such as `v.trim()` or `v.toNumber()` are not applied to the returned values. When you need the validated and transformed output instead, reach for `pickDirty` (see below). + +### `getDirtyPaths` + +Returns a list of paths to dirty fields. Use it for logging, telemetry, or driving a custom walker over the form's dirty state. + +```tsx +import { getDirtyPaths } from '@formisch/solid'; + +const dirtyPaths = getDirtyPaths(profileForm); +// e.g. [['email'], ['user', 'name']] +``` + +Arrays are atomic: only the array's own path is returned, never the paths of individual items. + +### `pickDirty` + +Filters a user-supplied value down to its dirty parts using the form's dirty mask. Use it when you have a separately-derived value — most commonly the validated and transformed output from a submit handler — and you want only its dirty parts. + +```tsx +import { pickDirty, type SubmitHandler } from '@formisch/solid'; + +const onSubmit: SubmitHandler = async (output) => { + const dirty = pickDirty(profileForm, { from: output }); + if (dirty) { + await api.update(dirty); + } +}; +``` + +## Why `pickDirty` exists + +Dirty state is tracked against the form **input** — the raw values the user typed. But Valibot schemas can transform that input into a different output shape before it reaches your submit handler: + +```tsx +import * as v from 'valibot'; + +const Schema = v.object({ + name: v.pipe(v.string(), v.trim()), + age: v.pipe(v.string(), v.toNumber()), +}); +``` + +After validation, `output.name` has whitespace trimmed and `output.age` is a number. `getDirtyInput` would give you the raw strings, because the dirty state is bound to the form input — not to the validated output. + +`pickDirty(form, { from: output })` is the bridge: it walks the form's dirty tree as a structural mask and reads the corresponding values from the output you supply. The result preserves the transformed types. + +```tsx +import { pickDirty, type SubmitHandler } from '@formisch/solid'; + +const onSubmit: SubmitHandler = async (output) => { + // output.age is a number — pickDirty preserves that. + const dirty = pickDirty(form, { from: output }); + if (dirty) { + await api.update(dirty); + } +}; +``` + +If the schema reshapes the output entirely — for example, by combining several input fields into a single output value — `pickDirty` returns `undefined` because the shape no longer aligns with the form's input shape. + +## Atomic arrays + +All three methods treat arrays as atomic. When any descendant of an array is dirty, the full array is returned (or its own path, for `getDirtyPaths`). The methods never produce sparse arrays. + +Sparse arrays don't round-trip safely. Serializers compact them, `JSON.stringify` writes `null` for holes, and indices lose positional meaning. Returning the full current array preserves order and lets the server treat the array as a complete replacement. + +This differs from objects, where clean keys are omitted. Objects model keyed dictionaries; arrays model ordered sequences. + +## Common patterns + +The snippets below assume `form` is your form store (the result of `createForm`). + +### Skip submission when nothing changed + +The form's `isDirty` flag is the cheapest way to ask "is anything dirty?" — it short-circuits on the first dirty field it finds and doesn't allocate. Use it when all you need is the yes/no answer. If you actually consume the output of `getDirtyInput`, `getDirtyPaths`, or `pickDirty`, call that method directly — its return value already signals "nothing dirty", so checking `form.isDirty` first would just walk the tree twice. + +```tsx +import type { SubmitHandler } from '@formisch/solid'; + +const onSubmit: SubmitHandler = async (output) => { + if (!form.isDirty) { + return; + } + await api.update(output); +}; +``` + +### Send only the dirty raw input + +```tsx +import { getDirtyInput } from '@formisch/solid'; + +const dirty = getDirtyInput(form); +if (dirty) { + await api.patch(dirty); +} +``` + +### Send only the dirty validated output + +```tsx +import { pickDirty, type SubmitHandler } from '@formisch/solid'; + +const onSubmit: SubmitHandler = async (output) => { + const dirty = pickDirty(form, { from: output }); + if (dirty) { + await api.update(dirty); + } +}; +``` + +## Performance + +All three methods walk the form's field tree and call `getFieldBool` recursively to skip clean subtrees. Cost is effectively linear in field count for typical balanced forms (shallow and wide) and degrades toward `O(N²)` for deeply nested trees with few siblings at each level. + +Call these methods from submit or blur handlers — not from tight reactive loops on every keystroke. For very large or deeply nested forms (thousands of fields), profile before binding them to high-frequency events. diff --git a/website/src/routes/(docs)/solid/guides/(main-concepts)/form-methods/index.mdx b/website/src/routes/(docs)/solid/guides/(main-concepts)/form-methods/index.mdx index fd7926f1..9f6ffaad 100644 --- a/website/src/routes/(docs)/solid/guides/(main-concepts)/form-methods/index.mdx +++ b/website/src/routes/(docs)/solid/guides/(main-concepts)/form-methods/index.mdx @@ -27,6 +27,19 @@ To retrieve values from your form, you can use: Formisch uses SolidJS's signals internally which means that reading values with these methods is reactive. When the form state changes, any component or computation that uses these methods will automatically update to reflect the new state. +## Dirty state + +To work with the dirty state of your form, Formisch provides three methods: + +- `getDirtyInput`: Get the dirty + parts of the form input +- `getDirtyPaths`: Get the paths + of dirty fields +- `pickDirty`: Filter an + externally-supplied value down to its dirty parts using the form's dirty mask + +See the dirty fields guide for when to reach for each. + ## Setting values To manually update form values or errors, use: diff --git a/website/src/routes/(docs)/solid/guides/menu.md b/website/src/routes/(docs)/solid/guides/menu.md index cd5576c7..4cb48ea0 100644 --- a/website/src/routes/(docs)/solid/guides/menu.md +++ b/website/src/routes/(docs)/solid/guides/menu.md @@ -21,6 +21,7 @@ - [Special inputs](/solid/guides/special-inputs/) - [Controlled fields](/solid/guides/controlled-fields/) - [Nested fields](/solid/guides/nested-fields/) +- [Dirty fields](/solid/guides/dirty-fields/) - [Field arrays](/solid/guides/field-arrays/) - [TypeScript](/solid/guides/typescript/) - [Architecture](/solid/guides/architecture/) diff --git a/website/src/routes/(docs)/svelte/api/menu.md b/website/src/routes/(docs)/svelte/api/menu.md index 1759af7e..a24769a4 100644 --- a/website/src/routes/(docs)/svelte/api/menu.md +++ b/website/src/routes/(docs)/svelte/api/menu.md @@ -16,11 +16,14 @@ - [focus](/methods/api/focus/) - [getAllErrors](/methods/api/getAllErrors/) +- [getDirtyInput](/methods/api/getDirtyInput/) +- [getDirtyPaths](/methods/api/getDirtyPaths/) - [getErrors](/methods/api/getErrors/) - [getInput](/methods/api/getInput/) - [handleSubmit](/methods/api/handleSubmit/) - [insert](/methods/api/insert/) - [move](/methods/api/move/) +- [pickDirty](/methods/api/pickDirty/) - [remove](/methods/api/remove/) - [replace](/methods/api/replace/) - [reset](/methods/api/reset/) @@ -41,8 +44,12 @@ - [FormConfig](/svelte/api/FormConfig/) - [FormSchema](/core/api/FormSchema/) - [FormStore](/svelte/api/FormStore/) +- [GetFieldDirtyInputConfig](/methods/api/GetFieldDirtyInputConfig/) +- [GetFieldDirtyPathsConfig](/methods/api/GetFieldDirtyPathsConfig/) - [GetFieldErrorsConfig](/methods/api/GetFieldErrorsConfig/) - [GetFieldInputConfig](/methods/api/GetFieldInputConfig/) +- [GetFormDirtyInputConfig](/methods/api/GetFormDirtyInputConfig/) +- [GetFormDirtyPathsConfig](/methods/api/GetFormDirtyPathsConfig/) - [GetFormErrorsConfig](/methods/api/GetFormErrorsConfig/) - [GetFormInputConfig](/methods/api/GetFormInputConfig/) - [InsertConfig](/methods/api/InsertConfig/) @@ -50,6 +57,7 @@ - [PartialValues](/core/api/PartialValues/) - [Path](/core/api/Path/) - [PathKey](/core/api/PathKey/) +- [PickDirtyConfig](/methods/api/PickDirtyConfig/) - [MoveConfig](/methods/api/MoveConfig/) - [RemoveConfig](/methods/api/RemoveConfig/) - [ReplaceConfig](/methods/api/ReplaceConfig/) diff --git a/website/src/routes/(docs)/svelte/guides/(advanced-guides)/dirty-fields/index.mdx b/website/src/routes/(docs)/svelte/guides/(advanced-guides)/dirty-fields/index.mdx new file mode 100644 index 00000000..73e178fd --- /dev/null +++ b/website/src/routes/(docs)/svelte/guides/(advanced-guides)/dirty-fields/index.mdx @@ -0,0 +1,175 @@ +--- +title: Dirty fields +description: >- + Learn how to work with dirty form state in Formisch. This guide covers the + three dirty-extraction methods, when to reach for each, and why pickDirty + exists alongside getDirtyInput. +contributors: + - fabian-hiller +--- + +import { Link } from '~/components'; + +# Dirty fields + +When a user edits a form, Formisch tracks which fields have changed since they were initialized. The library exposes three methods for working with that dirty state: `getDirtyInput`, `getDirtyPaths`, and `pickDirty`. This guide explains what each one is for, when to reach for it, and why `pickDirty` exists alongside the others. + +## What "dirty" means + +A field is dirty when its current input differs from its start input. The form starts clean. As the user types, only the fields they touch flip to dirty. Resetting a field (or resetting the form with a new `initialInput`) clears the dirty flag. + +> The dirty flag is per-field. Editing a deeply nested value does not flip its ancestors' flags. The dirty-extraction methods walk the tree internally to find dirty descendants, which is why all three methods do the same kind of work under the hood. + +## The three methods + +### `getDirtyInput` + +Returns the dirty subtree of the form's input. Use it when you want to ship the values the user typed — typically for a PATCH endpoint that accepts the same shape as your form input. + +```ts +import { + createForm, + getDirtyInput, + type SubmitHandler, +} from '@formisch/svelte'; +import * as v from 'valibot'; + +const UserSchema = v.object({ + name: v.pipe(v.string(), v.nonEmpty()), + email: v.pipe(v.string(), v.email()), +}); + +const { user } = $props(); + +const profileForm = createForm({ + schema: UserSchema, + initialInput: user, +}); + +const onSubmit: SubmitHandler = async () => { + const dirty = getDirtyInput(profileForm); + if (dirty) { + await api.patchUser(user.id, dirty); + } +}; +``` + +If only `email` was edited, `dirty` is `{ email: 'new@example.com' }`. If nothing was edited, it is `undefined`. + +Because dirty state is bound to the form input, `getDirtyInput` always returns the raw user input. Schema transformations such as `v.trim()` or `v.toNumber()` are not applied to the returned values. When you need the validated and transformed output instead, reach for `pickDirty` (see below). + +### `getDirtyPaths` + +Returns a list of paths to dirty fields. Use it for logging, telemetry, or driving a custom walker over the form's dirty state. + +```ts +import { getDirtyPaths } from '@formisch/svelte'; + +const dirtyPaths = getDirtyPaths(profileForm); +// e.g. [['email'], ['user', 'name']] +``` + +Arrays are atomic: only the array's own path is returned, never the paths of individual items. + +### `pickDirty` + +Filters a user-supplied value down to its dirty parts using the form's dirty mask. Use it when you have a separately-derived value — most commonly the validated and transformed output from a submit handler — and you want only its dirty parts. + +```ts +import { pickDirty, type SubmitHandler } from '@formisch/svelte'; + +const onSubmit: SubmitHandler = async (output) => { + const dirty = pickDirty(profileForm, { from: output }); + if (dirty) { + await api.update(dirty); + } +}; +``` + +## Why `pickDirty` exists + +Dirty state is tracked against the form **input** — the raw values the user typed. But Valibot schemas can transform that input into a different output shape before it reaches your submit handler: + +```ts +import * as v from 'valibot'; + +const Schema = v.object({ + name: v.pipe(v.string(), v.trim()), + age: v.pipe(v.string(), v.toNumber()), +}); +``` + +After validation, `output.name` has whitespace trimmed and `output.age` is a number. `getDirtyInput` would give you the raw strings, because the dirty state is bound to the form input — not to the validated output. + +`pickDirty(form, { from: output })` is the bridge: it walks the form's dirty tree as a structural mask and reads the corresponding values from the output you supply. The result preserves the transformed types. + +```ts +import { pickDirty, type SubmitHandler } from '@formisch/svelte'; + +const onSubmit: SubmitHandler = async (output) => { + // output.age is a number — pickDirty preserves that. + const dirty = pickDirty(form, { from: output }); + if (dirty) { + await api.update(dirty); + } +}; +``` + +If the schema reshapes the output entirely — for example, by combining several input fields into a single output value — `pickDirty` returns `undefined` because the shape no longer aligns with the form's input shape. + +## Atomic arrays + +All three methods treat arrays as atomic. When any descendant of an array is dirty, the full array is returned (or its own path, for `getDirtyPaths`). The methods never produce sparse arrays. + +Sparse arrays don't round-trip safely. Serializers compact them, `JSON.stringify` writes `null` for holes, and indices lose positional meaning. Returning the full current array preserves order and lets the server treat the array as a complete replacement. + +This differs from objects, where clean keys are omitted. Objects model keyed dictionaries; arrays model ordered sequences. + +## Common patterns + +The snippets below assume `form` is your form store (the result of `createForm`). + +### Skip submission when nothing changed + +The form's `isDirty` flag is the cheapest way to ask "is anything dirty?" — it short-circuits on the first dirty field it finds and doesn't allocate. Use it when all you need is the yes/no answer. If you actually consume the output of `getDirtyInput`, `getDirtyPaths`, or `pickDirty`, call that method directly — its return value already signals "nothing dirty", so checking `form.isDirty` first would just walk the tree twice. + +```ts +import type { SubmitHandler } from '@formisch/svelte'; + +const onSubmit: SubmitHandler = async (output) => { + if (!form.isDirty) { + return; + } + await api.update(output); +}; +``` + +### Send only the dirty raw input + +```ts +import { getDirtyInput } from '@formisch/svelte'; + +const dirty = getDirtyInput(form); +if (dirty) { + await api.patch(dirty); +} +``` + +### Send only the dirty validated output + +```ts +import { pickDirty, type SubmitHandler } from '@formisch/svelte'; + +const onSubmit: SubmitHandler = async (output) => { + const dirty = pickDirty(form, { from: output }); + if (dirty) { + await api.update(dirty); + } +}; +``` + +## Performance + +All three methods walk the form's field tree and call `getFieldBool` recursively to skip clean subtrees. Cost is effectively linear in field count for typical balanced forms (shallow and wide) and degrades toward `O(N²)` for deeply nested trees with few siblings at each level. + +Call these methods from submit or blur handlers — not from tight reactive loops on every keystroke. For very large or deeply nested forms (thousands of fields), profile before binding them to high-frequency events. diff --git a/website/src/routes/(docs)/svelte/guides/(main-concepts)/form-methods/index.mdx b/website/src/routes/(docs)/svelte/guides/(main-concepts)/form-methods/index.mdx index 3fada3b4..d4d21f74 100644 --- a/website/src/routes/(docs)/svelte/guides/(main-concepts)/form-methods/index.mdx +++ b/website/src/routes/(docs)/svelte/guides/(main-concepts)/form-methods/index.mdx @@ -27,6 +27,19 @@ To retrieve values from your form, you can use: Formisch uses reactive stores internally which means that reading values with these methods is reactive. When the form state changes, any component or subscription that uses these methods will automatically update to reflect the new state. +## Dirty state + +To work with the dirty state of your form, Formisch provides three methods: + +- `getDirtyInput`: Get the dirty + parts of the form input +- `getDirtyPaths`: Get the paths + of dirty fields +- `pickDirty`: Filter an + externally-supplied value down to its dirty parts using the form's dirty mask + +See the dirty fields guide for when to reach for each. + ## Setting values To manually update form values or errors, use: diff --git a/website/src/routes/(docs)/svelte/guides/menu.md b/website/src/routes/(docs)/svelte/guides/menu.md index 725b47b4..b22c3882 100644 --- a/website/src/routes/(docs)/svelte/guides/menu.md +++ b/website/src/routes/(docs)/svelte/guides/menu.md @@ -21,6 +21,7 @@ - [Special inputs](/svelte/guides/special-inputs/) - [Controlled fields](/svelte/guides/controlled-fields/) - [Nested fields](/svelte/guides/nested-fields/) +- [Dirty fields](/svelte/guides/dirty-fields/) - [Field arrays](/svelte/guides/field-arrays/) - [TypeScript](/svelte/guides/typescript/) - [Architecture](/svelte/guides/architecture/) diff --git a/website/src/routes/(docs)/vue/api/menu.md b/website/src/routes/(docs)/vue/api/menu.md index 2c82d34c..8e9bf8bf 100644 --- a/website/src/routes/(docs)/vue/api/menu.md +++ b/website/src/routes/(docs)/vue/api/menu.md @@ -16,11 +16,14 @@ - [focus](/methods/api/focus/) - [getAllErrors](/methods/api/getAllErrors/) +- [getDirtyInput](/methods/api/getDirtyInput/) +- [getDirtyPaths](/methods/api/getDirtyPaths/) - [getErrors](/methods/api/getErrors/) - [getInput](/methods/api/getInput/) - [handleSubmit](/methods/api/handleSubmit/) - [insert](/methods/api/insert/) - [move](/methods/api/move/) +- [pickDirty](/methods/api/pickDirty/) - [remove](/methods/api/remove/) - [replace](/methods/api/replace/) - [reset](/methods/api/reset/) @@ -41,14 +44,19 @@ - [FormConfig](/vue/api/FormConfig/) - [FormSchema](/core/api/FormSchema/) - [FormStore](/vue/api/FormStore/) +- [GetFieldDirtyInputConfig](/methods/api/GetFieldDirtyInputConfig/) +- [GetFieldDirtyPathsConfig](/methods/api/GetFieldDirtyPathsConfig/) - [GetFieldErrorsConfig](/methods/api/GetFieldErrorsConfig/) - [GetFieldInputConfig](/methods/api/GetFieldInputConfig/) +- [GetFormDirtyInputConfig](/methods/api/GetFormDirtyInputConfig/) +- [GetFormDirtyPathsConfig](/methods/api/GetFormDirtyPathsConfig/) - [GetFormErrorsConfig](/methods/api/GetFormErrorsConfig/) - [GetFormInputConfig](/methods/api/GetFormInputConfig/) - [InsertConfig](/methods/api/InsertConfig/) - [PartialValues](/core/api/PartialValues/) - [Path](/core/api/Path/) - [PathKey](/core/api/PathKey/) +- [PickDirtyConfig](/methods/api/PickDirtyConfig/) - [MoveConfig](/methods/api/MoveConfig/) - [RemoveConfig](/methods/api/RemoveConfig/) - [ReplaceConfig](/methods/api/ReplaceConfig/) diff --git a/website/src/routes/(docs)/vue/guides/(advanced-guides)/dirty-fields/index.mdx b/website/src/routes/(docs)/vue/guides/(advanced-guides)/dirty-fields/index.mdx new file mode 100644 index 00000000..10f8e00a --- /dev/null +++ b/website/src/routes/(docs)/vue/guides/(advanced-guides)/dirty-fields/index.mdx @@ -0,0 +1,171 @@ +--- +title: Dirty fields +description: >- + Learn how to work with dirty form state in Formisch. This guide covers the + three dirty-extraction methods, when to reach for each, and why pickDirty + exists alongside getDirtyInput. +contributors: + - fabian-hiller +--- + +import { Link } from '~/components'; + +# Dirty fields + +When a user edits a form, Formisch tracks which fields have changed since they were initialized. The library exposes three methods for working with that dirty state: `getDirtyInput`, `getDirtyPaths`, and `pickDirty`. This guide explains what each one is for, when to reach for it, and why `pickDirty` exists alongside the others. + +## What "dirty" means + +A field is dirty when its current input differs from its start input. The form starts clean. As the user types, only the fields they touch flip to dirty. Resetting a field (or resetting the form with a new `initialInput`) clears the dirty flag. + +> The dirty flag is per-field. Editing a deeply nested value does not flip its ancestors' flags. The dirty-extraction methods walk the tree internally to find dirty descendants, which is why all three methods do the same kind of work under the hood. + +## The three methods + +### `getDirtyInput` + +Returns the dirty subtree of the form's input. Use it when you want to ship the values the user typed — typically for a PATCH endpoint that accepts the same shape as your form input. + +```ts +import { getDirtyInput, type SubmitHandler, useForm } from '@formisch/vue'; +import * as v from 'valibot'; + +const UserSchema = v.object({ + name: v.pipe(v.string(), v.nonEmpty()), + email: v.pipe(v.string(), v.email()), +}); + +const props = defineProps<{ user: User }>(); + +const profileForm = useForm({ + schema: UserSchema, + initialInput: props.user, +}); + +const onSubmit: SubmitHandler = async () => { + const dirty = getDirtyInput(profileForm); + if (dirty) { + await api.patchUser(props.user.id, dirty); + } +}; +``` + +If only `email` was edited, `dirty` is `{ email: 'new@example.com' }`. If nothing was edited, it is `undefined`. + +Because dirty state is bound to the form input, `getDirtyInput` always returns the raw user input. Schema transformations such as `v.trim()` or `v.toNumber()` are not applied to the returned values. When you need the validated and transformed output instead, reach for `pickDirty` (see below). + +### `getDirtyPaths` + +Returns a list of paths to dirty fields. Use it for logging, telemetry, or driving a custom walker over the form's dirty state. + +```ts +import { getDirtyPaths } from '@formisch/vue'; + +const dirtyPaths = getDirtyPaths(profileForm); +// e.g. [['email'], ['user', 'name']] +``` + +Arrays are atomic: only the array's own path is returned, never the paths of individual items. + +### `pickDirty` + +Filters a user-supplied value down to its dirty parts using the form's dirty mask. Use it when you have a separately-derived value — most commonly the validated and transformed output from a submit handler — and you want only its dirty parts. + +```ts +import { pickDirty, type SubmitHandler } from '@formisch/vue'; + +const onSubmit: SubmitHandler = async (output) => { + const dirty = pickDirty(profileForm, { from: output }); + if (dirty) { + await api.update(dirty); + } +}; +``` + +## Why `pickDirty` exists + +Dirty state is tracked against the form **input** — the raw values the user typed. But Valibot schemas can transform that input into a different output shape before it reaches your submit handler: + +```ts +import * as v from 'valibot'; + +const Schema = v.object({ + name: v.pipe(v.string(), v.trim()), + age: v.pipe(v.string(), v.toNumber()), +}); +``` + +After validation, `output.name` has whitespace trimmed and `output.age` is a number. `getDirtyInput` would give you the raw strings, because the dirty state is bound to the form input — not to the validated output. + +`pickDirty(form, { from: output })` is the bridge: it walks the form's dirty tree as a structural mask and reads the corresponding values from the output you supply. The result preserves the transformed types. + +```ts +import { pickDirty, type SubmitHandler } from '@formisch/vue'; + +const onSubmit: SubmitHandler = async (output) => { + // output.age is a number — pickDirty preserves that. + const dirty = pickDirty(form, { from: output }); + if (dirty) { + await api.update(dirty); + } +}; +``` + +If the schema reshapes the output entirely — for example, by combining several input fields into a single output value — `pickDirty` returns `undefined` because the shape no longer aligns with the form's input shape. + +## Atomic arrays + +All three methods treat arrays as atomic. When any descendant of an array is dirty, the full array is returned (or its own path, for `getDirtyPaths`). The methods never produce sparse arrays. + +Sparse arrays don't round-trip safely. Serializers compact them, `JSON.stringify` writes `null` for holes, and indices lose positional meaning. Returning the full current array preserves order and lets the server treat the array as a complete replacement. + +This differs from objects, where clean keys are omitted. Objects model keyed dictionaries; arrays model ordered sequences. + +## Common patterns + +The snippets below assume `form` is your form store (the result of `useForm`). + +### Skip submission when nothing changed + +The form's `isDirty` flag is the cheapest way to ask "is anything dirty?" — it short-circuits on the first dirty field it finds and doesn't allocate. Use it when all you need is the yes/no answer. If you actually consume the output of `getDirtyInput`, `getDirtyPaths`, or `pickDirty`, call that method directly — its return value already signals "nothing dirty", so checking `form.isDirty` first would just walk the tree twice. + +```ts +import type { SubmitHandler } from '@formisch/vue'; + +const onSubmit: SubmitHandler = async (output) => { + if (!form.isDirty) { + return; + } + await api.update(output); +}; +``` + +### Send only the dirty raw input + +```ts +import { getDirtyInput } from '@formisch/vue'; + +const dirty = getDirtyInput(form); +if (dirty) { + await api.patch(dirty); +} +``` + +### Send only the dirty validated output + +```ts +import { pickDirty, type SubmitHandler } from '@formisch/vue'; + +const onSubmit: SubmitHandler = async (output) => { + const dirty = pickDirty(form, { from: output }); + if (dirty) { + await api.update(dirty); + } +}; +``` + +## Performance + +All three methods walk the form's field tree and call `getFieldBool` recursively to skip clean subtrees. Cost is effectively linear in field count for typical balanced forms (shallow and wide) and degrades toward `O(N²)` for deeply nested trees with few siblings at each level. + +Call these methods from submit or blur handlers — not from tight reactive loops on every keystroke. For very large or deeply nested forms (thousands of fields), profile before binding them to high-frequency events. diff --git a/website/src/routes/(docs)/vue/guides/(main-concepts)/form-methods/index.mdx b/website/src/routes/(docs)/vue/guides/(main-concepts)/form-methods/index.mdx index 88dcaccd..5c632ea0 100644 --- a/website/src/routes/(docs)/vue/guides/(main-concepts)/form-methods/index.mdx +++ b/website/src/routes/(docs)/vue/guides/(main-concepts)/form-methods/index.mdx @@ -27,6 +27,19 @@ To retrieve values from your form, you can use: Formisch uses Vue's reactivity system internally which means that reading values with these methods is reactive. When the form state changes, any component or computation that uses these methods will automatically update to reflect the new state. +## Dirty state + +To work with the dirty state of your form, Formisch provides three methods: + +- `getDirtyInput`: Get the dirty + parts of the form input +- `getDirtyPaths`: Get the paths + of dirty fields +- `pickDirty`: Filter an + externally-supplied value down to its dirty parts using the form's dirty mask + +See the dirty fields guide for when to reach for each. + ## Setting values To manually update form values or errors, use: diff --git a/website/src/routes/(docs)/vue/guides/menu.md b/website/src/routes/(docs)/vue/guides/menu.md index 8a277ee3..9f301af0 100644 --- a/website/src/routes/(docs)/vue/guides/menu.md +++ b/website/src/routes/(docs)/vue/guides/menu.md @@ -21,6 +21,7 @@ - [Special inputs](/vue/guides/special-inputs/) - [Controlled fields](/vue/guides/controlled-fields/) - [Nested fields](/vue/guides/nested-fields/) +- [Dirty fields](/vue/guides/dirty-fields/) - [Field arrays](/vue/guides/field-arrays/) - [TypeScript](/vue/guides/typescript/) - [Architecture](/vue/guides/architecture/)