Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/core/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
@@ -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');
});
});
73 changes: 73 additions & 0 deletions packages/core/src/field/getDirtyFieldInput/getDirtyFieldInput.ts
Original file line number Diff line number Diff line change
@@ -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 {
Comment thread
fabian-hiller marked this conversation as resolved.
// 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<string, unknown> = {};

// 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;
}
1 change: 1 addition & 0 deletions packages/core/src/field/getDirtyFieldInput/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './getDirtyFieldInput.ts';
1 change: 1 addition & 0 deletions packages/core/src/field/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export * from './getDirtyFieldInput/index.ts';
export * from './getElementInput/index.ts';
export * from './getFieldBool/index.ts';
export * from './getFieldInput/index.ts';
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/types/path/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export type {
DirtyPath,
Path,
PathValue,
PathKey,
Expand Down
98 changes: 98 additions & 0 deletions packages/core/src/types/path/path.test-d.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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<DirtyPath<{ name: string; age: number }>>().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<DirtyPath<string>>().toBeNever();
expectTypeOf<DirtyPath<string[]>>().toBeNever();
expectTypeOf<DirtyPath<[number, string]>>().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<Result>();
});

test('should merge keys across object union members', () => {
expectTypeOf<
DirtyPath<{ shared: string } & ({ a: string } | { b: number })>
>().toEqualTypeOf<readonly ['shared'] | readonly ['a'] | readonly ['b']>();
});

test('should be a subtype of RequiredPath', () => {
expectTypeOf<
DirtyPath<{ name: string; user: { email: string } }>
>().toMatchTypeOf<RequiredPath>();
});
});
42 changes: 42 additions & 0 deletions packages/core/src/types/path/path.ts
Original file line number Diff line number Diff line change
Expand Up @@ -245,3 +245,45 @@ export type ValidArrayPath<TValue, TPath extends RequiredPath> =
TPath extends LazyArrayPath<Required<TValue>, TPath>
? TPath
: LazyArrayPath<Required<TValue>, 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, TKey extends PathKey, TDepth extends 0[]> =
TChild extends Record<PropertyKey, unknown>
? readonly [TKey, ...DirtyPath<TChild, [...TDepth, 0]>]
: 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<PropertyKey, unknown>` 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<PropertyKey, unknown>
? {
[TKey in ExactKeysOf<TValue>]:
| readonly [TKey]
| DeepDirtyPath<
NonNullable<PropertiesOf<TValue>[TKey]>,
TKey,
TDepth
>;
}[ExactKeysOf<TValue>]
: never;
24 changes: 24 additions & 0 deletions packages/eslint-config/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
3 changes: 3 additions & 0 deletions packages/methods/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ All notable changes to the library will be documented in this file.

## vX.X.X (Month DD, YYYY)
Comment thread
fabian-hiller marked this conversation as resolved.

- 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`)

Expand Down
4 changes: 3 additions & 1 deletion packages/methods/eslint.config.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import {
baseConfigs,
createSourceConfig,
createTestConfig,
tseslint,
} from '@formisch/eslint-config';

Expand All @@ -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'] })
);
2 changes: 1 addition & 1 deletion packages/methods/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading
Loading