refactor(ssr): Phase 8 — migrate New/Edit Invoice onto the engine (conditional :next)
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>
This commit is contained in:
@@ -189,3 +189,43 @@ entity*, not a true multi-data-step flow — so it exercises the engine's render
|
||||
preview path (`:all-data` feeds the test table) but not the cross-step *merge*. The merge
|
||||
(`get-all` combining independent steps) gets its real workout in Phase 7+ (Invoice Pay,
|
||||
New Invoice, Vendor, Client), where steps collect genuinely different fields.
|
||||
|
||||
## Conditional `:next` + dual-purpose (new+edit) — New Invoice (Phase 8)
|
||||
|
||||
A step's `:next` is just `(fn [data] -> next-step-key | :done)`, so **branching the flow is a
|
||||
one-liner** — no `CustomNext` protocol, no 308-redirect-to-submit hack:
|
||||
|
||||
```clojure
|
||||
{:key :basic-details
|
||||
:next (fn [data] (if (= :customize (:customize-accounts data)) :accounts :done))}
|
||||
```
|
||||
|
||||
`:default` skips the expense-accounts step entirely (the done-fn uses the vendor's default
|
||||
account); `:customize` routes through the grid. The old wizard expressed this with
|
||||
`mm/CustomNext` returning either `navigate-handler{:to :accounts}` or a 308 to the submit
|
||||
route — and the 308 path was broken (see `gotchas.md`, the `{}`→nil 500). The engine's
|
||||
conditional `:next` is both simpler and correct.
|
||||
|
||||
**Dual-purpose (create *and* edit) = one config, one `:init-fn` that branches on a route id:**
|
||||
|
||||
```clojure
|
||||
(defn new-init-fn [request]
|
||||
(if-let [id (->db-id (get-in request [:route-params :db/id]))]
|
||||
{:init-data {:basic-details (… entity prefilled, :customize-accounts :customize)
|
||||
:accounts {:invoice/expense-accounts (… existing rows)}}} ; edit
|
||||
{:init-data {:basic-details {:invoice/date (coerce/to-date (time/now)) ; new
|
||||
:customize-accounts :default}}}))
|
||||
```
|
||||
|
||||
`create-wizard!` stores `:init-data` **as the per-step `:step-data` map directly**, so seeding
|
||||
`{:basic-details … :accounts …}` opens both steps populated — the edit case repopulates the
|
||||
grid without a separate hydrate. Two open routes (`new-wizard`, `edit-wizard`) both reduce to
|
||||
`(partial wizard2/open-wizard config)`; the done-fn branches on `(:db/id all-data)` to return
|
||||
the next-steps modal (create) vs the swapped table row (edit).
|
||||
|
||||
**Async step fragments read the posted form, not multi-form-state.** The basic-details
|
||||
fragments (account-prediction radio, due-date / scheduled-payment suggestions) and the
|
||||
accounts totals all post the whole `#wizard-form`; in the engine that form carries the flat
|
||||
`invoice/*` fields + the opaque `wizard-id`, so a fragment decodes what it needs straight from
|
||||
`form-params` (and, for a cross-step value like the invoice total on the accounts step, reads
|
||||
`ws/get-all` via the posted `wizard-id`). No `mm/wrap-decode-multi-form-state` stack survives.
|
||||
|
||||
Reference in New Issue
Block a user