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:
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user