refactor(ssr): re-author form/wizard modals — whole-form swap, session wizard engine, Hiccup rendering #14
Reference in New Issue
Block a user
Delete Branch "integreat-execute-refactor"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
SSR form & wizard modal re-authoring
Re-authors every server-rendered form/wizard modal around three patterns, with no user-facing behavior change (parity gated by characterization + e2e specs). Originally this branch also moved rendering from Hiccup to Selmer templates — that step was explored and then reverted; rendering stays in Hiccup (
com/*).The three patterns kept
hx-target/hx-selectthe<form>,outerHTML), with targeted partial swaps where it matters so the caret/focus survives:#account-location-<index>#account-totals/#expense-totals/#summary-totals(their own swappable<tbody>)closest .account-cellNo out-of-band swaps.
*-no-cursor*twins and faked cursor re-roots are gone.components/wizard2+wizard_state, the Django formtools model). Per-step state lives in the Ring session — no EDN-snapshot round-trip, nostep-params[…]prefix, no per-interaction routes. The legacymm/*multi-step machinery is deleted.Single-step modals (Transaction Edit, Bulk Code, Invoice Bulk Edit, Sales Summary) become plain forms; genuinely multi-step ones (Transaction Rule, Invoice Pay, New/Edit Invoice, Vendor, Client) run on the engine.
Rendering: Hiccup (Selmer reverted)
An earlier iteration templated the interactive modals in Selmer. That has been reverted (commit
8b43017d): the four form modals render through the shared Hiccup components again, and the Selmer layer is removed entirely —auto-ap.ssr.selmer,auto-ap.ssr.components.selmer,selmer_test, the wholeresources/templates/tree (55.html), theselmerdependency, and the tailwindresources/templatescontent glob;com/single-modal-cardto centralize themd:w-[950px] md:h-[650px]modal chrome that had lived only in the Selmer templates;ssr-form-migrationskill + modernization plan.Also included
ssr-form-migrationskill capturing the doctrine; characterization specs per modal;/test-resete2e isolation.Verification
All four reverted namespaces compile and render with no stringified-hiccup leaks;
output.cssrebuilds byte-identically (no Tailwind class loss); 60 e2e specs pass — the four reverted modals (incl. the whole-form-swap focus/caret tests) plus the untouched wizard / pay / new-invoice / rule modals.a01dfc197eto70c178de83SSR modernization: ssr-form-migration skill + Transaction Edit plain-form/Selmer migrationto refactor(ssr): full Selmer migration of Transaction Edit; remove the wizardrefactor(ssr): full Selmer migration of Transaction Edit; remove the wizardto refactor(ssr): full Selmer migration of Transaction Edit + Bulk Code; remove the wizardBuilds the reusable multi-step wizard engine the plan front-loads in Phase 6, as two protocol-free namespaces. This replaces the EDN-snapshot-in-a-hidden-field round-trip for genuine multi-step flows: per-step validated data lives in the Ring session and is combined only at the end — only an opaque wizard-id rides in the form. - components/wizard_state.clj — pure session storage (Django formtools SessionStorage model): create-wizard!, instance, exists?, current-step, context, step-data, put-step (REPLACE not merge), set-step, get-all (combine at end), forget. State namespaced by wizard-id at [:wizards <id> ...]; :context holds read-only step inputs outside :step-data so it never merges into the result. Each fn is session -> session'. - components/wizard2.clj — the engine: open-wizard, render-wizard, handle-step-submit, wizard-form. A wizard is a config map (steps with :decode/:validate/:render/:next, plus :init-fn/:done-fn/:submit-route). Steps' :render get {wizard-id, current-step, context, all-data, step-data, errors, request}; nav posts a `direction` field (next/back/submit). Two routes per wizard (open + submit); the engine threads the session into the response itself — no wrap-wizard / wrap-decode-multi-form-state stack. REPL-proven lifecycle (before wiring any modal): 1. OPEN -> seeds session state, renders step 1, form leaks NO accumulated data 2. NEXT -> stores {:info {:name "Acme"}}, advances to :terms 3. INVALID -> re-renders the same step with errors, no advance 4. DONE -> done-fn gets combined {:name "Acme" :days 30} (get-all), instance forgotten 5. BACK -> :terms -> :info, no validation 6. EXPIRED -> unknown wizard-id re-opens fresh instead of 500-ing Inert infrastructure — nothing imports it yet (Transaction Rule migrates onto it next), so the e2e suite is unaffected. cljfmt clean. Skill: form-vs-wizard.md updated from aspirational to the realized engine API + the Phase-6 fit note (Transaction Rule exercises render/nav/preview; the cross-step merge gets its workout in Phase 7+). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>Invoice Pay is the first GENUINE multi-data-step wizard, and migrating it exercises the engine's central abstraction for the first time: choose-method collects {:bank-account :method}, payment-details collects {:invoices :check-number :handwritten-date :mode}, and the engine's get-all MERGES the two independent step payloads for the per-method pay (handwrite-check transacts a pending check; the others go through print-checks-internal). This is exactly the mechanism the Phase-6 adversarial review flagged as unproven. What changed - Deleted the 3 wizard records (PayWizard / ChoosePaymentMethodModal / PaymentDetailsStep), MultiStepFormState, the EDN snapshot, and the step-params[...] prefix. Replaced with pay-wizard-config (init-fn builds read-only :context; two steps; done-fn = pay!) driven by wizard2. - De-cursored the payment-details amounts grid (fc/cursor-map -> explicit (map-indexed) over :context :invoices with path->name2 names). - The bank-account cards' method controls now post {bank-account, method, direction:next} straight to the engine submit-route (was a bespoke navigate route). - Routes 3 -> 2: open-pay-wizard (GET), pay-step (every transition); the pay-wizard-navigate route is deleted. - Used the post-review engine primitives: :open-response (modal wrap), nav-footer (with new :save-label "Pay"), auto nav-field stripping (flat decode, no allowlist), Enter guard. invoices.clj falls fully off the framework: Invoice Pay was the last mm/fc user (bulk-edit went in Phase 5), so fc/ 0, mm/ 0, defrecord 0, step-params 0 — and the multi-modal / form-cursor / malli.util requires are removed. Gotcha discovered + documented: wizard session data must be EDN-safe (the cookie session store has no clj-time readers), so the date default is computed in render, not stored in context. Verification: invoice-pay spec 3/3 (the merge end-to-end); full suite 58/58; load-file clean; cljfmt clean. Skill fed: scorecard row (merge proven; whole-file zeroing) + the EDN-session-safety gotcha. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>The hardest modal in the app: one wizard that both creates and edits invoices, with a conditional middle step (basic-details → [accounts] → next-steps, where the expense-accounts step is skipped on the default-accounts path). Migrated off mm/* + form-cursor + the EDN snapshot onto the session-backed engine (wizard2). Finding: the OLD basic-details "Save" was broken. It hx-puts /invoice/new/navigate, whose `[:to {:optional true} …]` query-schema 500s on empty query-params — Ring's wrap-params yields {} for a no-query PUT, and main-transformer's parse-empty-as-nil decodes {} → nil, which the bare [:map] rejects. Production uses the identical wrap-params, so it was broken there too. So e2e/invoice-new.spec.ts is an ACCEPTANCE gate (red on the old code, green on the engine, whose submit is a POST with no query-schema): the migration fixes a latent bug. Create semantics (default → vendor default account, location-spread; customize → posted grid; edit → prefill + updated row) were pinned at the REPL. What changed: - defrecord 4 → 0 (NewWizard2 / BasicDetailsStep / AccountsStep / NextSteps), mm/ 0, fc/ cursor refs 0, step-params[…] field names 0. - Conditional `:next` `(if (= :customize …) :accounts :done)` replaces mm/CustomNext + the broken 308-to-submit. Dual-purpose new+edit = one :init-fn branching on a route :db/id; create-wizard! seeds :init-data as per-step step-data so edit opens populated. - The broken new-wizard-navigate route is deleted; the genuine async helpers (account-prediction, due/scheduled-payment-date, location-select, expense total/balance, add-row) remain but read the posted flat form (+ ws/get-all for the cross-step total). - next-steps becomes the done-fn's returned modal (Pay now / Add another / Close). - Dates ride as java.util.Date (#inst) in step-data so it's EDN-safe across the non-terminal step (clj-time DateTimes break the cookie store). Verification: full e2e suite 61/61 (58 prior + 3 new); maybe-spread-locations unit test 6/6; create semantics + edit prefill confirmed at the REPL. Skill fed (scorecard Phase 8, gotchas {}→nil 500 + #inst dates, form-vs-wizard conditional :next + dual-purpose). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>A five-step linear wizard (info → terms → account → address → legal) plus a separate Merge dialog, migrated off mm/* + form-cursor + the EDN snapshot onto the session-backed engine (wizard2), following the Phase 8 template. Latent bug found + fixed: the old "Next" PUT /admin/vendor/navigat carried a [:map [:db/id entity-id]] route-schema on a route with no :db/id path param, so empty route-params {} → main-transformer's parse-empty-as-nil → nil → 500 on every advance (the same quirk as Phase 8's query-params, now via route-params). The engine's submit is a POST with no such schema; the dead navigate route is deleted. What changed: - defrecord 5 → 0 (InfoModal/TermsModal/AccountModal/AddressModal/LegalEntityModal + VendorWizard), mm/ 0, fc/ cursor refs 0 (wizard AND the de-cursored Merge dialog), step-params[…] 0. - 5 de-cursored step renders (plain data + path->name2 + a *errors* binding); the 3 repeated grids became add-row-handler + a blank-row row render; the timeline is preserved as a per-step side panel. - :init-fn branches new (empty) vs edit (entity split across the 5 steps' :init-data, seeded as per-step step-data so edit opens populated); per-step :validate via mc/validate + me/humanize replaces wrap-ensure-step; vendor-step wraps handle-step-submit in try+ to surface create-time validation as a 4xx. Two new gotchas found + fixed + documented: - empty-step decode: an all-blank step collapses to nil (parse-empty-as-nil), which a schema :validate rejects as "invalid type"; decode-with coerces nil → {} so optional- only steps advance while required-field steps still fail on the missing key. - blank nested entity: an untouched Address (all-nil, no :db/id) makes :upsert-entity mint a tempid used only as value (datomic error); blank-address? drops it. Verification: full e2e suite 65/65 (61 prior + 4 new: info renders + timeline; create across all 5 steps persists; edit opens prefilled and a rename persists; a too-short name blocks advancing). Create + edit confirmed at the REPL incl. the cookie-session EDN round-trip. Skill fed (scorecard Phase 9; gotchas for both new traps). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>The largest SSR modal, moved off the mm/* multi-step wizard protocol machinery (ClientWizard/*Modal records, MultiStepFormState, fc/* form-cursors, EDN-snapshot round-trip) onto the session-backed engine (wizard2 + wizard-state): flat de-cursored field names, whole-form HTMX swaps, per-step session state combined by the done-fn. Seven linear steps (info → matches → contact → bank-accounts → integrations → cash-flow → other-settings), each a data-driven {:decode :validate :render :next}. The grid, form schemas, and the sales power-query export are preserved unchanged. The parameterized [:bank-account which] mm sub-step (which the linear engine can't model) becomes a sub-editor of the bank-accounts step: the list view and per-account editor are whole-form swaps of #wizard-form, driven by dedicated routes (new/edit/accept/discard/ sort) that mutate the :bank-accounts step-data in the session directly and re-render via the engine's render-wizard. The bank-accounts step's :decode is a pass-through that re-affirms the session-managed list (read via a `wiz` hidden the engine doesn't strip), so Next never wipes it. Notable fixes carried over from prior phases: - New vs edit is keyed off :db/id presence (the engine always POSTs, so the old PUT/POST split no longer distinguishes them). - Client + bank-account dates are coerced to #inst for EDN-safe session storage (clj-time DateTime has no cookie-session reader). - An empty Contact-step address posts blank fields → decodes to an all-nil, db/id-less map; blank-address? drops it before upsert (else datomic: "tempid used only as value"). Routes: drop ::navigate/::discard; add the four bank-account sub-editor routes. Full e2e suite green (71/71); client-wizard acceptance spec rewritten for the engine (flat field names, data-primary nav, bank-account open/accept/discard sub-flows). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>Many templates were minified onto a single line. Reformatted every template under resources/templates/ with djLint (django profile, 2-space indent) so the markup, {% %} blocks, and {{ }} interpolations are human-readable, plus hand-split the two multi-<span> option partials (invoice-option / rule-option). Pure reflow — no markup, tag, or text content changed (the only content-adjacent delta is harmless trailing whitespace inside single-interpolation elements). link.html / panel-empty.html stay on one line (single element). resources/public/index.html left as-is (already readable, non-template static). Full e2e suite 72/72 green (no rendering regressions). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>- BUG D: clicking "Bulk Edit" with no invoices selected 500'd. selected->ids returns nil with no selection, and all-ids-not-locked fed that nil into a Datomic `:in $ [?i ...]` query ("Unable to find data source"). Guard the body with (when (seq all-ids) ...) so an empty selection yields [] and the modal opens cleanly. Verified live: no "Oh, drat" toast, no 500. - BUG B: modal-footer- called (hx/alpine-appear ...) twice; the 2nd return value (an attribute map) landed in child position and rendered as literal EDN ({:x-show ...}) in the red error banner whenever unexpectedError flipped true. Delete the duplicate. Verified: rendered HTML now has one alpine-appear and no EDN-text child. (Pre-existing defect, also present on master.) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>The pay wizard modal was w-[50em] (800px) but its bank-account cards are w-[30em] (480px), so once the CSS rebuild activated the real width it rendered with a large empty right region — visible especially via the "Pay now" step after creating an invoice. It had effectively been content-width (~543px) before and looked fine. Set the pay modal to w-[34em] (544px) so it fits its content and stays consistent across both pay steps. Verified live: modal is 544px and the method cards fill it, centered. Also fix the post-create "next steps" modal's stale header text ("Invoice accounts " -> "Invoice created"). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>Move all markup in the Transaction Bulk Code modal out of Clojure and into Selmer templates so bulk_code.clj only assembles data. - Replace the inline sel/raw HTML strings and one Hiccup [:p] with templates: head, form-errors, footer, account-entries, success-body. - Render the expense-account grid from a {% for %} template (account-grid.html + account-row.html) driven by a per-row view-model (account-row-vm); the row reuses the shared components/typeahead.html via a {% with %} include (no fork). - Extract behaviour-preserving data-prep helpers reused by the view-model: sc/typeahead-ctx, sc/money-input-attrs, sc/validated-field-classes, sc/errors-str, edit/account-typeahead-ctx, edit/location-select-ctx. Verified: REPL render parity + browser QA (add/remove row, typeahead select, per-row location swap, percentage validation, submit, vendor auto-populate); no JS errors. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>Each bulk-code route now ends in a single sel/render call; all composition (modal chrome, body, account grid, rows, footer, errors) happens in the templates via {% extends %}/{% block %}/{% include %}/{% with %}, reading one nested view-model (form-ctx). No HTML is stitched together in Clojure. - Add components/modal-card.html: a base chrome with head/body/footer blocks; bulk-code/card.html extends it. (Transaction Edit keeps its string-slot edit-modal.html for now.) - New top-level templates: open.html, form.html, card.html, body.html; rework account-grid/account-row/footer/head to pull the shared component partials in via {% include %}+{% with %} instead of hardcoding class strings or receiving pre-rendered HTML strings. - render-form / open-handler collapse to one sel/render of form.html / open.html. bulk-code-body*, footer*, form-errors-html, account-grid*, the *errors* dynamic var and ferr are gone; field errors are read straight from :form-errors. - Extract sc/{select,button,a-button,a-icon-button}-ctx so templates can include those partials with computed context (the render wrappers now call the -ctx fns). Verified: rendered output is DOM-identical to the prior version across empty / populated / error scenarios (whitespace-normalized token compare), plus browser QA (open, vendor auto-populate, add/remove row, typeahead, per-row location swap, percentage validation, submit); no JS errors. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>Move the remaining static markup out of the bulk-code form view-model and into the templates, leaving form-ctx as plain data (plus a urls map and two button contexts). The form/vendor hx-wiring, the status <option> list, the per-row transition / location-swap / remove wiring, and the field names are now literal in the templates, built from the row index and the shared urls. - form.html: form attrs literal; ids render name="ids[N]" via forloop.counter0. - body.html: vendor-changed wiring literal; status is an inline <select> with literal options (selected via {% if status.value = ... %}); field wrappers use {% if has_error %}has-error. - account-row.html: the <tr> transitions, db/id hidden, location-cell swap and remove <a> are literal with {{ row.index }} / {{ urls.changed }}; only the Alpine x-data, errors, and the typeahead/location/money control contexts are passed as data. - form-ctx / account-row-vm reduced to data; drop the now-unused sc/validated-field-classes. Tradeoff: the status <select> and the remove <a> inline the shared base classes (those partials can't take literal option labels / per-row wiring), so those two class strings are duplicated in the bulk-code templates. Verified: moved wiring correct by targeted checks (ids[N], form/vendor hx-*, account-row-N, location swap + remove with index, status selected, no unrendered tags); full browser flow green -- open (3 ids), vendor auto-populate, status set+persist, add/remove row, submit "Transactions Coded", no JS errors. Shared component class-sets unchanged (this commit only touches bulk-code). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>Update: the Hiccup→Selmer step has been reverted (commit
8b43017d). The four migrated modals (transaction edit, bulk-code, invoice bulk-edit, sales-summary) render in Hiccup (com/*) again; the whole-form swap doctrine, top-rooted render fns, and the session-backed wizard engine are unchanged. Deleted the selmer namespaces, theresources/templatestree (55 files), the selmer dep, and the tailwind templates glob. Verified: namespaces compile + render leak-free, output.css byte-identical, 60 e2e specs green. Note: this PR's title is now stale — happy to retitle.refactor(ssr): full Selmer migration of Transaction Edit + Bulk Code; remove the wizardto refactor(ssr): re-author form/wizard modals — whole-form swap, session wizard engine, Hiccup renderingView command line instructions
Checkout
From your project repository, check out a new branch and test the changes.