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:
@@ -47,17 +47,46 @@
|
||||
|
||||
(defn wizard-form
|
||||
"Wrap a step body in the wizard <form>: the form posts to the submit route, and only the
|
||||
wizard-id + current-step ride along (no accumulated data — that lives in the session)."
|
||||
wizard-id + current-step ride along (no accumulated data — that lives in the session).
|
||||
Enter is guarded so it triggers the step's primary nav button (the one marked
|
||||
`data-primary`) rather than whichever submit button the browser picks first."
|
||||
[config wizard-id current-step body]
|
||||
[:form (merge {:id (:form-id config "wizard-form")
|
||||
:hx-post (:submit-route config)
|
||||
:hx-target "this"
|
||||
:hx-swap "outerHTML"}
|
||||
:hx-swap "outerHTML"
|
||||
"@keydown.enter.prevent.stop" "$el.querySelector('[data-primary]')?.click()"}
|
||||
(:form-attrs config))
|
||||
(com/hidden {:name "wizard-id" :value wizard-id})
|
||||
(com/hidden {:name "current-step" :value (name current-step)})
|
||||
body])
|
||||
|
||||
(defn nav-footer
|
||||
"Standard wizard footer controls — so consumers don't hand-roll the `direction` buttons
|
||||
(and mis-target Back vs Save, or forget the Enter guard). Buttons post a `direction`
|
||||
field the engine branches on; the advance/save button is marked `data-primary` so the
|
||||
form's Enter guard triggers it. Also renders the `#form-errors` slot.
|
||||
|
||||
(nav-footer {:next \"Test\"}) ; an intermediate step: Next
|
||||
(nav-footer {:back? true :save? true}) ; the last step: Back + Save"
|
||||
[{:keys [next back? save?]}]
|
||||
[:div.flex.justify-end.items-baseline.gap-x-4
|
||||
[:div#form-errors]
|
||||
(when back?
|
||||
(com/button {:type "submit" :name "direction" :value "back" :class "w-24"} "Back"))
|
||||
(when next
|
||||
(com/button {:type "submit" :name "direction" :value "next" :data-primary "" :color :primary :class "w-24"} next))
|
||||
(when save?
|
||||
(com/button {:type "submit" :name "direction" :value "submit" :data-primary "" :color :primary :class "w-24"} "Save"))])
|
||||
|
||||
(defn blank-row
|
||||
"A fresh repeated-row map for an 'add row' interaction, with a temp `:db/id` (so a row
|
||||
schema requiring `[:db/id [:or entity-id temp-id]]` validates and the step can advance,
|
||||
instead of the add button silently doing nothing) plus `:new?` for the appear
|
||||
animation. Merge in any field defaults: `(blank-row :foo/location \"Shared\")`."
|
||||
[& {:as defaults}]
|
||||
(merge {:db/id (str (java.util.UUID/randomUUID)) :new? true} defaults))
|
||||
|
||||
(defn render-wizard
|
||||
"Render the current step's body inside the wizard form. `step-data`/`errors` let a
|
||||
validation re-render show the just-posted values + messages."
|
||||
@@ -86,16 +115,23 @@
|
||||
(assoc :session session)))
|
||||
|
||||
(defn open-wizard
|
||||
"Create a wizard instance in the session and render its first step. `:init-fn` returns
|
||||
{:context ..., :init-data ...} (both optional)."
|
||||
"Create a wizard instance in the session, render its first step, and return a Ring
|
||||
response with the updated session threaded. `:init-fn` returns {:context ..., :init-data
|
||||
...} (both optional). If the config supplies an `:open-response` fn it is applied to the
|
||||
rendered form hiccup to build the response (e.g. wrap it in a modal shell via
|
||||
modal-response); otherwise a bare html-response is returned. This makes open-wizard
|
||||
directly usable as a route handler — `(partial open-wizard config)` — for modal
|
||||
wizards, instead of every consumer re-implementing create!/render/wrap/thread."
|
||||
[config request]
|
||||
(let [{:keys [context init-data]} ((:init-fn config) request)
|
||||
first-step (-> config :steps first :key)
|
||||
[id session'] (ws/create-wizard! (:session request) (:name config)
|
||||
{:first-step first-step
|
||||
:context context
|
||||
:init-data init-data})]
|
||||
(render-response config id session' request)))
|
||||
:init-data init-data})
|
||||
form (render-wizard {:config config :wizard-id id :session session' :request request})
|
||||
resp ((or (:open-response config) html-response) form)]
|
||||
(assoc resp :session session')))
|
||||
|
||||
(defn- expired-response
|
||||
"The wizard instance is gone from the session (server restart / session expiry / a stale
|
||||
@@ -127,7 +163,11 @@
|
||||
|
||||
:else
|
||||
(let [step (step-by-key config current-step)
|
||||
posted ((:decode step) request)
|
||||
;; The engine owns wizard-id / current-step / direction. Strip them so the
|
||||
;; step's :decode never sees them and can decode straight into its schema --
|
||||
;; no per-consumer allowlist, and they can't leak into the saved entity.
|
||||
clean (update request :form-params dissoc "wizard-id" "current-step" "direction")
|
||||
posted ((:decode step) clean)
|
||||
errors (when-let [v (:validate step)] (v posted request))]
|
||||
(if (seq errors)
|
||||
(render-response config wizard-id session request
|
||||
|
||||
Reference in New Issue
Block a user