Compare commits
56 Commits
569e52d1c1
...
integreat-
| Author | SHA1 | Date | |
|---|---|---|---|
| 638a75925c | |||
| 8114db9988 | |||
| 3251b364a1 | |||
| ddffbf58f9 | |||
| 7b0e8bfd65 | |||
| b0fe7cc70d | |||
| c5dc305854 | |||
| 5502f4c4a2 | |||
| 7a53441ec7 | |||
| 4b2a3e53dd | |||
| 01aca9d362 | |||
| a2d8517668 | |||
| 107a02f4f1 | |||
| 15ff9855c1 | |||
| d56056d66c | |||
| 2bf87056d7 | |||
| 4139919036 | |||
| 599b849e6f | |||
| a289ff2557 | |||
| 03620e9d42 | |||
| 70c178de83 | |||
| e2ccfc8d2c | |||
| e8cbd2760c | |||
| e0da8e1866 | |||
| 2e3c1e3646 | |||
| a7e9fbaf6b | |||
| 8a676718a7 | |||
| 3ffb661da3 | |||
| f9438ba983 | |||
| 7d34b8a5f6 | |||
| c09d85ede6 | |||
| ec4f88b7fc | |||
| 8ca5e75c4d | |||
| 4aed27b204 | |||
| d0028f403c | |||
| 6b4392b74b | |||
| cdc87d3710 | |||
| 1e3952a7fb | |||
| e099714af1 | |||
| 11024b7b89 | |||
| de2a1ab850 | |||
| 85aaf7b759 | |||
| 3641846f70 | |||
| d360316590 | |||
| 8215e6376d | |||
| 3759258ebe | |||
| 0e02c489e0 | |||
| 917b7f3857 | |||
| a8d8a8d111 | |||
| 360847fa58 | |||
| 55650c2dab | |||
| 19186097d5 | |||
| 1f6395382d | |||
| d52159637e | |||
| 3648597031 | |||
| 901d9eb508 |
122
.claude/skills/ssr-form-migration/SKILL.md
Normal file
122
.claude/skills/ssr-form-migration/SKILL.md
Normal file
@@ -0,0 +1,122 @@
|
||||
---
|
||||
name: ssr-form-migration
|
||||
description: Migrate an SSR form or wizard modal to the whole-form HTMX swap doctrine, top-rooted render functions, the data-driven session-backed wizard engine, and (where it helps) Selmer templates. Use when simplifying a server-rendered form/wizard modal in this codebase, removing EDN-snapshot round-trips, deleting *-no-cursor* duplicate render fns, collapsing per-interaction routes, or replacing the multi-step wizard protocol machinery.
|
||||
---
|
||||
|
||||
# SSR Form & Wizard Migration
|
||||
|
||||
A repeatable method for making a server-rendered form/wizard modal **simpler** without
|
||||
changing user-facing behavior. Distilled from the first proven migration — the
|
||||
`transaction/edit.clj` modal, which already runs on the whole-form `hx-select` swap
|
||||
approach with **zero out-of-band swaps**. Every migration *reads this skill first* and
|
||||
*extends it last* (the Growth contract below). If migration N+1 is not easier than N,
|
||||
the skill-update step was skipped — treat that as a bug.
|
||||
|
||||
The four patterns every migration moves code toward live in `reference/`:
|
||||
|
||||
- `reference/swap-doctrine.md` — the four-rule HTMX swap priority order + the focus
|
||||
invariant + Alpine-survives-swap hardening + target-selector strategy.
|
||||
- `reference/render-functions.md` — one render fn per component, taking explicit data
|
||||
**or a top-rooted cursor**; no faked cursor positions, no `*-no-cursor*` twins.
|
||||
- `reference/form-vs-wizard.md` — single-step → plain form; multi-step → data-driven
|
||||
engine with **per-step state in the Ring session** (the Django `formtools` model).
|
||||
- `reference/selmer-conventions.md` — plain-HTML attributes via Selmer, the
|
||||
Hiccup↔Selmer interop bridge, include/block patterns.
|
||||
|
||||
Growing cookbooks (append every migration):
|
||||
`component-cookbook.md`, `gotchas.md`, `test-recipes.md`, `scorecard.md`.
|
||||
|
||||
---
|
||||
|
||||
## The per-migration playbook
|
||||
|
||||
Run this loop for each modal. The phase notes in the migration plan list only what is
|
||||
*specific* to a modal; this loop is the constant.
|
||||
|
||||
1. **Read the skill.** Skim `reference/` and note which `component-cookbook.md`
|
||||
entries and `gotchas.md` you can reuse. Start from the cookbook, not a blank file.
|
||||
|
||||
2. **Classify** (`reference/form-vs-wizard.md`).
|
||||
- Single logical step (even with a `?mode=` toggle or add/remove rows) → **plain
|
||||
form**: no server-side wizard state, no snapshot, no protocol.
|
||||
- Genuinely multiple steps the user advances through → **wizard**: the data-driven
|
||||
engine + per-step session storage.
|
||||
- When in doubt, it's a form.
|
||||
|
||||
3. **Baseline the scorecard** (`scorecard.md`, heuristics in §6 of the plan). Record
|
||||
before-numbers with cheap tools:
|
||||
```bash
|
||||
F=src/clj/auto_ap/ssr/<modal>.clj
|
||||
wc -l $F # LOC (heuristic 4)
|
||||
grep -c 'defn.*-no-cursor' $F # *-no-cursor* twins (heuristic 1)
|
||||
grep -cE 'with-cursor|MapCursor\.' $F # faked cursor re-roots (heuristic 1)
|
||||
grep -c 'hx-swap-oob' $F # OOB swaps (heuristic 7)
|
||||
grep -cE '"hx-[a-z]' $F # mixed string hx- attrs (heuristic 8)
|
||||
# route count: count this modal's entries in src/cljc/auto_ap/routes/*.cljc
|
||||
```
|
||||
|
||||
4. **Characterize behavior (test-first).** Write or confirm a Playwright spec that
|
||||
captures *current* behavior before you touch anything — focus/caret survival across
|
||||
swaps, each field round-trip, validation errors, and the real save. This spec is the
|
||||
parity contract; it must stay green through every commit. See `test-recipes.md`.
|
||||
|
||||
5. **Consolidate render functions** (`reference/render-functions.md`). Make each render
|
||||
fn take explicit data or a **top-rooted cursor**. Delete `*-no-cursor*` duplicates
|
||||
and any `with-cursor`/`MapCursor` rebind that fakes a deep starting position
|
||||
(heuristics 1, 2). Using a cursor is fine; faking where it *starts* is not.
|
||||
|
||||
6. **Templatize in Selmer** (`reference/selmer-conventions.md`) where the component is
|
||||
interactive/attribute-heavy. Reuse cookbook bits; add new ones back (heuristics 5, 8).
|
||||
Static markup may stay Hiccup — Selmer scope is hybrid (Open decision 2).
|
||||
|
||||
7. **Wire HTMX per the swap doctrine** (`reference/swap-doctrine.md`). Keep the focus
|
||||
invariant intact. No OOB except a genuinely disjoint region, documented (heuristic 7).
|
||||
|
||||
8. **Collapse routes** to 2 (`GET` open, `POST` submit) — `+1` for an add-row endpoint,
|
||||
`+1` for the single `*-form-changed` whole-form re-render endpoint (heuristic 6).
|
||||
|
||||
9. **Verify.** Modal e2e green + full e2e suite at-or-above baseline; assert DB
|
||||
mutations by querying Datomic, not markup; REPL-check the pure render/data fns.
|
||||
Re-measure the scorecard — **no metric may regress for the touched modal** without a
|
||||
written exception in `gotchas.md`.
|
||||
|
||||
10. **Commit** one reversible feature commit. The message includes the scorecard delta
|
||||
and the reused/new cookbook entries.
|
||||
|
||||
11. **Feed the skill** (the Growth contract). *Not optional.*
|
||||
|
||||
---
|
||||
|
||||
## Growth contract — the last task of every migration
|
||||
|
||||
- Converted a component? → add its before/after to `component-cookbook.md`.
|
||||
- Hit a surprise? → one entry in `gotchas.md`.
|
||||
- Found a test pattern? → `test-recipes.md`.
|
||||
- Playbook step missing or wrong? → fix this `SKILL.md`.
|
||||
- Measured the scorecard? → append the row to `scorecard.md`.
|
||||
|
||||
**Success signal:** each migration reuses more cookbook entries and starts from a better
|
||||
scorecard baseline than the previous one.
|
||||
|
||||
---
|
||||
|
||||
## Non-negotiables
|
||||
|
||||
- **Focus invariant:** the input the user is typing in is *never* inside the region its
|
||||
own request swaps. Violating this drops the caret. (Proven by the
|
||||
`transaction-edit-swap.spec.ts` caret tests.)
|
||||
- **No new OOB swaps.** If tempted to OOB something inside the same feature, restructure
|
||||
the DOM so the dependent element shares an ancestor with the trigger and use an
|
||||
ordinary swap (e.g. totals in a sibling `<tbody>`).
|
||||
- **Behavior parity is proven by tests, not by reading.** The full e2e suite stays green
|
||||
after every migration.
|
||||
- **Don't game the heuristics.** They're directional evidence paired with the e2e parity
|
||||
gate; review the trend, not single numbers.
|
||||
|
||||
## Project conventions that bite (see `gotchas.md`)
|
||||
|
||||
- Edit Clojure with the clojure-mcp tools (`clojure_edit`, `clojure_edit_replace_sexp`),
|
||||
not the raw file editor. `clj-paren-repair` then `lein cljfmt fix` when a file won't
|
||||
compile.
|
||||
- Run tests via the `clojure-eval` skill / `clj-nrepl-eval -p PORT`, not `lein test`.
|
||||
- Temp files go in `./tmp/`.
|
||||
@@ -0,0 +1,205 @@
|
||||
# Component cookbook
|
||||
|
||||
GROWS every migration. Each entry: what it is, the swap rule it uses, and the canonical
|
||||
snippet. Reuse these before writing anything new; the success signal is *more reuse each
|
||||
migration*.
|
||||
|
||||
Seeded from `transaction/edit.clj` (Hiccup form — Selmer versions land in Phase 2).
|
||||
|
||||
---
|
||||
|
||||
## typeahead (account / vendor) — Alpine + tippy, survives swaps
|
||||
|
||||
Used for account and vendor selection. Click-to-select (not a live text caret), so a
|
||||
whole-form swap on change is safe. Null-guard `tippy?`/`$refs.input?`.
|
||||
|
||||
```clojure
|
||||
(defn account-typeahead* [{:keys [name value client-id x-model]}]
|
||||
[:div.flex.flex-col
|
||||
(com/typeahead {:name name
|
||||
:placeholder "Search..."
|
||||
:url (hu/url (bidi/path-for ssr-routes/only-routes :account-search)
|
||||
(cond-> {:purpose "transaction"} client-id (assoc :client-id client-id)))
|
||||
:id name
|
||||
:x-model x-model ; binds selected value into the row's Alpine scope
|
||||
:value value
|
||||
:content-fn (fn [v] (:account/name (d-accounts/clientize ... v client-id)))})])
|
||||
```
|
||||
Reuse note: `:x-model` lets the *parent* row read the selected id (e.g. `accountId`) to
|
||||
gate a targeted location swap. See account-row.
|
||||
|
||||
## account-row — cursor render fn + per-row targeted location swap + whole-form remove
|
||||
|
||||
The canonical "row in a repeated grid" pattern. One render fn, top-rooted cursor.
|
||||
- account typeahead binds `accountId` into row Alpine scope;
|
||||
- **location cell** swaps *only itself* (`#account-location-<index>`) on `changed`
|
||||
(swap-doctrine Rule 2);
|
||||
- **amount cell** swaps *only* `#account-totals` (Rule 4, sibling tbody);
|
||||
- **remove** swaps the whole form (Rule 3).
|
||||
|
||||
```clojure
|
||||
(defn transaction-account-row* [{:keys [value client-id amount-mode index]}]
|
||||
(com/data-grid-row
|
||||
(-> {:class "account-row" :id (str "account-row-" index)
|
||||
:x-data (hx/json {:show ... :accountId (fc/field-value (:transaction-account/account value))})
|
||||
:data-key "show" :x-ref "p"}
|
||||
hx/alpine-mount-then-appear)
|
||||
(fc/with-field :db/id (com/hidden {:name (fc/field-name) :value (fc/field-value)}))
|
||||
(fc/with-field :transaction-account/account
|
||||
(com/data-grid-cell {} (com/validated-field {:errors (fc/field-errors)}
|
||||
(account-typeahead* {:value (fc/field-value) :client-id client-id
|
||||
:name (fc/field-name) :x-model "accountId"}))))
|
||||
(fc/with-field :transaction-account/location
|
||||
(com/data-grid-cell {:id (str "account-location-" index)} ...Rule 2 targeted swap...))
|
||||
(fc/with-field :transaction-account/amount
|
||||
(com/data-grid-cell {} ...Rule 4 totals swap...))
|
||||
(com/data-grid-cell {:class "align-top"} ...Rule 3 whole-form remove...)))
|
||||
```
|
||||
TODO Phase 2: drop the `transaction-account-row-no-cursor*` twin; this is the only kept form.
|
||||
|
||||
## totals in a sibling `<tbody>` — Rule 4 instead of OOB
|
||||
|
||||
Running totals live in their own `<tbody id="account-totals">`, a sibling of the
|
||||
input-bearing rows, so an amount edit refreshes them with a plain targeted swap and never
|
||||
replaces the amount input (caret survives).
|
||||
|
||||
```clojure
|
||||
(com/data-grid
|
||||
{:footer-tbody
|
||||
[:tbody {:id "account-totals"}
|
||||
(com/data-grid-row {:class "account-total-row"} ... (account-total* request) ...)
|
||||
(com/data-grid-row {:class "account-balance-row"} ... (account-balance* request) ...)]}
|
||||
...input rows...)
|
||||
```
|
||||
|
||||
## money-input / text-input amount field — Rule 4 targeted totals swap
|
||||
|
||||
```clojure
|
||||
(com/money-input
|
||||
{:name (fc/field-name) :id (str "account-amount-" index) :class "w-16 account-amount-field"
|
||||
:hx-post (bidi/path-for ssr-routes/only-routes ::route/edit-form-changed)
|
||||
:hx-target "#account-totals" :hx-select "#account-totals" :hx-swap "outerHTML"
|
||||
:hx-trigger "keyup changed delay:300ms" :hx-include "closest form"})
|
||||
```
|
||||
`%` mode swaps to `com/text-input {:type "number" :step "0.01"}` with the same swap attrs.
|
||||
|
||||
## memo field — Rule 1, no request
|
||||
|
||||
```clojure
|
||||
(com/text-input {:value (fc/field-value) :name (fc/field-name) :id "edit-memo"
|
||||
:placeholder "Optional note"}) ; no hx-* — rides along to save
|
||||
```
|
||||
|
||||
## location-select — first Selmer-migrated component (validated)
|
||||
|
||||
The account row's location `<select>`, rendered from a Selmer template instead of
|
||||
`com/select`. The first interactive modal component off Hiccup; proves the render-file
|
||||
path + interop bridge on real, e2e-covered markup (swap 6/6, transaction-edit 8/8).
|
||||
|
||||
```clojure
|
||||
;; templates/components/location-select.html — plain HTML, {% for %} + {% if selected %}
|
||||
(defn location-select* [{:keys [name client-locations value ...]}]
|
||||
(let [options (cond ...) ; [[value label] ...]
|
||||
selected (or value (ffirst options))
|
||||
classes (str/join " " (conj (vec inputs/default-input-classes) "w-full"))]
|
||||
(sel/render->hiccup "templates/components/location-select.html"
|
||||
{:name name :classes classes
|
||||
:options (for [[v l] options] {:value v :label l :selected (= v selected)})})))
|
||||
```
|
||||
Reuse: pass `inputs/default-input-classes` in (don't hard-code); embed via
|
||||
`render->hiccup` so it drops into the still-Hiccup row. See `selmer-conventions.md`.
|
||||
|
||||
## fixed-index row from explicit data — de-faking a deep cursor
|
||||
|
||||
When a row always lives at a known index (e.g. simple mode renders exactly `accounts[0]`),
|
||||
render it from **explicit data with explicit field names** instead of faking a cursor
|
||||
rooted there. Build the name the same way the cursor would (`path->name2`) and read errors
|
||||
from the same path — no `with-cursor`/`MapCursor` rebind, no `with-field-default` (which
|
||||
*mutates* the cursor and breaks swap behavior, see `gotchas.md`).
|
||||
|
||||
```clojure
|
||||
(defn- account-field-name [index field] ; == path->name2 for this path
|
||||
(str "step-params[transaction/accounts][" index "]["
|
||||
(if (keyword? field)
|
||||
(str (when (namespace field) (str (namespace field) "/")) (name field))
|
||||
field) "]"))
|
||||
|
||||
(defn- account-field-errors [index field]
|
||||
(when (bound? #'fc/*form-errors*)
|
||||
(get-in fc/*form-errors* [:step-params :transaction/accounts index field])))
|
||||
|
||||
;; render the row directly -- no fc/with-field / fc/with-cursor wrappers
|
||||
[:span
|
||||
(com/hidden {:name (account-field-name 0 :db/id) :value row-id})
|
||||
(com/validated-field {:errors (account-field-errors 0 :transaction-account/account)}
|
||||
(account-typeahead* {:name (account-field-name 0 :transaction-account/account) ...}))
|
||||
...]
|
||||
```
|
||||
Verify byte-parity against the cursor version (the swap spec's simple-mode tests catch
|
||||
divergence). Scorecard heuristic 1: faked roots → 0.
|
||||
|
||||
## mode toggle ($/% radio, simple/advanced link) — Rule 3, whole-form swap
|
||||
|
||||
```clojure
|
||||
(com/radio-card {:options [{:value "$" :content "$"} {:value "%" :content "%"}]
|
||||
:value amount-mode :name "step-params[amount-mode]"
|
||||
:hx-post (bidi/path-for ssr-routes/only-routes ::route/toggle-amount-mode)
|
||||
:hx-target "#wizard-form" :hx-select "#wizard-form" :hx-swap "outerHTML"
|
||||
:hx-include "closest form"})
|
||||
```
|
||||
TODO Phase 2: the simple/advanced toggle becomes a `?mode=` re-render (plain form), not a
|
||||
dedicated route.
|
||||
|
||||
---
|
||||
|
||||
## The Selmer component library (`auto-ap.ssr.components.selmer` / `sc`) — Phase 2-final
|
||||
|
||||
Every shared component the modal renders through is now a thin Clojure wrapper over a
|
||||
partial under `resources/templates/components/`. **Reuse these before reaching for the
|
||||
Hiccup `com/*` versions in a migrated modal.** Each wrapper builds a context (reusing the
|
||||
real class helpers so output matches modulo Tailwind order) and renders its own partial via
|
||||
the interop bridge; dynamic HTMX/Alpine attrs go through `sc/attrs->str` →
|
||||
`{{ attrs|safe }}`. See `selmer-conventions.md` for the mechanics.
|
||||
|
||||
| Wrapper | Partial | Notes |
|
||||
|---------|---------|-------|
|
||||
| `sc/hidden` / `sc/text-input` / `sc/money-input` | `hidden`/`text-input`/`money-input`.html | leaf inputs; class via `inputs/default-input-classes` + `use-size` |
|
||||
| `sc/select` (Phase 3) | `select.html` | generic `<select>`; `options [[value label] …]`, `:value` (string/keyword) marks selected, extra hx-/x- attrs ride through. `location-select.html` generalized — reach for this before `com/select`. Added for the bulk-code status field. |
|
||||
|
||||
## inline click-to-edit cell (Phase 4) — targeted `.account-cell` swap, not a whole-form op
|
||||
|
||||
A "display value + pencil → edit-in-place → check/cancel" cell. Three tiny **stateless** routes,
|
||||
each swapping just the cell (`hx-target="closest .account-cell"`, `outerHTML`): a `display` cell
|
||||
(value + pencil `hx-get edit`), an `edit` cell (typeahead + check `hx-put save` / cancel
|
||||
`hx-get cancel`). State rides in the request (item index + current value via `hx-vals`), so no
|
||||
server-side "which cell is editing" flag is needed. Keep it as its own routes — it is a distinct
|
||||
feature, *not* folded into the whole-form `form-changed` dispatcher (that would lose the targeted
|
||||
swap and re-render the whole modal on every pencil click). The cells are assembled with `sc/*` +
|
||||
`sel/raw` strings (like `edit.clj`'s `footer*`); SVGs ride in as `svg/*` Hiccup via the
|
||||
`sc/a-icon-button` body (no `[:svg]` literal lands in the modal file).
|
||||
|
||||
## db/id-keyed item merge (Phase 4) — for rows the form posts only partially
|
||||
|
||||
When a row renders some fields read-only (so they aren't posted) but the entity holds them
|
||||
(sales-summary auto items post only db/id/category/account — not ledger-side/amount), the flat
|
||||
`wrap-derive-state` must **overlay posted items onto the persisted items by `:db/id`** so the
|
||||
unposted fields survive a re-render: `(merge (by-id (:db/id posted)) posted)`. New rows (temp
|
||||
`:db/id` not in the entity) ride through as-is. This is the row-level analog of edit's
|
||||
"entity-only fields always from the entity"; without it, a re-render drops ledger-side/amount and
|
||||
the debit/credit split + totals break.
|
||||
| `sc/validated-field` | `validated-field.html` | label + body + always-present error `<p>`; pass-through attrs land on the wrapping div (the per-row location cell hangs its swap wiring here) |
|
||||
| `sc/button` / `sc/a-button` / `sc/a-icon-button` | `button`/`a-button`/`a-icon-button`.html | spinner via `{% include "spinner.html" %}`; class via `btn/bg-colors` |
|
||||
| `sc/badge` / `sc/link` | `badge`/`link`.html | |
|
||||
| `sc/button-group` / `sc/button-group-button` | `button-group(+button).html` | the group does **not** mutate children's classes (the Hiccup `group-` added rounded-l/r) — add rounding in the caller/template (tabs do) |
|
||||
| `sc/radio-card` | `radio-card.html` | reproduces the `select-keys [:hx-post :hx-target :hx-swap :hx-include :hx-trigger]` filter (drops `:hx-vals`/`:hx-select`) **and** the dangling-`[:h3]` quirk: only the `<ul>` renders |
|
||||
| `sc/data-grid` (+ `-header`/`-row`/`-cell`) | `data-grid*.html` | table shell + optional `footer-tbody` (the swappable totals tbody) |
|
||||
| `sc/typeahead` | `typeahead.html` | full Alpine + tippy; resolves `{value,label}` server-side via `content-fn`; every `tippy?.` null-guard preserved; hidden posting `<input>` with `:value="value.value"` + the `x-init` watcher |
|
||||
| `sc/modal` | `modal.html` | the `@click.outside="open=false"` wrapper |
|
||||
| SVGs | `spinner`/`svg-x`/`svg-external-link`/`svg-drop-down`.html | static, `{% include %}`d so the markup isn't duplicated |
|
||||
|
||||
Modal-specific structure lives under `resources/templates/transaction-edit/`
|
||||
(`edit-form`, `edit-modal`, `links-body`, `manual-coding`, `simple-mode`, `account-totals`,
|
||||
`details-panel`, the four match panels, `transitioner`). The render fns in `edit.clj`
|
||||
gather data, call `sc/*`, and interpolate the fragments into these layout templates as
|
||||
`{{ frag|safe }}`. **Verify each wrapper by class-set equality + e2e, never byte-parity**
|
||||
(`hh/add-class` is set-based, so class order differs from the Hiccup output).
|
||||
264
.claude/skills/ssr-form-migration/reference/form-vs-wizard.md
Normal file
264
.claude/skills/ssr-form-migration/reference/form-vs-wizard.md
Normal file
@@ -0,0 +1,264 @@
|
||||
# Forms vs. wizards (and the data-driven wizard engine)
|
||||
|
||||
## Classify first
|
||||
|
||||
| Signal | Classification |
|
||||
|--------|----------------|
|
||||
| One logical step — even with a `?mode=` toggle, $/% radio, or add/remove rows | **plain form** |
|
||||
| The user genuinely advances through ordered steps, each validated before the next | **wizard** |
|
||||
| In doubt | **form** |
|
||||
|
||||
Most "wizards" in this codebase are single-step forms wearing wizard costumes: they
|
||||
implement the multi-step protocol (`mm/ModalWizardStep` + friends), serialize an EDN
|
||||
snapshot into hidden fields, and register 10–20 stacked-middleware routes — all for one
|
||||
step. That is pure overhead to delete.
|
||||
|
||||
> **Done — Transaction Edit is now a plain form.** `LinksStep`/`EditWizard` and all `mm/*`
|
||||
> usage were deleted from `transaction/edit.clj`; the worked example below is realized, not
|
||||
> aspirational. See "Single-step → plain form (realized)".
|
||||
|
||||
## The machinery being replaced
|
||||
|
||||
The old shape (kept here as the "before"):
|
||||
|
||||
```clojure
|
||||
(defrecord LinksStep [linear-wizard]
|
||||
mm/ModalWizardStep
|
||||
(step-name [_] "Transaction Actions")
|
||||
(step-key [_] :links)
|
||||
(edit-path [_ _] [])
|
||||
(step-schema [_] (mm/form-schema linear-wizard))
|
||||
(render-step [this {{:keys [snapshot step-params]} :multi-form-state :as request}] ...))
|
||||
```
|
||||
|
||||
…plus the snapshot round-trip: the whole accumulating form state is serialized to hidden
|
||||
fields (custom EDN readers), then rebuilt every request by merging the posted pieces back
|
||||
into the snapshot (`:multi-form-state :snapshot` is read ~75× in `edit.clj`). The
|
||||
serialization needs custom readers, the merge is error-prone, and the payload grows each
|
||||
step.
|
||||
|
||||
---
|
||||
|
||||
## Single-step → plain form
|
||||
|
||||
Two routes: `GET` (render) and `POST` (validate + save). State is plain form fields + an
|
||||
entity id. No snapshot, no server state, no protocol.
|
||||
|
||||
```clojure
|
||||
{::route/edit (fn [req] (html-response (render-edit-form {:entity (get-entity req)})))
|
||||
::route/edit-submit (fn [req] (validate-and-save req))}
|
||||
```
|
||||
|
||||
A `?mode=` toggle is just the `GET` re-rendering with a different query param — still a
|
||||
plain form. An add-row interaction is one extra `POST` that appends a fresh row and
|
||||
re-renders (the `+1` route).
|
||||
|
||||
### Single-step → plain form (realized: Transaction Edit)
|
||||
|
||||
What replacing the wizard actually looked like, end to end:
|
||||
|
||||
1. **Delete the records + middleware.** `EditWizard`/`LinksStep`, `mm/open-wizard-handler`,
|
||||
`mm/next-handler`, `mm/submit-handler`, `mm/wrap-wizard`, `mm/wrap-decode-multi-form-state`,
|
||||
and the `edit-wizard-navigate` route all go. `render-step` becomes a plain `render-form`.
|
||||
2. **Rename the fields off `step-params[...]`.** Field names are now the schema path
|
||||
directly (`(path->name2 :transaction/accounts 0 :transaction-account/account)` →
|
||||
`transaction/accounts[0][transaction-account/account]`). They decode straight into the
|
||||
form schema via the unchanged `wrap-nested-form-params` + `mc/decode` — no two-key
|
||||
snapshot/step-params decode. **Strip stray keys after decode** (`select-keys` to the
|
||||
schema's keys) or a non-schema input like the tab group's `method` hidden 500s the save
|
||||
(see `gotchas.md`).
|
||||
3. **Flat state.** `wrap-derive-state` builds a plain `{:snapshot :edit-path :step-params}`
|
||||
map (not the `MultiStepFormState` record): `entity-only` fields from the entity, editable
|
||||
fields from the live posted form (absent = cleared). The ~34 `:snapshot` reads keep
|
||||
working.
|
||||
4. **Validation/error flow without `wrap-ensure-step`.** Reuse the generic
|
||||
`wrap-form-4xx-2` directly: `(-> submit-edit (wrap-form-4xx-2 render-form-response) …)`.
|
||||
`submit-edit` runs `assert-schema` then dispatches the save; on a throw, `wrap-form-4xx-2`
|
||||
re-renders the whole form with `:form-errors` keyed by schema paths. A `*errors*` dynamic
|
||||
var (bound by `render-form`) replaces the form-cursor's `*form-errors*` for field lookups.
|
||||
5. **Routes shrink to** `edit-wizard` (GET open), `edit-submit` (POST), `edit-form-changed`
|
||||
(POST whole-form re-render for dependent changes), `location-select` (GET),
|
||||
`unlink-payment` (POST).
|
||||
|
||||
---
|
||||
|
||||
## Genuinely multi-step → data-driven engine with session-stored step state
|
||||
|
||||
> **Inspiration — Django `formtools` `WizardView`.** Django's wizard does *not* round-trip
|
||||
> a serialized blob of the whole form through the page. Each step's validated data is
|
||||
> written to a **storage backend (the user session by default)** under that step's key,
|
||||
> and the steps are combined only at the very end via `get_all_cleaned_data()`. We adopt
|
||||
> the same model: **replace the EDN snapshot + piecewise merging with per-step form state
|
||||
> stored in the Ring session.** A step writes its own data under its own key; nothing is
|
||||
> merged into a snapshot and nothing about other steps rides through the form.
|
||||
> Refs: `formtools.wizard.views.WizardView`, `SessionStorage`, `get_all_cleaned_data()`
|
||||
> (https://django-formtools.readthedocs.io/en/latest/wizard.html).
|
||||
|
||||
A wizard is **data**:
|
||||
|
||||
```clojure
|
||||
(def vendor-wizard-config
|
||||
{:steps [{:key :info :schema info-schema :fields [...] :render render-info-step
|
||||
:next (fn [data] :terms)}
|
||||
{:key :terms :schema terms-schema :fields [...] :render render-terms-step
|
||||
:next (fn [data] :done)}]
|
||||
:init-fn (fn [req] {...})
|
||||
:submit-route "/admin/vendor/wizard/submit"
|
||||
:done-fn (fn [all-data req] (save! all-data) (html-response "Saved"))})
|
||||
```
|
||||
|
||||
with a tiny engine (no protocols) whose state lives **in the session**, keyed by a wizard
|
||||
instance id, each step's data under its own step key — the formtools `SessionStorage`
|
||||
model. No snapshot, no custom EDN readers, no merge-into-snapshot:
|
||||
|
||||
```clojure
|
||||
;; Storage backed by the Ring session. Path: [:wizards <wizard-id> :step-data <step-key>]
|
||||
(defn create-wizard! [session config]
|
||||
(let [id (str (java.util.UUID/randomUUID))]
|
||||
[id (assoc-in session [:wizards id]
|
||||
{:current-step (-> config :steps first :key) :step-data {}})]))
|
||||
|
||||
(defn put-step [session id k data] (assoc-in session [:wizards id :step-data k] data)) ; replace, not merge
|
||||
(defn set-step [session id k] (assoc-in session [:wizards id :current-step] k))
|
||||
(defn get-all [session id] (->> (get-in session [:wizards id :step-data]) vals (apply merge)))
|
||||
(defn forget [session id] (update session :wizards dissoc id))
|
||||
```
|
||||
|
||||
The render emits only a **reference token** (`wizard-id`, `current-step`) in the form —
|
||||
never the form's state. The submit handler validates the posted step, `put-step`s it,
|
||||
computes `:next`, and either advances (`set-step`) or finishes (`get-all` + `:done-fn` +
|
||||
`forget`). Every fn returns the updated session for the handler to thread into the Ring
|
||||
response (`(assoc resp :session session')`).
|
||||
|
||||
**Two routes per wizard:** open (`partial open-wizard config`) and submit
|
||||
(`partial handle-step-submit config`). State is namespaced by `wizard-id` inside the
|
||||
session, so multiple in-flight wizards (and browser tabs) don't collide, and it is
|
||||
discarded on completion (`forget`).
|
||||
|
||||
### Storage lifetime (Open decision 1)
|
||||
|
||||
State lives in the Ring session, scoped to true multi-step wizards (plain forms hold
|
||||
none). Lifetime follows the session; `forget` on completion prevents session bloat. For
|
||||
long-lived wizards, confirm the session backend (in-memory vs. durable) is acceptable or
|
||||
pick a durable store.
|
||||
|
||||
## The engine — REALIZED (Phase 6)
|
||||
|
||||
Built and REPL-proven in Phase 6 as two namespaces (no protocols, no defrecords):
|
||||
|
||||
- **`auto-ap.ssr.components.wizard-state`** — the pure session-storage layer (the skeleton
|
||||
above, fleshed out): `create-wizard!` / `instance` / `exists?` / `current-step` /
|
||||
`context` / `step-data` / `put-step` (replace) / `set-step` / `get-all` / `forget`. Each
|
||||
is `session -> session'` (or a read); nothing mutates global state. `:context` holds
|
||||
read-only data the steps need (e.g. an entity id) **outside** `:step-data`, so it never
|
||||
gets merged into the combined result.
|
||||
- **`auto-ap.ssr.components.wizard2`** — the engine: `open-wizard`, `render-wizard`,
|
||||
`handle-step-submit`, and the `wizard-form` shell. A wizard is a **config map**:
|
||||
|
||||
```clojure
|
||||
{:name :vendor :form-id "wizard-form" :submit-route "<resolved url>"
|
||||
:init-fn (fn [request] {:context {...} :init-data {step-key data}})
|
||||
:done-fn (fn [all-data request] ring-response)
|
||||
:steps [{:key :info
|
||||
:decode (fn [request] -> data-map) ; parse this step's posted fields
|
||||
:validate (fn [data request] -> errors|nil) ; optional
|
||||
:render (fn [ctx] -> hiccup) ; step body; engine wraps the <form>
|
||||
:next (fn [data] -> next-step-key | :done)}
|
||||
...]}
|
||||
```
|
||||
|
||||
The step's `:render` gets `{:wizard-id :current-step :context :all-data :step-data
|
||||
:errors :request :config}`. `:all-data` (every step combined so far) is exactly what a
|
||||
**read-only summary/preview step** consumes. Nav buttons post a `direction` field:
|
||||
`"next"` (validate+advance via `:next`), `"back"` (no validate), `"submit"` (== next, for
|
||||
the last step). Only `wizard-id` + `current-step` ride in the form — **no snapshot**.
|
||||
|
||||
**Two routes per wizard:** `(partial open-wizard config)` (GET) and
|
||||
`(partial handle-step-submit config)` (POST). No `wrap-wizard` / `wrap-decode-multi-form-state`
|
||||
stack — the engine threads the session itself and `(assoc resp :session session')`.
|
||||
|
||||
**Proven via REPL** (lifecycle, before any modal used it): open seeds session state and
|
||||
renders step 1 with no accumulated data in the form; next stores `{step-key data}` and
|
||||
advances; an invalid step re-renders itself with errors (no advance); the final step's
|
||||
`:done` calls `done-fn` with the combined `get-all` data and `forget`s the instance; back
|
||||
navigates without validating; an unknown/expired `wizard-id` re-opens fresh instead of
|
||||
500-ing. See the lifecycle eval in the Phase 6 commit message.
|
||||
|
||||
**Note (Phase 6 fit).** Transaction Rule itself is *edit + read-only preview of one
|
||||
entity*, not a true multi-data-step flow — so it exercises the engine's render/navigation/
|
||||
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.
|
||||
|
||||
## Sub-editor: a parameterized sub-step on the linear engine (Phase 10, bank accounts)
|
||||
|
||||
The engine's steps are a flat list — it has no nested/parameterized step like the old
|
||||
mm `[:bank-account which]`. When a step owns a *collection you edit one item at a time*
|
||||
(a list view ⇄ a per-item editor, with accept/discard/sort), don't try to bend the step
|
||||
list. Model it as a **sub-editor of that step**, entirely in whole-form swaps:
|
||||
|
||||
- **The step renders the list view** (cards/rows + an "add" affordance). Each item's
|
||||
edit/new control is an `hx-get` that targets `#wizard-form` with `hx-swap outerHTML` and
|
||||
carries `?wizard-id=<id>&index=N` (the wizard-id is in the render ctx).
|
||||
- **The editor is its own `<form id="wizard-form">`** (so it swaps cleanly and the next
|
||||
swap replaces it) with the item's fields + hidden `wizard-id` + a hidden item index. Its
|
||||
Accept `hx-post`s an accept route; Discard `hx-get`s a discard route. It is NOT a wizard
|
||||
step and does NOT go through `handle-step-submit`.
|
||||
- **Dedicated routes mutate the step's data in the session directly** and re-render the
|
||||
list via the engine: read `(ws/step-data session wid <step-key>)`, splice the decoded
|
||||
item into the vector (`assoc` at index, or `conj` to append for new), `ws/put-step`, then
|
||||
`(wizard2/render-wizard {:config … :wizard-id wid :session session' :request request})`
|
||||
and `(assoc :session session')`. Discard just re-renders from the unchanged session.
|
||||
- **The step's own `:decode` is a pass-through.** Because the list lives in the session
|
||||
(managed by the sub-editor, not by in-form inputs), the step's Next must re-affirm it,
|
||||
not decode it from a near-empty form. Read it back with the wizard-id — but the engine
|
||||
strips `wizard-id`/`current-step`/`direction` from form-params before `:decode`, so smuggle
|
||||
it through an extra hidden the engine leaves alone (we used `wiz`):
|
||||
`(or (ws/step-data (:session request) (get-in request [:form-params "wiz"]) <step-key>) {…})`.
|
||||
- Give the step a no-op `:validate` (`(fn [_ _] nil)`) — items are validated on Accept.
|
||||
- Clean control keys out of the decoded item before storage (`select-keys` to `:db/id` +
|
||||
the entity's own namespace) so `wizard-id`/index/`:new?` never reach datomic.
|
||||
|
||||
This keeps the doctrine intact (every byte is a whole-form swap of `#wizard-form`; no EDN
|
||||
snapshot rides the page) while giving the linear engine an add/edit/sort sub-flow it has
|
||||
no native concept for.
|
||||
399
.claude/skills/ssr-form-migration/reference/gotchas.md
Normal file
399
.claude/skills/ssr-form-migration/reference/gotchas.md
Normal file
@@ -0,0 +1,399 @@
|
||||
# Gotchas
|
||||
|
||||
GROWS every migration. One entry per surprise. Also the home for any **written exception**
|
||||
to the scorecard ratchet (a metric that regressed for a documented reason).
|
||||
|
||||
---
|
||||
|
||||
## Stale `$refs` / `tippy` after a swap
|
||||
|
||||
A whole-form swap can run an Alpine event handler *before* the component re-initialises,
|
||||
so a handler that dereferences `$refs.input.__x_tippy` or calls `tippy.show()` throws.
|
||||
**Always null-guard:** `$refs.input?.__x_tippy?.hide()`, `tippy?.show()`. The
|
||||
`transaction-edit-swap.spec.ts` `trackErrors()` helper fails the test on any `pageerror`
|
||||
or `console.error`, which is exactly how a stale-ref throw surfaces.
|
||||
|
||||
## Let the server value win — don't preserve Alpine state across a server-driven change
|
||||
|
||||
When a server change should update a component (e.g. choosing a vendor sets its default
|
||||
account), rebuild that section fresh on the swap so the server-provided value lands
|
||||
without keying tricks. The bug this prevents: "changing the vendor a *second* time doesn't
|
||||
update the account" because preserved Alpine state shadowed the new server value. If you
|
||||
*must* preserve a component, key it by value so a change forces re-init:
|
||||
`(assoc attrs :key (str id "--" current-value))`.
|
||||
|
||||
## Focus dies if the typed input is inside its own swapped region
|
||||
|
||||
The single most important invariant. Amount field → swap a sibling tbody, not the row.
|
||||
Memo → swap nothing. If a caret test (`sameNode`) fails, the input is in its own swap
|
||||
region — re-target to a sibling/ancestor that excludes it.
|
||||
|
||||
## Faked cursors breed duplicate render fns
|
||||
|
||||
A `with-cursor`/`MapCursor` re-root to fake a deep start forces a `*-no-cursor*` twin.
|
||||
Removing the fake lets you delete the twin. Don't "fix" a faked cursor in place — top-root
|
||||
it and collapse to one render fn. (See `render-functions.md`.)
|
||||
|
||||
## Edit Clojure with clojure-mcp tools, not the file editor
|
||||
|
||||
`clojure_edit` / `clojure_edit_replace_sexp`. If a file won't compile: `clj-paren-repair`
|
||||
the file, then retry; if still broken, `lein cljfmt check`. Run tests via `clojure-eval` /
|
||||
`clj-nrepl-eval -p PORT`, never `lein test` (slow, last resort).
|
||||
|
||||
## Solr/typeahead in tests
|
||||
|
||||
Account/vendor search is backed by Solr, unavailable in tests. To drive a typeahead in
|
||||
e2e: type under the 3-char threshold, then inject a result into Alpine state
|
||||
(`Alpine.$data(el).elements = [{value, label}]`) and click it — the real click handler,
|
||||
`tippy.hide()`, Alpine reactivity, and the HTMX swap all run as in production. Entity ids
|
||||
come from `GET /test-info`.
|
||||
|
||||
---
|
||||
|
||||
## UI-only control fields must be stripped before a Datomic upsert
|
||||
|
||||
The wizard snapshot/step-params carry UI control fields that are **not** schema
|
||||
attributes — `:action`, `:amount-mode`, and (added by the simple/advanced work) `:mode`.
|
||||
The `:manual` save handler stripped `:action`/`:amount-mode` but not `:mode`, so every
|
||||
*advanced* manual save passed `:mode "advanced"` into `:upsert-transaction` and 500'd with
|
||||
`:db.error/not-an-entity :mode`. Lesson: when a save derives its tx-data from the form
|
||||
snapshot, **strip every non-schema control key** before transacting. The session-backed
|
||||
wizard engine (Phase 6) avoids this class of bug by storing per-step *validated* data
|
||||
only — UI control fields never enter the combined data. This was a real production bug
|
||||
surfaced by the e2e gate, not a test artifact.
|
||||
|
||||
## E2E helpers must use the Alpine **v3** API, not the v2 `__x` internal
|
||||
|
||||
The app loads Alpine v3 (`cdn.jsdelivr.net/npm/alpinejs@3.x.x`). The v2 internal
|
||||
`el.__x.$data` is **gone** — `el.__x` is `undefined`, so any helper that pokes it silently
|
||||
no-ops. A stale `selectAccountFromTypeahead` did this and left the posted account empty
|
||||
(account-controlled by `x-model`, so the raw DOM `.value` you set is overwritten from
|
||||
Alpine's empty state). Drive components the real way instead: `window.Alpine.$data(el)`,
|
||||
open the tippy dropdown, inject `elements`, click the result — exactly as
|
||||
`transaction-edit-swap.spec.ts` does. Probe with
|
||||
`{ hasLegacy__x: !!el.__x, hasAlpineData: !!window.Alpine.$data(el) }`.
|
||||
|
||||
## Diagnosing a "modal won't close after save"
|
||||
|
||||
The edit modal closes on an `hx-trigger: modalclose` from a *successful* save; a
|
||||
validation failure re-renders the `#wizard-form` (200), and a server exception returns 500
|
||||
(caught by `wrap-error`). To find which: capture POST responses in Playwright
|
||||
(`page.on('response', …)`), read the `edit-submit` body — a `<form id="wizard-form">` means
|
||||
validation re-render; a `#error {…}` stack means a 500. Then serialize the form right
|
||||
before save (`new FormData(document.querySelector('#wizard-form'))`) to see exactly what
|
||||
posts. This is how the `:mode` 500 and the empty-account bugs above were isolated.
|
||||
|
||||
## De-faking a cursor is not a drop-in — `with-field-default` mutates
|
||||
|
||||
Tempting fix for a faked deep cursor (`with-cursor` + synthetic `MapCursor` at index 0):
|
||||
replace it with `(fc/with-field-default 0 {})` to advance naturally. **It broke the
|
||||
simple-mode swap** (`transaction-edit-swap` test 1 threw). `with-field-default` calls
|
||||
`cursor/transact!` — it *mutates the form cursor* (assoc-ing the default row) as a render
|
||||
side effect, which changes simple-mode behavior. The read-only synthetic `MapCursor` did
|
||||
not. Lesson: removing a faked cursor on these modals is **not** a one-liner — it's part of
|
||||
the larger render-fn extraction (render the row from explicit data, construct field names
|
||||
directly, look up errors explicitly), done when the simple/advanced rows are reworked into
|
||||
pure render fns / Selmer. Don't swap one cursor primitive for another and assume parity;
|
||||
verify against the swap spec, and expect the de-fake to come with the render-fn rewrite.
|
||||
|
||||
## Snapshot operations read stale state and drop live form values (heuristic 2)
|
||||
|
||||
The whole-form operation handlers (`apply-new-account`, `apply-remove-account`,
|
||||
`apply-toggle-amount-mode`) rebuild the account rows from the **decoded `:snapshot`** (the
|
||||
hidden EDN field), not from the live posted `:step-params`. So any value the user has typed
|
||||
but that hasn't been re-serialised into the snapshot yet — e.g. an amount typed right
|
||||
before clicking "New account" — is **silently lost** when the operation re-renders. This is
|
||||
the snapshot round-trip fragility the migration removes (heuristic 2: → 0 merges; state
|
||||
should ride in the form, not a parallel snapshot). It bit the percentage-split e2e: typing
|
||||
50% then adding a row reverted the first row to its snapshot value, yielding a 66.67/33.33
|
||||
split. Two ways it shows up and how to handle until the snapshot is gone:
|
||||
|
||||
**Fixed (Stage 1):** the operation handlers read the live `:step-params` rows (already
|
||||
schema-decoded by `mm/wrap-wizard`) so typed values survive add/remove/toggle.
|
||||
|
||||
**Done (Stage 2 — the snapshot round-trip is gone).** The EDN `snapshot` hidden field +
|
||||
custom readers + `merge-multi-form-state` are removed. A `db/id` hidden rides in the form;
|
||||
`wrap-derive-state` rebuilds `:multi-form-state` per request from `entity ∪ step-params`,
|
||||
and `EditWizard.render-wizard` renders a plain form (no snapshot/edit-path/current-step
|
||||
hiddens). The ~34 `:snapshot` reads still work — `:snapshot` is now a derived map, not a
|
||||
round-tripped blob.
|
||||
|
||||
**Trap that cost hours — derive `entity ∪ step-params` correctly.** First cut was
|
||||
`(merge base step-params)`. Bug: `base` always carries the entity's *persisted* accounts,
|
||||
so after the user removes every row (step-params has no accounts key) the merge falls back
|
||||
to base → the persisted accounts **resurrect** on the next operation. Fix: editable fields
|
||||
(accounts, vendor, memo, approval, action, mode, amount-mode) come **only** from the live
|
||||
form (absent = cleared); only entity-only fields (`db/id`, client, amount, description,
|
||||
status, type) come from the entity. Lesson: with a posted form, "field absent" means
|
||||
*cleared*, not "use the persisted value" — never merge the entity's editable fields back in.
|
||||
|
||||
**Verify the snapshot removal on a FRESH server, and don't trust a long-lived in-process
|
||||
test server.** Protocol/defrecord (`EditWizard.render-wizard`) and middleware reloads do
|
||||
**not** fully take in a running REPL — the server kept rendering the old snapshot field
|
||||
after `:reload`, and an in-process server that isn't reseeded between `npx playwright`
|
||||
invocations accumulates state that makes order-dependent tests flake. Both produced hours
|
||||
of phantom failures. Restart the REPL clean (or reseed) before trusting an e2e result; CI
|
||||
boots a fresh server per run, so the fresh-server number (38 pass / 1 unrelated) is the real one.
|
||||
|
||||
## Characterization tests rot against table order and removed wizard chrome
|
||||
|
||||
Two stale-test traps surfaced once the masking failure was fixed (a `mode: 'serial'` file
|
||||
hides every test after the first failure, so fixing one unmasks the next):
|
||||
|
||||
- **Hard-coded amounts per table row index** (`openEditModal(page, 3)` then
|
||||
`expect(amount).toBeCloseTo(400)`) break because same-date seed transactions have no
|
||||
pinned row order. Read the actual value (e.g. the grid's `.account-grand-total-row`)
|
||||
instead of hard-coding.
|
||||
- **Helpers that navigate the old multi-step wizard** (`click('button:has-text("Transaction
|
||||
Actions")')`) hang once the modal is single-page. Drop the navigation; the action tabs
|
||||
are present immediately.
|
||||
|
||||
## Flat decode leaks stray form fields into the saved entity (the `method` 500)
|
||||
|
||||
Dropping the wizard's `step-params[...]` field-name prefix and decoding posted params
|
||||
**straight into the form schema** means the decode now captures **every** posted field, not
|
||||
just the namespaced ones. A single stray field breaks the save:
|
||||
|
||||
- The tab switcher is `(com/button-group {:name "method"} …)`, which emits
|
||||
`<input type="hidden" name="method">`. Under the wizard, `method` lived *outside*
|
||||
`step-params[...]` so it never entered the decoded map. After the rename it decodes to
|
||||
`:method ""` (malli `:map` is open and passes unknown keys), rides into `snapshot` →
|
||||
`tx-data`, and `:upsert-transaction` rejects it → **HTTP 500 on save**.
|
||||
- Symptom: the save POST fires (confirm with a `println` in the submit handler) but the
|
||||
modal never closes, because the 500 trips `htmx:response-error`. The server error may go
|
||||
to mulog, not stdout — an empty stdout log does **not** mean "no error." Reproduce the
|
||||
exact POST with `curl` (add/remove one field) to isolate the offender fast.
|
||||
|
||||
**Fix:** strip the decoded map to the schema's known top-level keys before threading on
|
||||
(`select-keys decoded edit-form-keys`); keep that allowlist next to the schema. Nested
|
||||
account sub-maps decode fine — only the top level needs the guard.
|
||||
|
||||
## REPL reload does not refresh a running jetty's routes — restart the JVM
|
||||
|
||||
`handler/match->handler-lookup` is a top-level `def` capturing `(merge ssr/key->handler …)`
|
||||
at load, through a chain of module-level `def`s (`edit` → `ssr.transaction` → `ssr.core` →
|
||||
`handler`). Reloading the leaf `edit.clj` updates it but **not** the captured merges, and a
|
||||
jetty started `(run-jetty app …)` holds a static `app` that doesn't re-deref the lookup per
|
||||
request. Net: after a handler/route/record change, an already-running dev server keeps
|
||||
serving the **old** code — `curl` shows the pre-change response (e.g. the old wizard
|
||||
transitioner) while your REPL renders the new one. **Restart in a fresh JVM** for
|
||||
route/record/middleware changes. For e2e, the Playwright test server
|
||||
(`lein run -m auto-ap.test-server`) is a fresh JVM compiling from disk — but kill any stale
|
||||
`:3333` first (`reuseExistingServer` reuses it), and kill **by port**
|
||||
(`ss -tlnp | grep :3333`), never `pkill -f test-server` (it matches its own command line).
|
||||
|
||||
## Full-suite e2e flakes are shared-seed interference
|
||||
|
||||
The test server seeds once at boot; edit tests **save** (mutate) those seed transactions.
|
||||
Run in parallel, workers race the same rows and earlier saves pollute later reads → phantom
|
||||
failures that pass in isolation.
|
||||
|
||||
**Proper fix (landed on `staging`, adopted at the rebase):** a `/test-reset` endpoint
|
||||
(`test_server.clj` → `reset-test-data!` recreates + re-seeds the in-memory db) called from a
|
||||
`test.beforeEach` in each spec, plus `fullyParallel: false` + `workers: 1` in
|
||||
`playwright.config.ts`. Every test starts from the same deterministic dataset regardless of
|
||||
run order. This **supersedes** the earlier `--workers=1`-only workaround (which kept order
|
||||
dependence; it merely serialized the races instead of eliminating cross-test state).
|
||||
Post-adoption baseline is **39 pass / 0 fail** — the previously-flaky
|
||||
`transaction-navigation.spec.ts` date-range test is now green, because `/test-reset` removes
|
||||
the residual mutation it was tripping over.
|
||||
|
||||
## A value-bound typeahead hidden goes stale across a whole-form swap unless keyed
|
||||
|
||||
A typeahead (`sc/typeahead`) posts its value through a hidden `<input :value="value.value">`
|
||||
whose DOM `.value` is set by Alpine, not by the server-rendered static `value` attr. After a
|
||||
**whole-form `outerHTML` swap** that re-renders the typeahead, Alpine may preserve the *previous*
|
||||
component's empty `.value` instead of binding the new server value — so the field posts blank
|
||||
on the next submit. Fix: pass **`:id`** to `sc/typeahead` (the account typeahead already does).
|
||||
`:id` makes the wrapper emit `:key (str id "--" value)`, and the value-keyed `:key` forces a
|
||||
clean Alpine re-init that lands the server value. The bulk-code *vendor* typeahead hit this
|
||||
(account rows didn't, because they pass `:id`) — symptom: "vendor not preserved on a validation
|
||||
re-render." Note the testing trap: reading the hidden's `.value` in isolation
|
||||
(`inputValue()` / `toHaveValue`) is an unreliable probe — it lags Alpine. Assert what the form
|
||||
**actually posts** instead: `new FormData(form).get('vendor')` (wrap in `expect.poll`).
|
||||
|
||||
## Round-trip a multi-row selection as `ids[]`, not as an EDN/filter snapshot
|
||||
|
||||
A bulk modal acts on a *selection* of N entities (bulk-code: the checked transactions), the
|
||||
analog of a single modal's one `db/id`. The wizard stashed the whole search-params blob (filters
|
||||
+ `selected` + `all-selected`) in the EDN snapshot and re-ran the filter query on every post.
|
||||
Don't carry that forward. Instead **resolve the selection to a concrete id vector once at open**
|
||||
(`selected->ids` → the not-locked set) and ride it back in hidden `ids[0..n]` fields; re-read it
|
||||
on each post (`[:vector {:coerce? true} entity-id]` + the `coerce-vector` transformer turns the
|
||||
`{"0" "123"}` index-map into `[123]`). No snapshot, no filter round-trip, and it's *more* correct
|
||||
— you code exactly the rows the user saw, immune to data changing between open and submit. This
|
||||
is heuristic 2 → 0 for a multi-select modal.
|
||||
|
||||
## No parity gate? Build one first — seed + characterization spec, before touching code
|
||||
|
||||
A modal with **no e2e coverage** (and no test-server seed for its domain) cannot be migrated
|
||||
safely — "behavior parity is proven by tests, not by reading" is the skill's #1 non-negotiable.
|
||||
Phase 4 (POS Sales Summary) had zero coverage. The fix: (1) seed a representative entity in
|
||||
`test_server.clj`'s `seed-test-data` and surface its id via `/test-info`; (2) write a
|
||||
characterization spec against the **unmodified** modal and confirm it green; (3) commit the gate
|
||||
*separately, ahead of the rewrite*. Reach the modal the real way (grid → row's edit button), not
|
||||
a direct fragment URL. To discover the actual rendered structure (field names, ids, swap targets)
|
||||
— especially when the code has dead/buggy render fns — dump the live modal HTML with a throwaway
|
||||
spec first; assert against what *renders*, not what the code looks like.
|
||||
|
||||
## Characterize before you fix; never assert a bug as working
|
||||
|
||||
Writing the gate often surfaces pre-existing bugs (Phase 4: a "New Summary Item" button that
|
||||
threw `newRowIndex is not defined`, and a totals display whose malformed Hiccup discarded its
|
||||
own labels). Do **not** assert the broken behavior as if it works, and do **not** silently "fix"
|
||||
it mid-refactor — surface it and let the user decide fix-vs-preserve. If they choose *fix*: the
|
||||
spec first documents the break (a passing test of the *current* inert behavior or an explicit
|
||||
note), then is rewritten to assert the *fixed* behavior as part of the migration commit.
|
||||
|
||||
## htmx `keyup`-triggered inputs need real keystrokes in tests
|
||||
|
||||
A money/text input wired `hx-trigger="keyup changed delay:300ms"` does **not** fire on Playwright
|
||||
`.fill()` + `dispatchEvent('change')` — `fill` sets the value without keyup events. Use
|
||||
`.click()` then `.pressSequentially('500')` (types char-by-char, firing keyup) so the targeted
|
||||
swap actually triggers. (A `change`-triggered control is the opposite — `dispatchEvent('change')`
|
||||
is fine there.)
|
||||
|
||||
## clojure-mcp structural edits reformat the whole file — use text Edit in big shared files
|
||||
|
||||
`clojure_edit` / `clojure_edit_replace_sexp` re-emit the **entire file** through the formatter.
|
||||
In a small single-modal file that's fine (cljfmt-clean output). In a **large multi-modal file**
|
||||
(Phase 5: `invoices.clj`, 1812 lines) a one-line require addition produced a **650-line spurious
|
||||
whitespace diff** that buries the real change and makes review impossible. For a surgical
|
||||
migration inside a big shared file, use the **text-based Edit tool** (exact-string match — no
|
||||
reformat); this is the AGENTS.md "edit Clojure with file tools only when absolutely necessary"
|
||||
carve-out. Verify with `load-file` (compile) + `lein cljfmt check`, not by eyeballing. Confirm the
|
||||
diff is contained with `git diff -U0 <file> | grep '^@@'` — the hunks should cluster only where you
|
||||
edited (requires + the modal region), nothing else.
|
||||
|
||||
## Wiring a modal onto the wizard2 engine — use the engine's primitives, don't re-roll them
|
||||
|
||||
Phase 6's first migration (Transaction Rule) hit three traps; an adversarial review pointed
|
||||
out the engine had the information to prevent all three, so **the engine now absorbs them**.
|
||||
A consumer is just a config map + the step `:render` fns — reach for these instead of
|
||||
re-implementing them (and re-hitting the bug):
|
||||
|
||||
- **Nav fields are stripped for you.** `handle-step-submit` `dissoc`s its own
|
||||
`wizard-id`/`current-step`/`direction` from `:form-params` before calling a step's
|
||||
`:decode` (`wizard2.clj`), so your decode sees only real fields and they can't ride into
|
||||
the saved entity. (The old failure was a **500 on save** — `:db.error/not-an-entity
|
||||
:current-step` — because an open `:map` decode kept them. No allowlist needed anymore.)
|
||||
- **`wizard2/open-wizard` owns the modal wrap.** Give the config an `:open-response` fn
|
||||
(e.g. `(fn [form] (modal-response [:div#transitioner.flex-1 form]))`); then the
|
||||
new/edit routes are literally `(partial wizard2/open-wizard config)`. Don't hand-roll
|
||||
`create!/render/wrap/thread` — that boilerplate was duplicating engine internals.
|
||||
- **Add rows with `wizard2/blank-row`.** It supplies a temp `:db/id` (so a row schema
|
||||
requiring `[:db/id [:or entity-id temp-id]]` validates and the step actually advances —
|
||||
the old symptom was "the Next/Test button does nothing") plus `:new?` for the appear
|
||||
animation: `(wizard2/blank-row :foo/location "Shared")`.
|
||||
- **Footer with `wizard2/nav-footer`.** It emits the `direction` submit buttons (Back /
|
||||
primary advance / Save), marks the advance/save button `data-primary`, and the form's
|
||||
Enter guard (`wizard2/wizard-form`) triggers `data-primary` — so Enter and Back/Save
|
||||
aren't left to per-consumer convention. (Testing note that survives: Back and Save are
|
||||
*both* `type=submit`, so target a save button by its text, not `button[type=submit]`.)
|
||||
|
||||
## Scorecard exceptions (ratchet violations with a reason)
|
||||
|
||||
**Heuristic 4 (LOC net ↓) — exception (Phase 3, Transaction Bulk Code: 420→506).** When the
|
||||
modal'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, so the single-file LOC rises even though total system
|
||||
complexity drops. This is the opposite of a fat wizard (edit went 1608→1548). The trade is
|
||||
intended and every other heuristic improved sharply (mm coupling 19→0, snapshot merges 4→0,
|
||||
wizard records 3→0, routes 4→3, `find *`→explicit-id swap). Watch for it on the small
|
||||
"single-step wearing a wizard costume" modals — LOC is the wrong headline metric there;
|
||||
the mm-coupling / snapshot / route counts are.
|
||||
|
||||
**Heuristic 9 (Hiccup in render path) — partial exception (Phase 2-final).** The post-save
|
||||
`com/success-modal` confirmation dialogs in `save-handler` keep ~6 `[:p …]` Hiccup lines.
|
||||
They are terminal responses (shown after the form closes), reuse a shared dialog component,
|
||||
and sit outside the form's interactive render path. Migrating them means porting the shared
|
||||
`success-modal` to Selmer — a Phase 11 cross-cutting task, not a single-modal one.
|
||||
|
||||
## Keep wizard session data EDN-safe (the cookie store has no custom readers)
|
||||
|
||||
The session-backed engine stores per-step data + context in the Ring session, and this app's
|
||||
session store is a **cookie-store** (`ring.middleware.session.cookie`) that serializes with
|
||||
`pr-str` and reads back with plain `clojure.edn/read-string` — **no custom tag readers**. So
|
||||
anything you put in a wizard's `:context` or that a step `:decode` returns (which `put-step`
|
||||
persists) must round-trip through bare EDN. A `clj-time` `DateTime` does not: it `pr-str`s as
|
||||
`#clj-time/date-time "…"` and the read side 500s with **"No reader function for tag
|
||||
clj-time/date-time"** on the *next* request that reads the cookie.
|
||||
|
||||
This first bit Invoice Pay (Phase 7), whose context defaulted `:handwritten-date (time/now)`.
|
||||
Rules of thumb:
|
||||
- **Context**: store only EDN-safe primitives (numbers, strings, keywords, vectors, maps,
|
||||
`#inst`/`java.util.Date`). Compute clj-time defaults in the *render* fn, not in context.
|
||||
- **Step data**: a `clj-time` value decoded by a step is fine *in memory* on the terminal
|
||||
(`:done`) path — `get-all` reads it before `forget` clears the wizard, so it never reaches
|
||||
the cookie. It only bites if a clj-time value survives in a step that gets re-persisted
|
||||
(a non-terminal `put-step`). When in doubt, decode dates to `#inst` or keep them as strings
|
||||
until the done-fn.
|
||||
- The old `mm` wizard dodged this because it read its EDN snapshot with
|
||||
`clojure.edn/read-string {:readers clj-time.coerce/data-readers}` (see `multi_modal.clj`) —
|
||||
the cookie store has no such readers. (A durable/typed session backend would remove this
|
||||
constraint; until then, EDN-safe is the rule. See `form-vs-wizard.md` open question.)
|
||||
|
||||
## A bare `[:map …]` query-schema 500s on empty query-params (the `{}`→nil trap)
|
||||
|
||||
`auto-ap.ssr.utils/main-transformer` includes `parse-empty-as-nil`, whose **`:map` decoder
|
||||
turns any map with no truthy values into `nil`** (`(if (seq (filter identity (vals m))) m nil)`).
|
||||
So `(mc/coerce [:map [:k {:optional true} …]] {} main-transformer)` decodes `{}` → `nil`,
|
||||
then validates `nil` against `[:map …]` → `:malli.core/invalid-type` → **500**.
|
||||
|
||||
Ring's `wrap-params` sets `:query-params` to `{}` (not nil) for a request with no query
|
||||
string. So **any handler wrapped with `wrap-schema-enforce :query-schema [:map …]` 500s on a
|
||||
PUT/POST that carries no `?query`** — `(and query-schema query-params)` is truthy for `{}`,
|
||||
so the coercion runs and blows up. This is exactly why the pre-migration New Invoice
|
||||
basic-details "Save" was broken: its button `hx-put`s `/invoice/new/navigate` (no `?to`), and
|
||||
`mm/next-handler`'s `[:to {:optional true} …]` query-schema 500d every time (the
|
||||
`CustomNext`/308-to-submit logic never even ran).
|
||||
|
||||
- A `[:maybe [:map …]]` query-schema survives (`nil` is valid) — that's why the *grid*
|
||||
query-schema, hit by the same empty POST, doesn't throw.
|
||||
- **The engine sidesteps this entirely**: `handle-step-submit` is a POST with **no**
|
||||
query-schema, so empty query-params never reach a `[:map]` coercion. Migrating a wizard
|
||||
off the `mm` navigate route *removes* the bug; you don't need to fix the old route.
|
||||
|
||||
## Keep wizard dates as `#inst`, not clj-time, in step-data
|
||||
|
||||
Reinforcing the EDN-safety rule above: a new+edit wizard that stores dates across a
|
||||
non-terminal step (New Invoice: `basic-details` holds `:invoice/date` while you visit
|
||||
`accounts`) must keep them **EDN-safe**. Decode them to `java.util.Date` (`coerce/to-date`)
|
||||
before they land in step-data, and coerce back to clj-time only for display
|
||||
(`coerce/from-date` → `atime/unparse-local`). A helper that maps over the date keys
|
||||
(`->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.
|
||||
@@ -0,0 +1,85 @@
|
||||
# Render functions: explicit data, or a top-rooted cursor
|
||||
|
||||
**One function, data in, markup out.** The data can arrive as a plain map *or* via a
|
||||
cursor — as long as the cursor was rooted at the top of the form and walked down to here,
|
||||
never faked to start at this depth. The rule is about *where the cursor starts*, not
|
||||
whether you use one.
|
||||
|
||||
## GOOD — explicit data, pure, testable without setup
|
||||
|
||||
```clojure
|
||||
(defn account-row [{:keys [account index client-id amount-mode]}]
|
||||
(com/data-grid-row
|
||||
(com/hidden {:name (str "accounts[" index "][db/id]")
|
||||
:value (or (:db/id account) "")})
|
||||
(com/data-grid-cell
|
||||
(account-typeahead* {:value (:transaction-account/account account)
|
||||
:name (str "accounts[" index "][account]")
|
||||
:client-id client-id}))
|
||||
...))
|
||||
```
|
||||
|
||||
## ALSO FINE — a cursor that started at the form root and was advanced naturally
|
||||
|
||||
```clojure
|
||||
;; The top-level render walks the cursor; the row fn receives the dereferenced row
|
||||
;; (or the advanced cursor). No rebinding of *current*/*prefix* to fake depth.
|
||||
(defn account-rows [accounts-cursor]
|
||||
(for [row-cursor (fc/each accounts-cursor)] ; advanced from the root, not faked
|
||||
(account-row {:account @row-cursor :index (fc/index row-cursor) ...})))
|
||||
```
|
||||
|
||||
`transaction/edit.clj`'s `transaction-account-row*` is the cursor form done right: the
|
||||
caller (`account-grid-body*`) holds a top-rooted cursor via `fc/cursor-map` and hands each
|
||||
row cursor to one render fn.
|
||||
|
||||
---
|
||||
|
||||
## The SMELL this migration removes
|
||||
|
||||
### 1. Faking the cursor's starting position
|
||||
|
||||
A "form cursor" is fine. The pain is **rebinding the dynamic root deeper in the tree** so
|
||||
a deeply nested render fn can run against a fragment. Real example from
|
||||
`transaction/edit.clj`'s `simple-mode-fields*` (the thing to delete):
|
||||
|
||||
```clojure
|
||||
;; SMELL: re-roots the cursor to a synthetic MapCursor pointed at accounts[0] so a
|
||||
;; fragment can render "deep". Fragile, and the source of the *-no-cursor* twin below.
|
||||
(fc/with-field :transaction/accounts
|
||||
(fc/with-cursor (let [cur fc/*current*]
|
||||
(if (sequential? @cur)
|
||||
(nth cur 0 nil)
|
||||
(auto_ap.cursor.MapCursor. {} (cursor/state cur)
|
||||
(conj (cursor/path cur) 0))))
|
||||
...))
|
||||
```
|
||||
|
||||
Target: the cursor begins at the top level of what the form consumes and walks down
|
||||
naturally. Because the **whole form is re-rendered each time** (swap doctrine), there is
|
||||
no longer any reason to fake a deep starting position.
|
||||
|
||||
### 2. The `*-no-cursor*` twin
|
||||
|
||||
Faking the deep cursor forces a *second copy of the same markup* — one that reads the
|
||||
faked cursor and one that takes plain params for the cases where the fake can't be set up.
|
||||
`transaction/edit.clj` has exactly this pair:
|
||||
|
||||
```clojure
|
||||
(defn transaction-account-row* [{:keys [value index client-id ...]}] ...) ; cursor form
|
||||
(defn transaction-account-row-no-cursor* [{:keys [account index client-id ...]}] ...) ; duplicate markup
|
||||
```
|
||||
|
||||
**Fix:** keep one render fn. If a caller already holds a top-rooted cursor, advance it and
|
||||
hand the row data (or the advanced cursor) to that one fn. Delete the `*-no-cursor*` copy.
|
||||
Heuristic 1 targets `grep -c 'defn.*-no-cursor'` → 0 and faked-cursor re-roots → 0.
|
||||
|
||||
## Scorecard hooks (heuristics 1, 2)
|
||||
|
||||
```bash
|
||||
grep -c 'defn.*-no-cursor' $F # → 0
|
||||
grep -cE 'with-cursor|MapCursor\.' $F # faked re-roots → 0 (top-rooted cursors are fine)
|
||||
```
|
||||
|
||||
Top-rooted cursors do **not** count against heuristic 1 — only *re-roots that fake depth*
|
||||
and the `*-no-cursor*` twins do.
|
||||
354
.claude/skills/ssr-form-migration/reference/scorecard.md
Normal file
354
.claude/skills/ssr-form-migration/reference/scorecard.md
Normal file
@@ -0,0 +1,354 @@
|
||||
# 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)
|
||||
|
||||
```bash
|
||||
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 38–39). 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-put`s `/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.
|
||||
|
||||
---
|
||||
|
||||
## Phase 10 — New/Edit Client (the largest modal: 7 steps + a bank-account sub-editor)
|
||||
|
||||
**Coupling outcome:** `defrecord` **9 → 0** (InfoModal / MatchesModal / ContactModal /
|
||||
BankAccountsModal / IntegrationsModal / BankAccountModal / CashFlowModal /
|
||||
OtherSettingsModal + ClientWizard all gone), `mm/` **0**, `fc/` cursor refs **0**,
|
||||
`step-params[…]` **0**, `bank-account-card`/`bank-account-form` multimethods (dispatched on
|
||||
`(comp deref :bank-account/type)`) collapsed to plain `case` on a data map. Routes: the
|
||||
broken `navigate` + `discard` are deleted; four bank-account sub-editor routes added
|
||||
(new/edit/accept/discard) + sort kept. The grid, both form schemas, and ~200 lines of
|
||||
sales power-query export are preserved **verbatim** (stitched around the rewritten wizard
|
||||
region rather than retyped).
|
||||
|
||||
**The new pattern — a parameterized sub-step on a linear engine.** The old
|
||||
`[:bank-account which]` mm sub-step (open one account, Accept/discard/sort, back to the
|
||||
list) doesn't map onto wizard2's flat step list. Modeled instead as a *sub-editor of the
|
||||
bank-accounts step*: see `form-vs-wizard.md` ("Sub-editor"). Key moves: list + editor are
|
||||
both whole-form swaps of `#wizard-form`; dedicated routes mutate `:bank-accounts`
|
||||
step-data in the session via `ws/put-step` and re-render through `wizard2/render-wizard`;
|
||||
the step's own `:decode` is a **pass-through** that re-reads the session list (via a `wiz`
|
||||
hidden the engine doesn't strip) so Next never wipes the out-of-band list.
|
||||
|
||||
**Fixes carried/!surfaced:** new-vs-edit keyed off `:db/id` presence (engine always POSTs,
|
||||
so the old PUT/POST split is gone); client + bank-account dates → `#inst` for EDN-safe
|
||||
session; the **blank-address** trap recurs (empty Contact address posts blank fields →
|
||||
all-nil db/id-less map → "tempid used only as value") — same `blank-address?` drop as
|
||||
Phase 9. A long detour confirmed the REPL is direct-link-poisoned: validate migrations
|
||||
against a fresh `TEST_SERVER_PORT=… lein run -m auto-ap.test-server` JVM, not the REPL.
|
||||
|
||||
**Verification:** full e2e suite **71/71** (65 prior + 6 client-wizard: new dialog +
|
||||
timeline; edit prefill w/ disabled code; bank-accounts card + add affordance; editor
|
||||
open/discard; accept-merge; edit→save round-trip). Engine flow + accept + pass-through +
|
||||
edit init also confirmed at the REPL.
|
||||
|
||||
---
|
||||
|
||||
## Phase 11 — Cleanup (delete the dead wizard machinery)
|
||||
|
||||
With all 11 plan modals migrated, the `mm` multi-step wizard framework had **zero runtime
|
||||
callers** (`transaction/edit.clj` reads `:multi-form-state` but builds it from its own
|
||||
`wrap-derive-state` — it never required the namespace). Deleted:
|
||||
`auto-ap.ssr.components.multi-modal` (the protocols `ModalWizardStep` /
|
||||
`LinearModalWizard` / `Initializable` / `Discardable`, the `MultiStepFormState` record,
|
||||
`wrap-wizard` / `wrap-decode-multi-form-state` / `default-render-step` / `encode-step-key`
|
||||
+ the rest, ~22KB) and its last importer, the already-broken
|
||||
`edit_simple_advanced_mode_test.clj` (it `:refer`'d `edit-vendor-changed-handler` /
|
||||
`edit-wizard-toggle-mode-handler`, both removed when Transaction Edit migrated, so it could
|
||||
no longer load). Removed a stale unused `mm` require in `test_server.clj`.
|
||||
|
||||
**Not removed:** `form-cursor` (`fc/*`) is still used by ~18 non-wizard forms outside this
|
||||
plan (accounts, ledger reports, users, imports, …) — out of scope. The Alpine
|
||||
`alpine-morph` mechanism is still referenced by the whole-form-swap focus infra (the swap
|
||||
doctrine), not the wizard machinery, so it stays. The lingering `MultiStepFormState`
|
||||
mentions in migrated files are historical comments, not code.
|
||||
|
||||
**Verification:** a fresh from-disk JVM compiles without the deleted namespace; full e2e
|
||||
suite **71/71**.
|
||||
@@ -0,0 +1,148 @@
|
||||
# Selmer template conventions
|
||||
|
||||
> **Validated** in the Transaction Edit migration: `location-select*` now renders from
|
||||
> `resources/templates/components/location-select.html` via the interop bridge, embedded
|
||||
> back into the Hiccup account row. Verified: swap spec 6/6, transaction-edit 8/8 (the
|
||||
> Shared Location test selects through the Selmer `<select>`, saves, and spreads to DT).
|
||||
|
||||
## Why Selmer for interactive components
|
||||
|
||||
In Hiccup the same Alpine/HTMX attribute is sometimes a keyword and sometimes a string in
|
||||
the same file — there's no rule a reader (or an LLM) can rely on. The real
|
||||
`com/typeahead-` mixes them in one map:
|
||||
|
||||
```clojure
|
||||
:x-modelable "value.value" ; keyword key
|
||||
"x-ref" "hidden" ; string key
|
||||
"@keydown.down.prevent.stop" "tippy?.show();" ; handlers MUST be strings
|
||||
:x-init "..." ; structural attrs are keywords
|
||||
```
|
||||
|
||||
In a Selmer template the same markup is unambiguous plain HTML:
|
||||
|
||||
```html
|
||||
{# templates/components/typeahead.html #}
|
||||
<div class="relative" x-data="{{ x_data|safe }}" x-model="{{ x_model }}">
|
||||
<a class="{{ classes }}" x-ref="input" tabindex="0"
|
||||
@keydown.down.prevent.stop="tippy?.show()"
|
||||
@keydown.backspace="tippy?.hide(); value = {value:'', label:''}">
|
||||
<span x-text="value.label"></span>
|
||||
</a>
|
||||
</div>
|
||||
```
|
||||
|
||||
Note the `tippy?.` null-guard carried over from the swap doctrine — Selmer doesn't change
|
||||
the Alpine-survives-swap requirement.
|
||||
|
||||
## The render helper + interop bridge (`auto-ap.ssr.selmer`)
|
||||
|
||||
```clojure
|
||||
(sel/render template ctx) ; selmer/render-file -> HTML string (classpath-relative path)
|
||||
(sel/render-str template ctx) ; render from a string (tests/REPL)
|
||||
(sel/hiccup->html h) ; Hiccup -> string, for {{ frag|safe }} inside a template
|
||||
(sel/raw html-string) ; wrap a rendered string so hiccup2 emits it verbatim
|
||||
(sel/render->hiccup template ctx); render + raw, ready to drop into a Hiccup tree
|
||||
```
|
||||
|
||||
The bridge works **both ways** (proven in `selmer_test`): a Hiccup component renders inside
|
||||
a Selmer template (`hiccup->html` + `|safe`), and a Selmer fragment renders inside a Hiccup
|
||||
tree (`render->hiccup`, which `raw`-wraps so hiccup2 doesn't double-escape).
|
||||
|
||||
## The worked example — `location-select*`
|
||||
|
||||
Template (`resources/templates/components/location-select.html`): plain HTML, an
|
||||
`{% for %}` over option maps, `{% if opt.selected %}`.
|
||||
|
||||
```clojure
|
||||
;; Clojure side: build the data, compute classes (reuse inputs/default-input-classes so
|
||||
;; styling can't drift), render, and return a Hiccup-embeddable fragment.
|
||||
(defn location-select* [{:keys [name client-locations value ...]}]
|
||||
(let [options (cond ...) ; [[value label] ...]
|
||||
selected (or value (ffirst options))
|
||||
classes (str/join " " (conj (vec inputs/default-input-classes) "w-full"))]
|
||||
(sel/render->hiccup "templates/components/location-select.html"
|
||||
{:name name :classes classes
|
||||
:options (for [[v l] options] {:value v :label l :selected (= v selected)})})))
|
||||
```
|
||||
|
||||
Lessons:
|
||||
- **Pass computed values in, don't hard-code.** Reuse the Clojure source of truth
|
||||
(`inputs/default-input-classes`) as a context value rather than copying class strings
|
||||
into the template — otherwise styling drifts from the shared components.
|
||||
- **Verify by string-match + e2e, not byte-parity.** `hh/add-class` is set-based, so class
|
||||
*order* differs from the old `com/select` output; CSS is order-independent and the e2e
|
||||
proves behavior. (`testing-conventions`: don't assert on exact markup.)
|
||||
- **Embed via the bridge.** `render->hiccup` lets the Selmer fragment sit inside the
|
||||
still-Hiccup row (`com/validated-field` wraps it) — strangler, one component at a time.
|
||||
|
||||
## Composition — verified mechanics (selmer 1.12.61)
|
||||
|
||||
Proven by REPL before the full migration (do the same before relying on any of these):
|
||||
|
||||
- **`{% include %}` works in FILE renders only.** `sel/render` = `selmer/render-file`, and
|
||||
include/extends/block are *parse-stage* tags. Rendering a template **string** that
|
||||
contains `{% include %}` throws `unrecognized tag: :include` (the runtime expr-tag has a
|
||||
nil handler). So includes only work from a `.html` file, never from `render-str`.
|
||||
- **`{% include %}` sees `{% for %}` loop bindings.** Inside `{% for row in rows %}…{% include "components/x.html" %}…{% endfor %}` the partial can read `row.*`. Good for repeated
|
||||
rows — though Clojure-composing the rows (below) is usually simpler.
|
||||
- **`{% include … with k=v %}` is IGNORED** in 1.12.61 (the `with` clause is dropped). To
|
||||
parametrize, either wrap with the block tag `{% with k=v %}{% include … %}{% endwith %}`
|
||||
(works), or — preferred — let a Clojure wrapper render the partial with an explicit ctx.
|
||||
|
||||
## The Clojure-wrapper composition pattern (`auto-ap.ssr.components.selmer` / `sc`)
|
||||
|
||||
Because `{% include with %}` can't pass args and the server computes most values anyway,
|
||||
each shared component is a **thin Clojure wrapper that renders its own partial** (the
|
||||
proven `location-select*` shape, generalised). The element *structure* lives 100% in the
|
||||
`.html`; the only Clojure is data assembly. The modal's render fns compose these wrappers
|
||||
and assemble a view-model, interpolating the pre-rendered fragments as `{{ frag|safe }}`.
|
||||
|
||||
```clojure
|
||||
(sc/hidden {:name … :value …}) ; -> render "components/hidden.html"
|
||||
(sc/validated-field {:label … :errors …} body…)
|
||||
(sc/typeahead {:name … :url … :value … :content-fn …}) ; resolves label server-side
|
||||
(sc/data-grid {:headers […] :footer-tbody …} rows…)
|
||||
```
|
||||
|
||||
### `attrs->str` — the dynamic-attribute bridge
|
||||
|
||||
HTMX/Alpine attributes vary per call site, so a fixed template can't enumerate them.
|
||||
`sc/attrs->str` serialises an attribute *map* to an HTML attribute *string* injected with
|
||||
`{{ attrs|safe }}`: `<input type="hidden"{{ attrs|safe }}>`. Rules mirror hiccup2 — nil/false
|
||||
dropped, `true` → bare boolean attr, else `name="escaped"` (via `hu/escape-html`, so JSON
|
||||
`x-data` and `x-init` quotes become `"`/`'` and the browser decodes them back).
|
||||
Keyword keys keep their full name incl. namespaced/colon forms (`:x-hx-val:account-id` →
|
||||
`x-hx-val:account-id`). This keeps templates free of per-attribute `{% if %}` ladders while
|
||||
still emitting 100% Selmer markup — `attrs->str` serialises data, it does not build Hiccup.
|
||||
|
||||
### Reuse the real class helpers
|
||||
|
||||
Wrappers reuse `inputs/default-input-classes`, `inputs/use-size`, `hh/add-class`,
|
||||
`btn/bg-colors` so output matches the Hiccup component **modulo Tailwind class order**.
|
||||
Verify by class-**set** equality + e2e, never byte-parity (see the cookbook entries).
|
||||
|
||||
### Trivial wrapper divs
|
||||
|
||||
A bare `<div class="w-72">…</div>` around a fragment is composed with a `wrap-div` string
|
||||
helper (or put the class in the parent template), not a Hiccup vector — string composition
|
||||
of a structural wrapper is not Hiccup and avoids a micro-template per div.
|
||||
|
||||
Keep `|safe` to values the server fully controls (rendered fragments, JSON for `x-data`),
|
||||
never raw user input.
|
||||
|
||||
## Scope (Open decision 2)
|
||||
|
||||
Hybrid, and the boundary is real: **the modal's attribute-heavy components delegate to the
|
||||
shared `com/typeahead` / `com/select` / `com/button-group-button`.** Converting those is a
|
||||
*cross-cutting* change (every modal uses them), so it belongs to the Phase 11 Selmer sweep,
|
||||
not a single modal. `location-select*` is the first, self-contained proof; the shared
|
||||
components follow when the sweep promotes them to Selmer partials.
|
||||
|
||||
## Attribute-consistency scorecard (heuristic 8)
|
||||
|
||||
```bash
|
||||
grep -cE '"x-[a-z]|"hx-[a-z]|"@' <migrated-template> # → 0 mixed encodings in Selmer
|
||||
```
|
||||
A migrated Selmer template has no mixed `:x-`/`"x-"` encodings because everything is plain
|
||||
HTML. (The Hiccup `"@click"`/`":class"` offenders that remain in `edit.clj` live in the
|
||||
shared-component call sites — they clear when those components move to Selmer.)
|
||||
149
.claude/skills/ssr-form-migration/reference/swap-doctrine.md
Normal file
149
.claude/skills/ssr-form-migration/reference/swap-doctrine.md
Normal file
@@ -0,0 +1,149 @@
|
||||
# Whole-form HTMX swap doctrine
|
||||
|
||||
Every interactive control picks a swap strategy in this **priority order** (prefer the
|
||||
earliest rule that works). Worked examples are the real `transaction/edit.clj` swaps.
|
||||
|
||||
## Rule 1 — No request when the field affects nothing else
|
||||
|
||||
Its value rides along in the form and is read on submit. No `hx-*` at all.
|
||||
|
||||
```clojure
|
||||
;; transaction/edit.clj — the memo field. Editing it issues NO request; the value
|
||||
;; just rides along until save. The e2e proves zero POSTs fire while typing.
|
||||
(com/text-input {:value (fc/field-value)
|
||||
:name (fc/field-name)
|
||||
:id "edit-memo"
|
||||
:placeholder "Optional note"})
|
||||
```
|
||||
|
||||
## Rule 2 — Targeted swap of a single isolated cell when the effect is purely local
|
||||
|
||||
Give the cell a stable id, keep it **out of the typed input's subtree**, and post the
|
||||
whole form but `hx-select` back only that cell.
|
||||
|
||||
```clojure
|
||||
;; transaction/edit.clj — selecting an account only changes that row's valid Location
|
||||
;; options, so the change swaps just this cell. Nothing else re-renders.
|
||||
[:div {:id (str "account-location-" index)} ; stable, per-row id
|
||||
(com/validated-field
|
||||
{:x-hx-val:account-id "accountId"
|
||||
:x-dispatch:changed "accountId" ; Alpine fires `changed` when account changes
|
||||
:hx-trigger "changed"
|
||||
:hx-post (bidi/path-for ssr-routes/only-routes ::route/edit-form-changed)
|
||||
:hx-target (str "#account-location-" index)
|
||||
:hx-select (str "#account-location-" index)
|
||||
:hx-swap "outerHTML"
|
||||
:hx-include "closest form"} ; whole form posts; only this cell swaps back
|
||||
(location-select* {...}))]
|
||||
```
|
||||
|
||||
## Rule 3 — Whole-form swap when the change touches interdependent state
|
||||
|
||||
Vendor change, add/remove row, mode toggle, $/% radio. The form's hidden state rides
|
||||
along, so one swap keeps everything consistent — **no out-of-band swaps**.
|
||||
|
||||
```clojure
|
||||
;; transaction/edit.clj — vendor change rebuilds the whole manual-coding section
|
||||
;; (vendor default account, terms, etc. are interdependent).
|
||||
[:div {:hx-trigger "change"
|
||||
:hx-post (bidi/path-for ssr-routes/only-routes ::route/edit-vendor-changed)
|
||||
:hx-target "#wizard-form"
|
||||
:hx-select "#wizard-form"
|
||||
:hx-swap "outerHTML"
|
||||
:hx-sync "this:replace"
|
||||
:hx-include "closest form"}
|
||||
...]
|
||||
```
|
||||
|
||||
The active tab/action round-trips through the form (it's a hidden field bound to Alpine
|
||||
`activeForm`), so it **survives** the whole-form swap — that's why a whole-form swap is
|
||||
safe here even though the user is "on" a tab.
|
||||
|
||||
## Rule 4 — OOB only for genuinely disjoint DOM regions
|
||||
|
||||
A global flash/toast, a nav badge, a modal at the document root. **If tempted to OOB
|
||||
something inside the same feature, restructure instead**: give the dependent element a
|
||||
common ancestor with the trigger and use an ordinary swap.
|
||||
|
||||
Worked example — running **totals live in their own sibling `<tbody>`** so an amount edit
|
||||
swaps the totals without ever replacing the amount input:
|
||||
|
||||
```clojure
|
||||
;; The totals tbody is a sibling of the input-bearing rows.
|
||||
(com/data-grid
|
||||
{:footer-tbody [:tbody {:id "account-totals"} ...totals rows...]}
|
||||
...account rows with inputs...)
|
||||
|
||||
;; The amount input posts the whole form but hx-selects ONLY #account-totals.
|
||||
(com/money-input
|
||||
{:name (fc/field-name)
|
||||
:id (str "account-amount-" index)
|
||||
:class "w-16 account-amount-field"
|
||||
:hx-post (bidi/path-for ssr-routes/only-routes ::route/edit-form-changed)
|
||||
:hx-target "#account-totals" ; a SIBLING of this input's row...
|
||||
:hx-select "#account-totals" ; ...so the input is never in the swapped region
|
||||
:hx-swap "outerHTML"
|
||||
:hx-trigger "keyup changed delay:300ms"
|
||||
:hx-include "closest form"})
|
||||
```
|
||||
|
||||
`grep -c hx-swap-oob` on a migrated modal must be `0` unless a justified disjoint-region
|
||||
case is documented here and in `gotchas.md`.
|
||||
|
||||
---
|
||||
|
||||
## The focus invariant (must always hold)
|
||||
|
||||
> The input the user is typing in is never inside the region its own request swaps.
|
||||
|
||||
This is *the* reason the doctrine works. The amount field swaps a sibling tbody; the memo
|
||||
field swaps nothing; the account typeahead's change swaps the whole form but the typeahead
|
||||
isn't an active text caret at that moment (it's a click-to-select). The
|
||||
`transaction-edit-swap.spec.ts` `sameNode` assertions exist to catch any violation.
|
||||
|
||||
## Alpine components must survive swaps
|
||||
|
||||
When a whole-form swap replaces a region containing Alpine/tippy components, they get
|
||||
re-initialised from the server-provided values. Two hardening moves:
|
||||
|
||||
1. **Null-guard every reference** that depends on Alpine/tippy being initialised:
|
||||
```clojure
|
||||
"@keydown.down.prevent.stop" "tippy?.show()"
|
||||
"@keydown.enter.prevent.stop" "$refs.input?.__x_tippy?.hide(); ..."
|
||||
```
|
||||
(`$refs.input?` / `tippy?` — the `?` matters; a swap can run a handler before re-init.)
|
||||
|
||||
2. **Let the server value win.** Because the section is rebuilt fresh on each swap, the
|
||||
server-driven value (e.g. a vendor's default account) lands without keying tricks —
|
||||
no preserved stale Alpine state to fight. The "changing the vendor a *second* time
|
||||
still updates it" e2e is the regression guard for this.
|
||||
|
||||
If you *do* preserve a component across a morph/replace, key it by its server value so
|
||||
a server-driven change forces re-init: `(assoc attrs :key (str id "--" current-value))`.
|
||||
|
||||
Use `hx/alpine-mount-then-appear` for rows that should mount-then-transition-in (it sets
|
||||
`x-data {data-key false}`, `x-init $nextTick(() => key=true)`, `x-show key`).
|
||||
|
||||
---
|
||||
|
||||
## Selector strategy for targeted swaps (a consideration, not a mandate)
|
||||
|
||||
Rules 2 and 4 need a stable `hx-target`/`hx-select`. Per-element unique ids
|
||||
(`#account-location-0`) work and are what transaction-edit uses today. They get noisy in
|
||||
deeply repeated/nested structures. When you hit that (Phase 5 / the wizards), consider:
|
||||
|
||||
- **Semantic markup + data-attributes** — mark rows/cells with their identity and target
|
||||
by attribute, no per-element ids:
|
||||
```html
|
||||
<tr data-row="account" data-index="0">
|
||||
<td data-cell="location"> … </td>
|
||||
</tr>
|
||||
<!-- hx-target="[data-row='account'][data-index='0'] [data-cell='location']" -->
|
||||
```
|
||||
- **A `form-path -> selector` function**, derived the same way a cursor path is, so the
|
||||
server and the markup agree on the target by construction. A render fn at form-path
|
||||
`[:accounts 0 :location]` computes its own stable selector from that path.
|
||||
|
||||
**Decision status:** still per-element ids. The first modal to hit nested repeated swaps
|
||||
(Invoice Bulk Edit, Phase 5) settles the convention and records it here + in
|
||||
`component-cookbook.md` for the wizards to reuse.
|
||||
137
.claude/skills/ssr-form-migration/reference/test-recipes.md
Normal file
137
.claude/skills/ssr-form-migration/reference/test-recipes.md
Normal file
@@ -0,0 +1,137 @@
|
||||
# Test recipes
|
||||
|
||||
GROWS every migration. How to characterize and verify a modal. Consistent with the
|
||||
project `testing-conventions` skill: test user-observable behavior, assert DB state
|
||||
directly, don't test the means.
|
||||
|
||||
## The three test layers
|
||||
|
||||
1. **Characterization e2e first (Playwright).** Before changing a modal, write/confirm a
|
||||
spec capturing *current* behavior — focus/caret survival across swaps, each field
|
||||
round-trip, validation errors, the real save. This is the parity contract; keep it
|
||||
green through every commit.
|
||||
2. **Pure-function checks via REPL.** Once render/data-prep fns are pure, exercise them
|
||||
with `clojure-eval` / `clj-nrepl-eval -p <port>`. Assert on returned data; for markup
|
||||
use string matches (`(re-find #"accounts\[0\]\[account\]" (str html))`) — this style
|
||||
survives the Selmer switch. Avoid brittle structural assertions.
|
||||
3. **DB-state assertions for mutations.** If a submit writes Datomic, verify by querying
|
||||
the DB, not by asserting on markup.
|
||||
|
||||
## Running e2e
|
||||
|
||||
```bash
|
||||
npx playwright test # full suite
|
||||
npx playwright test e2e/transaction-edit-swap.spec.ts # one spec
|
||||
```
|
||||
- Config: `playwright.config.ts`, `baseURL http://localhost:3333`, `webServer:
|
||||
lein run -m auto-ap.test-server`, `reuseExistingServer: !CI`.
|
||||
- **The server must be from the worktree you're testing.** `reuseExistingServer` will
|
||||
silently reuse *any* server on `:3333` — including another worktree's. Confirm with
|
||||
`ls -la /proc/$(lsof -ti :3333)/cwd` (or restart on a clean port) before trusting a run.
|
||||
- The test-server port is hardcoded (`test_server.clj` `run-jetty {:port 3333}`); to run a
|
||||
second server from another worktree, change that or parameterise it.
|
||||
|
||||
## Driving a typeahead in e2e (Solr unavailable in tests)
|
||||
|
||||
```js
|
||||
await typeahead.locator('a[x-ref="input"]').click(); // open tippy dropdown
|
||||
const search = page.locator('[data-tippy-root] input[x-model="search"]').first();
|
||||
await search.fill('te'); // under 3-char Solr threshold
|
||||
await typeahead.evaluate((el, id) => { // inject a clickable result
|
||||
window.Alpine.$data(el).elements = [{ value: id, label: 'Test Account' }];
|
||||
}, accountId);
|
||||
await page.locator('[data-tippy-root] a', { hasText: 'Test Account' }).first().click();
|
||||
```
|
||||
Entity ids come from `GET /test-info` (`{accounts:{test-account, vendor, vendor2, ...}}`).
|
||||
|
||||
## Proving the focus invariant (caret survival) — the key swap test
|
||||
|
||||
```js
|
||||
// before the debounced swap lands, capture the live focused node...
|
||||
await page.evaluate(() => { window.__focused = document.activeElement; });
|
||||
await swap; // waitForResponse on the *-form-changed POST
|
||||
const ok = await page.evaluate(() => {
|
||||
const a = document.activeElement;
|
||||
return { sameNode: a === window.__focused, value: a?.value, caret: a?.selectionStart };
|
||||
});
|
||||
// ...assert the SAME node survived with value + caret intact.
|
||||
```
|
||||
`trackErrors(page)` (collect `pageerror` + `console.error`, assert `[]`) catches a swap
|
||||
that throws on a stale `$refs`/`tippy` — pair it with every swap test.
|
||||
|
||||
## Asserting "no request" (Rule 1 fields)
|
||||
|
||||
```js
|
||||
let posts = 0;
|
||||
page.on('request', r => { if (r.url().includes('edit-form-changed') && r.method()==='POST') posts++; });
|
||||
// ...type in the memo...
|
||||
expect(posts).toBe(0); // memo affects nothing → issues no request
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## E2E baseline (the regression gate — never drop below this)
|
||||
|
||||
The full suite must stay green after every migration. Specs touching the migrated modals:
|
||||
|
||||
| Spec | Tests | Role |
|
||||
|------|-------|------|
|
||||
| `e2e/transaction-edit-swap.spec.ts` | 8 | **Phase 2 parity contract** — whole-form `hx-select` swaps, caret survival, no-request memo, vendor re-select |
|
||||
| `e2e/transaction-edit.spec.ts` | 15 | transaction edit behavior |
|
||||
| `e2e/bulk-code-transactions.spec.ts` | 18 | Phase 3 (bulk code) |
|
||||
| `e2e/transaction-import.spec.ts` | 4 | import |
|
||||
| `e2e/transaction-navigation.spec.ts` | 13 | navigation |
|
||||
|
||||
### Running e2e from a non-default worktree (recipe)
|
||||
|
||||
`:3333` is often taken by another worktree's server. To run this worktree's code:
|
||||
|
||||
1. Boot the test server in-process on this worktree's REPL at an alternate port — no
|
||||
second JVM, and it live-reloads as you edit:
|
||||
```clojure
|
||||
(require '[auto-ap.test-server :as ts] '[ring.adapter.jetty :refer [run-jetty]]
|
||||
'[datomic.api :as dc])
|
||||
;; reseed helper — call before each full run so state doesn't leak between runs
|
||||
(defn reseed! []
|
||||
(try (.stop (:server test-srv)) (catch Throwable _))
|
||||
(try (dc/delete-database "datomic:mem://playwright-test") (catch Throwable _))
|
||||
(def test-srv (let [c (ts/create-test-db) id (ts/seed-test-data c)]
|
||||
(reset! ts/test-transaction-id id)
|
||||
{:server (run-jetty (ts/test-app) {:port 3334 :join? false}) :tx-id id})))
|
||||
(reseed!)
|
||||
```
|
||||
2. `playwright.config.ts` honors `BASE_URL`; setting it also disables the auto-started
|
||||
webServer (so worktrees don't fight over :3333):
|
||||
```bash
|
||||
BASE_URL=http://localhost:3334 npx playwright test --workers=1 --reporter=line
|
||||
```
|
||||
3. **Reseed (`reseed!`) before each full run.** One long-lived in-process server persists
|
||||
its in-mem DB across separate `npx playwright` invocations; the swap spec's
|
||||
`clearAccounts`/save mutate the shared transaction and leak into later specs. The
|
||||
normal harness avoids this by booting a fresh server per `npx playwright test`.
|
||||
|
||||
### Pass/fail baseline — measured on the merged hx-select reference (Phase 2 start)
|
||||
|
||||
Server: in-process from `integreat-execute-refactor` on `:3334`, `--workers=1`, fresh seed.
|
||||
|
||||
| Spec | Result |
|
||||
|------|--------|
|
||||
| `transaction-edit-swap.spec.ts` | **6 / 6 pass** — the whole-form swap parity contract |
|
||||
| `transaction-edit.spec.ts` | **1 fail (masks 7 via `mode: 'serial'`)** — `Shared Location … spread on save and reopen` fails: the save POST returns a validation error (amount/balance test-data assumption: "$200 = full amount of the 2nd transaction" doesn't hold), so the modal stays open. **Pre-existing on the merged reference, not introduced by this work.** |
|
||||
| Full suite (39) | **30 pass / 2 fail / 7 skipped.** 2nd failure: `transaction-navigation.spec.ts` date-range persistence (`date-range=all` expected, got `month`) — drift from the base branch's "require Apply for date-range filters" change, unrelated to forms. |
|
||||
|
||||
**Gate for the Transaction Edit refactor:** the 6/6 swap-doctrine spec + REPL pure-fn
|
||||
checks.
|
||||
|
||||
### Current state — after the Phase 2 modal work (never drop below this)
|
||||
|
||||
Full suite (workers=1, fresh seed): **38 passed / 1 failed / 0 skipped.**
|
||||
|
||||
- `transaction-edit-swap.spec.ts` — **6/6** (parity contract held through every change).
|
||||
- `transaction-edit.spec.ts` — **8/8** (was 1 pass + 7 masked). Greened by: the `:mode`
|
||||
500 fix, the Alpine-v3 typeahead helper, rewriting the percentage-split test to avoid
|
||||
the snapshot-drops-live-values ordering trap, reading the real transaction total instead
|
||||
of a hard-coded `400`, and dropping the removed `"Transaction Actions"` wizard-nav step.
|
||||
- Remaining 1 failure: `transaction-navigation.spec.ts:92` date-range-preset persistence —
|
||||
**unrelated to forms** (drift from the base branch's "require Apply for date-range
|
||||
filters" change). Pre-existing; out of scope for this migration.
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -51,3 +51,6 @@ sysco-poller/**/*.csv
|
||||
.tmp/**
|
||||
playwright-report/**
|
||||
test-results/**
|
||||
# Scratch dir for temp files (screenshots, logs, etc.); keep the dir, ignore contents
|
||||
/tmp/*
|
||||
!/tmp/.gitkeep
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
# Integreat Development Guide
|
||||
|
||||
## Temporary Files
|
||||
|
||||
Write any temporary files (screenshots, scratch logs, generated artifacts, etc.) to the `./tmp/` directory at the repo root. Its contents are gitignored (only `.gitkeep` is tracked), so nothing there will be accidentally committed. Do not scatter temp files elsewhere in the repo or in the system `/tmp`.
|
||||
|
||||
## Build & Run Commands
|
||||
|
||||
### Build
|
||||
|
||||
@@ -0,0 +1,777 @@
|
||||
# SSR Form & Wizard Simplification — Migration Plan
|
||||
|
||||
> **Status:** Planning / for execution by an agent or engineer.
|
||||
> **Owner:** Bryce
|
||||
> **Type:** Refactor (no user-facing behavior change; parity required).
|
||||
|
||||
This plan describes a series of low-risk migrations that make the server-side
|
||||
rendered (SSR) forms and wizards substantially simpler. It is self-contained:
|
||||
every concept needed to execute is stated here, illustrated with code snippets.
|
||||
The work is sequenced so each migration is small, reversible, and *teaches a
|
||||
skill* that makes the next migration cheaper.
|
||||
|
||||
---
|
||||
|
||||
## 1. Goals
|
||||
|
||||
1. **Render forms by re-rendering the whole form** (or a precise, isolated
|
||||
fragment) over HTMX, using hx-select to choose elements, instead of mutating
|
||||
the DOM in place. This removes the class of bugs around stale state, lost
|
||||
focus/caret, and out-of-band patching.
|
||||
2. **Root cursors at the top; never fake their position.** Cursors are fine and
|
||||
stay — a render function may take an explicit data map *or* a cursor. What we
|
||||
remove is the practice of **faking a cursor to start deeper** in the tree to
|
||||
satisfy a partial render, and the duplicate `*-no-cursor*` variants that
|
||||
fakery forces. The target: a cursor always begins at the top level of what the
|
||||
form consumes and walks down naturally from there. (Because the whole form is
|
||||
re-rendered each time, there is no longer any reason to fake a deep starting
|
||||
position.)
|
||||
3. **Stop forcing single-step forms through wizard machinery.** Most "wizards"
|
||||
are single-step; they become plain forms. Genuine multi-step flows use a
|
||||
small data-driven engine instead of protocols + middleware stacking, and
|
||||
**store each step's data in the session** (combined only at the end) instead
|
||||
of round-tripping and merging an EDN snapshot — the Django `formtools` model.
|
||||
4. **Render HTML with Selmer templates** (Jinja-style) instead of Hiccup for the
|
||||
interactive, attribute-heavy components, so Alpine/HTMX attributes are
|
||||
first-class HTML rather than a mix of Clojure keywords and strings.
|
||||
5. **Capture the migration method in a skill** that is created after the first
|
||||
successful migration and extended by every migration thereafter.
|
||||
|
||||
Net effect target: large reduction in lines of code, route count, and branching
|
||||
complexity, with measurably more reuse across similar forms.
|
||||
|
||||
---
|
||||
|
||||
## 2. Why — the current pain (rationale)
|
||||
|
||||
### 2.1 In-place DOM mutation is fragile
|
||||
Re-rendering only fragments and patching the rest (via morph or out-of-band
|
||||
swaps) means the server and the DOM can disagree. Keeping a focused input alive
|
||||
through a patch requires keying tricks and guards. Re-rendering the **whole
|
||||
form** and letting the typed value ride along in the form is simpler and
|
||||
correct, *provided the input the user is typing in is never inside the region
|
||||
being swapped*.
|
||||
|
||||
### 2.2 Faking cursor positions forces duplicate functions
|
||||
A "form cursor" itself is fine. The pain comes from **faking the cursor's
|
||||
starting position** — rebinding the dynamic root deeper in the tree so a deeply
|
||||
nested render function can run against a fragment. That fakery is fragile and
|
||||
hard to follow, and it has spawned duplicate render functions: one that reads the
|
||||
faked cursor and one that takes plain params for the cases where the fake can't
|
||||
be set up.
|
||||
|
||||
```clojure
|
||||
;; SMELL: this render fn assumes the cursor was faked to start deep at an account,
|
||||
;; so it only works when *current*/*prefix* were rebound to point there first.
|
||||
(defn account-row* [{:keys [value client-id]}]
|
||||
(com/data-grid-row
|
||||
(fc/with-field :transaction-account/account
|
||||
(com/data-grid-cell
|
||||
(account-typeahead* {:value (fc/field-value) :name (fc/field-name)})))
|
||||
...))
|
||||
|
||||
;; SMELL: a second copy of the same markup, just to avoid the faked-deep cursor
|
||||
(defn account-row-no-cursor* [{:keys [account index client-id]}]
|
||||
...)
|
||||
```
|
||||
|
||||
**Target:** the cursor starts at the top of the form's data and walks down
|
||||
naturally; a row render either takes explicit row data or receives a cursor the
|
||||
caller advanced step-by-step from the root — never one teleported to a deep node.
|
||||
|
||||
### 2.3 Single-step forms wear wizard costumes
|
||||
Several forms implement a multi-step wizard protocol (5 protocols, 15+ methods),
|
||||
serialize an EDN snapshot with custom readers into hidden fields, and register
|
||||
10–20 routes with stacked middleware — all for a single-step form. That is pure
|
||||
overhead.
|
||||
|
||||
### 2.4 Multi-step wizards round-trip and merge a snapshot
|
||||
The genuine multi-step wizards carry the whole accumulating form state as an EDN
|
||||
snapshot in hidden fields, then rebuild it each request by merging the posted
|
||||
pieces back into the snapshot. The serialization needs custom readers, the merge
|
||||
logic is error-prone, and the page payload grows with every step. The fix is to
|
||||
**store each step's data in the session under its own key and combine only at the
|
||||
end** — the Django `formtools` model (§3.3) — so no snapshot is built or merged.
|
||||
|
||||
### 2.5 Hiccup makes Alpine/HTMX attributes ambiguous
|
||||
The same attribute is sometimes a keyword and sometimes a string in the same
|
||||
file, and event handlers must be strings while structural Alpine attrs are
|
||||
keywords. There is no rule a reader (or an LLM) can rely on:
|
||||
|
||||
```clojure
|
||||
;; Both of these appear in one component file today:
|
||||
:x-ref "input" ; keyword key
|
||||
"x-ref" "hidden" ; string key
|
||||
:x-model "value.value"
|
||||
"x-model" "search"
|
||||
"@keydown.down.prevent.stop" "tippy.show();" ; handlers must be strings
|
||||
:x-init "..." ; structural attrs are keywords
|
||||
```
|
||||
|
||||
In a Selmer template the same markup is unambiguous plain HTML:
|
||||
|
||||
```html
|
||||
<input x-ref="input" x-model="value.value"
|
||||
@keydown.down.prevent.stop="tippy?.show()" />
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Target state (the patterns, with snippets)
|
||||
|
||||
These four patterns are what every migration moves code *toward*. The skill
|
||||
(§5) holds the canonical, growing version of each.
|
||||
|
||||
### 3.1 Whole-form HTMX swap doctrine
|
||||
|
||||
Decide per interactive control, in this priority order:
|
||||
|
||||
1. **No request** when the field affects nothing else. Its value rides along in
|
||||
the form and is read on submit.
|
||||
```html
|
||||
<!-- a memo / free-text field that influences nothing -->
|
||||
<input name="memo" /> <!-- no hx-* at all -->
|
||||
```
|
||||
2. **Targeted swap of a single isolated cell** when a field's effect is purely
|
||||
local. Give the cell a stable id and keep it out of the typed input's subtree.
|
||||
```html
|
||||
<!-- selecting an account only changes the valid Location options -->
|
||||
<select name="accounts[0][account]"
|
||||
hx-post="/transaction/edit-form-changed"
|
||||
hx-target="#account-location-0"
|
||||
hx-select="#account-location-0"
|
||||
hx-swap="outerHTML" hx-trigger="changed">
|
||||
</select>
|
||||
<div id="account-location-0"> ...location options... </div>
|
||||
```
|
||||
3. **Whole-form swap** when the change touches interdependent state (vendor,
|
||||
add/remove row, mode toggle, $/% radio). The form's hidden state rides along,
|
||||
so one swap keeps everything consistent — **no out-of-band swaps**.
|
||||
```html
|
||||
<form id="wizard-form"
|
||||
hx-post="/transaction/edit-form-changed"
|
||||
hx-target="#wizard-form" hx-select="#wizard-form" hx-swap="outerHTML">
|
||||
...
|
||||
</form>
|
||||
```
|
||||
4. **Out-of-band (OOB) swap only for genuinely disjoint DOM regions** — a global
|
||||
flash/toast, a nav badge, a modal mounted at the document root. If you are
|
||||
tempted to OOB something *inside the same feature*, that is a signal to
|
||||
**restructure the DOM so the dependent element shares a common ancestor** with
|
||||
the trigger, and use an ordinary swap. Example: put running totals in a
|
||||
sibling `<tbody>` so an amount edit can swap totals without replacing the
|
||||
amount input:
|
||||
```clojure
|
||||
;; totals live in their own tbody, a sibling of the input rows
|
||||
(com/data-grid- {:rows ...
|
||||
:footer-tbody [:tbody {:id "account-totals"} ...]})
|
||||
|
||||
;; the amount input swaps ONLY the totals tbody (never itself)
|
||||
[:input {:name "accounts[0][amount]"
|
||||
:hx-post "/transaction/edit-form-changed"
|
||||
:hx-target "#account-totals" :hx-select "#account-totals"
|
||||
:hx-swap "outerHTML" :hx-trigger "keyup changed delay:300ms"}]
|
||||
```
|
||||
|
||||
**Focus invariant (must always hold):** the input the user is typing in is never
|
||||
inside the region its own request swaps.
|
||||
|
||||
**Alpine components must survive swaps.** Null-guard every reference that depends
|
||||
on Alpine/tippy being initialised, and key a component by its server-provided
|
||||
value so a server-driven change re-initialises it instead of preserving stale
|
||||
state:
|
||||
```clojure
|
||||
;; null-guard:
|
||||
"@keydown.enter.prevent.stop" "$refs.input?.__x_tippy?.hide(); ..."
|
||||
;; key by current value so morph/replace re-inits on server change:
|
||||
(assoc attrs :key (str id "--" current-value))
|
||||
```
|
||||
|
||||
**Selector strategy for targeted swaps (a consideration, not a mandate).**
|
||||
Rules 2 and 4 above need a stable `hx-target`/`hx-select`. The obvious approach
|
||||
— a unique `id` on every swappable element — gets noisy in repeated structures
|
||||
(e.g. a table of financial accounts where choosing an account must swap *that
|
||||
row's* dropdown). When you reach those advanced cases, consider a more
|
||||
consistent scheme instead of hand-minting ids everywhere:
|
||||
|
||||
- **Semantic markup + data-attributes** to craft a fine-grained selector without
|
||||
per-element ids. For example, mark rows/cells with their identity and target
|
||||
by attribute:
|
||||
```html
|
||||
<tr data-row="account" data-index="0">
|
||||
<td data-cell="account">
|
||||
<select hx-post="/transaction/edit-form-changed"
|
||||
hx-target="[data-row='account'][data-index='0'] [data-cell='location']"
|
||||
hx-select="[data-row='account'][data-index='0'] [data-cell='location']"
|
||||
hx-swap="outerHTML" hx-trigger="changed">…</select>
|
||||
</td>
|
||||
<td data-cell="location">…</td>
|
||||
</tr>
|
||||
```
|
||||
- **A `form-path -> id` (or `-> selector`) function**, derived the same way a
|
||||
cursor path is, so the server and the markup agree on the target by
|
||||
construction rather than by convention. A render fn at form-path
|
||||
`[:accounts 0 :location]` would compute its own stable selector (id or
|
||||
data-attribute query) from that path, mirroring §3.2's top-rooted cursor.
|
||||
|
||||
The aim is *consistency and predictability* of swap targets in repeated/nested
|
||||
structures — pick whichever keeps targets unambiguous and easy to generate. Note
|
||||
this in `reference/swap-doctrine.md` and let the first modal that hits nested
|
||||
repeated swaps (Phase 5 / the wizards) settle on a convention for the cookbook.
|
||||
|
||||
### 3.2 Render functions: explicit data, or a top-rooted cursor
|
||||
|
||||
One function, data in, markup out. The data can arrive as a plain map or via a
|
||||
cursor — **as long as the cursor was rooted at the top of the form and walked
|
||||
down to here**, never faked to start at this depth.
|
||||
|
||||
```clojure
|
||||
;; GOOD: pure, works everywhere, testable without setup
|
||||
(defn account-row [{:keys [account index client-id amount-mode]}]
|
||||
(com/data-grid-row
|
||||
(com/hidden {:name (str "accounts[" index "][db/id]")
|
||||
:value (or (:db/id account) "")})
|
||||
(com/data-grid-cell
|
||||
(account-typeahead* {:value (:transaction-account/account account)
|
||||
:name (str "accounts[" index "][account]")
|
||||
:client-id client-id}))
|
||||
...))
|
||||
```
|
||||
|
||||
```clojure
|
||||
;; ALSO FINE: a cursor that started at the form root and was advanced naturally.
|
||||
;; The top-level render walks the cursor; the row fn receives the dereferenced
|
||||
;; row (or the advanced cursor) — no rebinding of *current*/*prefix* to fake depth.
|
||||
(defn account-rows [accounts-cursor]
|
||||
(for [row-cursor (fc/each accounts-cursor)] ; advanced from the root, not faked
|
||||
(account-row {:account @row-cursor :index (fc/index row-cursor) ...})))
|
||||
```
|
||||
|
||||
The rule is about *where the cursor starts*, not whether you use one. If a caller
|
||||
already holds a top-rooted cursor, advance it and hand the row data (or the
|
||||
advanced cursor) to one render function. Never rebind the cursor to teleport to a
|
||||
deep node, and never keep a second `*-no-cursor*` copy of the markup.
|
||||
|
||||
### 3.3 Forms vs. wizards (and the data-driven wizard engine)
|
||||
|
||||
- **Single-step → plain form.** Two routes: `GET` (render) and `POST` (validate
|
||||
+ save). State is plain form fields + an entity id. No snapshot, no server
|
||||
state, no protocol.
|
||||
```clojure
|
||||
{::route/edit (fn [req] (html-response (render-edit-form {:entity (get-entity req)})))
|
||||
::route/edit-submit (fn [req] (validate-and-save req))}
|
||||
```
|
||||
|
||||
- **Genuinely multi-step → data-driven engine with session-stored step state.**
|
||||
|
||||
> **Inspiration — Django `formtools` `WizardView`.** Django's wizard does *not*
|
||||
> round-trip a serialized blob of the whole form through the page. Each step's
|
||||
> validated (cleaned) data is written to a **storage backend (the user session
|
||||
> by default)** under that step's key, and the steps are combined only at the
|
||||
> very end via `get_all_cleaned_data()`. We adopt the same model: **replace the
|
||||
> EDN snapshot + piecewise merging with per-step form state stored in the
|
||||
> session.** A step writes its own data under its own key; nothing is merged
|
||||
> into a snapshot and nothing about other steps rides through the form.
|
||||
> Refs: `formtools.wizard.views.WizardView`, its `storage` backends
|
||||
> (`SessionStorage`), and `get_all_cleaned_data()`
|
||||
> (https://django-formtools.readthedocs.io/en/latest/wizard.html).
|
||||
|
||||
A wizard is *data*:
|
||||
```clojure
|
||||
(def vendor-wizard-config
|
||||
{:steps [{:key :info :schema info-schema :fields [...] :render render-info-step
|
||||
:next (fn [data] :terms)}
|
||||
{:key :terms :schema terms-schema :fields [...] :render render-terms-step
|
||||
:next (fn [data] :done)}]
|
||||
:init-fn (fn [req] {...})
|
||||
:submit-route "/admin/vendor/wizard/submit"
|
||||
:done-fn (fn [all-data req] (save! all-data) (html-response "Saved"))})
|
||||
```
|
||||
with a tiny engine (no protocols) whose state lives **in the session**, keyed
|
||||
by a wizard instance id, with each step's data stored under its own step key —
|
||||
the formtools `SessionStorage` model. No snapshot, no custom EDN readers, no
|
||||
merge-into-snapshot:
|
||||
```clojure
|
||||
;; Storage backed by the Ring session (replaces the hidden EDN snapshot).
|
||||
;; Path in session: [:wizards <wizard-id> :step-data <step-key>]
|
||||
(defn create-wizard! [session config]
|
||||
(let [id (str (java.util.UUID/randomUUID))]
|
||||
[id (assoc-in session [:wizards id]
|
||||
{:current-step (-> config :steps first :key) :step-data {}})]))
|
||||
|
||||
(defn put-step [session id k data] (assoc-in session [:wizards id :step-data k] data)) ; replace, not merge
|
||||
(defn set-step [session id k] (assoc-in session [:wizards id :current-step] k))
|
||||
(defn get-all [session id] (->> (get-in session [:wizards id :step-data]) vals (apply merge)))
|
||||
(defn forget [session id] (update session :wizards dissoc id))
|
||||
|
||||
(defn render-wizard [{:keys [wizard-id config session request]}]
|
||||
(let [{:keys [current-step step-data]} (get-in session [:wizards wizard-id])
|
||||
step (first (filter #(= (:key %) current-step) (:steps config)))]
|
||||
[:form#wizard-form {:hx-post (:submit-route config)
|
||||
:hx-target "#wizard-form" :hx-select "#wizard-form" :hx-swap "outerHTML"}
|
||||
;; only a reference token rides in the form -- not the form's state
|
||||
(com/hidden {:name "wizard-id" :value wizard-id})
|
||||
(com/hidden {:name "current-step" :value (name current-step)})
|
||||
((:render step) (assoc request :step-data (get step-data current-step {})))]))
|
||||
|
||||
;; Handlers thread the (possibly updated) session back into the Ring response.
|
||||
(defn handle-step-submit [config {:keys [session] :as request}]
|
||||
(let [{:strs [wizard-id current-step]} (:form-params request)
|
||||
step (first (filter #(= (:key %) (keyword current-step)) (:steps config)))
|
||||
data (select-keys (:form-params request) (map name (:fields step)))]
|
||||
(if-let [errors (mc/explain (:schema step) data)]
|
||||
(-> (render-wizard {:wizard-id wizard-id :config config :session session
|
||||
:request (assoc request :errors errors)})
|
||||
html-response)
|
||||
(let [session' (put-step session wizard-id (keyword current-step) data)
|
||||
nxt ((:next step) data)]
|
||||
(if (= nxt :done)
|
||||
(-> ((:done-fn config) (get-all session' wizard-id) request) ; combine only at the end
|
||||
(assoc :session (forget session' wizard-id)))
|
||||
(let [session'' (set-step session' wizard-id nxt)]
|
||||
(-> (html-response (render-wizard {:wizard-id wizard-id :config config
|
||||
:session session'' :request request}))
|
||||
(assoc :session session''))))))))
|
||||
```
|
||||
Two routes per wizard: open (`partial open-wizard config`) and submit
|
||||
(`partial handle-step-submit config`). State is namespaced by `wizard-id` inside
|
||||
the session, so multiple in-flight wizards (and tabs) don't collide, and it is
|
||||
discarded on completion (`forget`). See Open decision 1 for the storage-backend
|
||||
choice (Ring session store vs. a durable store for long-lived wizards).
|
||||
|
||||
### 3.4 Selmer templates
|
||||
|
||||
Interactive components render from Selmer templates with plain-HTML attributes.
|
||||
Selmer composes via `{% include %}` and `{% block %}`; an interop bridge lets a
|
||||
Selmer template embed Hiccup output (and vice versa) during the transition.
|
||||
|
||||
```html
|
||||
{# templates/components/typeahead.html #}
|
||||
<div class="relative" x-data="{{ x_data|safe }}" x-model="{{ x_model }}">
|
||||
<a class="{{ classes }}" x-ref="input" tabindex="0"
|
||||
@keydown.down.prevent.stop="tippy?.show()"
|
||||
@keydown.backspace="tippy?.hide(); value = {value:'', label:''}">
|
||||
<span x-text="value.label"></span>
|
||||
</a>
|
||||
...
|
||||
</div>
|
||||
```
|
||||
|
||||
```clojure
|
||||
;; render helper + interop bridge
|
||||
(defn render [tpl ctx] (selmer/render-file tpl ctx))
|
||||
(defn hiccup->html [h] (hiccup/html h)) ; embed hiccup inside selmer via {{ frag|safe }}
|
||||
;; selmer fragment inside hiccup: [:div (hiccup/raw (render "..." ctx))]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Principles
|
||||
|
||||
1. **Strangler, not big-bang.** New engine, Selmer renderer, and the swap
|
||||
doctrine live alongside the old code. Migrate one modal at a time behind its
|
||||
own route. Old machinery is deleted only when its last caller is gone.
|
||||
2. **Simplest first.** Each migration is small and reversible (one commit).
|
||||
Start with the already-proven modal, then the smallest fresh ones, and leave
|
||||
the largest/most complex for last — by which point the skill is mature.
|
||||
3. **Skill-driven and self-reinforcing.** After the first successful migration,
|
||||
distil the method into a skill (§5). Every subsequent migration *reads* the
|
||||
skill first and *extends* it last.
|
||||
4. **Quality must measurably improve.** Each migration records a scorecard (§6);
|
||||
no metric may regress for the touched modal.
|
||||
5. **Behavior parity is proven by tests, not by reading** (§7). The full e2e
|
||||
suite must stay green after every migration.
|
||||
|
||||
---
|
||||
|
||||
## 5. The skill: `ssr-form-migration`
|
||||
|
||||
**When it is created:** in **Phase 1**, immediately after — and distilled from —
|
||||
the first successful modal migration (the transaction-edit modal, whose
|
||||
whole-form swap implementation already exists and serves as the reference). The
|
||||
skill is *not* written speculatively; it encodes a method that already worked.
|
||||
|
||||
**Where:** `.claude/skills/ssr-form-migration/` (matches the existing project
|
||||
convention, e.g. `.claude/skills/testing-conventions/SKILL.md`).
|
||||
|
||||
**Structure:**
|
||||
```
|
||||
.claude/skills/ssr-form-migration/
|
||||
SKILL.md # the playbook (§8): classify → migrate → verify → record
|
||||
reference/
|
||||
swap-doctrine.md # §3.1 rules, focus invariant, OOB-vs-hoist, Alpine hardening,
|
||||
# target-selector strategy (semantic/data-attr/form-path->id)
|
||||
render-functions.md # §3.2 explicit-data or top-rooted cursor; no faked positions
|
||||
form-vs-wizard.md # §3.3 classification + the data-driven engine
|
||||
selmer-conventions.md # §3.4 attr style, interop bridge, include/block patterns
|
||||
component-cookbook.md # GROWS: typeahead, account-row, totals, money-input, mode-toggle…
|
||||
gotchas.md # GROWS: stale $refs, key-by-value, wizard-id GC, coercion…
|
||||
test-recipes.md # GROWS: how to e2e a swap; assert a Selmer render; fixture a wizard-id
|
||||
scorecard.md # the §6 heuristics + a running table of every migration's numbers
|
||||
```
|
||||
|
||||
**Growth contract — the last task of every migration:**
|
||||
- Converted a component? → add its before/after to `component-cookbook.md`.
|
||||
- Hit a surprise? → one entry in `gotchas.md`.
|
||||
- Found a test pattern? → `test-recipes.md`.
|
||||
- Playbook step missing/wrong? → fix `SKILL.md`.
|
||||
- Measured the scorecard? → append the row to `scorecard.md`.
|
||||
|
||||
**Success signal:** each migration should reuse more cookbook entries and start
|
||||
from a better scorecard baseline than the previous one. If migration N+1 is not
|
||||
easier than N, the skill-update step is being skipped — treat that as a bug.
|
||||
|
||||
---
|
||||
|
||||
## 6. Quality scorecard (the ratchet)
|
||||
|
||||
Cheap to measure (`grep -c`, `wc -l`, `clj-kondo`), recorded before/after each
|
||||
migration in the commit message and `scorecard.md`. **No metric may regress for
|
||||
the touched modal.**
|
||||
|
||||
| # | Heuristic | Measure | Target |
|
||||
|---|-----------|---------|--------|
|
||||
| 1 | Faked cursor positions (not cursors themselves) | count cursor-root rebinds (`binding` of `*current*`/`*prefix*`/`*form-data*`, or `with-field`/`with-*` used to *re-root* deeper) + `grep -c '\-no-cursor'` | → 0 (top-rooted cursors are fine) |
|
||||
| 2 | Implicit state merges (snapshot/cursor) | count merge sites | → 0 (forms); explicit `update-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 |
|
||||
|
||||
These are directional evidence, not targets to game. Pair them with the e2e
|
||||
parity gate (§7) so "simpler" can never mean "broken."
|
||||
|
||||
---
|
||||
|
||||
## 7. Testing strategy
|
||||
|
||||
Consistent with the project's `testing-conventions` skill (test user-observable
|
||||
behavior; assert DB state directly; don't test the means).
|
||||
|
||||
1. **Characterization e2e first.** Before changing a modal, write/confirm a
|
||||
Playwright spec capturing its current behavior — focus/caret survival across
|
||||
swaps, the field round-trip, validation errors, and the actual save. This
|
||||
spec is the parity contract the refactor must keep green.
|
||||
2. **Pure-function checks via REPL.** Once render fns are pure, exercise the
|
||||
data-prep functions with `clojure-eval` / `clj-nrepl-eval`. Assert on returned
|
||||
data; for markup use string matches (`(re-find #"accounts\[0\]\[account\]" (str html))`)
|
||||
— this style survives the Selmer switch. Avoid brittle structural assertions.
|
||||
3. **DB-state assertions for mutations.** If a submit writes Datomic, verify by
|
||||
querying the DB, not by asserting on markup.
|
||||
|
||||
**Regression gate:** the full e2e suite must stay green after every migration.
|
||||
Record the current pass/fail baseline in `test-recipes.md` at the first
|
||||
migration and never drop below it.
|
||||
|
||||
---
|
||||
|
||||
## 8. Per-migration playbook (the repeatable loop)
|
||||
|
||||
This is the canonical loop each modal phase follows; it lives in `SKILL.md`.
|
||||
Modal phases below list only what is *specific* to that modal plus this loop.
|
||||
|
||||
1. [ ] **Read the skill.** Note applicable cookbook entries and gotchas.
|
||||
2. [ ] **Classify.** Single-step → plain form (no server state). Multi-step →
|
||||
wizard (engine + server state). When in doubt, it's a form.
|
||||
3. [ ] **Baseline the scorecard (§6).** Record before-numbers.
|
||||
4. [ ] **Characterize behavior (test-first).** Write/confirm the e2e spec.
|
||||
5. [ ] **Consolidate render functions** so they take explicit data or a
|
||||
top-rooted cursor — remove faked cursor positions and `*-no-cursor*`
|
||||
duplicates (heuristics 1, 2). Using a cursor is fine; faking its start is not.
|
||||
6. [ ] **Templatize in Selmer**; reuse cookbook bits, add new ones back
|
||||
(heuristics 5, 8).
|
||||
7. [ ] **Wire HTMX per the swap doctrine** (§3.1); focus invariant intact; OOB
|
||||
only for disjoint regions (heuristic 7).
|
||||
8. [ ] **Collapse routes** to 2 (+1 for add-row) (heuristic 6).
|
||||
9. [ ] **Verify:** modal e2e + full suite green; assert DB mutations; REPL-check
|
||||
pure fns. Re-measure scorecard — no regressions.
|
||||
10. [ ] **Commit** one reversible feature commit; message includes the scorecard
|
||||
delta and reused/new cookbook entries.
|
||||
11. [ ] **Feed the skill** (cookbook / gotchas / test-recipes / scorecard /
|
||||
SKILL.md). *Not optional.*
|
||||
|
||||
---
|
||||
|
||||
## 9. Phases & tasks
|
||||
|
||||
> Migration target inventory (verify line counts at execution time):
|
||||
|
||||
| Modal | File | Steps | Target | Phase |
|
||||
|-------|------|-------|--------|-------|
|
||||
| Transaction Edit | `transaction/edit.clj` | 1 (mode toggle) | form | 2 (skill trial) |
|
||||
| Transaction Bulk Code | `transaction/bulk_code.clj` | 1 | form | 3 |
|
||||
| Sales Summary Edit | `pos/sales_summaries.clj` | 1 | form | 4 |
|
||||
| Invoice Bulk Edit | `invoices.clj` | 1 | form | 5 |
|
||||
| Transaction Rule | `admin/transaction_rules.clj` | 2 | wizard | 6 |
|
||||
| Invoice Pay | `invoices.clj` | 2 | wizard | 7 |
|
||||
| New Invoice | `invoice/new_invoice_wizard.clj` | 3 | wizard | 8 |
|
||||
| Vendor | `admin/vendors.clj` | 5 | wizard | 9 |
|
||||
| Client | `admin/clients.clj` | 7 | wizard | 10 |
|
||||
|
||||
---
|
||||
|
||||
### Phase 1 — Distil the skill (no app code changes)
|
||||
|
||||
**Rationale:** the transaction-edit modal has already been migrated to the
|
||||
whole-form swap approach successfully. Capture that working method as a skill
|
||||
*now*, so every later migration is cheaper and consistent. (If the reference
|
||||
implementation is not yet on the working branch, merge it first — that is an
|
||||
acceptable prerequisite.)
|
||||
|
||||
- [ ] Create `.claude/skills/ssr-form-migration/SKILL.md` with the playbook (§8).
|
||||
- [ ] Write `reference/swap-doctrine.md` from §3.1 (the four rules, focus
|
||||
invariant, OOB-vs-hoist, Alpine hardening), using the real transaction-edit
|
||||
swaps as worked examples.
|
||||
- [ ] Write `reference/render-functions.md` from §3.2 (explicit data or a
|
||||
top-rooted cursor; remove faked positions and `*-no-cursor*` duplicates).
|
||||
- [ ] Write `reference/form-vs-wizard.md` from §3.3 (classification + engine).
|
||||
- [ ] Stub `reference/selmer-conventions.md` from §3.4, marked "validated in
|
||||
Phase 2."
|
||||
- [ ] Seed `component-cookbook.md` with whatever transaction-edit already proved
|
||||
(e.g. the hardened typeahead, the totals-in-sibling-`<tbody>` pattern).
|
||||
- [ ] Seed `gotchas.md` (stale `$refs`, key-by-value).
|
||||
- [ ] Seed `test-recipes.md`; record the **current full e2e pass/fail baseline**.
|
||||
- [ ] Create `scorecard.md` with the §6 table and an empty results table.
|
||||
- [ ] **Exit criteria:** an agent can read `SKILL.md` and the references and
|
||||
understand the whole method without this plan.
|
||||
|
||||
---
|
||||
|
||||
### Phase 2 — Trial the skill on Transaction Edit (first test subject)
|
||||
|
||||
**Rationale:** validate the freshly written skill against the one modal whose
|
||||
"correct" outcome we already know. This is also where Selmer + pure functions
|
||||
are completed for this modal and the Selmer conventions get written from a real,
|
||||
verified example. Target type: **plain form** (single step with a mode toggle —
|
||||
the toggle is just a `GET` with a `?mode=` query param that re-renders the form).
|
||||
|
||||
**Foundation (do once, here):**
|
||||
- [ ] Add the `selmer` dependency to `project.clj`.
|
||||
- [ ] Build the render helper (`selmer/render-file`) and the **interop bridge**
|
||||
(Hiccup→string for embedding in Selmer, and Selmer fragment inside Hiccup).
|
||||
- [ ] Prove interop: a throwaway Selmer page renders inside the existing layout,
|
||||
and a Hiccup component renders inside a Selmer template.
|
||||
|
||||
**Modal migration (run the §8 loop), specifics:**
|
||||
- [ ] Confirm/author the characterization e2e spec covering: typing in memo keeps
|
||||
focus; selecting an account updates only its Location options; changing vendor
|
||||
/ adding / removing a row / toggling mode / toggling $-vs-% re-renders the
|
||||
whole form correctly; amount edits update totals without losing the amount
|
||||
caret; save round-trips.
|
||||
- [ ] Extract pure render fns: `render-simple-fields`, `render-advanced-fields`,
|
||||
`account-row`, `account-totals` (remove any `*-no-cursor*` duplicates).
|
||||
- [ ] Convert those render fns to Selmer templates; record each as a cookbook
|
||||
entry; finalize `selmer-conventions.md`.
|
||||
- [ ] Verify the swaps match the doctrine (whole-form for structural changes,
|
||||
targeted cell for account→location, sibling-`<tbody>` for totals, no request
|
||||
for memo); confirm `grep -c hx-swap-oob` is 0.
|
||||
- [ ] Collapse routes: `GET /transaction/edit` (with `?mode=`), `POST
|
||||
/transaction/edit`, plus the single `edit-form-changed` re-render endpoint.
|
||||
- [ ] Verify (modal e2e + full suite green; DB save asserted).
|
||||
- [ ] **Feed the skill:** refine `SKILL.md` and references from anything the
|
||||
trial revealed; append the scorecard row (this is the baseline others beat).
|
||||
- [ ] **Exit criteria:** skill-driven migration reproduces the known-good
|
||||
behavior; Selmer conventions are validated; cookbook has ≥3 reusable entries.
|
||||
|
||||
---
|
||||
|
||||
### Phase 3 — Transaction Bulk Code (plain form)
|
||||
|
||||
**Rationale:** the smallest *fresh* modal — first real test of "read the skill,
|
||||
apply it cold." Single-step form currently wearing a wizard costume.
|
||||
|
||||
- [ ] Run the §8 loop.
|
||||
- [ ] Classify as plain form; delete the wizard protocol/record and snapshot.
|
||||
- [ ] Extract `render-bulk-code-fields`; reuse cookbook typeahead/money-input.
|
||||
- [ ] Search params preserved as plain hidden fields (no EDN snapshot).
|
||||
- [ ] Collapse 4 wizard routes → 2 (`GET` open, `POST` submit).
|
||||
- [ ] Verify bulk-code applies correctly (assert DB) + full suite green.
|
||||
- [ ] Feed the skill; append scorecard row.
|
||||
- [ ] **Exit criteria:** ≥2 cookbook entries reused; LOC, route-count, and
|
||||
faked-cursor count all down vs. baseline.
|
||||
|
||||
---
|
||||
|
||||
### Phase 4 — Sales Summary Edit (plain form)
|
||||
|
||||
**Rationale:** another single-step form; reinforces the cold-apply loop.
|
||||
|
||||
- [ ] Run the §8 loop.
|
||||
- [ ] Classify as plain form; remove wizard record + `wrap-init-multi-form-state`.
|
||||
- [ ] Extract `render-sales-summary-fields` (pure); reuse cookbook entries.
|
||||
- [ ] Collapse 3 wizard routes → 2.
|
||||
- [ ] Verify edit saves (assert DB) + full suite green.
|
||||
- [ ] Feed the skill; append scorecard row.
|
||||
|
||||
---
|
||||
|
||||
### Phase 5 — Invoice Bulk Edit (plain form with rows + totals)
|
||||
|
||||
**Rationale:** first single-step form with dynamic account rows and live totals
|
||||
— exercises the add-row endpoint and the totals-in-sibling-`<tbody>` swap
|
||||
(instead of OOB).
|
||||
|
||||
- [ ] Run the §8 loop.
|
||||
- [ ] Extract `bulk-edit-account-row` (pure); reuse the `account-row`/`totals`
|
||||
cookbook entries from Phase 2.
|
||||
- [ ] Add-row: a `POST` that appends a fresh row; totals re-render via the
|
||||
sibling-`<tbody>` swap, **not** OOB.
|
||||
- [ ] **Settle a target-selector convention** for repeated/nested rows (§3.1
|
||||
"Selector strategy"): semantic data-attributes and/or a `form-path -> selector`
|
||||
helper, rather than hand-minted ids per element. Record the chosen convention
|
||||
in `reference/swap-doctrine.md` + `component-cookbook.md` so later wizards reuse it.
|
||||
- [ ] Collapse 4 wizard routes → 3 (open, submit, add-row).
|
||||
- [ ] Verify add/remove rows + totals + apply (assert DB) + full suite green.
|
||||
- [ ] Feed the skill; append scorecard row.
|
||||
- [ ] **Exit criteria:** `grep -c hx-swap-oob` is 0; row/totals patterns are
|
||||
confirmed reusable across two modals now.
|
||||
|
||||
---
|
||||
|
||||
### Phase 6 — Build the wizard engine + migrate Transaction Rule (2-step wizard)
|
||||
|
||||
**Rationale:** the first genuinely multi-step modal, and the simplest one — the
|
||||
right place to introduce the data-driven engine (§3.3) and **session-stored
|
||||
per-step state** (the Django `formtools` model), replacing the EDN snapshot +
|
||||
merge.
|
||||
|
||||
**Engine (do once, here):**
|
||||
- [ ] Create `components/wizard_state.clj` backed by the **Ring session**:
|
||||
`create-wizard!`, `put-step` (replace step data, do **not** merge into a
|
||||
snapshot), `set-step`, `get-all` (combine only at the end), `forget`. State is
|
||||
namespaced by `wizard-id` inside the session (`[:wizards <id> ...]`) so tabs
|
||||
and concurrent wizards don't collide. Each fn returns the updated session for
|
||||
the handler to thread into the Ring response. Test the lifecycle via REPL.
|
||||
- [ ] Create `components/wizard2.clj` (`render-wizard`, `handle-step-submit`,
|
||||
`open-wizard`) — engine threads session through and only `wizard-id` rides in
|
||||
the form. Test render + step navigation + that no snapshot is emitted.
|
||||
- [ ] Document the engine usage and the formtools inspiration in
|
||||
`reference/form-vs-wizard.md`.
|
||||
|
||||
**Modal migration (run the §8 loop), specifics:**
|
||||
- [ ] Extract `render-edit-step` and `render-test-step` (the test step shows a
|
||||
results table); keep `validate-transaction-rule` as the step `:schema`/custom check.
|
||||
- [ ] Define `transaction-rule-wizard-config` with both steps + `:done-fn`.
|
||||
- [ ] Collapse routes → 2 (open, submit).
|
||||
- [ ] Verify create / edit / run-test (assert DB) + full suite green.
|
||||
- [ ] Feed the skill; append scorecard row.
|
||||
- [ ] **Exit criteria:** engine proven on a real 2-step flow; state TTL works.
|
||||
|
||||
---
|
||||
|
||||
### Phase 7 — Invoice Pay (2-step wizard)
|
||||
|
||||
**Rationale:** 2 steps with conditional rendering by payment method (e.g.,
|
||||
handwrite-check fields) — exercises the engine's `:next`/conditional branching.
|
||||
|
||||
- [ ] Run the §8 loop.
|
||||
- [ ] Extract `render-choose-method-step` and `render-payment-details-step`.
|
||||
- [ ] Build `pay-wizard-config`; move setup logic into `:init-fn` (e.g. the
|
||||
`invoice-by-id` lookup); branch `:next` on payment method.
|
||||
- [ ] Collapse routes → 2.
|
||||
- [ ] Verify each payment method path (assert DB) + full suite green.
|
||||
- [ ] Feed the skill; append scorecard row.
|
||||
|
||||
---
|
||||
|
||||
### Phase 8 — New Invoice (3-step wizard)
|
||||
|
||||
**Rationale:** a true 3-step wizard with a conditional accounts step — the
|
||||
reference multi-step shape.
|
||||
|
||||
- [ ] Run the §8 loop.
|
||||
- [ ] Extract `render-basic-details-step`, `render-accounts-step`,
|
||||
`render-submit-step`; reuse the expense-account row cookbook entry.
|
||||
- [ ] Define step schemas separately; `:next` from basic-details skips accounts
|
||||
when not customizing.
|
||||
- [ ] `:init-fn` sets defaults (e.g. date = now).
|
||||
- [ ] Add-row for expense accounts via the sibling-`<tbody>` totals pattern.
|
||||
- [ ] Collapse routes → 2 (+1 add-row).
|
||||
- [ ] Verify create with/without custom accounts (assert DB) + full suite green.
|
||||
- [ ] Feed the skill; append scorecard row.
|
||||
|
||||
---
|
||||
|
||||
### Phase 9 — Vendor (5-step wizard)
|
||||
|
||||
**Rationale:** larger multi-step; by now the engine and cookbook are mature.
|
||||
|
||||
- [ ] Run the §8 loop.
|
||||
- [ ] Extract the 5 step render fns: `render-info-step`, `render-terms-step`,
|
||||
`render-account-step`, `render-address-step`, `render-legal-step`.
|
||||
- [ ] Build `vendor-wizard-config`; handle `:new` vs `:edit` via `:init-fn`
|
||||
(empty vs. loaded entity).
|
||||
- [ ] Replace the conditional `hx-post`/`hx-put` logic with the engine's submit.
|
||||
- [ ] Collapse routes → 2.
|
||||
- [ ] Verify create + edit across all steps (assert DB) + full suite green.
|
||||
- [ ] Feed the skill; append scorecard row.
|
||||
|
||||
---
|
||||
|
||||
### Phase 10 — Client (7-step wizard) — largest, last
|
||||
|
||||
**Rationale:** the biggest, most complex modal (nested bank accounts, location
|
||||
matches, emails, contact methods). Deliberately last, when the skill is richest.
|
||||
|
||||
- [ ] Run the §8 loop; split extraction into sub-tasks per step.
|
||||
- [ ] Extract the 7 step render fns (`:info`, `:matches`, `:contact`,
|
||||
`:bank-accounts`, `:integrations`, `:cash-flow`, `:other-settings`).
|
||||
- [ ] Convert each `add-new-entity-handler` (bank accounts, location matches,
|
||||
emails, contact methods) to an add-row `POST` using the cookbook row pattern;
|
||||
drop `fc/with-field-default` nesting.
|
||||
- [ ] Build `client-wizard-config`; `:new` vs `:edit` via `:init-fn`.
|
||||
- [ ] Collapse routes → 2 (+ add-row endpoints as needed).
|
||||
- [ ] Verify create + edit across all 7 steps thoroughly (assert DB) + full
|
||||
suite green.
|
||||
- [ ] Feed the skill; append scorecard row.
|
||||
|
||||
---
|
||||
|
||||
### Phase 11 — Cleanup
|
||||
|
||||
**Rationale:** remove the now-dead old machinery.
|
||||
|
||||
- [ ] Delete the legacy wizard module (protocols + middleware) once no caller
|
||||
remains; remove any v1→v2 shim.
|
||||
- [ ] Remove the Alpine morph dependency/extension if unreferenced.
|
||||
- [ ] Decide (Open decision 3) whether to extend Selmer to the remaining static
|
||||
Hiccup, now that the skill makes it cheap.
|
||||
- [ ] Promote recurring cookbook entries into shared Selmer partials/components.
|
||||
- [ ] Final scorecard review: confirm the suite-wide LOC/route/complexity drop.
|
||||
|
||||
---
|
||||
|
||||
## 10. Risks & mitigations
|
||||
|
||||
| Risk | Mitigation |
|
||||
|------|------------|
|
||||
| In-flight wizard state lost (restart / session expiry) | State lives in the session (formtools model), scoped to true multi-step wizards; plain forms hold none. Lifetime follows the session; for long-lived wizards choose a durable session backend or store (Open decision 1). `forget` on completion prevents session bloat. |
|
||||
| Mixed Hiccup/Selmer interop gets messy | Build + prove the interop bridge in Phase 2 before broad use; strangler keeps both valid. |
|
||||
| Selmer loses Hiccup's structural testability | Lean on e2e + DB assertions; unit-test the data-prep functions, not markup. |
|
||||
| Large files hide behavior (`clients.clj`, `edit.clj`) | They go last, after the skill is rich; characterization e2e first; split per step. |
|
||||
| Alpine components break across swaps | Codify hardening (null-guarded `tippy?`/`$refs`, key-by-value) as a cookbook entry applied everywhere. |
|
||||
| Heuristics get gamed (LOC golfing, fake route counts) | Directional evidence only; always paired with the e2e parity gate; review the trend, not single numbers. |
|
||||
| Skill-update step skipped under pressure | Required commit-message line (scorecard delta + reused/new entries); if N+1 isn't easier, flag it. |
|
||||
| Quality regresses silently | Ratchet rule: no metric may regress for a touched modal without a written exception in `gotchas.md`. |
|
||||
|
||||
---
|
||||
|
||||
## 11. Open decisions
|
||||
|
||||
1. **Wizard state storage** — store multi-step state in the **Ring session**
|
||||
(Django `formtools` `SessionStorage` model), keyed by `wizard-id`, none for
|
||||
plain forms? Confirm the session backend in use (in-memory vs. durable) is
|
||||
acceptable for in-flight wizard lifetime, or pick a durable store for
|
||||
long-lived flows. *(recommended: session storage, scoped to multi-step
|
||||
wizards only)*
|
||||
2. **Selmer scope** — convert only interactive/attribute-heavy components first
|
||||
(hybrid), or all SSR files (full sweep)? *(recommended: hybrid, revisit in
|
||||
Phase 11)*
|
||||
3. **Whole-form vs. targeted granularity defaults** — confirm the §3.1 priority
|
||||
order (no-request → targeted cell → whole-form → OOB-only-if-disjoint) as the
|
||||
project default. *(recommended: yes)*
|
||||
4. **First step** — start by distilling the skill (Phase 1) with the reference
|
||||
implementation merged as a prerequisite, rather than treating the merge
|
||||
itself as step one. *(recommended: yes)*
|
||||
@@ -1,5 +1,11 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
// Reset the shared test-server dataset before each test so tests are isolated
|
||||
// from one another (and from other spec files) regardless of run order.
|
||||
test.beforeEach(async ({ request }) => {
|
||||
await request.post('/test-reset');
|
||||
});
|
||||
|
||||
let testInfoCache: any = null;
|
||||
|
||||
async function getTestInfo(page: any) {
|
||||
@@ -34,7 +40,7 @@ async function openBulkCodeModal(page: any) {
|
||||
const codeButton = page.locator('button:has-text("Code")').first();
|
||||
await codeButton.click();
|
||||
await page.waitForSelector('#modal-holder[x-show="open"]', { state: 'visible' });
|
||||
await page.waitForSelector('#wizardmodal');
|
||||
await page.waitForSelector('#bulkcodemodal');
|
||||
}
|
||||
|
||||
async function closeBulkCodeModal(page: any) {
|
||||
@@ -150,7 +156,7 @@ async function addNewAccount(page: any) {
|
||||
}
|
||||
|
||||
async function submitBulkCodeForm(page: any) {
|
||||
const form = page.locator('#wizard-form');
|
||||
const form = page.locator('#bulk-code-form');
|
||||
await form.evaluate((el: HTMLFormElement) => {
|
||||
el.dispatchEvent(new Event('submit', { bubbles: true }));
|
||||
});
|
||||
@@ -178,7 +184,7 @@ test.describe('Bulk Code Transactions - Happy Path', () => {
|
||||
await expect(page.locator('text=Bulk editing 1 transactions')).toBeVisible();
|
||||
|
||||
// Select vendor
|
||||
const vendorHidden = page.locator('input[type="hidden"][name="step-params[vendor]"]').first();
|
||||
const vendorHidden = page.locator('input[type="hidden"][name="vendor"]').first();
|
||||
const testInfo = await getTestInfo(page);
|
||||
await vendorHidden.evaluate((el: HTMLInputElement, value: string) => {
|
||||
const newInput = document.createElement('input');
|
||||
@@ -190,7 +196,7 @@ test.describe('Bulk Code Transactions - Happy Path', () => {
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
// Select approval status
|
||||
const statusSelect = page.locator('select[name="step-params[approval-status]"]').first();
|
||||
const statusSelect = page.locator('select[name="approval-status"]').first();
|
||||
await statusSelect.selectOption('approved');
|
||||
|
||||
// Add account
|
||||
@@ -272,7 +278,7 @@ test.describe('Bulk Code Transactions - Validation', () => {
|
||||
const testInfo = await getTestInfo(page);
|
||||
const vendorId = testInfo.accounts.vendor;
|
||||
|
||||
const vendorContainer = page.locator('div[hx-post*="vendor-changed"]').first();
|
||||
const vendorContainer = page.locator('div[hx-vals*="vendor-changed"]').first();
|
||||
const vendorHidden = vendorContainer.locator('input[type="hidden"]').first();
|
||||
|
||||
await vendorHidden.evaluate((el: HTMLInputElement, value: string) => {
|
||||
@@ -287,11 +293,11 @@ test.describe('Bulk Code Transactions - Validation', () => {
|
||||
el.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
});
|
||||
|
||||
await page.waitForResponse(response => response.url().includes('/vendor-changed') && response.status() === 200);
|
||||
await page.waitForResponse(response => response.url().includes('/form-changed') && response.status() === 200);
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Select approval status
|
||||
const statusSelect = page.locator('select[name="step-params[approval-status]"]').first();
|
||||
const statusSelect = page.locator('select[name="approval-status"]').first();
|
||||
await statusSelect.selectOption('approved');
|
||||
|
||||
// Vendor selection pre-populated a default account row at 100%.
|
||||
@@ -304,10 +310,14 @@ test.describe('Bulk Code Transactions - Validation', () => {
|
||||
// Modal should still be open
|
||||
await expect(page.locator('#modal-holder[x-show="open"]')).toBeVisible();
|
||||
|
||||
// Vendor should still be selected
|
||||
const vendorHiddenAfter = page.locator('input[type="hidden"][name="step-params[vendor]"]').first();
|
||||
const vendorValueAfter = await vendorHiddenAfter.inputValue();
|
||||
expect(vendorValueAfter).toBe(vendorId.toString());
|
||||
// Vendor should still be selected. The vendor typeahead is Alpine-managed and posts
|
||||
// its value via an x-bound hidden input, so the right correctness check is what the
|
||||
// form actually submits (the value that gets saved), not the lagging DOM .value of the
|
||||
// hidden read in isolation.
|
||||
await expect.poll(async () =>
|
||||
page.locator('#bulk-code-form').evaluate((f: HTMLFormElement) =>
|
||||
new FormData(f).get('vendor'))
|
||||
).toBe(vendorId.toString());
|
||||
|
||||
// Status should still be selected
|
||||
const statusValueAfter = await statusSelect.inputValue();
|
||||
@@ -458,7 +468,7 @@ test.describe('Bulk Code Transactions - Vendor Pre-population', () => {
|
||||
|
||||
// The vendor typeahead dispatches change from its parent div
|
||||
// We need to set the hidden input and dispatch change on the container
|
||||
const vendorContainer = page.locator('div[hx-post*="vendor-changed"]').first();
|
||||
const vendorContainer = page.locator('div[hx-vals*="vendor-changed"]').first();
|
||||
const vendorHidden = vendorContainer.locator('input[type="hidden"]').first();
|
||||
|
||||
await vendorHidden.evaluate((el: HTMLInputElement, value: string) => {
|
||||
@@ -475,7 +485,7 @@ test.describe('Bulk Code Transactions - Vendor Pre-population', () => {
|
||||
});
|
||||
|
||||
// Wait for HTMX response
|
||||
await page.waitForResponse(response => response.url().includes('/vendor-changed') && response.status() === 200);
|
||||
await page.waitForResponse(response => response.url().includes('/form-changed') && response.status() === 200);
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Account should be pre-populated - check for account row
|
||||
@@ -515,7 +525,7 @@ test.describe('Bulk Code Transactions - Vendor Pre-population', () => {
|
||||
const testInfo = await getTestInfo(page);
|
||||
const vendorId = testInfo.accounts.vendor;
|
||||
|
||||
const vendorContainer = page.locator('div[hx-post*="vendor-changed"]').first();
|
||||
const vendorContainer = page.locator('div[hx-vals*="vendor-changed"]').first();
|
||||
const vendorHidden = vendorContainer.locator('input[type="hidden"]').first();
|
||||
|
||||
await vendorHidden.evaluate((el: HTMLInputElement, value: string) => {
|
||||
@@ -532,7 +542,7 @@ test.describe('Bulk Code Transactions - Vendor Pre-population', () => {
|
||||
});
|
||||
|
||||
// Wait for HTMX response
|
||||
await page.waitForResponse(response => response.url().includes('/vendor-changed') && response.status() === 200);
|
||||
await page.waitForResponse(response => response.url().includes('/form-changed') && response.status() === 200);
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Should pre-populate the vendor's default account (non-clientized) plus the "New account" row
|
||||
|
||||
151
e2e/client-wizard.spec.ts
Normal file
151
e2e/client-wizard.spec.ts
Normal file
@@ -0,0 +1,151 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
// Acceptance spec for the New/Edit Client wizard — the largest SSR modal in the app:
|
||||
// seven linear steps (info → matches → contact → bank-accounts → integrations → cash-flow →
|
||||
// other-settings) PLUS a parameterized bank-account sub-editor reached from the
|
||||
// bank-accounts step. Migrated onto the session-backed engine (wizard2): flat de-cursored
|
||||
// field names (client/name, not step-params[client/name]), whole-form HTMX swaps, and the
|
||||
// bank-account add/edit/sort modeled as whole-form swaps of #wizard-form.
|
||||
//
|
||||
// The seed (test_server.clj) exposes client "Test Client" (code TEST, location DT) which
|
||||
// owns one "Test Checking" (TEST-CHK, a checking account) bank account.
|
||||
test.beforeEach(async ({ request }) => { await request.post('/test-reset'); });
|
||||
|
||||
// The grid lazy-loads its rows into #entity-table after the page renders.
|
||||
async function openClientList(page: any) {
|
||||
await page.goto('/admin/client');
|
||||
await page.waitForSelector('#entity-table tbody tr[data-id]');
|
||||
}
|
||||
|
||||
async function openNewClient(page: any) {
|
||||
await openClientList(page);
|
||||
await page.locator('button:has-text("New Client")').first().click();
|
||||
await page.waitForSelector('#wizard-form');
|
||||
await page.waitForTimeout(400);
|
||||
}
|
||||
|
||||
async function openEditTestClient(page: any) {
|
||||
await openClientList(page);
|
||||
await page.locator('#entity-table tbody tr', { hasText: 'Test Client' }).first()
|
||||
.locator('[hx-get*="/edit"]').first().click();
|
||||
await page.waitForSelector('#wizard-form');
|
||||
await page.waitForTimeout(400);
|
||||
}
|
||||
|
||||
// Advance one step: click the data-primary Next button and wait until the whole-form swap
|
||||
// has actually changed the current-step hidden (the timeline lists every step name, so a
|
||||
// text check can't confirm progress — the hidden value can).
|
||||
async function advance(page: any) {
|
||||
const before = await page.locator('#wizard-form input[name="current-step"]').first().inputValue();
|
||||
await page.locator('#wizard-form button[data-primary]').first().click();
|
||||
await page.waitForFunction(
|
||||
(prev: string) => {
|
||||
const el = document.querySelector('#wizard-form input[name="current-step"]') as HTMLInputElement | null;
|
||||
return !!el && el.value !== prev;
|
||||
}, before, { timeout: 6000 });
|
||||
}
|
||||
|
||||
test.describe.configure({ mode: 'serial' });
|
||||
|
||||
test.describe('Client wizard (acceptance)', () => {
|
||||
test('new dialog renders the info step with the 7-step timeline', async ({ page }) => {
|
||||
await openNewClient(page);
|
||||
const form = page.locator('#wizard-form');
|
||||
await expect(form.locator('input[name="client/name"]')).toBeVisible();
|
||||
await expect(form.locator('input[name="client/code"]').first()).toBeVisible();
|
||||
await expect(form).toContainText('Locations');
|
||||
for (const label of ['Info', 'Matches', 'Contact', 'Bank Accounts',
|
||||
'Integrations', 'Cash Flow', 'Other Settings']) {
|
||||
await expect(form).toContainText(label);
|
||||
}
|
||||
});
|
||||
|
||||
test('edit opens prefilled with the name and a disabled code', async ({ page }) => {
|
||||
await openEditTestClient(page);
|
||||
const form = page.locator('#wizard-form');
|
||||
await expect(form.locator('input[name="client/name"]')).toHaveValue('Test Client');
|
||||
// the visible code input is disabled on edit (a hidden twin carries the value on submit)
|
||||
const code = form.locator('input[name="client/code"]').first();
|
||||
await expect(code).toHaveValue('TEST');
|
||||
await expect(code).toBeDisabled();
|
||||
});
|
||||
|
||||
test('bank-accounts step shows the seeded account card and a new-account affordance', async ({ page }) => {
|
||||
await openEditTestClient(page);
|
||||
await advance(page); // info -> matches
|
||||
await advance(page); // matches -> contact
|
||||
await advance(page); // contact -> bank-accounts
|
||||
const form = page.locator('#wizard-form');
|
||||
await expect(form).toContainText('Bank Accounts');
|
||||
await expect(form).toContainText('Test Checking');
|
||||
await expect(form).toContainText('Add a new');
|
||||
});
|
||||
|
||||
test('opening the bank-account editor swaps in the per-account form', async ({ page }) => {
|
||||
await openEditTestClient(page);
|
||||
await advance(page); await advance(page); await advance(page); // -> bank-accounts
|
||||
// click the pencil on the seeded account card to open its editor
|
||||
await page.locator('#wizard-form [hx-get*="/bank-account/edit"]').first().click();
|
||||
await page.waitForTimeout(450);
|
||||
const form = page.locator('#wizard-form');
|
||||
await expect(form.locator('input[name="bank-account/name"]')).toHaveValue('Test Checking');
|
||||
await expect(form).toContainText('Accept');
|
||||
// discard returns to the list
|
||||
await page.locator('#wizard-form [hx-get*="/bank-account/discard"]').first().click();
|
||||
await page.waitForTimeout(450);
|
||||
await expect(form).toContainText('Test Checking');
|
||||
});
|
||||
|
||||
test('accepting a bank-account edit merges the change back into the card', async ({ page }) => {
|
||||
await openEditTestClient(page);
|
||||
await advance(page); await advance(page); await advance(page); // -> bank-accounts
|
||||
await page.locator('#wizard-form [hx-get*="/bank-account/edit"]').first().click();
|
||||
await page.waitForTimeout(450);
|
||||
const form = page.locator('#wizard-form');
|
||||
await form.locator('input[name="bank-account/name"]').fill('Renamed Checking');
|
||||
await form.locator('button[data-primary]:has-text("Accept")').first().click();
|
||||
await page.waitForTimeout(450);
|
||||
// back on the list, the card now shows the new nickname
|
||||
await expect(form).toContainText('Renamed Checking');
|
||||
});
|
||||
|
||||
test('editing through to the last step and saving keeps the client in the grid', async ({ page }) => {
|
||||
await openEditTestClient(page);
|
||||
const nameInput = page.locator('#wizard-form input[name="client/name"]');
|
||||
await nameInput.fill('Test Client RENAMED');
|
||||
await expect(nameInput).toHaveValue('Test Client RENAMED');
|
||||
// info -> matches -> contact -> bank-accounts -> integrations -> cash-flow -> other-settings
|
||||
for (let i = 0; i < 6; i++) await advance(page);
|
||||
// the last step is the only one with a Feature Flags grid — confirm we really got here
|
||||
await expect(page.locator('#wizard-form')).toContainText('Feature Flags');
|
||||
// Save persists the edit; reload the grid and the rename is there
|
||||
await page.locator('#wizard-form button[data-primary]').first().click();
|
||||
await page.waitForTimeout(1200);
|
||||
await openClientList(page);
|
||||
await expect(page.locator('#entity-table')).toContainText('Test Client RENAMED');
|
||||
});
|
||||
|
||||
test('a bank-account edit persists through save and is shown on reopen', async ({ page }) => {
|
||||
// edit the seeded account's nickname via the sub-editor, then save the whole client
|
||||
await openEditTestClient(page);
|
||||
await advance(page); await advance(page); await advance(page); // -> bank-accounts
|
||||
await page.locator('#wizard-form [hx-get*="/bank-account/edit"]').first().click();
|
||||
await page.waitForTimeout(450);
|
||||
await page.locator('#wizard-form input[name="bank-account/name"]').fill('Persisted Checking');
|
||||
await page.locator('#wizard-form button[data-primary]:has-text("Accept")').first().click();
|
||||
await page.waitForTimeout(450);
|
||||
await expect(page.locator('#wizard-form')).toContainText('Persisted Checking');
|
||||
// bank-accounts -> integrations -> cash-flow -> other-settings, then Save
|
||||
await advance(page); await advance(page); await advance(page);
|
||||
await expect(page.locator('#wizard-form')).toContainText('Feature Flags');
|
||||
await page.locator('#wizard-form button[data-primary]').first().click();
|
||||
await page.waitForTimeout(1200);
|
||||
// reopen the client and walk back to the bank-accounts step: the new nickname is there,
|
||||
// and the seeded "Test Checking" name is gone (it was renamed, not duplicated)
|
||||
await openEditTestClient(page);
|
||||
await advance(page); await advance(page); await advance(page); // -> bank-accounts
|
||||
const form = page.locator('#wizard-form');
|
||||
await expect(form).toContainText('Persisted Checking');
|
||||
await expect(form).not.toContainText('Test Checking');
|
||||
});
|
||||
});
|
||||
145
e2e/invoice-bulk-edit.spec.ts
Normal file
145
e2e/invoice-bulk-edit.spec.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
// Characterization spec for the Invoice Bulk Edit modal. Captures CURRENT
|
||||
// (pre-migration) behavior so the wizard -> plain-Selmer migration can be proven
|
||||
// behavior-preserving. Reset the shared dataset before each test for isolation.
|
||||
test.beforeEach(async ({ request }) => {
|
||||
await request.post('/test-reset');
|
||||
});
|
||||
|
||||
async function getTestInfo(page: any) {
|
||||
return (await page.request.get('/test-info')).json();
|
||||
}
|
||||
|
||||
async function navigateToInvoices(page: any) {
|
||||
await page.setExtraHTTPHeaders({ 'x-clients': '"mine"' });
|
||||
await page.goto('/invoice');
|
||||
await page.waitForSelector('#entity-table tbody tr');
|
||||
}
|
||||
|
||||
async function selectFirstInvoice(page: any) {
|
||||
await page.locator('#entity-table tbody input[type="checkbox"]').first().click();
|
||||
await page.waitForTimeout(200);
|
||||
}
|
||||
|
||||
async function openBulkEditModal(page: any) {
|
||||
await page.locator('button:has-text("Bulk Edit")').first().click();
|
||||
await page.waitForSelector('#wizardmodal');
|
||||
}
|
||||
|
||||
async function addNewAccount(page: any) {
|
||||
await page.locator('a:has-text("New account")').first().click();
|
||||
await page.waitForTimeout(500);
|
||||
}
|
||||
|
||||
// Set an account on a row by replacing its Alpine-managed hidden input with a plain
|
||||
// one (Solr-backed typeahead is unavailable in tests), then dispatching the location
|
||||
// reload -- the same approach the bulk-code spec uses.
|
||||
async function setRowAccount(page: any, rowIndex: number, accountId: string) {
|
||||
const rows = page.locator('#bulk-edit-form tbody tr');
|
||||
const row = rows.nth(rowIndex);
|
||||
const hidden = row.locator('input[type="hidden"][name*="[account]"]').first();
|
||||
await hidden.evaluate((el: HTMLInputElement, value: string) => {
|
||||
const n = document.createElement('input');
|
||||
n.type = 'hidden'; n.name = el.name; n.value = value;
|
||||
el.parentNode!.replaceChild(n, el);
|
||||
}, accountId);
|
||||
await page.waitForTimeout(200);
|
||||
const loc = row.locator('[x-dispatch\\:changed]').first();
|
||||
if (await loc.count() > 0) {
|
||||
await loc.evaluate((el: HTMLElement) => el.dispatchEvent(new CustomEvent('changed', { bubbles: true })));
|
||||
await page.waitForTimeout(400);
|
||||
}
|
||||
}
|
||||
|
||||
async function setRowPercentage(page: any, rowIndex: number, pct: string) {
|
||||
const row = page.locator('#bulk-edit-form tbody tr').nth(rowIndex);
|
||||
const input = row.locator('input.amount-field, input[name*="percentage"]').first();
|
||||
await input.fill(pct);
|
||||
await input.dispatchEvent('change');
|
||||
await page.waitForTimeout(200);
|
||||
}
|
||||
|
||||
async function submitForm(page: any) {
|
||||
await page.locator('#bulk-edit-form').evaluate((f: HTMLFormElement) =>
|
||||
f.dispatchEvent(new Event('submit', { bubbles: true })));
|
||||
}
|
||||
|
||||
test.describe.configure({ mode: 'serial' });
|
||||
|
||||
test.describe('Invoice Bulk Edit (characterization)', () => {
|
||||
test('opens the modal with the expense-account grid', async ({ page }) => {
|
||||
await navigateToInvoices(page);
|
||||
await selectFirstInvoice(page);
|
||||
await openBulkEditModal(page);
|
||||
|
||||
const modal = page.locator('#wizardmodal');
|
||||
await expect(modal).toContainText('Bulk editing 1 invoices');
|
||||
await expect(modal).toContainText('Account');
|
||||
await expect(modal).toContainText('Location');
|
||||
await expect(modal).toContainText('TOTAL');
|
||||
await expect(modal).toContainText('BALANCE');
|
||||
// a default expense-account row is present, plus the New account button
|
||||
expect(await modal.locator('input[name*="expense-accounts"][name*="[account]"]').count()).toBeGreaterThanOrEqual(1);
|
||||
await expect(modal.locator('a:has-text("New account")')).toBeVisible();
|
||||
});
|
||||
|
||||
test('New account adds an expense-account row', async ({ page }) => {
|
||||
await navigateToInvoices(page);
|
||||
await selectFirstInvoice(page);
|
||||
await openBulkEditModal(page);
|
||||
|
||||
const accountRows = () => page.locator('#bulk-edit-form input[name*="expense-accounts"][name*="[account]"]');
|
||||
const before = await accountRows().count();
|
||||
await addNewAccount(page);
|
||||
expect(await accountRows().count()).toBe(before + 1);
|
||||
});
|
||||
|
||||
test('saving a 100% account coding closes the modal', async ({ page }) => {
|
||||
const info = await getTestInfo(page);
|
||||
await navigateToInvoices(page);
|
||||
await selectFirstInvoice(page);
|
||||
await openBulkEditModal(page);
|
||||
|
||||
// the default row is at 100% already; set its account and save
|
||||
await setRowAccount(page, 0, info.accounts['test-account'].toString());
|
||||
await setRowPercentage(page, 0, '100');
|
||||
await submitForm(page);
|
||||
|
||||
// a successful save fires modalclose -> the modal closes
|
||||
await expect(page.locator('#wizardmodal')).toBeHidden({ timeout: 8000 });
|
||||
});
|
||||
|
||||
// The TOTAL/BALANCE percentage rows were dead code in the wizard (commented out, with a
|
||||
// duplicate id="total"); the migration implements them as a sibling-<tbody> Rule-4 swap.
|
||||
test('TOTAL/BALANCE percentages render and recompute on edit (implemented)', async ({ page }) => {
|
||||
await navigateToInvoices(page);
|
||||
await selectFirstInvoice(page);
|
||||
await openBulkEditModal(page);
|
||||
|
||||
// default row is 100% -> TOTAL 100.0%
|
||||
await expect(page.locator('#expense-totals')).toContainText('100.0%');
|
||||
// edit to 50% -> the totals tbody refreshes via the targeted swap
|
||||
const pct = page.locator('#bulk-edit-form input.amount-field').first();
|
||||
await pct.click();
|
||||
await pct.fill('');
|
||||
await pct.pressSequentially('50'); // keyup -> Rule-4 swap of #expense-totals
|
||||
await expect(page.locator('#expense-totals')).toContainText('50.0%', { timeout: 5000 });
|
||||
});
|
||||
|
||||
test('rejects when account percentages do not total 100%', async ({ page }) => {
|
||||
const info = await getTestInfo(page);
|
||||
await navigateToInvoices(page);
|
||||
await selectFirstInvoice(page);
|
||||
await openBulkEditModal(page);
|
||||
|
||||
await setRowAccount(page, 0, info.accounts['test-account'].toString());
|
||||
await setRowPercentage(page, 0, '50');
|
||||
await submitForm(page);
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// modal stays open on validation failure
|
||||
await expect(page.locator('#modal-holder[x-show="open"]')).toBeVisible();
|
||||
await expect(page.locator('#wizardmodal')).toContainText('does not equal 100%');
|
||||
});
|
||||
});
|
||||
101
e2e/invoice-new.spec.ts
Normal file
101
e2e/invoice-new.spec.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
// Acceptance spec for the New Invoice wizard (basic-details -> [accounts] -> next-steps),
|
||||
// the dual-purpose new+edit wizard with a CONDITIONAL middle step: basic-details creates
|
||||
// straight away when customize-accounts = :default (the vendor's default expense account),
|
||||
// and routes through the expense-accounts grid when :customize.
|
||||
//
|
||||
// NOTE: the pre-migration `mm` flow's basic-details "Save" was broken in this harness (and
|
||||
// prod): the button PUT /invoice/new/navigate, whose `:to` query-schema 500s on empty
|
||||
// query-params (the {}->nil main-transformer quirk). So this is an ACCEPTANCE gate -- red on
|
||||
// the old code, green on the engine (whose submit is a POST with no query-schema). The seed
|
||||
// exposes client TEST + vendor "Test Vendor" (default account "Test Account") via /test-info.
|
||||
test.beforeEach(async ({ request }) => { await request.post('/test-reset'); });
|
||||
|
||||
async function seedIds(page: any): Promise<{ client: number; vendor: number }> {
|
||||
const info = await (await page.request.get('/test-info')).json();
|
||||
return { client: info.clientIds.test, vendor: info.accounts.vendor };
|
||||
}
|
||||
|
||||
// Open the wizard from the invoice list (so htmx/alpine are present -- opening the modal
|
||||
// fragment directly would submit natively).
|
||||
async function openNewWizard(page: any) {
|
||||
await page.setExtraHTTPHeaders({ 'x-clients': '"mine"' });
|
||||
await page.goto('/invoice');
|
||||
await page.waitForSelector('#entity-table tbody tr');
|
||||
await page.locator('button:has-text("New invoice")').first().click();
|
||||
await page.waitForSelector('#wizard-form');
|
||||
await page.waitForTimeout(400);
|
||||
}
|
||||
|
||||
// The vendor field is a typeahead whose hidden input posts invoice/vendor; set it the way a
|
||||
// dropdown pick would land (the value the form submits).
|
||||
async function setVendor(page: any, vendorId: number) {
|
||||
await page.evaluate((id: number) => {
|
||||
const hidden = document.querySelector('input[name="invoice/vendor"]') as HTMLInputElement;
|
||||
hidden.value = String(id);
|
||||
hidden.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
}, vendorId);
|
||||
}
|
||||
|
||||
// The customize-accounts radio lives in an async fragment loaded on the "bryce" event (fired
|
||||
// when the Alpine vendorId changes). Trigger that htmx load explicitly after setting vendor.
|
||||
async function loadPrediction(page: any) {
|
||||
await page.evaluate(() => {
|
||||
const el = document.querySelector('#expense-account-prediction [hx-put], #expense-account-prediction[hx-put]');
|
||||
// @ts-ignore
|
||||
if (el && window.htmx) window.htmx.trigger(el, 'bryce');
|
||||
});
|
||||
await page.waitForTimeout(600);
|
||||
}
|
||||
|
||||
const save = (page: any) => page.locator('#wizard-form button:has-text("Save")').first().click();
|
||||
|
||||
test.describe.configure({ mode: 'serial' });
|
||||
|
||||
test.describe('New Invoice wizard (acceptance)', () => {
|
||||
test('basic-details renders the invoice fields', async ({ page }) => {
|
||||
await openNewWizard(page);
|
||||
const form = page.locator('#wizard-form');
|
||||
await expect(form).toContainText('New invoice');
|
||||
await expect(form).toContainText('Vendor');
|
||||
await expect(form).toContainText('Date');
|
||||
await expect(form).toContainText('Invoice Number');
|
||||
await expect(form).toContainText('Total');
|
||||
await expect(form.locator('input[name="invoice/total"]')).toBeVisible();
|
||||
});
|
||||
|
||||
test('default-accounts path creates the invoice and offers to pay it now', async ({ page }) => {
|
||||
const { vendor } = await seedIds(page);
|
||||
await openNewWizard(page);
|
||||
await setVendor(page, vendor);
|
||||
await page.locator('input[name="invoice/invoice-number"]').fill('NEW-1001');
|
||||
await page.locator('input[name="invoice/total"]').fill('212.44');
|
||||
await page.waitForTimeout(200);
|
||||
await save(page);
|
||||
await page.waitForTimeout(1200);
|
||||
// the next-steps modal (done-fn output) -- no accounts step on the default path
|
||||
await expect(page.locator('body')).toContainText('Would you like to pay this invoice now?');
|
||||
});
|
||||
|
||||
test('customize-accounts path routes through the expense-accounts grid then creates', async ({ page }) => {
|
||||
const { vendor } = await seedIds(page);
|
||||
await openNewWizard(page);
|
||||
await setVendor(page, vendor);
|
||||
await page.locator('input[name="invoice/invoice-number"]').fill('NEW-1002');
|
||||
await page.locator('input[name="invoice/total"]').fill('300.00');
|
||||
await loadPrediction(page);
|
||||
// pick "Customize accounts" (the radio in the async fragment)
|
||||
await page.locator('input[name="customize-accounts"][value="customize"]').first().check();
|
||||
await page.waitForTimeout(150);
|
||||
await save(page);
|
||||
await page.waitForTimeout(1000);
|
||||
// the expense-accounts step: a grid prefilled with the vendor's default account + total
|
||||
const form = page.locator('#wizard-form');
|
||||
await expect(form).toContainText('Invoice accounts');
|
||||
await expect(form).toContainText('INVOICE TOTAL');
|
||||
await save(page); // accounts -> done
|
||||
await page.waitForTimeout(1200);
|
||||
await expect(page.locator('body')).toContainText('Would you like to pay this invoice now?');
|
||||
});
|
||||
});
|
||||
73
e2e/invoice-pay.spec.ts
Normal file
73
e2e/invoice-pay.spec.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
// Characterization spec for the Invoice Pay wizard (the first genuine multi-data-step
|
||||
// wizard: choose-method -> payment-details, merged at submit). Captures CURRENT
|
||||
// (pre-migration) behavior so the migration onto the session-backed engine can be proven
|
||||
// behavior-preserving. The seed's lone unpaid invoice (UNPAID-001, Test Vendor, $150,
|
||||
// client TEST) is payable; its client has one visible check bank account (Test Checking).
|
||||
test.beforeEach(async ({ request }) => { await request.post('/test-reset'); });
|
||||
|
||||
// Select the unpaid invoice on the grid and open the pay wizard (choose-method step).
|
||||
async function openPayWizard(page: any) {
|
||||
await page.setExtraHTTPHeaders({ 'x-clients': '"mine"' });
|
||||
await page.goto('/invoice');
|
||||
await page.waitForSelector('#entity-table tbody tr');
|
||||
await page.locator('#entity-table tbody input[type="checkbox"]').first().click();
|
||||
await page.waitForTimeout(300);
|
||||
// #pay-button's container hx-gets /invoice/pay on click; wait for the wizard to land.
|
||||
await page.locator('#pay-button').first().click();
|
||||
await page.waitForTimeout(900);
|
||||
}
|
||||
|
||||
// The bank-account card's method options (print-check / debit / handwrite-check) live in a
|
||||
// <template x-ref="tooltip"> revealed by clicking the card's tooltip button; open it.
|
||||
async function openMethodTooltip(page: any) {
|
||||
await page.locator('button[x-ref="button"]').first().click();
|
||||
await page.waitForTimeout(400);
|
||||
}
|
||||
|
||||
// Advance choose-method -> payment-details by picking a method (each is an hx-put to
|
||||
// .../pay/navigate?to=:payment-details carrying step-params[method]).
|
||||
async function pickMethod(page: any, method: string) {
|
||||
await openMethodTooltip(page);
|
||||
await page.locator(`[hx-vals*="${method}"]`).first().click();
|
||||
await page.waitForTimeout(900);
|
||||
}
|
||||
|
||||
test.describe.configure({ mode: 'serial' });
|
||||
|
||||
test.describe('Invoice Pay wizard (characterization)', () => {
|
||||
test('choose-method step renders the bank account and its payment methods', async ({ page }) => {
|
||||
await openPayWizard(page);
|
||||
const body = page.locator('body');
|
||||
await expect(body).toContainText('Payment method');
|
||||
await expect(body).toContainText('Test Checking');
|
||||
// a check account offers print-check / debit / handwrite-check (in the card's tooltip)
|
||||
await openMethodTooltip(page);
|
||||
expect(await page.locator('[hx-vals*="handwrite-check"]').count()).toBeGreaterThan(0);
|
||||
expect(await page.locator('[hx-vals*="print-check"]').count()).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('picking handwrite-check advances to the payment-details step', async ({ page }) => {
|
||||
await openPayWizard(page);
|
||||
await pickMethod(page, 'handwrite-check');
|
||||
const body = page.locator('body');
|
||||
await expect(body).toContainText('Check number'); // handwrite-check-only field
|
||||
await expect(body).toContainText('Date'); // check date
|
||||
await expect(body.locator('button:has-text("Pay"), a:has-text("Pay")').first()).toBeVisible();
|
||||
});
|
||||
|
||||
test('completing a handwritten-check payment shows the success modal', async ({ page }) => {
|
||||
await openPayWizard(page);
|
||||
await pickMethod(page, 'handwrite-check');
|
||||
// step 2 collects the check number; method (step 1) + check-number (step 2) combine at submit
|
||||
// scope to the wizard form (the background grid filters also have a check-number input)
|
||||
await page.locator('#wizard-form input[name*="check-number"]').first().fill('10001');
|
||||
await page.waitForTimeout(150);
|
||||
// the footer Pay submit button, scoped to the form (not the background #pay-button)
|
||||
await page.locator('#wizard-form button:has-text("Pay")').first().click();
|
||||
await page.waitForTimeout(1500);
|
||||
// the submit transacts a pending check payment and swaps in the completion modal
|
||||
await expect(page.locator('body')).toContainText('payment is complete');
|
||||
});
|
||||
});
|
||||
141
e2e/sales-summary-edit.spec.ts
Normal file
141
e2e/sales-summary-edit.spec.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
// Characterization spec for the POS Sales Summary edit modal. Captures CURRENT
|
||||
// (pre-migration) behavior so the wizard -> plain-Selmer migration can be proven
|
||||
// behavior-preserving. Reset the shared dataset before each test for isolation.
|
||||
test.beforeEach(async ({ request }) => {
|
||||
await request.post('/test-reset');
|
||||
});
|
||||
|
||||
async function getTestInfo(page: any) {
|
||||
return (await page.request.get('/test-info')).json();
|
||||
}
|
||||
|
||||
async function openEditModal(page: any) {
|
||||
await page.setExtraHTTPHeaders({ 'x-clients': '"mine"' });
|
||||
await page.goto('/pos/summaries');
|
||||
await page.waitForSelector('#entity-table tbody tr');
|
||||
// The row's edit button is an hx-get to /pos/summaries/<id> (the edit-wizard route).
|
||||
await page.locator('#entity-table tbody tr').first()
|
||||
.locator('a[hx-get], button[hx-get]').first().click();
|
||||
await page.waitForSelector('#wizardmodal');
|
||||
}
|
||||
|
||||
test.describe.configure({ mode: 'serial' });
|
||||
|
||||
test.describe('Sales Summary Edit (characterization)', () => {
|
||||
test('opens the edit modal with debit/credit columns, categories, accounts and amounts', async ({ page }) => {
|
||||
await openEditModal(page);
|
||||
|
||||
const modal = page.locator('#wizardmodal');
|
||||
await expect(modal).toContainText('Edit Summary');
|
||||
await expect(modal).toContainText('Debits');
|
||||
await expect(modal).toContainText('Credits');
|
||||
// seeded items
|
||||
await expect(modal).toContainText('Cash Deposit'); // debit item category
|
||||
await expect(modal).toContainText('Food Sales'); // credit item category
|
||||
// resolved account names (account-display-cell pulls the account name)
|
||||
await expect(modal).toContainText('Second Account'); // debit item account
|
||||
await expect(modal).toContainText('Test Account'); // credit item account
|
||||
// amounts render
|
||||
await expect(modal).toContainText('$500.00');
|
||||
|
||||
// two account cells, each with an inline-edit pencil
|
||||
expect(await modal.locator('.account-cell').count()).toBe(2);
|
||||
expect(await modal.locator('[hx-get*="edit/item-account"]').count()).toBe(2);
|
||||
});
|
||||
|
||||
test('seeded summary is balanced (shows Balanced totals, no out-of-balance)', async ({ page }) => {
|
||||
await openEditModal(page);
|
||||
const modal = page.locator('#wizardmodal');
|
||||
// balanced: $500 debit == $500 credit -> the (now-fixed) totals block shows Balanced
|
||||
await expect(modal.locator('#summary-totals')).toContainText('Balanced');
|
||||
await expect(modal.locator('#summary-totals')).toContainText('$500.00');
|
||||
await expect(modal).not.toContainText('Unbalanced');
|
||||
});
|
||||
|
||||
test('inline account edit: pencil opens the typeahead editor; cancel restores the display', async ({ page }) => {
|
||||
await openEditModal(page);
|
||||
const modal = page.locator('#wizardmodal');
|
||||
|
||||
// The debit row shows "Second Account" with a pencil. Click it -> account-edit-cell.
|
||||
const debitCell = modal.locator('.account-cell', { hasText: 'Second Account' }).first();
|
||||
await debitCell.locator('[hx-get*="edit/item-account"]').click();
|
||||
|
||||
// edit cell: a typeahead plus check (save) + cancel buttons
|
||||
const editCell = modal.locator('.account-cell').filter({ has: page.locator('[hx-put*="save-item-account"]') }).first();
|
||||
await expect(editCell.locator('[hx-put*="save-item-account"]')).toBeVisible();
|
||||
await expect(editCell.locator('[hx-get*="cancel-item-account"]')).toBeVisible();
|
||||
|
||||
// Cancel -> back to display mode showing the original account
|
||||
await editCell.locator('[hx-get*="cancel-item-account"]').click();
|
||||
await page.waitForTimeout(300);
|
||||
await expect(modal.locator('.account-cell', { hasText: 'Second Account' }).first()).toBeVisible();
|
||||
// back in display mode: the pencil (edit) is shown again
|
||||
await expect(modal.locator('.account-cell', { hasText: 'Second Account' })
|
||||
.first().locator('[hx-get*="edit/item-account"]')).toBeVisible();
|
||||
});
|
||||
|
||||
test('inline account edit: save (check) re-renders the account display cell', async ({ page }) => {
|
||||
await openEditModal(page);
|
||||
const modal = page.locator('#wizardmodal');
|
||||
|
||||
const creditCell = modal.locator('.account-cell', { hasText: 'Test Account' }).first();
|
||||
await creditCell.locator('[hx-get*="edit/item-account"]').click();
|
||||
|
||||
const editCell = modal.locator('.account-cell').filter({ has: page.locator('[hx-put*="save-item-account"]') }).first();
|
||||
await expect(editCell.locator('[hx-put*="save-item-account"]')).toBeVisible();
|
||||
// Save without changing -> display cell re-renders, account preserved, pencil back.
|
||||
await editCell.locator('[hx-put*="save-item-account"]').click();
|
||||
await page.waitForTimeout(300);
|
||||
const display = modal.locator('.account-cell', { hasText: 'Test Account' }).first();
|
||||
await expect(display).toBeVisible();
|
||||
await expect(display.locator('[hx-get*="edit/item-account"]')).toBeVisible();
|
||||
});
|
||||
|
||||
// The "New Summary Item" button was broken in the pre-migration wizard (its Alpine
|
||||
// handler threw "newRowIndex is not defined"); the migration fixes it as a whole-form
|
||||
// swap (op=new-item). This asserts the FIXED behavior: clicking adds an editable manual
|
||||
// row (category + account typeahead + debit/credit money inputs).
|
||||
test('New Summary Item adds an editable manual row (fixed)', async ({ page }) => {
|
||||
await openEditModal(page);
|
||||
const modal = page.locator('#wizardmodal');
|
||||
expect(await modal.locator('.manual-item-row').count()).toBe(0);
|
||||
|
||||
await modal.locator('a[hx-vals*="new-item"]').click();
|
||||
await expect(modal.locator('.manual-item-row').first()).toBeVisible();
|
||||
expect(await modal.locator('.manual-item-row').count()).toBe(1);
|
||||
|
||||
const row = modal.locator('.manual-item-row').first();
|
||||
await expect(row.locator('input[placeholder="Category/Explanation"]')).toBeVisible();
|
||||
expect(await row.locator('input[name*="[debit]"]').count()).toBe(1);
|
||||
expect(await row.locator('input[name*="[credit]"]').count()).toBe(1);
|
||||
});
|
||||
|
||||
test('a manual debit amount recomputes the totals to Unbalanced (fixed)', async ({ page }) => {
|
||||
await openEditModal(page);
|
||||
const modal = page.locator('#wizardmodal');
|
||||
await expect(modal.locator('#summary-totals')).toContainText('Balanced');
|
||||
await modal.locator('a[hx-vals*="new-item"]').click();
|
||||
await expect(modal.locator('.manual-item-row').first()).toBeVisible();
|
||||
// adding a $500 debit -> $1000 debit vs $500 credit -> the totals block recomputes
|
||||
const debit = modal.locator('.manual-item-row input[name*="[debit]"]').first();
|
||||
await debit.click();
|
||||
await debit.pressSequentially('500'); // fires keyup -> hx-trigger "keyup changed delay:300ms"
|
||||
await expect(modal.locator('#summary-totals')).toContainText('Unbalanced', { timeout: 5000 });
|
||||
});
|
||||
|
||||
test('Save closes the modal and the summary stays in the grid', async ({ page }) => {
|
||||
await openEditModal(page);
|
||||
const modal = page.locator('#wizardmodal');
|
||||
// submit the form via the Save button; the PUT swaps the grid row + fires modalclose
|
||||
const putResp = page.waitForResponse(r =>
|
||||
r.url().includes('/pos/summaries') && r.request().method() === 'PUT');
|
||||
await modal.locator('button[type="submit"]').click();
|
||||
expect((await putResp).status()).toBe(200);
|
||||
// modalclose hides the modal (it is hidden, not removed from the DOM)
|
||||
await expect(modal).toBeHidden({ timeout: 5000 });
|
||||
// the grid still shows the summary row
|
||||
await expect(page.locator('#entity-table tbody tr')).toHaveCount(1);
|
||||
});
|
||||
});
|
||||
389
e2e/transaction-edit-swap.spec.ts
Normal file
389
e2e/transaction-edit-swap.spec.ts
Normal file
@@ -0,0 +1,389 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
// These tests cover the "post the whole form, hx-select what to swap" behaviour
|
||||
// on the transaction edit page. Each edit hits its own route, the server
|
||||
// re-renders the entire form, and the client selects what to swap back -- with
|
||||
// no out-of-band swaps and no morph extension:
|
||||
// - discrete changes (vendor, account, location, mode, add/remove row) swap
|
||||
// all of #edit-form (the active action/tab round-trips through the form,
|
||||
// so it survives the swap);
|
||||
// - typed fields never swap the input the user is in -- the amount field swaps
|
||||
// only the #account-totals tbody (a sibling of the input rows), and the memo
|
||||
// posts with hx-swap=none.
|
||||
// Because the active input is never part of a swapped region, focus and caret
|
||||
// survive a plain swap.
|
||||
|
||||
// Collect any uncaught page errors or console errors so a swap that throws
|
||||
// (e.g. a tooltip callback dereferencing a stale $refs) fails the test loudly.
|
||||
function trackErrors(page: any): string[] {
|
||||
const errors: string[] = [];
|
||||
page.on('pageerror', (e: any) => errors.push('pageerror: ' + e.message));
|
||||
page.on('console', (m: any) => {
|
||||
if (m.type() === 'error') errors.push('console: ' + m.text());
|
||||
});
|
||||
return errors;
|
||||
}
|
||||
|
||||
async function openManualAdvanced(page: any, transactionIndex = 0) {
|
||||
await page.goto('/transaction2');
|
||||
await page.waitForSelector('table tbody tr');
|
||||
await page
|
||||
.locator('button[hx-get*="/transaction2/"][hx-get*="/edit"]')
|
||||
.nth(transactionIndex)
|
||||
.click();
|
||||
await page.waitForSelector('#modal-holder[x-show="open"]', { state: 'visible' });
|
||||
await page.waitForSelector('#editmodal');
|
||||
await page.click('button:has-text("Manual")');
|
||||
|
||||
// First transaction has no accounts so it opens in "simple" mode. Switch to
|
||||
// advanced mode (a whole-form swap) so the account grid is present.
|
||||
const advancedLink = page.locator('a:has-text("Switch to advanced mode")');
|
||||
if (await advancedLink.count()) {
|
||||
await advancedLink.first().click();
|
||||
await page.waitForSelector('#account-grid-body');
|
||||
}
|
||||
}
|
||||
|
||||
// Drives the vendor typeahead like a user: open the dropdown, inject a result
|
||||
// (Solr is unavailable in tests), click it, and wait for the whole-form swap.
|
||||
async function selectVendor(page: any, vendorId: number, label: string) {
|
||||
const vendor = page
|
||||
.locator('div[hx-vals*="vendor-changed"]')
|
||||
.first()
|
||||
.locator('div.relative[x-data]')
|
||||
.first();
|
||||
await vendor.locator('a[x-ref="input"]').click();
|
||||
const search = page.locator('[data-tippy-root] input[x-model="search"]').first();
|
||||
await search.waitFor({ state: 'visible' });
|
||||
await search.fill('xx');
|
||||
await vendor.evaluate((el: HTMLElement, opt: { id: number; label: string }) => {
|
||||
(window as any).Alpine.$data(el).elements = [{ value: opt.id, label: opt.label }];
|
||||
}, { id: vendorId, label });
|
||||
|
||||
const swap = page.waitForResponse(
|
||||
(r: any) =>
|
||||
r.url().includes('edit-form-changed') &&
|
||||
r.request().method() === 'POST' &&
|
||||
r.status() === 200
|
||||
);
|
||||
await page.locator('[data-tippy-root] a', { hasText: label }).first().click();
|
||||
await swap;
|
||||
await page.waitForTimeout(400);
|
||||
}
|
||||
|
||||
// Removes every existing account row (each remove is its own whole-form swap), so a
|
||||
// test starts from a known-empty state regardless of what earlier tests saved
|
||||
// onto the shared transaction.
|
||||
async function clearAccounts(page: any) {
|
||||
// eslint-disable-next-line no-constant-condition
|
||||
while (true) {
|
||||
const removeButtons = page.locator('#account-grid-body .account-remove-action');
|
||||
const count = await removeButtons.count();
|
||||
if (count === 0) break;
|
||||
await removeButtons.first().click();
|
||||
await expect
|
||||
.poll(async () => page.locator('#account-grid-body .account-remove-action').count())
|
||||
.toBeLessThan(count);
|
||||
}
|
||||
}
|
||||
|
||||
test.describe.configure({ mode: 'serial' });
|
||||
|
||||
test.describe('Transaction Edit whole-form swap', () => {
|
||||
test('whole-form swaps (toggle mode, add account) do not throw', async ({ page }) => {
|
||||
const errors = trackErrors(page);
|
||||
|
||||
await openManualAdvanced(page, 0);
|
||||
|
||||
// Add an account row -- another whole-form swap.
|
||||
await page
|
||||
.locator('#account-grid-body')
|
||||
.locator('button:has-text("New account"), a:has-text("New account")')
|
||||
.first()
|
||||
.click();
|
||||
|
||||
await expect
|
||||
.poll(async () => page.locator('#account-grid-body tbody tr.account-row').count())
|
||||
.toBeGreaterThan(0);
|
||||
|
||||
// The form must survive the swap intact.
|
||||
await expect(page.locator('#edit-form')).toHaveCount(1);
|
||||
expect(errors, errors.join('\n')).toEqual([]);
|
||||
});
|
||||
|
||||
test('keeps focus and typed value in the amount field across a swap', async ({ page }) => {
|
||||
const errors = trackErrors(page);
|
||||
|
||||
await openManualAdvanced(page, 0);
|
||||
|
||||
// Ensure exactly one account row exists.
|
||||
const rows = await page.locator('#account-grid-body tbody tr.account-row').count();
|
||||
if (rows === 0) {
|
||||
await page
|
||||
.locator('#account-grid-body')
|
||||
.locator('button:has-text("New account"), a:has-text("New account")')
|
||||
.first()
|
||||
.click();
|
||||
await expect
|
||||
.poll(async () => page.locator('#account-grid-body tbody tr.account-row').count())
|
||||
.toBeGreaterThan(0);
|
||||
}
|
||||
|
||||
const amount = page.locator('.account-amount-field').first();
|
||||
await amount.waitFor();
|
||||
|
||||
// Type a clean value via the keyboard. Typing fires the field's htmx trigger
|
||||
// (keyup), which posts the whole form but swaps back only the #account-totals
|
||||
// tbody -- a sibling of this input's row, so the input is never replaced. It's
|
||||
// type=number (no text caret), so we assert focus + node identity + value.
|
||||
await amount.click();
|
||||
await amount.press('Control+a');
|
||||
|
||||
const amountSwap = page.waitForResponse(
|
||||
(r: any) =>
|
||||
r.url().includes('edit-form-changed') &&
|
||||
r.request().method() === 'POST' &&
|
||||
r.status() === 200
|
||||
);
|
||||
await amount.pressSequentially('150', { delay: 40 });
|
||||
|
||||
// Identify the live focused node (before the debounced swap lands) so we can
|
||||
// prove the *same* node survives.
|
||||
await page.evaluate(() => {
|
||||
(window as any).__focusedAmount = document.activeElement;
|
||||
});
|
||||
|
||||
await amountSwap;
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
const state = await page.evaluate(() => {
|
||||
const active = document.activeElement as HTMLInputElement;
|
||||
return {
|
||||
sameNode: active === (window as any).__focusedAmount,
|
||||
isAmountField: !!active && active.classList.contains('account-amount-field'),
|
||||
value: active ? active.value : null,
|
||||
};
|
||||
});
|
||||
|
||||
// Focus must stay on the amount field after the swap...
|
||||
expect(state.isAmountField).toBe(true);
|
||||
// ...on the very same DOM node (the input is never part of the swapped region)...
|
||||
expect(state.sameNode).toBe(true);
|
||||
// ...with the value the user typed left intact.
|
||||
expect(state.value).toBe('150');
|
||||
|
||||
// The TOTAL must have recomputed server-side from the posted amount and been
|
||||
// applied via the #account-totals swap.
|
||||
await expect(page.locator('.account-total-row #total')).toContainText('150');
|
||||
|
||||
expect(errors, errors.join('\n')).toEqual([]);
|
||||
});
|
||||
|
||||
test('memo edits issue no request and keep their value/caret', async ({ page }) => {
|
||||
const errors = trackErrors(page);
|
||||
|
||||
// Memo affects nothing else in the form, so editing it must NOT issue a
|
||||
// request at all -- its value just rides along in the form until save.
|
||||
let memoRequests = 0;
|
||||
page.on('request', (r: any) => {
|
||||
if (r.url().includes('edit-form-changed') && r.method() === 'POST') memoRequests++;
|
||||
});
|
||||
|
||||
await page.goto('/transaction2');
|
||||
await page.waitForSelector('table tbody tr');
|
||||
await page.locator('button[hx-get*="/transaction2/"][hx-get*="/edit"]').nth(0).click();
|
||||
await page.waitForSelector('#editmodal');
|
||||
|
||||
const memo = page.locator('#edit-memo');
|
||||
await memo.waitFor();
|
||||
|
||||
// Clear any seeded memo text and type "hello".
|
||||
await memo.click();
|
||||
await memo.press('Control+a');
|
||||
await memo.pressSequentially('hello', { delay: 40 });
|
||||
|
||||
// Drop the caret in the middle and insert a char -> "heXllo", caret -> 3.
|
||||
await memo.evaluate((el: HTMLInputElement) => {
|
||||
el.focus();
|
||||
el.setSelectionRange(2, 2);
|
||||
});
|
||||
await page.evaluate(() => {
|
||||
(window as any).__focusedMemo = document.activeElement;
|
||||
});
|
||||
await memo.press('X');
|
||||
|
||||
// Give the old debounce window a chance to (not) fire.
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
const state = await page.evaluate(() => {
|
||||
const active = document.activeElement as HTMLInputElement;
|
||||
return {
|
||||
sameNode: active === (window as any).__focusedMemo,
|
||||
id: active ? active.id : null,
|
||||
value: active ? active.value : null,
|
||||
caret: active ? active.selectionStart : null,
|
||||
};
|
||||
});
|
||||
|
||||
// No request fired, and the value/caret are simply intact (nothing swapped).
|
||||
expect(memoRequests).toBe(0);
|
||||
expect(state.id).toBe('edit-memo');
|
||||
expect(state.sameNode).toBe(true);
|
||||
expect(state.value).toBe('heXllo');
|
||||
expect(state.caret).toBe(3);
|
||||
|
||||
expect(errors, errors.join('\n')).toEqual([]);
|
||||
});
|
||||
|
||||
test('choosing an account from the typeahead does not throw and persists', async ({ page }) => {
|
||||
const errors = trackErrors(page);
|
||||
|
||||
await openManualAdvanced(page, 0);
|
||||
|
||||
// Start from a clean, empty account row so selecting the account actually
|
||||
// changes accountId (and fires the change-gated whole-form swap).
|
||||
await clearAccounts(page);
|
||||
await page
|
||||
.locator('#account-grid-body')
|
||||
.locator('button:has-text("New account"), a:has-text("New account")')
|
||||
.first()
|
||||
.click();
|
||||
await expect
|
||||
.poll(async () => page.locator('#account-grid-body tbody tr.account-row').count())
|
||||
.toBeGreaterThan(0);
|
||||
|
||||
const row = page.locator('#account-grid-body tbody tr.account-row').first();
|
||||
const typeahead = row.locator('div.relative[x-data]').first();
|
||||
|
||||
// Open the dropdown (tippy renders the popper into [data-tippy-root]).
|
||||
await typeahead.locator('a[x-ref="input"]').click();
|
||||
const search = page.locator('[data-tippy-root] input[x-model="search"]').first();
|
||||
await search.waitFor({ state: 'visible' });
|
||||
|
||||
// Account search is backed by Solr (unavailable in tests), so type under the
|
||||
// 3-char threshold and inject a clickable result into the typeahead state --
|
||||
// the click handler, tippy.hide(), Alpine reactivity and the HTMX swap all run
|
||||
// exactly as in production.
|
||||
await search.fill('te');
|
||||
const testInfo = await (await page.request.get('/test-info')).json();
|
||||
const accountId: number = testInfo.accounts['test-account'];
|
||||
await typeahead.evaluate((el: HTMLElement, id: number) => {
|
||||
(window as any).Alpine.$data(el).elements = [{ value: id, label: 'Test Account' }];
|
||||
}, accountId);
|
||||
|
||||
// Clicking the result runs `value = element; tippy.hide(); ...` and dispatches
|
||||
// the change that fires the whole-form swap.
|
||||
const swap = page.waitForResponse(
|
||||
(r: any) =>
|
||||
r.url().includes('edit-form-changed') &&
|
||||
r.request().method() === 'POST' &&
|
||||
r.status() === 200
|
||||
);
|
||||
await page.locator('[data-tippy-root] a', { hasText: 'Test Account' }).first().click();
|
||||
await swap;
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
// The chosen account must survive the whole-form swap.
|
||||
const hidden = page
|
||||
.locator('#account-grid-body tbody tr.account-row')
|
||||
.first()
|
||||
.locator('input[type="hidden"][name*="transaction-account/account"]')
|
||||
.first();
|
||||
await expect(hidden).toHaveValue(accountId.toString());
|
||||
|
||||
expect(errors, errors.join('\n')).toEqual([]);
|
||||
});
|
||||
|
||||
test('selecting a vendor populates its default account across the swap', async ({ page }) => {
|
||||
const errors = trackErrors(page);
|
||||
|
||||
// Open the modal in simple mode (transaction 0 has no accounts).
|
||||
await page.goto('/transaction2');
|
||||
await page.waitForSelector('table tbody tr');
|
||||
await page.locator('button[hx-get*="/transaction2/"][hx-get*="/edit"]').nth(0).click();
|
||||
await page.waitForSelector('#editmodal');
|
||||
await page.click('button:has-text("Manual")');
|
||||
await page.waitForSelector('div[hx-vals*="vendor-changed"]');
|
||||
|
||||
const testInfo = await (await page.request.get('/test-info')).json();
|
||||
const vendorId: number = testInfo.accounts.vendor;
|
||||
const defaultAccountId: number = testInfo.accounts['test-account'];
|
||||
|
||||
// Drive the vendor typeahead like a user: open dropdown, inject a result
|
||||
// (Solr is unavailable in tests), click it.
|
||||
const vendor = page.locator('div[hx-vals*="vendor-changed"]').first().locator('div.relative[x-data]').first();
|
||||
await vendor.locator('a[x-ref="input"]').click();
|
||||
const search = page.locator('[data-tippy-root] input[x-model="search"]').first();
|
||||
await search.waitFor({ state: 'visible' });
|
||||
await search.fill('te');
|
||||
await vendor.evaluate((el: HTMLElement, id: number) => {
|
||||
(window as any).Alpine.$data(el).elements = [{ value: id, label: 'Test Vendor' }];
|
||||
}, vendorId);
|
||||
|
||||
const swap = page.waitForResponse(
|
||||
(r: any) =>
|
||||
r.url().includes('edit-form-changed') &&
|
||||
r.request().method() === 'POST' &&
|
||||
r.status() === 200
|
||||
);
|
||||
await page.locator('[data-tippy-root] a', { hasText: 'Test Vendor' }).first().click();
|
||||
await swap;
|
||||
await page.waitForTimeout(400);
|
||||
|
||||
// The vendor's default account must now be reflected in the account field.
|
||||
// Because the section is rebuilt fresh from the server (no preserved Alpine
|
||||
// state), the server-driven account value lands without any keying tricks.
|
||||
const accountHidden = page
|
||||
.locator('input[type="hidden"][name*="transaction-account/account"]')
|
||||
.first();
|
||||
await expect(accountHidden).toHaveValue(defaultAccountId.toString());
|
||||
|
||||
// The displayed account label should resolve too.
|
||||
await expect(page.locator('span[x-text="value.label"]', { hasText: 'Test Account' })).toBeVisible();
|
||||
|
||||
expect(errors, errors.join('\n')).toEqual([]);
|
||||
});
|
||||
|
||||
test('changing the vendor a second time still updates it', async ({ page }) => {
|
||||
const errors = trackErrors(page);
|
||||
|
||||
await page.goto('/transaction2');
|
||||
await page.waitForSelector('table tbody tr');
|
||||
await page.locator('button[hx-get*="/transaction2/"][hx-get*="/edit"]').nth(0).click();
|
||||
await page.waitForSelector('#editmodal');
|
||||
await page.click('button:has-text("Manual")');
|
||||
await page.waitForSelector('div[hx-vals*="vendor-changed"]');
|
||||
|
||||
const testInfo = await (await page.request.get('/test-info')).json();
|
||||
const vendor1: number = testInfo.accounts.vendor;
|
||||
const vendor2: number = testInfo.accounts.vendor2;
|
||||
const account1: number = testInfo.accounts['test-account'];
|
||||
const account2: number = testInfo.accounts['second-account'];
|
||||
|
||||
const vendorLabel = page
|
||||
.locator('div[hx-vals*="vendor-changed"] span[x-text="value.label"]')
|
||||
.first();
|
||||
const accountHidden = page
|
||||
.locator('input[type="hidden"][name*="transaction-account/account"]')
|
||||
.first();
|
||||
|
||||
// First vendor.
|
||||
await selectVendor(page, vendor1, 'Test Vendor');
|
||||
await expect(vendorLabel).toHaveText('Test Vendor');
|
||||
await expect(accountHidden).toHaveValue(account1.toString());
|
||||
|
||||
// Second vendor -- the regression guard: the section (and its vendor
|
||||
// typeahead) is rebuilt fresh on every swap, so a second change still fires
|
||||
// its request and updates the default account.
|
||||
await selectVendor(page, vendor2, 'Second Vendor');
|
||||
await expect(vendorLabel).toHaveText('Second Vendor');
|
||||
await expect(accountHidden).toHaveValue(account2.toString());
|
||||
|
||||
// And back again, to be sure it keeps working.
|
||||
await selectVendor(page, vendor1, 'Test Vendor');
|
||||
await expect(vendorLabel).toHaveText('Test Vendor');
|
||||
await expect(accountHidden).toHaveValue(account1.toString());
|
||||
|
||||
expect(errors, errors.join('\n')).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,11 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
// Reset the shared test-server dataset before each test so tests are isolated
|
||||
// from one another (and from other spec files) regardless of run order.
|
||||
test.beforeEach(async ({ request }) => {
|
||||
await request.post('/test-reset');
|
||||
});
|
||||
|
||||
async function openEditModal(page: any, transactionIndex: number = 0) {
|
||||
// Navigate to transactions page
|
||||
await page.goto('/transaction2');
|
||||
@@ -13,13 +19,21 @@ async function openEditModal(page: any, transactionIndex: number = 0) {
|
||||
|
||||
// Wait for the modal to open
|
||||
await page.waitForSelector('#modal-holder[x-show="open"]', { state: 'visible' });
|
||||
await page.waitForSelector('#wizardmodal');
|
||||
await page.waitForSelector('#editmodal');
|
||||
|
||||
// The modal is now single-page (Edit Transaction). Click "Manual" tab to ensure
|
||||
// the manual account coding form is active.
|
||||
await page.click('button:has-text("Manual")');
|
||||
|
||||
// Wait for the manual form to appear
|
||||
// Transactions with 0-1 accounts open in "simple" mode, which has no account
|
||||
// grid. Switch to "advanced" mode (a whole-form swap) so the grid the rest of
|
||||
// these helpers manipulate is present.
|
||||
const advancedLink = page.locator('a:has-text("Switch to advanced mode")');
|
||||
if (await advancedLink.count()) {
|
||||
await advancedLink.first().click();
|
||||
}
|
||||
|
||||
// Wait for the manual form (account grid) to appear
|
||||
await page.waitForSelector('#account-grid-body');
|
||||
}
|
||||
|
||||
@@ -33,68 +47,33 @@ async function getTestInfo(page: any) {
|
||||
}
|
||||
|
||||
async function selectAccountFromTypeahead(page: any, rowIndex: number, accountName: string) {
|
||||
// The account search uses Solr which isn't available in tests.
|
||||
// Instead, we directly set the hidden input value via JavaScript.
|
||||
|
||||
// Get all rows except the new-row, total, balance, and transaction total rows
|
||||
const allRows = page.locator('#account-grid-body tbody tr');
|
||||
const rowCount = await allRows.count();
|
||||
|
||||
// Find the row that has a hidden input for account (actual account rows)
|
||||
let accountRow = null;
|
||||
let accountRowIndex = 0;
|
||||
for (let i = 0; i < rowCount; i++) {
|
||||
const row = allRows.nth(i);
|
||||
const hasAccountInput = await row.locator('input[name*="transaction-account/account"]').count() > 0;
|
||||
if (hasAccountInput) {
|
||||
if (accountRowIndex === rowIndex) {
|
||||
accountRow = row;
|
||||
break;
|
||||
}
|
||||
accountRowIndex++;
|
||||
}
|
||||
}
|
||||
|
||||
if (!accountRow) {
|
||||
throw new Error(`Could not find account row at index ${rowIndex}`);
|
||||
}
|
||||
|
||||
// Find the hidden input for the account
|
||||
const hiddenInput = accountRow.locator('input[type="hidden"][name*="transaction-account/account"]').first();
|
||||
|
||||
// Get account IDs from test-info endpoint
|
||||
const testInfo = await getTestInfo(page);
|
||||
// Account search is backed by Solr (unavailable in tests). Drive the typeahead the
|
||||
// way a user does, using the Alpine v3 API: open the tippy dropdown, inject a result
|
||||
// into the component's `elements`, then click it. This runs the real click handler,
|
||||
// Alpine reactivity and the HTMX swap exactly as in production -- unlike poking the
|
||||
// long-removed Alpine v2 `__x` internal, which silently no-ops on Alpine v3 and left
|
||||
// the posted account empty.
|
||||
const accountKey = accountName === 'Test' ? 'test-account' : 'second-account';
|
||||
const label = `${accountName} Account`;
|
||||
const testInfo = await getTestInfo(page);
|
||||
const accountId = testInfo.accounts[accountKey];
|
||||
|
||||
if (!accountId) {
|
||||
throw new Error(`Could not find account with name ${accountName}`);
|
||||
}
|
||||
|
||||
// Set the hidden input value and trigger change
|
||||
// Also update Alpine.js data to prevent it from overwriting our value
|
||||
await hiddenInput.evaluate((el: HTMLInputElement, value: string) => {
|
||||
// Set the DOM value
|
||||
el.value = value;
|
||||
const row = page.locator('#account-grid-body tbody tr.account-row').nth(rowIndex);
|
||||
const typeahead = row.locator('div.relative[x-data]').first();
|
||||
await typeahead.locator('a[x-ref="input"]').click();
|
||||
const search = page.locator('[data-tippy-root] input[x-model="search"]').first();
|
||||
await search.waitFor({ state: 'visible' });
|
||||
await search.fill('te');
|
||||
await typeahead.evaluate((el: any, opt: { id: number; label: string }) => {
|
||||
(window as any).Alpine.$data(el).elements = [{ value: opt.id, label: opt.label }];
|
||||
}, { id: accountId, label });
|
||||
await page.locator('[data-tippy-root] a', { hasText: label }).first().click();
|
||||
|
||||
// Update Alpine.js component data
|
||||
const alpineEl = el.closest('[x-data]');
|
||||
if (alpineEl && (alpineEl as any).__x) {
|
||||
(alpineEl as any).__x.$data.value.value = parseInt(value);
|
||||
(alpineEl as any).__x.$data.value.label = 'Selected Account';
|
||||
}
|
||||
|
||||
// Also update any parent Alpine model (accountId)
|
||||
const rowEl = el.closest('tr[x-data]');
|
||||
if (rowEl && (rowEl as any).__x) {
|
||||
(rowEl as any).__x.$data.accountId = parseInt(value);
|
||||
}
|
||||
|
||||
el.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
}, accountId.toString());
|
||||
|
||||
// Wait for any HTMX updates
|
||||
await page.waitForTimeout(300);
|
||||
// Wait for the change-gated whole-form swap to settle.
|
||||
await page.waitForTimeout(400);
|
||||
}
|
||||
|
||||
async function findAccountRow(page: any, rowIndex: number) {
|
||||
@@ -151,14 +130,13 @@ async function getAccountLocation(page: any, rowIndex: number): Promise<string>
|
||||
}
|
||||
|
||||
async function removeAllAccounts(page: any) {
|
||||
const accountRows = page.locator('#account-grid-body tbody tr.account-row');
|
||||
const rowCount = await accountRows.count();
|
||||
|
||||
for (let i = rowCount - 1; i >= 0; i--) {
|
||||
const row = accountRows.nth(i);
|
||||
const removeButton = row.locator('.account-remove-action');
|
||||
await removeButton.click();
|
||||
// Wait for the Alpine.js removal animation (500ms + buffer)
|
||||
// Re-query each iteration: every remove is a whole-form swap that re-renders the rows,
|
||||
// so a row index captured up front goes stale. Click the last remove button until none
|
||||
// remain.
|
||||
for (let guard = 0; guard < 20; guard++) {
|
||||
const removeButtons = page.locator('#account-grid-body .account-remove-action');
|
||||
if (await removeButtons.count() === 0) break;
|
||||
await removeButtons.last().click();
|
||||
await page.waitForTimeout(700);
|
||||
}
|
||||
}
|
||||
@@ -172,23 +150,23 @@ async function saveTransaction(page: any) {
|
||||
}
|
||||
|
||||
async function toggleToPercentMode(page: any) {
|
||||
const percentRadio = page.locator('input[name="step-params[amount-mode]"][value="%"]');
|
||||
const percentRadio = page.locator('input[name="amount-mode"][value="%"]');
|
||||
await percentRadio.click();
|
||||
|
||||
// Wait for HTMX to swap the grid body
|
||||
await page.waitForResponse(response =>
|
||||
response.url().includes('/toggle-amount-mode') && response.status() === 200
|
||||
response.url().includes('/edit-form-changed') && response.status() === 200
|
||||
);
|
||||
await page.waitForTimeout(200);
|
||||
}
|
||||
|
||||
async function toggleToDollarMode(page: any) {
|
||||
const dollarRadio = page.locator('input[name="step-params[amount-mode]"][value="$"]');
|
||||
const dollarRadio = page.locator('input[name="amount-mode"][value="$"]');
|
||||
await dollarRadio.click();
|
||||
|
||||
// Wait for HTMX to swap the grid body
|
||||
await page.waitForResponse(response =>
|
||||
response.url().includes('/toggle-amount-mode') && response.status() === 200
|
||||
response.url().includes('/edit-form-changed') && response.status() === 200
|
||||
);
|
||||
await page.waitForTimeout(200);
|
||||
}
|
||||
@@ -237,78 +215,39 @@ test.describe('Transaction Edit Shared Location', () => {
|
||||
});
|
||||
|
||||
test.describe('Transaction Edit Full Workflow', () => {
|
||||
test('should code transaction with vendor using percentage, then split 50/50, then switch to dollars', async ({ page }) => {
|
||||
// Step 1: Open edit modal and code with 100% to one account
|
||||
await openEditModal(page);
|
||||
|
||||
// Switch to percentage mode first (this re-renders the grid from server state)
|
||||
test('splits a transaction 50/50 in percentage mode and stores it as dollars', async ({ page }) => {
|
||||
// Transaction 0 is $100. Code it 50% / 50% across two accounts in percentage mode and
|
||||
// verify the save-time %->$ conversion stores/displays $50 + $50 on reopen.
|
||||
//
|
||||
// This intentionally types a percentage and THEN adds another row -- a whole-form
|
||||
// operation. The operation handlers now rebuild from the live posted form, not the
|
||||
// stale snapshot, so the first row's typed 50% survives (it used to revert, yielding a
|
||||
// 66.67/33.33 split).
|
||||
await openEditModal(page, 0);
|
||||
await removeAllAccounts(page);
|
||||
await toggleToPercentMode(page);
|
||||
|
||||
// Check if there's already an account from previous tests
|
||||
const allRows = page.locator('#account-grid-body tbody tr');
|
||||
const hasExistingAccount = await allRows.locator('input[name*="transaction-account/account"]').count() > 0;
|
||||
|
||||
if (!hasExistingAccount) {
|
||||
// Add a new account row if none exist
|
||||
await addNewAccount(page);
|
||||
}
|
||||
|
||||
// Select the account
|
||||
await selectAccountFromTypeahead(page, 0, 'Test');
|
||||
|
||||
// Set amount to 100%
|
||||
await setAccountAmount(page, 0, '100');
|
||||
|
||||
// Save the transaction
|
||||
await saveTransaction(page);
|
||||
|
||||
// Step 2: Re-open and split 50/50 with two accounts
|
||||
await openEditModal(page);
|
||||
|
||||
// Note: amount-mode is UI-only state, so it resets to $ when re-opening
|
||||
// Switch back to percentage mode
|
||||
await toggleToPercentMode(page);
|
||||
|
||||
// The existing account from step 1 should already be there
|
||||
// Change its amount from 100% to 50%
|
||||
await setAccountAmount(page, 0, '50');
|
||||
|
||||
// Add a second account at 50%
|
||||
await addNewAccount(page);
|
||||
await page.waitForTimeout(1000);
|
||||
await selectAccountFromTypeahead(page, 1, 'Second');
|
||||
await setAccountAmount(page, 1, '50');
|
||||
|
||||
// Save
|
||||
await saveTransaction(page);
|
||||
|
||||
// Step 3: Re-open and verify dollar amounts
|
||||
await openEditModal(page);
|
||||
|
||||
// The accounts should be persisted from the previous save
|
||||
// Wait for accounts to load
|
||||
// Reopen: dollar mode is the default, and each account is the converted $50.
|
||||
await openEditModal(page, 0);
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Verify we're in dollar mode (default)
|
||||
const dollarRadio = page.locator('input[name="step-params[amount-mode]"][value="$"]');
|
||||
const dollarRadio = page.locator('input[name="amount-mode"][value="$"]');
|
||||
await expect(dollarRadio).toBeChecked();
|
||||
|
||||
// Verify amounts are in dollars (converted from percentages on save)
|
||||
const row0 = await findAccountRow(page, 0);
|
||||
const row1 = await findAccountRow(page, 1);
|
||||
|
||||
const amount0 = row0.locator('.account-amount-field');
|
||||
const amount1 = row1.locator('.account-amount-field');
|
||||
|
||||
// Each should be $50.00 (or close to it)
|
||||
const val0 = await amount0.inputValue();
|
||||
const val1 = await amount1.inputValue();
|
||||
|
||||
const val0 = await (await findAccountRow(page, 0)).locator('.account-amount-field').inputValue();
|
||||
const val1 = await (await findAccountRow(page, 1)).locator('.account-amount-field').inputValue();
|
||||
expect(parseFloat(val0)).toBeCloseTo(50.0, 1);
|
||||
expect(parseFloat(val1)).toBeCloseTo(50.0, 1);
|
||||
|
||||
// Save
|
||||
await saveTransaction(page);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -339,13 +278,13 @@ test.describe('Transaction Edit Validation', () => {
|
||||
await expect(page.locator('#modal-holder[x-show="open"]')).toBeVisible();
|
||||
|
||||
// The form should still be present
|
||||
const form = page.locator('#wizard-form');
|
||||
const form = page.locator('#edit-form');
|
||||
await expect(form).toBeVisible();
|
||||
|
||||
// Verify the account row is still there with our $50 value
|
||||
const amountInput = page.locator('.account-amount-field').first();
|
||||
const value = await amountInput.inputValue();
|
||||
expect(parseFloat(value)).toBeCloseTo(50.0, 1);
|
||||
// Note: the validation-error response re-renders the manual section, and with
|
||||
// a single account that renders in "simple" mode (no advanced grid), so we
|
||||
// don't assert on the advanced-grid amount field here. The error message
|
||||
// below confirms the $50 value was received and validated.
|
||||
|
||||
// Verify the user-friendly error message is displayed
|
||||
const errorElement = page.locator('#form-errors .error-content');
|
||||
@@ -367,15 +306,16 @@ async function openEditModalForTransaction(page: any, description: string) {
|
||||
const editButton = row.locator('button[hx-get*="/transaction2/"][hx-get*="/edit"]').first();
|
||||
await editButton.click();
|
||||
|
||||
// Wait for the modal to open
|
||||
// Wait for the modal to open. The modal is single-page now (no multi-step wizard
|
||||
// navigation), so the action tabs -- including "Link to payment" -- are available
|
||||
// immediately; callers click the tab they need.
|
||||
await page.waitForSelector('#modal-holder[x-show="open"]', { state: 'visible' });
|
||||
await page.waitForSelector('#wizardmodal');
|
||||
await page.waitForSelector('#editmodal');
|
||||
|
||||
// Click Next to go to the links step (button says "Transaction Actions")
|
||||
await page.click('button:has-text("Transaction Actions")');
|
||||
|
||||
// Wait for the links step to load
|
||||
await page.waitForSelector('text=Transaction Actions', { state: 'visible' });
|
||||
// The modal is single-page: the link tabs ("Link to payment", "Link to unpaid
|
||||
// invoices", ...) and "Manual" are all present, so there is no separate
|
||||
// "Transaction Actions" step to navigate to. Just wait for the tabs to render.
|
||||
await page.waitForSelector('button:has-text("Link to payment")');
|
||||
}
|
||||
|
||||
async function selectVendorFromTypeahead(page: any, vendorName: string) {
|
||||
@@ -386,7 +326,7 @@ async function selectVendorFromTypeahead(page: any, vendorName: string) {
|
||||
throw new Error(`Could not find vendor with name ${vendorName}`);
|
||||
}
|
||||
|
||||
const vendorContainer = page.locator('div[hx-post*="edit-vendor-changed"]').first();
|
||||
const vendorContainer = page.locator('div[hx-vals*="vendor-changed"]').first();
|
||||
const vendorHidden = vendorContainer.locator('input[type="hidden"]').first();
|
||||
|
||||
await vendorHidden.evaluate((el: HTMLInputElement, value: string) => {
|
||||
@@ -401,7 +341,7 @@ async function selectVendorFromTypeahead(page: any, vendorName: string) {
|
||||
el.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
});
|
||||
|
||||
await page.waitForResponse(response => response.url().includes('/edit-vendor-changed') && response.status() === 200);
|
||||
await page.waitForResponse(response => response.url().includes('/edit-form-changed') && response.status() === 200);
|
||||
await page.waitForTimeout(500);
|
||||
}
|
||||
|
||||
@@ -449,9 +389,17 @@ test.describe('Transaction Edit Vendor Pre-population', () => {
|
||||
const testInfo = await getTestInfo(page);
|
||||
expect(accountValue).toBe(testInfo.accounts['test-account'].toString());
|
||||
|
||||
// The populated account amount should equal this transaction's amount (the vendor
|
||||
// default fills the single row with the whole amount). Read the actual amount from
|
||||
// the grid's transaction-total row rather than hard-coding it -- table row order is
|
||||
// not pinned across same-date seed transactions.
|
||||
const txTotalText = await page.locator('.account-grand-total-row').innerText();
|
||||
const txTotal = parseFloat(txTotalText.replace(/[^0-9.]/g, ''));
|
||||
expect(txTotal).toBeGreaterThan(0);
|
||||
|
||||
const amountInput = page.locator('.account-amount-field').first();
|
||||
const amountValue = await amountInput.inputValue();
|
||||
expect(parseFloat(amountValue)).toBeCloseTo(400.0, 1);
|
||||
expect(parseFloat(amountValue)).toBeCloseTo(txTotal, 1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -461,11 +409,11 @@ test.describe('Transaction Edit Vendor Pre-population', () => {
|
||||
// `elements` instead of being fetched. Everything else -- the dropdown's own
|
||||
// search input firing a native `change` on blur, the `value = element` click
|
||||
// handler, the Alpine reactivity, and the HTMX round-trip to
|
||||
// `edit-vendor-changed` -- runs exactly as in production. This is the flow that
|
||||
// `edit-form-changed` (op=vendor-changed) -- runs exactly as in production. This is the flow that
|
||||
// regressed: a stale native `change` from the search input used to win the race
|
||||
// and revert the vendor to its previous value.
|
||||
async function selectVendorViaDropdown(page: any, vendorId: number, vendorName: string) {
|
||||
const wrapper = page.locator('div[hx-post*="edit-vendor-changed"]').first();
|
||||
const wrapper = page.locator('div[hx-vals*="vendor-changed"]').first();
|
||||
const typeahead = wrapper.locator('div.relative[x-data]').first();
|
||||
|
||||
// Open the dropdown (tippy renders the popper into [data-tippy-root]).
|
||||
@@ -493,7 +441,7 @@ async function selectVendorViaDropdown(page: any, vendorId: number, vendorName:
|
||||
|
||||
await page.waitForResponse(
|
||||
(response: any) =>
|
||||
response.url().includes('/edit-vendor-changed') && response.status() === 200
|
||||
response.url().includes('/edit-form-changed') && response.status() === 200
|
||||
);
|
||||
await page.waitForTimeout(500);
|
||||
}
|
||||
@@ -510,9 +458,9 @@ async function openManualVendorSection(page: any, transactionIndex: number) {
|
||||
await editButton.click();
|
||||
|
||||
await page.waitForSelector('#modal-holder[x-show="open"]', { state: 'visible' });
|
||||
await page.waitForSelector('#wizardmodal');
|
||||
await page.waitForSelector('#editmodal');
|
||||
await page.click('button:has-text("Manual")');
|
||||
await page.waitForSelector('div[hx-post*="edit-vendor-changed"]');
|
||||
await page.waitForSelector('div[hx-vals*="vendor-changed"]');
|
||||
}
|
||||
|
||||
test.describe('Transaction Edit Vendor Selection', () => {
|
||||
@@ -528,14 +476,14 @@ test.describe('Transaction Edit Vendor Selection', () => {
|
||||
// round-trip. Before the fix this reverted to blank because a stale
|
||||
// `change` event submitted the previous vendor and its response won.
|
||||
const label = page
|
||||
.locator('div[hx-post*="edit-vendor-changed"] span[x-text="value.label"]')
|
||||
.locator('div[hx-vals*="vendor-changed"] span[x-text="value.label"]')
|
||||
.first();
|
||||
await expect(label).toHaveText('Test Vendor');
|
||||
|
||||
// The server-rendered hidden input must carry the newly selected vendor id.
|
||||
const hidden = page
|
||||
.locator(
|
||||
'div[hx-post*="edit-vendor-changed"] input[type="hidden"][name="step-params[transaction/vendor]"]'
|
||||
'div[hx-vals*="vendor-changed"] input[type="hidden"][name="transaction/vendor"]'
|
||||
)
|
||||
.first();
|
||||
await expect(hidden).toHaveValue(vendorId.toString());
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
// Reset the shared test-server dataset before each test so tests are isolated
|
||||
// from one another (and from other spec files) regardless of run order.
|
||||
test.beforeEach(async ({ request }) => {
|
||||
await request.post('/test-reset');
|
||||
});
|
||||
|
||||
// The SSR manual transaction import accepts the exact Yodlee positional-column
|
||||
// TSV format from the master branch. Column order (14 columns), per
|
||||
// auto-ap.import.manual/columns:
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
// Reset the shared test-server dataset before each test so tests are isolated
|
||||
// from one another (and from other spec files) regardless of run order.
|
||||
test.beforeEach(async ({ request }) => {
|
||||
await request.post('/test-reset');
|
||||
});
|
||||
|
||||
async function navigateToTransactions(page: any, path: string = '/transaction2') {
|
||||
await page.setExtraHTTPHeaders({
|
||||
'x-clients': '"mine"'
|
||||
@@ -90,15 +96,24 @@ test.describe('Transaction Navigation - Amount Filter Persistence', () => {
|
||||
|
||||
test.describe('Transaction Navigation - Date Filter Persistence', () => {
|
||||
test('should persist date-range preset when navigating between pages', async ({ page }) => {
|
||||
// Step 1: Navigate with date-range=all (includes 2022 test data)
|
||||
// Step 1: Navigate with date-range=all (includes 2022 test data).
|
||||
// The server expands the "all" preset into a concrete start-date (~6 years
|
||||
// back) and drops the date-range key, so persistence happens via start-date.
|
||||
await navigateToTransactions(page, '/transaction2?date-range=all');
|
||||
|
||||
// Step 2: Click Unapproved nav link
|
||||
await clickTransactionNavLink(page, 'Unapproved');
|
||||
|
||||
// Step 3: Verify date-range persisted
|
||||
const unapprovedUrl = page.url();
|
||||
expect(unapprovedUrl).toContain('date-range=all');
|
||||
// Step 3: Verify the expanded date range persisted as a start-date.
|
||||
// "all" resolves to roughly 6 years before today (MM/DD/YYYY).
|
||||
const sixYearsAgo = new Date();
|
||||
sixYearsAgo.setFullYear(sixYearsAgo.getFullYear() - 6);
|
||||
const mm = String(sixYearsAgo.getMonth() + 1).padStart(2, '0');
|
||||
const dd = String(sixYearsAgo.getDate()).padStart(2, '0');
|
||||
const expectedStart = `${mm}/${dd}/${sixYearsAgo.getFullYear()}`;
|
||||
|
||||
const startDate = new URL(page.url()).searchParams.get('start-date');
|
||||
expect(startDate).toBe(expectedStart);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
126
e2e/transaction-rule.spec.ts
Normal file
126
e2e/transaction-rule.spec.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
// Characterization spec for the Transaction Rule wizard (edit step + test/preview step).
|
||||
// Captures CURRENT (pre-migration) behavior so the migration onto the session-backed
|
||||
// wizard engine can be proven behavior-preserving. Reset the dataset before each test.
|
||||
test.beforeEach(async ({ request }) => {
|
||||
await request.post('/test-reset');
|
||||
});
|
||||
|
||||
async function getTestInfo(page: any) {
|
||||
return (await page.request.get('/test-info')).json();
|
||||
}
|
||||
|
||||
async function navigateToRules(page: any) {
|
||||
// The rule fixtures live under client TEST2 (to stay out of the single-client TEST
|
||||
// transaction grid the other specs use), so view as an admin who sees all clients.
|
||||
await page.request.get('/test-set-client-mode?mode=multi-client');
|
||||
await page.setExtraHTTPHeaders({ 'x-clients': '"all"' });
|
||||
await page.goto('/admin/transaction-rule');
|
||||
await page.waitForSelector('#entity-table');
|
||||
}
|
||||
|
||||
async function openNewDialog(page: any) {
|
||||
await page.locator('button:has-text("New Transaction Rule")').first().click();
|
||||
await page.waitForSelector('#wizard-form');
|
||||
}
|
||||
|
||||
async function openEditDialog(page: any) {
|
||||
// the edit pencil on the seeded rule's row (hx-get .../<id>/edit)
|
||||
await page.locator('#entity-table tbody tr').first()
|
||||
.locator('[hx-get*="/edit"]').first().click();
|
||||
await page.waitForSelector('#wizard-form');
|
||||
}
|
||||
|
||||
// Add a valid account-coding row (Solr typeahead unavailable in tests, so inject the
|
||||
// account id into the row's hidden input), location Shared, percentage 100.
|
||||
async function addAccount(page: any, accountId: string) {
|
||||
await page.locator('#wizard-form a:has-text("New account")').first().click();
|
||||
await page.waitForTimeout(400);
|
||||
const hidden = page.locator('#wizard-form input[type="hidden"][name*="[transaction-rule-account/account]"]').first();
|
||||
await hidden.evaluate((el: HTMLInputElement, v: string) => {
|
||||
const n = document.createElement('input'); n.type = 'hidden'; n.name = el.name; n.value = v;
|
||||
el.parentNode!.replaceChild(n, el);
|
||||
}, accountId);
|
||||
await page.waitForTimeout(200);
|
||||
const loc = page.locator('#wizard-form select[name*="[transaction-rule-account/location]"]').first();
|
||||
if (await loc.count() > 0) await loc.selectOption('Shared').catch(() => {});
|
||||
const pct = page.locator('#wizard-form input[name*="[transaction-rule-account/percentage]"]').first();
|
||||
await pct.fill('100');
|
||||
await pct.dispatchEvent('change');
|
||||
await page.waitForTimeout(200);
|
||||
}
|
||||
|
||||
async function fillDescription(page: any, desc: string) {
|
||||
await page.locator('#wizard-form input[name="transaction-rule/description"]').first().fill(desc);
|
||||
}
|
||||
|
||||
// Approval status is required to advance/save; the radio-card's first option is "Approved".
|
||||
async function selectApproved(page: any) {
|
||||
const radio = page.locator('#wizard-form input[type="radio"][name*="transaction-approval-status"]').first();
|
||||
await radio.check({ force: true }).catch(async () => {
|
||||
await page.locator('#wizard-form label:has-text("Approved")').first().click();
|
||||
});
|
||||
await page.waitForTimeout(100);
|
||||
}
|
||||
|
||||
async function clickTest(page: any) {
|
||||
// the footer "Test" button navigates edit -> test
|
||||
await page.locator('#wizard-form button:has-text("Test"), #wizard-form a:has-text("Test")').first().click();
|
||||
await page.waitForTimeout(800);
|
||||
}
|
||||
|
||||
test.describe.configure({ mode: 'serial' });
|
||||
|
||||
test.describe('Transaction Rule wizard (characterization)', () => {
|
||||
test('New dialog opens the edit step with rule form + account grid', async ({ page }) => {
|
||||
await navigateToRules(page);
|
||||
await openNewDialog(page);
|
||||
|
||||
const modal = page.locator('#wizard-form');
|
||||
await expect(modal).toContainText('Description');
|
||||
await expect(modal).toContainText('Outcomes');
|
||||
await expect(modal).toContainText('New account');
|
||||
await expect(modal).toContainText('Approval status');
|
||||
// the step indicator + the Test (advance) control
|
||||
await expect(modal.locator('button:has-text("Test"), a:has-text("Test")').first()).toBeVisible();
|
||||
});
|
||||
|
||||
test('Edit dialog pre-populates the seeded rule', async ({ page }) => {
|
||||
await navigateToRules(page);
|
||||
await openEditDialog(page);
|
||||
const desc = page.locator('#wizard-form input[name="transaction-rule/description"]').first();
|
||||
await expect(desc).toHaveValue('ZZRULEMATCH');
|
||||
});
|
||||
|
||||
test('advancing to the test step renders the matching-transactions preview', async ({ page }) => {
|
||||
const info = await getTestInfo(page);
|
||||
await navigateToRules(page);
|
||||
await openNewDialog(page);
|
||||
await fillDescription(page, 'ZZRULEMATCH');
|
||||
await addAccount(page, info.accounts['test-account'].toString());
|
||||
await selectApproved(page);
|
||||
await clickTest(page);
|
||||
// the wizard advances to the test/preview step (the test-table query + render is
|
||||
// reused unchanged by the migration; the seed has no recent match, so the count is 0)
|
||||
const modal = page.locator('#wizard-form');
|
||||
await expect(modal).toContainText('Matching transactions');
|
||||
});
|
||||
|
||||
test('Saving from the test step creates the rule and closes the modal', async ({ page }) => {
|
||||
const info = await getTestInfo(page);
|
||||
await navigateToRules(page);
|
||||
const before = await page.locator('#entity-table tbody tr').count();
|
||||
await openNewDialog(page);
|
||||
await fillDescription(page, 'ZZRULEMATCH');
|
||||
await addAccount(page, info.accounts['test-account'].toString());
|
||||
await selectApproved(page);
|
||||
await clickTest(page);
|
||||
// Save from the test step (the precise Save button, not Back which is also submit)
|
||||
await page.locator('#wizard-form button:has-text("Save")').first().click();
|
||||
await page.waitForTimeout(1000);
|
||||
// modal closed + a new rule row added
|
||||
await expect(page.locator('#wizard-form')).toBeHidden();
|
||||
expect(await page.locator('#entity-table tbody tr').count()).toBe(before + 1);
|
||||
});
|
||||
});
|
||||
103
e2e/vendor-wizard.spec.ts
Normal file
103
e2e/vendor-wizard.spec.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
// Acceptance spec for the New/Edit Vendor wizard (info → terms → account → address → legal),
|
||||
// migrated onto the session-backed engine (wizard2). Like New Invoice, the pre-migration
|
||||
// "Next" PUT /admin/vendor/navigat 500d on the empty route-params {}→nil quirk (a
|
||||
// [:map [:db/id …]] route-schema on a route with no path param), so this is an ACCEPTANCE
|
||||
// gate: green on the engine. Required fields: vendor/name (min 3) on info, vendor/default-account
|
||||
// on account.
|
||||
test.beforeEach(async ({ request }) => { await request.post('/test-reset'); });
|
||||
|
||||
async function seedAccount(page: any): Promise<number> {
|
||||
const info = await (await page.request.get('/test-info')).json();
|
||||
return info.accounts['test-account'];
|
||||
}
|
||||
|
||||
async function openNewVendor(page: any) {
|
||||
await page.goto('/admin/vendor');
|
||||
await page.waitForSelector('#entity-table');
|
||||
await page.locator('button:has-text("New Vendor")').first().click();
|
||||
await page.waitForSelector('#wizard-form');
|
||||
await page.waitForTimeout(400);
|
||||
}
|
||||
|
||||
// The advance/save button is the engine's data-primary nav button.
|
||||
const primary = (page: any) => page.locator('#wizard-form button[data-primary]').first().click();
|
||||
|
||||
async function setHidden(page: any, name: string, value: number) {
|
||||
await page.evaluate(({ name, value }: { name: string; value: number }) => {
|
||||
const h = document.querySelector(`input[name="${name}"]`) as HTMLInputElement;
|
||||
h.value = String(value);
|
||||
h.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
}, { name, value });
|
||||
}
|
||||
|
||||
test.describe.configure({ mode: 'serial' });
|
||||
|
||||
test.describe('Vendor wizard (acceptance)', () => {
|
||||
test('info step renders with the name field and a timeline', async ({ page }) => {
|
||||
await openNewVendor(page);
|
||||
const form = page.locator('#wizard-form');
|
||||
await expect(form).toContainText('Basic Info');
|
||||
await expect(form).toContainText('Terms'); // timeline step
|
||||
await expect(form.locator('input[name="vendor/name"]')).toBeVisible();
|
||||
});
|
||||
|
||||
test('create a vendor across all 5 steps adds it to the grid', async ({ page }) => {
|
||||
const account = await seedAccount(page);
|
||||
await openNewVendor(page);
|
||||
// info
|
||||
await page.locator('input[name="vendor/name"]').fill('Acme Supplies');
|
||||
await primary(page); // -> terms
|
||||
await page.waitForTimeout(500);
|
||||
await expect(page.locator('#wizard-form')).toContainText('Terms Overrides');
|
||||
await primary(page); // -> account
|
||||
await page.waitForTimeout(500);
|
||||
await expect(page.locator('#wizard-form')).toContainText('Default Account');
|
||||
await setHidden(page, 'vendor/default-account', account);
|
||||
await primary(page); // -> address
|
||||
await page.waitForTimeout(500);
|
||||
await expect(page.locator('#wizard-form')).toContainText('Street');
|
||||
await primary(page); // -> legal
|
||||
await page.waitForTimeout(500);
|
||||
await expect(page.locator('#wizard-form')).toContainText('Legal Entity');
|
||||
await primary(page); // Save
|
||||
await page.waitForTimeout(1200);
|
||||
// the vendor persists: reload the grid and it's there
|
||||
await page.goto('/admin/vendor');
|
||||
await page.waitForSelector('#entity-table tbody tr');
|
||||
await expect(page.locator('#entity-table')).toContainText('Acme Supplies');
|
||||
});
|
||||
|
||||
test('edit opens prefilled and a rename persists', async ({ page }) => {
|
||||
const account = await seedAccount(page);
|
||||
await page.goto('/admin/vendor');
|
||||
await page.waitForSelector('#entity-table tbody tr');
|
||||
// open the edit wizard for the seeded "Test Vendor" (its row pencil)
|
||||
await page.locator('#entity-table tbody tr', { hasText: 'Test Vendor' }).first()
|
||||
.locator('[hx-get*="/edit"]').first().click();
|
||||
await page.waitForSelector('#wizard-form');
|
||||
await page.waitForTimeout(400);
|
||||
// info step is prefilled with the existing name
|
||||
await expect(page.locator('input[name="vendor/name"]')).toHaveValue('Test Vendor');
|
||||
await page.locator('input[name="vendor/name"]').fill('Test Vendor RENAMED');
|
||||
await primary(page); await page.waitForTimeout(400); // terms
|
||||
await primary(page); await page.waitForTimeout(400); // account (default-account already set)
|
||||
await setHidden(page, 'vendor/default-account', account);
|
||||
await primary(page); await page.waitForTimeout(400); // address
|
||||
await primary(page); await page.waitForTimeout(400); // legal
|
||||
await primary(page); await page.waitForTimeout(1000); // save
|
||||
await page.goto('/admin/vendor');
|
||||
await page.waitForSelector('#entity-table tbody tr');
|
||||
await expect(page.locator('#entity-table')).toContainText('Test Vendor RENAMED');
|
||||
});
|
||||
|
||||
test('info step blocks advancing when the name is too short', async ({ page }) => {
|
||||
await openNewVendor(page);
|
||||
await page.locator('input[name="vendor/name"]').fill('ab'); // < 3 chars
|
||||
await primary(page);
|
||||
await page.waitForTimeout(500);
|
||||
// still on the info step (validation re-renders it, no advance)
|
||||
await expect(page.locator('#wizard-form')).toContainText('Basic Info');
|
||||
});
|
||||
});
|
||||
@@ -1,17 +1,28 @@
|
||||
import { defineConfig, devices } from '@playwright/test';
|
||||
|
||||
// Allow pointing the suite at an already-running test server (e.g. one booted from a
|
||||
// specific worktree on a non-default port) via BASE_URL. When BASE_URL is set we skip
|
||||
// the auto-started webServer entirely, so parallel worktrees don't fight over :3333.
|
||||
const baseURL = process.env.BASE_URL ?? 'http://localhost:3333';
|
||||
const useExternalServer = !!process.env.BASE_URL;
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './e2e',
|
||||
fullyParallel: true,
|
||||
// These tests share a single stateful test server with one fixed dataset and
|
||||
// mutate the same transactions (coding, bulk coding, etc.), so they must run
|
||||
// serially. Running them in parallel causes cross-test races and flakes.
|
||||
fullyParallel: false,
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
workers: process.env.CI ? 1 : undefined,
|
||||
workers: 1,
|
||||
reporter: 'html',
|
||||
use: {
|
||||
baseURL: 'http://localhost:3333',
|
||||
baseURL,
|
||||
trace: 'on-first-retry',
|
||||
},
|
||||
webServer: {
|
||||
webServer: useExternalServer
|
||||
? undefined
|
||||
: {
|
||||
command: 'lein run -m auto-ap.test-server',
|
||||
url: 'http://localhost:3333/test-info',
|
||||
reuseExistingServer: !process.env.CI,
|
||||
|
||||
@@ -96,6 +96,7 @@
|
||||
[org.clojure/core.async]]
|
||||
|
||||
[hiccup "2.0.0-alpha2"]
|
||||
[selmer "1.12.61"]
|
||||
|
||||
;; needed for java 11
|
||||
[javax.xml.bind/jaxb-api "2.4.0-b180830.0359"]
|
||||
|
||||
@@ -416,4 +416,64 @@ htmx.onLoad(function(content) {
|
||||
console.error('Failed to copy text to clipboard:', err);
|
||||
}
|
||||
}
|
||||
/*
|
||||
(function() {
|
||||
var lastFocusedSelector = null;
|
||||
var lastCursorPosition = null;
|
||||
|
||||
document.addEventListener('htmx:beforeSwap', function(evt) {
|
||||
var active = document.activeElement;
|
||||
if (active && active !== document.body) {
|
||||
// Build a selector to find this element after swap
|
||||
if (active.id) {
|
||||
lastFocusedSelector = '#' + active.id;
|
||||
} else if (active.name) {
|
||||
lastFocusedSelector = '[name="' + active.name + '"]';
|
||||
} else {
|
||||
lastFocusedSelector = null;
|
||||
}
|
||||
|
||||
// Save cursor position for text inputs. selectionStart is null on
|
||||
// inputs that don't support selection (number, date, select, etc.),
|
||||
// and calling setSelectionRange on those throws, so only capture it
|
||||
// when it's an actual numeric caret position.
|
||||
if (typeof active.selectionStart === 'number') {
|
||||
lastCursorPosition = {
|
||||
start: active.selectionStart,
|
||||
end: active.selectionEnd,
|
||||
direction: active.selectionDirection
|
||||
};
|
||||
} else {
|
||||
lastCursorPosition = null;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('htmx:afterSwap', function(evt) {
|
||||
if (lastFocusedSelector) {
|
||||
setTimeout(function() {
|
||||
var el = document.querySelector(lastFocusedSelector);
|
||||
// If morph already kept focus on the right element there's nothing
|
||||
// to do; only restore when focus was actually lost by the swap.
|
||||
if (el && el.focus && document.activeElement !== el) {
|
||||
el.focus();
|
||||
if (lastCursorPosition && el.setSelectionRange) {
|
||||
try {
|
||||
el.setSelectionRange(
|
||||
lastCursorPosition.start,
|
||||
lastCursorPosition.end,
|
||||
lastCursorPosition.direction
|
||||
);
|
||||
} catch (e) { }
|
||||
}
|
||||
}
|
||||
lastFocusedSelector = null;
|
||||
lastCursorPosition = null;
|
||||
}, 10);
|
||||
}
|
||||
});
|
||||
})();
|
||||
|
||||
*/
|
||||
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
9
resources/templates/components/a-button.html
Normal file
9
resources/templates/components/a-button.html
Normal file
@@ -0,0 +1,9 @@
|
||||
<a class="{{ classes }}"{{ attrs|safe }}>
|
||||
{% if indicator %}
|
||||
<div class="htmx-indicator flex items-center">
|
||||
{% include "templates/components/spinner.html" %}
|
||||
<div class="ml-3">Loading...</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="inline-flex gap-2 items-center justify-center{% if indicator %} htmx-indicator-hidden{% endif %}">{{ body|safe }}</div>
|
||||
</a>
|
||||
3
resources/templates/components/a-icon-button.html
Normal file
3
resources/templates/components/a-icon-button.html
Normal file
@@ -0,0 +1,3 @@
|
||||
<a class="{{ classes }}"{{ attrs|safe }}>
|
||||
<div class="h-4 w-4">{{ body|safe }}</div>
|
||||
</a>
|
||||
2
resources/templates/components/badge.html
Normal file
2
resources/templates/components/badge.html
Normal file
@@ -0,0 +1,2 @@
|
||||
<div class="{{ classes }}"{{ attrs|safe }}>{{ body|safe }}
|
||||
</div>
|
||||
2
resources/templates/components/button-group-button.html
Normal file
2
resources/templates/components/button-group-button.html
Normal file
@@ -0,0 +1,2 @@
|
||||
<button class="{{ classes }}"{{ attrs|safe }}>{{ body|safe }}
|
||||
</button>
|
||||
6
resources/templates/components/button-group.html
Normal file
6
resources/templates/components/button-group.html
Normal file
@@ -0,0 +1,6 @@
|
||||
<div class="inline-flex rounded-md shadow-sm"
|
||||
role="group"
|
||||
hx-on:click="this.querySelector("input").value = event.target.value; this.querySelector("input").dispatchEvent(new Event('change', {bubbles: true}));">
|
||||
<input type="hidden" name="{{ name }}">
|
||||
{{ body|safe }}
|
||||
</div>
|
||||
7
resources/templates/components/button.html
Normal file
7
resources/templates/components/button.html
Normal file
@@ -0,0 +1,7 @@
|
||||
<button class="{{ classes }}"{{ attrs|safe }}>
|
||||
<div class="htmx-indicator flex items-center absolute inset-0 justify-center">
|
||||
{% include "templates/components/spinner.html" %}
|
||||
{% if loading_label %}<div class="ml-3">Loading...</div>{% endif %}
|
||||
</div>
|
||||
<div class="htmx-indicator-invisible inline-flex gap-2 items-center justify-center">{{ body|safe }}</div>
|
||||
</button>
|
||||
3
resources/templates/components/data-grid-cell.html
Normal file
3
resources/templates/components/data-grid-cell.html
Normal file
@@ -0,0 +1,3 @@
|
||||
<td class="px-4 py-2{% if klass %} {{ klass }}{% endif %}"
|
||||
{{ attrs|safe }}>{{ body|safe }}
|
||||
</td>
|
||||
10
resources/templates/components/data-grid-header.html
Normal file
10
resources/templates/components/data-grid-header.html
Normal file
@@ -0,0 +1,10 @@
|
||||
<th class="px-4 py-3{% if klass %} {{ klass }}{% endif %}"
|
||||
scope="col"
|
||||
@click="{{ click|safe }}"
|
||||
{{ attrs|safe }}>
|
||||
{% if sort_key %}
|
||||
<a href="#">{{ body|safe }}</a>
|
||||
{% else %}
|
||||
{{ body|safe }}
|
||||
{% endif %}
|
||||
</th>
|
||||
2
resources/templates/components/data-grid-row.html
Normal file
2
resources/templates/components/data-grid-row.html
Normal file
@@ -0,0 +1,2 @@
|
||||
<tr class="{{ classes }}"{{ attrs|safe }}>{{ body|safe }}
|
||||
</tr>
|
||||
11
resources/templates/components/data-grid.html
Normal file
11
resources/templates/components/data-grid.html
Normal file
@@ -0,0 +1,11 @@
|
||||
<div class="shrink overflow-y-scroll">
|
||||
<table class="{{ table_class }}"{{ table_attrs|safe }}>
|
||||
<thead class="{{ thead_class }}">
|
||||
<tr>{{ headers|safe }}</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{ rows|safe }}
|
||||
</tbody>
|
||||
{{ footer_tbody|safe }}
|
||||
</table>
|
||||
</div>
|
||||
3
resources/templates/components/hidden.html
Normal file
3
resources/templates/components/hidden.html
Normal file
@@ -0,0 +1,3 @@
|
||||
{# Hidden input. The Clojure wrapper (sc/hidden) serializes the full attribute map
|
||||
(name, value, optional id/form/class/Alpine :value bind) into `attrs`. #}
|
||||
<input type="hidden"{{ attrs|safe }}>
|
||||
1
resources/templates/components/link.html
Normal file
1
resources/templates/components/link.html
Normal file
@@ -0,0 +1 @@
|
||||
<a class="{{ classes }}"{{ attrs|safe }}>{{ body|safe }}</a>
|
||||
8
resources/templates/components/location-select.html
Normal file
8
resources/templates/components/location-select.html
Normal file
@@ -0,0 +1,8 @@
|
||||
{# Location <select> for a transaction account row. Plain-HTML attributes -- the Selmer
|
||||
migration target (no Hiccup keyword/string attribute ambiguity). Rendered into the
|
||||
surrounding Hiccup row via the auto-ap.ssr.selmer interop bridge. #}
|
||||
<select name="{{ name }}" class="{{ classes }}">
|
||||
{% for opt in options %}
|
||||
<option value="{{ opt.value }}"{% if opt.selected %} selected{% endif %}>{{ opt.label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
2
resources/templates/components/modal.html
Normal file
2
resources/templates/components/modal.html
Normal file
@@ -0,0 +1,2 @@
|
||||
<div class="{{ classes }}" @click.outside="open=false"{{ attrs|safe }}>{{ body|safe }}
|
||||
</div>
|
||||
2
resources/templates/components/money-input.html
Normal file
2
resources/templates/components/money-input.html
Normal file
@@ -0,0 +1,2 @@
|
||||
{# Money input (number, step=0.01, right-aligned). sc/money-input builds `attrs`. #}
|
||||
<input {{ attrs|safe }}>
|
||||
16
resources/templates/components/radio-card.html
Normal file
16
resources/templates/components/radio-card.html
Normal file
@@ -0,0 +1,16 @@
|
||||
<ul class="{{ ul_class }}">
|
||||
{% for opt in options %}
|
||||
<li class="{{ li_class }}">
|
||||
<div class="{{ div_class }}">
|
||||
<input id="{{ opt.id }}"
|
||||
type="radio"
|
||||
value="{{ opt.value }}"
|
||||
name="{{ name }}"
|
||||
class="{{ input_class }}"
|
||||
{{ input_attrs|safe }}
|
||||
{% if opt.checked %}checked{% endif %}>
|
||||
<label for="{{ opt.id }}" class="{{ label_class }}">{{ opt.content|safe }}</label>
|
||||
</div>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
8
resources/templates/components/select.html
Normal file
8
resources/templates/components/select.html
Normal file
@@ -0,0 +1,8 @@
|
||||
{# Generic <select>. options = [{value, label, selected}]. Plain-HTML attributes -- the
|
||||
Selmer migration target (no Hiccup keyword/string attribute ambiguity). Extra attrs
|
||||
(hx-*, x-*) ride through {{ attrs|safe }}. #}
|
||||
<select name="{{ name }}" class="{{ classes }}"{{ attrs|safe }}>
|
||||
{% for opt in options %}
|
||||
<option value="{{ opt.value }}"{% if opt.selected %} selected{% endif %}>{{ opt.label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
11
resources/templates/components/spinner.html
Normal file
11
resources/templates/components/spinner.html
Normal file
@@ -0,0 +1,11 @@
|
||||
<svg aria-hidden="true"
|
||||
class="animate-spin inline w-4 h-4 text-white"
|
||||
fill="none"
|
||||
role="status"
|
||||
viewbox="0 0 100 101"
|
||||
xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z" fill="#E5E7EB">
|
||||
</path>
|
||||
<path d="M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z" fill="currentColor">
|
||||
</path>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
7
resources/templates/components/svg-drop-down.html
Normal file
7
resources/templates/components/svg-drop-down.html
Normal file
@@ -0,0 +1,7 @@
|
||||
<svg aria-hidden="true"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewbox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M19 9l-7 7-7-7" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"></path>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 240 B |
8
resources/templates/components/svg-external-link.html
Normal file
8
resources/templates/components/svg-external-link.html
Normal file
@@ -0,0 +1,8 @@
|
||||
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs></defs><title>navigation-next</title>
|
||||
<path d="M23,9.5H12.387a4,4,0,0,0-4,4v2" fill="none" stroke-linecap="round" stroke-linejoin="round" stroke="currentColor">
|
||||
</path>
|
||||
<polyline fill="none" points="19 13.498 23 9.498 19 5.498" stroke-linecap="round" stroke-linejoin="round" stroke="currentColor"></polyline>
|
||||
<path d="M14.387,13v5.5a1,1,0,0,1-1,1h-12a1,1,0,0,1-1-1V6.5a1,1,0,0,1,1-1h12a1,1,0,0,1,1,1V7" fill="none" stroke-linecap="round" stroke-linejoin="round" stroke="currentColor">
|
||||
</path>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 579 B |
3
resources/templates/components/svg-x.html
Normal file
3
resources/templates/components/svg-x.html
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg viewbox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs></defs><title>delete-2</title><circle cx="12" cy="12" fill="none" r="11.5" stroke-linecap="round" stroke-linejoin="round" stroke="currentColor"></circle><line fill="none" stroke-linecap="round" stroke-linejoin="round" stroke="currentColor" x1="7" x2="17" y1="7" y2="17"></line><line fill="none" stroke-linecap="round" stroke-linejoin="round" stroke="currentColor" x1="17" x2="7" y1="7" y2="17"></line>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 478 B |
3
resources/templates/components/text-input.html
Normal file
3
resources/templates/components/text-input.html
Normal file
@@ -0,0 +1,3 @@
|
||||
{# Text input. sc/text-input builds the full attr map (type/autocomplete/class+size
|
||||
already merged, reusing inputs/default-input-classes + hh/add-class) into `attrs`. #}
|
||||
<input {{ attrs|safe }}>
|
||||
60
resources/templates/components/typeahead.html
Normal file
60
resources/templates/components/typeahead.html
Normal file
@@ -0,0 +1,60 @@
|
||||
{# Click-to-select typeahead (Alpine + tippy). Survives whole-form swaps; null-guarded
|
||||
tippy?. / $refs.input? throughout. The Clojure wrapper (sc/typeahead) resolves the
|
||||
initial {value,label} server-side and builds x_data + the hidden-input attrs. #}
|
||||
<div class="relative"
|
||||
x-data="{{ x_data }}"
|
||||
x-modelable="value.value"
|
||||
{% if x_model %}x-model="{{ x_model }}"{% endif %}
|
||||
{% if key %}key="{{ key }}"{% endif %}>
|
||||
{% if disabled %}
|
||||
<span x-text="value.label"></span>
|
||||
{% else %}
|
||||
<a class="{{ a_class }}"
|
||||
x-tooltip.on.click="{content: ()=>($refs.dropdown?.innerHTML ?? ''), placement: 'bottom', onMount(i) {htmx.process(i.popper); }, popperOptions: {strategy: 'fixed', modifiers: [{name: 'flip', options: {fallbackPlacements: ['top']}}]}, theme: 'dropdown', allowHTML: true, interactive:true}"
|
||||
@keydown.down.prevent.stop="tippy?.show();"
|
||||
@keydown.backspace="tippy?.hide(); value = {value: '', label: '' }"
|
||||
tabindex="0"
|
||||
x-init="{{ a_xinit }}"
|
||||
x-ref="input"><input {{ hidden_attrs|safe }}>
|
||||
<div class="flex w-full justify-items-stretch">
|
||||
<span class="flex-grow text-left" x-text="value.label"></span>
|
||||
<div class="w-3 h-3 m-1 inline ml-1 justify-self-end text-gray-500 self-center">
|
||||
{% include "templates/components/svg-drop-down.html" %}
|
||||
</div>
|
||||
<div x-show="value.warning">
|
||||
<div class="peer absolute inline-flex items-center z-10 justify-center w-6 h-6 text-xs font-black text-white border-3 border-white rounded-full -top-2 -right-2 dark:border-gray-900 bg-red-300"
|
||||
x-tooltip="value.warning">!</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
{% endif %}
|
||||
<template x-ref="dropdown">
|
||||
<ul class="dropdown-contents bg-gray-100 dark:bg-gray-600 ring-1"
|
||||
@keydown.escape="$refs.input?.__x_tippy?.hide(); value = {value: '', label: '' }; "
|
||||
x-destroy="if ($refs.input) {$refs.input.focus();}">
|
||||
<input type="text"
|
||||
autofocus
|
||||
class="{{ search_class }}"
|
||||
x-model="search"
|
||||
placeholder="{{ placeholder }}"
|
||||
@change.stop=""
|
||||
@keydown.down.prevent="active ++; active = active >= elements.length - 1 ? elements.length - 1 : active"
|
||||
@keydown.up.prevent="active --; active = active < 0 ? 0 : active"
|
||||
@keydown.enter.prevent.stop="$refs.input?.__x_tippy?.hide(); value = elements.length > 0 ? $data.elements[active >= 0 ? active : 0] : {'value': '', label: ''}; $refs.input?.focus()"
|
||||
x-init="$el.focus(); $watch('search', s => { if($el.value.length > 2) {fetch(addQueryParam(baseUrl, 'q', s)).then(data=>data.json()).then(data => {elements = data; active=-1; $refs.input?.__x_tippy?.popperInstance?.update()}) }})">
|
||||
<div class="dropdown-options rounded-b-lg overflow-hidden">
|
||||
<template x-for="(element, index) in elements">
|
||||
<li><a class="px-4 py-2 flex gap-2 items-center outline-0 focus:bg-neutral-100 hover:bg-neutral-100 whitespace-nowrap [&.active]:bg-primary-500 [&.active]:dark:bg-primary-700 text-gray-800 dark:text-gray-100"
|
||||
href="#"
|
||||
:class="active == index ? 'active' : ''"
|
||||
@mouseover="active = index"
|
||||
@mouseout="active = -1"
|
||||
@click.prevent="value = element; $refs.input?.__x_tippy?.hide(); setTimeout(() => $refs.input?.focus(), 10)"
|
||||
x-html="element.label"></a></li>
|
||||
</template><template x-if="elements.length == 0">
|
||||
<li class="px-4 py-2 flex gap-2 items-center outline-0 focus:bg-neutral-100 hover:bg-neutral-100 whitespace-nowrap [&.active]:bg-primary-500 text-gray-800 dark:text-gray-100 text-xs ">No results found</li>
|
||||
</template>
|
||||
</div>
|
||||
</ul>
|
||||
</template>
|
||||
</div>
|
||||
12
resources/templates/components/validated-field.html
Normal file
12
resources/templates/components/validated-field.html
Normal file
@@ -0,0 +1,12 @@
|
||||
{# Field wrapper with label + always-present error <p> (the errors- variant of field-).
|
||||
`classes` already folds group / has-error / caller class via hh/add-class; `attrs`
|
||||
carries any pass-through div attributes (the per-row location cell hangs its hx-* /
|
||||
x-dispatch swap wiring here); `body` is the pre-rendered inner control HTML;
|
||||
`errors_str` is the comma-joined string errors (empty when none). #}
|
||||
<div class="{{ classes }}"{{ attrs|safe }}>
|
||||
{% if label %}
|
||||
<label class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">{{ label }}</label>
|
||||
{% endif %}
|
||||
{{ body|safe }}
|
||||
<p class="mt-2 text-xs text-red-600 dark:text-red-500 h-4">{{ errors_str }}</p>
|
||||
</div>
|
||||
8
resources/templates/interop-smoke.html
Normal file
8
resources/templates/interop-smoke.html
Normal file
@@ -0,0 +1,8 @@
|
||||
<div id="interop-smoke" class="p-2">
|
||||
<h3>{{ title }}</h3>
|
||||
{# a Hiccup-rendered component, passed in pre-rendered and emitted verbatim #}
|
||||
{{ hiccup_frag|safe }}
|
||||
<input x-ref="input"
|
||||
x-model="value.value"
|
||||
@keydown.down.prevent.stop="tippy?.show()" />
|
||||
</div>
|
||||
5
resources/templates/invoice-bulk-edit/edit-form.html
Normal file
5
resources/templates/invoice-bulk-edit/edit-form.html
Normal file
@@ -0,0 +1,5 @@
|
||||
{# Top-level plain bulk-edit form (no wizard). The resolved (not-locked) invoice id set
|
||||
rides in hidden ids[] fields so the selection survives form-changed / submit posts
|
||||
without an EDN snapshot or a filter round-trip. #}
|
||||
<form id="bulk-edit-form"{{ form_attrs|safe }}>{{ ids_hidden|safe }}{{ modal|safe }}
|
||||
</form>
|
||||
@@ -0,0 +1,7 @@
|
||||
{# Running TOTAL / BALANCE percentage rows in their own swappable <tbody>, a sibling of
|
||||
the input rows, so a percentage edit refreshes them with a targeted swap (Rule 4) and
|
||||
never replaces the input-bearing rows above. Replaces the old per-cell bulk-edit-total
|
||||
/ bulk-edit-balance routes. #}
|
||||
<tbody id="expense-totals">
|
||||
{{ rows|safe }}
|
||||
</tbody>
|
||||
7
resources/templates/sales-summary/edit-form.html
Normal file
7
resources/templates/sales-summary/edit-form.html
Normal file
@@ -0,0 +1,7 @@
|
||||
{# Plain sales-summary edit form (no wizard). db/id rides in a hidden field; all other
|
||||
state is the live form, re-derived against the entity each request (no EDN snapshot,
|
||||
no step-params). #}
|
||||
<form id="summary-edit-form"{{ form_attrs|safe }}>
|
||||
<input type="hidden" name="db/id" value="{{ db_id }}">
|
||||
{{ modal|safe }}
|
||||
</form>
|
||||
21
resources/templates/sales-summary/summary-body.html
Normal file
21
resources/templates/sales-summary/summary-body.html
Normal file
@@ -0,0 +1,21 @@
|
||||
{# Sales-summary modal body: a read-only Debits | Credits two-column view of the auto
|
||||
items (each account is inline-editable), a swappable totals/balance block, and an
|
||||
editable Manual Items section with a working "New Summary Item" add. #}
|
||||
<div class="space-y-4 p-2">
|
||||
<div class="grid grid-cols-2 gap-6">
|
||||
<div>
|
||||
<div class="font-semibold text-sm mb-2">Debits</div>
|
||||
<div class="space-y-1">{{ debit_rows|safe }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="font-semibold text-sm mb-2">Credits</div>
|
||||
<div class="space-y-1">{{ credit_rows|safe }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="summary-totals">{{ totals|safe }}</div>
|
||||
<div class="mt-4 border-t pt-3">
|
||||
<div class="font-semibold text-sm mb-2">Manual Items</div>
|
||||
<div class="space-y-2" id="manual-items">{{ manual_rows|safe }}</div>
|
||||
<div class="mt-2 flex justify-center">{{ new_item_button|safe }}</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,13 @@
|
||||
{# Bulk-code modal body: vendor field (a change repopulates the default account via a
|
||||
whole-form swap), status select, and the expense-account grid. #}
|
||||
<div class="space-y-4 p-4">
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div {{ vendor_changed_attrs|safe }}>{{ vendor_field|safe }}
|
||||
</div>
|
||||
<div>{{ status_field|safe }}</div>
|
||||
<div class="col-span-2 pt-4">
|
||||
<h3 class="text-lg font-medium mb-3">Expense Accounts</h3>
|
||||
{{ accounts_field|safe }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,6 @@
|
||||
{# Top-level plain form for bulk-code (no wizard). The resolved (not-locked) transaction
|
||||
id set rides in hidden ids[] fields -- the analog of the edit modal's single db/id
|
||||
hidden -- so the selection survives form-changed / submit posts without an EDN snapshot
|
||||
or a filter round-trip. #}
|
||||
<form id="bulk-code-form"{{ form_attrs|safe }}>{{ ids_hidden|safe }}{{ modal|safe }}
|
||||
</form>
|
||||
5
resources/templates/transaction-edit/account-totals.html
Normal file
5
resources/templates/transaction-edit/account-totals.html
Normal file
@@ -0,0 +1,5 @@
|
||||
{# Totals live in their own swappable <tbody> so an amount edit refreshes them with a
|
||||
targeted swap, never replacing the input-bearing rows above (caret survives). #}
|
||||
<tbody id="account-totals">
|
||||
{{ rows|safe }}
|
||||
</tbody>
|
||||
@@ -0,0 +1,4 @@
|
||||
<div x-data="{{ x_data }}">
|
||||
{{ status_hidden|safe }}
|
||||
<div class="inline-flex rounded-md shadow-sm" role="group">{{ buttons|safe }}</div>
|
||||
</div>
|
||||
39
resources/templates/transaction-edit/details-panel.html
Normal file
39
resources/templates/transaction-edit/details-panel.html
Normal file
@@ -0,0 +1,39 @@
|
||||
{# Read-only transaction summary shown in the modal's left side panel. #}
|
||||
<div class="p-4 space-y-4">
|
||||
<h3 class="text-sm font-semibold text-gray-900 uppercase tracking-wider">Details</h3>
|
||||
<div class="space-y-3">
|
||||
<div>
|
||||
<div class="text-xs font-medium text-gray-500">Amount</div>
|
||||
<div class="text-sm font-medium text-gray-900">{{ amount }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-xs font-medium text-gray-500">Date</div>
|
||||
<div class="text-sm text-gray-900">{{ date }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-xs font-medium text-gray-500">Bank Account</div>
|
||||
<div class="text-sm text-gray-900">{{ bank_account }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-xs font-medium text-gray-500">Post Date</div>
|
||||
<div class="text-sm text-gray-900">{{ post_date }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-xs font-medium text-gray-500">Description</div>
|
||||
<div class="text-sm text-gray-900 truncate cursor-help"
|
||||
title="{{ description_original }}">{{ description_simple }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-xs font-medium text-gray-500">Check Number</div>
|
||||
<div class="text-sm text-gray-900">{{ check_number }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-xs font-medium text-gray-500">Status</div>
|
||||
<div class="text-sm text-gray-900">{{ status }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-xs font-medium text-gray-500">Transaction Type</div>
|
||||
<div class="text-sm text-gray-900">{{ type }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
7
resources/templates/transaction-edit/edit-form.html
Normal file
7
resources/templates/transaction-edit/edit-form.html
Normal file
@@ -0,0 +1,7 @@
|
||||
{# Top-level plain form. The entity id rides in a hidden field; all other state is the
|
||||
live form, re-derived against the entity each request (no serialized snapshot, no
|
||||
wizard step-params). #}
|
||||
<form id="edit-form"{{ form_attrs|safe }}>
|
||||
<input type="hidden" name="db/id" value="{{ db_id }}">
|
||||
{{ modal|safe }}
|
||||
</form>
|
||||
15
resources/templates/transaction-edit/edit-modal.html
Normal file
15
resources/templates/transaction-edit/edit-modal.html
Normal file
@@ -0,0 +1,15 @@
|
||||
{# Modal card chrome (header / optional side panel / body / footer). Single-step, so
|
||||
no timeline, no back/next nav -- just the Done button in the footer. Enter triggers
|
||||
the save button via $refs.next. #}
|
||||
<div class="modal-card bg-white rounded-lg shadow dark:bg-gray-700 dark:text-white modal-content flex flex-col max-h-screen max-w-screen md:w-[950px] md:h-[650px] w-full h-full last-modal-step transition duration-150"
|
||||
@keydown.enter.prevent.stop="if ($refs.next) {$refs.next.click()}"
|
||||
x-data="">
|
||||
<div class="flex items-start justify-between p-4 border-b rounded-t dark:border-gray-600 shrink-0">{{ head|safe }}</div>
|
||||
<div class="flex shrink overflow-auto grow">
|
||||
{% if side_panel %}
|
||||
<div class="grow-0 w-64 bg-gray-50 border-r hidden md:block overflow-y-auto max-h-full">{{ side_panel|safe }}</div>
|
||||
{% endif %}
|
||||
<div class="px-6 py-2 space-y-6 overflow-y-scroll w-full shrink grow">{{ body|safe }}</div>
|
||||
</div>
|
||||
<div class="p-4 border-t">{{ footer|safe }}</div>
|
||||
</div>
|
||||
6
resources/templates/transaction-edit/invoice-option.html
Normal file
6
resources/templates/transaction-edit/invoice-option.html
Normal file
@@ -0,0 +1,6 @@
|
||||
<div class="ml-3">
|
||||
<span class="block text-sm font-medium">{{ number }}</span>
|
||||
<span class="block text-sm text-gray-500">{{ vendor }}</span>
|
||||
<span class="block text-sm text-gray-500">{{ date }}</span>
|
||||
<span class="block text-sm font-medium">{{ amount }}</span>
|
||||
</div>
|
||||
27
resources/templates/transaction-edit/linked-payment.html
Normal file
27
resources/templates/transaction-edit/linked-payment.html
Normal file
@@ -0,0 +1,27 @@
|
||||
<div class="my-4 p-4 bg-blue-50 rounded">
|
||||
<h3 class="text-lg font-bold mb-2">Linked Payment{{ external_link|safe }}</h3>
|
||||
<div class="space-y-2">
|
||||
<div class="flex justify-between">
|
||||
<div class="font-medium">Payment #</div>
|
||||
<div>{{ number }}</div>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<div class="font-medium">Vendor</div>
|
||||
<div>{{ vendor }}</div>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<div class="font-medium">Amount</div>
|
||||
<div>{{ amount }}</div>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<div class="font-medium">Status</div>
|
||||
<div>{{ status }}</div>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<div class="font-medium">Date</div>
|
||||
<div>{{ date }}</div>
|
||||
</div>
|
||||
{{ payment_id_hidden|safe }}<div class="mt-4"{{ unlink_attrs|safe }}>{{ unlink_button|safe }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
32
resources/templates/transaction-edit/links-body.html
Normal file
32
resources/templates/transaction-edit/links-body.html
Normal file
@@ -0,0 +1,32 @@
|
||||
{# The single step's body: memo + the activeForm tab switcher (link payment / unpaid /
|
||||
autopay / rule / manual) + the five x-show panels. Fragments are pre-rendered. #}
|
||||
<div class="space-y-1">
|
||||
<div>
|
||||
{{ memo_field|safe }}
|
||||
<div x-data="{{ x_data }}" @unlinked="canChange=true">
|
||||
<div class="flex space-x-2 mb-4">{{ action_hidden|safe }}{{ tabs|safe }}</div>
|
||||
<div x-show="activeForm === 'link-payment'"
|
||||
x-transition:enter="transition ease-out duration-500"
|
||||
x-transition:enter-start="opacity-0 transform scale-95"
|
||||
x-transition:enter-end="opacity-100 transform scale-100">{{ panel_payment|safe }}</div>
|
||||
<div x-show="activeForm === 'link-unpaid-invoices'"
|
||||
x-transition:enter="transition ease-out duration-500"
|
||||
x-transition:enter-start="opacity-0 transform scale-95"
|
||||
x-transition:enter-end="opacity-100 transform scale-100">{{ panel_unpaid|safe }}</div>
|
||||
<div x-show="activeForm === 'link-autopay-invoices'"
|
||||
x-transition:enter="transition ease-out duration-500"
|
||||
x-transition:enter-start="opacity-0 transform scale-95"
|
||||
x-transition:enter-end="opacity-100 transform scale-100">{{ panel_autopay|safe }}</div>
|
||||
<div x-show="activeForm === 'apply-rule'"
|
||||
x-transition:enter="transition ease-out duration-500"
|
||||
x-transition:enter-start="opacity-0 transform scale-95"
|
||||
x-transition:enter-end="opacity-100 transform scale-100">{{ panel_rule|safe }}</div>
|
||||
<div x-show="activeForm === 'manual'"
|
||||
x-transition:enter="transition ease-out duration-500"
|
||||
x-transition:enter-start="opacity-0 transform scale-95"
|
||||
x-transition:enter-end="opacity-100 transform scale-100">
|
||||
<div>{{ panel_manual|safe }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
11
resources/templates/transaction-edit/manual-coding.html
Normal file
11
resources/templates/transaction-edit/manual-coding.html
Normal file
@@ -0,0 +1,11 @@
|
||||
{# Vendor field (a change repopulates the default account via a whole-form swap) + either
|
||||
the simple single-row coding or the advanced account grid. #}
|
||||
<div id="manual-coding-section">
|
||||
{{ mode_hidden|safe }}<div {{ vendor_changed_attrs|safe }}>{{ vendor_field|safe }}
|
||||
</div>
|
||||
{% if is_simple %}
|
||||
<div x-data="{{ simple_xdata }}">{{ simple_mode|safe }}</div>
|
||||
{% else %}
|
||||
<div>{{ toggle_link|safe }}{{ accounts_field|safe }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
1
resources/templates/transaction-edit/panel-empty.html
Normal file
1
resources/templates/transaction-edit/panel-empty.html
Normal file
@@ -0,0 +1 @@
|
||||
<div class="text-center py-4 text-gray-500">{{ message }}</div>
|
||||
10
resources/templates/transaction-edit/panel-list.html
Normal file
10
resources/templates/transaction-edit/panel-list.html
Normal file
@@ -0,0 +1,10 @@
|
||||
{# Shared shell for the autopay/unpaid/rule match panels: heading + action hidden +
|
||||
prompt label + a radio-card of options. #}
|
||||
<div>
|
||||
<h3 class="text-lg font-bold mb-4">{{ heading }}</h3>
|
||||
{{ action_hidden|safe }}
|
||||
<div class="space-y-2">
|
||||
<label class="block text-sm font-medium mb-1">{{ prompt }}</label>
|
||||
{{ radio|safe }}
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,2 @@
|
||||
{# Outer wrapper for the payment tab; the unlink-payment route swaps #payment-matches. #}
|
||||
<div id="payment-matches">{{ inner|safe }}</div>
|
||||
4
resources/templates/transaction-edit/rule-option.html
Normal file
4
resources/templates/transaction-edit/rule-option.html
Normal file
@@ -0,0 +1,4 @@
|
||||
<div class="ml-3">
|
||||
<span class="block text-sm font-medium">{{ note }}</span>
|
||||
<span class="block text-sm text-gray-500">{{ description }}</span>
|
||||
</div>
|
||||
14
resources/templates/transaction-edit/simple-mode.html
Normal file
14
resources/templates/transaction-edit/simple-mode.html
Normal file
@@ -0,0 +1,14 @@
|
||||
{# Simple mode: a single account row (account typeahead + location select) rendered at a
|
||||
fixed index 0, plus the link to switch to the advanced grid. Selecting the account
|
||||
swaps just the location cell (#simple-account-location). #}
|
||||
<div>
|
||||
<span>{{ row_id_hidden|safe }}
|
||||
<div class="flex gap-2 mt-2">
|
||||
{{ account_field|safe }}
|
||||
<div id="simple-account-location">{{ location_field|safe }}</div>
|
||||
{{ amount_hidden|safe }}
|
||||
</div>
|
||||
</span>
|
||||
<div class="mt-1"><a class="text-sm text-blue-600 hover:underline cursor-pointer"
|
||||
{{ toggle_attrs|safe }}>Switch to advanced mode</a></div>
|
||||
</div>
|
||||
3
resources/templates/transaction-edit/transitioner.html
Normal file
3
resources/templates/transaction-edit/transitioner.html
Normal file
@@ -0,0 +1,3 @@
|
||||
{# Wrapper the modal stack expects around the opened form (the wizard transition hooks
|
||||
are gone -- there is only one step). #}
|
||||
<div id="transitioner" class="flex-1">{{ body|safe }}</div>
|
||||
@@ -333,7 +333,8 @@
|
||||
|
||||
(iol-ion.tx.upsert-sales-summary-ledger/upsert-sales-summary (dc/db conn) {:db/id 17592314241429})
|
||||
|
||||
(mark-all-dirty 5)
|
||||
(mark-all-dirty 14)
|
||||
|
||||
(delete-all)
|
||||
|
||||
(sales-summaries-v2)
|
||||
|
||||
@@ -62,7 +62,15 @@
|
||||
(.setHandler server stats-handler))
|
||||
(.setStopAtShutdown server true))
|
||||
|
||||
(def ^:dynamic *http-port-override* nil)
|
||||
(def ^:dynamic *http-port-override*
|
||||
;; In dev, `lein mcp-repl` records the chosen HTTP port in `.http-port` so it
|
||||
;; stays stable across reloads. `refresh` re-evaluates this def, so reading the
|
||||
;; file here (rather than relying solely on an alter-var-root override that gets
|
||||
;; reset) keeps the port from falling back to (env :port). Absent in prod.
|
||||
(let [f (java.io.File. ".http-port")]
|
||||
(when (.exists f)
|
||||
(let [p (.trim ^String (slurp f))]
|
||||
(when (seq p) p)))))
|
||||
|
||||
(mount/defstate port :start (Integer/parseInt (str (or *http-port-override* (env :port) "3000"))))
|
||||
|
||||
|
||||
@@ -10,8 +10,7 @@
|
||||
[bidi.bidi :as bidi]
|
||||
[clj-time.coerce :as coerce]
|
||||
[clj-time.core :as time]
|
||||
[datomic.api :as dc]
|
||||
[hiccup2.core :as hiccup]))
|
||||
[datomic.api :as dc]))
|
||||
|
||||
(defn hourly-changes []
|
||||
(let [tx-instant-attr (:db/id (dc/pull (dc/db conn) '[:db/id] :db/txInstant))
|
||||
@@ -56,34 +55,68 @@
|
||||
[:div
|
||||
[:h1.text-2xl.mb-3.font-bold "Growth in clients"]
|
||||
[:div
|
||||
[:div {:class "w-full h-64"
|
||||
:id "client-chart"
|
||||
:data-chart (hx/json {:labels ["2 years ago" "1 year ago" "today"],
|
||||
:series [(for [n [2 1 0]
|
||||
[:div.w-full.h-64
|
||||
[:canvas.w-full.h-full {:x-data (hx/json {:chart nil
|
||||
:labels ["2 years ago" "1 year ago" "today"]
|
||||
:data (for [n [2 1 0]
|
||||
:let [start (time/plus (time/now) (time/years (- n)))]]
|
||||
(->> (dc/q '[:find (count ?c)
|
||||
:in $
|
||||
:where [?c :client/code]]
|
||||
(dc/as-of (dc/db conn) (coerce/to-date start)))
|
||||
first
|
||||
first))]})}]
|
||||
[:script {:lang "javascript"}
|
||||
(hiccup/raw
|
||||
"new Chartist.Bar('#client-chart', JSON.parse(document.getElementById('client-chart').getAttribute('data-chart')))")]]]])
|
||||
first))})
|
||||
:x-init "new Chart($el, {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: labels,
|
||||
datasets: [{
|
||||
label: 'Clients',
|
||||
data: data,
|
||||
borderWidth: 1
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
scales: {
|
||||
y: {
|
||||
beginAtZero: true
|
||||
}
|
||||
}
|
||||
}
|
||||
});"}]]]]])
|
||||
|
||||
(com/content-card {:class "w-1/2"}
|
||||
[:div {:class "flex flex-col px-4 py-3 space-y-3"}
|
||||
[:div
|
||||
[:h1.text-2xl.mb-3.font-bold "Changes by hour"]
|
||||
[:div
|
||||
[:div {:class "w-full h-64"
|
||||
:id "changes"
|
||||
:data-chart (hx/json {:labels (for [n (range -24 0)]
|
||||
(format "%d" n)),
|
||||
:series [(hourly-changes)]})}]
|
||||
[:script {:lang "javascript"}
|
||||
(hiccup/raw
|
||||
"new Chartist.Line('#changes', JSON.parse(document.getElementById('changes').getAttribute('data-chart')))")]]]])])
|
||||
[:div.w-full.h-64
|
||||
[:canvas.w-full.h-full {:x-data (hx/json {:chart nil
|
||||
:labels (for [n (range -24 0)]
|
||||
(format "%d" n))
|
||||
:data (hourly-changes)})
|
||||
:x-init "new Chart($el, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: labels,
|
||||
datasets: [{
|
||||
label: 'Changes',
|
||||
data: data,
|
||||
borderWidth: 1
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
scales: {
|
||||
y: {
|
||||
beginAtZero: true
|
||||
}
|
||||
}
|
||||
}
|
||||
});"}]]]]])])
|
||||
"Admin"))
|
||||
|
||||
(def key->handler
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -35,7 +35,7 @@
|
||||
default-grid-fields-schema)]))
|
||||
|
||||
(defn filters [request]
|
||||
[:form {"hx-trigger" "change delay:500ms, keyup changed from:.hot-filter delay:1000ms"
|
||||
[:form {"hx-trigger" "datesApplied, change delay:500ms, keyup changed from:.hot-filter delay:1000ms"
|
||||
"hx-get" (bidi/path-for ssr-routes/only-routes
|
||||
::route/table)
|
||||
"hx-target" "#entity-table"
|
||||
|
||||
@@ -14,21 +14,21 @@
|
||||
[auto-ap.rule-matching :as rm]
|
||||
[auto-ap.solr :as solr]
|
||||
[auto-ap.ssr-routes :as ssr-routes]
|
||||
[auto-ap.ssr.common-handlers :refer [add-new-entity-handler]]
|
||||
[auto-ap.ssr.company :refer [bank-account-typeahead*]]
|
||||
[auto-ap.ssr.components :as com]
|
||||
[auto-ap.ssr.components.multi-modal :as mm]
|
||||
[auto-ap.ssr.form-cursor :as fc]
|
||||
[auto-ap.ssr.components.wizard2 :as wizard2]
|
||||
[auto-ap.ssr.grid-page-helper :as helper :refer [wrap-apply-sort]]
|
||||
[auto-ap.ssr.hx :as hx]
|
||||
[auto-ap.ssr.nested-form-params :as nfp]
|
||||
[auto-ap.ssr.svg :as svg]
|
||||
[auto-ap.ssr.utils
|
||||
:refer [apply-middleware-to-all-handlers
|
||||
:refer [->db-id apply-middleware-to-all-handlers
|
||||
default-grid-fields-schema entity-id
|
||||
field-validation-error form-validation-error
|
||||
html-response many-entity modal-response money percentage
|
||||
ref->enum-schema ref->radio-options regex temp-id
|
||||
wrap-entity wrap-merge-prior-hx wrap-schema-enforce]]
|
||||
html-response main-transformer many-entity modal-response money
|
||||
path->name2 percentage ref->enum-schema ref->radio-options regex
|
||||
temp-id wrap-entity wrap-form-4xx-2 wrap-merge-prior-hx
|
||||
wrap-schema-enforce]]
|
||||
[auto-ap.time :as atime]
|
||||
[auto-ap.utils :refer [dollars=]]
|
||||
[bidi.bidi :as bidi]
|
||||
@@ -37,7 +37,23 @@
|
||||
[clojure.string :as str]
|
||||
[datomic.api :as dc]
|
||||
[malli.core :as mc]
|
||||
[malli.util :as mut]))
|
||||
[malli.error :as me]))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Field-name / error helpers for the (de-cursored) rule form. No step-params
|
||||
;; prefix -- posted fields decode straight into form-schema. Mirrors edit.clj.
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(def ^:dynamic *errors*
|
||||
"Humanized form errors for the current rule render, keyed by form-schema paths.
|
||||
Bound by render-edit-step from the engine ctx :errors."
|
||||
{})
|
||||
|
||||
(defn- fname [& path] (apply path->name2 path))
|
||||
(defn- ferr [& path] (get-in *errors* (vec path)))
|
||||
(defn- err? [& path] (boolean (seq (apply ferr path))))
|
||||
(defn- account-field-name [index field] (path->name2 :transaction-rule/accounts index field))
|
||||
(defn- account-field-errors [index field] (ferr :transaction-rule/accounts index field))
|
||||
|
||||
(def query-schema (mc/schema
|
||||
[:maybe
|
||||
@@ -437,67 +453,63 @@
|
||||
client-id))))))})])
|
||||
|
||||
(defn- transaction-rule-account-row*
|
||||
[account client-id client-locations]
|
||||
"One account-coding row, from a plain account map + its index (no cursor). The Alpine
|
||||
cross-field dispatch wiring (clientId -> accountId -> location) is preserved verbatim;
|
||||
only the field names/values move from the form cursor to explicit data + path->name2."
|
||||
[account index client-id client-locations]
|
||||
(let [acct (:transaction-rule-account/account account)
|
||||
acct-id (if (map? acct) (:db/id acct) acct)
|
||||
aname (account-field-name index :transaction-rule-account/account)
|
||||
lname (account-field-name index :transaction-rule-account/location)]
|
||||
(com/data-grid-row
|
||||
(-> {:x-data (hx/json {:accountId (or (:db/id (fc/field-value (:transaction-rule-account/account account)))
|
||||
(fc/field-value (:transaction-rule-account/account account)))
|
||||
:location (fc/field-value (:transaction-rule-account/location account))
|
||||
:show (boolean (not (fc/field-value (:new? account))))})
|
||||
(-> {:x-data (hx/json {:accountId acct-id
|
||||
:location (:transaction-rule-account/location account)
|
||||
:show (boolean (not (:new? account)))})
|
||||
:data-key "show"
|
||||
:x-ref "p"}
|
||||
hx/alpine-mount-then-appear)
|
||||
(let [account-name (fc/field-name (:transaction-rule-account/account account))]
|
||||
(list
|
||||
|
||||
(fc/with-field :db/id
|
||||
(com/hidden {:name (fc/field-name)
|
||||
:value (fc/field-value)}))
|
||||
(fc/with-field :transaction-rule-account/account
|
||||
(com/hidden {:name (account-field-name index :db/id)
|
||||
:value (:db/id account)})
|
||||
(com/data-grid-cell
|
||||
{}
|
||||
(com/validated-field
|
||||
{:errors (fc/field-errors)}
|
||||
{:errors (account-field-errors index :transaction-rule-account/account)}
|
||||
[:div {:hx-trigger "changed"
|
||||
:hx-target "next div"
|
||||
:hx-vals (format "js:{name: '%s', 'client-id': event.detail.clientId || '', value: event.detail.accountId || ''}" account-name)
|
||||
:hx-vals (format "js:{name: '%s', 'client-id': event.detail.clientId || '', value: event.detail.accountId || ''}" aname)
|
||||
:hx-get (str (bidi/path-for ssr-routes/only-routes ::route/account-typeahead))
|
||||
:x-init "$watch('clientId', cid => $dispatch('changed', $data));"}]
|
||||
(account-typeahead* {:value (fc/field-value)
|
||||
(account-typeahead* {:value acct-id
|
||||
:client-id client-id
|
||||
:name (fc/field-name)
|
||||
:x-model "accountId"}))))
|
||||
(fc/with-field :transaction-rule-account/location
|
||||
:name aname
|
||||
:x-model "accountId"})))
|
||||
(com/data-grid-cell
|
||||
{}
|
||||
(com/validated-field
|
||||
{:errors (fc/field-errors)
|
||||
:x-data (hx/json {:location (fc/field-value)})}
|
||||
;; TODO make this thing into a component
|
||||
{:errors (account-field-errors index :transaction-rule-account/location)
|
||||
:x-data (hx/json {:location (:transaction-rule-account/location account)})}
|
||||
[:div {:hx-trigger "changed"
|
||||
:hx-target "next *"
|
||||
:hx-swap "outerHTML"
|
||||
:hx-vals (format "js:{name: '%s', 'client-id': event.detail.clientId || '', 'account-id': event.detail.accountId || '', value: event.detail.location || ''}" (fc/field-name))
|
||||
:hx-vals (format "js:{name: '%s', 'client-id': event.detail.clientId || '', 'account-id': event.detail.accountId || '', value: event.detail.location || ''}" lname)
|
||||
:hx-get (bidi/path-for ssr-routes/only-routes ::route/location-select)
|
||||
:x-init "$watch('clientId', cid => $dispatch('changed', $data)); $watch('accountId', cid => $dispatch('changed', $data) )"}]
|
||||
(location-select* {:name (fc/field-name)
|
||||
:account-location (:account/location (cond->> (:transaction-rule-account/account @account)
|
||||
(nat-int? (:transaction-rule-account/account @account)) (dc/pull (dc/db conn)
|
||||
'[:account/location])))
|
||||
(location-select* {:name lname
|
||||
:account-location (:account/location (when (nat-int? acct-id)
|
||||
(dc/pull (dc/db conn) '[:account/location] acct-id)))
|
||||
:client-locations client-locations
|
||||
:x-model "location"
|
||||
:value (fc/field-value)}))))
|
||||
(fc/with-field :transaction-rule-account/percentage
|
||||
:value (:transaction-rule-account/location account)})))
|
||||
(com/data-grid-cell
|
||||
{}
|
||||
(com/validated-field
|
||||
{:errors (fc/field-errors)}
|
||||
(com/money-input {:name (fc/field-name)
|
||||
{:errors (account-field-errors index :transaction-rule-account/percentage)}
|
||||
(com/money-input {:name (account-field-name index :transaction-rule-account/percentage)
|
||||
:class "w-16"
|
||||
:value (some-> (fc/field-value)
|
||||
:value (some-> (:transaction-rule-account/percentage account)
|
||||
(* 100)
|
||||
(long))}))))))
|
||||
(long))})))
|
||||
(com/data-grid-cell {:class "align-top"}
|
||||
(com/a-icon-button {"@click.prevent.stop" "show=false; setTimeout(() => $refs.p.remove(), 500)"} svg/x))))
|
||||
(com/a-icon-button {"@click.prevent.stop" "show=false; setTimeout(() => $refs.p.remove(), 500)"} svg/x)))))
|
||||
|
||||
(defn all-ids-not-locked [all-ids]
|
||||
(->> all-ids
|
||||
@@ -638,274 +650,212 @@
|
||||
(html-response (row* (:identity request) entity {:delete-after-settle? true :class "live-removed"})
|
||||
:headers {"hx-retarget" (format "#entity-table tr[data-id=\"%d\"]" (:db/id entity))}))
|
||||
|
||||
(defrecord EditModal [linear-wizard]
|
||||
mm/ModalWizardStep
|
||||
(step-name [_]
|
||||
"Edit")
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; The rule wizard on the data-driven session engine (wizard2 / wizard-state),
|
||||
;; replacing the EditModal/TestModal/TransactionRuleWizard records +
|
||||
;; MultiStepFormState + the EDN-snapshot round-trip.
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(step-key [_]
|
||||
:edit)
|
||||
(defn- rule-modal-card [& {:keys [head body footer]}]
|
||||
(com/modal-card-advanced
|
||||
{}
|
||||
(com/modal-header {} head)
|
||||
(com/modal-body {} body)
|
||||
(com/modal-footer {} footer)))
|
||||
|
||||
(edit-path [_ _] [])
|
||||
|
||||
(step-schema [_]
|
||||
(mm/form-schema linear-wizard))
|
||||
|
||||
(render-step [this request]
|
||||
(mm/default-render-step
|
||||
linear-wizard this
|
||||
(defn render-edit-step
|
||||
"Edit step: the rule form, de-cursored (explicit data + path->name2 + *errors*)."
|
||||
[{:keys [step-data errors]}]
|
||||
(binding [*errors* (or errors {})]
|
||||
(let [rule (or step-data {})
|
||||
rule-client (:transaction-rule/client rule)
|
||||
client-id (if (map? rule-client) (:db/id rule-client) rule-client)
|
||||
client-locations (some->> client-id (pull-attr (dc/db conn) :client/locations))
|
||||
accounts (vec (:transaction-rule/accounts rule))]
|
||||
(rule-modal-card
|
||||
:head "Transaction rule"
|
||||
:body (mm/default-step-body {}
|
||||
[:form#my-form {:hx-ext "response-targets"
|
||||
:hx-target-400 "#form-errors .error-content"
|
||||
:hx-indicator "#submit"
|
||||
:x-trap "true"
|
||||
(if (:db/id (fc/field-value))
|
||||
:hx-put
|
||||
:hx-post) (str (bidi/path-for ssr-routes/only-routes ::route/save))}
|
||||
:body [:div#my-form {:x-trap "true"}
|
||||
[:fieldset {:class "hx-disable"
|
||||
:x-data (hx/json {:clientId (or (:db/id (:transaction-rule/client (fc/field-value)))
|
||||
(:transaction-rule/client (fc/field-value)))})}
|
||||
|
||||
:x-data (hx/json {:clientId client-id})}
|
||||
[:div.space-y-1
|
||||
(when-let [id (:db/id (fc/field-value))]
|
||||
(com/hidden {:name "db/id"
|
||||
:value id}))
|
||||
(fc/with-field :transaction-rule/description
|
||||
(com/validated-field {:label "Description"
|
||||
:errors (fc/field-errors)}
|
||||
(com/text-input {:name (fc/field-name)
|
||||
:error? (fc/error?)
|
||||
(when-let [id (:db/id rule)]
|
||||
(com/hidden {:name "db/id" :value id}))
|
||||
(com/validated-field {:label "Description" :errors (ferr :transaction-rule/description)}
|
||||
(com/text-input {:name (fname :transaction-rule/description)
|
||||
:error? (err? :transaction-rule/description)
|
||||
:x-init "$el.focus()"
|
||||
:placeholder "HOME DEPOT"
|
||||
:class "w-96"
|
||||
:value (fc/field-value)})))
|
||||
[:div.filters {:x-data (hx/json {:clientFilter (boolean (fc/field-value (:transaction-rule/client fc/*current*)))
|
||||
:clientGroupFilter (boolean (fc/field-value (:transaction-rule/client-group fc/*current*)))
|
||||
:bankAccountFilter (boolean (fc/field-value (:transaction-rule/bank-account fc/*current*)))
|
||||
:amountFilter (boolean (or (fc/field-value (:transaction-rule/amount-gte fc/*current*))
|
||||
(fc/field-value (:transaction-rule/amount-lte fc/*current*))))
|
||||
:domFilter (boolean (or (fc/field-value (:transaction-rule/dom-gte fc/*current*))
|
||||
(fc/field-value (:transaction-rule/dom-lte fc/*current*))))})}
|
||||
|
||||
:value (:transaction-rule/description rule)}))
|
||||
[:div.filters {:x-data (hx/json {:clientFilter (boolean (:transaction-rule/client rule))
|
||||
:clientGroupFilter (boolean (:transaction-rule/client-group rule))
|
||||
:bankAccountFilter (boolean (:transaction-rule/bank-account rule))
|
||||
:amountFilter (boolean (or (:transaction-rule/amount-gte rule) (:transaction-rule/amount-lte rule)))
|
||||
:domFilter (boolean (or (:transaction-rule/dom-gte rule) (:transaction-rule/dom-lte rule)))})}
|
||||
[:div.flex.gap-2.mb-2
|
||||
(com/a-button {"@click" "clientFilter=true"
|
||||
"x-show" "!clientFilter"} "Filter client")
|
||||
(com/a-button {"@click" "clientGroupFilter=true"
|
||||
"x-show" "!clientGroupFilter"} "Filter client group")
|
||||
(com/a-button {"@click" "bankAccountFilter=true"
|
||||
"x-show" "clientFilter && !bankAccountFilter"} "Filter bank account")
|
||||
(com/a-button {"@click" "amountFilter=true"
|
||||
"x-show" "!amountFilter"} "Filter amount")
|
||||
(com/a-button {"@click" "domFilter=true"
|
||||
"x-show" "!domFilter"} "Filter day of month")]
|
||||
(fc/with-field :transaction-rule/client
|
||||
|
||||
(com/a-button {"@click" "clientFilter=true" "x-show" "!clientFilter"} "Filter client")
|
||||
(com/a-button {"@click" "clientGroupFilter=true" "x-show" "!clientGroupFilter"} "Filter client group")
|
||||
(com/a-button {"@click" "bankAccountFilter=true" "x-show" "clientFilter && !bankAccountFilter"} "Filter bank account")
|
||||
(com/a-button {"@click" "amountFilter=true" "x-show" "!amountFilter"} "Filter amount")
|
||||
(com/a-button {"@click" "domFilter=true" "x-show" "!domFilter"} "Filter day of month")]
|
||||
(com/validated-field
|
||||
(-> {:label "Client"
|
||||
:errors (fc/field-errors)
|
||||
:x-show "clientFilter"}
|
||||
(hx/alpine-appear))
|
||||
(-> {:label "Client" :errors (ferr :transaction-rule/client) :x-show "clientFilter"} (hx/alpine-appear))
|
||||
[:div.w-96
|
||||
(com/typeahead {:name (fc/field-name)
|
||||
:error? (fc/error?)
|
||||
:class "w-96"
|
||||
:placeholder "Search..."
|
||||
(com/typeahead {:name (fname :transaction-rule/client)
|
||||
:error? (err? :transaction-rule/client)
|
||||
:class "w-96" :placeholder "Search..."
|
||||
:url (bidi/path-for ssr-routes/only-routes :company-search)
|
||||
:x-model "clientId"
|
||||
:value (fc/field-value)
|
||||
:content-fn (fn [c] (pull-attr (dc/db conn) :client/name c))})]))
|
||||
(fc/with-field :transaction-rule/client-group
|
||||
|
||||
:value rule-client
|
||||
:content-fn (fn [c] (pull-attr (dc/db conn) :client/name c))})])
|
||||
(com/validated-field
|
||||
(-> {:label "Client Group"
|
||||
:errors (fc/field-errors)
|
||||
:x-show "clientGroupFilter"}
|
||||
(hx/alpine-appear))
|
||||
(-> {:label "Client Group" :errors (ferr :transaction-rule/client-group) :x-show "clientGroupFilter"} (hx/alpine-appear))
|
||||
[:div.w-96
|
||||
(com/text-input {:name (fc/field-name)
|
||||
:error? (fc/error?)
|
||||
:class "w-24"
|
||||
:placeholder "NTG"
|
||||
:value (fc/field-value)})]))
|
||||
(fc/with-field :transaction-rule/bank-account
|
||||
(com/text-input {:name (fname :transaction-rule/client-group)
|
||||
:error? (err? :transaction-rule/client-group)
|
||||
:class "w-24" :placeholder "NTG"
|
||||
:value (:transaction-rule/client-group rule)})])
|
||||
(com/validated-field
|
||||
(-> {:label "Bank Account"
|
||||
:errors (fc/field-errors)
|
||||
:x-show "bankAccountFilter"}
|
||||
hx/alpine-appear)
|
||||
(-> {:label "Bank Account" :errors (ferr :transaction-rule/bank-account) :x-show "bankAccountFilter"} hx/alpine-appear)
|
||||
[:div.w-96
|
||||
[:div#bank-account-changer {:hx-get (bidi/path-for ssr-routes/only-routes :bank-account-typeahead)
|
||||
:hx-trigger "changed"
|
||||
:hx-target "next *"
|
||||
:hx-include "#bank-account-changer"
|
||||
:hx-swap "innerHTML"
|
||||
|
||||
:hx-vals (format "js:{name: '%s', 'client-id': event.detail.clientId}" (fc/field-name))
|
||||
:hx-swap "outerHTML"
|
||||
:hx-vals (format "js:{name: '%s', 'client-id': event.detail.clientId}" (fname :transaction-rule/bank-account))
|
||||
:x-init "$watch('clientId', cid => $dispatch('changed', $data))"}]
|
||||
|
||||
(bank-account-typeahead* {:client-id (:transaction-rule/client (fc/field-value))
|
||||
:name (fc/field-name)
|
||||
:value (fc/field-value)})]))
|
||||
|
||||
(com/field (-> {:label "Amount"
|
||||
:x-show "amountFilter"}
|
||||
hx/alpine-appear)
|
||||
(bank-account-typeahead* {:client-id client-id
|
||||
:name (fname :transaction-rule/bank-account)
|
||||
:value (:transaction-rule/bank-account rule)})])
|
||||
(com/field (-> {:label "Amount" :x-show "amountFilter"} hx/alpine-appear)
|
||||
[:div.flex.gap-2
|
||||
(fc/with-field :transaction-rule/amount-gte
|
||||
[:div.flex.flex-col
|
||||
(com/money-input {:name (fc/field-name)
|
||||
:placeholder ">="
|
||||
:class "w-24"
|
||||
:value (fc/field-value)})
|
||||
(com/errors {:errors (fc/field-errors)})])
|
||||
(fc/with-field :transaction-rule/amount-lte
|
||||
(com/money-input {:name (fname :transaction-rule/amount-gte) :placeholder ">=" :class "w-24" :value (:transaction-rule/amount-gte rule)})
|
||||
(com/errors {:errors (ferr :transaction-rule/amount-gte)})]
|
||||
[:div.flex.flex-col
|
||||
(com/money-input {:name (fc/field-name)
|
||||
:placeholder "<="
|
||||
:class "w-24"
|
||||
:value (fc/field-value)})
|
||||
(com/errors {:errors (fc/field-errors)})])])
|
||||
|
||||
(com/field (-> {:label "Day of month"
|
||||
:x-show "domFilter"}
|
||||
hx/alpine-appear)
|
||||
(com/money-input {:name (fname :transaction-rule/amount-lte) :placeholder "<=" :class "w-24" :value (:transaction-rule/amount-lte rule)})
|
||||
(com/errors {:errors (ferr :transaction-rule/amount-lte)})]])
|
||||
(com/field (-> {:label "Day of month" :x-show "domFilter"} hx/alpine-appear)
|
||||
[:div.flex.gap-2
|
||||
(fc/with-field :transaction-rule/dom-gte
|
||||
(com/validated-field
|
||||
{:errors (fc/field-errors)}
|
||||
(com/int-input {:name (fc/field-name)
|
||||
:placeholder ">="
|
||||
:class "w-24"
|
||||
:value (fc/field-value)})))
|
||||
(fc/with-field :transaction-rule/dom-lte
|
||||
(com/validated-field
|
||||
{:errors (fc/field-errors)}
|
||||
(com/int-input {:name (fc/field-name)
|
||||
:placeholder ">="
|
||||
:class "w-24"
|
||||
:value (fc/field-value)})))])]
|
||||
|
||||
(com/validated-field {:errors (ferr :transaction-rule/dom-gte)}
|
||||
(com/int-input {:name (fname :transaction-rule/dom-gte) :placeholder ">=" :class "w-24" :value (:transaction-rule/dom-gte rule)}))
|
||||
(com/validated-field {:errors (ferr :transaction-rule/dom-lte)}
|
||||
(com/int-input {:name (fname :transaction-rule/dom-lte) :placeholder ">=" :class "w-24" :value (:transaction-rule/dom-lte rule)}))])]
|
||||
[:h2.text-lg "Outcomes"]
|
||||
(fc/with-field :transaction-rule/vendor
|
||||
(com/validated-field {:label "Assign Vendor"
|
||||
:errors (fc/field-errors)}
|
||||
(com/validated-field {:label "Assign Vendor" :errors (ferr :transaction-rule/vendor)}
|
||||
[:div.w-96
|
||||
(com/typeahead {:name (fc/field-name)
|
||||
(com/typeahead {:name (fname :transaction-rule/vendor)
|
||||
:placeholder "Search..."
|
||||
:url (bidi/path-for ssr-routes/only-routes :vendor-search)
|
||||
:class "w-96"
|
||||
:value (fc/field-value)
|
||||
:content-fn #(pull-attr (dc/db conn) :vendor/name %)})]))
|
||||
|
||||
(fc/with-field :transaction-rule/accounts
|
||||
:value (:transaction-rule/vendor rule)
|
||||
:content-fn #(pull-attr (dc/db conn) :vendor/name %)})])
|
||||
(com/validated-field
|
||||
{:errors (fc/field-errors)}
|
||||
(let [client-locations (some->> (fc/field-value) :transaction-rule/client (pull-attr (dc/db conn) :client/locations))]
|
||||
{:errors (ferr :transaction-rule/accounts)}
|
||||
(com/data-grid {:headers [(com/data-grid-header {} "Account")
|
||||
(com/data-grid-header {:class "w-32"} "Location")
|
||||
(com/data-grid-header {:class "w-16"} "%")
|
||||
(com/data-grid-header {:class "w-16"})]}
|
||||
(fc/cursor-map #(transaction-rule-account-row* % (:transaction-rule/client (fc/field-value)) client-locations))
|
||||
(map-indexed (fn [i a] (transaction-rule-account-row* a i client-id client-locations)) accounts)
|
||||
(com/data-grid-new-row {:colspan 4
|
||||
:hx-get (bidi/path-for ssr-routes/only-routes
|
||||
::route/new-account)
|
||||
:index (count (fc/field-value))
|
||||
:hx-get (bidi/path-for ssr-routes/only-routes ::route/new-account)
|
||||
:index (count accounts)
|
||||
:tr-params (hx/bind-alpine-vals {} {"client-id" "clientId"})}
|
||||
"New account")))))
|
||||
|
||||
(fc/with-field :transaction-rule/transaction-approval-status
|
||||
(com/validated-field {:label "Approval status"
|
||||
:errors (fc/field-errors)}
|
||||
"New account")))
|
||||
(com/validated-field {:label "Approval status" :errors (ferr :transaction-rule/transaction-approval-status)}
|
||||
(com/radio-card {:options (ref->radio-options "transaction-approval-status")
|
||||
:value (fc/field-value)
|
||||
:name (fc/field-name)
|
||||
:value (:transaction-rule/transaction-approval-status rule)
|
||||
:name (fname :transaction-rule/transaction-approval-status)
|
||||
:size :small
|
||||
:orientation :horizontal})))]]])
|
||||
:footer
|
||||
(mm/default-step-footer linear-wizard this :validation-route ::route/navigate)
|
||||
:validation-route ::route/navigate)))
|
||||
:orientation :horizontal}))]]]
|
||||
:footer (wizard2/nav-footer {:next "Test"})))))
|
||||
|
||||
(defrecord TestModal [linear-wizard]
|
||||
mm/ModalWizardStep
|
||||
(step-name [_]
|
||||
"Test")
|
||||
|
||||
(step-key [_]
|
||||
:test)
|
||||
|
||||
(edit-path [_ _] [])
|
||||
|
||||
(step-schema [_]
|
||||
(mut/select-keys (mm/form-schema linear-wizard) #{}))
|
||||
|
||||
(render-step [this request]
|
||||
(mm/default-render-step
|
||||
linear-wizard this
|
||||
(defn render-test-step
|
||||
"Test step: a read-only preview of the transactions the rule (the combined session
|
||||
data) matches. The query/render is reused unchanged."
|
||||
[{:keys [all-data request]}]
|
||||
(rule-modal-card
|
||||
:head [:div.p-2.flex.space-x-4 [:div "Transaction Rule"] [:div ">"] [:div "Results"]]
|
||||
:body [:div.space-y-1 {:class "w-[850px] h-[600px]"}
|
||||
(transaction-rule-test-table* {:entity (:snapshot (:multi-form-state request))
|
||||
:clients (:clients request)})]
|
||||
:footer
|
||||
(mm/default-step-footer linear-wizard this :validation-route ::route/navigate)
|
||||
:validation-route ::route/navigate)))
|
||||
(transaction-rule-test-table* {:entity all-data :clients (:clients request)})]
|
||||
:footer (wizard2/nav-footer {:back? true :save? true})))
|
||||
|
||||
(defrecord TransactionRuleWizard [transaction-rule current-step entity]
|
||||
mm/LinearModalWizard
|
||||
(hydrate-from-request
|
||||
[this request]
|
||||
this
|
||||
#_(assoc this :entity (:entity request)))
|
||||
(navigate [this step-key]
|
||||
(assoc this :current-step step-key))
|
||||
(get-current-step [this]
|
||||
(if current-step
|
||||
(mm/get-step this current-step)
|
||||
(mm/get-step this :edit)))
|
||||
(render-wizard [this {:keys [multi-form-state] :as request}]
|
||||
(mm/default-render-wizard
|
||||
this request
|
||||
:form-params
|
||||
(-> mm/default-form-props
|
||||
(assoc (if (get-in multi-form-state [:snapshot :db/id])
|
||||
:hx-put
|
||||
:hx-post)
|
||||
(str (bidi/path-for ssr-routes/only-routes ::route/save))))))
|
||||
(steps [_]
|
||||
[:edit
|
||||
:test])
|
||||
(defn- decode-rule-form
|
||||
"Parse the posted edit-step fields straight into the rule map (no step-params prefix).
|
||||
The engine has already stripped its own nav fields (wizard-id / current-step /
|
||||
direction), so they can't leak into the decoded rule."
|
||||
[request]
|
||||
(let [nested (:form-params (nfp/nested-params-request request {}))]
|
||||
(mc/decode form-schema nested main-transformer)))
|
||||
|
||||
(get-step [this step-key]
|
||||
(let [step-key-result (mc/parse mm/step-key-schema step-key)
|
||||
[step-key-type step-key] step-key-result]
|
||||
(if (= :step step-key-type)
|
||||
(get {:edit (->EditModal this)
|
||||
:test (->TestModal this)}
|
||||
step-key)
|
||||
(defn- rule-form-errors
|
||||
"Per-step validation: schema-validate so an invalid form can't advance to the test step
|
||||
(matches the old navigate-validates behavior). Returns a humanized errors map or nil.
|
||||
The full custom checks (percentage sum, location, bank-account) run at save."
|
||||
[rule _request]
|
||||
(when-not (mc/validate form-schema rule)
|
||||
(me/humanize (mc/explain form-schema rule))))
|
||||
|
||||
nil)))
|
||||
(form-schema [_] form-schema)
|
||||
(submit [_ {:keys [multi-form-state request-method identity] :as request}]
|
||||
|
||||
(let [transaction-rule (:snapshot multi-form-state)
|
||||
_ (validate-transaction-rule transaction-rule)
|
||||
entity (cond-> transaction-rule
|
||||
(:transaction-rule/client-group transaction-rule) (update :transaction-rule/client-group str/upper-case)
|
||||
(= :post request-method) (assoc :db/id "new")
|
||||
true (assoc :transaction-rule/note (entity->note transaction-rule)))
|
||||
{:keys [tempids]} (audit-transact [[:upsert-entity entity]]
|
||||
(:identity request))
|
||||
updated-rule (dc/pull (dc/db conn)
|
||||
default-read
|
||||
(or (get tempids (:db/id entity)) (:db/id entity)))]
|
||||
(defn save-rule!
|
||||
"Engine done-fn: validate + upsert the rule, then return the grid row + modalclose."
|
||||
[all-data request]
|
||||
(validate-transaction-rule all-data)
|
||||
(let [editing? (some? (:db/id all-data))
|
||||
entity (cond-> all-data
|
||||
(:transaction-rule/client-group all-data) (update :transaction-rule/client-group str/upper-case)
|
||||
(not editing?) (assoc :db/id "new")
|
||||
true (assoc :transaction-rule/note (entity->note all-data)))
|
||||
{:keys [tempids]} (audit-transact [[:upsert-entity entity]] (:identity request))
|
||||
saved (dc/pull (dc/db conn) default-read (or (get tempids (:db/id entity)) (:db/id entity)))]
|
||||
(html-response
|
||||
(row* identity updated-rule {:flash? true})
|
||||
(row* (:identity request) saved {:flash? true})
|
||||
:headers (cond-> {"hx-trigger" "modalclose"}
|
||||
(= :post request-method) (assoc "hx-retarget" "#entity-table tbody"
|
||||
"hx-reswap" "afterbegin")
|
||||
(= :put request-method) (assoc "hx-retarget" (format "#entity-table tr[data-id=\"%d\"]" (:db/id updated-rule))
|
||||
"hx-reswap" "outerHTML"))))))
|
||||
(def rule-wizard (->TransactionRuleWizard nil nil nil))
|
||||
(not editing?) (assoc "hx-retarget" "#entity-table tbody" "hx-reswap" "afterbegin")
|
||||
editing? (assoc "hx-retarget" (format "#entity-table tr[data-id=\"%d\"]" (:db/id saved)) "hx-reswap" "outerHTML")))))
|
||||
|
||||
(def transaction-rule-wizard-config
|
||||
{:name :transaction-rule
|
||||
:form-id "wizard-form"
|
||||
:submit-route (bidi/path-for ssr-routes/only-routes ::route/save)
|
||||
:form-attrs {:hx-ext "response-targets"
|
||||
:hx-target-400 "#form-errors"}
|
||||
:init-fn (fn [request]
|
||||
{:context {}
|
||||
:init-data (when-let [e (:entity request)] {:edit e})})
|
||||
;; The engine owns the modal wrap: open-wizard applies this to the rendered form, so the
|
||||
;; new/edit routes are just (partial wizard2/open-wizard config) -- no hand-rolled
|
||||
;; create!/render/wrap/thread boilerplate.
|
||||
:open-response (fn [form]
|
||||
(modal-response [:div#transitioner.flex-1 form]))
|
||||
:steps [{:key :edit
|
||||
:decode decode-rule-form
|
||||
:validate rule-form-errors
|
||||
:render render-edit-step
|
||||
:next (fn [_] :test)}
|
||||
{:key :test
|
||||
:decode (fn [_] {})
|
||||
:render render-test-step
|
||||
:next (fn [_] :done)}]
|
||||
:done-fn save-rule!})
|
||||
|
||||
(defn save-step
|
||||
"POST handler for every step transition (next / back / save) -- the engine reads the
|
||||
`direction` field and either advances, goes back, or finishes via done-fn."
|
||||
[request]
|
||||
(wizard2/handle-step-submit transaction-rule-wizard-config request))
|
||||
|
||||
(defn- new-account
|
||||
"Render one fresh (de-cursored) account row at the posted index (the data grid's
|
||||
newRowIndex Alpine counter increments it for repeated adds)."
|
||||
[request]
|
||||
(let [idx (-> request :query-params :index)
|
||||
idx (if (string? idx) (Integer/parseInt idx) idx)
|
||||
client-id (-> request :query-params :client-id)
|
||||
client-locations (some->> client-id (pull-attr (dc/db conn) :client/locations))]
|
||||
(html-response
|
||||
(transaction-rule-account-row* (wizard2/blank-row :transaction-rule-account/location "Shared")
|
||||
idx client-id client-locations))))
|
||||
|
||||
(def key->handler
|
||||
(apply-middleware-to-all-handlers
|
||||
@@ -916,16 +866,9 @@
|
||||
(wrap-entity [:route-params :db/id] default-read)
|
||||
(wrap-schema-enforce :route-params [:map [:db/id entity-id]]))
|
||||
::route/new-account
|
||||
(->
|
||||
(add-new-entity-handler [:step-params :transaction-rule/accounts]
|
||||
(fn render [cursor request]
|
||||
(transaction-rule-account-row*
|
||||
cursor
|
||||
(:client-id (:query-params request))
|
||||
(some->> (:client-id (:query-params request)) (pull-attr (dc/db conn) :client/locations))))
|
||||
(fn build-new-row [base _]
|
||||
(assoc base :transaction-rule-account/location "Shared")))
|
||||
(-> new-account
|
||||
(wrap-schema-enforce :query-schema [:map
|
||||
[:index {:optional true} [:maybe nat-int?]]
|
||||
[:client-id {:optional true}
|
||||
[:maybe entity-id]]]))
|
||||
|
||||
@@ -943,10 +886,7 @@
|
||||
[:maybe entity-id]]
|
||||
[:value {:optional true}
|
||||
[:maybe entity-id]]]))
|
||||
::route/save (-> mm/submit-handler
|
||||
(mm/wrap-wizard rule-wizard)
|
||||
(mm/wrap-decode-multi-form-state)
|
||||
(wrap-entity [:form-params :db/id] default-read))
|
||||
::route/save save-step
|
||||
|
||||
::route/execute (-> execute
|
||||
(wrap-entity [:route-params :db/id] default-read)
|
||||
@@ -976,24 +916,11 @@
|
||||
(wrap-entity [:route-params :db/id] default-read)
|
||||
(wrap-schema-enforce :route-schema [:map [:db/id entity-id]]))
|
||||
|
||||
::route/navigate (-> mm/next-handler
|
||||
(mm/wrap-wizard rule-wizard)
|
||||
(mm/wrap-decode-multi-form-state))
|
||||
::route/edit-dialog (-> mm/open-wizard-handler
|
||||
(mm/wrap-wizard rule-wizard)
|
||||
(mm/wrap-init-multi-form-state (fn [request]
|
||||
(mm/->MultiStepFormState (:entity request)
|
||||
[]
|
||||
(:entity request))))
|
||||
::route/edit-dialog (-> (partial wizard2/open-wizard transaction-rule-wizard-config)
|
||||
(wrap-entity [:route-params :db/id] default-read)
|
||||
(wrap-schema-enforce :route-schema [:map [:db/id entity-id]]))
|
||||
|
||||
::route/new-dialog (-> mm/open-wizard-handler
|
||||
(mm/wrap-wizard rule-wizard)
|
||||
(mm/wrap-init-multi-form-state (fn [_]
|
||||
(mm/->MultiStepFormState {}
|
||||
[]
|
||||
{}))))})
|
||||
::route/new-dialog (partial wizard2/open-wizard transaction-rule-wizard-config)})
|
||||
(fn [h]
|
||||
(-> h
|
||||
(wrap-copy-qp-pqp)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -50,7 +50,7 @@
|
||||
[:link {:rel "stylesheet" :href "/output.css"}]
|
||||
[:script {:defer true :src "https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"}]
|
||||
[:style
|
||||
"body{background:linear-gradient(160deg,#79b52e 0%,#009cea 100%);min-height:100vh}"]]
|
||||
"body{background:linear-gradient(160deg,#79b52e 0%,#009cea 100%);min-height:100vh}@keyframes slideUp{from{opacity:0;transform:translateY(20px)}to{opacity:1;transform:translateY(0)}}"]]
|
||||
[:body contents]]))})
|
||||
|
||||
(defn- page-contents [request]
|
||||
@@ -72,7 +72,7 @@
|
||||
[:div.flex-shrink-0.w-5.h-5.text-red-500 svg/alert]
|
||||
[:div.flex-1.min-w-0
|
||||
[:p.text-sm.font-medium.text-gray-900 "Something went wrong"]
|
||||
[:p.text-xs.text-gray-500.mt-0.5
|
||||
[:div.text-xs.text-gray-500.mt-0.5
|
||||
"Our team has been notified. Please try again."
|
||||
[:span {:x-data (hx/json {"e" false})}
|
||||
" "
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
(com/data-grid-header {} "Synced count")
|
||||
(com/data-grid-header {} "Approved transactions")
|
||||
(com/data-grid-header {} "Unapproved transactions")
|
||||
(com/data-grid-header {} "Requires feedback transactions")
|
||||
(com/data-grid-header {} "Client Review transactions")
|
||||
(com/data-grid-header {} "Missing transactions")])
|
||||
#_#_:thead-params {:class "sticky top-0 z-50"}}
|
||||
(for [row report]
|
||||
|
||||
@@ -81,7 +81,7 @@
|
||||
(dropdown-search-results* {:options (get-clients identity (get (:query-params request) "search-text"))})))
|
||||
|
||||
(defn dropdown [{:keys [client-selection client identity clients]}]
|
||||
[:div#company-dropdown {:x-data (hx/json {})}
|
||||
[:div#company-dropdown {:x-data (hx/json {}) :class "shrink-0"}
|
||||
[:script
|
||||
(hiccup/raw
|
||||
"localStorage.setItem(\"last-client-id\", \"" (:db/id client) "\")" "\n"
|
||||
@@ -93,9 +93,10 @@
|
||||
:else
|
||||
client-selection) ")")]
|
||||
[:div
|
||||
[:button#company-dropdown-button {:class "text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:outline-none focus:ring-blue-300 font-medium rounded-lg text-sm px-4 py-2.5 text-center inline-flex items-center dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800"
|
||||
[:button#company-dropdown-button {:class "text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:outline-none focus:ring-blue-300 font-medium rounded-lg text-sm px-4 py-2.5 text-center inline-flex items-center whitespace-nowrap dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800"
|
||||
"x-tooltip.on.click" "{content: ()=>$refs.tooltip.innerHTML, theme: 'light', onMount(i) { htmx.process(i.popper); }, allowHTML: true, interactive:true}"
|
||||
:type "button"}
|
||||
[:span {:class "truncate max-w-[10rem] sm:max-w-[14rem]"}
|
||||
(cond
|
||||
(= :mine client-selection)
|
||||
"My Companies"
|
||||
@@ -107,8 +108,8 @@
|
||||
(:client/name client)
|
||||
|
||||
:else
|
||||
(str (count clients) " Companies"))
|
||||
[:div.w-4.h-4.ml-2
|
||||
(str (count clients) " Companies"))]
|
||||
[:div.w-4.h-4.ml-2.shrink-0
|
||||
svg/drop-down]]
|
||||
[:template#company-dropdown-list {:x-ref "tooltip"}
|
||||
[:div {:class "w-[300px]"
|
||||
|
||||
@@ -14,5 +14,5 @@
|
||||
[:section (merge params {:class (hh/add-class " py-3 sm:py-5" (:class params))})
|
||||
[:div {:class (:max-w params "max-w-screen-2xl")}
|
||||
(into
|
||||
[:div {:class "relative overflow-scroll shadow-md dark:bg-gray-800 sm:rounded-lg border-2 border-gray-200 dark:border-gray-900 bg-white"}]
|
||||
[:div {:class "relative overflow-auto shadow-md dark:bg-gray-800 sm:rounded-lg border-2 border-gray-200 dark:border-gray-900 bg-white"}]
|
||||
children)]])
|
||||
|
||||
@@ -45,10 +45,10 @@
|
||||
[:label {:for "checkbox-all", :class "sr-only"} "checkbox"]]])
|
||||
|
||||
(defn data-grid-
|
||||
[{:keys [headers thead-params id] :as params} & rest]
|
||||
[{:keys [headers thead-params id footer-tbody] :as params} & rest]
|
||||
[:div.shrink.overflow-y-scroll
|
||||
[:table (merge {:class "w-full text-sm text-left text-gray-500 dark:text-gray-400 shrink"}
|
||||
(dissoc params :headers :thead-params))
|
||||
(dissoc params :headers :thead-params :footer-tbody))
|
||||
[:thead (update thead-params :class #(-> "text-xs text-gray-800 uppercase bg-gray-50 dark:bg-gray-700 dark:text-gray-400 group-[.raw]:sticky group-[.raw]:z-10 group-[.raw]:top-0"
|
||||
(hh/add-class (or % ""))))
|
||||
(into
|
||||
@@ -56,7 +56,11 @@
|
||||
headers)]
|
||||
(into
|
||||
[:tbody {}]
|
||||
rest)]])
|
||||
rest)
|
||||
;; Optional second <tbody> (valid HTML) so callers can keep a stable,
|
||||
;; separately-swappable region in the same table -- e.g. totals rows that
|
||||
;; update without touching the input-bearing rows above them.
|
||||
footer-tbody]])
|
||||
|
||||
;; needed for tailwind
|
||||
;; lg:table-cell md:table-cell
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
[clj-time.core :as t]
|
||||
[clj-time.periodic :as per]))
|
||||
|
||||
(defn date-range-field [{:keys [value id apply-button?]}]
|
||||
(defn date-range-field [{:keys [value id]}]
|
||||
[:div {:id id}
|
||||
(com/field {:label "Date Range"}
|
||||
[:div.space-y-4
|
||||
@@ -17,7 +17,7 @@
|
||||
(com/button-group-button {:size :small :value "week" :hx-trigger "click"} "Week")
|
||||
(com/button-group-button {:size :small :value "month" :hx-trigger "click"} "Month")
|
||||
(com/button-group-button {:size :small :value "year" :hx-trigger "click"} "Year"))]
|
||||
[:div.flex.space-x-1.items-baseline.w-full.justify-start
|
||||
[:div.flex.space-x-1.items-baseline.w-full.justify-start {"@change.stop" ""}
|
||||
(com/date-input {:name "start-date"
|
||||
:value (some-> (:start value)
|
||||
(atime/unparse-local atime/normal-date))
|
||||
@@ -31,9 +31,8 @@
|
||||
:placeholder "Date"
|
||||
:size :small
|
||||
:class "shrink date-filter-input"})
|
||||
(when apply-button?
|
||||
(but/button- {:color :secondary
|
||||
:size :small
|
||||
:type "button"
|
||||
"x-on:click" "$dispatch('datesApplied')"}
|
||||
"Apply"))]])])
|
||||
"Apply")]])])
|
||||
|
||||
@@ -51,7 +51,7 @@
|
||||
{:x-init "$el.indeterminate = true"}))]))
|
||||
|
||||
(defn typeahead- [params]
|
||||
[:div.relative {:x-data (hx/json {:baseUrl (str (:url params))
|
||||
[:div.relative (cond-> {:x-data (hx/json {:baseUrl (str (:url params))
|
||||
:value {:value ((:value-fn params identity) (:value params)) :label ((:content-fn params identity) (:value params))}
|
||||
:tippy nil
|
||||
:search ""
|
||||
@@ -61,13 +61,18 @@
|
||||
[])})
|
||||
:x-modelable "value.value"
|
||||
:x-model (:x-model params)}
|
||||
;; Key the component by its current value so alpine-morph re-initialises
|
||||
;; it (rather than preserving stale Alpine x-data) whenever the *server*
|
||||
;; changes the value -- e.g. the default account a vendor selection
|
||||
;; populates. alpine-morph keys off the `key` attribute, not `id`.
|
||||
(:id params) (assoc :key (str (:id params) "--" ((:value-fn params identity) (:value params)))))
|
||||
(if (:disabled params)
|
||||
[:span {:x-text "value.label"}]
|
||||
[:a {:class (-> (hh/add-class (or (:class params) "") default-input-classes)
|
||||
(hh/add-class "cursor-pointer"))
|
||||
"x-tooltip.on.click" "{content: ()=>$refs.dropdown.innerHTML, placement: 'bottom', onMount(i) {htmx.process(i.popper); }, popperOptions: {strategy: 'fixed', modifiers: [{name: 'flip', options: {fallbackPlacements: ['top']}}]}, theme: 'dropdown', allowHTML: true, interactive:true}"
|
||||
"@keydown.down.prevent.stop" "tippy.show();"
|
||||
"@keydown.backspace" "tippy.hide(); value = {value: '', label: '' }"
|
||||
"x-tooltip.on.click" "{content: ()=>($refs.dropdown?.innerHTML ?? ''), placement: 'bottom', onMount(i) {htmx.process(i.popper); }, popperOptions: {strategy: 'fixed', modifiers: [{name: 'flip', options: {fallbackPlacements: ['top']}}]}, theme: 'dropdown', allowHTML: true, interactive:true}"
|
||||
"@keydown.down.prevent.stop" "tippy?.show();"
|
||||
"@keydown.backspace" "tippy?.hide(); value = {value: '', label: '' }"
|
||||
:tabindex 0
|
||||
:x-init (str "$nextTick(() => tippy = $el.__x_tippy); " (:x-init params))
|
||||
:x-ref "input"}
|
||||
@@ -94,7 +99,7 @@
|
||||
|
||||
[:template {:x-ref "dropdown"}
|
||||
[:ul.dropdown-contents {:class "bg-gray-100 dark:bg-gray-600 ring-1"
|
||||
"@keydown.escape" "tippy.hide(); value = {value: '', label: '' }; "
|
||||
"@keydown.escape" "$refs.input?.__x_tippy?.hide(); value = {value: '', label: '' }; "
|
||||
:x-destroy "if ($refs.input) {$refs.input.focus();}"}
|
||||
[:input {:type "text"
|
||||
:autofocus true
|
||||
@@ -107,8 +112,8 @@
|
||||
"@change.stop" ""
|
||||
"@keydown.down.prevent" "active ++; active = active >= elements.length - 1 ? elements.length - 1 : active"
|
||||
"@keydown.up.prevent" "active --; active = active < 0 ? 0 : active"
|
||||
"@keydown.enter.prevent.stop" "tippy.hide(); value = elements.length > 0 ? $data.elements[active >= 0 ? active : 0] : {'value': '', label: ''}; $refs.input.focus()"
|
||||
"x-init" "$el.focus(); $watch('search', s => { if($el.value.length > 2) {fetch(addQueryParam(baseUrl, 'q', s)).then(data=>data.json()).then(data => {elements = data; active=-1; tippy.popperInstance.update()}) }})"}]
|
||||
"@keydown.enter.prevent.stop" "$refs.input?.__x_tippy?.hide(); value = elements.length > 0 ? $data.elements[active >= 0 ? active : 0] : {'value': '', label: ''}; $refs.input?.focus()"
|
||||
"x-init" "$el.focus(); $watch('search', s => { if($el.value.length > 2) {fetch(addQueryParam(baseUrl, 'q', s)).then(data=>data.json()).then(data => {elements = data; active=-1; $refs.input?.__x_tippy?.popperInstance?.update()}) }})"}]
|
||||
[:div.dropdown-options {:class "rounded-b-lg overflow-hidden"}
|
||||
[:template {:x-for "(element, index) in elements"}
|
||||
[:li [:a {:class "px-4 py-2 flex gap-2 items-center outline-0 focus:bg-neutral-100 hover:bg-neutral-100 whitespace-nowrap [&.active]:bg-primary-500 [&.active]:dark:bg-primary-700 text-gray-800 dark:text-gray-100"
|
||||
@@ -117,7 +122,7 @@
|
||||
|
||||
"@mouseover" "active = index"
|
||||
"@mouseout" "active = -1"
|
||||
"@click.prevent" "value = element; tippy.hide(); setTimeout(() => $refs.input.focus(), 10)"
|
||||
"@click.prevent" "value = element; $refs.input?.__x_tippy?.hide(); setTimeout(() => $refs.input?.focus(), 10)"
|
||||
"x-html" "element.label"}]]]
|
||||
[:template {:x-if "elements.length == 0"}
|
||||
[:li {:class "px-4 py-2 flex gap-2 items-center outline-0 focus:bg-neutral-100 hover:bg-neutral-100 whitespace-nowrap [&.active]:bg-primary-500 text-gray-800 dark:text-gray-100 text-xs "}
|
||||
@@ -126,7 +131,7 @@
|
||||
(defn multi-typeahead-dropdown- [params]
|
||||
[:template {:x-ref "dropdown"}
|
||||
[:ul.dropdown-contents {:class "bg-gray-100 dark:bg-gray-600 rounded-lg shadow-2xl w-max z-50 ring-1 p-4"
|
||||
"@keydown.escape.prevent" "tippy.hide();"
|
||||
"@keydown.escape.prevent" "$refs.input?.__x_tippy?.hide();"
|
||||
:x-destroy "if ($refs.input) {$refs.input.focus();}"}
|
||||
[:div {:class (-> "relative"
|
||||
#_(hh/replace-wildcard ["rounded" "border"] "border-bottom bg-gray-100 rounded-t-lg w-full"))}
|
||||
@@ -240,9 +245,9 @@
|
||||
[:span {:x-text "value.label"}]
|
||||
[:a {:class (-> (hh/add-class (or (:class params) "") default-input-classes)
|
||||
(hh/add-class "cursor-pointer"))
|
||||
"x-tooltip.on.click.prevent" "{content: ()=>$refs.dropdown.innerHTML, placement: 'bottom', onMount(i) {htmx.process(i.popper); }, popperOptions: {strategy: 'fixed', modifiers: [{name: 'flip', options: {fallbackPlacements: ['top']}}]}, theme: 'dropdown', allowHTML: true, interactive:true}"
|
||||
"@keydown.down.prevent.stop" "tippy.show();"
|
||||
"@keydown.backspace" "tippy.hide(); value=new Set( []);"
|
||||
"x-tooltip.on.click.prevent" "{content: ()=>($refs.dropdown?.innerHTML ?? ''), placement: 'bottom', onMount(i) {htmx.process(i.popper); }, popperOptions: {strategy: 'fixed', modifiers: [{name: 'flip', options: {fallbackPlacements: ['top']}}]}, theme: 'dropdown', allowHTML: true, interactive:true}"
|
||||
"@keydown.down.prevent.stop" "$refs.input?.__x_tippy?.show();"
|
||||
"@keydown.backspace" "$refs.input?.__x_tippy?.hide(); value=new Set( []);"
|
||||
:tabindex 0
|
||||
:x-init (str "$nextTick(() => tippy = $el.__x_tippy); " (:x-init params))
|
||||
:x-ref "input"}
|
||||
@@ -325,7 +330,7 @@
|
||||
(-> params
|
||||
(update :class (fnil hh/add-class "") default-input-classes)
|
||||
(assoc :x-model "value")
|
||||
(assoc "x-tooltip.on.focus" "{content: ()=>$refs.tooltip.innerHTML, theme: 'light', onMount(i) { htmx.process(i.popper); }, allowHTML: true, interactive:true}")
|
||||
(assoc "x-tooltip.on.focus" "{content: ()=>($refs.tooltip?.innerHTML ?? ''), theme: 'light', onMount(i) { htmx.process(i.popper); }, allowHTML: true, interactive:true}")
|
||||
|
||||
(assoc :x-init "$nextTick(() => tippy = $el.__x_tippy); ")
|
||||
(assoc :type "text")
|
||||
@@ -333,7 +338,7 @@
|
||||
(assoc "autocomplete" "off")
|
||||
(assoc "@change" "value = $event.target.value;")
|
||||
|
||||
(assoc "@keydown.escape" "tippy.hide(); ")
|
||||
(assoc "@keydown.escape" "$el?.__x_tippy?.hide(); ")
|
||||
#_(assoc "hx-on" (hiccup/raw "changeDate: htmx.trigger(this, \"change\") "))
|
||||
(update :class #(str % (use-size size) " w-full"))
|
||||
(dissoc :size))]
|
||||
|
||||
@@ -1,401 +0,0 @@
|
||||
(ns auto-ap.ssr.components.multi-modal
|
||||
(:require [auto-ap.cursor :as cursor]
|
||||
[auto-ap.ssr-routes :as ssr-routes]
|
||||
[auto-ap.ssr.components :as com]
|
||||
[auto-ap.ssr.components.timeline :as timeline]
|
||||
[auto-ap.ssr.form-cursor :as fc]
|
||||
[auto-ap.ssr.hx :as hx]
|
||||
[auto-ap.ssr.nested-form-params :refer [wrap-nested-form-params]]
|
||||
[auto-ap.ssr.svg :as svg]
|
||||
[auto-ap.ssr.utils
|
||||
:refer [assert-schema html-response main-transformer
|
||||
modal-response wrap-form-4xx-2 wrap-schema-enforce]]
|
||||
[bidi.bidi :as bidi]
|
||||
[hiccup.util :as hu]
|
||||
[malli.core :as mc]
|
||||
[malli.core :as m]))
|
||||
|
||||
(def default-form-props {:hx-ext "response-targets"
|
||||
:hx-swap "outerHTML"
|
||||
:hx-target-400 "#form-errors .error-content"
|
||||
:hx-trigger "submit"
|
||||
:hx-target "this"})
|
||||
|
||||
(defprotocol ModalWizardStep
|
||||
(step-key [this])
|
||||
(edit-path [this request])
|
||||
(render-step [this request])
|
||||
(step-schema [this])
|
||||
(step-name [this]))
|
||||
|
||||
(defprotocol Initializable
|
||||
(init-step-params [this multi-form-state request]))
|
||||
|
||||
(defprotocol CustomNext
|
||||
(custom-next-handler [this request]))
|
||||
|
||||
(defprotocol Discardable
|
||||
(can-discard? [this step-params])
|
||||
(discard-changes [this request]))
|
||||
|
||||
(defprotocol LinearModalWizard
|
||||
(hydrate-from-request [this request])
|
||||
(get-current-step [this])
|
||||
(navigate [this step-key])
|
||||
|
||||
(form-schema [this])
|
||||
(steps [this])
|
||||
(get-step [this step-key])
|
||||
(render-wizard [this request])
|
||||
(submit [this request]))
|
||||
|
||||
(defrecord MultiStepFormState [snapshot edit-path step-params])
|
||||
(defn select-state [multi-form-state edit-path default]
|
||||
(->MultiStepFormState (:snapshot multi-form-state)
|
||||
edit-path
|
||||
(or (get-in (:snapshot multi-form-state) edit-path)
|
||||
default)))
|
||||
|
||||
(defn merge-multi-form-state [{:keys [snapshot edit-path step-params] :as multi-form-state}]
|
||||
(let [cursor (cursor/cursor (or snapshot {}))
|
||||
;; this hack makes sure that, in the event of a missing vector entry, will make sure to add it first
|
||||
edit-cursor (cond-> cursor
|
||||
(seq edit-path) (cursor/ensure-path! edit-path {})
|
||||
(seq edit-path) (get-in edit-path {}))
|
||||
|
||||
_ (cursor/transact! edit-cursor (fn [spot]
|
||||
(merge spot step-params)))]
|
||||
(assoc multi-form-state
|
||||
:snapshot @cursor
|
||||
:edit-path []
|
||||
:step-params @cursor)))
|
||||
|
||||
(defn get-mfs-field [mfs k]
|
||||
(or (get (:step-params mfs) k)
|
||||
(get-in (:snapshot mfs) (conj (or (:edit-path mfs) [])
|
||||
k))))
|
||||
|
||||
(def step-key-schema (mc/schema [:orn {:decode/arbitrary clojure.edn/read-string
|
||||
:encode/arbitrary pr-str}
|
||||
[:sub-step [:cat :keyword [:or :int :string]]]
|
||||
[:step :keyword]]))
|
||||
|
||||
(def encode-step-key
|
||||
(m/-instrument {:schema [:=> [:cat step-key-schema] :any]}
|
||||
(fn encode-step-key [sk]
|
||||
(mc/encode step-key-schema sk main-transformer))))
|
||||
|
||||
(defn render-timeline [linear-wizard current-step validation-route]
|
||||
(let [step-names (map #(step-name (get-step linear-wizard %)) (steps linear-wizard))
|
||||
active-index (.indexOf step-names (step-name current-step))]
|
||||
(timeline/vertical-timeline
|
||||
{}
|
||||
(for [[n i] (map vector (steps linear-wizard) (range))]
|
||||
(timeline/vertical-timeline-step (cond-> {}
|
||||
(= i active-index) (assoc :active? true)
|
||||
(< i active-index) (assoc :visited? true)
|
||||
(= i (dec (count step-names))) (assoc :last? true))
|
||||
[:a.cursor-pointer.whitespace-nowrap {:x-data (hx/json {:timelineIndex i})
|
||||
:hx-put (hu/url (bidi/path-for ssr-routes/only-routes validation-route)
|
||||
{:from (encode-step-key (step-key current-step))
|
||||
:to (encode-step-key (step-key (get-step linear-wizard n)))})}
|
||||
(step-name (get-step linear-wizard n))])))))
|
||||
(defn back-button [linear-wizard step validation-route]
|
||||
[:a.cursor-pointer.whitespace-nowrap.font-medium.text-blue-600 {:hx-put (hu/url (bidi/path-for ssr-routes/only-routes validation-route)
|
||||
{:from (encode-step-key (step-key step))
|
||||
:to (encode-step-key (->> (partition-all 2 1 (steps linear-wizard))
|
||||
(filter (fn [[from to]]
|
||||
(= to (step-key step))))
|
||||
ffirst))})
|
||||
:class "dark:text-blue-500"}
|
||||
"Back"])
|
||||
|
||||
(defn default-next-button [linear-wizard step validation-route & {:keys [next-button-content]}]
|
||||
(let [steps (steps linear-wizard)
|
||||
last? (= (step-key step) (last steps))
|
||||
next-step (when-not last? (->> steps
|
||||
(drop-while #(not= (step-key step)
|
||||
%))
|
||||
(drop 1)
|
||||
first
|
||||
(get-step linear-wizard)))]
|
||||
(com/validated-save-button (cond-> {:errors (seq fc/*form-errors*)
|
||||
;;:x-data (hx/json {})
|
||||
:x-ref "next"
|
||||
:class "w-48"}
|
||||
(not last?) (assoc :hx-put (hu/url (bidi/path-for ssr-routes/only-routes validation-route)
|
||||
{:from (encode-step-key (step-key step))
|
||||
:to (encode-step-key (step-key next-step))})))
|
||||
|
||||
(or next-button-content
|
||||
(if next-step
|
||||
(step-name next-step)
|
||||
"Save"))
|
||||
(when-not last?
|
||||
[:div.w-5.h-5 svg/arrow-right]))))
|
||||
|
||||
(defn default-step-body [params & children]
|
||||
[:div.space-y-1 {}
|
||||
children])
|
||||
|
||||
(defn default-step-footer [linear-wizard step & {:keys [validation-route
|
||||
discard-button
|
||||
next-button
|
||||
next-button-content]}]
|
||||
[:div.flex.justify-end
|
||||
[:div.flex.items-baseline.gap-x-4
|
||||
(let [step-errors (:step-params fc/*form-errors*)]
|
||||
(com/form-errors {:errors (or (:errors step-errors)
|
||||
(when (sequential? step-errors) step-errors))}))
|
||||
(when (not= (first (steps linear-wizard))
|
||||
(step-key step))
|
||||
(when validation-route
|
||||
(back-button linear-wizard step validation-route)))
|
||||
(when (and (satisfies? Discardable step) (can-discard? step @fc/*current*))
|
||||
discard-button)
|
||||
(cond next-button
|
||||
next-button
|
||||
|
||||
validation-route
|
||||
(default-next-button linear-wizard step validation-route
|
||||
:next-button-content next-button-content)
|
||||
|
||||
:else
|
||||
[:div "No action possible."])]])
|
||||
|
||||
(defn default-render-step [linear-wizard step & {:keys [head body footer validation-route discard-route width-height-class side-panel]}]
|
||||
(let [is-last? (= (step-key step) (last (steps linear-wizard)))]
|
||||
(com/modal-card-advanced
|
||||
{"@keydown.enter.prevent.stop" "if ($refs.next ) {$refs.next.click()}"
|
||||
:class (str
|
||||
(or width-height-class " md:w-[750px] md:h-[600px] ")
|
||||
" w-full h-full
|
||||
group-[.forward]/transition:htmx-swapping:opacity-0
|
||||
group-[.forward]/transition:htmx-swapping:-translate-x-1/4
|
||||
group-[.forward]/transition:htmx-swapping:scale-75
|
||||
group-[.forward]/transition:htmx-swapping:ease-in
|
||||
group-[.forward]/transition:htmx-added:opacity-0
|
||||
group-[.forward]/transition:htmx-added:scale-75
|
||||
group-[.forward]/transition:htmx-added:translate-x-1/4
|
||||
group-[.forward]/transition:htmx-added:ease-out
|
||||
|
||||
group-[.backward]/transition:htmx-swapping:opacity-0
|
||||
group-[.backward]/transition:htmx-swapping:translate-x-1/4
|
||||
group-[.backward]/transition:htmx-swapping:scale-75
|
||||
group-[.backward]/transition:htmx-swapping:ease-in
|
||||
group-[.backward]/transition:htmx-added:opacity-0
|
||||
group-[.backward]/transition:htmx-added:scale-75
|
||||
group-[.backward]/transition:htmx-added:-translate-x-1/4
|
||||
group-[.backward]/transition:htmx-added:ease-out
|
||||
opacity-100 translate-x-0 scale-100"
|
||||
(when is-last? "last-modal-step")
|
||||
" transition duration-150
|
||||
")
|
||||
#_#_":class" (hiccup/raw "{
|
||||
\"htmx-added:opacity-0 opacity-100\": $data.transitionType=='forward',
|
||||
\"htmx-swapping:translate-x-2/3 htmx-swapping:opacity-0 htmx-swapping:scale-0 htmx-added:-translate-x-2/3 htmx-added:opacity-0 htmx-added:scale-0 scale-100 translate-x-0 opacity-100\": $data.transitionType=='backward'
|
||||
}
|
||||
")
|
||||
"x-data" ""}
|
||||
(com/modal-header {}
|
||||
head)
|
||||
#_(com/modal-header-attachment {})
|
||||
[:div.flex.shrink.overflow-auto.grow
|
||||
(when side-panel
|
||||
[:div.grow-0.w-64.bg-gray-50.border-r.hidden.md:block.overflow-y-auto
|
||||
{:class "max-h-full"}
|
||||
side-panel])
|
||||
(when (:render-timeline? linear-wizard)
|
||||
[:div.grow-0.pr-6.pt-2.bg-gray-100.self-stretch.hidden.md:block #_{:style "margin-left:-20px"}
|
||||
(render-timeline linear-wizard step validation-route)])
|
||||
(com/modal-body {}
|
||||
body)]
|
||||
|
||||
(com/modal-footer {}
|
||||
footer))))
|
||||
|
||||
(defn wrap-ensure-step [handler]
|
||||
(->
|
||||
(fn [{:keys [wizard multi-form-state] :as request}]
|
||||
(assert-schema (step-schema (get-current-step wizard)) (:step-params multi-form-state))
|
||||
(handler request))
|
||||
(wrap-form-4xx-2 (fn [{:keys [wizard] :as request}] ;; THIS MAY BE BETTER TO JUST MAKE THE LINEAR WIZARD POPULATE FROM THE REQUEST
|
||||
(html-response
|
||||
(render-wizard wizard request)
|
||||
:headers {"x-transition-type" "none"
|
||||
"HX-reswap" "outerHTML"})))))
|
||||
|
||||
(defn get-transition-type [wizard from-step-key to-step-key]
|
||||
(let [to-step-index (.indexOf (steps wizard) to-step-key)
|
||||
|
||||
from-step-index (.indexOf (steps wizard)
|
||||
from-step-key)]
|
||||
(cond (= -1 to-step-index)
|
||||
nil
|
||||
(= -1 from-step-index)
|
||||
nil
|
||||
(= from-step-index to-step-index)
|
||||
nil
|
||||
(> from-step-index to-step-index)
|
||||
"backward"
|
||||
:else
|
||||
"forward")))
|
||||
|
||||
(defn navigate-handler [{{:keys [wizard] :as request} :request to-step :to-step oob :oob}]
|
||||
(let [current-step (get-current-step wizard)
|
||||
wizard (navigate wizard to-step)
|
||||
new-step (get-current-step wizard)
|
||||
transition-type (get-transition-type wizard (step-key current-step) to-step)]
|
||||
(html-response
|
||||
(render-wizard wizard
|
||||
(-> request
|
||||
(assoc :multi-form-state (-> (:multi-form-state request)
|
||||
(merge-multi-form-state)
|
||||
(select-state (edit-path new-step request) {})
|
||||
(#(cond-> %
|
||||
(satisfies? Initializable new-step)
|
||||
(assoc :step-params
|
||||
(init-step-params new-step % request))))))))
|
||||
:headers {"HX-reswap" (when transition-type "outerHTML swap:0.16s")
|
||||
"x-transition-type" (or transition-type "none")}
|
||||
:oob (or oob []))))
|
||||
|
||||
(def next-handler
|
||||
|
||||
(-> (fn [{:keys [wizard] :as request}]
|
||||
(let [current-step (get-current-step wizard)]
|
||||
(if (satisfies? CustomNext current-step)
|
||||
(custom-next-handler current-step request)
|
||||
(navigate-handler {:request request
|
||||
:to-step (:to (:query-params request))}))))
|
||||
(wrap-ensure-step)
|
||||
(wrap-schema-enforce :query-schema
|
||||
[:map
|
||||
[:to {:optional true} [:maybe step-key-schema]]])))
|
||||
|
||||
(def discard-handler
|
||||
(->
|
||||
(fn [{:keys [wizard multi-form-state] :as request}]
|
||||
(let [current-step (get-current-step wizard)
|
||||
to-step (:to (:query-params request))
|
||||
wizard (navigate wizard to-step)
|
||||
transition-type (get-transition-type wizard (step-key current-step) to-step)]
|
||||
(html-response
|
||||
(render-wizard wizard
|
||||
(-> request
|
||||
(assoc :multi-form-state (discard-changes current-step multi-form-state))))
|
||||
:headers {"HX-reswap" (when transition-type "outerHTML swap:0.16s")
|
||||
"x-transition-type" (or transition-type "none")})))
|
||||
(wrap-schema-enforce :query-schema
|
||||
[:map
|
||||
[:to step-key-schema]])))
|
||||
|
||||
(def submit-handler
|
||||
(-> (fn [{:keys [wizard multi-form-state] :as request}]
|
||||
(submit wizard (-> request
|
||||
(assoc :multi-form-state (merge-multi-form-state multi-form-state)))))
|
||||
(wrap-ensure-step)))
|
||||
|
||||
(defn default-render-wizard [linear-wizard {:keys [multi-form-state form-errors snapshot current-step] :as request} & {:keys [form-params render-timeline?]
|
||||
:or {render-timeline? true}}]
|
||||
(let [current-step (get-current-step (assoc linear-wizard :render-timeline? render-timeline?))
|
||||
edit-path (edit-path current-step request)]
|
||||
[:form#wizard-form form-params
|
||||
(fc/start-form multi-form-state (when form-errors {:step-params form-errors})
|
||||
(list
|
||||
(fc/with-field :snapshot
|
||||
(com/hidden {:name (fc/field-name)
|
||||
:value (pr-str (fc/field-value))}))
|
||||
(fc/with-field :edit-path
|
||||
(com/hidden {:name (fc/field-name)
|
||||
:value (pr-str (or edit-path []))}))
|
||||
(com/hidden {:name "current-step"
|
||||
:value (pr-str (step-key current-step))})
|
||||
|
||||
(fc/with-field :step-params
|
||||
(com/modal
|
||||
{:id "wizardmodal"}
|
||||
|
||||
(render-step current-step request)))))]))
|
||||
|
||||
(defn wrap-wizard [handler linear-wizard]
|
||||
(fn [request]
|
||||
(let [current-step-key (if-let [current-step (get (:form-params request) "current-step")]
|
||||
(mc/decode step-key-schema current-step main-transformer)
|
||||
(first (steps linear-wizard)))
|
||||
current-step (get-step linear-wizard current-step-key)
|
||||
multi-form-state (-> (:multi-form-state request)
|
||||
(update :snapshot (fn [snapshot]
|
||||
(mc/decode (form-schema linear-wizard)
|
||||
snapshot
|
||||
main-transformer)))
|
||||
(update :step-params (fn [step-params]
|
||||
(or
|
||||
(mc/decode (step-schema current-step)
|
||||
step-params
|
||||
main-transformer)
|
||||
{} ;; Todo add a defaultable
|
||||
))))
|
||||
request (-> request
|
||||
(assoc :multi-form-state multi-form-state))
|
||||
linear-wizard (navigate linear-wizard current-step-key)]
|
||||
(handler
|
||||
(assoc request :wizard (hydrate-from-request linear-wizard request))))))
|
||||
|
||||
(defn open-wizard-handler [{:keys [wizard current-step query-params] :as request}]
|
||||
(cond->
|
||||
(modal-response
|
||||
[:div#transitioner.flex-1 {:x-data (hx/json {"transitionType" "none"})
|
||||
:x-ref "transitioner"
|
||||
:class ""
|
||||
"@htmx:after-request" "if(event.detail.xhr.getResponseHeader('x-transition-type')) {
|
||||
$refs.transitioner.classList.remove('forward')
|
||||
$refs.transitioner.classList.remove('backward');
|
||||
$refs.transitioner.classList.add('group/transition')
|
||||
$refs.transitioner.classList.add(event.detail.xhr.getResponseHeader('x-transition-type'));
|
||||
} else {
|
||||
|
||||
$refs.transitioner.classList.remove('group/transition')
|
||||
}
|
||||
"}
|
||||
(render-wizard wizard request)])
|
||||
(get query-params :replace-modal) (assoc-in [:headers "hx-trigger"] "modalswap")))
|
||||
|
||||
(defn wrap-init-multi-form-state [handler get-multi-form-state]
|
||||
(->
|
||||
(fn init-multi-form [request]
|
||||
(handler (assoc request :multi-form-state (get-multi-form-state request))))
|
||||
(wrap-nested-form-params)))
|
||||
|
||||
(defn wrap-decode-multi-form-state [handler]
|
||||
(wrap-init-multi-form-state
|
||||
handler
|
||||
(fn parse-multi-form-state [request]
|
||||
(map->MultiStepFormState (mc/decode [:map
|
||||
[:snapshot {:optional true
|
||||
:decode/arbitrary
|
||||
#(clojure.edn/read-string {:readers clj-time.coerce/data-readers
|
||||
:eof nil}
|
||||
%)}
|
||||
[:maybe :any]]
|
||||
[:edit-path {:optional true :decode/arbitrary (fn [z]
|
||||
(clojure.edn/read-string z))} [:maybe [:sequential {:min 0} any?]]]
|
||||
[:step-params {:optional true}
|
||||
[:maybe
|
||||
:any]]]
|
||||
(:form-params request)
|
||||
main-transformer)))))
|
||||
|
||||
#_(comment
|
||||
(def f {"snapshot"
|
||||
"{:invoices [{:invoice_id 17592297837035, :amount 23.0, :invoice {:db/id 17592297837035, :invoice/vendor {:db/id 17592186045722, :vendor/name \"Sysco\"}, :invoice/client {:db/id 17592232555238}, :invoice/outstanding-balance 23.0, :invoice/invoice-number \"702,34\"}} {:invoice_id 17592297837049, :amount 23.0, :invoice {:db/id 17592297837049, :invoice/vendor {:db/id 17592186045722, :vendor/name \"Sysco\"}, :invoice/client {:db/id 17592232555238}, :invoice/outstanding-balance 23.0, :invoice/invoice-number \"80[234234\"}}], :client 17592232555238}",
|
||||
"edit-path" "[]",
|
||||
"current-step" ":payment-details",
|
||||
"mode" "advanced",
|
||||
"step-params"
|
||||
{"invoices"
|
||||
{"0" {"invoice_id" "17592297837035", "amount" "1"},
|
||||
"1" {"invoice_id" "17592297837049", "amount" "23.00"}}}})
|
||||
(mc/decode [:map [:step-params {:optional true} [:maybe :any]]]
|
||||
f
|
||||
main-transformer))
|
||||
@@ -7,32 +7,42 @@
|
||||
[auto-ap.ssr.components.buttons :refer [icon-button-]]
|
||||
[auto-ap.ssr.components.user-dropdown :as user-dropdown]
|
||||
[auto-ap.ssr.svg :as svg]
|
||||
[bidi.bidi :as bidi]))
|
||||
[bidi.bidi :as bidi]
|
||||
[clojure.string :as str]))
|
||||
|
||||
(defn navbar- [{:keys [client-selection client identity clients dd-env]}]
|
||||
[:nav {:class "fixed z-30 w-full bg-white border-b border-gray-200 dark:bg-gray-800 dark:border-gray-700"}
|
||||
[:div {:class "px-3 py-3 lg:px-5 lg:pl-3"}
|
||||
[:div {:class "flex items-center justify-between"}
|
||||
[:div {:class "flex items-center justify-start"}
|
||||
[:button {:aria-controls "left-nav", :id "left-nav-toggle" :type "button", :class "inline-flex items-center p-2 mt-2 ml-2 mr-2 text-sm text-gray-500 rounded-lg hover:bg-gray-100 focus:outline-none focus:ring-2 focus:ring-gray-200 dark:text-gray-400 dark:hover:bg-gray-700 dark:focus:ring-gray-600"
|
||||
[:div {:class "px-3 lg:px-5 lg:pl-3 h-16 flex items-center"}
|
||||
[:div {:class "flex items-center w-full"}
|
||||
;; Left cluster: sidebar toggle, logo, environment badge. Holds its size.
|
||||
[:div {:class "flex items-center shrink-0"}
|
||||
[:button {:aria-controls "left-nav", :id "left-nav-toggle" :type "button", :class "inline-flex items-center p-2 mr-2 text-sm text-gray-500 rounded-lg hover:bg-gray-100 focus:outline-none focus:ring-2 focus:ring-gray-200 dark:text-gray-400 dark:hover:bg-gray-700 dark:focus:ring-gray-600"
|
||||
"@click" "leftNavShow = !leftNavShow"}
|
||||
[:span {:class "sr-only"} "Open sidebar"]
|
||||
[:svg {:class "w-6 h-6", :aria-hidden "true", :fill "currentColor", :viewbox "0 0 20 20", :xmlns "http://www.w3.org/2000/svg"}
|
||||
[:path {:clip-rule "evenodd", :fill-rule "evenodd", :d "M2 4.75A.75.75 0 012.75 4h14.5a.75.75 0 010 1.5H2.75A.75.75 0 012 4.75zm0 10.5a.75.75 0 01.75-.75h7.5a.75.75 0 010 1.5h-7.5a.75.75 0 01-.75-.75zM2 10a.75.75 0 01.75-.75h14.5a.75.75 0 010 1.5H2.75A.75.75 0 012 10z"}]]]
|
||||
[:a {:href (bidi/path-for ssr-routes/only-routes ::dashboard/page) :class "flex ml-2 hidden md:mr-24 sm:inline"}
|
||||
[:img {:src "/img/logo-big2.png", :class "h-10", :alt "Integreat logo"}]]
|
||||
(when-not (= "prod" dd-env) [:div.rounded-full.bg-yellow-200.text-lg.text-yellow-800.px-4.hidden.md:block.mr-8 "environment: " dd-env])]
|
||||
|
||||
[:div {:class "flex items-center gap-4"}
|
||||
[:a {:href (bidi/path-for ssr-routes/only-routes ::dashboard/page) :class "hidden sm:flex items-center shrink-0"}
|
||||
[:img {:src "/img/logo-big2.png", :class "h-10 max-w-none", :alt "Integreat logo"}]]
|
||||
(when (and dd-env (not= "prod" dd-env))
|
||||
(let [env-label (str "environment: " dd-env)]
|
||||
[:div {:class "shrink-0"}
|
||||
;; Full pill when there is room (md-lg and xl+); compact letter badge in the tight lg range.
|
||||
[:span {:class "hidden md:inline-flex lg:hidden xl:inline-flex items-center ml-4 h-8 px-3 rounded-full bg-yellow-200 text-yellow-800 text-sm font-medium whitespace-nowrap"}
|
||||
env-label]
|
||||
[:span {:class "hidden lg:flex xl:hidden items-center justify-center ml-3 w-8 h-8 rounded-full bg-yellow-200 text-yellow-800 text-sm font-bold"
|
||||
:title env-label}
|
||||
(str/upper-case (subs dd-env 0 1))]]))]
|
||||
|
||||
;; Search: fills the middle, grows to a comfortable max and shrinks first when space is tight.
|
||||
(when (is-admin? identity)
|
||||
[:button.mt-1.lg:w-96.relative.hidden.lg:block {:class "bg-gray-50 hover:bg-gray-200 dark:hover:bg-gray-700 border border-gray-300 text-gray-900 sm:text-sm rounded-lg focus:ring-primary-500 focus:border-primary-500 w-full pl-10 py-4 pr-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-primary-500 dark:focus:border-primary-500 gap-4 "
|
||||
[:button.relative.hidden.lg:block.flex-1.min-w-0.max-w-md.mx-4 {:class "bg-gray-50 hover:bg-gray-200 dark:hover:bg-gray-700 border border-gray-300 text-gray-900 sm:text-sm rounded-lg focus:ring-primary-500 focus:border-primary-500 pl-10 h-10 pr-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-primary-500 dark:focus:border-primary-500"
|
||||
:hx-get (bidi/path-for ssr-routes/only-routes :search)}
|
||||
[:div {:class "absolute inset-y-0 left-0 flex items-center pl-3 pointer-events-none text-gray-500"}
|
||||
[:div.w-4.h-4 svg/search]
|
||||
[:span.ml-2 "Search"]]])
|
||||
[:div {:class "hidden mr-3 -mb-1 sm:block"}
|
||||
[:span]]
|
||||
|
||||
;; Right cluster: mobile search, company selector, user menu. Stays pinned right and keeps its size.
|
||||
[:div {:class "flex items-center gap-2 sm:gap-4 ml-auto shrink-0"}
|
||||
(icon-button-
|
||||
{:id "toggleSidebarMobileSearch", :type "button", :class "p-2 text-gray-500 rounded-lg lg:hidden hover:text-gray-900 hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-white"
|
||||
:hx-get (bidi/path-for ssr-routes/only-routes
|
||||
|
||||
310
src/clj/auto_ap/ssr/components/selmer.clj
Normal file
310
src/clj/auto_ap/ssr/components/selmer.clj
Normal file
@@ -0,0 +1,310 @@
|
||||
(ns auto-ap.ssr.components.selmer
|
||||
"Selmer-rendered versions of the shared SSR components used by the Transaction Edit
|
||||
modal (see .claude/skills/ssr-form-migration). Each wrapper assembles a plain-data
|
||||
context and renders its own template under resources/templates/components/ via the
|
||||
interop bridge -- the element structure lives entirely in the .html templates; the
|
||||
only Clojure is data assembly. Dynamic HTMX/Alpine attributes (which vary per call
|
||||
site) are serialized to an attribute string by `attrs->str` and injected with
|
||||
{{ attrs|safe }}, so the templates stay free of per-attribute {% if %} ladders.
|
||||
|
||||
Reuses class logic from auto-ap.ssr.components.inputs so output matches the Hiccup
|
||||
components byte-for-byte modulo Tailwind class ordering (verify by string-match +
|
||||
e2e, never byte-parity -- see selmer-conventions.md)."
|
||||
(:require
|
||||
[auto-ap.ssr.components.buttons :as btn]
|
||||
[auto-ap.ssr.components.inputs :as inputs]
|
||||
[auto-ap.ssr.hiccup-helper :as hh]
|
||||
[auto-ap.ssr.hx :as hx]
|
||||
[auto-ap.ssr.selmer :as sel]
|
||||
[clojure.string :as str]
|
||||
[hiccup.util :as hu]))
|
||||
|
||||
(defn- attr-name [k]
|
||||
(if (keyword? k) (subs (str k) 1) (str k)))
|
||||
|
||||
(defn attrs->str
|
||||
"Serialize an attribute map to an HTML attribute string with a leading space, so it
|
||||
concatenates after fixed template attributes: <input type=\"text\"{{ attrs|safe }}>.
|
||||
nil/false values are dropped, true renders a bare boolean attribute, everything else
|
||||
renders name=\"escaped-value\". Mirrors how hiccup2 emits attributes."
|
||||
[m]
|
||||
(->> m
|
||||
(keep (fn [[k v]]
|
||||
(cond
|
||||
(nil? v) nil
|
||||
(false? v) nil
|
||||
(true? v) (str " " (attr-name k))
|
||||
:else (str " " (attr-name k) "=\""
|
||||
(hu/escape-html (if (keyword? v) (name v) (str v)))
|
||||
"\""))))
|
||||
(apply str)))
|
||||
|
||||
(defn render
|
||||
"Render a component partial and trim outer whitespace (so {# comments #} and the
|
||||
file's trailing newline don't leak into the embedding tree). Returns a raw-wrapped
|
||||
string ready to drop into Hiccup or another Selmer context value."
|
||||
[template ctx]
|
||||
(sel/raw (str/trim (sel/render template ctx))))
|
||||
|
||||
(defn- body->html
|
||||
"Render child content (Hiccup vectors and/or raw Selmer fragments) to an HTML string."
|
||||
[body]
|
||||
(->> (if (sequential? body) body [body])
|
||||
(remove nil?)
|
||||
(map sel/hiccup->html)
|
||||
(apply str)))
|
||||
|
||||
;; --- leaf inputs -----------------------------------------------------------------
|
||||
|
||||
(defn hidden [{:keys [name value] :as params}]
|
||||
(render "templates/components/hidden.html"
|
||||
{:attrs (attrs->str (merge {:name name}
|
||||
(when (some? value) {:value value})
|
||||
(dissoc params :name :value)))}))
|
||||
|
||||
(defn text-input [{:keys [size] :as params}]
|
||||
(let [attrs (-> params
|
||||
(dissoc :error? :size)
|
||||
(assoc :type "text" :autocomplete "off")
|
||||
(update :class #(-> ""
|
||||
(hh/add-class inputs/default-input-classes)
|
||||
(hh/add-class %)))
|
||||
(update :class #(str % (inputs/use-size size))))]
|
||||
(render "templates/components/text-input.html" {:attrs (attrs->str attrs)})))
|
||||
|
||||
(defn money-input [{:keys [size] :as params}]
|
||||
(let [attrs (-> params
|
||||
(dissoc :size)
|
||||
(update :class (fnil hh/add-class "") inputs/default-input-classes)
|
||||
(update :class hh/add-class "appearance-none text-right")
|
||||
(update :class #(str % (inputs/use-size size)))
|
||||
(assoc :type "number" :step "0.01"))]
|
||||
(render "templates/components/money-input.html" {:attrs (attrs->str attrs)})))
|
||||
|
||||
(defn select
|
||||
"Generic <select> rendered from a Selmer partial (the location-select.html shape,
|
||||
generalized). options = [[value label] ...]; `value` (string or keyword) marks the
|
||||
selected option. Class defaults to the standard input classes, like com/select. Extra
|
||||
attrs (hx-*, x-*) ride through onto the element."
|
||||
[{:keys [name value options class] :as params}]
|
||||
(let [classes (-> ""
|
||||
(hh/add-class inputs/default-input-classes)
|
||||
(hh/add-class (or class "")))
|
||||
sel (cond-> value (keyword? value) clojure.core/name)
|
||||
attrs (dissoc params :name :value :options :class)]
|
||||
(render "templates/components/select.html"
|
||||
{:name name
|
||||
:classes classes
|
||||
:attrs (attrs->str attrs)
|
||||
:options (for [[v label] options]
|
||||
{:value v :label label :selected (= (str v) (str sel))})})))
|
||||
|
||||
;; --- field wrapper ---------------------------------------------------------------
|
||||
|
||||
(defn validated-field
|
||||
"Selmer port of com/validated-field (the errors- variant of field-): label + body +
|
||||
an always-present error <p>. Pass-through attrs land on the wrapping div (the account
|
||||
row's location cell hangs its swap wiring here)."
|
||||
[{:keys [label errors] :as params} & body]
|
||||
(let [classes (cond-> (or (:class params) "")
|
||||
(sequential? errors) (hh/add-class "has-error")
|
||||
:always (hh/add-class "group"))
|
||||
attrs (dissoc params :label :errors :error-source :error-key :class)
|
||||
errors-str (when (sequential? errors)
|
||||
(str/join ", " (filter string? errors)))]
|
||||
(render "templates/components/validated-field.html"
|
||||
{:label label
|
||||
:classes classes
|
||||
:attrs (attrs->str attrs)
|
||||
:body (body->html body)
|
||||
:errors_str (or errors-str "")})))
|
||||
|
||||
;; --- buttons / badges / links ----------------------------------------------------
|
||||
|
||||
(defn badge [{:keys [color] :as params} & children]
|
||||
(let [classes (-> (hh/add-class
|
||||
"absolute inline-flex items-center z-10 justify-center w-6 h-6 text-xs font-black text-white \n border-3 border-white rounded-full -top-2 -right-2 dark:border-gray-900"
|
||||
(:class params))
|
||||
(hh/add-class (or (some-> color (#(str "bg-" % "-300"))) "bg-red-300")))]
|
||||
(render "templates/components/badge.html"
|
||||
{:classes classes
|
||||
:attrs (attrs->str (dissoc params :class))
|
||||
:body (body->html children)})))
|
||||
|
||||
(defn link [{:keys [class] :as params} & children]
|
||||
(render "templates/components/link.html"
|
||||
{:classes (str class " font-medium text-blue-600 dark:text-blue-500 hover:underline cursor-pointer")
|
||||
:attrs (attrs->str (dissoc params :class))
|
||||
:body (body->html children)}))
|
||||
|
||||
(defn button [{:keys [color disabled minimal-loading?] :as params} & children]
|
||||
(let [classes (cond-> (:class params)
|
||||
true (str " focus:ring-4 font-bold rounded-lg text-xs p-3 text-center mr-2 inline-flex items-center relative justify-center disabled:opacity-50"
|
||||
(btn/bg-colors color disabled))
|
||||
(not disabled) (str " hover:scale-105 transition duration-100")
|
||||
disabled (str " cursor-not-allowed")
|
||||
(some? color) (str " text-white ")
|
||||
(nil? color) (str " bg-white dark:bg-gray-600 border-gray-300 dark:border-gray-700 text-gray-500 hover:text-gray-800 dark:text-gray-400 dark:hover:text-gray-100 font-medium border border-gray-300 dark:border-gray-700"))]
|
||||
(render "templates/components/button.html"
|
||||
{:classes classes
|
||||
:attrs (attrs->str (dissoc params :class))
|
||||
:loading_label (not minimal-loading?)
|
||||
:body (body->html children)})))
|
||||
|
||||
(defn a-button [{:keys [color disabled] :as params} & children]
|
||||
(let [indicator? (:indicator? params true)
|
||||
classes (cond-> (:class params)
|
||||
true (str " focus:ring-4 font-bold rounded-lg text-xs p-3 text-center mr-2 inline-flex items-center hover:scale-105 transition duration-100 justify-center")
|
||||
(= :secondary color) (str " text-white bg-blue-500 hover:bg-blue-600 focus:ring-blue-300 dark:bg-blue-600 dark:hover:bg-blue-700")
|
||||
(= :primary color) (str " text-white bg-green-500 hover:bg-green-600 focus:ring-green-300 dark:bg-green-600 dark:hover:bg-green-700 ")
|
||||
(= :secondary-light color) (str " text-blue-800 bg-white-200 border-gray-100 border hover:bg-blue-100 focus:ring-blue-100 dark:bg-blue-400 dark:hover:bg-blue-800 ")
|
||||
(some? color) (str " text-white " (btn/bg-colors color disabled))
|
||||
(nil? color) (str " bg-white dark:bg-gray-600 border-gray-300 dark:border-gray-700 text-gray-500 hover:text-gray-800 dark:text-gray-400 dark:hover:text-gray-100 font-medium border border-gray-300 dark:border-gray-700"))]
|
||||
(render "templates/components/a-button.html"
|
||||
{:classes classes
|
||||
:attrs (attrs->str (-> (dissoc params :class)
|
||||
(assoc :tabindex 0 :href (:href params "#"))))
|
||||
:indicator indicator?
|
||||
:body (body->html children)})))
|
||||
|
||||
(defn a-icon-button [{:keys [class] :as params} & children]
|
||||
(let [class-str (or class "")
|
||||
has-padding? (re-find #"\bp[xy]?-\d+(\.\d+)?\b" class-str)
|
||||
classes (str class-str (if has-padding? "" " p-3")
|
||||
" inline-flex items-center justify-center bg-white dark:bg-gray-600 items-center text-sm font-medium border border-gray-300 dark:border-gray-700 text-center text-gray-500 hover:text-gray-800 rounded-lg dark:text-gray-400 dark:hover:text-gray-100")]
|
||||
(render "templates/components/a-icon-button.html"
|
||||
{:classes classes
|
||||
:attrs (attrs->str (-> (dissoc params :class)
|
||||
(assoc :href (or (:href params) ""))))
|
||||
:body (body->html children)})))
|
||||
|
||||
(defn button-group-button [{:keys [size] :or {size :normal} :as params} & children]
|
||||
(let [classes (cond-> (:class params)
|
||||
true (str " font-medium text-gray-900 bg-white border border-gray-200 hover:bg-gray-100 hover:text-primary-700 focus:z-10 focus:ring-2 focus:ring-green-700 focus:text-green-700 dark:bg-gray-700 dark:border-gray-600 dark:text-white dark:hover:text-white dark:hover:bg-gray-600 dark:focus:ring-green-500 dark:focus:text-white disabled:opacity-50")
|
||||
(= :small size) (str " text-xs px-3 py-2")
|
||||
(= :normal size) (str " text-sm px-4 py-2"))]
|
||||
(render "templates/components/button-group-button.html"
|
||||
{:classes classes
|
||||
:attrs (attrs->str (-> (dissoc params :class :size)
|
||||
(assoc :type (or (:type params) "button"))))
|
||||
:body (body->html children)})))
|
||||
|
||||
(defn button-group [{:keys [name]} & children]
|
||||
(render "templates/components/button-group.html"
|
||||
{:name name
|
||||
:body (body->html children)}))
|
||||
|
||||
;; --- radio-card ------------------------------------------------------------------
|
||||
|
||||
(defn radio-card
|
||||
"Selmer port of com/radio-card. NB: the Hiccup radio-card- has a dangling [:h3 title]
|
||||
the let discards, so only the <ul> renders -- reproduced here. Only the documented
|
||||
htmx keys ride onto each <input> (the same select-keys filter; :hx-vals / :hx-select
|
||||
are intentionally dropped, matching existing behavior)."
|
||||
[{:keys [options name title size orientation width] :or {size :medium width "w-48"}
|
||||
selected-value :value :as params}]
|
||||
(let [htmx-attrs (select-keys params [:hx-post :hx-target :hx-swap :hx-include :hx-trigger])
|
||||
sel (cond-> selected-value (keyword? selected-value) clojure.core/name)
|
||||
ul-class (cond-> " text-sm font-medium text-gray-900 bg-white border border-gray-200 rounded-lg dark:bg-gray-700 dark:border-gray-600 dark:text-white"
|
||||
(= orientation :horizontal) (-> (hh/add-class "flex gap-2 flex-wrap")
|
||||
(hh/remove-wildcard ["w-" "rounded-lg" "border" "bg-"]))
|
||||
:always (str " " width " "))
|
||||
li-class (cond-> "w-full border-b border-gray-200 rounded-t-lg dark:border-gray-600"
|
||||
(= orientation :horizontal) (-> (hh/remove-wildcard ["w-full" "rounded-"])
|
||||
(hh/add-class "w-auto shrink-0 block rounded-lg border border-gray-200 dark:border-gray-600 px-3")))
|
||||
div-class (cond-> "flex items-center"
|
||||
(not= orientation :horizontal) (hh/add-class "pl-3"))
|
||||
input-class (cond-> "w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-700 dark:focus:ring-offset-gray-700 focus:ring-2 dark:bg-gray-600 dark:border-gray-500"
|
||||
(= size :small) (str " text-xs")
|
||||
(= size :medium) (str " text-sm"))
|
||||
label-class (cond-> "w-full ml-2 font-medium text-gray-900 dark:text-gray-300"
|
||||
(= size :small) (str " text-xs py-2")
|
||||
(= size :medium) (str " text-sm py-3")
|
||||
(= orientation :horizontal) (hh/remove-class "w-full"))]
|
||||
(render "templates/components/radio-card.html"
|
||||
{:ul_class ul-class :li_class li-class :div_class div-class
|
||||
:input_class input-class :label_class label-class
|
||||
:name name
|
||||
:input_attrs (attrs->str htmx-attrs)
|
||||
:options (for [{:keys [value content]} options]
|
||||
{:id (str "list-" name "-" value)
|
||||
:value value
|
||||
:checked (= sel value)
|
||||
:content (body->html content)})})))
|
||||
|
||||
;; --- data grid -------------------------------------------------------------------
|
||||
|
||||
(defn data-grid-header [params & body]
|
||||
(render "templates/components/data-grid-header.html"
|
||||
{:klass (:class params)
|
||||
:click (format "$dispatch('sorted', {key: '%s'})" (:sort-key params))
|
||||
:sort_key (:sort-key params)
|
||||
:attrs (attrs->str (cond-> {} (:style params) (assoc :style (:style params))))
|
||||
:body (body->html body)}))
|
||||
|
||||
(defn data-grid-row [params & body]
|
||||
(render "templates/components/data-grid-row.html"
|
||||
{:classes (str (:class params) " border-b dark:border-gray-600 group hover:bg-gray-100 dark:hover:bg-gray-700")
|
||||
:attrs (attrs->str (dissoc params :class))
|
||||
:body (body->html body)}))
|
||||
|
||||
(defn data-grid-cell [params & body]
|
||||
(render "templates/components/data-grid-cell.html"
|
||||
{:klass (:class params)
|
||||
:attrs (attrs->str (dissoc params :class))
|
||||
:body (body->html body)}))
|
||||
|
||||
(defn data-grid
|
||||
"Table shell: outer scroll div > table > thead(headers) > tbody(rows) + optional
|
||||
footer-tbody. `headers`, `rows`, and `footer-tbody` are pre-rendered fragments."
|
||||
[{:keys [headers footer-tbody] :as params} & rows]
|
||||
(render "templates/components/data-grid.html"
|
||||
{:table_class "w-full text-sm text-left text-gray-500 dark:text-gray-400 shrink"
|
||||
:table_attrs (attrs->str (dissoc params :headers :thead-params :footer-tbody))
|
||||
:thead_class "text-xs text-gray-800 uppercase bg-gray-50 dark:bg-gray-700 dark:text-gray-400 group-[.raw]:sticky group-[.raw]:z-10 group-[.raw]:top-0"
|
||||
:headers (body->html headers)
|
||||
:rows (body->html rows)
|
||||
:footer_tbody (when footer-tbody (body->html footer-tbody))}))
|
||||
|
||||
;; --- modal + typeahead -----------------------------------------------------------
|
||||
|
||||
(defn modal [{:as params} & children]
|
||||
(render "templates/components/modal.html"
|
||||
{:classes (hh/add-class "" (:class params ""))
|
||||
:attrs (attrs->str (dissoc params :handle-unexpected-error? :class))
|
||||
:body (body->html children)}))
|
||||
|
||||
(defn typeahead
|
||||
"Selmer port of com/typeahead. Resolves the initial {value,label} server-side via
|
||||
value-fn/content-fn (DB lookups), builds the Alpine x-data, and serializes the
|
||||
hidden posting-input attributes. Preserves every tippy?. null-guard."
|
||||
[{:keys [value value-fn content-fn x-model x-init id class placeholder disabled url]
|
||||
:as params}]
|
||||
(let [vf (or value-fn identity)
|
||||
cf (or content-fn identity)
|
||||
vval (vf value)
|
||||
vlabel (cf value)
|
||||
x-data (hx/json {:baseUrl (str url)
|
||||
:value {:value vval :label vlabel}
|
||||
:tippy nil :search "" :active -1
|
||||
:elements (if vval [{:value vval :label vlabel}] [])})
|
||||
a-class (-> (hh/add-class (or class "") inputs/default-input-classes)
|
||||
(hh/add-class "cursor-pointer"))
|
||||
a-xinit (str "$nextTick(() => tippy = $el.__x_tippy); " x-init)
|
||||
search-class (-> (or class "")
|
||||
(hh/add-class inputs/default-input-classes)
|
||||
(hh/replace-wildcard ["rounded" "border"] "border-bottom bg-gray-100 rounded-t-lg w-full"))
|
||||
hidden-attrs (-> params
|
||||
(dissoc :class :value-fn :content-fn :placeholder :x-model)
|
||||
(assoc "x-ref" "hidden" :type "hidden" ":value" "value.value"
|
||||
:x-init "$watch('value', v => { $el.value = (v && v.value != null) ? v.value : ''; $nextTick(() => $dispatch('change')); }); "))]
|
||||
(render "templates/components/typeahead.html"
|
||||
{:x_data x-data
|
||||
:x_model x-model
|
||||
:key (when id (str id "--" vval))
|
||||
:disabled disabled
|
||||
:a_class a-class
|
||||
:a_xinit a-xinit
|
||||
:search_class search-class
|
||||
:placeholder placeholder
|
||||
:hidden_attrs (attrs->str hidden-attrs)})))
|
||||
183
src/clj/auto_ap/ssr/components/wizard2.clj
Normal file
183
src/clj/auto_ap/ssr/components/wizard2.clj
Normal file
@@ -0,0 +1,183 @@
|
||||
(ns auto-ap.ssr.components.wizard2
|
||||
"Data-driven multi-step wizard engine — no protocols, no defrecords, no middleware
|
||||
stacking. A wizard is a plain *config map*; per-step validated state lives in the Ring
|
||||
session (see `wizard-state`), combined only at the end. Two routes per wizard: open
|
||||
(GET) and submit (POST). Only an opaque `wizard-id` + the `current-step` ride in the
|
||||
form — never the accumulated data, so there is no EDN snapshot to serialize or merge.
|
||||
|
||||
## Config shape
|
||||
|
||||
{:name :vendor ; instance label (for debugging)
|
||||
:form-id \"wizard-form\" ; the <form> id (swap target)
|
||||
:submit-route \"/admin/vendor/wizard\" ; resolved URL the form posts to
|
||||
:form-attrs {...} ; extra <form> attrs (hx-ext, etc.)
|
||||
:init-fn (fn [request] {:context {...} :init-data {step-key data}})
|
||||
:done-fn (fn [all-data request] ring-response) ; called when a step's :next = :done
|
||||
:steps [{:key :info
|
||||
:decode (fn [request] -> data-map) ; parse this step's posted fields
|
||||
:validate (fn [data request] -> errors|nil) ; optional
|
||||
:render (fn [ctx] -> hiccup) ; renders the step body
|
||||
:next (fn [data] -> next-step-key | :done)}
|
||||
...]}
|
||||
|
||||
The engine wraps each step's body in the wizard <form> (adding the wizard-id /
|
||||
current-step hiddens + hx-post). A step's `:render` receives a ctx map:
|
||||
|
||||
{:wizard-id :current-step :context :all-data :step-data :errors :request :config}
|
||||
|
||||
`:step-data` is the previously-stored data for this step (so editing repopulates), or
|
||||
the just-posted data on a validation re-render. `:all-data` is every step combined so
|
||||
far (handy for a read-only preview/summary step). Navigation buttons post a `direction`
|
||||
field: \"next\" (validate+advance), \"back\" (no validate), \"submit\" (== next, for the
|
||||
last step). See `reference/form-vs-wizard.md`."
|
||||
(:require
|
||||
[auto-ap.ssr.components :as com]
|
||||
[auto-ap.ssr.components.wizard-state :as ws]
|
||||
[auto-ap.ssr.utils :refer [html-response]]))
|
||||
|
||||
(defn- step-by-key [config k]
|
||||
(first (filter #(= (:key %) k) (:steps config))))
|
||||
|
||||
(defn- prev-step
|
||||
"The step key before `k` in the linear step order (or `k` itself if first)."
|
||||
[config k]
|
||||
(let [keys (mapv :key (:steps config))
|
||||
i (.indexOf keys k)]
|
||||
(if (pos? i) (nth keys (dec i)) k)))
|
||||
|
||||
(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).
|
||||
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"
|
||||
"@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\"}) ; intermediate step: Next
|
||||
(nav-footer {:back? true :save? true}) ; last step: Back + Save
|
||||
(nav-footer {:back? true :save? true :save-label \"Pay\"}) ; last step, custom label"
|
||||
[{:keys [next back? save? save-label]}]
|
||||
[: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"} (or save-label "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."
|
||||
[{:keys [config wizard-id session request step-errors step-posted]}]
|
||||
(let [cur (ws/current-step session wizard-id)
|
||||
step (step-by-key config cur)
|
||||
ctx {:wizard-id wizard-id
|
||||
:current-step cur
|
||||
:context (ws/context session wizard-id)
|
||||
:all-data (ws/get-all session wizard-id)
|
||||
:step-data (or step-posted (ws/step-data session wizard-id cur))
|
||||
:errors step-errors
|
||||
:request request
|
||||
:config config}]
|
||||
(wizard-form config wizard-id cur ((:render step) ctx))))
|
||||
|
||||
(defn- render-response
|
||||
"html-response of the rendered wizard, with the (possibly updated) session threaded into
|
||||
the Ring response so the session store persists the new wizard state."
|
||||
[config wizard-id session request & [extra]]
|
||||
(-> (html-response (render-wizard (merge {:config config
|
||||
:wizard-id wizard-id
|
||||
:session session
|
||||
:request request}
|
||||
extra)))
|
||||
(assoc :session session)))
|
||||
|
||||
(defn open-wizard
|
||||
"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})
|
||||
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
|
||||
tab). Re-open a fresh wizard rather than 500-ing."
|
||||
[config request]
|
||||
(open-wizard config request))
|
||||
|
||||
(defn handle-step-submit
|
||||
"Submit handler. Reads wizard-id / current-step / direction from the posted form, then:
|
||||
- \"back\": move to the previous step (no validation).
|
||||
- else: decode + validate the current step; on error re-render it with messages;
|
||||
otherwise store the step's data and either advance to `:next` or, when
|
||||
`:next` is :done, call `done-fn` with all combined data and `forget` the
|
||||
instance."
|
||||
[config request]
|
||||
(let [fp (:form-params request)
|
||||
wizard-id (get fp "wizard-id")
|
||||
current-step (keyword (get fp "current-step"))
|
||||
direction (or (get fp "direction") "next")
|
||||
session (:session request)]
|
||||
(cond
|
||||
(not (ws/exists? session wizard-id))
|
||||
(expired-response config request)
|
||||
|
||||
(= direction "back")
|
||||
(render-response config wizard-id
|
||||
(ws/set-step session wizard-id (prev-step config current-step))
|
||||
request)
|
||||
|
||||
:else
|
||||
(let [step (step-by-key config current-step)
|
||||
;; 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
|
||||
{:step-errors errors :step-posted posted})
|
||||
(let [session' (ws/put-step session wizard-id current-step posted)
|
||||
nxt ((:next step) posted)]
|
||||
(if (= nxt :done)
|
||||
(-> ((:done-fn config) (ws/get-all session' wizard-id) request)
|
||||
(assoc :session (ws/forget session' wizard-id)))
|
||||
(render-response config wizard-id
|
||||
(ws/set-step session' wizard-id nxt)
|
||||
request))))))))
|
||||
66
src/clj/auto_ap/ssr/components/wizard_state.clj
Normal file
66
src/clj/auto_ap/ssr/components/wizard_state.clj
Normal file
@@ -0,0 +1,66 @@
|
||||
(ns auto-ap.ssr.components.wizard-state
|
||||
"Session-backed storage for multi-step wizards — the Django formtools `SessionStorage`
|
||||
model. Each wizard instance's per-step *validated* data lives in the Ring session under
|
||||
|
||||
[:wizards <wizard-id> :step-data <step-key>]
|
||||
|
||||
and the steps are combined only at the very end via `get-all`. This replaces the
|
||||
EDN-snapshot-in-a-hidden-field round-trip (and its custom readers + merge logic): no
|
||||
data about other steps ever rides through the page — only an opaque `wizard-id` token.
|
||||
|
||||
State is namespaced by `wizard-id` (a random uuid), so concurrent wizards and browser
|
||||
tabs don't collide, and a completed/abandoned wizard is discarded with `forget`.
|
||||
|
||||
These functions are pure: each takes a session map and returns a new session map (or a
|
||||
read). The engine (`wizard2`) threads the returned session into the Ring response; the
|
||||
session store (cookie / durable) then persists it. Nothing here touches global state."
|
||||
(:require
|
||||
[clojure.string :as str]))
|
||||
|
||||
(defn create-wizard!
|
||||
"Seed a fresh wizard instance. Returns `[wizard-id session']`. `opts`:
|
||||
:first-step the step key the wizard opens on (required)
|
||||
:context read-only data the steps need but don't edit (e.g. an entity id) — kept
|
||||
out of :step-data so it never gets merged into the combined result
|
||||
:init-data optional pre-filled per-step data ({step-key data}), e.g. when editing an
|
||||
existing entity so step 1 opens populated.
|
||||
Despite the bang, this only *computes* the next session — it doesn't mutate anything;
|
||||
the caller threads `session'` into its response."
|
||||
[session config-name {:keys [first-step context init-data]}]
|
||||
(let [id (str (java.util.UUID/randomUUID))]
|
||||
[id (assoc-in session [:wizards id]
|
||||
{:config-name config-name
|
||||
:current-step first-step
|
||||
:context (or context {})
|
||||
:step-data (or init-data {})})]))
|
||||
|
||||
(defn instance [session id] (get-in session [:wizards id]))
|
||||
(defn exists? [session id] (boolean (and id (get-in session [:wizards id]))))
|
||||
(defn current-step [session id] (get-in session [:wizards id :current-step]))
|
||||
(defn context [session id] (get-in session [:wizards id :context]))
|
||||
(defn step-data [session id step-key] (get-in session [:wizards id :step-data step-key]))
|
||||
|
||||
(defn put-step
|
||||
"Store (REPLACE, never merge) a step's validated data. Replacing is the whole point —
|
||||
re-submitting a step overwrites that step only; other steps are untouched."
|
||||
[session id step-key data]
|
||||
(assoc-in session [:wizards id :step-data step-key] data))
|
||||
|
||||
(defn set-step
|
||||
"Move the wizard's current step (navigation)."
|
||||
[session id step-key]
|
||||
(assoc-in session [:wizards id :current-step] step-key))
|
||||
|
||||
(defn get-all
|
||||
"Combine every stored step's data into one map (the formtools `get_all_cleaned_data`).
|
||||
Combined only here, at the end — later steps win on key collisions (steps order)."
|
||||
[session id]
|
||||
(->> (get-in session [:wizards id :step-data])
|
||||
vals
|
||||
(apply merge {})))
|
||||
|
||||
(defn forget
|
||||
"Discard the wizard instance (on completion or abandonment) so the session doesn't grow
|
||||
unbounded. Call from the done-fn's response."
|
||||
[session id]
|
||||
(update session :wizards dissoc id))
|
||||
@@ -282,6 +282,7 @@
|
||||
[:div {:x-data (hx/json {:selected [] :all_selected false :type (:entity-name grid-spec)})
|
||||
"x-on:copy" "if (selected.length > 0) {$clipboard(JSON.stringify({'type': type, 'selected': selected}))}"
|
||||
"x-on:client-selected.document" "selected=[]; all_selected=false"
|
||||
"x-on:reset-selection.document" "selected=[]; all_selected=false"
|
||||
"x-bind:hx-vals" "JSON.stringify({selected: $data.selected, 'all-selected': $data.all_selected})"
|
||||
:x-init "$watch('selected', s=> $dispatch('selectedChanged', {selected: s, all_selected: all_selected}) );
|
||||
$watch('all_selected', a=>$dispatch('selectedChanged', {selected: selected, all_selected: a}))"}
|
||||
|
||||
@@ -56,7 +56,7 @@
|
||||
[:div {:id "exact-match-id-tag"}]))
|
||||
|
||||
(defn filters [request]
|
||||
[:form#invoice-filters {"hx-trigger" "change delay:500ms, keyup changed from:.hot-filter delay:1000ms"
|
||||
[:form#invoice-filters {"hx-trigger" "datesApplied, change delay:500ms, keyup changed from:.hot-filter delay:1000ms"
|
||||
"hx-get" (bidi/path-for ssr-routes/only-routes
|
||||
::route/import-table)
|
||||
"hx-target" "#entity-table"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -31,7 +31,7 @@
|
||||
[auto-ap.ssr.ui :refer [base-page]]
|
||||
[auto-ap.ssr.utils
|
||||
:refer [apply-middleware-to-all-handlers clj-date-schema
|
||||
html-response main-transformer money strip
|
||||
html-response modal-response main-transformer money strip
|
||||
wrap-form-4xx-2 wrap-implied-route-param
|
||||
wrap-merge-prior-hx wrap-schema-decode
|
||||
wrap-schema-enforce]]
|
||||
@@ -69,6 +69,40 @@
|
||||
selected)]
|
||||
ids))
|
||||
|
||||
(defn all-ids-not-locked
|
||||
"Filters journal-entry ids to only those whose date is on/after the client's
|
||||
locked-until date (i.e. not in a reconciled/locked period)."
|
||||
[all-ids]
|
||||
(->> all-ids
|
||||
(dc/q '[:find ?t
|
||||
:in $ [?t ...]
|
||||
:where
|
||||
[?t :journal-entry/client ?c]
|
||||
[(get-else $ ?c :client/locked-until #inst "2000-01-01") ?lu]
|
||||
[?t :journal-entry/date ?d]
|
||||
[(>= ?d ?lu)]]
|
||||
(dc/db conn))
|
||||
(map first)))
|
||||
|
||||
(defn bulk-delete [request]
|
||||
(assert-admin (:identity request))
|
||||
(let [params (:form-params request)
|
||||
ids (selected->ids (assoc-in request [:route-params :external?] true) params)
|
||||
all-ids (all-ids-not-locked ids)]
|
||||
(if (> (count all-ids) 1000)
|
||||
(modal-response
|
||||
(com/success-modal {:title "Too many ledger entries"}
|
||||
[:p "You can only delete 1000 ledger entries at a time."]))
|
||||
(do
|
||||
(alog/info ::bulk-delete-ledger :count (count all-ids) :sample (take 3 all-ids))
|
||||
(audit-transact-batch
|
||||
(map (fn [i] [:db/retractEntity i]) all-ids)
|
||||
(:identity request))
|
||||
(modal-response
|
||||
(com/success-modal {:title "Ledger Entries Deleted"}
|
||||
[:p (str "Successfully deleted " (count all-ids) " ledger entries.")])
|
||||
:headers {"hx-trigger" "invalidated, reset-selection"})))))
|
||||
|
||||
(defn delete [{invoice :entity :as request identity :identity}]
|
||||
(exception->notification
|
||||
#(when-not (= :invoice-status/unpaid (:invoice/status invoice))
|
||||
@@ -696,6 +730,8 @@
|
||||
::route/csv (helper/csv-route grid-page)
|
||||
::route/external-import-page external-import-page
|
||||
::route/bank-account-filter bank-account-filter
|
||||
::route/bulk-delete (-> bulk-delete
|
||||
(wrap-schema-enforce :form-schema query-schema))
|
||||
::route/external-import-parse (-> external-import-parse
|
||||
(wrap-schema-enforce :form-schema parse-form-schema)
|
||||
(wrap-form-4xx-2 external-import-parse)
|
||||
|
||||
@@ -11,10 +11,10 @@
|
||||
[auto-ap.routes.transactions :as transaction-routes]
|
||||
[auto-ap.ssr-routes :as ssr-routes]
|
||||
[auto-ap.ssr.components :as com]
|
||||
[auto-ap.ssr.components.date-range :as dr]
|
||||
[auto-ap.ssr.components.link-dropdown :refer [link-dropdown]]
|
||||
[auto-ap.ssr.grid-page-helper :as helper]
|
||||
[auto-ap.ssr.hx :as hx]
|
||||
[auto-ap.ssr.pos.common :refer [date-range-field*]]
|
||||
[auto-ap.ssr.svg :as svg]
|
||||
[auto-ap.ssr.utils
|
||||
:refer [clj-date-schema entity-id html-response ref->enum-schema
|
||||
@@ -31,7 +31,7 @@
|
||||
|
||||
(defn exact-match-id* [request]
|
||||
(if (nat-int? (:exact-match-id (:query-params request)))
|
||||
[:div {:x-data (hx/json {:exact_match (:exact-match-id (:query-params request))}) :id "exact-match-id-tag"}
|
||||
[:div {:x-data (hx/json {:exact_match (:exact-match-id (:query-params request))}) :id "exact-match-id-tag" :class "filter-trigger"}
|
||||
(com/hidden {:name "exact-match-id"
|
||||
"x-model" "exact_match"})
|
||||
(com/pill {:color :primary}
|
||||
@@ -46,7 +46,8 @@
|
||||
[:div {:hx-trigger "clientSelected from:body"
|
||||
:hx-get (bidi.bidi/path-for ssr-routes/only-routes ::route/bank-account-filter)
|
||||
:hx-target "this"
|
||||
:hx-swap "outerHTML"}
|
||||
:hx-swap "outerHTML"
|
||||
:class "filter-trigger"}
|
||||
(when (:client request)
|
||||
(let [bank-account-belongs-to-client? (get (set (map :db/id (:client/bank-accounts (:client request))))
|
||||
(:db/id (:bank-account (:query-params request))))]
|
||||
@@ -67,7 +68,7 @@
|
||||
(html-response (bank-account-filter* request)))
|
||||
|
||||
(defn filters [request]
|
||||
[:form#ledger-filters {"hx-trigger" "change delay:500ms, keyup changed from:.hot-filter delay:1000ms"
|
||||
[:form#ledger-filters {"hx-trigger" "datesApplied, change delay:500ms from:.filter-trigger, keyup changed from:.hot-filter delay:1000ms"
|
||||
"hx-get" (bidi/path-for ssr-routes/only-routes
|
||||
::route/table)
|
||||
"hx-target" "#entity-table"
|
||||
@@ -82,7 +83,8 @@
|
||||
:url (bidi/path-for ssr-routes/only-routes :vendor-search)
|
||||
:value (:vendor (:query-params request))
|
||||
:value-fn :db/id
|
||||
:content-fn :vendor/name}))
|
||||
:content-fn :vendor/name
|
||||
:class "filter-trigger"}))
|
||||
(com/field {:label "Account"}
|
||||
(com/typeahead {:name "account"
|
||||
:id "account"
|
||||
@@ -90,11 +92,15 @@
|
||||
:value (:account (:query-params request))
|
||||
:value-fn :db/id
|
||||
:content-fn #(:account/name (d-accounts/clientize (dc/pull (dc/db conn) d-accounts/default-read (:db/id %))
|
||||
(:db/id (:client request))))}))
|
||||
(:db/id (:client request))))
|
||||
:class "filter-trigger"}))
|
||||
|
||||
(bank-account-filter* request)
|
||||
|
||||
(date-range-field* request)
|
||||
(dr/date-range-field {:value {:start (:start-date (:query-params request))
|
||||
:end (:end-date (:query-params request))}
|
||||
:id "date-range"
|
||||
:apply-button? true})
|
||||
(com/field {:label "Invoice #"}
|
||||
(com/text-input {:name "invoice-number"
|
||||
:id "invoice-number"
|
||||
@@ -144,6 +150,7 @@
|
||||
(com/hidden {:name "only-unbalanced"
|
||||
":value" "onlyUnbalanced ? 'on' : ''"})
|
||||
(com/checkbox {:value (:only-unbalanced (:query-params request))
|
||||
:class "filter-trigger"
|
||||
:x-model "onlyUnbalanced"}
|
||||
"Show unbalanced")]
|
||||
(exact-match-id* request)]])
|
||||
@@ -468,14 +475,33 @@
|
||||
:fetch-page fetch-page
|
||||
:oob-render
|
||||
(fn [request]
|
||||
[(assoc-in (date-range-field* request) [1 :hx-swap-oob] true)
|
||||
[(assoc-in (dr/date-range-field {:value {:start (:start-date (:query-params request))
|
||||
:end (:end-date (:query-params request))}
|
||||
:id "date-range"
|
||||
:apply-button? true}) [1 :hx-swap-oob] true)
|
||||
(assoc-in (exact-match-id* request) [1 :hx-swap-oob] true)])
|
||||
:query-schema query-schema
|
||||
:action-buttons (fn [request]
|
||||
[(when-not (:external? (:route-params request)) (com/button {:color :primary
|
||||
[(when-not (:external? (:route-params request))
|
||||
(com/button {:color :primary
|
||||
:hx-get (bidi/path-for ssr-routes/only-routes
|
||||
::route/new)}
|
||||
"Add journal entry"))])
|
||||
"Add journal entry"))
|
||||
(when (and (:external? (:route-params request))
|
||||
(= "admin" (:user/role (:identity request))))
|
||||
(com/button {:color :red
|
||||
:hx-post (bidi/path-for ssr-routes/only-routes ::route/bulk-delete)
|
||||
;; target the persistent modal shell content slot directly so the
|
||||
;; request never relies on the outerHTML swap inherited from the
|
||||
;; data-grid card (which would replace #modal-holder and break the
|
||||
;; next click). modal-response also retargets here.
|
||||
:hx-target "#modal-content"
|
||||
:hx-swap "innerHTML"
|
||||
"x-bind:hx-vals" "JSON.stringify({selected: $data.selected, 'all-selected': $data.all_selected})"
|
||||
"x-bind:disabled" "selected.length === 0 && !all_selected"
|
||||
"hx-include" "#ledger-filters"
|
||||
:hx-confirm "Are you sure you want to delete these ledger entries?"}
|
||||
"Delete selected"))])
|
||||
:row-buttons (fn [request entity]
|
||||
[(when (and (= :invoice-status/unpaid (:invoice/status entity))
|
||||
(can? (:identity request) {:subject :invoice :activity :delete}))
|
||||
|
||||
@@ -129,7 +129,17 @@
|
||||
(let [{:keys [client warning]} (maybe-trim-clients request client)
|
||||
{:keys [data report]} (get-report (assoc-in request [:form-params :client] client))
|
||||
client-count (count (set (map :client-id (:data data))))
|
||||
table-contents (concat-tables (concat (:summaries report) (:details report)))]
|
||||
table-contents (concat-tables (concat (:summaries report) (:details report)))
|
||||
warning-text (not-empty (str/join "\n " (filter not-empty [warning (:warning report)])))
|
||||
sample-links (when (can? (:identity request)
|
||||
{:subject :history
|
||||
:activity :view})
|
||||
(seq (for [n (:invalid-ids report)]
|
||||
[:div
|
||||
(com/link {:href (str (bidi/path-for ssr-routes/only-routes
|
||||
:admin-history)
|
||||
"/" n)}
|
||||
"Sample")])))]
|
||||
(list
|
||||
[:div.text-2xl.font-bold.text-gray-600 (str "Profit and loss - " (str/join ", " (map :client/name client)))]
|
||||
(table {:widths (into [20] (take (dec (cell-count table-contents))
|
||||
@@ -140,18 +150,8 @@
|
||||
[13 6])))))
|
||||
:investigate-url (bidi.bidi/path-for ssr-routes/only-routes ::route/investigate)
|
||||
:table table-contents
|
||||
:warning [:div
|
||||
(not-empty (str (str/join "\n " (filter not-empty [warning (:warning report)]))))
|
||||
|
||||
(when (can? (:identity request)
|
||||
{:subject :history
|
||||
:activity :view})
|
||||
(for [n (:invalid-ids report)]
|
||||
[:div
|
||||
(com/link {:href (str (bidi/path-for ssr-routes/only-routes
|
||||
:admin-history)
|
||||
"/" n)}
|
||||
"Sample")]))]}))))])
|
||||
:warning (when (or warning-text sample-links)
|
||||
[:div warning-text sample-links])}))))])
|
||||
|
||||
(defn form* [request & children]
|
||||
(let [params (or (:query-params request) {})]
|
||||
@@ -254,10 +254,10 @@
|
||||
(str/replace (->> client-ids (pull-many (dc/db conn) [:client/name]) (map :client/name) (str/join "-")) #"[^\w]" "_"))
|
||||
|
||||
(defn profit-and-loss-args->name [request]
|
||||
(let [date (atime/unparse-local
|
||||
(:date (:query-params request))
|
||||
atime/iso-date)
|
||||
name (->> request :query-params :client (map :db/id) join-names)]
|
||||
(let [{:keys [client periods]} (:form-params request)
|
||||
client (if (= :all client) (:clients request) client)
|
||||
date (some-> periods last :end (atime/unparse-local atime/iso-date))
|
||||
name (->> client (map :db/id) join-names)]
|
||||
(format "Profit-and-loss-%s-for-%s" date name)))
|
||||
|
||||
(defn print-profit-and-loss [request]
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user