refactor(ssr): wizard2 engine absorbs the per-consumer boilerplate (review follow-up)

Adversarial review of Phase 6 found the engine's coupling had relocated rather than
dissolved: every wizard consumer had to hand-build a decode allowlist, re-implement the
open-handler modal wrap, mint temp ids for added rows, and hand-roll the nav buttons +
Enter guard. The engine had the information to prevent all four. Now it does:

- handle-step-submit strips its own nav fields (wizard-id/current-step/direction) from
  form-params before calling a step's :decode -- no per-consumer allowlist, and they can
  no longer leak into the saved entity (the Phase-6 "500 on save" class of bug is
  structurally impossible).
- open-wizard takes an :open-response config fn and owns the create!/render/wrap/thread
  flow, so modal wizards route through (partial wizard2/open-wizard config) directly.
- wizard2/blank-row supplies a temp :db/id (+ :new?) so an added row passes schema
  validation and the step actually advances.
- wizard2/nav-footer emits the direction buttons (Back/advance/Save), marks the primary,
  and wizard-form guards Enter to trigger the primary button.

Consumer (transaction_rules.clj) gets correspondingly leaner: deleted rule-form-keys +
the decode allowlist, rule-nav, and the hand-rolled open-rule-wizard; new/edit routes are
now (partial wizard2/open-wizard config). A new wizard is now just a config map + the step
:render fns. LOC 964 -> 932, and the deleted code was exactly the cross-consumer
boilerplate, not modal-specific logic.

Verification: rule spec 4/4; full suite 55/55; cljfmt clean. Skill gotchas updated from
"three traps" to "use the engine's primitives" (the engine now absorbs them).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-25 14:38:07 -07:00
parent 107a02f4f1
commit a2d8517668
3 changed files with 86 additions and 70 deletions

View File

@@ -265,23 +265,31 @@ carve-out. Verify with `load-file` (compile) + `lein cljfmt check`, not by eyeba
diff is contained with `git diff -U0 <file> | grep '^@@'` — the hunks should cluster only where you
edited (requires + the modal region), nothing else.
## Wiring a modal onto the wizard2 engine — three traps that cost a debug cycle each
## Wiring a modal onto the wizard2 engine — use the engine's primitives, don't re-roll them
1. **Strip the engine's nav fields in the step `:decode`.** The posted form carries
`wizard-id` / `current-step` / `direction` alongside the real fields. If the step schema is
an open `:map` (most are), `mc/decode` keeps them, they ride into `get-all`, and the save's
`:upsert-entity` dies with `:db.error/not-an-entity ... :current-step`. Fix: `select-keys`
the decode to the schema's known top-level keys (the same allowlist trick as the flat-form
migrations). Symptom is a **500 on save**, not a validation message.
2. **New repeated-row needs a temp `:db/id` or the step can't advance.** If the row schema
requires `[:db/id [:or entity-id temp-id]]`, an added row with no id fails per-step
validation, so the engine re-renders the *same* step instead of advancing — looks like "the
Next/Test button does nothing." Give new rows `(str (java.util.UUID/randomUUID))`.
3. **Nav is a `direction` field, and Back/Save are both submit buttons.** The footer buttons
are plain `<button type="submit" name="direction" value="next|back|submit">`; the clicked
one's value rides in the POST and the engine branches on it. In tests, a selector like
`button:has-text("Save"), button[type=submit]` also matches **Back** (also a submit) and
`.first()` clicks Back — target the button by its text/value precisely.
Phase 6's first migration (Transaction Rule) hit three traps; an adversarial review pointed
out the engine had the information to prevent all three, so **the engine now absorbs them**.
A consumer is just a config map + the step `:render` fns — reach for these instead of
re-implementing them (and re-hitting the bug):
- **Nav fields are stripped for you.** `handle-step-submit` `dissoc`s its own
`wizard-id`/`current-step`/`direction` from `:form-params` before calling a step's
`:decode` (`wizard2.clj`), so your decode sees only real fields and they can't ride into
the saved entity. (The old failure was a **500 on save** — `:db.error/not-an-entity
:current-step` — because an open `:map` decode kept them. No allowlist needed anymore.)
- **`wizard2/open-wizard` owns the modal wrap.** Give the config an `:open-response` fn
(e.g. `(fn [form] (modal-response [:div#transitioner.flex-1 form]))`); then the
new/edit routes are literally `(partial wizard2/open-wizard config)`. Don't hand-roll
`create!/render/wrap/thread` — that boilerplate was duplicating engine internals.
- **Add rows with `wizard2/blank-row`.** It supplies a temp `:db/id` (so a row schema
requiring `[:db/id [:or entity-id temp-id]]` validates and the step actually advances —
the old symptom was "the Next/Test button does nothing") plus `:new?` for the appear
animation: `(wizard2/blank-row :foo/location "Shared")`.
- **Footer with `wizard2/nav-footer`.** It emits the `direction` submit buttons (Back /
primary advance / Save), marks the advance/save button `data-primary`, and the form's
Enter guard (`wizard2/wizard-form`) triggers `data-primary` — so Enter and Back/Save
aren't left to per-consumer convention. (Testing note that survives: Back and Save are
*both* `type=submit`, so target a save button by its text, not `button[type=submit]`.)
## Scorecard exceptions (ratchet violations with a reason)