Files
integreat/.claude/skills/ssr-form-migration/reference/scorecard.md
Bryce c5dc305854 refactor(ssr): Phase 9 — migrate New/Edit Vendor onto the engine (5-step wizard)
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>
2026-06-25 22:39:10 -07:00

22 KiB
Raw Blame History

Quality scorecard (the ratchet)

Cheap to measure (grep -c, wc -l, clj-kondo), recorded before/after each migration in the commit message and in the results table below. No metric may regress for the touched modal without a written exception in gotchas.md. These are directional evidence, not targets to game — always paired with the e2e parity gate.

Heuristics

# Heuristic Measure Target
1 Faked cursor positions (not cursors themselves) grep -cE 'with-cursor|MapCursor\.' re-roots + grep -c 'defn.*-no-cursor' → 0 (top-rooted cursors are fine)
2 Implicit state merges (snapshot/cursor) count merge sites → 0 (forms); explicit put-step only (wizards)
3 Branching complexity clj-kondo, or count cond/condp/case/nested if + max depth net ↓
4 Lines of code wc -l on the modal's file(s) net ↓
5 Reuse / cross-form similarity cookbook components reused; duplicated-block count reuse ↑, dup ↓
6 Route count count routes for the modal → 2 (+1 for add-row)
7 OOB swaps grep -c hx-swap-oob → 0 unless a justified disjoint-region case is documented
8 Attribute consistency mixed :x-/"x-" encodings in migrated template → 0

How to measure (copy/paste)

F=src/clj/auto_ap/ssr/<modal>.clj
echo "LOC                $(wc -l < $F)"
echo "no-cursor twins    $(grep -c 'defn.*-no-cursor' $F)"
echo "faked-cursor roots $(grep -cE 'with-cursor|MapCursor\.' $F)"
echo "snapshot merges    $(grep -c ':multi-form-state :snapshot' $F)"
echo "branch forms       $(grep -cE '\(cond |\(condp |\(case |\(when-not ' $F)"
echo "hx-swap-oob        $(grep -c 'hx-swap-oob' $F)"
echo "mixed string hx-   $(grep -cE '\"hx-[a-z]' $F)"
# route count: count this modal's entries in src/cljc/auto_ap/routes/*.cljc

Results

Each migration appends one row (after-numbers), referencing the before in the diff.

Phase Modal LOC Routes no-cursor twins faked roots snapshot merges OOB mixed hx- cookbook reused / added
1 (baseline) Transaction Edit transaction/edit.clj 1608 ~12 1 2 ~75 0 8 — / seeded 7 entries
2 Transaction Edit transaction/edit.clj 1593 ~5 0 0 0 round-trip 0 8 (shared) location-select / 1 Selmer
2-final Transaction Edit (full Selmer + wizard removed) 1548 5 0 0 0 0 0 full sc/* lib / ~30 partials
3 Transaction Bulk Code transaction/bulk_code.clj 506 (was 420 — see exception) 3 0 0 0 0 0† reused all of Phase-2's sc/* lib + account-typeahead*/location-select* + edit-modal/transitioner chrome / added sc/select

† The one "hx-..." string hit is a response-header map ({"hx-trigger" "refreshTable, reset-selection"}), not a mixed attribute encoding. mm coupling 19→0, wizard records 3→0, step-params 10→0 (the 2 hits are comments), Hiccup-in-render → 0 except the shared com/success-modal (heuristic-9 exception, as in Phase 2). | 4 | POS Sales Summary pos/sales_summaries.clj | 732 (was 790) | 6 modal | 0 | 0 | 0 | 1✦ | 0✦ | reused sc/* lib + edit-modal/transitioner chrome / added the inline click-to-edit account-cell + manual-items patterns |

✦ The residual 1 hx-swap-oob and the "hx-..." string hits all live in the grid page code (the grid-page render lambdas + the filters form + the submit response-header map) — none are in the migrated modal render path, which is 100% Selmer. defrecord count 0 (all 4 wizard records gone), fc/ cursor refs 51→0, mm coupling 20→0, step-params 27→0 (2 comments). LOC dropped (this wizard held real custom code, unlike bulk-code's thin shell). Two pre-existing bugs fixed (per the user's call): the "New Summary Item" add button (was throwing newRowIndex is not defined) and the dead totals/balance display.

New heuristics introduced at 2-final (full Selmer)

# Heuristic Measure Target
9 Hiccup HTML tags in the render path grep -cE '\[:(div|span|p|a|button|input|h[1-6]|ul|li|label|select|option|t(able|head|body|r|d|h)|form|svg|template)' over the modal's render fns → 0 (success-modal confirmation dialogs may keep the shared Hiccup component)
10 mm wizard coupling grep -c 'mm/' the modal file + grep -c 'defrecord.*Wizard|ModalWizardStep' → 0 for a single-step modal

Phase 2 progress. Achieved with parity held (swap spec 6/6, transaction-edit spec 8/8, full suite 38 pass / 1 unrelated fail / 0 skip, up from 30/2/7):

  • deleted the dead *-no-cursor* twin (no-cursor 1→0);
  • de-faked the simple-mode cursor (faked roots 2→0) via explicit data + explicit field names (account-field-name) + explicit error lookup — the render-fn rewrite the with-field-default shortcut couldn't do;
  • collapsed the 5 manual-coding operation routes into one edit-form-changed dispatcher (routes ~12→~5; the operations are now pure apply-* fns);
  • fixed a real production bug (:mode → 500 on every advanced manual save);
  • greened transaction-edit.spec.ts (8/8) and matured the skill.

Phase 2 complete. The wizard→plain-form rewrite removed the snapshot round-trip (heuristic 2 → 0) and the first interactive component (location-select) is migrated to a Selmer template (selmer-conventions.md validated). Remaining for later phases: drop the now-thin mm/ModalWizardStep protocol wrappers, and the cross-cutting Phase 11 Selmer sweep of the shared com/typeahead/com/select/com/button-group-button (those shared call sites hold the last 8 mixed @/:-attr offenders; they clear when the shared components move to Selmer — not a single-modal task, per Open decision 2).

Phase 2-final — full Selmer + wizard removed. Every component the modal renders through was ported to a Selmer partial under resources/templates/components/ with a thin Clojure wrapper in auto-ap.ssr.components.selmer (sc/*); the modal's own structure lives under resources/templates/transaction-edit/. The mm wizard abstraction (EditWizard/LinksStep records, MultiStepFormState, step-params[...] field names, wrap-wizard/wrap-decode-multi-form-state middleware) was deleted — there was only ever one step, so it was pure overhead. Result: heuristic 8 (mixed hx-) and 9 (Hiccup in render) and 10 (mm coupling) all → 0; the edit-wizard-navigate route is gone (routes 5). Parity held: swap spec 6/6, transaction-edit spec 8/8, full suite 38 pass / 1 pre-existing unrelated fail (serial, fresh seed). The only Hiccup left in the file is the post-save com/success-modal confirmation dialogs (terminal, shared component — out of the form's render path). See form-vs-wizard.md (drop-the- wizard test), selmer-conventions.md (composition mechanics), and gotchas.md (stray-field decode leak; jetty reload staleness).

Phase 3 — Transaction Bulk Code (first cold apply of the mature skill). Single-step form wearing a full wizard costume (BulkCodeWizard/AccountsStep, MultiStepFormState, the step-params[...] prefix, the old find * location swap). Migrated to a plain form by mirroring Phase 2 — and it was mostly reuse: the entire sc/* Selmer component library, account-typeahead*/location-select*, and the edit-modal/transitioner chrome were imported wholesale; the only new shared component was sc/select (the status dropdown — location-select.html generalized). Parity held: bulk-code spec 13/13, full suite 39/39 (up from the Phase-2 baseline of 3839). mm coupling 19→0, snapshot merges 4→0, wizard records 3→0, routes 4→3 (open / submit / form-changed — the per-op new-account + vendor-changed routes folded into one form-changed op dispatcher), the location swap moved off find * onto explicit #account-location-<index> + hx-select.

The one regression — LOC 420→506 (documented exception, see gotchas.md). Unlike edit (whose wizard held real custom code), bulk-code's wizard was a thin shell that delegated almost everything to mm/* defaults (default-render-step, default-render-wizard, submit-handler, open-wizard-handler). Ripping the wizard out moves that previously-shared plumbing into the file as explicit render/decode/submit/handler code. The trade is intended: every other heuristic improved and the modal is now self-contained and wizard-free. New patterns added to the cookbook: the selection-as-ids[] round-trip (resolve the non-editable selection to a concrete id vector at open, ride it in hidden fields — the bulk analog of edit's single db/id), and the :id-keyed vendor typeahead (a value-bound hidden must be keyed or its posted value goes stale across a whole-form swap).

Phase 4 — POS Sales Summary (first modal with no prior test coverage). The largest migration so far and the first that required building the parity gate first: the modal had zero e2e/clj tests and the test server seeded no POS data, so the work began by seeding a balanced sales summary + writing a 7-test characterization spec (committed separately, ahead of the rewrite). Then the standard wizard→plain-Selmer migration: MainStep/EditWizard + MultiStepFormState deleted, the 51 fc/ cursor refs de-cursored into explicit data + Selmer, step-params dropped, the EDN snapshot replaced by flat wrap-decode/wrap-derive-state (with a db/id-keyed item merge so the read-only fields the form doesn't post — ledger-side, amount — survive a re-render). The inline click-to-edit account cell (pencil → typeahead editor → check/cancel) was preserved as three small targeted .account-cell-swap routes (a distinct feature, not folded into the form-changed dispatcher). LOC 790→732 (net ↓ — a fat wizard, opposite of bulk-code).

Characterize-then-fix. Writing the gate surfaced two pre-existing bugs: the "New Summary Item" button threw newRowIndex is not defined (dead since forever) and the totals/balance display was dead code (malformed Hiccup that discarded its labels). The spec first documented them as broken (never assert a bug as working); then, on the user's call, the migration fixed both — add-item is now a whole-form-swap op=new-item adding an editable manual row, and a proper #summary-totals block shows running Total + Balanced/Unbalanced (a Rule-4 targeted swap on manual amount edits). The spec was updated to assert the fixed behavior. New cookbook entries: the inline click-to-edit cell and the db/id-keyed item merge for partially-posted rows.

Phase 5 — Invoice Bulk Edit (cold apply in a 1812-line shared file). Structurally Phase 3's bulk-code applied to invoices (selected entities → expense-account rows: account/location/percentage), so it was near-pure reuse: bulk-code's flat-state plumbing (ids round-trip, wrap-bulk-state, schema/decode) + edit's account-totals-tbody for the live totals. BulkEditWizard/AccountsStep + MultiStepFormState deleted, step-params dropped, the rows de-cursored to Selmer with the explicit-id location swap, bulk-edit routes 5→3 (the new-account + total + balance routes folded into one form-changed op dispatcher + the sibling-<tbody> totals swap). Implemented the dead TOTAL/BALANCE display (the wizard had them commented out with a duplicate id="total") as a #expense-totals sibling-<tbody> refreshed by a Rule-4 percentage-keyup swap. Parity held: invoice-bulk-edit spec 5/5, full suite 50/50.

Editing a wizard buried in a large shared file: the clojure-mcp structural tools (clojure_edit / replace_sexp) reformat the whole file — here that was a spurious 650-line whitespace diff that would bury the real change. For a surgical migration inside a big multi-modal file, use the text-based Edit tool instead (the AGENTS.md "absolutely necessary" carve-out), then load-file + cljfmt to verify. The resulting diff was fully contained to the requires + the bulk-edit region.

Repeated-row target-selector convention — settled (the Phase 5 exit criterion). Across edit / bulk-code / sales-summary / invoice-bulk-edit the convention converged on: explicit per-row ids (#account-location-<index>, #account-row-<index>) for a cell-local swap (Rule 2), and a single stable-id sibling-<tbody> (#account-totals / #expense-totals) for running totals (Rule 4) — not data-attribute selectors or a form-path→selector helper. Per-row ids are generated from the row index the form already uses for field names (path->name2), so server and markup agree by construction. Whole-form swap (Rule 3) covers structural changes (add/remove row). This is now the cookbook default; see swap-doctrine.md.

Phase 6 — the wizard engine, and its first real modal (Transaction Rule). The inflection phase. (a) Engine (6a, committed separately): wizard-state + wizard2, the Django formtools SessionStorage model, REPL-proven before any modal touched it. (b) First real modal (6b): the Transaction Rule wizard (edit step + read-only test/preview step) migrated onto the engine and fully de-cursored like Phases 2-5. Scorecard (admin/transaction_rules.clj): fc/ cursor refs 82 -> 0, mm/ coupling 20 -> 0, defrecords 3 -> 0 (EditModal / TestModal / TransactionRuleWizard all gone), LOC 1000 -> 964, the 4 wizard routes (open/navigate/save + per-dialog) collapse to 2 (open-rule-wizard for new+edit, save-step for every transition). Parity held: rule spec 4/4, full suite 55/55.

The engine generalizes even for a one-data-step "wizard". Transaction Rule is edit + a read-only preview of the same entity, not two independent data steps — so it exercises the engine's render / navigation / :all-data-preview path but not the cross-step merge (that waits for Phase 7's Invoice Pay). The test step's :render reads :all-data (the engine's get-all), which here is just the edit step's rule — so the formtools "combine at the end" mechanism is exactly what feeds the preview table. Nav is the engine's direction field (plain submit buttons name="direction" value="next|back|submit"), so the per-step navigate route is deleted.

Note (scope): the de-cursored edit step keeps com/* Hiccup leaf components rather than porting to sc/* Selmer partials — the modal's value was removing fc/ + mm/ and proving the engine, not re-templating its (conditional, Alpine-cross-field) layout. Hiccup-in-render (heuristic 9) is therefore a documented partial here; the leaf-component com/ -> sc/ swap is a mechanical follow-up. The Alpine cross-field dispatch wiring (clientId -> accountId -> location) was preserved verbatim — de-cursoring touched only the data plumbing.

Phase 7 — Invoice Pay: the engine's cross-step merge, finally proven. The first genuine multi-data-step wizard (every prior one was single-data-step wearing wizard costume, or edit+preview of one entity). Step 1 choose-method collects {:bank-account :method}; step 2 payment-details collects {:invoices :check-number :handwritten-date :mode}; the engine's get-all merges the two independent payloads for the per-method pay! (handwrite-check transacts a pending check; the others go through print-checks-internal). This is the exact mechanism the Phase-6 reviewer flagged as unproven — now exercised end-to-end (gate 3/3: choose-method renders the bank-account + methods → handwrite-check advances to details → check number + submit shows the completion modal). Conditional rendering by method (handwrite shows check-number, print shows date) lives in step 2's render, reading :method from :all-data.

The whole file falls off the framework. Invoice Pay was the last mm/fc user in invoices.clj (bulk-edit went in Phase 5), so the migration zeroed the file: fc/ cursor refs 0, mm/ 0, defrecord 0 (PayWizard + ChoosePaymentMethodModal + PaymentDetailsStep all gone), step-params 0 — and the multi-modal / form-cursor / malli.util requires were deleted outright. The pay wizard's 3 routes (open / navigate / submit) collapse to 2 (open = open-pay-wizard, every transition = pay-step); the cards post {bank-account, method, direction:next} straight to the engine submit-route instead of a bespoke navigate route.

Engine dividends from the review follow-up paid off here. This migration used the primitives the engine absorbed after Phase 6: :open-response (modal wrap, so open is one handler), nav-footer (with the new :save-label "Pay"), the auto nav-field stripping (the flat {bank-account, method} decode needs no allowlist), and the Enter guard — so the consumer is config + render + the per-method pay!, not framework plumbing.

New constraint discovered: wizard session data must be EDN-safe (the cookie store has no clj-time readers) — see gotchas.md. The de-cursored amounts grid stores the enriched invoice list in :context, which is fine at gate scale (1 invoice) but is the session-bloat risk the reviewer named; a leaner context (ids + amounts, re-query in render) is the follow-up if real payments carry many invoices.


Phase 8 — New / Edit Invoice (the conditional-:next, dual-purpose wizard)

auto-ap.ssr.invoice.new-invoice-wizard — 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 accounts is skipped on the default-accounts path), Solr typeaheads for client+vendor, an async account-prediction fragment, and live expense-account totals.

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 (the {}→nil main-transformer quirk — see gotchas.md). Production uses the identical wrap-params, so it was broken there too; the underlying create only worked when POSTed straight to new-invoice-submit. So the Phase 8 gate (e2e/invoice-new.spec.ts) is an acceptance gate, not a green→green characterization: red on the old code, green on the engine (whose submit is a POST with no query-schema). The migration fixes a latent bug. The create semantics (default → vendor default account, location-spread; customize → the posted grid; edit → prefilled + updated row) were pinned via REPL before/around the migration.

Conditional :next is a one-liner ((if (= :customize …) :accounts :done)) replacing the mm/CustomNext protocol + the broken 308-to-submit. Dual-purpose = one :init-fn branching on a route :db/id; create-wizard! seeds :init-data as per-step step-data so edit opens both steps populated. See form-vs-wizard.md.

Coupling outcome (the review's lens). The whole wizard collapses to config + render + fragments: defrecord 4 → 0 (NewWizard2 / BasicDetailsStep / AccountsStep / NextSteps all gone), mm/ 0, fc/ cursor refs 0, step-params[…] field names 0. The broken new-wizard-navigate route is deleted (3 wizard-nav routes → the engine's open + submit); the genuine async helpers (account-prediction, due-date, scheduled-payment-date, location-select, expense-account total/balance, add-row) remain but were de-coupled from multi-form-state — each now reads the posted flat form (+ ws/get-all for the one cross-step value). next-steps stops being a wizard step and becomes the done-fn's returned modal (Pay now / Add another / Close), matching the Phase 7 pay-success shape.

Verification: full e2e suite 61/61 (58 prior + 3 new: basic-details renders; default-path create → next-steps; customize-path → accounts grid → create → next-steps); the maybe-spread-locations unit test still 6/6; create semantics + edit prefill confirmed at the REPL; dates ride as #inst so step-data is EDN-safe across the non-terminal step.


Phase 9 — New / Edit Vendor (5-step linear wizard)

auto-ap.ssr.admin.vendors — a five-step linear wizard (info → terms → account → address → legal) plus a separate Merge dialog. Migrated onto the engine following the Phase 8 template.

Latent bug found + fixed (again): the old "Next" PUT /admin/vendor/navigat (note the typo) carried a [:map [:db/id entity-id]] route-schema on a route with no :db/id path param, so empty route-params {}→nil 500d every advance (same main-transformer quirk as Phase 8's query-params). The engine's submit is a POST with no such schema → gone.

Coupling outcome: defrecord 5 → 0 (InfoModal / TermsModal / AccountModal / AddressModal / LegalEntityModal + VendorWizard all gone), mm/ 0, fc/ cursor refs 0 (the wizard and the de-cursored Merge dialog), step-params[…] 0. Routes: the broken navigate is deleted (open + submit + 3 add-rows + account-typeahead remain). The 5 step renders are plain data + path->name2 + a *errors* binding; the 3 repeated grids (terms-overrides / automatic-payment / account-overrides) became add-row-handler + a blank-row row render. The wizard timeline is preserved as a per-step side panel.

Engine refinements exercised: conditionless linear :next; :init-fn branches new (empty) vs edit (entity split across the 5 steps' :init-data, which create-wizard! seeds as per-step step-data so edit opens fully populated); per-step :validate via mc/validate + me/humanize replaces the old wrap-ensure-step schema assertion; vendor-step wraps handle-step-submit in try+ to surface create-time validation as a 4xx. Two new gotchas surfaced and are documented: the empty-step {}→nil decode trap and the blank-nested-entity upsert error (see gotchas.md).

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 semantics also confirmed at the REPL (incl. the cookie-session EDN round-trip). maybe-spread-locations-style domain helpers untouched.