@structuralists/scaffolding 0.10.0 → 0.10.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/eslint.config.mjs +56 -2
- package/package.json +1 -1
- package/src/components/Chat/ChatRecipientsHeader/ChatRecipientsHeader.stories.tsx +4 -4
- package/src/components/Layout/Bar/Bar.stories.tsx +1 -1
- package/src/components/Layout/Panels/Panels.stories.tsx +1 -1
- package/src/components/Layout/Stack/Stack.stories.tsx +1 -1
- package/src/components/Modals/LargeModal/LargeModal.stories.tsx +1 -1
- package/src/components/Modals/MediumModal/MediumModal.stories.tsx +3 -3
- package/src/components/Modals/internal/ModalHeader.tsx +1 -1
- package/src/components/Overlays/Popover/Popover.stories.tsx +3 -3
- package/src/components/Overlays/Tooltip/Tooltip.stories.tsx +1 -1
- package/src/components/Tables/BigTable/BigTable.stories.tsx +1 -1
- package/src/forms/CLAUDE.md +67 -31
- package/src/{components/Forms → forms/elements}/Button/Button.stories.tsx +1 -1
- package/src/{components/Forms → forms/elements}/IconButton/index.tsx +1 -1
- package/src/{components/Forms → forms/elements}/Select/MultiSelect/MultiSelect.stories.tsx +2 -2
- package/src/{components/Forms → forms/elements}/Select/MultiSelect/index.tsx +1 -1
- package/src/{components/Forms → forms/elements}/Select/SingleSelect/SingleSelect.stories.tsx +3 -3
- package/src/{components/Forms → forms/elements}/Select/SingleSelect/index.tsx +1 -1
- package/src/forms/plan.md +42 -32
- package/src/forms/{useFormState → state/useFormState}/FormDebugger.tsx +3 -2
- package/src/forms/state/useFormState/deriveErrors.test.ts +76 -0
- package/src/forms/state/useFormState/deriveErrors.ts +35 -0
- package/src/forms/{useFormState → state/useFormState}/types.ts +2 -1
- package/src/forms/state/useFormState/useFormDebugger.test.tsx +57 -0
- package/src/forms/state/useFormState/useFormDebugger.ts +38 -0
- package/src/forms/{useFormState → state/useFormState}/useFormState.stories.tsx +4 -4
- package/src/forms/state/useFormState/useFormState.ts +69 -0
- package/src/forms/state/useFormState/useFormSubmit.test.tsx +86 -0
- package/src/forms/state/useFormState/useFormSubmit.ts +38 -0
- package/src/forms/{validations → state/validations}/walk.ts +3 -2
- package/src/index.ts +10 -10
- package/src/storybook/Composition.stories.tsx +4 -4
- package/src/storybook/_StoryUtils.stories.tsx +1 -1
- package/src/forms/useFormState/useFormState.ts +0 -109
- /package/src/{components/Forms → forms/elements}/Button/index.tsx +0 -0
- /package/src/{components/Forms → forms/elements}/Button/styles.module.css +0 -0
- /package/src/{components/Forms → forms/elements}/Button/types.ts +0 -0
- /package/src/{components/Forms → forms/elements}/ColorInput/index.tsx +0 -0
- /package/src/{components/Forms → forms/elements}/ColorInput/styles.module.css +0 -0
- /package/src/{components/Forms → forms/elements}/ColorInput/types.ts +0 -0
- /package/src/{components/Forms → forms/elements}/Field/Field.stories.tsx +0 -0
- /package/src/{components/Forms → forms/elements}/Field/index.tsx +0 -0
- /package/src/{components/Forms → forms/elements}/Field/styles.module.css +0 -0
- /package/src/{components/Forms → forms/elements}/Field/types.ts +0 -0
- /package/src/{components/Forms → forms/elements}/IconButton/IconButton.stories.tsx +0 -0
- /package/src/{components/Forms → forms/elements}/IconButton/styles.module.css +0 -0
- /package/src/{components/Forms → forms/elements}/IconButton/types.ts +0 -0
- /package/src/{components/Forms → forms/elements}/Input/Input.stories.tsx +0 -0
- /package/src/{components/Forms → forms/elements}/Input/index.tsx +0 -0
- /package/src/{components/Forms → forms/elements}/Input/styles.module.css +0 -0
- /package/src/{components/Forms → forms/elements}/Input/types.ts +0 -0
- /package/src/{components/Forms → forms/elements}/SearchInput/index.tsx +0 -0
- /package/src/{components/Forms → forms/elements}/SearchInput/styles.module.css +0 -0
- /package/src/{components/Forms → forms/elements}/SearchInput/types.ts +0 -0
- /package/src/{components/Forms → forms/elements}/Select/MultiSelect/types.ts +0 -0
- /package/src/{components/Forms → forms/elements}/Select/SingleSelect/types.ts +0 -0
- /package/src/{components/Forms → forms/elements}/Select/index.ts +0 -0
- /package/src/{components/Forms → forms/elements}/Select/internal/OptionList.tsx +0 -0
- /package/src/{components/Forms → forms/elements}/Select/internal/SelectTrigger.tsx +0 -0
- /package/src/{components/Forms → forms/elements}/Select/internal/styles.module.css +0 -0
- /package/src/{components/Forms → forms/elements}/Textarea/Textarea.stories.tsx +0 -0
- /package/src/{components/Forms → forms/elements}/Textarea/index.tsx +0 -0
- /package/src/{components/Forms → forms/elements}/Textarea/styles.module.css +0 -0
- /package/src/{components/Forms → forms/elements}/Textarea/types.ts +0 -0
- /package/src/forms/{path → state/path}/path.test.ts +0 -0
- /package/src/forms/{path → state/path}/path.ts +0 -0
- /package/src/forms/{path → state/path}/types.test-d.ts +0 -0
- /package/src/forms/{path → state/path}/types.ts +0 -0
- /package/src/forms/{useFormState → state/useFormState}/FormDebugger.module.css +0 -0
- /package/src/forms/{useFormState → state/useFormState}/FormDebugger.test.tsx +0 -0
- /package/src/forms/{useFormState → state/useFormState}/errorAt.test.ts +0 -0
- /package/src/forms/{useFormState → state/useFormState}/errorAt.ts +0 -0
- /package/src/forms/{useFormState → state/useFormState}/inspectable.test.ts +0 -0
- /package/src/forms/{useFormState → state/useFormState}/inspectable.ts +0 -0
- /package/src/forms/{useFormState → state/useFormState}/snapshotStore.test.ts +0 -0
- /package/src/forms/{useFormState → state/useFormState}/snapshotStore.ts +0 -0
- /package/src/forms/{useFormState → state/useFormState}/useFormState.test-d.ts +0 -0
- /package/src/forms/{useFormState → state/useFormState}/useFormState.test.tsx +0 -0
- /package/src/forms/{validations → state/validations}/perField.ts +0 -0
- /package/src/forms/{validations → state/validations}/types.test-d.ts +0 -0
- /package/src/forms/{validations → state/validations}/types.ts +0 -0
- /package/src/forms/{validations → state/validations}/walk.test.ts +0 -0
- /package/src/forms/{validators → state/validators}/validators.test.ts +0 -0
- /package/src/forms/{validators → state/validators}/validators.ts +0 -0
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { describe, test, expect } from 'bun:test';
|
|
2
|
+
import { deriveFormErrors } from './deriveErrors';
|
|
3
|
+
import { matches, notEmpty } from '../validators/validators';
|
|
4
|
+
|
|
5
|
+
// Direct, React-free tests of the pure derivation: values + constraints in,
|
|
6
|
+
// structured `{ path, error }[]` out. Hook-level behavior (live re-derivation
|
|
7
|
+
// across renders, submit gating) stays in useFormState.test.tsx.
|
|
8
|
+
|
|
9
|
+
type SignupForm = {
|
|
10
|
+
email: string | undefined;
|
|
11
|
+
nickname: string | undefined;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
const emptyForm: SignupForm = { email: undefined, nickname: undefined };
|
|
15
|
+
|
|
16
|
+
describe('deriveFormErrors', () => {
|
|
17
|
+
test('no constraints yields no errors', () => {
|
|
18
|
+
expect(deriveFormErrors(emptyForm, undefined)).toEqual([]);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test('an empty constraints object yields no errors', () => {
|
|
22
|
+
expect(deriveFormErrors(emptyForm, {})).toEqual([]);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test('a failing field contributes one path-addressed entry', () => {
|
|
26
|
+
const errors = deriveFormErrors(emptyForm, { email: notEmpty('email') });
|
|
27
|
+
expect(errors).toEqual([
|
|
28
|
+
{ path: ['email'], error: "'email' cannot be empty" },
|
|
29
|
+
]);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test('a passing field contributes nothing', () => {
|
|
33
|
+
const errors = deriveFormErrors(
|
|
34
|
+
{ email: 'a@b.co', nickname: undefined },
|
|
35
|
+
{ email: notEmpty('email') },
|
|
36
|
+
);
|
|
37
|
+
expect(errors).toEqual([]);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test('every failing constrained field gets its own entry', () => {
|
|
41
|
+
const errors = deriveFormErrors(emptyForm, {
|
|
42
|
+
email: notEmpty('email'),
|
|
43
|
+
nickname: notEmpty('nickname'),
|
|
44
|
+
});
|
|
45
|
+
expect(errors).toEqual([
|
|
46
|
+
{ path: ['email'], error: "'email' cannot be empty" },
|
|
47
|
+
{ path: ['nickname'], error: "'nickname' cannot be empty" },
|
|
48
|
+
]);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test('a validator array runs in order with first-error-wins', () => {
|
|
52
|
+
const constraints = {
|
|
53
|
+
email: [notEmpty('email'), matches('email', /@/, 'a valid email')],
|
|
54
|
+
};
|
|
55
|
+
expect(deriveFormErrors(emptyForm, constraints)).toEqual([
|
|
56
|
+
{ path: ['email'], error: "'email' cannot be empty" },
|
|
57
|
+
]);
|
|
58
|
+
expect(
|
|
59
|
+
deriveFormErrors({ email: 'nope', nickname: undefined }, constraints),
|
|
60
|
+
).toEqual([{ path: ['email'], error: "'email' must be a valid email" }]);
|
|
61
|
+
expect(
|
|
62
|
+
deriveFormErrors({ email: 'a@b.co', nickname: undefined }, constraints),
|
|
63
|
+
).toEqual([]);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test('an empty validator array passes', () => {
|
|
67
|
+
expect(deriveFormErrors(emptyForm, { email: [] })).toEqual([]);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test('a null constraint entry (possible from untyped JS) is skipped', () => {
|
|
71
|
+
const constraints = { email: null } as unknown as {
|
|
72
|
+
email: (val: string | undefined) => string | null;
|
|
73
|
+
};
|
|
74
|
+
expect(deriveFormErrors(emptyForm, constraints)).toEqual([]);
|
|
75
|
+
});
|
|
76
|
+
});
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { Path } from '../path/types';
|
|
2
|
+
import type { Validations } from '../validations/types';
|
|
3
|
+
import { validateEntry } from '../validations/walk';
|
|
4
|
+
import type { FlatConstraintEntry } from '../validations/walk';
|
|
5
|
+
import type { FormError, FormValuesObject } from './types';
|
|
6
|
+
|
|
7
|
+
// The pure half of the hook's error model: current values + constraints in,
|
|
8
|
+
// structured `{ path, error }[]` out. Validation is live-derived (validators
|
|
9
|
+
// are pure and cheap), so this runs every render — it must stay free of
|
|
10
|
+
// React and of any per-call state. `isValid` is just this list's emptiness;
|
|
11
|
+
// the hook derives it inline.
|
|
12
|
+
export const deriveFormErrors = <T extends FormValuesObject>(
|
|
13
|
+
values: T,
|
|
14
|
+
constraints: Validations<T> | undefined,
|
|
15
|
+
): FormError<T>[] => {
|
|
16
|
+
const errors: FormError<T>[] = [];
|
|
17
|
+
if (!constraints) return errors;
|
|
18
|
+
|
|
19
|
+
for (const key of Object.keys(constraints) as (keyof T & string)[]) {
|
|
20
|
+
// No cast: if the grammar grows a constraint form the walk doesn't
|
|
21
|
+
// understand, this assignment is the compile error that says so.
|
|
22
|
+
const entry: FlatConstraintEntry | undefined = constraints[key];
|
|
23
|
+
if (entry == null) continue;
|
|
24
|
+
const failure = validateEntry(entry, values[key], [key]);
|
|
25
|
+
if (failure == null) continue;
|
|
26
|
+
// The walk returns the address it was handed, and `[key]` — a key of
|
|
27
|
+
// a constraints object type-checked against T — is a valid single-key
|
|
28
|
+
// Path<T>. TS can't compute Path<T> for an unresolved generic T, so
|
|
29
|
+
// the correlation needs the same honest widening as the keys cast
|
|
30
|
+
// above.
|
|
31
|
+
errors.push({ path: failure.path as Path<T>, error: failure.error });
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return errors;
|
|
35
|
+
};
|
|
@@ -90,7 +90,8 @@ export type FormError<T extends FormValuesObject> = {
|
|
|
90
90
|
|
|
91
91
|
export type FormErrors<T extends FormValuesObject> = readonly FormError<T>[];
|
|
92
92
|
|
|
93
|
-
// What the hook publishes to its Debugger after
|
|
93
|
+
// What the hook publishes (via `useFormDebugger`) to its Debugger after
|
|
94
|
+
// every commit. Snapshots are
|
|
94
95
|
// replaced whole (never mutated) so `useSyncExternalStore` consumers can
|
|
95
96
|
// rely on reference equality.
|
|
96
97
|
export type FormDebugSnapshot<T extends FormValuesObject> = {
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { afterEach, describe, test, expect } from 'bun:test';
|
|
2
|
+
import { useState } from 'react';
|
|
3
|
+
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
|
|
4
|
+
|
|
5
|
+
// The Debugger portals into document.body, so leftovers from a previous
|
|
6
|
+
// test are visible to `screen` queries — clean up explicitly (bun:test has
|
|
7
|
+
// no global afterEach for RTL's auto-cleanup to hook).
|
|
8
|
+
afterEach(cleanup);
|
|
9
|
+
import { useFormDebugger } from './useFormDebugger';
|
|
10
|
+
|
|
11
|
+
// The debugger-plumbing boundary in isolation: the hook must hand back a
|
|
12
|
+
// render-stable component wired to the latest published snapshot. The full
|
|
13
|
+
// overlay behavior (open/close, live form wiring) is covered in
|
|
14
|
+
// FormDebugger.test.tsx through useFormState.
|
|
15
|
+
|
|
16
|
+
const Host = () => {
|
|
17
|
+
const [count, setCount] = useState(0);
|
|
18
|
+
const Debugger = useFormDebugger({
|
|
19
|
+
snapshot: {
|
|
20
|
+
values: { count },
|
|
21
|
+
errors: [],
|
|
22
|
+
isValid: true,
|
|
23
|
+
submitAttempted: false,
|
|
24
|
+
},
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
return (
|
|
28
|
+
<div>
|
|
29
|
+
<button type="button" onClick={() => setCount((prev) => prev + 1)}>
|
|
30
|
+
inc
|
|
31
|
+
</button>
|
|
32
|
+
<Debugger label="dbg" />
|
|
33
|
+
</div>
|
|
34
|
+
);
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
describe('useFormDebugger', () => {
|
|
38
|
+
test('returns a Debugger showing the current snapshot', () => {
|
|
39
|
+
render(<Host />);
|
|
40
|
+
fireEvent.click(screen.getByRole('button', { name: 'dbg' }));
|
|
41
|
+
|
|
42
|
+
expect(screen.getByText('count')).toBeTruthy();
|
|
43
|
+
expect(screen.getByText('0')).toBeTruthy();
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test('the component identity survives re-renders and shows fresh snapshots', () => {
|
|
47
|
+
render(<Host />);
|
|
48
|
+
fireEvent.click(screen.getByRole('button', { name: 'dbg' }));
|
|
49
|
+
|
|
50
|
+
fireEvent.click(screen.getByRole('button', { name: 'inc' }));
|
|
51
|
+
|
|
52
|
+
// The window is still open (a remounted Debugger would have reset to
|
|
53
|
+
// closed) and reflects the snapshot published after the re-render.
|
|
54
|
+
expect(screen.getByText('1')).toBeTruthy();
|
|
55
|
+
expect(screen.queryByText('0')).toBeNull();
|
|
56
|
+
});
|
|
57
|
+
});
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { useEffect, useRef } from 'react';
|
|
2
|
+
import { createFormDebugger } from './FormDebugger';
|
|
3
|
+
import type { FormDebuggerComponent } from './FormDebugger';
|
|
4
|
+
import { createSnapshotStore } from './snapshotStore';
|
|
5
|
+
import type { SnapshotStore } from './snapshotStore';
|
|
6
|
+
import type { FormDebugSnapshot, FormValuesObject } from './types';
|
|
7
|
+
|
|
8
|
+
export type UseFormDebuggerArgs<T extends FormValuesObject> = {
|
|
9
|
+
snapshot: FormDebugSnapshot<T>;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
// Debugger plumbing: one store + one component per hook instance, created
|
|
13
|
+
// lazily on first render. The component's identity must be stable across
|
|
14
|
+
// renders — recreated each render it would remount (and lose its
|
|
15
|
+
// open/closed state) on every keystroke.
|
|
16
|
+
export const useFormDebugger = <T extends FormValuesObject>(
|
|
17
|
+
args: UseFormDebuggerArgs<T>,
|
|
18
|
+
): FormDebuggerComponent => {
|
|
19
|
+
const { snapshot } = args;
|
|
20
|
+
|
|
21
|
+
const debugRef = useRef<{
|
|
22
|
+
store: SnapshotStore<FormDebugSnapshot<T>>;
|
|
23
|
+
Debugger: FormDebuggerComponent;
|
|
24
|
+
} | null>(null);
|
|
25
|
+
if (debugRef.current === null) {
|
|
26
|
+
const store = createSnapshotStore(snapshot);
|
|
27
|
+
debugRef.current = { store, Debugger: createFormDebugger(store) };
|
|
28
|
+
}
|
|
29
|
+
const { store, Debugger } = debugRef.current;
|
|
30
|
+
|
|
31
|
+
// Publish after every commit. With no debugger window subscribed this is a
|
|
32
|
+
// field write and an empty notify loop — effectively free.
|
|
33
|
+
useEffect(() => {
|
|
34
|
+
store.publish(snapshot);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
return Debugger;
|
|
38
|
+
};
|
|
@@ -5,10 +5,10 @@ import { useFormState } from './useFormState';
|
|
|
5
5
|
import { errorAt } from './errorAt';
|
|
6
6
|
import type { FormErrors } from './types';
|
|
7
7
|
import { matches, minLength, notEmpty } from '../validators/validators';
|
|
8
|
-
import { Field } from '../../
|
|
9
|
-
import { Input } from '../../
|
|
10
|
-
import { Button } from '../../
|
|
11
|
-
import { SingleSelect } from '../../
|
|
8
|
+
import { Field } from '../../elements/Field';
|
|
9
|
+
import { Input } from '../../elements/Input';
|
|
10
|
+
import { Button } from '../../elements/Button';
|
|
11
|
+
import { SingleSelect } from '../../elements/Select';
|
|
12
12
|
|
|
13
13
|
const meta: Meta = {
|
|
14
14
|
title: 'Forms/useFormState',
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { useState } from 'react';
|
|
2
|
+
import { deriveFormErrors } from './deriveErrors';
|
|
3
|
+
import type {
|
|
4
|
+
FormHelpers,
|
|
5
|
+
FormValuesObject,
|
|
6
|
+
UnionPolicyCheck,
|
|
7
|
+
} from './types';
|
|
8
|
+
import { useFormDebugger } from './useFormDebugger';
|
|
9
|
+
import { useFormSubmit } from './useFormSubmit';
|
|
10
|
+
import type { Refine, Validations } from '../validations/types';
|
|
11
|
+
|
|
12
|
+
// `const V` freezes the inferred type of an inline `constraints` object —
|
|
13
|
+
// each validator's precise type and Refinement marker survive without any
|
|
14
|
+
// `as const` at the call site. Constraint objects built outside the call
|
|
15
|
+
// still need `as const satisfies Validations<T>` (see src/forms/CLAUDE.md).
|
|
16
|
+
//
|
|
17
|
+
// The `UnionPolicyCheck<T>` intersection enforces the union policy (see
|
|
18
|
+
// "Union policy" in src/forms/CLAUDE.md) at the hook boundary: a form type
|
|
19
|
+
// containing a disallowed union fails here, naming the offending keys,
|
|
20
|
+
// rather than silently producing dead types deep inside a resolved path
|
|
21
|
+
// later. It must stay OUT of the `initialValues` property type — an
|
|
22
|
+
// intersection target there defeats literal widening during inference
|
|
23
|
+
// (`b: 0` infers as `0`, not `number`).
|
|
24
|
+
type Args<T extends FormValuesObject, V extends Validations<T>> = {
|
|
25
|
+
initialValues: T;
|
|
26
|
+
constraints?: V;
|
|
27
|
+
onSubmit?: (values: Refine<T, V>) => void;
|
|
28
|
+
} & UnionPolicyCheck<T>;
|
|
29
|
+
|
|
30
|
+
// Plumbing only: each slice of form state lives in its own pure function or
|
|
31
|
+
// focused hook, and this hook just links them up and recomposes their
|
|
32
|
+
// outputs into the `FormHelpers<T>` surface. Values state is the one slice
|
|
33
|
+
// kept inline — today it is a bare `useState`, and the granular setters that
|
|
34
|
+
// would earn it a module of its own arrive with the path-based grammar.
|
|
35
|
+
export const useFormState = <
|
|
36
|
+
T extends FormValuesObject,
|
|
37
|
+
const V extends Validations<T> = Validations<T>,
|
|
38
|
+
>(
|
|
39
|
+
args: Args<T, V>,
|
|
40
|
+
): FormHelpers<T> => {
|
|
41
|
+
const { initialValues, constraints, onSubmit } = args;
|
|
42
|
+
|
|
43
|
+
const [values, onValueChanges] = useState<T>(initialValues);
|
|
44
|
+
|
|
45
|
+
const errors = deriveFormErrors(values, constraints);
|
|
46
|
+
const isValid = errors.length === 0;
|
|
47
|
+
|
|
48
|
+
// Explicit type arguments: V rides on `onSubmit`'s parameter type, which
|
|
49
|
+
// is not an inference site (see useFormSubmit.ts).
|
|
50
|
+
const { submitAttempted, submit } = useFormSubmit<T, V>({
|
|
51
|
+
values,
|
|
52
|
+
isValid,
|
|
53
|
+
onSubmit,
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
const Debugger = useFormDebugger({
|
|
57
|
+
snapshot: { values, errors, isValid, submitAttempted },
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
return {
|
|
61
|
+
values,
|
|
62
|
+
onValueChanges,
|
|
63
|
+
errors,
|
|
64
|
+
isValid,
|
|
65
|
+
submitAttempted,
|
|
66
|
+
submit,
|
|
67
|
+
Debugger,
|
|
68
|
+
};
|
|
69
|
+
};
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { describe, test, expect, mock } from 'bun:test';
|
|
2
|
+
import { act, renderHook } from '@testing-library/react';
|
|
3
|
+
import { useFormSubmit } from './useFormSubmit';
|
|
4
|
+
|
|
5
|
+
// The submit-orchestration boundary in isolation: the attempt flag and the
|
|
6
|
+
// validity gate, driven by whatever `isValid` the caller derived. Full-form
|
|
7
|
+
// behavior (validity flipping as values change) stays in useFormState.test.tsx.
|
|
8
|
+
|
|
9
|
+
type Form = { email: string | undefined };
|
|
10
|
+
|
|
11
|
+
describe('useFormSubmit', () => {
|
|
12
|
+
test('starts with submitAttempted false', () => {
|
|
13
|
+
const { result } = renderHook(() =>
|
|
14
|
+
useFormSubmit<Form>({ values: { email: undefined }, isValid: true }),
|
|
15
|
+
);
|
|
16
|
+
expect(result.current.submitAttempted).toBe(false);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test('submit on an invalid form marks the attempt and skips onSubmit', () => {
|
|
20
|
+
const onSubmit = mock(() => {});
|
|
21
|
+
const { result } = renderHook(() =>
|
|
22
|
+
useFormSubmit<Form>({ values: { email: undefined }, isValid: false, onSubmit }),
|
|
23
|
+
);
|
|
24
|
+
|
|
25
|
+
act(() => {
|
|
26
|
+
result.current.submit();
|
|
27
|
+
});
|
|
28
|
+
expect(result.current.submitAttempted).toBe(true);
|
|
29
|
+
expect(onSubmit).not.toHaveBeenCalled();
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test('submit on a valid form calls onSubmit with the current values', () => {
|
|
33
|
+
const onSubmit = mock(() => {});
|
|
34
|
+
const { result } = renderHook(() =>
|
|
35
|
+
useFormSubmit<Form>({ values: { email: 'a@b.co' }, isValid: true, onSubmit }),
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
act(() => {
|
|
39
|
+
result.current.submit();
|
|
40
|
+
});
|
|
41
|
+
expect(result.current.submitAttempted).toBe(true);
|
|
42
|
+
expect(onSubmit).toHaveBeenCalledTimes(1);
|
|
43
|
+
expect(onSubmit).toHaveBeenCalledWith({ email: 'a@b.co' });
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test('submit without an onSubmit handler is a no-op beyond the flag', () => {
|
|
47
|
+
const { result } = renderHook(() =>
|
|
48
|
+
useFormSubmit<Form>({ values: { email: 'a@b.co' }, isValid: true }),
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
act(() => {
|
|
52
|
+
result.current.submit();
|
|
53
|
+
});
|
|
54
|
+
expect(result.current.submitAttempted).toBe(true);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test('submitAttempted stays true once validity flips', () => {
|
|
58
|
+
const onSubmit = mock(() => {});
|
|
59
|
+
const initialProps: { isValid: boolean; email: string | undefined } = {
|
|
60
|
+
isValid: false,
|
|
61
|
+
email: undefined,
|
|
62
|
+
};
|
|
63
|
+
const { result, rerender } = renderHook(
|
|
64
|
+
(props: { isValid: boolean; email: string | undefined }) =>
|
|
65
|
+
useFormSubmit<Form>({
|
|
66
|
+
values: { email: props.email },
|
|
67
|
+
isValid: props.isValid,
|
|
68
|
+
onSubmit,
|
|
69
|
+
}),
|
|
70
|
+
{ initialProps },
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
act(() => {
|
|
74
|
+
result.current.submit();
|
|
75
|
+
});
|
|
76
|
+
expect(onSubmit).not.toHaveBeenCalled();
|
|
77
|
+
|
|
78
|
+
rerender({ isValid: true, email: 'a@b.co' });
|
|
79
|
+
expect(result.current.submitAttempted).toBe(true);
|
|
80
|
+
|
|
81
|
+
act(() => {
|
|
82
|
+
result.current.submit();
|
|
83
|
+
});
|
|
84
|
+
expect(onSubmit).toHaveBeenCalledWith({ email: 'a@b.co' });
|
|
85
|
+
});
|
|
86
|
+
});
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { useState } from 'react';
|
|
2
|
+
import type { Refine, Validations } from '../validations/types';
|
|
3
|
+
import type { FormValuesObject } from './types';
|
|
4
|
+
|
|
5
|
+
export type UseFormSubmitArgs<
|
|
6
|
+
T extends FormValuesObject,
|
|
7
|
+
V extends Validations<T>,
|
|
8
|
+
> = {
|
|
9
|
+
values: T;
|
|
10
|
+
isValid: boolean;
|
|
11
|
+
onSubmit?: (values: Refine<T, V>) => void;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
// Submit orchestration: owns the `submitAttempted` flag (UIs gate error
|
|
15
|
+
// display on it) and the validity gate in front of `onSubmit`. This is the
|
|
16
|
+
// home of the ONE sanctioned refinement cast (see the cast doctrine in
|
|
17
|
+
// src/forms/CLAUDE.md). `V` is not inferable from `onSubmit`'s parameter
|
|
18
|
+
// position, so `useFormState` applies both type arguments explicitly.
|
|
19
|
+
export const useFormSubmit = <
|
|
20
|
+
T extends FormValuesObject,
|
|
21
|
+
V extends Validations<T> = Validations<T>,
|
|
22
|
+
>(
|
|
23
|
+
args: UseFormSubmitArgs<T, V>,
|
|
24
|
+
) => {
|
|
25
|
+
const { values, isValid, onSubmit } = args;
|
|
26
|
+
|
|
27
|
+
const [submitAttempted, setSubmitAttempted] = useState(false);
|
|
28
|
+
|
|
29
|
+
const submit = () => {
|
|
30
|
+
setSubmitAttempted(true);
|
|
31
|
+
if (!isValid) return;
|
|
32
|
+
// Every constrained field's validator just passed at runtime — exactly
|
|
33
|
+
// the guarantee Refine<T, V> encodes.
|
|
34
|
+
onSubmit?.(values as Refine<T, V>);
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
return { submitAttempted, submit };
|
|
38
|
+
};
|
|
@@ -19,8 +19,9 @@ export type ValidationError = {
|
|
|
19
19
|
type AnyFieldValidator = (val: never) => string | null;
|
|
20
20
|
|
|
21
21
|
// The walk's view of one entry in a `Validations<T>` object. This type is
|
|
22
|
-
// what lets the compiler police the walk's assumptions: the
|
|
23
|
-
// `constraints[key]` to it WITHOUT
|
|
22
|
+
// what lets the compiler police the walk's assumptions: the error derivation
|
|
23
|
+
// (`useFormState/deriveErrors.ts`) assigns `constraints[key]` to it WITHOUT
|
|
24
|
+
// a cast, so when the grammar grows a form
|
|
24
25
|
// that is neither a function nor an array of them (nested spec, list `each`),
|
|
25
26
|
// that assignment stops compiling and the walk must learn the new form —
|
|
26
27
|
// instead of a stale walk misinterpreting it at runtime.
|
package/src/index.ts
CHANGED
|
@@ -3,26 +3,26 @@ export { Heading, type HeadingProps } from './components/Content/Heading';
|
|
|
3
3
|
export { Link, type LinkProps } from './components/Content/Link';
|
|
4
4
|
export { Stack, type StackProps } from './components/Layout/Stack';
|
|
5
5
|
export { Grid, type GridProps } from './components/Layout/Grid';
|
|
6
|
-
export { Button, type ButtonProps } from './
|
|
6
|
+
export { Button, type ButtonProps } from './forms/elements/Button';
|
|
7
7
|
export {
|
|
8
8
|
IconButton,
|
|
9
9
|
type IconButtonProps,
|
|
10
10
|
type IconButtonVariant,
|
|
11
11
|
type IconButtonSize,
|
|
12
|
-
} from './
|
|
12
|
+
} from './forms/elements/IconButton';
|
|
13
13
|
export {
|
|
14
14
|
Tooltip,
|
|
15
15
|
type TooltipProps,
|
|
16
16
|
type TooltipPlacement,
|
|
17
17
|
} from './components/Overlays/Tooltip';
|
|
18
|
-
export { Input, type InputProps } from './
|
|
19
|
-
export { SearchInput, type SearchInputProps } from './
|
|
20
|
-
export { ColorInput, type ColorInputProps } from './
|
|
21
|
-
export { Textarea, type TextareaProps } from './
|
|
22
|
-
export { SingleSelect, type SingleSelectProps } from './
|
|
23
|
-
export { MultiSelect, type MultiSelectProps } from './
|
|
24
|
-
export type { SelectOption, SelectSize } from './
|
|
25
|
-
export { Field, type FieldProps } from './
|
|
18
|
+
export { Input, type InputProps } from './forms/elements/Input';
|
|
19
|
+
export { SearchInput, type SearchInputProps } from './forms/elements/SearchInput';
|
|
20
|
+
export { ColorInput, type ColorInputProps } from './forms/elements/ColorInput';
|
|
21
|
+
export { Textarea, type TextareaProps } from './forms/elements/Textarea';
|
|
22
|
+
export { SingleSelect, type SingleSelectProps } from './forms/elements/Select';
|
|
23
|
+
export { MultiSelect, type MultiSelectProps } from './forms/elements/Select';
|
|
24
|
+
export type { SelectOption, SelectSize } from './forms/elements/Select';
|
|
25
|
+
export { Field, type FieldProps } from './forms/elements/Field';
|
|
26
26
|
export { MediumModal, LargeModal, ConfirmModal } from './components/Modals';
|
|
27
27
|
export type { MediumModalProps, LargeModalProps, ConfirmModalProps } from './components/Modals';
|
|
28
28
|
export { Divider } from './components/Layout/Divider';
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import type { Meta, StoryObj } from '@storybook/react-vite';
|
|
2
2
|
import { useRef, useState } from 'react';
|
|
3
|
-
import { Button } from '../
|
|
4
|
-
import { Field } from '../
|
|
5
|
-
import { Input } from '../
|
|
3
|
+
import { Button } from '../forms/elements/Button';
|
|
4
|
+
import { Field } from '../forms/elements/Field';
|
|
5
|
+
import { Input } from '../forms/elements/Input';
|
|
6
6
|
import { MediumModal } from '../components/Modals/MediumModal';
|
|
7
7
|
import { Menu } from '../components/Content/Menu';
|
|
8
8
|
import { Popover } from '../components/Overlays/Popover';
|
|
9
|
-
import { SingleSelect } from '../
|
|
9
|
+
import { SingleSelect } from '../forms/elements/Select/SingleSelect';
|
|
10
10
|
import { Stack } from '../components/Layout/Stack';
|
|
11
11
|
import { Text } from '../components/Content/Text';
|
|
12
12
|
import { Tooltip } from '../components/Overlays/Tooltip';
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { Meta, StoryObj } from '@storybook/react-vite';
|
|
2
2
|
import { Lorem, Placeholder, Toggle, Repeat } from './index';
|
|
3
|
-
import { Button } from '../
|
|
3
|
+
import { Button } from '../forms/elements/Button';
|
|
4
4
|
import { Stack } from '../components/Layout/Stack';
|
|
5
5
|
import { Text } from '../components/Content/Text';
|
|
6
6
|
import { MediumModal } from '../components/Modals';
|
|
@@ -1,109 +0,0 @@
|
|
|
1
|
-
import { useEffect, useRef, useState } from 'react';
|
|
2
|
-
import { createFormDebugger } from './FormDebugger';
|
|
3
|
-
import type { FormDebuggerComponent } from './FormDebugger';
|
|
4
|
-
import { createSnapshotStore } from './snapshotStore';
|
|
5
|
-
import type { SnapshotStore } from './snapshotStore';
|
|
6
|
-
import type {
|
|
7
|
-
FormDebugSnapshot,
|
|
8
|
-
FormError,
|
|
9
|
-
FormHelpers,
|
|
10
|
-
FormValuesObject,
|
|
11
|
-
UnionPolicyCheck,
|
|
12
|
-
} from './types';
|
|
13
|
-
import type { Path } from '../path/types';
|
|
14
|
-
import type { Refine, Validations } from '../validations/types';
|
|
15
|
-
import { validateEntry } from '../validations/walk';
|
|
16
|
-
import type { FlatConstraintEntry } from '../validations/walk';
|
|
17
|
-
|
|
18
|
-
// `const V` freezes the inferred type of an inline `constraints` object —
|
|
19
|
-
// each validator's precise type and Refinement marker survive without any
|
|
20
|
-
// `as const` at the call site. Constraint objects built outside the call
|
|
21
|
-
// still need `as const satisfies Validations<T>` (see src/forms/CLAUDE.md).
|
|
22
|
-
//
|
|
23
|
-
// The `UnionPolicyCheck<T>` intersection enforces the union policy (see
|
|
24
|
-
// "Union policy" in src/forms/CLAUDE.md) at the hook boundary: a form type
|
|
25
|
-
// containing a disallowed union fails here, naming the offending keys,
|
|
26
|
-
// rather than silently producing dead types deep inside a resolved path
|
|
27
|
-
// later. It must stay OUT of the `initialValues` property type — an
|
|
28
|
-
// intersection target there defeats literal widening during inference
|
|
29
|
-
// (`b: 0` infers as `0`, not `number`).
|
|
30
|
-
type Args<T extends FormValuesObject, V extends Validations<T>> = {
|
|
31
|
-
initialValues: T;
|
|
32
|
-
constraints?: V;
|
|
33
|
-
onSubmit?: (values: Refine<T, V>) => void;
|
|
34
|
-
} & UnionPolicyCheck<T>;
|
|
35
|
-
|
|
36
|
-
export const useFormState = <
|
|
37
|
-
T extends FormValuesObject,
|
|
38
|
-
const V extends Validations<T> = Validations<T>,
|
|
39
|
-
>(
|
|
40
|
-
args: Args<T, V>,
|
|
41
|
-
): FormHelpers<T> => {
|
|
42
|
-
const { initialValues, constraints, onSubmit } = args;
|
|
43
|
-
|
|
44
|
-
const [values, onValueChanges] = useState<T>(initialValues);
|
|
45
|
-
const [submitAttempted, setSubmitAttempted] = useState(false);
|
|
46
|
-
|
|
47
|
-
const errors: FormError<T>[] = [];
|
|
48
|
-
if (constraints) {
|
|
49
|
-
for (const key of Object.keys(constraints) as (keyof T & string)[]) {
|
|
50
|
-
// No cast: if the grammar grows a constraint form the walk doesn't
|
|
51
|
-
// understand, this assignment is the compile error that says so.
|
|
52
|
-
const entry: FlatConstraintEntry | undefined = constraints[key];
|
|
53
|
-
if (entry == null) continue;
|
|
54
|
-
const failure = validateEntry(entry, values[key], [key]);
|
|
55
|
-
if (failure == null) continue;
|
|
56
|
-
// The walk returns the address it was handed, and `[key]` — a key of
|
|
57
|
-
// a constraints object type-checked against T — is a valid single-key
|
|
58
|
-
// Path<T>. TS can't compute Path<T> for an unresolved generic T, so
|
|
59
|
-
// the correlation needs the same honest widening as the keys cast
|
|
60
|
-
// above.
|
|
61
|
-
errors.push({ path: failure.path as Path<T>, error: failure.error });
|
|
62
|
-
}
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
const isValid = errors.length === 0;
|
|
66
|
-
|
|
67
|
-
// Debugger plumbing: one store + one component per hook instance, created
|
|
68
|
-
// lazily on first render. The component's identity must be stable across
|
|
69
|
-
// renders — recreated each render it would remount (and lose its
|
|
70
|
-
// open/closed state) on every keystroke.
|
|
71
|
-
const debugRef = useRef<{
|
|
72
|
-
store: SnapshotStore<FormDebugSnapshot<T>>;
|
|
73
|
-
Debugger: FormDebuggerComponent;
|
|
74
|
-
} | null>(null);
|
|
75
|
-
if (debugRef.current === null) {
|
|
76
|
-
const store = createSnapshotStore<FormDebugSnapshot<T>>({
|
|
77
|
-
values,
|
|
78
|
-
errors,
|
|
79
|
-
isValid,
|
|
80
|
-
submitAttempted,
|
|
81
|
-
});
|
|
82
|
-
debugRef.current = { store, Debugger: createFormDebugger(store) };
|
|
83
|
-
}
|
|
84
|
-
const { store, Debugger } = debugRef.current;
|
|
85
|
-
|
|
86
|
-
// Publish after every commit. With no debugger window subscribed this is a
|
|
87
|
-
// field write and an empty notify loop — effectively free.
|
|
88
|
-
useEffect(() => {
|
|
89
|
-
store.publish({ values, errors, isValid, submitAttempted });
|
|
90
|
-
});
|
|
91
|
-
|
|
92
|
-
const submit = () => {
|
|
93
|
-
setSubmitAttempted(true);
|
|
94
|
-
if (!isValid) return;
|
|
95
|
-
// Every constrained field's validator just passed at runtime — exactly
|
|
96
|
-
// the guarantee Refine<T, V> encodes.
|
|
97
|
-
onSubmit?.(values as Refine<T, V>);
|
|
98
|
-
};
|
|
99
|
-
|
|
100
|
-
return {
|
|
101
|
-
values,
|
|
102
|
-
onValueChanges,
|
|
103
|
-
errors,
|
|
104
|
-
isValid,
|
|
105
|
-
submitAttempted,
|
|
106
|
-
submit,
|
|
107
|
-
Debugger,
|
|
108
|
-
};
|
|
109
|
-
};
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|