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>
This commit is contained in:
2026-06-25 22:39:10 -07:00
parent 5502f4c4a2
commit c5dc305854
5 changed files with 597 additions and 614 deletions

View File

@@ -365,3 +365,35 @@ before they land in step-data, and coerce back to clj-time only for display
(`->edn-safe-dates`) right after `mc/decode` is the clean seam — both the step `:decode` and
the edit `:init-fn` run the posted/persisted map through it. Datomic's upsert wants
`java.util.Date` anyway, so the done-fn needs no extra conversion.
## The `{}`→nil trap has a THIRD face: empty-step decode → validation "invalid type"
Beyond query-params (Phase 8) and route-params (Phase 9's `/navigat`), the same
`parse-empty-as-nil` `:map` decoder bites a wizard step whose fields are all blank: an
all-empty step posts only blank inputs → the decoded all-nil map collapses to `nil`. If that
`nil` then flows into a `:validate` that does `(mc/validate step-schema data)`, validation
fails with `[invalid type]` (nil isn't a map) and the step can never advance — even though
every field is optional. The legal/address steps (all-optional) hit this.
Fix at the seam: have the step `:decode` coerce nil back to `{}`:
```clojure
(defn- decode-with [schema request]
(or (mc/decode schema (... nested form-params ...) main-transformer) {}))
```
Now an optional-only step validates `{}` (passes, advances) while a required-field step
(e.g. account needs `:vendor/default-account`) still fails on the *missing key*, not on a
spurious nil. Don't "fix" it by skipping validation when data is nil — that lets a genuinely
empty required step through.
## A new (db/id-less) nested entity with all-nil fields → datomic "tempid used only as value"
The empty Address step decodes to `{:vendor/address {:address/street1 nil, …}}` — a map of
nils with no `:db/id`. `:upsert-entity` mints a tempid for that nested map but, since every
attribute is nil, the address entity has nothing transacted, so the tempid is referenced as
a ref value but never defined → `:db.error/tempid-not-an-entity … used only as value`. Drop
such blank nested maps before the upsert:
```clojure
(defn- blank-address? [a] (and (map? a) (not (:db/id a)) (every? nil? (vals a))))
```
This is the nested-entity analogue of "don't create empty rows"; the engine's `blank-row`
gives *added* rows a tempid, but a never-touched optional nested entity must be elided.