@structuralists/scaffolding 0.5.1 → 0.6.1
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/.claude/skills/pr-screenshots/SKILL.md +84 -0
- package/.github/workflows/ci.yml +38 -0
- package/package.json +1 -1
- package/src/forms/CLAUDE.md +13 -5
- package/src/forms/plan.md +132 -5
- package/src/forms/useFormState/FormDebugger.module.css +41 -0
- package/src/forms/useFormState/FormDebugger.test.tsx +74 -0
- package/src/forms/useFormState/FormDebugger.tsx +72 -0
- package/src/forms/useFormState/inspectable.test.ts +42 -0
- package/src/forms/useFormState/inspectable.ts +35 -0
- package/src/forms/useFormState/snapshotStore.test.ts +56 -0
- package/src/forms/useFormState/snapshotStore.ts +31 -0
- package/src/forms/useFormState/types.ts +18 -0
- package/src/forms/useFormState/useFormState.stories.tsx +194 -0
- package/src/forms/useFormState/useFormState.ts +45 -3
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: pr-screenshots
|
|
3
|
+
description: Attach screenshots to a GitHub PR description by uploading them as prerelease assets and embedding their download URLs. Use when a PR would benefit from visual evidence (UI changes, Storybook stories, component work) or when asked to add images to a PR.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Attach screenshots to a PR description
|
|
7
|
+
|
|
8
|
+
GitHub has no public API for uploading images into PR descriptions — the web
|
|
9
|
+
UI's drag-and-drop uses an internal endpoint (`/upload/policies/assets`) that
|
|
10
|
+
requires browser session cookies. The working CLI technique is to host the
|
|
11
|
+
images as **release assets on a throwaway prerelease** and embed their
|
|
12
|
+
`browser_download_url` in the PR body.
|
|
13
|
+
|
|
14
|
+
Origin: https://mareksuppa.com/til/github-pr-images-from-cli/ — first proven
|
|
15
|
+
here on PR #14.
|
|
16
|
+
|
|
17
|
+
## 1. Capture
|
|
18
|
+
|
|
19
|
+
Use `chrome-devtools-axi` against the local Storybook (`bun run dev`, port
|
|
20
|
+
6015):
|
|
21
|
+
|
|
22
|
+
- **Resize first**: `chrome-devtools-axi resize 900 640` — the default
|
|
23
|
+
viewport is very tall and produces screenshots that are mostly empty space.
|
|
24
|
+
- **Isolate the story**: open
|
|
25
|
+
`http://localhost:6015/iframe.html?id=<story-id>&viewMode=story` to skip
|
|
26
|
+
the Storybook chrome (sidebar, toolbar, addon panel).
|
|
27
|
+
- Interact to reach the state worth showing (filled fields, open overlays,
|
|
28
|
+
visible errors) — a screenshot demonstrating *behavior* beats an idle one.
|
|
29
|
+
- `chrome-devtools-axi screenshot <path>` — save to the session scratchpad,
|
|
30
|
+
not the repo.
|
|
31
|
+
- **Read the PNGs back** (Read tool) to verify framing and content before
|
|
32
|
+
publishing. Retake rather than ship a shot with dead space or missing
|
|
33
|
+
state.
|
|
34
|
+
|
|
35
|
+
## 2. Upload as prerelease assets
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
gh release create "pr-<N>-images" shot-1.png shot-2.png \
|
|
39
|
+
--title "PR #<N> screenshots" \
|
|
40
|
+
--notes "Image assets embedded in PR #<N>'s description. Not a software release — safe to delete after the PR merges." \
|
|
41
|
+
--prerelease
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
- `--prerelease` + a non-semver tag (`pr-<N>-images`) keep it out of the real
|
|
45
|
+
release stream and invisible to release automation.
|
|
46
|
+
- Name files descriptively — the filename is how you select each asset's URL.
|
|
47
|
+
|
|
48
|
+
## 3. Get URLs and embed
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
gh api "repos/<owner>/<repo>/releases/tags/pr-<N>-images" \
|
|
52
|
+
--jq '.assets[] | .name + " " + .browser_download_url'
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Fetch the current body, append, and write back (don't clobber):
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
gh pr view <N> --json body --jq .body > /tmp/pr-body.md
|
|
59
|
+
# append a "## Screenshots" section with  images
|
|
60
|
+
gh pr edit <N> --body-file /tmp/pr-body.md
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Give each image real alt text and a one-line caption saying what state it
|
|
64
|
+
shows. Add a footnote that the images live on the `pr-<N>-images` prerelease
|
|
65
|
+
and are safe to delete after merge.
|
|
66
|
+
|
|
67
|
+
## 4. Cleanup (after merge)
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
gh release delete "pr-<N>-images" --cleanup-tag --yes
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
## Private-repo caveat
|
|
74
|
+
|
|
75
|
+
On a private repo, `browser_download_url` returns **404 to anonymous curl
|
|
76
|
+
and to token-header curl** — that endpoint authenticates via browser
|
|
77
|
+
session only. This is expected, not breakage: the images render for anyone
|
|
78
|
+
viewing the PR logged in. To verify an asset exists from the CLI, download
|
|
79
|
+
it through the API instead:
|
|
80
|
+
|
|
81
|
+
```bash
|
|
82
|
+
gh api -H "Accept: application/octet-stream" \
|
|
83
|
+
"repos/<owner>/<repo>/releases/assets/<asset-id>"
|
|
84
|
+
```
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
# The same four checks the release job runs post-merge (publish.yml), but on
|
|
4
|
+
# every PR — so merging green is enforced, and the release job's checks are a
|
|
5
|
+
# formality rather than the first place a break surfaces.
|
|
6
|
+
on:
|
|
7
|
+
pull_request:
|
|
8
|
+
|
|
9
|
+
concurrency:
|
|
10
|
+
group: ci-${{ github.ref }}
|
|
11
|
+
cancel-in-progress: true
|
|
12
|
+
|
|
13
|
+
jobs:
|
|
14
|
+
checks:
|
|
15
|
+
runs-on: ubuntu-latest
|
|
16
|
+
steps:
|
|
17
|
+
- uses: actions/checkout@v4
|
|
18
|
+
|
|
19
|
+
- uses: oven-sh/setup-bun@v2
|
|
20
|
+
with:
|
|
21
|
+
bun-version: latest
|
|
22
|
+
|
|
23
|
+
- run: bun install --frozen-lockfile
|
|
24
|
+
|
|
25
|
+
- name: Cache Playwright browsers
|
|
26
|
+
uses: actions/cache@v4
|
|
27
|
+
with:
|
|
28
|
+
path: ~/.cache/ms-playwright
|
|
29
|
+
key: playwright-${{ runner.os }}-${{ hashFiles('bun.lock') }}
|
|
30
|
+
restore-keys: playwright-${{ runner.os }}-
|
|
31
|
+
|
|
32
|
+
- name: Install Playwright chromium
|
|
33
|
+
run: npx playwright install chromium --with-deps
|
|
34
|
+
|
|
35
|
+
- run: bun run typecheck
|
|
36
|
+
- run: bun run lint
|
|
37
|
+
- run: bun run test
|
|
38
|
+
- run: bun run test:storybook
|
package/package.json
CHANGED
package/src/forms/CLAUDE.md
CHANGED
|
@@ -147,11 +147,19 @@ precision end-to-end. So we wire it up before adding any consumers.
|
|
|
147
147
|
|
|
148
148
|
- `useFormState/` — the hook + the value-model types (`FormValueSimple`,
|
|
149
149
|
`FormValuesObject`, `FormValueList`). Hook surface: `{ values,
|
|
150
|
-
onValueChanges, errors, isValid, submitAttempted, submit }`;
|
|
151
|
-
live-derived from current values each render (validators are
|
|
152
|
-
cheap), `submitAttempted` lets UIs gate error display, and
|
|
153
|
-
performs the one honest cast to `Refine<T, V>` — earned because
|
|
154
|
-
validators just passed at runtime.
|
|
150
|
+
onValueChanges, errors, isValid, submitAttempted, submit, Debugger }`;
|
|
151
|
+
`errors` is live-derived from current values each render (validators are
|
|
152
|
+
pure and cheap), `submitAttempted` lets UIs gate error display, and
|
|
153
|
+
`submit()` performs the one honest cast to `Refine<T, V>` — earned because
|
|
154
|
+
the validators just passed at runtime. `Debugger` is a per-instance
|
|
155
|
+
dev-time overlay (fixed trigger, bottom-right, portaled to `<body>`) that
|
|
156
|
+
opens a live `JsonTable` view of the form's internal state. Plumbing: the
|
|
157
|
+
hook publishes a `FormDebugSnapshot` into a tiny `snapshotStore` after
|
|
158
|
+
every commit; only an *open* debugger window subscribes
|
|
159
|
+
(`useSyncExternalStore`), so an unused Debugger costs ~nothing. The
|
|
160
|
+
component is created once per hook instance (lazy ref) — its identity must
|
|
161
|
+
stay stable across renders or it would remount on every keystroke.
|
|
162
|
+
`inspectable.ts` converts arrays/Sets to shapes `JsonTable` can render.
|
|
155
163
|
- `path/` — typed cursor (`Path<T>`, `ValueAt<T,P>`, `Cursor<T>`, `read()`).
|
|
156
164
|
Will be used for granular setters and for surfacing per-field
|
|
157
165
|
errors/touched state. Coupled to the form value model intentionally.
|
package/src/forms/plan.md
CHANGED
|
@@ -1,8 +1,31 @@
|
|
|
1
1
|
# Plan: composable, recursive `Validations`
|
|
2
2
|
|
|
3
|
-
Status: **
|
|
4
|
-
`Validations<T>`, one validator per key, shallow `Refine`) is
|
|
5
|
-
|
|
3
|
+
Status: **in progress** — order of operations below. The baseline (flat
|
|
4
|
+
`Validations<T>`, one validator per key, shallow `Refine`) is **merged to main**
|
|
5
|
+
(PR #12, squashed; released as 0.5.0).
|
|
6
|
+
|
|
7
|
+
## Order of operations
|
|
8
|
+
|
|
9
|
+
The items were written unordered. The working order is **low-risk /
|
|
10
|
+
low-coupling first**: each early item is independently mergeable and valuable
|
|
11
|
+
even if the risky type work later stalls, and the risk zone comes last so a
|
|
12
|
+
TS wall can't strand finished work behind it.
|
|
13
|
+
|
|
14
|
+
1. **Item 7 — Debugger.** Zero type risk, valuable for developing everything
|
|
15
|
+
after it (watching nested state and structured errors live). ← *current*
|
|
16
|
+
2. **Item 1 — validator arrays per key.** `ExcludedOf<C[number]>` already
|
|
17
|
+
proven inside `allOf`.
|
|
18
|
+
3. **Error-model swap** to `{path, error}[]` on the *flat* baseline (paths
|
|
19
|
+
are single-key paths). Doesn't depend on nested constraints; hard
|
|
20
|
+
prerequisite for item 6; makes phases 2–3 runtime-plumbing-free.
|
|
21
|
+
4. **Item 5 — elements/state split.** Pure churn, no type risk; do it before
|
|
22
|
+
item 6 so the wrappers land in their final home once.
|
|
23
|
+
5. **Item 6 — field binding** (`getFormFieldPropsAt` + wrappers). Needs the
|
|
24
|
+
error model and the split; `Path`/`ValueAt` already validated.
|
|
25
|
+
6. **Type spike, then items 2/3/4** — the recursion risk zone. Throwaway
|
|
26
|
+
`.test-d.ts` of the full grammar at `InsuranceQuoteForm` scale *before*
|
|
27
|
+
any runtime work; three outcomes (works / slow / intractable) each with a
|
|
28
|
+
known response. Worst case, everything above still shipped.
|
|
6
29
|
|
|
7
30
|
## Goal
|
|
8
31
|
|
|
@@ -15,13 +38,19 @@ Evolve `Validations<T>` so a constraints object can:
|
|
|
15
38
|
(`Refinement` markers → `Refine<T, V>` → narrowed `onSubmit`) keeps
|
|
16
39
|
working at every level.
|
|
17
40
|
|
|
18
|
-
Plus
|
|
41
|
+
Plus four items that ride on top, independent of the type work:
|
|
19
42
|
|
|
20
43
|
5. split the forms area into **'form elements'** and **'form state'**
|
|
21
44
|
(see the section at the end),
|
|
22
45
|
6. **field binding**: a `getFormFieldPropsAt(path)` helper plus
|
|
23
46
|
form-element shorthands (`TextInputForForm` style) so wiring a field is
|
|
24
|
-
one expression (see the section at the end)
|
|
47
|
+
one expression (see the section at the end),
|
|
48
|
+
7. **form `Debugger`**: a dev-time overlay component, returned from the
|
|
49
|
+
hook, that shows the form's live internal state (see the section at the
|
|
50
|
+
end),
|
|
51
|
+
8. **per-field meta state**: visited/touched tracking and stable React key
|
|
52
|
+
generation, plus the open research question of keeping that meta state
|
|
53
|
+
in sync through array manipulation (see the section at the end).
|
|
25
54
|
|
|
26
55
|
## Target grammar
|
|
27
56
|
|
|
@@ -255,6 +284,104 @@ Placement note: these wrappers are the bridge between 'form elements' and
|
|
|
255
284
|
'form state', so where they live should fall out of the split in item 5 —
|
|
256
285
|
they are the one layer allowed to know about both sides.
|
|
257
286
|
|
|
287
|
+
## 7. Form `Debugger` — dev-time state introspection
|
|
288
|
+
|
|
289
|
+
A component for watching a form's internal state live while interacting with
|
|
290
|
+
the form proper, in stories and during development. Two elements:
|
|
291
|
+
|
|
292
|
+
- **Trigger** — small, `position: fixed`, floats above all content
|
|
293
|
+
(z-index above everything the library renders).
|
|
294
|
+
- **Window** — opened by the trigger; shows a JSON-ish representation of the
|
|
295
|
+
form's internal state: `values`, `errors`, `isValid`, `submitAttempted`,
|
|
296
|
+
and whatever internal state lands later (touched map, etc.).
|
|
297
|
+
`JsonTable` is the obvious in-house renderer for this.
|
|
298
|
+
|
|
299
|
+
### Hook plumbing
|
|
300
|
+
|
|
301
|
+
Each `useFormState` instance binds its internal state to its own debugger
|
|
302
|
+
and returns the component on the hook result:
|
|
303
|
+
|
|
304
|
+
```tsx
|
|
305
|
+
const { values, submit, Debugger, ... } = useFormState({ ... });
|
|
306
|
+
|
|
307
|
+
return (
|
|
308
|
+
<>
|
|
309
|
+
<form>...</form>
|
|
310
|
+
<Debugger /> {/* dev/story usage; omit it and nothing renders */}
|
|
311
|
+
</>
|
|
312
|
+
);
|
|
313
|
+
```
|
|
314
|
+
|
|
315
|
+
Mechanism sketch — a tiny event-emitter/subscription channel, so the
|
|
316
|
+
form's own render tree never pays for the debugger:
|
|
317
|
+
|
|
318
|
+
- The hook keeps a small store (current snapshot + subscriber set) in a ref
|
|
319
|
+
and publishes into it each render — publishing is a no-op when nobody
|
|
320
|
+
subscribes.
|
|
321
|
+
- `Debugger` is created once per hook instance (stable identity across
|
|
322
|
+
renders — memoized, not redefined inline, or it remounts every keystroke)
|
|
323
|
+
and closes over that store. When mounted and open, it subscribes —
|
|
324
|
+
`useSyncExternalStore` is the natural fit — so state flows hook → store →
|
|
325
|
+
debugger without threading props or context.
|
|
326
|
+
- Rendering the window is where the debugger pays its cost; a closed
|
|
327
|
+
trigger is ~free.
|
|
328
|
+
|
|
329
|
+
### Open questions (for when this is picked up)
|
|
330
|
+
|
|
331
|
+
- **Snapshot transport**: display-side needs stable snapshots (rendering
|
|
332
|
+
live mutable refs tears); likely publish a frozen snapshot object per
|
|
333
|
+
render.
|
|
334
|
+
- **Multiple forms on one screen**: each hook returns its own `Debugger`,
|
|
335
|
+
so triggers could stack/overlap. May want an instance label prop and/or
|
|
336
|
+
offset management; punt until it hurts.
|
|
337
|
+
- **Production leakage**: it's dev tooling, but it ships in the library.
|
|
338
|
+
Decide whether it's tree-shaken naturally (only renders if placed in
|
|
339
|
+
JSX — probably sufficient) or needs an env gate.
|
|
340
|
+
- **Placement re: item 5 split**: the plumbing is 'form state'; the
|
|
341
|
+
trigger/window UI is presentational. Like the item-6 wrappers, it's a
|
|
342
|
+
bridge layer — settle its home when the split lands.
|
|
343
|
+
|
|
344
|
+
## 8. Per-field meta state: visited tracking + stable React keys
|
|
345
|
+
|
|
346
|
+
Alongside the *value* tree, the hook needs a parallel *meta* tree — state
|
|
347
|
+
about fields that isn't field data:
|
|
348
|
+
|
|
349
|
+
- **visited/touched** — has the user interacted with this field yet? Feeds
|
|
350
|
+
the error-display policy (item 6's `errorMessage` gates on it via
|
|
351
|
+
`onBlur`).
|
|
352
|
+
- **stable React keys** — list elements need identity that survives
|
|
353
|
+
reorders/inserts/removals. Index keys break exactly when lists get
|
|
354
|
+
interesting; the meta layer is the natural place to mint and hold a
|
|
355
|
+
stable id per element.
|
|
356
|
+
|
|
357
|
+
### The hard part: array manipulation keeping meta in sync (open research)
|
|
358
|
+
|
|
359
|
+
When a list is reordered, inserted into, or spliced, per-element meta
|
|
360
|
+
(visited flags, keys) must follow its element — not its index. Two candidate
|
|
361
|
+
approaches, deliberately unresolved until we get there:
|
|
362
|
+
|
|
363
|
+
- **Owned array operations.** Past projects needed a dedicated set of list
|
|
364
|
+
manipulation ops (`insertAt`, `removeAt`, `move`, …) that update values
|
|
365
|
+
and meta in the same motion. Known to work; the cost is that consumers
|
|
366
|
+
must use *our* ops — raw `onValueChanges` with a hand-spliced array
|
|
367
|
+
silently desyncs meta.
|
|
368
|
+
- **Identity tracking via Map/WeakMap.** Keep meta keyed by element object
|
|
369
|
+
identity (`WeakMap<element, meta>`) rather than by path/index, and detect
|
|
370
|
+
reorders by re-matching identities after each change. Frees consumers to
|
|
371
|
+
manipulate arrays however they like; open questions are elements that get
|
|
372
|
+
*replaced* rather than moved (immutable-update styles create new objects
|
|
373
|
+
on every edit — identity breaks precisely when fields are edited), and
|
|
374
|
+
primitive-element lists (`string[]`) which have no stable identity at all.
|
|
375
|
+
|
|
376
|
+
Possibly a hybrid: identity-matching as the default heuristic, owned ops as
|
|
377
|
+
the guaranteed path. **Research topic for when we get here** — record
|
|
378
|
+
findings in the learning map before committing to either.
|
|
379
|
+
|
|
380
|
+
Sequencing: visited tracking lands with item 6 (its `onBlur` is the write
|
|
381
|
+
path). Keys + array-sync matter from phase 3 (list `each` specs) onward —
|
|
382
|
+
error paths and per-element meta both need element identity once lists are
|
|
383
|
+
editable.
|
|
384
|
+
|
|
258
385
|
## Open decisions
|
|
259
386
|
|
|
260
387
|
- **Whole-value + structural constraints on the same key** (e.g. validate
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
.root {
|
|
2
|
+
position: fixed;
|
|
3
|
+
right: var(--ui-space-4);
|
|
4
|
+
bottom: var(--ui-space-4);
|
|
5
|
+
/* Same z-index tier as every other portal layer (Modal, Popover, Tooltip);
|
|
6
|
+
* stacking between layers comes from DOM-append order, not z-index. */
|
|
7
|
+
z-index: 100;
|
|
8
|
+
display: flex;
|
|
9
|
+
flex-direction: column;
|
|
10
|
+
align-items: flex-end;
|
|
11
|
+
gap: var(--ui-space-2);
|
|
12
|
+
font-family: var(--ui-font);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
.trigger {
|
|
16
|
+
padding: var(--ui-space-1) var(--ui-space-3);
|
|
17
|
+
font-family: var(--ui-font-mono);
|
|
18
|
+
font-size: var(--ui-text-xsmall);
|
|
19
|
+
color: var(--ui-foreground);
|
|
20
|
+
background-color: var(--ui-background-1);
|
|
21
|
+
border: 1px solid var(--ui-border);
|
|
22
|
+
border-radius: var(--ui-radius-large);
|
|
23
|
+
box-shadow: 0 2px 8px var(--ui-shadow-soft);
|
|
24
|
+
cursor: pointer;
|
|
25
|
+
user-select: none;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
.trigger:hover {
|
|
29
|
+
background-color: var(--ui-background-2);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
.window {
|
|
33
|
+
width: 360px;
|
|
34
|
+
max-height: min(480px, 60vh);
|
|
35
|
+
overflow: auto;
|
|
36
|
+
padding: var(--ui-space-2);
|
|
37
|
+
background-color: var(--ui-background-0);
|
|
38
|
+
border: 1px solid var(--ui-border);
|
|
39
|
+
border-radius: var(--ui-radius-large);
|
|
40
|
+
box-shadow: 0 4px 16px var(--ui-shadow-soft);
|
|
41
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { afterEach, describe, test, expect } from 'bun:test';
|
|
2
|
+
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
|
|
3
|
+
|
|
4
|
+
// The Debugger portals into document.body, so leftovers from a previous
|
|
5
|
+
// test are visible to `screen` queries — clean up explicitly (bun:test has
|
|
6
|
+
// no global afterEach for RTL's auto-cleanup to hook).
|
|
7
|
+
afterEach(cleanup);
|
|
8
|
+
import { useFormState } from './useFormState';
|
|
9
|
+
import { notEmpty } from '../validators/validators';
|
|
10
|
+
|
|
11
|
+
type DebugForm = { nickname: string | undefined };
|
|
12
|
+
|
|
13
|
+
const DebugHost = () => {
|
|
14
|
+
const { values, onValueChanges, Debugger } = useFormState({
|
|
15
|
+
initialValues: { nickname: undefined } as DebugForm,
|
|
16
|
+
constraints: { nickname: notEmpty('nickname') },
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
return (
|
|
20
|
+
<div>
|
|
21
|
+
<input
|
|
22
|
+
aria-label="nickname"
|
|
23
|
+
value={values.nickname ?? ''}
|
|
24
|
+
onChange={(e) => {
|
|
25
|
+
const nickname = e.target.value;
|
|
26
|
+
onValueChanges((prev) => ({ ...prev, nickname }));
|
|
27
|
+
}}
|
|
28
|
+
/>
|
|
29
|
+
<Debugger label="debug me" />
|
|
30
|
+
</div>
|
|
31
|
+
);
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
describe('FormDebugger', () => {
|
|
35
|
+
test('renders the trigger with the window closed', () => {
|
|
36
|
+
render(<DebugHost />);
|
|
37
|
+
expect(screen.getByRole('button', { name: 'debug me' })).toBeTruthy();
|
|
38
|
+
expect(screen.queryByText('isValid')).toBeNull();
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test('clicking the trigger opens the window with the current state', () => {
|
|
42
|
+
render(<DebugHost />);
|
|
43
|
+
fireEvent.click(screen.getByRole('button', { name: 'debug me' }));
|
|
44
|
+
|
|
45
|
+
expect(screen.getByText('isValid')).toBeTruthy();
|
|
46
|
+
expect(screen.getByText('submitAttempted')).toBeTruthy();
|
|
47
|
+
expect(screen.getByText("'nickname' cannot be empty")).toBeTruthy();
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test('the open window live-updates as the form changes', () => {
|
|
51
|
+
render(<DebugHost />);
|
|
52
|
+
fireEvent.click(screen.getByRole('button', { name: 'debug me' }));
|
|
53
|
+
|
|
54
|
+
fireEvent.change(screen.getByLabelText('nickname'), {
|
|
55
|
+
target: { value: 'will' },
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
// The window survived the form re-render (stable component identity —
|
|
59
|
+
// a remount would have reset it to closed) and shows the new state.
|
|
60
|
+
expect(screen.getByText('will')).toBeTruthy();
|
|
61
|
+
expect(screen.queryByText("'nickname' cannot be empty")).toBeNull();
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test('clicking the trigger again closes the window', () => {
|
|
65
|
+
render(<DebugHost />);
|
|
66
|
+
const trigger = screen.getByRole('button', { name: 'debug me' });
|
|
67
|
+
|
|
68
|
+
fireEvent.click(trigger);
|
|
69
|
+
expect(screen.getByText('isValid')).toBeTruthy();
|
|
70
|
+
|
|
71
|
+
fireEvent.click(trigger);
|
|
72
|
+
expect(screen.queryByText('isValid')).toBeNull();
|
|
73
|
+
});
|
|
74
|
+
});
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { useState, useSyncExternalStore } from 'react';
|
|
2
|
+
import type { ReactElement } from 'react';
|
|
3
|
+
import { createPortal } from 'react-dom';
|
|
4
|
+
import { JsonTable } from '../../components/Json/JsonTable';
|
|
5
|
+
import { toInspectable } from './inspectable';
|
|
6
|
+
import type { SnapshotStore } from './snapshotStore';
|
|
7
|
+
import type { FormDebugSnapshot, FormValuesObject } from './types';
|
|
8
|
+
import styles from './FormDebugger.module.css';
|
|
9
|
+
|
|
10
|
+
export type FormDebuggerProps = {
|
|
11
|
+
/** Trigger text. Give each form a distinct label when several debuggers
|
|
12
|
+
* are mounted at once — triggers stack in the same corner. */
|
|
13
|
+
label?: string;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export type FormDebuggerComponent = (props: FormDebuggerProps) => ReactElement;
|
|
17
|
+
|
|
18
|
+
// Builds the per-hook-instance Debugger component. Called once per
|
|
19
|
+
// `useFormState` instance (from a lazy ref) so the returned component's
|
|
20
|
+
// identity is stable across renders — a component recreated each render
|
|
21
|
+
// would remount, and lose its open/closed state, on every keystroke.
|
|
22
|
+
export const createFormDebugger = <T extends FormValuesObject>(
|
|
23
|
+
store: SnapshotStore<FormDebugSnapshot<T>>,
|
|
24
|
+
): FormDebuggerComponent => {
|
|
25
|
+
// The subscription lives in the window, not the trigger: a closed
|
|
26
|
+
// debugger never re-renders with the form.
|
|
27
|
+
const DebuggerWindow = (props: { label: string }) => {
|
|
28
|
+
const { label } = props;
|
|
29
|
+
|
|
30
|
+
const snapshot = useSyncExternalStore(
|
|
31
|
+
store.subscribe,
|
|
32
|
+
store.getSnapshot,
|
|
33
|
+
store.getSnapshot,
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
return (
|
|
37
|
+
<div className={styles.window}>
|
|
38
|
+
<JsonTable
|
|
39
|
+
title={label}
|
|
40
|
+
value={toInspectable({
|
|
41
|
+
isValid: snapshot.isValid,
|
|
42
|
+
submitAttempted: snapshot.submitAttempted,
|
|
43
|
+
values: snapshot.values,
|
|
44
|
+
errors: snapshot.errors,
|
|
45
|
+
})}
|
|
46
|
+
/>
|
|
47
|
+
</div>
|
|
48
|
+
);
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const FormDebugger = (props: FormDebuggerProps) => {
|
|
52
|
+
const { label = 'form state' } = props;
|
|
53
|
+
|
|
54
|
+
const [isOpen, setIsOpen] = useState(false);
|
|
55
|
+
|
|
56
|
+
return createPortal(
|
|
57
|
+
<div className={styles.root}>
|
|
58
|
+
{isOpen && <DebuggerWindow label={label} />}
|
|
59
|
+
<button
|
|
60
|
+
type="button"
|
|
61
|
+
className={styles.trigger}
|
|
62
|
+
onClick={() => setIsOpen((open) => !open)}
|
|
63
|
+
>
|
|
64
|
+
{label}
|
|
65
|
+
</button>
|
|
66
|
+
</div>,
|
|
67
|
+
document.body,
|
|
68
|
+
);
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
return FormDebugger;
|
|
72
|
+
};
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { describe, test, expect } from 'bun:test';
|
|
2
|
+
import { toInspectable } from './inspectable';
|
|
3
|
+
|
|
4
|
+
describe('toInspectable', () => {
|
|
5
|
+
test('passes leaves through untouched', () => {
|
|
6
|
+
expect(toInspectable('hi')).toBe('hi');
|
|
7
|
+
expect(toInspectable(42)).toBe(42);
|
|
8
|
+
expect(toInspectable(10n)).toBe(10n);
|
|
9
|
+
expect(toInspectable(null)).toBeNull();
|
|
10
|
+
expect(toInspectable(undefined)).toBeUndefined();
|
|
11
|
+
expect(toInspectable(true)).toBe(true);
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
test('converts arrays to index-keyed objects', () => {
|
|
15
|
+
expect(toInspectable(['a', 'b'])).toEqual({ 0: 'a', 1: 'b' });
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test('converts arrays of objects recursively', () => {
|
|
19
|
+
expect(toInspectable([{ name: 'ada' }, { name: 'bo' }])).toEqual({
|
|
20
|
+
0: { name: 'ada' },
|
|
21
|
+
1: { name: 'bo' },
|
|
22
|
+
});
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test('renders Sets as a descriptive string leaf', () => {
|
|
26
|
+
expect(toInspectable(new Set(['a', 'b']))).toBe('Set(2) { "a", "b" }');
|
|
27
|
+
expect(toInspectable(new Set())).toBe('Set(0) { }');
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test('walks nested objects', () => {
|
|
31
|
+
const form = {
|
|
32
|
+
email: 'a@b.co',
|
|
33
|
+
address: { city: undefined, tags: ['home'] },
|
|
34
|
+
drivers: [{ name: 'ada', incidents: [] }],
|
|
35
|
+
};
|
|
36
|
+
expect(toInspectable(form)).toEqual({
|
|
37
|
+
email: 'a@b.co',
|
|
38
|
+
address: { city: undefined, tags: { 0: 'home' } },
|
|
39
|
+
drivers: { 0: { name: 'ada', incidents: {} } },
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
});
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// Converts a form-state snapshot into a shape JsonTable renders without
|
|
2
|
+
// throwing. JsonTable dispatches on "plain object vs leaf": arrays and Sets
|
|
3
|
+
// fall to the leaf renderer, and an array of objects (FormValueList) would
|
|
4
|
+
// throw as a React child. A debugger must render *any* legal form state, so:
|
|
5
|
+
//
|
|
6
|
+
// - arrays → index-keyed plain objects ({ 0: ..., 1: ... }), recursively
|
|
7
|
+
// - Sets → a descriptive string leaf: `Set(2) { "a", "b" }`
|
|
8
|
+
// - objects → walked recursively
|
|
9
|
+
// - leaves → passed through untouched
|
|
10
|
+
const isPlainObject = (value: unknown): value is Record<string, unknown> =>
|
|
11
|
+
typeof value === 'object' &&
|
|
12
|
+
value !== null &&
|
|
13
|
+
(Object.getPrototypeOf(value) === Object.prototype ||
|
|
14
|
+
Object.getPrototypeOf(value) === null);
|
|
15
|
+
|
|
16
|
+
export const toInspectable = (value: unknown): unknown => {
|
|
17
|
+
if (value instanceof Set) {
|
|
18
|
+
const members = [...value].map((member) => JSON.stringify(member));
|
|
19
|
+
return `Set(${value.size}) { ${members.join(', ')} }`;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
if (Array.isArray(value)) {
|
|
23
|
+
return Object.fromEntries(
|
|
24
|
+
value.map((element, index) => [index, toInspectable(element)]),
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if (isPlainObject(value)) {
|
|
29
|
+
return Object.fromEntries(
|
|
30
|
+
Object.entries(value).map(([key, child]) => [key, toInspectable(child)]),
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return value;
|
|
35
|
+
};
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { describe, test, expect, mock } from 'bun:test';
|
|
2
|
+
import { createSnapshotStore } from './snapshotStore';
|
|
3
|
+
|
|
4
|
+
describe('createSnapshotStore', () => {
|
|
5
|
+
test('getSnapshot returns the initial snapshot', () => {
|
|
6
|
+
const store = createSnapshotStore({ n: 1 });
|
|
7
|
+
expect(store.getSnapshot()).toEqual({ n: 1 });
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
test('publish replaces the snapshot and notifies subscribers', () => {
|
|
11
|
+
const store = createSnapshotStore({ n: 1 });
|
|
12
|
+
const onChange = mock(() => {});
|
|
13
|
+
store.subscribe(onChange);
|
|
14
|
+
|
|
15
|
+
store.publish({ n: 2 });
|
|
16
|
+
|
|
17
|
+
expect(store.getSnapshot()).toEqual({ n: 2 });
|
|
18
|
+
expect(onChange).toHaveBeenCalledTimes(1);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test('publish keeps the snapshot reference stable between publishes', () => {
|
|
22
|
+
const store = createSnapshotStore({ n: 1 });
|
|
23
|
+
store.publish({ n: 2 });
|
|
24
|
+
expect(store.getSnapshot()).toBe(store.getSnapshot());
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test('unsubscribe stops notifications', () => {
|
|
28
|
+
const store = createSnapshotStore({ n: 1 });
|
|
29
|
+
const onChange = mock(() => {});
|
|
30
|
+
const unsubscribe = store.subscribe(onChange);
|
|
31
|
+
|
|
32
|
+
unsubscribe();
|
|
33
|
+
store.publish({ n: 2 });
|
|
34
|
+
|
|
35
|
+
expect(onChange).not.toHaveBeenCalled();
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test('publish with no subscribers is a no-op notify', () => {
|
|
39
|
+
const store = createSnapshotStore({ n: 1 });
|
|
40
|
+
expect(() => store.publish({ n: 2 })).not.toThrow();
|
|
41
|
+
expect(store.getSnapshot()).toEqual({ n: 2 });
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test('notifies every subscriber', () => {
|
|
45
|
+
const store = createSnapshotStore({ n: 1 });
|
|
46
|
+
const first = mock(() => {});
|
|
47
|
+
const second = mock(() => {});
|
|
48
|
+
store.subscribe(first);
|
|
49
|
+
store.subscribe(second);
|
|
50
|
+
|
|
51
|
+
store.publish({ n: 2 });
|
|
52
|
+
|
|
53
|
+
expect(first).toHaveBeenCalledTimes(1);
|
|
54
|
+
expect(second).toHaveBeenCalledTimes(1);
|
|
55
|
+
});
|
|
56
|
+
});
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// Minimal external store connecting a hook's per-render state to observers
|
|
2
|
+
// outside its render tree (the form Debugger). Shaped for
|
|
3
|
+
// `useSyncExternalStore`: `subscribe` returns an unsubscribe function and
|
|
4
|
+
// `getSnapshot` must keep returning the same reference until the next
|
|
5
|
+
// `publish` — snapshots are replaced whole, never mutated.
|
|
6
|
+
export type SnapshotStore<Snapshot> = {
|
|
7
|
+
publish: (next: Snapshot) => void;
|
|
8
|
+
subscribe: (onChange: () => void) => () => void;
|
|
9
|
+
getSnapshot: () => Snapshot;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export const createSnapshotStore = <Snapshot,>(
|
|
13
|
+
initial: Snapshot,
|
|
14
|
+
): SnapshotStore<Snapshot> => {
|
|
15
|
+
let current = initial;
|
|
16
|
+
const listeners = new Set<() => void>();
|
|
17
|
+
|
|
18
|
+
return {
|
|
19
|
+
publish: (next) => {
|
|
20
|
+
current = next;
|
|
21
|
+
for (const listener of listeners) listener();
|
|
22
|
+
},
|
|
23
|
+
subscribe: (onChange) => {
|
|
24
|
+
listeners.add(onChange);
|
|
25
|
+
return () => {
|
|
26
|
+
listeners.delete(onChange);
|
|
27
|
+
};
|
|
28
|
+
},
|
|
29
|
+
getSnapshot: () => current,
|
|
30
|
+
};
|
|
31
|
+
};
|
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
// Type-only import; FormDebugger.tsx imports value types from this file in
|
|
2
|
+
// turn, but the cycle never exists at runtime.
|
|
3
|
+
import type { FormDebuggerComponent } from './FormDebugger';
|
|
4
|
+
|
|
1
5
|
export type FormValueSimple = string | number | bigint | string[] | Set<string> | undefined | null;
|
|
2
6
|
|
|
3
7
|
export type FormValuesObject = { [k in string]: FormValue };
|
|
@@ -10,6 +14,16 @@ export type FormErrors<T extends FormValuesObject> = Partial<
|
|
|
10
14
|
Record<keyof T, string>
|
|
11
15
|
>;
|
|
12
16
|
|
|
17
|
+
// What the hook publishes to its Debugger after every commit. Snapshots are
|
|
18
|
+
// replaced whole (never mutated) so `useSyncExternalStore` consumers can
|
|
19
|
+
// rely on reference equality.
|
|
20
|
+
export type FormDebugSnapshot<T extends FormValuesObject> = {
|
|
21
|
+
values: T;
|
|
22
|
+
errors: FormErrors<T>;
|
|
23
|
+
isValid: boolean;
|
|
24
|
+
submitAttempted: boolean;
|
|
25
|
+
};
|
|
26
|
+
|
|
13
27
|
export type FormHelpers<T extends FormValuesObject> = {
|
|
14
28
|
values: T;
|
|
15
29
|
// todo: likely remove once other setter are available
|
|
@@ -20,4 +34,8 @@ export type FormHelpers<T extends FormValuesObject> = {
|
|
|
20
34
|
isValid: boolean;
|
|
21
35
|
submitAttempted: boolean;
|
|
22
36
|
submit: () => void;
|
|
37
|
+
// Dev-time introspection overlay bound to this form instance: a fixed
|
|
38
|
+
// trigger that opens a window showing the form's live internal state.
|
|
39
|
+
// Render it anywhere (it portals to <body>); omit it and nothing mounts.
|
|
40
|
+
Debugger: FormDebuggerComponent;
|
|
23
41
|
};
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { useState } from 'react';
|
|
2
2
|
import type { Meta, StoryObj } from '@storybook/react-vite';
|
|
3
|
+
import { expect, userEvent, within } from 'storybook/test';
|
|
3
4
|
import { useFormState } from './useFormState';
|
|
4
5
|
import { allOf, matches, minLength, notEmpty } from '../validators/validators';
|
|
5
6
|
import { Field } from '../../components/Forms/Field';
|
|
@@ -153,6 +154,43 @@ export const SignupForm: Story = {
|
|
|
153
154
|
<SignupDemo />
|
|
154
155
|
</div>
|
|
155
156
|
),
|
|
157
|
+
// Walks the headline flow: errors gated on submit, allOf first-error
|
|
158
|
+
// progression, live clearing, and the narrowed payload reaching onSubmit.
|
|
159
|
+
play: async ({ canvasElement }) => {
|
|
160
|
+
const canvas = within(canvasElement);
|
|
161
|
+
const body = within(canvasElement.ownerDocument.body);
|
|
162
|
+
|
|
163
|
+
// Errors are gated on submitAttempted — nothing shown initially.
|
|
164
|
+
await expect(
|
|
165
|
+
canvas.queryByText("'email' cannot be empty"),
|
|
166
|
+
).not.toBeInTheDocument();
|
|
167
|
+
|
|
168
|
+
// A failing submit surfaces every constrained field's error.
|
|
169
|
+
await userEvent.click(canvas.getByRole('button', { name: 'Sign up' }));
|
|
170
|
+
await expect(canvas.getByText("'email' cannot be empty")).toBeInTheDocument();
|
|
171
|
+
await expect(
|
|
172
|
+
canvas.getByText("'displayName' cannot be empty"),
|
|
173
|
+
).toBeInTheDocument();
|
|
174
|
+
await expect(canvas.getByText("'role' cannot be empty")).toBeInTheDocument();
|
|
175
|
+
|
|
176
|
+
// allOf progression: notEmpty now passes, matches takes over.
|
|
177
|
+
await userEvent.type(canvas.getByLabelText(/^Email/), 'not-an-email');
|
|
178
|
+
await expect(
|
|
179
|
+
canvas.getByText("'email' must be a valid email"),
|
|
180
|
+
).toBeInTheDocument();
|
|
181
|
+
|
|
182
|
+
// Fix every field; errors clear live.
|
|
183
|
+
await userEvent.clear(canvas.getByLabelText(/^Email/));
|
|
184
|
+
await userEvent.type(canvas.getByLabelText(/^Email/), 'will@example.com');
|
|
185
|
+
await userEvent.type(canvas.getByLabelText(/^Display name/), 'Will');
|
|
186
|
+
await userEvent.click(canvas.getByRole('button', { name: 'Role' }));
|
|
187
|
+
// The option list is portaled — query the document, not the canvas.
|
|
188
|
+
await userEvent.click(await body.findByRole('option', { name: 'Engineer' }));
|
|
189
|
+
|
|
190
|
+
// Valid submit delivers the narrowed payload to onSubmit.
|
|
191
|
+
await userEvent.click(canvas.getByRole('button', { name: 'Sign up' }));
|
|
192
|
+
await expect(canvas.getByText(/onSubmit received/)).toBeInTheDocument();
|
|
193
|
+
},
|
|
156
194
|
};
|
|
157
195
|
|
|
158
196
|
const LiveValidityDemo = () => {
|
|
@@ -193,4 +231,160 @@ export const LiveValidity: Story = {
|
|
|
193
231
|
<LiveValidityDemo />
|
|
194
232
|
</div>
|
|
195
233
|
),
|
|
234
|
+
play: async ({ canvasElement }) => {
|
|
235
|
+
const canvas = within(canvasElement);
|
|
236
|
+
|
|
237
|
+
// Errors here are live — no submit gating.
|
|
238
|
+
await expect(canvas.getByText('false')).toBeInTheDocument();
|
|
239
|
+
await userEvent.type(canvas.getByLabelText(/^Nickname/), 'ab');
|
|
240
|
+
await expect(
|
|
241
|
+
canvas.getByText("'nickname' must be at least 3 characters"),
|
|
242
|
+
).toBeInTheDocument();
|
|
243
|
+
|
|
244
|
+
await userEvent.type(canvas.getByLabelText(/^Nickname/), 'c');
|
|
245
|
+
await expect(canvas.getByText('true')).toBeInTheDocument();
|
|
246
|
+
},
|
|
247
|
+
};
|
|
248
|
+
|
|
249
|
+
type DebuggerDemoValues = {
|
|
250
|
+
email: string | undefined;
|
|
251
|
+
nickname: string | undefined;
|
|
252
|
+
tags: string[];
|
|
253
|
+
};
|
|
254
|
+
|
|
255
|
+
// The Debugger comes off the hook itself — drop it anywhere in the tree and
|
|
256
|
+
// a fixed trigger appears bottom-right; open it and watch values/errors/
|
|
257
|
+
// isValid/submitAttempted update live as you interact with the form.
|
|
258
|
+
const DebuggerDemo = () => {
|
|
259
|
+
const [tagDraft, setTagDraft] = useState('');
|
|
260
|
+
|
|
261
|
+
const { values, onValueChanges, errors, submitAttempted, submit, Debugger } =
|
|
262
|
+
useFormState({
|
|
263
|
+
initialValues: {
|
|
264
|
+
email: undefined,
|
|
265
|
+
nickname: undefined,
|
|
266
|
+
tags: [],
|
|
267
|
+
} as DebuggerDemoValues,
|
|
268
|
+
constraints: {
|
|
269
|
+
email: allOf(notEmpty('email'), matches('email', /@/, 'a valid email')),
|
|
270
|
+
nickname: notEmpty('nickname'),
|
|
271
|
+
},
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
const shownErrors = submitAttempted ? errors : {};
|
|
275
|
+
|
|
276
|
+
return (
|
|
277
|
+
<form
|
|
278
|
+
style={{ maxWidth: 420, display: 'grid', gap: 16 }}
|
|
279
|
+
onSubmit={(e) => {
|
|
280
|
+
e.preventDefault();
|
|
281
|
+
submit();
|
|
282
|
+
}}
|
|
283
|
+
>
|
|
284
|
+
<Field label="Email" error={shownErrors.email} htmlFor="debug-email">
|
|
285
|
+
<Input
|
|
286
|
+
id="debug-email"
|
|
287
|
+
type="email"
|
|
288
|
+
value={values.email ?? ''}
|
|
289
|
+
onChange={(e) => {
|
|
290
|
+
const email = e.target.value;
|
|
291
|
+
onValueChanges((prev) => ({ ...prev, email }));
|
|
292
|
+
}}
|
|
293
|
+
placeholder="you@example.com"
|
|
294
|
+
/>
|
|
295
|
+
</Field>
|
|
296
|
+
|
|
297
|
+
<Field label="Nickname" error={shownErrors.nickname} htmlFor="debug-nickname">
|
|
298
|
+
<Input
|
|
299
|
+
id="debug-nickname"
|
|
300
|
+
value={values.nickname ?? ''}
|
|
301
|
+
onChange={(e) => {
|
|
302
|
+
const nickname = e.target.value;
|
|
303
|
+
onValueChanges((prev) => ({ ...prev, nickname }));
|
|
304
|
+
}}
|
|
305
|
+
/>
|
|
306
|
+
</Field>
|
|
307
|
+
|
|
308
|
+
<Field
|
|
309
|
+
label="Tags"
|
|
310
|
+
hint="Unconstrained string[] — shows how the Debugger renders list values"
|
|
311
|
+
htmlFor="debug-tag-draft"
|
|
312
|
+
>
|
|
313
|
+
<div style={{ display: 'flex', gap: 8 }}>
|
|
314
|
+
<Input
|
|
315
|
+
id="debug-tag-draft"
|
|
316
|
+
value={tagDraft}
|
|
317
|
+
onChange={(e) => setTagDraft(e.target.value)}
|
|
318
|
+
placeholder="add a tag"
|
|
319
|
+
/>
|
|
320
|
+
<Button
|
|
321
|
+
type="button"
|
|
322
|
+
onClick={() => {
|
|
323
|
+
if (!tagDraft) return;
|
|
324
|
+
onValueChanges((prev) => ({ ...prev, tags: [...prev.tags, tagDraft] }));
|
|
325
|
+
setTagDraft('');
|
|
326
|
+
}}
|
|
327
|
+
>
|
|
328
|
+
Add
|
|
329
|
+
</Button>
|
|
330
|
+
</div>
|
|
331
|
+
</Field>
|
|
332
|
+
|
|
333
|
+
{values.tags.length > 0 && (
|
|
334
|
+
<div style={{ fontSize: 13 }}>tags: {values.tags.join(', ')}</div>
|
|
335
|
+
)}
|
|
336
|
+
|
|
337
|
+
<div>
|
|
338
|
+
<Button type="submit" variant="primary">
|
|
339
|
+
Submit
|
|
340
|
+
</Button>
|
|
341
|
+
</div>
|
|
342
|
+
|
|
343
|
+
<Debugger label="signup form" />
|
|
344
|
+
</form>
|
|
345
|
+
);
|
|
346
|
+
};
|
|
347
|
+
|
|
348
|
+
export const WithDebugger: Story = {
|
|
349
|
+
render: () => (
|
|
350
|
+
<div style={{ padding: 24, fontFamily: 'var(--ui-font)' }}>
|
|
351
|
+
<DebuggerDemo />
|
|
352
|
+
</div>
|
|
353
|
+
),
|
|
354
|
+
// The Debugger portals to <body>, so its trigger/window are queried on the
|
|
355
|
+
// document, not the canvas. Exercises open → live update → close, which
|
|
356
|
+
// also pins the stable-identity guarantee: a remounting Debugger would
|
|
357
|
+
// lose its open state on the first keystroke.
|
|
358
|
+
play: async ({ canvasElement }) => {
|
|
359
|
+
const canvas = within(canvasElement);
|
|
360
|
+
const body = within(canvasElement.ownerDocument.body);
|
|
361
|
+
|
|
362
|
+
// Closed: trigger only, no window content.
|
|
363
|
+
await expect(body.queryByText('isValid')).not.toBeInTheDocument();
|
|
364
|
+
|
|
365
|
+
// Open: live state, including errors the form itself isn't showing yet
|
|
366
|
+
// (its display is submit-gated; the debugger sees the raw truth).
|
|
367
|
+
await userEvent.click(body.getByRole('button', { name: 'signup form' }));
|
|
368
|
+
await expect(body.getByText('isValid')).toBeInTheDocument();
|
|
369
|
+
await expect(body.getByText("'email' cannot be empty")).toBeInTheDocument();
|
|
370
|
+
await expect(
|
|
371
|
+
body.getByText("'nickname' cannot be empty"),
|
|
372
|
+
).toBeInTheDocument();
|
|
373
|
+
|
|
374
|
+
// Live update while open: value appears, its error entry drops out.
|
|
375
|
+
await userEvent.type(canvas.getByLabelText(/^Nickname/), 'will');
|
|
376
|
+
await expect(body.getByText('will')).toBeInTheDocument();
|
|
377
|
+
await expect(
|
|
378
|
+
body.queryByText("'nickname' cannot be empty"),
|
|
379
|
+
).not.toBeInTheDocument();
|
|
380
|
+
|
|
381
|
+
// List values render in the window (index-keyed).
|
|
382
|
+
await userEvent.type(canvas.getByLabelText(/^Tags/), 'typescript');
|
|
383
|
+
await userEvent.click(canvas.getByRole('button', { name: 'Add' }));
|
|
384
|
+
await expect(body.getByText('typescript')).toBeInTheDocument();
|
|
385
|
+
|
|
386
|
+
// Close: window unmounts, trigger stays.
|
|
387
|
+
await userEvent.click(body.getByRole('button', { name: 'signup form' }));
|
|
388
|
+
await expect(body.queryByText('isValid')).not.toBeInTheDocument();
|
|
389
|
+
},
|
|
196
390
|
};
|
|
@@ -1,5 +1,14 @@
|
|
|
1
|
-
import { useState } from 'react';
|
|
2
|
-
import
|
|
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
|
+
FormErrors,
|
|
9
|
+
FormHelpers,
|
|
10
|
+
FormValuesObject,
|
|
11
|
+
} from './types';
|
|
3
12
|
import type { Refine, Validations } from '../validations/types';
|
|
4
13
|
|
|
5
14
|
// `const V` freezes the inferred type of an inline `constraints` object —
|
|
@@ -38,6 +47,31 @@ export const useFormState = <
|
|
|
38
47
|
|
|
39
48
|
const isValid = Object.keys(errors).length === 0;
|
|
40
49
|
|
|
50
|
+
// Debugger plumbing: one store + one component per hook instance, created
|
|
51
|
+
// lazily on first render. The component's identity must be stable across
|
|
52
|
+
// renders — recreated each render it would remount (and lose its
|
|
53
|
+
// open/closed state) on every keystroke.
|
|
54
|
+
const debugRef = useRef<{
|
|
55
|
+
store: SnapshotStore<FormDebugSnapshot<T>>;
|
|
56
|
+
Debugger: FormDebuggerComponent;
|
|
57
|
+
} | null>(null);
|
|
58
|
+
if (debugRef.current === null) {
|
|
59
|
+
const store = createSnapshotStore<FormDebugSnapshot<T>>({
|
|
60
|
+
values,
|
|
61
|
+
errors,
|
|
62
|
+
isValid,
|
|
63
|
+
submitAttempted,
|
|
64
|
+
});
|
|
65
|
+
debugRef.current = { store, Debugger: createFormDebugger(store) };
|
|
66
|
+
}
|
|
67
|
+
const { store, Debugger } = debugRef.current;
|
|
68
|
+
|
|
69
|
+
// Publish after every commit. With no debugger window subscribed this is a
|
|
70
|
+
// field write and an empty notify loop — effectively free.
|
|
71
|
+
useEffect(() => {
|
|
72
|
+
store.publish({ values, errors, isValid, submitAttempted });
|
|
73
|
+
});
|
|
74
|
+
|
|
41
75
|
const submit = () => {
|
|
42
76
|
setSubmitAttempted(true);
|
|
43
77
|
if (!isValid) return;
|
|
@@ -46,5 +80,13 @@ export const useFormState = <
|
|
|
46
80
|
onSubmit?.(values as Refine<T, V>);
|
|
47
81
|
};
|
|
48
82
|
|
|
49
|
-
return {
|
|
83
|
+
return {
|
|
84
|
+
values,
|
|
85
|
+
onValueChanges,
|
|
86
|
+
errors,
|
|
87
|
+
isValid,
|
|
88
|
+
submitAttempted,
|
|
89
|
+
submit,
|
|
90
|
+
Debugger,
|
|
91
|
+
};
|
|
50
92
|
};
|