Compare commits
56 Commits
3641846f70
...
integreat-
| Author | SHA1 | Date | |
|---|---|---|---|
| 8b43017d6e | |||
| e1a2f7b638 | |||
| f16c52d70b | |||
| ee558a34e9 | |||
| a760d15509 | |||
| 314c47b7a6 | |||
| 67f8906631 | |||
| 333c280732 | |||
| ab7529eca6 | |||
| b44213bffd | |||
| 6c791efb06 | |||
| fc8ce2633e | |||
| da2cc711d4 | |||
| 74f1a49a10 | |||
| 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 |
125
.claude/skills/ssr-form-migration/SKILL.md
Normal file
125
.claude/skills/ssr-form-migration/SKILL.md
Normal file
@@ -0,0 +1,125 @@
|
||||
---
|
||||
name: ssr-form-migration
|
||||
description: Migrate an SSR form or wizard modal to the whole-form HTMX swap doctrine, top-rooted render functions, and the data-driven session-backed wizard engine. 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. Rendering stays in Hiccup (`com/*` components) — the earlier Selmer-templating step was abandoned.
|
||||
---
|
||||
|
||||
# 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 three 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).
|
||||
|
||||
> **Rendering stays in Hiccup.** An earlier iteration of this skill added a fourth
|
||||
> pattern — templating interactive components in Selmer — which was later **abandoned**.
|
||||
> All modals render through the shared Hiccup components (`com/*`); there is no Selmer
|
||||
> layer. Ignore any residual Selmer references in the cookbooks below.
|
||||
|
||||
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. **Render in Hiccup** with the shared `com/*` components. Reuse cookbook bits; add new
|
||||
ones back (heuristic 5). (An earlier version of this step templated interactive
|
||||
components in Selmer; that was abandoned — everything renders through Hiccup.)
|
||||
|
||||
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,208 @@
|
||||
# Component cookbook
|
||||
|
||||
> **Note:** Selmer was abandoned — all rendering is Hiccup (`com/*`). Ignore any
|
||||
> Selmer/template/`sc/*` references below; use the equivalent `com/*` Hiccup component.
|
||||
|
||||
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.
|
||||
357
.claude/skills/ssr-form-migration/reference/scorecard.md
Normal file
357
.claude/skills/ssr-form-migration/reference/scorecard.md
Normal file
@@ -0,0 +1,357 @@
|
||||
# Quality scorecard (the ratchet)
|
||||
|
||||
> **Note:** Selmer was abandoned — rendering is Hiccup (`com/*`). Ignore the Selmer-specific
|
||||
> heuristics/mentions below; the swap-doctrine, render-function, and engine ratchets still apply.
|
||||
|
||||
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**.
|
||||
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.
|
||||
@@ -1,5 +1,12 @@
|
||||
# SSR Form & Wizard Simplification — Migration Plan
|
||||
|
||||
> **⚠️ Selmer reverted (2026-06-29).** Pattern 4 below — migrating rendering from Hiccup
|
||||
> to **Selmer templates** — was **abandoned and reverted**. The swap doctrine, top-rooted
|
||||
> render functions, and the session-backed wizard engine (patterns 1–3) were kept; all
|
||||
> rendering remains in **Hiccup** (`com/*` components). The `auto-ap.ssr.selmer` /
|
||||
> `auto-ap.ssr.components.selmer` namespaces and the `resources/templates/` tree no longer
|
||||
> exist. Treat every Selmer instruction below as historical context only.
|
||||
>
|
||||
> **Status:** Planning / for execution by an agent or engineer.
|
||||
> **Owner:** Bryce
|
||||
> **Type:** Refactor (no user-facing behavior change; parity required).
|
||||
@@ -775,3 +782,13 @@ matches, emails, contact methods). Deliberately last, when the skill is richest.
|
||||
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)*
|
||||
|
||||
---
|
||||
|
||||
## 12. Post-migration QA follow-up (2026-06-27)
|
||||
|
||||
A full agent-browser QA pass over the 9 migrated modals found size/animation/500 regressions.
|
||||
Findings, root causes (incl. the stale-Tailwind-CSS size root cause), and a prioritized fix
|
||||
task list live in:
|
||||
|
||||
- **`docs/plans/2026-06-27-001-fix-ssr-modal-regressions-plan.md`**
|
||||
|
||||
227
docs/plans/2026-06-27-001-fix-ssr-modal-regressions-plan.md
Normal file
227
docs/plans/2026-06-27-001-fix-ssr-modal-regressions-plan.md
Normal file
@@ -0,0 +1,227 @@
|
||||
# SSR Modal Regression Fixes — QA Findings & Resumable Task List
|
||||
|
||||
> **Status:** ALL identified items **FIXED + verified** (2026-06-27) — BUG A, BUG C, BUG D, BUG B,
|
||||
> BUG E/F, the CSS size root cause, and a subtle wizard fade-in (§3). The only thing intentionally
|
||||
> left is the optional richer forward/back *slide* transition, which needs design sign-off (§3).
|
||||
> **Owner:** Bryce
|
||||
> **Branch:** `integreat-execute-refactor`
|
||||
> **Date:** 2026-06-27
|
||||
> **Context:** Follow-up to the SSR rendering-modernization migration
|
||||
> (`2026-06-02-001-refactor-ssr-rendering-modernization-plan.md`, Phases 2–11 complete).
|
||||
> The migration to simplified route rendering + whole-form/wizard swaps introduced
|
||||
> modal **size**, **animation**, and a set of **HTTP 500** regressions. This doc records a
|
||||
> full browser QA pass (agent-browser, 9 migrated modals) with root causes and fixes.
|
||||
|
||||
## 0. How to resume / reproduce
|
||||
|
||||
- App is running on the port in `.http-port` (was `42987`); nREPL in `nrepl-port` (was `35689`).
|
||||
- Browser session: `agent-browser --session integreat ...`. Log in via
|
||||
`http://localhost:<port>/dev-login` → "Continue to dashboard".
|
||||
- **Test at a realistic viewport:** `agent-browser --session integreat set viewport 1440 900`
|
||||
(a short viewport exaggerates modal overflow and gives false positives).
|
||||
- Screenshots from this pass are in `./tmp/` (gitignored): `02..19-*.png`. Raw running notes:
|
||||
`./tmp/findings.md`.
|
||||
- After rebuilding CSS, the browser caches the old `output.css`; bust it by swapping the
|
||||
`<link>` href (`?v=<n>`) or hard-reloading, or you'll keep seeing the old sizes.
|
||||
|
||||
## 1. Test matrix (9 migrated modals)
|
||||
|
||||
| Modal | File | Type | Result |
|
||||
|-------|------|------|--------|
|
||||
| Transaction Edit | `transaction/edit.clj` | form | Opens OK; **BUG C** — whole-form swap 500 |
|
||||
| Transaction Bulk Code | `transaction/bulk_code.clj` | form | ✅ Works |
|
||||
| Sales Summary Edit | `pos/sales_summaries.clj` | form | ✅ Works |
|
||||
| Invoice Bulk Edit | `invoices.clj` | form | ✅ Works (empty-selection 500 = **BUG D**) |
|
||||
| Transaction Rule | `admin/transaction_rules.clj` | wizard | ✅ Works |
|
||||
| Invoice Pay | `invoices.clj` | wizard | ✅ Works (narrow until CSS rebuild — see §2) |
|
||||
| New Invoice | `invoice/new_invoice_wizard.clj` | wizard | **BUG A** — choose-client 500 |
|
||||
| Vendor | `admin/vendors.clj` | wizard | Size broken (§2) + **BUG E** inner overflow |
|
||||
| Client | `admin/clients.clj` | wizard | Size broken (§2) + **BUG F** inner overflow |
|
||||
|
||||
Cross-cutting: **§2** (stale CSS → sizes), **BUG B** (footer EDN leak), **animations** (§3).
|
||||
|
||||
## 2. ★ ROOT CAUSE of broken modal SIZES — stale compiled Tailwind CSS
|
||||
|
||||
`resources/public/output.css` (committed, last built **Jun 24**) is **missing the migration's
|
||||
newer arbitrary size classes**. Tailwind only compiles classes present in source at build time,
|
||||
so any size value added/changed after Jun 24 isn't in the CSS and the element falls back to its
|
||||
`w-full h-full` sibling class → the modal balloons to full width.
|
||||
|
||||
- **Present** (these modals size correctly today): `md:w-[750px]` (New Invoice), `md:w-[950px]`
|
||||
(Transaction Edit / Bulk Code / Sales Summary), `md:h-[600px]`, `md:h-[650px]`, `w-[850px]`
|
||||
(Transaction Rule).
|
||||
- **Missing** (these balloon / mis-size): `md:w-[760px]` (Vendor), `md:w-[820px]` (Client),
|
||||
`md:h-[520px]`, `md:h-[560px]`, `w-[50em]` (Invoice Pay).
|
||||
|
||||
**Verified:** rebuilding to a temp file generates all missing classes; after rebuild + cache-bust,
|
||||
the Vendor card went **1257px → 760×520** and Client → **820×560** (exactly as declared).
|
||||
|
||||
**DEEPER ROOT CAUSE found while fixing (2026-06-27):** `tailwind.config.js` `content` only scanned
|
||||
`./src/**/*.{cljs,clj,cljc}` — it **never scanned `resources/templates/**/*.html`** (the 46 Selmer
|
||||
templates the migration introduced). So a naive rebuild *drops* every template-only class
|
||||
(e.g. `md:w-[950px]` / `md:h-[650px]`, used only in `templates/transaction-edit/edit-modal.html`,
|
||||
which would have re-broken Transaction Edit / Bulk Code / Sales Summary). The durable fix is to add
|
||||
the templates glob to the config, then rebuild.
|
||||
|
||||
**FIX — DONE & verified:**
|
||||
1. Added `"./resources/templates/**/*.html"` to `tailwind.config.js` `content`.
|
||||
2. `npx tailwindcss -i resources/input.css -o resources/public/output.css` (kept unminified to
|
||||
match the committed file; add `--minify` only if the prod pipeline minifies).
|
||||
- Verified: all modal size classes now present (`md:w-[760px]`, `md:w-[820px]`, `md:h-[520px]`,
|
||||
`md:h-[560px]`, `w-[50em]` **and** the template-sourced `md:w-[950px]`/`md:h-[650px]`). Class-diff
|
||||
vs the old CSS shows the only removed classes are orphaned (the deleted `mm/*` modal-stack
|
||||
`forward`/`backward`/`group/transition`/`htmx-*:translate-x-2/3` animation set + the unused
|
||||
`lg:w-[900px]` sales size) — none still referenced in src/ or templates/. Vendor modal confirmed
|
||||
live at **760×520** (was 1257px).
|
||||
- **Still TODO:** confirm the prod/CI build runs this tailwind step (so output.css can't drift
|
||||
again) — ideally wire it into `lein build` / buildspec.
|
||||
|
||||
> Note: during QA I rebuilt `output.css` to verify, then reverted it so the working tree is clean
|
||||
> for review. The verified rebuild is preserved at `tmp/output-rebuilt.css`; backup at
|
||||
> `tmp/output.css.bak`.
|
||||
|
||||
## 3. Modal ANIMATIONS
|
||||
|
||||
The new wizard step cards swap via `hx-target "this" hx-swap "outerHTML"` but their
|
||||
`modal-card-advanced` carries **no transition classes**, so step→step and modal enter/leave do not
|
||||
animate:
|
||||
|
||||
- New Invoice steps (`render-basic-details`, `render-accounts`): card class is only
|
||||
`md:w-[750px] md:h-[600px] w-full h-full` — no `htmx-swapping:`/`htmx-added:` variants.
|
||||
- `next-steps-modal` (new_invoice_wizard.clj ~678): only static `scale-100 translate-x-0
|
||||
opacity-100` — missing the `htmx-swapping:`/`htmx-added:` swap variants.
|
||||
- By contrast, `dialog/success-modal-` and the Invoice Pay card (`last-modal-step transition
|
||||
duration-150`) keep transitions, so the intended pattern still exists to copy from.
|
||||
|
||||
**FIX — DONE (forward/back slide restored) in the shared `wizard2` engine.** The user confirmed the
|
||||
old wizard had a directional **slide forward/back** between steps; the engine migration dropped it.
|
||||
Restored the original mechanism (read out of the deleted `mm/multi_modal.clj`) in one shared place:
|
||||
- `wizard2/step-slide-classes` — the `group-[.forward]/transition:htmx-*` + `group-[.backward]/…`
|
||||
variants, now applied to the swapped wizard `<form>`.
|
||||
- `wizard2/transitioner` — the `#transitioner` wrapper with the `@htmx:after-request` hook that reads
|
||||
the `x-transition-type` response header and toggles `group/transition` + `forward|backward` on
|
||||
itself (so the variants fire on the next swap). All 5 wizard configs' `:open-response` now wrap the
|
||||
form in `wizard2/transitioner` instead of a plain `#transitioner` div.
|
||||
- `wizard2/handle-step-submit` now sets `x-transition-type` (forward on advance, backward on Back,
|
||||
`none` on a same-step validation re-render) + `HX-reswap: outerHTML swap:0.16s` (the swap delay
|
||||
that lets the slide-out play). Direction is computed from the step order (`transition-type`).
|
||||
- The earlier per-card `fade-in` (interim) was removed in favor of this.
|
||||
- CSS rebuilt so the `group-[.forward]/transition:htmx-*` variants (16 fwd + 16 back) are compiled.
|
||||
- Applies to ALL wizards (new-invoice, vendor, client, pay, transaction-rule) since it lives in the
|
||||
engine. REPL-verified: `open-wizard` emits the transitioner, the form carries the slide classes,
|
||||
and submit responses carry `x-transition-type` + the `HX-reswap` swap delay.
|
||||
- **Live-verify caveat:** the long-lived dev server froze its route table at startup
|
||||
(`auto-ap.handler/match->handler-lookup` is a `def` that merged the per-ns `key->handler` maps), so
|
||||
an nREPL reload of leaf namespaces does NOT reach the running router — a server refresh
|
||||
(`stop jetty → tools.namespace refresh → user/start-http`) or a fresh `lein run` is needed to see
|
||||
it live. (Documented hazard; see the QA memory.)
|
||||
|
||||
## 4. Bug list + fixes (prioritized task list)
|
||||
|
||||
### P0 — functional 500s
|
||||
|
||||
**BUG A — New Invoice: choosing a client → 500 from `/invoice/new/due-date`.**
|
||||
- File: `src/clj/auto_ap/ssr/invoice/new_invoice_wizard.clj`, fn `due-date` (and same latent bug in
|
||||
`scheduled-payment-date`).
|
||||
- Exception: `ClassCastException: org.joda.time.DateTime cannot be cast to java.util.Date`.
|
||||
- Cause: `form-vendor-client` decodes `:invoice/date`/`:invoice/due` to **clj-time `DateTime`**
|
||||
(via `clj-date-schema`), but `due-date` then calls `(some-> date coerce/from-date)` —
|
||||
`coerce/from-date` expects a `java.util.Date`. `scheduled-payment-date` has the same
|
||||
`(some-> due coerce/from-date)` but is masked because `due` is nil-guarded by a `when`.
|
||||
- Repro: open New Invoice, pick any client (vendor still empty) → red "unexpected error" banner;
|
||||
network shows PUT `/invoice/new/due-date` = 500. REPL-confirmed the decoded date is already a
|
||||
`DateTime` and `time/plus` works on it directly.
|
||||
- **FIX — DONE & verified live:** dropped the `coerce/from-date` calls — `due-date` now uses the
|
||||
decoded `date`/`due` `DateTime`s directly (removed the `date (some-> date coerce/from-date)`
|
||||
rebind and the `due` coerce); same for `scheduled-payment-date`. Live: selecting a client now
|
||||
fires PUT `/invoice/new/due-date` + `/scheduled-payment-date` → **200** (was 500).
|
||||
|
||||
**BUG C — Transaction Edit: any whole-form swap (mode toggle, vendor change, add/remove row, $/%
|
||||
toggle) → 500 from `/transaction2/edit-form-changed`, whenever the txn has ≥1 autopay-invoice
|
||||
match.** (HIGH severity — breaks the flagship modal's core swap doctrine.)
|
||||
- File: `src/clj/auto_ap/ssr/transaction/edit.clj`.
|
||||
- Exception: `ClassCastException: PersistentVector cannot be cast to Named` at **edit.clj:849**
|
||||
(`links-body*` does `(name (:action step-params))`).
|
||||
- Cause: the link-panels each render a hidden `<input name="action">`. The **unpaid** panel
|
||||
(line 690) and **rule** panel (line 730) add `:form ""` to exclude that hidden from form
|
||||
serialization. The **autopay** panel (**line 661**) is **missing `:form ""`**, so its
|
||||
`action="link-autopay-invoices"` hidden is serialized alongside the main `action` hidden (line
|
||||
862, value "manual"). Ring collapses the duplicate `action` params into a vector → `(name
|
||||
vector)` throws.
|
||||
- Repro: edit a transaction whose "Link to autopay invoices" tab shows a badge count ≥1, click
|
||||
"Switch to advanced mode". REPL-confirmed: single `action` → 200; `action` as a vector → the
|
||||
exact 500. (Txns with 0 autopay matches render `panel-empty*` with no action-hidden and swap
|
||||
fine — hence intermittent.)
|
||||
- **FIX — DONE & verified live:** added `:form ""` to the autopay panel's action-hidden
|
||||
(**edit.clj:661**) to match the unpaid/rule panels. Live: on a txn with an autopay match,
|
||||
"Switch to advanced mode" now POSTs `edit-form-changed` → **200** with a single `action=manual`
|
||||
param (the duplicate `action=link-autopay-invoices` is gone) and the mode toggles correctly.
|
||||
- (Optional hardening, not applied: make `links-body*` coerce a vector action via
|
||||
`(some-> (:action step-params) (as-> a (if (coll? a) (last a) a)) name)`.)
|
||||
|
||||
### P1 — sizing / layout (after §2 CSS rebuild)
|
||||
|
||||
**§2 CSS rebuild** — do first; fixes Vendor/Client/Invoice-Pay widths.
|
||||
|
||||
**BUG E/F — Vendor + Client wizard step layout overflows the card** — **DONE & verified live.**
|
||||
- **Actual root cause (found while fixing):** `step-card` places the step timeline in a *vertical*
|
||||
left sidebar (`grow-0 ... self-stretch hidden md:block`), but `vendor-timeline`/`client-timeline`
|
||||
rendered the **horizontal** `timeline/timeline` component. A horizontal `<ol>` in a shrink-to-fit
|
||||
sidebar forced the sidebar to ~full card width, pushing the body off-screen → horizontal scroll,
|
||||
clipped fields, the "huge grey left region". (Vendor additionally pinned its bodies to a fixed
|
||||
`w-[600px]`, compounding it.)
|
||||
- **FIX applied:**
|
||||
- `vendors.clj` `vendor-timeline` + `clients.clj` `client-timeline`: use
|
||||
`timeline/vertical-timeline` + `timeline/vertical-timeline-step` (the components already existed)
|
||||
instead of the horizontal pair.
|
||||
- `vendors.clj`: the 5 step bodies' `w-[600px] h-[350px]` → `w-full h-[350px]` so the body fills
|
||||
the remaining width next to the now-narrow vertical timeline.
|
||||
- **Verified live:** Vendor 760×520 and Client 820×560 with a proper vertical timeline, **no
|
||||
horizontal overflow**, fields fill the body; forward nav (Info→Terms) works, the vendor-name chip
|
||||
renders, and the validation-error re-render also lays out correctly.
|
||||
- Note: edits made with the plain `Edit` tool (exact-string) — the clojure-mcp editors reformat the
|
||||
whole file against a stricter config than the project's `lein cljfmt`, producing large spurious
|
||||
diffs; both files pass `lein cljfmt check`.
|
||||
|
||||
### P2 — robustness / cosmetic
|
||||
|
||||
**BUG D — Invoice Bulk Edit with no selection → 500** — **DONE & verified live.** With no
|
||||
selection, `selected->ids` returns `nil`, which `all-ids-not-locked` fed straight into a Datomic
|
||||
`:in $ [?i ...]` query → "Unable to find data source" exception → global "Oh, drat!" toast.
|
||||
- File: `src/clj/auto_ap/ssr/invoices.clj` — wrapped `all-ids-not-locked`'s body in
|
||||
`(when (seq all-ids) ...)`, so an empty/nil selection yields `[]` and the modal opens cleanly
|
||||
(also protects every other caller). Live: Bulk Edit with nothing selected now opens the modal,
|
||||
no toast, no 500.
|
||||
|
||||
**BUG B — Footer leaks a raw Hiccup attr-map as text** in the red "unexpected error" banner
|
||||
(`{:x-show "unexpectedError", ...}`). **Pre-existing in master** (NOT a migration regression), but
|
||||
visible whenever `unexpectedError` flips true (e.g. it showed during BUG A). Cause: `modal-footer-`
|
||||
calls `(hx/alpine-appear {...})` **twice** — the 2nd return value lands in child position and
|
||||
renders as literal EDN.
|
||||
- File: `src/clj/auto_ap/ssr/components/dialog.clj` ~line 59.
|
||||
- **FIX — DONE & verified:** deleted the duplicate `(hx/alpine-appear ...)` line in `modal-footer-`.
|
||||
Rendering the footer to HTML now shows a single `alpine-appear` (the legit attrs) and no literal
|
||||
`{:x-show ...}` text child.
|
||||
|
||||
**Cosmetic — over-tall empty modals.** Invoice/Transaction Bulk Edit and similar use fixed
|
||||
`md:h-[650px]`; with little content they show a large empty lower region. Consider letting height
|
||||
hug content (cap with `max-h`) rather than a fixed height. Low priority.
|
||||
|
||||
## 5. Suggested order of work
|
||||
|
||||
1. **§2 CSS rebuild** (unblocks all width checks; commit `output.css`; verify prod build does this).
|
||||
2. **BUG A** + **BUG C** (the two functional 500s; one-liners each, REPL-verified).
|
||||
3. **BUG E / BUG F** (wizard step-body layout rework; needs visual iteration per step).
|
||||
4. **§3 animations** (add swap-transition classes to wizard step cards).
|
||||
5. **BUG D**, **BUG B** (robustness / cosmetic).
|
||||
6. Re-run the full agent-browser pass (all 9 modals) + the Playwright e2e suite as the parity gate.
|
||||
|
||||
## 6. What was verified vs. inferred
|
||||
|
||||
- **Verified in browser + REPL:** BUG A (500 + exact exception + fix direction), BUG C (500 +
|
||||
exact exception + vector repro + line 661 cause), §2 CSS root cause (missing classes + rebuild
|
||||
fixes Vendor 1257→760 and Client→820), BUG D (500 on empty selection), Vendor/Client inner
|
||||
overflow (screenshots 16/17), and that Bulk Code / Sales Summary / Transaction Rule / Invoice
|
||||
Pay / Transaction Edit-open all render without errors.
|
||||
- **Inferred (static analysis, not yet visually A/B'd against master):** §3 animation regression
|
||||
(step cards lack `htmx-swapping:`/`htmx-added:` classes) and BUG B being pre-existing.
|
||||
@@ -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,11 +310,15 @@ 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();
|
||||
expect(statusValueAfter).toBe('approved');
|
||||
@@ -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;
|
||||
|
||||
// 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);
|
||||
|
||||
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();
|
||||
|
||||
// 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 selectAccountFromTypeahead(page, 0, 'Test');
|
||||
await setAccountAmount(page, 0, '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,14 +278,14 @@ 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');
|
||||
await expect(errorElement).toBeVisible();
|
||||
@@ -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,22 +1,33 @@
|
||||
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: {
|
||||
command: 'lein run -m auto-ap.test-server',
|
||||
url: 'http://localhost:3333/test-info',
|
||||
reuseExistingServer: !process.env.CI,
|
||||
timeout: 120000,
|
||||
},
|
||||
webServer: useExternalServer
|
||||
? undefined
|
||||
: {
|
||||
command: 'lein run -m auto-ap.test-server',
|
||||
url: 'http://localhost:3333/test-info',
|
||||
reuseExistingServer: !process.env.CI,
|
||||
timeout: 120000,
|
||||
},
|
||||
projects: [
|
||||
{
|
||||
name: 'chromium',
|
||||
|
||||
@@ -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
@@ -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"))))
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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]
|
||||
(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))))})
|
||||
: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/data-grid-cell
|
||||
{}
|
||||
(com/validated-field
|
||||
{:errors (fc/field-errors)}
|
||||
[: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-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)
|
||||
:client-id client-id
|
||||
:name (fc/field-name)
|
||||
:x-model "accountId"}))))
|
||||
(fc/with-field :transaction-rule-account/location
|
||||
(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
|
||||
[: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-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])))
|
||||
:client-locations client-locations
|
||||
:x-model "location"
|
||||
:value (fc/field-value)}))))
|
||||
(fc/with-field :transaction-rule-account/percentage
|
||||
(com/data-grid-cell
|
||||
{}
|
||||
(com/validated-field
|
||||
{:errors (fc/field-errors)}
|
||||
(com/money-input {:name (fc/field-name)
|
||||
:class "w-16"
|
||||
:value (some-> (fc/field-value)
|
||||
(* 100)
|
||||
(long))}))))))
|
||||
(com/data-grid-cell {:class "align-top"}
|
||||
(com/a-icon-button {"@click.prevent.stop" "show=false; setTimeout(() => $refs.p.remove(), 500)"} svg/x))))
|
||||
"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 acct-id
|
||||
:location (:transaction-rule-account/location account)
|
||||
:show (boolean (not (:new? account)))})
|
||||
:data-key "show"
|
||||
:x-ref "p"}
|
||||
hx/alpine-mount-then-appear)
|
||||
(com/hidden {:name (account-field-name index :db/id)
|
||||
:value (:db/id account)})
|
||||
(com/data-grid-cell
|
||||
{}
|
||||
(com/validated-field
|
||||
{: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 || ''}" 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 acct-id
|
||||
:client-id client-id
|
||||
:name aname
|
||||
:x-model "accountId"})))
|
||||
(com/data-grid-cell
|
||||
{}
|
||||
(com/validated-field
|
||||
{: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 || ''}" 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 lname
|
||||
:account-location (:account/location (when (nat-int? acct-id)
|
||||
(dc/pull (dc/db conn) '[:account/location] acct-id)))
|
||||
:client-locations client-locations
|
||||
:value (:transaction-rule-account/location account)})))
|
||||
(com/data-grid-cell
|
||||
{}
|
||||
(com/validated-field
|
||||
{: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-> (:transaction-rule-account/percentage account)
|
||||
(* 100)
|
||||
(long))})))
|
||||
(com/data-grid-cell {:class "align-top"}
|
||||
(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 [_ _] [])
|
||||
(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 [:div#my-form {:x-trap "true"}
|
||||
[:fieldset {:class "hx-disable"
|
||||
:x-data (hx/json {:clientId client-id})}
|
||||
[:div.space-y-1
|
||||
(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 (: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")]
|
||||
(com/validated-field
|
||||
(-> {:label "Client" :errors (ferr :transaction-rule/client) :x-show "clientFilter"} (hx/alpine-appear))
|
||||
[:div.w-96
|
||||
(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 rule-client
|
||||
:content-fn (fn [c] (pull-attr (dc/db conn) :client/name c))})])
|
||||
(com/validated-field
|
||||
(-> {:label "Client Group" :errors (ferr :transaction-rule/client-group) :x-show "clientGroupFilter"} (hx/alpine-appear))
|
||||
[:div.w-96
|
||||
(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 (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 "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 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
|
||||
[:div.flex.flex-col
|
||||
(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 (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
|
||||
(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"]
|
||||
(com/validated-field {:label "Assign Vendor" :errors (ferr :transaction-rule/vendor)}
|
||||
[:div.w-96
|
||||
(com/typeahead {:name (fname :transaction-rule/vendor)
|
||||
:placeholder "Search..."
|
||||
:url (bidi/path-for ssr-routes/only-routes :vendor-search)
|
||||
:class "w-96"
|
||||
:value (:transaction-rule/vendor rule)
|
||||
:content-fn #(pull-attr (dc/db conn) :vendor/name %)})])
|
||||
(com/validated-field
|
||||
{: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"})]}
|
||||
(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 accounts)
|
||||
:tr-params (hx/bind-alpine-vals {} {"client-id" "clientId"})}
|
||||
"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 (:transaction-rule/transaction-approval-status rule)
|
||||
:name (fname :transaction-rule/transaction-approval-status)
|
||||
:size :small
|
||||
:orientation :horizontal}))]]]
|
||||
:footer (wizard2/nav-footer {:next "Test"})))))
|
||||
|
||||
(step-schema [_]
|
||||
(mm/form-schema linear-wizard))
|
||||
(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 all-data :clients (:clients request)})]
|
||||
:footer (wizard2/nav-footer {:back? true :save? true})))
|
||||
|
||||
(render-step [this request]
|
||||
(mm/default-render-step
|
||||
linear-wizard this
|
||||
: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))}
|
||||
[:fieldset {:class "hx-disable"
|
||||
:x-data (hx/json {:clientId (or (:db/id (:transaction-rule/client (fc/field-value)))
|
||||
(:transaction-rule/client (fc/field-value)))})}
|
||||
(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)))
|
||||
|
||||
[: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?)
|
||||
: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*))))})}
|
||||
(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))))
|
||||
|
||||
[: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
|
||||
(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 request) saved {:flash? true})
|
||||
:headers (cond-> {"hx-trigger" "modalclose"}
|
||||
(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")))))
|
||||
|
||||
(com/validated-field
|
||||
(-> {:label "Client"
|
||||
:errors (fc/field-errors)
|
||||
:x-show "clientFilter"}
|
||||
(hx/alpine-appear))
|
||||
[:div.w-96
|
||||
(com/typeahead {:name (fc/field-name)
|
||||
:error? (fc/error?)
|
||||
: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
|
||||
(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 (wizard2/transitioner 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!})
|
||||
|
||||
(com/validated-field
|
||||
(-> {:label "Client Group"
|
||||
:errors (fc/field-errors)
|
||||
: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/validated-field
|
||||
(-> {:label "Bank Account"
|
||||
:errors (fc/field-errors)
|
||||
: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"
|
||||
(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))
|
||||
|
||||
:hx-vals (format "js:{name: '%s', 'client-id': event.detail.clientId}" (fc/field-name))
|
||||
: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)
|
||||
[: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
|
||||
[: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)
|
||||
[: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)})))])]
|
||||
|
||||
[:h2.text-lg "Outcomes"]
|
||||
(fc/with-field :transaction-rule/vendor
|
||||
(com/validated-field {:label "Assign Vendor"
|
||||
:errors (fc/field-errors)}
|
||||
[:div.w-96
|
||||
(com/typeahead {:name (fc/field-name)
|
||||
: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
|
||||
(com/validated-field
|
||||
{:errors (fc/field-errors)}
|
||||
(let [client-locations (some->> (fc/field-value) :transaction-rule/client (pull-attr (dc/db conn) :client/locations))]
|
||||
(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))
|
||||
(com/data-grid-new-row {:colspan 4
|
||||
:hx-get (bidi/path-for ssr-routes/only-routes
|
||||
::route/new-account)
|
||||
:index (count (fc/field-value))
|
||||
: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)}
|
||||
(com/radio-card {:options (ref->radio-options "transaction-approval-status")
|
||||
:value (fc/field-value)
|
||||
:name (fc/field-name)
|
||||
:size :small
|
||||
:orientation :horizontal})))]]])
|
||||
:footer
|
||||
(mm/default-step-footer linear-wizard this :validation-route ::route/navigate)
|
||||
:validation-route ::route/navigate)))
|
||||
|
||||
(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
|
||||
: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)))
|
||||
|
||||
(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])
|
||||
|
||||
(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)
|
||||
|
||||
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)))]
|
||||
(html-response
|
||||
(row* identity updated-rule {: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))
|
||||
(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,18 +866,11 @@
|
||||
(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")))
|
||||
(wrap-schema-enforce :query-schema [:map
|
||||
[:client-id {:optional true}
|
||||
[:maybe entity-id]]]))
|
||||
(-> new-account
|
||||
(wrap-schema-enforce :query-schema [:map
|
||||
[:index {:optional true} [:maybe nat-int?]]
|
||||
[:client-id {:optional true}
|
||||
[:maybe entity-id]]]))
|
||||
|
||||
::route/location-select (-> location-select
|
||||
(wrap-schema-enforce :query-schema [:map
|
||||
@@ -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]
|
||||
@@ -84,18 +84,18 @@
|
||||
(com/validated-field {:label "Start"
|
||||
:errors (fc/field-errors)}
|
||||
[:div {:class "w-64"}
|
||||
(com/date-input {:name (fc/field-name)
|
||||
(com/date-input {:name (fc/field-name)
|
||||
:class "w-64"
|
||||
:value (some-> (fc/field-value)
|
||||
(atime/unparse-local atime/normal-date))})]))
|
||||
:value (some-> (fc/field-value)
|
||||
(atime/unparse-local atime/normal-date))})]))
|
||||
(fc/with-field :end-date
|
||||
(com/validated-field {:label "End"
|
||||
:errors (fc/field-errors)}
|
||||
[:div {:class "w-64"}
|
||||
(com/date-input {:name (fc/field-name)
|
||||
(com/date-input {:name (fc/field-name)
|
||||
:class "w-64"
|
||||
:value (some-> (fc/field-value)
|
||||
(atime/unparse-local atime/normal-date))})]))
|
||||
:value (some-> (fc/field-value)
|
||||
(atime/unparse-local atime/normal-date))})]))
|
||||
(com/button {:color :primary :class "self-center w-24"} "Run")])]
|
||||
(if report
|
||||
(report* {:request request :report report})
|
||||
@@ -104,15 +104,15 @@
|
||||
(defn page [request]
|
||||
(base-page
|
||||
request
|
||||
(com/page {:nav com/company-aside-nav
|
||||
(com/page {:nav com/company-aside-nav
|
||||
:client-selection (:client-selection request)
|
||||
:client (:client request)
|
||||
:clients (:clients request)
|
||||
:identity (:identity request)
|
||||
:app-params {:hx-get (bidi/path-for ssr-routes/only-routes :company-reconciliation-report)
|
||||
:identity (:identity request)
|
||||
:app-params {:hx-get (bidi/path-for ssr-routes/only-routes :company-reconciliation-report)
|
||||
:hx-trigger "clientSelected from:body"
|
||||
:hx-select "#app-contents"
|
||||
:hx-swap "outerHTML swap:300ms"}}
|
||||
:hx-swap "outerHTML swap:300ms"}}
|
||||
(com/breadcrumbs {}
|
||||
[:a {:href (bidi/path-for ssr-routes/only-routes :company)}
|
||||
"My Company"]
|
||||
@@ -133,7 +133,7 @@
|
||||
|
||||
(defn get-report-data [start-date end-date client-ids]
|
||||
(let [client-codes (map first (dc/q '[:find ?cc :in $ [?c ...] :where [?c :client/code ?cc]] (dc/db conn) client-ids))]
|
||||
(for [[ib ba c] (seq (apply get-intuit-bank-accounts (dc/db conn) client-codes))
|
||||
(for [[ib ba c] (seq (apply get-intuit-bank-accounts (dc/db conn) client-codes))
|
||||
:let [raw-transactions (get-transactions (atime/unparse-local start-date atime/iso-date)
|
||||
(atime/unparse-local end-date atime/iso-date)
|
||||
ib)
|
||||
|
||||
@@ -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,22 +93,23 @@
|
||||
: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"}
|
||||
(cond
|
||||
(= :mine client-selection)
|
||||
"My Companies"
|
||||
(= :all client-selection)
|
||||
"All Companies"
|
||||
[:span {:class "truncate max-w-[10rem] sm:max-w-[14rem]"}
|
||||
(cond
|
||||
(= :mine client-selection)
|
||||
"My Companies"
|
||||
(= :all client-selection)
|
||||
"All Companies"
|
||||
|
||||
(and client
|
||||
(= 1 (count clients)))
|
||||
(:client/name client)
|
||||
(and client
|
||||
(= 1 (count clients)))
|
||||
(:client/name client)
|
||||
|
||||
:else
|
||||
(str (count clients) " Companies"))
|
||||
[:div.w-4.h-4.ml-2
|
||||
:else
|
||||
(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]"
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
(def success-modal dialog/success-modal-)
|
||||
(def modal-card dialog/modal-card-)
|
||||
(def modal-card-advanced dialog/modal-card-advanced-)
|
||||
(def single-modal-card dialog/single-modal-card-)
|
||||
(def modal-header dialog/modal-header-)
|
||||
(def modal-header-attachment dialog/modal-header-attachment-)
|
||||
(def modal-body dialog/modal-body-)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
[:span.items-center.bg-red-100.text-red-800.text-xs.font-medium.mb-2.p-1.rounded-full.inline-flex (hx/alpine-appear {:x-show "unexpectedError" :class "dark:bg-red-900 dark:text-red-300"})
|
||||
[:span {:class "w-2 h-2 mr-1 bg-red-500 rounded-full"}] [:span.px-2.py-0.5 "An unexpected error has occured. Integreat staff have been notified."]]
|
||||
(when (:error params)
|
||||
[:span.items-center.bg-red-100.text-red-800.text-xs.font-medium.mb-2.p-1.rounded-full.inline-flex {:class "dark:bg-red-900 dark:text-red-300"}
|
||||
[:span.items-center.bg-red-100.text-red-800.text-xs.font-medium.mb-2.p-1.rounded-full.inline-flex {:class "dark:bg-red-900 dark:text-red-300"}
|
||||
[:span {:class "w-2 h-2 mr-1 bg-red-500 rounded-full"}]
|
||||
[:span.px-2.py-0.5 (:error params)]])
|
||||
[:div {:class "shrink-0"}
|
||||
@@ -55,7 +55,6 @@
|
||||
(defn modal-footer- [params & children]
|
||||
[:div {:class "p-4 border-t"}
|
||||
[:span.items-center.bg-red-100.text-red-800.text-xs.font-medium.mb-2.p-1.rounded-full.inline-flex
|
||||
(hx/alpine-appear {:x-show "unexpectedError" :class "dark:bg-red-900 dark:text-red-300"})
|
||||
(hx/alpine-appear {:x-show "unexpectedError" :class "dark:bg-red-900 dark:text-red-300"})
|
||||
[:span {:class "w-2 h-2 bg-red-500 rounded-full"}]
|
||||
[:span.px-2.py-0.5 "An unexpected error has occured. Integreat staff have been notified."]]
|
||||
@@ -64,9 +63,25 @@
|
||||
|
||||
(defn modal-card-advanced- [params & children]
|
||||
[:div (merge params
|
||||
{:class (hh/add-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" (:class params ""))})
|
||||
{:class (hh/add-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" (:class params ""))})
|
||||
children])
|
||||
|
||||
(defn single-modal-card-
|
||||
"Single-step modal-card chrome (header / optional side panel / body / footer) at the
|
||||
standard md:w-[950px] md:h-[650px] size. Enter triggers the footer save button via
|
||||
$refs.next. Reproduces the former Selmer templates/components/modal-card.html and
|
||||
transaction-edit/edit-modal.html, so the modal-size classes live in one Clojure source."
|
||||
[{:keys [side-panel]} head body footer]
|
||||
[: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]
|
||||
[:div {:class "flex shrink overflow-auto grow"}
|
||||
(when side-panel
|
||||
[:div {:class "grow-0 w-64 bg-gray-50 border-r hidden md:block overflow-y-auto max-h-full"} side-panel])
|
||||
[:div {:class "px-6 py-2 space-y-6 overflow-y-scroll w-full shrink grow"} body]]
|
||||
[:div {:class "p-4 border-t"} footer]])
|
||||
|
||||
(defn success-modal- [{:keys [title]} & children]
|
||||
(modal- {}
|
||||
(modal-card-advanced-
|
||||
|
||||
@@ -51,23 +51,28 @@
|
||||
{:x-init "$el.indeterminate = true"}))]))
|
||||
|
||||
(defn typeahead- [params]
|
||||
[:div.relative {: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 ""
|
||||
:active -1
|
||||
:elements (if ((:value-fn params identity) (:value params))
|
||||
[{:value ((:value-fn params identity) (:value params)) :label ((:content-fn params identity) (:value params))}]
|
||||
[])})
|
||||
:x-modelable "value.value"
|
||||
:x-model (:x-model 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 ""
|
||||
:active -1
|
||||
:elements (if ((:value-fn params identity) (:value params))
|
||||
[{:value ((:value-fn params identity) (:value params)) :label ((:content-fn params identity) (:value params))}]
|
||||
[])})
|
||||
: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])]
|
||||
[: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))]]))]
|
||||
|
||||
[:div {:class "flex items-center gap-4"}
|
||||
;; Search: fills the middle, grows to a comfortable max and shrinks first when space is tight.
|
||||
(when (is-admin? identity)
|
||||
[: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"]]])
|
||||
|
||||
(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 "
|
||||
: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
|
||||
|
||||
258
src/clj/auto_ap/ssr/components/wizard2.clj
Normal file
258
src/clj/auto_ap/ssr/components/wizard2.clj
Normal file
@@ -0,0 +1,258 @@
|
||||
(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.hx :as hx]
|
||||
[auto-ap.ssr.utils :refer [html-response]]))
|
||||
|
||||
(defn- step-by-key [config k]
|
||||
(first (filter #(= (:key %) k) (:steps config))))
|
||||
|
||||
(defn- step-index [config k]
|
||||
(.indexOf (mapv :key (:steps config)) k))
|
||||
|
||||
(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- transition-type
|
||||
"forward when advancing to a later step, backward when returning to an earlier one, nil
|
||||
when staying on the same step (a validation re-render) — used to drive the slide."
|
||||
[config from-key to-key]
|
||||
(let [fi (step-index config from-key)
|
||||
ti (step-index config to-key)]
|
||||
(cond
|
||||
(or (neg? fi) (neg? ti) (= fi ti)) nil
|
||||
(> fi ti) "backward"
|
||||
:else "forward")))
|
||||
|
||||
(def step-slide-classes
|
||||
"Forward/back slide variants applied to the swapped wizard <form>. The #transitioner
|
||||
ancestor (see `transitioner`) carries `group/transition` + `forward`|`backward`; during a
|
||||
step swap the outgoing form gets `htmx-swapping` and the incoming one `htmx-added`, so
|
||||
these variants animate the card sliding/fading in the matching direction."
|
||||
(str "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 transition duration-150"))
|
||||
|
||||
(defn transitioner
|
||||
"Wrap the opened wizard form in the #transitioner the slide animation hooks onto. After
|
||||
each step swap it reads the `x-transition-type` response header and toggles
|
||||
`group/transition` + `forward`|`backward` on itself, which drives `step-slide-classes` on
|
||||
the swapped form. Rendered once on open; the form swaps itself inside it, so this node
|
||||
(and its Alpine state) persists across steps."
|
||||
[form]
|
||||
[:div#transitioner.flex-1
|
||||
{:x-data (hx/json {"transitionType" "none"})
|
||||
:x-ref "transitioner"
|
||||
"@htmx:after-request" (str "if (event.detail.xhr.getResponseHeader('x-transition-type') && "
|
||||
"event.detail.xhr.getResponseHeader('x-transition-type') !== 'none') {"
|
||||
" $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'); }")}
|
||||
form])
|
||||
|
||||
(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. The form
|
||||
carries `step-slide-classes` so the whole-form swap animates as a directional slide."
|
||||
[config wizard-id current-step body]
|
||||
[:form (merge {:id (:form-id config "wizard-form")
|
||||
:class step-slide-classes
|
||||
: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- with-transition
|
||||
"Attach the slide headers the #transitioner reads: `x-transition-type` (forward/backward)
|
||||
plus an `HX-reswap` swap delay so the outgoing form's slide-out is visible before the
|
||||
swap. nil tt -> `none` (a same-step validation re-render does not animate)."
|
||||
[resp tt]
|
||||
(if tt
|
||||
(-> resp
|
||||
(assoc-in [:headers "x-transition-type"] tt)
|
||||
(assoc-in [:headers "HX-reswap"] "outerHTML swap:0.16s"))
|
||||
(assoc-in resp [:headers "x-transition-type"] "none")))
|
||||
|
||||
(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")
|
||||
(let [prev (prev-step config current-step)]
|
||||
(-> (render-response config wizard-id
|
||||
(ws/set-step session wizard-id prev)
|
||||
request)
|
||||
(with-transition (transition-type config current-step prev))))
|
||||
|
||||
: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)
|
||||
;; same step -> no slide (with-transition nil => "none")
|
||||
(-> (render-response config wizard-id session request
|
||||
{:step-errors errors :step-posted posted})
|
||||
(with-transition nil))
|
||||
(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)
|
||||
(with-transition (transition-type config current-step nxt))))))))))
|
||||
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}))"}
|
||||
|
||||
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)
|
||||
|
||||
@@ -482,10 +482,26 @@
|
||||
(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
|
||||
:hx-get (bidi/path-for ssr-routes/only-routes
|
||||
::route/new)}
|
||||
"Add journal entry"))])
|
||||
[(when-not (:external? (:route-params request))
|
||||
(com/button {:color :primary
|
||||
:hx-get (bidi/path-for ssr-routes/only-routes
|
||||
::route/new)}
|
||||
"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}))
|
||||
|
||||
@@ -102,16 +102,16 @@
|
||||
:numeric-code (:numeric_code account)
|
||||
:name (:name account)
|
||||
:sample sample
|
||||
:period {:start (coerce/to-date (:start p)) :end (coerce/to-date (:end p))}}))
|
||||
:period {:start (coerce/to-date (:start p)) :end (coerce/to-date (:end p))}}))
|
||||
|
||||
args (assoc (:form-params request)
|
||||
:periods (map (fn [d]
|
||||
{:start (coerce/to-date (:start d)) :end (coerce/to-date (:end d))}) periods))
|
||||
clients (pull-many (dc/db conn) [:client/code :client/name :db/id :client/feature-flags] client-ids)
|
||||
args (assoc (:form-params request)
|
||||
:periods (map (fn [d]
|
||||
{:start (coerce/to-date (:start d)) :end (coerce/to-date (:end d))}) periods))
|
||||
clients (pull-many (dc/db conn) [:client/code :client/name :db/id :client/feature-flags] client-ids)
|
||||
|
||||
pnl-data (l-reports/->PNLData args data (by :db/id :client/code clients))
|
||||
pnl-data (l-reports/->PNLData args data (by :db/id :client/code clients))
|
||||
#_#__ (clojure.pprint/pprint pnl-data)
|
||||
report (l-reports/summarize-pnl pnl-data)]
|
||||
report (l-reports/summarize-pnl pnl-data)]
|
||||
(alog/info ::profit-and-loss :params args)
|
||||
{:data report
|
||||
:report report})))
|
||||
@@ -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))
|
||||
@@ -139,19 +149,9 @@
|
||||
[13 6 13]
|
||||
[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")]))]}))))])
|
||||
:table table-contents
|
||||
:warning (when (or warning-text sample-links)
|
||||
[:div warning-text sample-links])}))))])
|
||||
|
||||
(defn form* [request & children]
|
||||
(let [params (or (:query-params request) {})]
|
||||
@@ -169,12 +169,12 @@
|
||||
(fc/with-field :client
|
||||
(com/validated-inline-field
|
||||
{:label "Customers" :errors (fc/field-errors)}
|
||||
(com/multi-typeahead {:name (fc/field-name)
|
||||
(com/multi-typeahead {:name (fc/field-name)
|
||||
:placeholder "Search for companies..."
|
||||
:class "w-64"
|
||||
:id "client"
|
||||
:id "client"
|
||||
:url (bidi/path-for ssr-routes/only-routes :company-search)
|
||||
:value (fc/field-value)
|
||||
:value (fc/field-value)
|
||||
:value-fn :db/id
|
||||
:content-fn :client/name})))
|
||||
(fc/with-field :periods
|
||||
@@ -204,12 +204,12 @@
|
||||
(defn profit-and-loss [request]
|
||||
(base-page
|
||||
request
|
||||
(com/page {:nav com/main-aside-nav
|
||||
(com/page {:nav com/main-aside-nav
|
||||
|
||||
:client-selection (:client-selection request)
|
||||
:clients (:clients request)
|
||||
:client (:client request)
|
||||
:identity (:identity request)
|
||||
:clients (:clients request)
|
||||
:client (:client request)
|
||||
:identity (:identity request)
|
||||
:request request}
|
||||
(apply com/breadcrumbs {} [[:a {:href (bidi/path-for ssr-routes/only-routes ::route/page)}
|
||||
"Ledger"]])
|
||||
@@ -222,9 +222,9 @@
|
||||
table (concat-tables (:details report))]
|
||||
(pdf/pdf
|
||||
(-> [{:left-margin 10 :right-margin 10 :top-margin 15 :bottom-margin 15
|
||||
:size :letter
|
||||
:font {:size 6
|
||||
:ttf-name "fonts/calibri-light.ttf"}}
|
||||
:size :letter
|
||||
:font {:size 6
|
||||
:ttf-name "fonts/calibri-light.ttf"}}
|
||||
[:heading (str "Profit and Loss - " (str/join ", " (map :client/name (seq (:client (:form-params request))))))]]
|
||||
|
||||
(conj [:paragraph {:color [128 0 0] :size 9} (:warning report)])
|
||||
@@ -254,35 +254,35 @@
|
||||
(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]
|
||||
(let [uuid (str (UUID/randomUUID))
|
||||
(let [uuid (str (UUID/randomUUID))
|
||||
{:keys [client warning]} (maybe-trim-clients request (:client (:form-params request)))
|
||||
request (assoc-in request [:form-params :client] client)
|
||||
request (assoc-in request [:form-params :client] client)
|
||||
pdf-data (binding [*report-pedantic* (boolean ((set (:client/feature-flags (first client)))
|
||||
"report-pedantic"))] (make-profit-and-loss-pdf request (:report (get-report request))))
|
||||
name (profit-and-loss-args->name request)
|
||||
key (str "reports/profit-and-loss/" uuid "/" name ".pdf")
|
||||
url (str "https://" (:data-bucket env) "/" key)]
|
||||
name (profit-and-loss-args->name request)
|
||||
key (str "reports/profit-and-loss/" uuid "/" name ".pdf")
|
||||
url (str "https://" (:data-bucket env) "/" key)]
|
||||
(s3/put-object :bucket-name (:data-bucket env/env)
|
||||
:key key
|
||||
:input-stream (io/make-input-stream pdf-data {})
|
||||
:metadata {:content-length (count pdf-data)
|
||||
:content-type "application/pdf"})
|
||||
:content-type "application/pdf"})
|
||||
@(dc/transact conn
|
||||
[{:report/name name
|
||||
:report/client (map :db/id client)
|
||||
:report/key key
|
||||
:report/url url
|
||||
[{:report/name name
|
||||
:report/client (map :db/id client)
|
||||
:report/key key
|
||||
:report/url url
|
||||
:report/creator (:user (:identity request))
|
||||
:report/created (java.util.Date.)}])
|
||||
{:report/name name
|
||||
:report/url url}))
|
||||
:report/url url}))
|
||||
|
||||
;; TODO PRINT WARNING
|
||||
(defn export [request]
|
||||
|
||||
@@ -9,29 +9,27 @@
|
||||
[auto-ap.client-routes :as client-routes]
|
||||
[auto-ap.routes.pos.sales-summaries :as route]
|
||||
[auto-ap.ssr-routes :as ssr-routes]
|
||||
[auto-ap.ssr.common-handlers :refer [add-new-entity-handler]]
|
||||
[auto-ap.ssr.components :as com]
|
||||
[auto-ap.ssr.components.link-dropdown :refer [link-dropdown]]
|
||||
[auto-ap.ssr.components.multi-modal :as mm]
|
||||
[auto-ap.ssr.form-cursor :as fc]
|
||||
[auto-ap.ssr.grid-page-helper :as helper :refer [wrap-apply-sort]]
|
||||
[auto-ap.ssr.hx :as hx]
|
||||
[auto-ap.ssr.nested-form-params :refer [wrap-nested-form-params]]
|
||||
[auto-ap.ssr.pos.common
|
||||
:refer [date-range-field*]]
|
||||
[auto-ap.ssr.svg :as svg]
|
||||
[auto-ap.ssr.utils
|
||||
:refer [apply-middleware-to-all-handlers clj-date-schema
|
||||
default-grid-fields-schema entity-id html-response money
|
||||
strip temp-id wrap-merge-prior-hx wrap-schema-enforce]]
|
||||
:refer [->db-id apply-middleware-to-all-handlers assert-schema
|
||||
clj-date-schema default-grid-fields-schema entity-id html-response
|
||||
main-transformer modal-response money path->name2 strip temp-id
|
||||
wrap-form-4xx-2 wrap-merge-prior-hx wrap-schema-enforce]]
|
||||
[auto-ap.time :as atime]
|
||||
[bidi.bidi :as bidi]
|
||||
[clj-time.coerce :as c]
|
||||
[clojure.string :as str]
|
||||
[datomic.api :as dc]
|
||||
[hiccup.util :as hu]
|
||||
[iol-ion.query :refer [dollars= dollars-0?]]
|
||||
[malli.core :as mc]
|
||||
[malli.util :as mut]))
|
||||
[iol-ion.query :refer [dollars=]]
|
||||
[malli.core :as mc]))
|
||||
|
||||
(def query-schema (mc/schema
|
||||
[:maybe
|
||||
@@ -133,63 +131,6 @@
|
||||
(str (subs s 0 (- max-len 3)) "...")
|
||||
s))
|
||||
|
||||
(defn account-typeahead*
|
||||
[{:keys [name value client-id]}]
|
||||
[:div.flex.flex-col
|
||||
(com/typeahead {:name name
|
||||
:placeholder "Search..."
|
||||
:url (hu/url (bidi/path-for ssr-routes/only-routes :account-search)
|
||||
{:client-id client-id
|
||||
:purpose "invoice"})
|
||||
:value value
|
||||
:content-fn (fn [value]
|
||||
(:account/name (d-accounts/clientize (dc/pull (dc/db conn) d-accounts/default-read value)
|
||||
client-id)))})])
|
||||
|
||||
(defn account-display-cell [{:keys [item field-name-prefix client-id]}]
|
||||
(let [account-id (:ledger-mapped/account item)
|
||||
account-name (when account-id
|
||||
(:account/name (d-accounts/clientize (dc/pull (dc/db conn) d-accounts/default-read account-id)
|
||||
client-id)))]
|
||||
[:div.account-cell.flex.items-center.gap-2
|
||||
(com/hidden {:name (str field-name-prefix "[ledger-mapped/account]")
|
||||
:value (or account-id "")})
|
||||
(if account-id
|
||||
[:span.text-sm account-name]
|
||||
(com/pill {:color :red} "Missing acct"))
|
||||
(com/a-icon-button {:class "p-1"
|
||||
:hx-get (bidi/path-for ssr-routes/only-routes ::route/edit-item-account)
|
||||
:hx-target "closest .account-cell"
|
||||
:hx-swap "outerHTML"
|
||||
:hx-vals (hx/json {:item-index (or (:item-index item) 0)
|
||||
:client-id client-id
|
||||
:current-account-id (or account-id "")})}
|
||||
svg/pencil)]))
|
||||
|
||||
(defn account-edit-cell [{:keys [field-name-prefix client-id current-account-id]}]
|
||||
(let [account-input-name (str field-name-prefix "[ledger-mapped/account]")]
|
||||
[:div.account-cell.flex.flex-col.gap-2
|
||||
(account-typeahead* {:name account-input-name
|
||||
:value current-account-id
|
||||
:client-id client-id})
|
||||
[:div.flex.gap-1
|
||||
(com/a-icon-button {:class "p-1"
|
||||
:hx-put (bidi/path-for ssr-routes/only-routes ::route/save-item-account)
|
||||
:hx-target "closest .account-cell"
|
||||
:hx-swap "outerHTML"
|
||||
:hx-include "closest .account-cell"
|
||||
:hx-vals (hx/json {:field-name-prefix field-name-prefix
|
||||
:client-id client-id})}
|
||||
svg/check)
|
||||
(com/a-icon-button {:class "p-1"
|
||||
:hx-get (bidi/path-for ssr-routes/only-routes ::route/cancel-item-account)
|
||||
:hx-target "closest .account-cell"
|
||||
:hx-swap "outerHTML"
|
||||
:hx-vals (hx/json {:field-name-prefix field-name-prefix
|
||||
:client-id client-id
|
||||
:current-account-id (or current-account-id "")})}
|
||||
svg/x)]]))
|
||||
|
||||
(def grid-page
|
||||
(helper/build {:id "entity-table"
|
||||
:id-fn :db/id
|
||||
@@ -247,7 +188,7 @@
|
||||
[:span.shrink-0.font-mono.tabular-nums.text-right.text-gray-900.whitespace-nowrap
|
||||
(format "$%,.2f" (:ledger-mapped/amount si))]])
|
||||
(for [_ (range (max 0 (- credit-count (count debit-items))))]
|
||||
[:li.py-0.5.text-sm " "])]
|
||||
[:li.py-0.5.text-sm " "])]
|
||||
[:div.border-t-2.border-gray-300.mt-1.pt-1.flex.justify-between.items-baseline
|
||||
[:span.text-xs.uppercase.tracking-wider.font-semibold.text-gray-500 "Total"]
|
||||
[:span.font-mono.tabular-nums.font-bold.text-gray-900
|
||||
@@ -273,7 +214,7 @@
|
||||
[:span.shrink-0.font-mono.tabular-nums.text-right.text-gray-900.whitespace-nowrap
|
||||
(format "$%,.2f" (:ledger-mapped/amount si))]])
|
||||
(for [_ (range (max 0 (- debit-count (count credit-items))))]
|
||||
[:li.py-0.5.text-sm " "])]
|
||||
[:li.py-0.5.text-sm " "])]
|
||||
[:div.border-t-2.border-gray-300.mt-1.pt-1.flex.justify-between.items-baseline
|
||||
[:span.text-xs.uppercase.tracking-wider.font-semibold.text-gray-500 "Total"]
|
||||
[:span.font-mono.tabular-nums.font-bold.text-gray-900
|
||||
@@ -348,296 +289,300 @@
|
||||
(not (and (:credit x)
|
||||
(:debit x))))]]]]])
|
||||
|
||||
(defn summary-total-row* [request]
|
||||
(let [total-credits (-> request
|
||||
:multi-form-state
|
||||
:step-params
|
||||
:sales-summary/items
|
||||
(total-credits))
|
||||
total-debits (-> request
|
||||
:multi-form-state
|
||||
:step-params
|
||||
:sales-summary/items
|
||||
(total-debits))]
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Field-name / error helpers (no step-params[...] prefix) + item helpers.
|
||||
;; Mirrors transaction/edit.clj and transaction/bulk_code.clj.
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(com/data-grid-row {:id "total-row"
|
||||
:class "bg-slate-50 border-t-2 border-slate-300"}
|
||||
(com/data-grid-cell {})
|
||||
(com/data-grid-cell {:class "text-right"}
|
||||
[:span.text-xs.uppercase.tracking-wider.font-semibold.text-slate-600
|
||||
"Total"])
|
||||
(com/data-grid-cell {:class "text-right"}
|
||||
[:span.font-mono.tabular-nums.font-bold.text-slate-900
|
||||
(format "$%,.2f" total-debits)])
|
||||
(com/data-grid-cell {:class "text-right"}
|
||||
[:span.font-mono.tabular-nums.font-bold.text-slate-900
|
||||
(format "$%,.2f" total-credits)])
|
||||
(com/data-grid-cell {}))))
|
||||
(def ^:dynamic *errors*
|
||||
"Humanized form errors for the current render, keyed by edit-schema paths. Bound by
|
||||
render-form from the request's :form-errors. Plain map -- no wizard, no cursor."
|
||||
{})
|
||||
|
||||
(defn unbalanced-row* [request]
|
||||
(let [total-credits (-> request
|
||||
:multi-form-state
|
||||
:step-params
|
||||
:sales-summary/items
|
||||
(total-credits))
|
||||
total-debits (-> request
|
||||
:multi-form-state
|
||||
:step-params
|
||||
:sales-summary/items
|
||||
(total-debits))
|
||||
unbalanced? (not (dollars= total-credits total-debits))
|
||||
debit-over? (and unbalanced? (> total-debits total-credits))
|
||||
credit-over? (and unbalanced? (> total-credits total-debits))]
|
||||
(defn- ferr [& path]
|
||||
(get-in *errors* (vec path)))
|
||||
|
||||
(com/data-grid-row {:id "unbalanced-row"
|
||||
:class (when unbalanced? "bg-red-50 border-t border-red-200")}
|
||||
(com/data-grid-cell {})
|
||||
(com/data-grid-cell {:class "text-right"}
|
||||
(when unbalanced?
|
||||
[:span.text-xs.uppercase.tracking-wider.font-semibold.text-red-700
|
||||
"Out of balance"]))
|
||||
(com/data-grid-cell {:class "text-right"}
|
||||
(when debit-over?
|
||||
[:span.font-mono.tabular-nums.font-bold.text-red-700
|
||||
(format "$%,.2f" (- total-debits total-credits))]))
|
||||
(com/data-grid-cell {:class "text-right"}
|
||||
(when credit-over?
|
||||
[:span.font-mono.tabular-nums.font-bold.text-red-700
|
||||
(format "$%,.2f" (- total-credits total-debits))]))
|
||||
(com/data-grid-cell {}))))
|
||||
(defn- item-field-name [index field]
|
||||
(path->name2 :sales-summary/items index field))
|
||||
|
||||
(defn summary-total-display [request]
|
||||
(let [total-credits (-> request
|
||||
:multi-form-state
|
||||
:step-params
|
||||
:sales-summary/items
|
||||
(total-credits))
|
||||
total-debits (-> request
|
||||
:multi-form-state
|
||||
:step-params
|
||||
:sales-summary/items
|
||||
(total-debits))]
|
||||
[:div.flex.justify-between.text-sm.py-1.border-t.mt-1
|
||||
{:id "total-display"}]
|
||||
[:span.font-semibold "Total"]
|
||||
[:div.flex.gap-8
|
||||
[:span.font-mono (format "$%,.2f" total-debits)]
|
||||
[:span.font-mono (format "$%,.2f" total-credits)]]))
|
||||
(defn- item-field-errors [index field]
|
||||
(ferr :sales-summary/items index field))
|
||||
|
||||
(defn unbalanced-display [request]
|
||||
(let [total-credits (-> request
|
||||
:multi-form-state
|
||||
:step-params
|
||||
:sales-summary/items
|
||||
(total-credits))
|
||||
total-debits (-> request
|
||||
:multi-form-state
|
||||
:step-params
|
||||
:sales-summary/items
|
||||
(total-debits))
|
||||
delta (- total-debits total-credits)]
|
||||
(when-not (dollars-0? delta)
|
||||
[:div.flex.justify-between.text-sm.py-1
|
||||
{:id "unbalanced-display"}
|
||||
[:span.font-semibold.text-red-600 "Unbalanced"]
|
||||
[:div.flex.gap-8
|
||||
[:span.font-mono (when (pos? delta) (format "$%,.2f" delta))
|
||||
[:span.font-mono (when (neg? delta) (format "$%,.2f" (Math/abs delta)))]]]])))
|
||||
(defn- item-side
|
||||
"Which column an item belongs to: its persisted ledger-side for auto items, else the
|
||||
filled-in credit/debit for a manual row (nil for a brand-new blank manual row)."
|
||||
[item]
|
||||
(cond
|
||||
(= :ledger-side/debit (:ledger-mapped/ledger-side item)) :debit
|
||||
(= :ledger-side/credit (:ledger-mapped/ledger-side item)) :credit
|
||||
(:debit item) :debit
|
||||
(:credit item) :credit
|
||||
:else nil))
|
||||
|
||||
(defn sales-summary-item-row* [{:keys [value client-id]}]
|
||||
(let [manual? (fc/field-value (:sales-summary-item/manual? value))]
|
||||
(com/data-grid-row (cond-> {:x-ref "p"
|
||||
:x-data (hx/json {})
|
||||
:class (when manual?
|
||||
"bg-indigo-50/40 border-l-2 border-indigo-300")}
|
||||
(fc/field-value (:new? value)) (hx/htmx-transition-appear))
|
||||
(fc/with-field :db/id
|
||||
(com/hidden {:name (fc/field-name)
|
||||
:value (fc/field-value)}))
|
||||
(when manual?
|
||||
(fc/with-field :sales-summary-item/manual?
|
||||
(com/hidden {:name (fc/field-name)
|
||||
:value true})))
|
||||
(com/data-grid-cell {:class "align-top"}
|
||||
(fc/with-field :sales-summary-item/category
|
||||
(if manual?
|
||||
(com/validated-field {:errors (fc/field-errors)}
|
||||
(com/text-input {:placeholder "Category/Explanation"
|
||||
:name (fc/field-name)
|
||||
:value (fc/field-value)}))
|
||||
(defn- sum-debits [items]
|
||||
(->> items (keep :debit) (filter number?) (reduce + 0.0)))
|
||||
|
||||
(list
|
||||
(com/hidden {:name (fc/field-name)
|
||||
:value (fc/field-value)})
|
||||
[:span.text-sm.text-gray-700
|
||||
(fc/field-value (:sales-summary-item/category value))]))))
|
||||
(com/data-grid-cell {:class "align-top"}
|
||||
(fc/with-field :ledger-mapped/account
|
||||
(com/validated-field {:errors (fc/field-errors)}
|
||||
(account-typeahead* {:value (fc/field-value)
|
||||
:client-id client-id
|
||||
:name (fc/field-name)}))))
|
||||
(com/data-grid-cell {:class "text-right align-top"}
|
||||
(defn- sum-credits [items]
|
||||
(->> items (keep :credit) (filter number?) (reduce + 0.0)))
|
||||
|
||||
(if manual?
|
||||
(fc/with-field :debit
|
||||
(com/validated-field {:errors (fc/field-errors)}
|
||||
(com/money-input {:class "w-24 text-right font-mono tabular-nums"
|
||||
:name (fc/field-name)
|
||||
:value (fc/field-value)})))
|
||||
(when (= (fc/field-value (:ledger-mapped/ledger-side value))
|
||||
:ledger-side/debit)
|
||||
[:span.font-mono.tabular-nums.text-gray-900.text-sm.whitespace-nowrap
|
||||
(format "$%,.2f" (fc/field-value (:ledger-mapped/amount value)))])))
|
||||
(com/data-grid-cell {:class "text-right align-top"}
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Render (Hiccup): account typeahead, inline account cell (display/edit),
|
||||
;; the read-only auto rows, the editable manual rows, totals/balance.
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(if manual?
|
||||
(fc/with-field :credit
|
||||
(com/validated-field {:errors (fc/field-errors)}
|
||||
(com/money-input {:class "w-24 text-right font-mono tabular-nums"
|
||||
:name (fc/field-name)
|
||||
:value (fc/field-value)})))
|
||||
(when (= (fc/field-value (:ledger-mapped/ledger-side value))
|
||||
:ledger-side/credit)
|
||||
[:span.font-mono.tabular-nums.text-gray-900.text-sm.whitespace-nowrap
|
||||
(format "$%,.2f" (fc/field-value (:ledger-mapped/amount value)))])))
|
||||
(com/data-grid-cell {:class "align-top"}
|
||||
(when manual?
|
||||
(com/a-icon-button {"@click.prevent.stop" "$refs.p.remove()"} svg/x))))))
|
||||
(defn account-typeahead* [{:keys [name value client-id]}]
|
||||
(com/typeahead {:name name
|
||||
:id name
|
||||
:placeholder "Search..."
|
||||
:url (hu/url (bidi/path-for ssr-routes/only-routes :account-search)
|
||||
{:client-id client-id
|
||||
:purpose "invoice"})
|
||||
:value value
|
||||
:content-fn (fn [value]
|
||||
(:account/name (d-accounts/clientize (dc/pull (dc/db conn) d-accounts/default-read value)
|
||||
client-id)))}))
|
||||
|
||||
(defrecord MainStep [linear-wizard]
|
||||
mm/ModalWizardStep
|
||||
(step-name [_]
|
||||
"Main")
|
||||
(step-key [_]
|
||||
:main)
|
||||
(defn account-display-cell*
|
||||
"Account name + inline-edit pencil. The pencil swaps just this `.account-cell`
|
||||
(#account-cell, Rule 2) into the edit cell."
|
||||
[{:keys [index account-id client-id]}]
|
||||
(let [account-id (when (and account-id (not= account-id "")) (->db-id account-id))
|
||||
account-name (when account-id
|
||||
(:account/name (d-accounts/clientize (dc/pull (dc/db conn) d-accounts/default-read account-id)
|
||||
client-id)))]
|
||||
[:div.account-cell.flex.items-center.gap-2
|
||||
(com/hidden {:name (item-field-name index :ledger-mapped/account)
|
||||
:value (or account-id "")})
|
||||
(if account-name
|
||||
[:span.text-sm account-name]
|
||||
(com/pill {:color :red} "Missing acct"))
|
||||
(com/a-icon-button {:class "p-1"
|
||||
:hx-get (bidi/path-for ssr-routes/only-routes ::route/edit-item-account)
|
||||
:hx-target "closest .account-cell"
|
||||
:hx-swap "outerHTML"
|
||||
:hx-vals (hx/json {:item-index index
|
||||
:client-id client-id
|
||||
:current-account-id (or account-id "")})}
|
||||
svg/pencil)]))
|
||||
|
||||
(edit-path [_ _]
|
||||
[])
|
||||
(defn account-edit-cell*
|
||||
"The account typeahead + check (save) / cancel buttons. Each swaps just the
|
||||
`.account-cell` back to the display cell."
|
||||
[{:keys [index account-id client-id]}]
|
||||
[:div.account-cell.flex.flex-col.gap-2
|
||||
(account-typeahead* {:name (item-field-name index :ledger-mapped/account)
|
||||
:value account-id
|
||||
:client-id client-id})
|
||||
[:div.flex.gap-1
|
||||
(com/a-icon-button {:class "p-1"
|
||||
:hx-put (bidi/path-for ssr-routes/only-routes ::route/save-item-account)
|
||||
:hx-target "closest .account-cell"
|
||||
:hx-swap "outerHTML"
|
||||
:hx-include "closest .account-cell"
|
||||
:hx-vals (hx/json {:item-index index :client-id client-id})}
|
||||
svg/check)
|
||||
(com/a-icon-button {:class "p-1"
|
||||
:hx-get (bidi/path-for ssr-routes/only-routes ::route/cancel-item-account)
|
||||
:hx-target "closest .account-cell"
|
||||
:hx-swap "outerHTML"
|
||||
:hx-vals (hx/json {:item-index index
|
||||
:client-id client-id
|
||||
:current-account-id (or account-id "")})}
|
||||
svg/x)]])
|
||||
|
||||
(step-schema [_]
|
||||
(mut/select-keys (mm/form-schema linear-wizard) #{:db/id :sales-summary/items}))
|
||||
(defn- auto-item-row*
|
||||
"A read-only auto item in its Debits/Credits column: category + inline-editable account
|
||||
cell + the (read-only) amount. Posts db/id, category, and account."
|
||||
[index item client-id]
|
||||
(let [side (item-side item)
|
||||
amount (if (= side :debit) (:debit item) (:credit item))]
|
||||
[:div.flex.items-center.gap-2.text-sm
|
||||
(com/hidden {:name (item-field-name index :db/id) :value (:db/id item)})
|
||||
(com/hidden {:name (item-field-name index :sales-summary-item/category)
|
||||
:value (:sales-summary-item/category item)})
|
||||
[:span.text-gray-500.flex-1 (str (:sales-summary-item/category item))]
|
||||
(account-display-cell* {:index index
|
||||
:account-id (:ledger-mapped/account item)
|
||||
:client-id client-id})
|
||||
[:span.ml-auto.font-mono.tabular-nums.text-gray-900 (format "$%,.2f" (or amount 0.0))]]))
|
||||
|
||||
(render-step
|
||||
[this {:keys [multi-form-state] :as request}]
|
||||
(let [client-id (:db/id (:sales-summary/client (:snapshot multi-form-state)))
|
||||
items (:sales-summary/items (:step-params multi-form-state))
|
||||
sorted-items (sort-items items)
|
||||
indexed-items (map-indexed vector sorted-items)
|
||||
debit-items (filter #(= :ledger-side/debit (:ledger-mapped/ledger-side (second %))) indexed-items)
|
||||
credit-items (filter #(= :ledger-side/credit (:ledger-mapped/ledger-side (second %))) indexed-items)
|
||||
max-rows (max (count debit-items) (count credit-items))
|
||||
padded-debits (concat debit-items (repeat (- max-rows (count debit-items)) nil))
|
||||
padded-credits (concat credit-items (repeat (- max-rows (count credit-items)) nil))]
|
||||
(mm/default-render-step
|
||||
linear-wizard this
|
||||
:head [:div.p-2 "Edit Summary"]
|
||||
:body (mm/default-step-body
|
||||
{}
|
||||
[:div
|
||||
(fc/with-field :db/id
|
||||
(com/hidden {:name (fc/field-name)
|
||||
:value (fc/field-value)}))
|
||||
[:div.grid.grid-cols-2.gap-6
|
||||
[:div
|
||||
[:div.font-semibold.text-sm.mb-2 "Debits"]
|
||||
[:div.space-y-1
|
||||
(for [[actual-idx item] padded-debits]
|
||||
(if item
|
||||
(let [manual? (:sales-summary-item/manual? item)]
|
||||
(if manual?
|
||||
[:div.flex.items-center.gap-2.text-sm {:x-ref "p" :x-data (hx/json {})}
|
||||
(com/hidden {:name (str "step-params[sales-summary/items][" actual-idx "][db/id]")
|
||||
:value (:db/id item)})
|
||||
(com/hidden {:name (str "step-params[sales-summary/items][" actual-idx "][sales-summary-item/manual?]")
|
||||
:value "true"})
|
||||
(fc/start-form-with-prefix [(str "step-params[sales-summary/items][" actual-idx "]")]
|
||||
item []
|
||||
(fc/with-field :sales-summary-item/category
|
||||
(com/text-input {:placeholder "Category"
|
||||
:name (fc/field-name)
|
||||
:value (fc/field-value)
|
||||
:class "w-32 text-sm"})))
|
||||
(account-typeahead* {:name (str "step-params[sales-summary/items][" actual-idx "][ledger-mapped/account]")
|
||||
:value (:ledger-mapped/account item)
|
||||
:client-id client-id})
|
||||
(fc/start-form-with-prefix [(str "step-params[sales-summary/items][" actual-idx "]")]
|
||||
item []
|
||||
(fc/with-field :debit
|
||||
(com/money-input {:class "w-24 text-right font-mono tabular-nums"
|
||||
:name (fc/field-name)
|
||||
:value (fc/field-value)})))
|
||||
(com/a-icon-button {"@click.prevent.stop" "$refs.p.remove()"} svg/x)]
|
||||
[:div.flex.items-center.gap-2.text-sm
|
||||
(com/hidden {:name (str "step-params[sales-summary/items][" actual-idx "][db/id]")
|
||||
:value (:db/id item)})
|
||||
(com/hidden {:name (str "step-params[sales-summary/items][" actual-idx "][sales-summary-item/category]")
|
||||
:value (:sales-summary-item/category item)})
|
||||
[:span.text-gray-500 (truncate (:sales-summary-item/category item) 30)]
|
||||
(account-display-cell {:item (assoc item :item-index actual-idx)
|
||||
:field-name-prefix (str "step-params[sales-summary/items][" actual-idx "]")
|
||||
:client-id client-id})
|
||||
[:span.ml-auto.font-mono.tabular-nums.text-gray-900 (format "$%,.2f" (:ledger-mapped/amount item))]]))
|
||||
[:div.h-6]))]
|
||||
[:div.mt-2.border-t.pt-1
|
||||
(summary-total-display request)
|
||||
(unbalanced-display request)]]
|
||||
[:div
|
||||
[:div.font-semibold.text-sm.mb-2 "Credits"]
|
||||
[:div.space-y-1
|
||||
(for [[actual-idx item] padded-credits]
|
||||
(if item
|
||||
(let [manual? (:sales-summary-item/manual? item)]
|
||||
(if manual?
|
||||
[:div.flex.items-center.gap-2.text-sm {:x-ref "p" :x-data (hx/json {})}
|
||||
(com/hidden {:name (str "step-params[sales-summary/items][" actual-idx "][db/id]")
|
||||
:value (:db/id item)})
|
||||
(com/hidden {:name (str "step-params[sales-summary/items][" actual-idx "][sales-summary-item/manual?]")
|
||||
:value "true"})
|
||||
(fc/start-form-with-prefix [(str "step-params[sales-summary/items][" actual-idx "]")]
|
||||
item []
|
||||
(fc/with-field :sales-summary-item/category
|
||||
(com/text-input {:placeholder "Category"
|
||||
:name (fc/field-name)
|
||||
:value (fc/field-value)
|
||||
:class "w-32 text-sm"})))
|
||||
(account-typeahead* {:name (str "step-params[sales-summary/items][" actual-idx "][ledger-mapped/account]")
|
||||
:value (:ledger-mapped/account item)
|
||||
:client-id client-id})
|
||||
(fc/start-form-with-prefix [(str "step-params[sales-summary/items][" actual-idx "]")]
|
||||
item []
|
||||
(fc/with-field :credit
|
||||
(com/money-input {:class "w-24 text-right font-mono tabular-nums"
|
||||
:name (fc/field-name)
|
||||
:value (fc/field-value)})))
|
||||
(com/a-icon-button {"@click.prevent.stop" "$refs.p.remove()"} svg/x)]
|
||||
[:div.flex.items-center.gap-2.text-sm
|
||||
(com/hidden {:name (str "step-params[sales-summary/items][" actual-idx "][db/id]")
|
||||
:value (:db/id item)})
|
||||
(com/hidden {:name (str "step-params[sales-summary/items][" actual-idx "][sales-summary-item/category]")
|
||||
:value (:sales-summary-item/category item)})
|
||||
[:span.text-gray-500 (truncate (:sales-summary-item/category item) 30)]
|
||||
(account-display-cell {:item (assoc item :item-index actual-idx)
|
||||
:field-name-prefix (str "step-params[sales-summary/items][" actual-idx "]")
|
||||
:client-id client-id})
|
||||
[:span.ml-auto.font-mono.tabular-nums.text-gray-900 (format "$%,.2f" (:ledger-mapped/amount item))]]))
|
||||
[:div.h-6]))]
|
||||
[:div.mt-2.border-t.pt-1
|
||||
(summary-total-display request)
|
||||
(unbalanced-display request)]]]
|
||||
[:div.mt-4.border-t.pt-2
|
||||
(fc/with-field :sales-summary/items
|
||||
(com/data-grid-new-row {:colspan 2
|
||||
:hx-get (bidi/path-for ssr-routes/only-routes ::route/new-summary-item)
|
||||
:row-offset 0
|
||||
:index (count (fc/field-value))
|
||||
:tr-params {:hx-vals (hx/json {:client-id client-id})}}
|
||||
"New Summary Item"))]])
|
||||
(defn- manual-amount-input* [index field item]
|
||||
(com/money-input {:name (item-field-name index field)
|
||||
:value (get item field)
|
||||
:class "w-24 text-right font-mono tabular-nums"
|
||||
:placeholder (str/capitalize (clojure.core/name field))
|
||||
:hx-post (bidi/path-for ssr-routes/only-routes ::route/form-changed)
|
||||
:hx-target "#summary-totals"
|
||||
:hx-select "#summary-totals"
|
||||
:hx-swap "outerHTML"
|
||||
:hx-trigger "keyup changed delay:300ms"
|
||||
:hx-include "closest form"}))
|
||||
|
||||
:footer
|
||||
(mm/default-step-footer linear-wizard this :validation-route ::route/edit-wizard-navigate)
|
||||
:validation-route ::route/edit-wizard-navigate
|
||||
:width-height-class "lg:w-[900px] lg:h-[600px]"))))
|
||||
(defn- manual-item-row*
|
||||
"An editable manual item: category + account typeahead + debit + credit money inputs +
|
||||
remove. The amount inputs swap the totals block (Rule 4); remove swaps the whole form."
|
||||
[index item client-id]
|
||||
[:div.manual-item-row.flex.items-center.gap-2
|
||||
(com/hidden {:name (item-field-name index :db/id) :value (:db/id item)})
|
||||
(com/hidden {:name (item-field-name index :sales-summary-item/manual?) :value "true"})
|
||||
(com/validated-field
|
||||
{:errors (item-field-errors index :sales-summary-item/category) :class "flex-1"}
|
||||
(com/text-input {:name (item-field-name index :sales-summary-item/category)
|
||||
:value (:sales-summary-item/category item)
|
||||
:placeholder "Category/Explanation"}))
|
||||
(com/validated-field
|
||||
{:errors (item-field-errors index :ledger-mapped/account)}
|
||||
(account-typeahead* {:name (item-field-name index :ledger-mapped/account)
|
||||
:value (:ledger-mapped/account item)
|
||||
:client-id client-id}))
|
||||
(manual-amount-input* index :debit item)
|
||||
(manual-amount-input* index :credit item)
|
||||
(com/a-icon-button {:class "p-1 account-remove-action"
|
||||
:hx-post (bidi/path-for ssr-routes/only-routes ::route/form-changed)
|
||||
:hx-vals (hx/json {:op "remove-item" :row-index index})
|
||||
:hx-target "#summary-edit-form"
|
||||
:hx-select "#summary-edit-form"
|
||||
:hx-swap "outerHTML"
|
||||
:hx-include "closest form"}
|
||||
svg/x)])
|
||||
|
||||
(defn- totals*
|
||||
"Swappable totals + balance block (#summary-totals). Fixes the previously-dead total /
|
||||
balance display: shows the running debit/credit totals and a Balanced / Unbalanced
|
||||
indicator."
|
||||
[items]
|
||||
(let [td (sum-debits items)
|
||||
tc (sum-credits items)
|
||||
balanced? (dollars= td tc)
|
||||
delta (- td tc)]
|
||||
[:div.border-t.pt-2.mt-2.space-y-1
|
||||
[:div.flex.justify-between.text-sm.font-semibold
|
||||
[:span "Total"]
|
||||
[:div.flex.gap-8
|
||||
[:span.font-mono (format "$%,.2f" td)]
|
||||
[:span.font-mono (format "$%,.2f" tc)]]]
|
||||
(if balanced?
|
||||
[:div.text-sm.text-emerald-700.font-semibold "Balanced"]
|
||||
[:div.text-sm.text-red-600.font-semibold.flex.justify-between
|
||||
[:span "Unbalanced"]
|
||||
[:span.font-mono (str (format "$%,.2f" (Math/abs delta)) " "
|
||||
(if (pos? delta) "Debit over" "Credit over"))]])]))
|
||||
|
||||
(defn- new-item-button* []
|
||||
(com/a-button {:hx-post (bidi/path-for ssr-routes/only-routes ::route/form-changed)
|
||||
:hx-vals (hx/json {:op "new-item"})
|
||||
:hx-target "#summary-edit-form"
|
||||
:hx-select "#summary-edit-form"
|
||||
:hx-swap "outerHTML"
|
||||
:hx-include "closest form"
|
||||
:color :secondary}
|
||||
"New Summary Item"))
|
||||
|
||||
(defn- footer* [request]
|
||||
[:div.flex.justify-end
|
||||
[:div.flex.items-baseline.gap-x-4
|
||||
(com/form-errors {:errors (seq (:errors (:form-errors request)))})
|
||||
(com/button {:color :primary :x-ref "next" :class "w-32" :type "submit"} "Save")]])
|
||||
|
||||
(defn render-form
|
||||
"Renders the whole plain sales-summary edit form (no wizard). Binds *errors* so the
|
||||
field-level lookups (item-field-errors) resolve. Reuses the edit modal chrome."
|
||||
[request]
|
||||
(binding [*errors* (or (:form-errors request) {})]
|
||||
(let [{tx-id :db/id client :sales-summary/client items :sales-summary/items} (:edit-state request)
|
||||
client-id (:db/id client)
|
||||
indexed (map-indexed vector items)
|
||||
auto (remove (fn [[_ it]] (:sales-summary-item/manual? it)) indexed)
|
||||
manual (filter (fn [[_ it]] (:sales-summary-item/manual? it)) indexed)
|
||||
debit-rows (->> auto
|
||||
(filter (fn [[_ it]] (= :debit (item-side it))))
|
||||
(map (fn [[i it]] (auto-item-row* i it client-id))))
|
||||
credit-rows (->> auto
|
||||
(filter (fn [[_ it]] (= :credit (item-side it))))
|
||||
(map (fn [[i it]] (auto-item-row* i it client-id))))
|
||||
manual-rows (->> manual
|
||||
(map (fn [[i it]] (manual-item-row* i it client-id))))]
|
||||
[:form (merge {:id "summary-edit-form"}
|
||||
{:hx-ext "response-targets"
|
||||
:hx-swap "outerHTML"
|
||||
:hx-target-400 "#form-errors .error-content"
|
||||
:hx-trigger "submit"
|
||||
:hx-target "this"
|
||||
:hx-put (bidi/path-for ssr-routes/only-routes ::route/edit-wizard-submit)})
|
||||
(com/hidden {:name "db/id" :value tx-id})
|
||||
(com/modal
|
||||
{:id "wizardmodal"}
|
||||
(com/single-modal-card
|
||||
{}
|
||||
[:div.p-2 "Edit Summary"]
|
||||
[:div.space-y-4.p-2
|
||||
[:div.grid.grid-cols-2.gap-6
|
||||
[:div
|
||||
[:div.font-semibold.text-sm.mb-2 "Debits"]
|
||||
[:div.space-y-1 debit-rows]]
|
||||
[:div
|
||||
[:div.font-semibold.text-sm.mb-2 "Credits"]
|
||||
[:div.space-y-1 credit-rows]]]
|
||||
[:div {:id "summary-totals"} (totals* items)]
|
||||
[:div.mt-4.border-t.pt-3
|
||||
[:div.font-semibold.text-sm.mb-2 "Manual Items"]
|
||||
[:div.space-y-2 {:id "manual-items"} manual-rows]
|
||||
[:div.mt-2.flex.justify-center (new-item-button*)]]]
|
||||
(footer* request)))])))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; State: derive the flat edit-state from the entity overlaid with the posted
|
||||
;; form (replaces MultiStepFormState + the EDN snapshot round-trip).
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(defn entity->edit-state
|
||||
"The persisted sales summary, shaped like the form's state: each item gets a :credit or
|
||||
:debit field derived from its ledger-side/amount (what initial-edit-wizard-state did)."
|
||||
[tx-id]
|
||||
(let [e (dc/pull (dc/db conn) default-read tx-id)
|
||||
items (->> (:sales-summary/items e)
|
||||
sort-items
|
||||
(mapv (fn [x]
|
||||
(if (= :ledger-side/debit (:ledger-mapped/ledger-side x))
|
||||
(assoc x :debit (:ledger-mapped/amount x))
|
||||
(assoc x :credit (:ledger-mapped/amount x))))))]
|
||||
{:db/id (:db/id e)
|
||||
:sales-summary/client (:sales-summary/client e)
|
||||
:sales-summary/items items}))
|
||||
|
||||
(defn- merge-items
|
||||
"Overlay the posted items onto the persisted items by :db/id, so read-only fields the
|
||||
form doesn't post (ledger-side, amount, the credit/debit shaping for auto items)
|
||||
survive while edited fields (category, account, manual credit/debit) win. New manual
|
||||
rows (temp db/id) have no persisted match and ride through as-is."
|
||||
[entity-items posted-items]
|
||||
(let [by-id (into {} (map (juxt :db/id identity)) entity-items)]
|
||||
(mapv (fn [pi] (merge (get by-id (:db/id pi)) pi)) posted-items)))
|
||||
|
||||
(defn wrap-decode
|
||||
"Parses the posted (nested) form params and decodes them straight into edit-schema --
|
||||
no step-params[...] prefix. Strips to the editable top-level keys."
|
||||
[handler]
|
||||
(-> (fn [request]
|
||||
(let [decoded (mc/decode edit-schema (:form-params request) main-transformer)
|
||||
decoded (if (map? decoded) (select-keys decoded [:db/id :sales-summary/items]) {})]
|
||||
(handler (assoc request :posted decoded))))
|
||||
(wrap-nested-form-params)))
|
||||
|
||||
(defn wrap-derive-state
|
||||
"Builds :edit-state from the entity (db/id hidden, or the route on initial open) overlaid
|
||||
with the live posted items -- no serialized snapshot. db/id + client always come from
|
||||
the entity; items are the merged posted items when present, else the entity's."
|
||||
[handler]
|
||||
(fn [request]
|
||||
(let [tx-id (->db-id (or (some-> request :form-params (get "db/id"))
|
||||
(-> request :route-params :db/id)))
|
||||
base (entity->edit-state tx-id)
|
||||
posted (:posted request)
|
||||
items (if (contains? posted :sales-summary/items)
|
||||
(merge-items (:sales-summary/items base) (:sales-summary/items posted))
|
||||
(:sales-summary/items base))]
|
||||
(handler (assoc request :edit-state (assoc base :sales-summary/items items))))))
|
||||
|
||||
(defn attach-ledger [i]
|
||||
(cond-> i
|
||||
@@ -645,142 +590,129 @@
|
||||
:ledger-mapped/amount (:credit i))
|
||||
(:debit i) (assoc :ledger-mapped/ledger-side :ledger-side/debit
|
||||
:ledger-mapped/amount (:debit i))
|
||||
true (dissoc :credit :debit)
|
||||
true (dissoc :credit :debit :new? :item-index)
|
||||
true (assoc :sales-summary-item/manual? true)))
|
||||
|
||||
(defrecord EditWizard [_ current-step]
|
||||
mm/LinearModalWizard
|
||||
(hydrate-from-request
|
||||
[this request]
|
||||
this)
|
||||
(navigate [this step-key]
|
||||
(assoc this :current-step step-key))
|
||||
(get-current-step
|
||||
[this]
|
||||
(mm/get-step this :main))
|
||||
(render-wizard [this {:keys [multi-form-state] :as request}]
|
||||
(mm/default-render-wizard
|
||||
this request
|
||||
:form-params
|
||||
(-> mm/default-form-props
|
||||
(assoc :hx-put
|
||||
(str (bidi/path-for ssr-routes/only-routes ::route/edit-wizard-submit))))
|
||||
:render-timeline? false))
|
||||
(steps [_]
|
||||
[:main])
|
||||
(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]
|
||||
(->MainStep this)))
|
||||
(form-schema [_]
|
||||
edit-schema)
|
||||
(submit [this {:keys [multi-form-state request-method identity] :as request}]
|
||||
(let [result (:snapshot multi-form-state)
|
||||
transaction [:upsert-sales-summary {:db/id (:db/id result)
|
||||
:sales-summary/items (map
|
||||
(fn [i]
|
||||
(if (:sales-summary-item/manual? i)
|
||||
(attach-ledger i)
|
||||
{:db/id (:db/id i)
|
||||
:ledger-mapped/account (:ledger-mapped/account i)}))
|
||||
(:sales-summary/items result))}]]
|
||||
@(dc/transact conn [transaction])
|
||||
(html-response
|
||||
(row* identity (dc/pull (dc/db conn) default-read (:db/id result))
|
||||
{:flash? true
|
||||
:request request})
|
||||
:headers (cond-> {"hx-trigger" "modalclose"
|
||||
"hx-retarget" (format "#entity-table tr[data-id=\"%d\"]" (:db/id result))
|
||||
"hx-reswap" "outerHTML"})))))
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; form-changed ops (whole-form re-render): add / remove a manual item. A bare
|
||||
;; re-render (no op) refreshes the totals block (manual amount edits).
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(def edit-wizard (->EditWizard nil nil))
|
||||
(defn apply-new-item [request]
|
||||
(let [items (vec (:sales-summary/items (:edit-state request)))
|
||||
new-item {:db/id (str (java.util.UUID/randomUUID))
|
||||
:new? true
|
||||
:sales-summary-item/manual? true
|
||||
:sales-summary-item/category ""}]
|
||||
(assoc-in request [:edit-state :sales-summary/items] (conj items new-item))))
|
||||
|
||||
(defn initial-edit-wizard-state [request]
|
||||
(let [entity (dc/pull (dc/db conn) default-read (:db/id (:route-params request)))
|
||||
entity (select-keys entity (mut/keys edit-schema))
|
||||
entity (update entity :sales-summary/items (comp #(map (fn [x]
|
||||
(if (= :ledger-side/debit (:ledger-mapped/ledger-side x))
|
||||
(assoc x :debit (:ledger-mapped/amount x))
|
||||
(assoc x :credit (:ledger-mapped/amount x))))
|
||||
%) sort-items))]
|
||||
(defn apply-remove-item [request]
|
||||
(let [row-index (some-> request :form-params (get "row-index") Integer/parseInt)
|
||||
items (vec (:sales-summary/items (:edit-state request)))
|
||||
updated (if (and row-index (< row-index (count items)))
|
||||
(vec (concat (subvec items 0 row-index)
|
||||
(subvec items (inc row-index))))
|
||||
items)]
|
||||
(assoc-in request [:edit-state :sales-summary/items] updated)))
|
||||
|
||||
(mm/->MultiStepFormState entity [] entity)))
|
||||
(defn form-changed-handler
|
||||
"Single whole-form re-render endpoint. Dispatches on `op` (add/remove a manual item);
|
||||
a missing op (a manual amount keyup) just re-renders (hx-select picks #summary-totals)."
|
||||
[request]
|
||||
(let [op (get-in request [:form-params "op"])
|
||||
request' (case op
|
||||
"new-item" (apply-new-item request)
|
||||
"remove-item" (apply-remove-item request)
|
||||
request)]
|
||||
(html-response (render-form request'))))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Inline account editor (targeted .account-cell swaps -- a distinct click-to-edit
|
||||
;; feature, kept as its own three small stateless routes).
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(defn edit-item-account [request]
|
||||
(let [{:keys [item-index client-id current-account-id]} (:query-params request)
|
||||
item-index (if (string? item-index) (Integer/parseInt item-index) item-index)
|
||||
field-name-prefix (str "step-params[sales-summary/items][" item-index "]")
|
||||
current-account-id (when (and current-account-id (not= current-account-id ""))
|
||||
(if (string? current-account-id)
|
||||
(Long/parseLong current-account-id)
|
||||
current-account-id))
|
||||
client-id (if (string? client-id) (Long/parseLong client-id) client-id)]
|
||||
idx (if (string? item-index) (Integer/parseInt item-index) item-index)
|
||||
account-id (when (and current-account-id (not= current-account-id "")) (->db-id current-account-id))]
|
||||
(html-response
|
||||
(account-edit-cell {:field-name-prefix field-name-prefix
|
||||
:client-id client-id
|
||||
:current-account-id current-account-id}))))
|
||||
(account-edit-cell* {:index idx :account-id account-id :client-id (->db-id client-id)}))))
|
||||
|
||||
(defn save-item-account [request]
|
||||
(let [field-name-prefix (get-in request [:params "field-name-prefix"])
|
||||
client-id (get-in request [:params "client-id"])
|
||||
account-input-name (str field-name-prefix "[ledger-mapped/account]")
|
||||
account-id-str (get-in request [:form-params account-input-name])
|
||||
account-id (when (and account-id-str (not= account-id-str ""))
|
||||
(Long/parseLong account-id-str))
|
||||
item {:ledger-mapped/account account-id
|
||||
:item-index (second (re-find #"\[(\d+)\]" (or field-name-prefix "")))}
|
||||
client-id (if (string? client-id) (Long/parseLong client-id) client-id)]
|
||||
(let [item-index (get-in request [:params "item-index"])
|
||||
idx (if (string? item-index) (Integer/parseInt item-index) item-index)
|
||||
client-id (->db-id (get-in request [:params "client-id"]))
|
||||
account-id-str (get-in request [:form-params (item-field-name idx :ledger-mapped/account)])
|
||||
account-id (when (and account-id-str (not= account-id-str "")) (->db-id account-id-str))]
|
||||
(html-response
|
||||
(account-display-cell {:item item
|
||||
:field-name-prefix field-name-prefix
|
||||
:client-id client-id}))))
|
||||
(account-display-cell* {:index idx :account-id account-id :client-id client-id}))))
|
||||
|
||||
(defn cancel-item-account [request]
|
||||
(let [{:keys [field-name-prefix client-id current-account-id]} (:query-params request)
|
||||
account-id (when (and current-account-id (not= current-account-id ""))
|
||||
(if (string? current-account-id)
|
||||
(Long/parseLong current-account-id)
|
||||
current-account-id))
|
||||
item {:ledger-mapped/account account-id
|
||||
:item-index (second (re-find #"\[(\d+)\]" (or field-name-prefix "")))}
|
||||
client-id (if (string? client-id) (Long/parseLong client-id) client-id)]
|
||||
(let [{:keys [item-index client-id current-account-id]} (:query-params request)
|
||||
idx (if (string? item-index) (Integer/parseInt item-index) item-index)
|
||||
account-id (when (and current-account-id (not= current-account-id "")) (->db-id current-account-id))]
|
||||
(html-response
|
||||
(account-display-cell {:item item
|
||||
:field-name-prefix field-name-prefix
|
||||
:client-id client-id}))))
|
||||
(account-display-cell* {:index idx :account-id account-id :client-id (->db-id client-id)}))))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Open + submit
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(defn open-handler [request]
|
||||
(modal-response
|
||||
[:div {:id "transitioner" :class "flex-1"}
|
||||
(render-form request)]))
|
||||
|
||||
(defn- render-form-response [request]
|
||||
(html-response (render-form request)
|
||||
:headers {"HX-reswap" "outerHTML"}))
|
||||
|
||||
(defn submit
|
||||
"Validates the posted edit-state against edit-schema (field errors via wrap-form-4xx-2),
|
||||
then upserts the sales summary: manual items attach-ledger (credit/debit -> ledger
|
||||
side+amount), auto items update only their account."
|
||||
[request]
|
||||
(let [{tx-id :db/id items :sales-summary/items :as edit-state} (:edit-state request)]
|
||||
(assert-schema edit-schema edit-state)
|
||||
(let [transaction [:upsert-sales-summary
|
||||
{:db/id tx-id
|
||||
:sales-summary/items (map (fn [i]
|
||||
(if (:sales-summary-item/manual? i)
|
||||
(attach-ledger i)
|
||||
{:db/id (:db/id i)
|
||||
:ledger-mapped/account (:ledger-mapped/account i)}))
|
||||
items)}]]
|
||||
@(dc/transact conn [transaction])
|
||||
(html-response
|
||||
(row* (:identity request) (dc/pull (dc/db conn) default-read tx-id)
|
||||
{:flash? true
|
||||
:request request})
|
||||
:headers {"hx-trigger" "modalclose"
|
||||
"hx-retarget" (format "#entity-table tr[data-id=\"%d\"]" tx-id)
|
||||
"hx-reswap" "outerHTML"}))))
|
||||
|
||||
(def key->handler
|
||||
(apply-middleware-to-all-handlers
|
||||
(->>
|
||||
{::route/page (helper/page-route grid-page)
|
||||
::route/table (helper/table-route grid-page)
|
||||
::route/edit-wizard (-> mm/open-wizard-handler
|
||||
(mm/wrap-wizard edit-wizard)
|
||||
(mm/wrap-init-multi-form-state initial-edit-wizard-state)
|
||||
(wrap-schema-enforce :route-schema [:map [:db/id entity-id]]))
|
||||
::route/edit-wizard-navigate (-> mm/next-handler
|
||||
(mm/wrap-wizard edit-wizard)
|
||||
(mm/wrap-decode-multi-form-state))
|
||||
::route/new-summary-item (-> (add-new-entity-handler [:step-params :sales-summary/items]
|
||||
(fn render [cursor request]
|
||||
(sales-summary-item-row*
|
||||
{:value cursor
|
||||
:client-id (:client-id (:query-params request))}))
|
||||
(fn build-new-row [base _]
|
||||
(assoc base :sales-summary-item/manual? true)))
|
||||
{::route/page (helper/page-route grid-page)
|
||||
::route/table (helper/table-route grid-page)
|
||||
::route/edit-wizard (-> open-handler
|
||||
(wrap-derive-state)
|
||||
(wrap-decode)
|
||||
(wrap-schema-enforce :route-schema [:map [:db/id entity-id]]))
|
||||
::route/form-changed (-> form-changed-handler
|
||||
(wrap-derive-state)
|
||||
(wrap-decode))
|
||||
::route/edit-item-account (-> edit-item-account
|
||||
(wrap-schema-enforce :query-schema [:map
|
||||
[:client-id {:optional true}
|
||||
[:maybe entity-id]]]))
|
||||
::route/edit-item-account (-> edit-item-account
|
||||
(wrap-schema-enforce :query-schema [:map
|
||||
[:item-index nat-int?]
|
||||
[:client-id {:optional true} [:maybe entity-id]]
|
||||
[:current-account-id {:optional true} [:maybe :string]]]))
|
||||
::route/save-item-account save-item-account
|
||||
::route/cancel-item-account cancel-item-account
|
||||
::route/edit-wizard-submit (-> mm/submit-handler
|
||||
(mm/wrap-wizard edit-wizard)
|
||||
(mm/wrap-decode-multi-form-state))})
|
||||
[:item-index nat-int?]
|
||||
[:client-id {:optional true} [:maybe entity-id]]
|
||||
[:current-account-id {:optional true} [:maybe :string]]]))
|
||||
::route/save-item-account save-item-account
|
||||
::route/cancel-item-account cancel-item-account
|
||||
::route/edit-wizard-submit (-> submit
|
||||
(wrap-form-4xx-2 render-form-response)
|
||||
(wrap-derive-state)
|
||||
(wrap-decode))}
|
||||
(fn [h]
|
||||
(-> h
|
||||
(wrap-copy-qp-pqp)
|
||||
|
||||
@@ -10,13 +10,11 @@
|
||||
[auto-ap.routes.utils :refer [wrap-client-redirect-unauthenticated]]
|
||||
[auto-ap.rule-matching :as rm]
|
||||
[auto-ap.ssr-routes :as ssr-routes]
|
||||
[auto-ap.ssr.common-handlers :refer [add-new-entity-handler]]
|
||||
[auto-ap.ssr.components :as com]
|
||||
[auto-ap.ssr.components.multi-modal :as mm :refer [wrap-wizard]]
|
||||
[auto-ap.ssr.form-cursor :as fc]
|
||||
[auto-ap.ssr.grid-page-helper :refer [wrap-apply-sort]]
|
||||
[auto-ap.ssr.hx :as hx]
|
||||
[auto-ap.ssr.ledger :refer [wrap-ensure-bank-account-belongs]]
|
||||
[auto-ap.ssr.nested-form-params :refer [wrap-nested-form-params]]
|
||||
[auto-ap.ssr.svg :as svg]
|
||||
[auto-ap.ssr.transaction.common :refer [grid-page query-schema
|
||||
selected->ids
|
||||
@@ -24,72 +22,57 @@
|
||||
[auto-ap.ssr.transaction.edit :as edit :refer [account-typeahead*
|
||||
location-select*]]
|
||||
[auto-ap.ssr.utils
|
||||
:refer [apply-middleware-to-all-handlers entity-id
|
||||
form-validation-error html-response percentage
|
||||
ref->enum-schema wrap-merge-prior-hx wrap-schema-enforce]]
|
||||
:refer [->db-id apply-middleware-to-all-handlers assert-schema entity-id
|
||||
form-validation-error html-response main-transformer modal-response
|
||||
path->name2 percentage ref->enum-schema wrap-form-4xx-2
|
||||
wrap-merge-prior-hx wrap-schema-enforce]]
|
||||
[bidi.bidi :as bidi]
|
||||
[clojure.string :as str]
|
||||
[datomic.api :as dc]
|
||||
[iol-ion.query :refer [dollars=]]
|
||||
[iol-ion.tx :refer [random-tempid]]
|
||||
[malli.core :as mc]))
|
||||
|
||||
(defn transaction-account-row* [{:keys [value client-id]}]
|
||||
(com/data-grid-row
|
||||
(-> {:x-data (hx/json {:show (boolean (not (fc/field-value (:new? value))))
|
||||
:accountId (fc/field-value (: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 :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 :location
|
||||
(com/data-grid-cell
|
||||
{}
|
||||
(com/validated-field
|
||||
{:errors (fc/field-errors)
|
||||
:x-hx-val:account-id "accountId"
|
||||
:hx-vals (hx/json (cond-> {:name (fc/field-name)}
|
||||
client-id (assoc :client-id client-id)))
|
||||
:x-dispatch:changed "accountId"
|
||||
:hx-trigger "changed"
|
||||
:hx-get (bidi/path-for ssr-routes/only-routes ::route/location-select)
|
||||
:hx-target "find *"
|
||||
:hx-swap "outerHTML"}
|
||||
(location-select* {:name (fc/field-name)
|
||||
:account-location (let [account-id (:account @value)]
|
||||
(when (nat-int? account-id)
|
||||
(:account/location (dc/pull (dc/db conn) '[:account/location] account-id))))
|
||||
:client-locations (pull-attr (dc/db conn) :client/locations client-id)
|
||||
:value (fc/field-value)}))))
|
||||
(fc/with-field :percentage
|
||||
(com/data-grid-cell
|
||||
{}
|
||||
(com/validated-field
|
||||
{:errors (fc/field-errors)}
|
||||
(com/money-input {:name (fc/field-name)
|
||||
:class "w-16"
|
||||
:value (some-> (fc/field-value)
|
||||
(* 100)
|
||||
(long))}))))
|
||||
(com/data-grid-cell {:class "align-top"}
|
||||
(com/a-icon-button {"@click.prevent.stop" "show=false; setTimeout(() => $refs.p.remove(), 500)"} svg/x))))
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Field-name / error helpers (no step-params[...] prefix -- posted fields
|
||||
;; decode straight into bulk-code-schema, mirroring transaction/edit.clj).
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(defn initial-bulk-edit-state [request]
|
||||
(mm/->MultiStepFormState {:search-params (:query-params request)
|
||||
:accounts []}
|
||||
[]
|
||||
{:search-params (:query-params request)
|
||||
:accounts []}))
|
||||
(defn- account-field-name [index field]
|
||||
(path->name2 :accounts index field))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Schema + decode
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(def bulk-code-schema
|
||||
(mc/schema [:map
|
||||
[:vendor {:optional true} [:maybe entity-id]]
|
||||
[:approval-status {:optional true} [:maybe (ref->enum-schema "transaction-approval-status")]]
|
||||
[:ids {:optional true} [:maybe [:vector {:coerce? true} entity-id]]]
|
||||
[:accounts {:optional true}
|
||||
[:maybe
|
||||
[:vector {:coerce? true}
|
||||
[:map
|
||||
[:db/id {:optional true} [:maybe :string]]
|
||||
[:account entity-id]
|
||||
[:location [:string {:min 1 :error/message "required"}]]
|
||||
[:percentage percentage]]]]]]))
|
||||
|
||||
(def ^:private bulk-code-form-keys
|
||||
"Editable top-level keys (vendor/status/accounts). The transaction selection (:ids)
|
||||
is non-editable -- it is threaded separately by wrap-bulk-state."
|
||||
[:vendor :approval-status :accounts])
|
||||
|
||||
(def ^:private approval-status-options
|
||||
"[value label] choices for the status <select>. Data, not markup -- the shared select
|
||||
partial renders the <option>s from this (Django/Jinja widget convention: option labels
|
||||
live in the data layer, never literal in the page template)."
|
||||
[["" "No Change"]
|
||||
["approved" "Approved"]
|
||||
["unapproved" "Unapproved"]
|
||||
["suppressed" "Suppressed"]
|
||||
["requires-feedback" "Client Review"]])
|
||||
|
||||
(defn all-ids-not-locked
|
||||
"Filters transaction IDs to only those that aren't locked (client locked date earlier than transaction date)"
|
||||
@@ -105,16 +88,258 @@
|
||||
(dc/db conn))
|
||||
(map first)))
|
||||
|
||||
(def bulk-code-schema
|
||||
(mc/schema [:map
|
||||
[:vendor {:optional true} [:maybe entity-id]]
|
||||
[:approval-status {:optional true} [:maybe (ref->enum-schema "transaction-approval-status")]]
|
||||
[:accounts {:optional true}
|
||||
[:maybe
|
||||
[:vector {:coerce? true}
|
||||
[:map [:account entity-id]
|
||||
[:location [:string {:min 1 :error/message "required"}]]
|
||||
[:percentage percentage]]]]]]))
|
||||
(defn wrap-bulk-state
|
||||
"Replaces the wizard's MultiStepFormState/snapshot round-trip. Parses the posted
|
||||
(nested) form params, decodes them straight into bulk-code-schema, and resolves the
|
||||
target transaction id set. On open (GET) the selection comes from the grid's
|
||||
query-params (selected / all-selected + filters); on every post the concrete
|
||||
(not-locked) id list rides back in hidden ids[] fields, so no EDN snapshot / filter
|
||||
round-trip is needed -- and we code exactly the transactions the user saw."
|
||||
[handler]
|
||||
(-> (fn [request]
|
||||
(let [parsed (:form-params request)
|
||||
decoded (mc/decode bulk-code-schema parsed main-transformer)
|
||||
decoded (if (map? decoded) decoded {})
|
||||
posted-ids (some->> (:ids decoded) (keep ->db-id) vec)
|
||||
ids (if (seq posted-ids)
|
||||
posted-ids
|
||||
(vec (all-ids-not-locked (selected->ids request (:query-params request)))))]
|
||||
(handler (assoc request :bulk-state (assoc (select-keys decoded bulk-code-form-keys) :ids ids)))))
|
||||
(wrap-nested-form-params)))
|
||||
|
||||
(defn- single-client-id
|
||||
"Returns the client ID if the user has access to exactly one client, nil otherwise."
|
||||
[request]
|
||||
(when (= 1 (count (:clients request)))
|
||||
(-> request :clients first :db/id)))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Render (Hiccup). render-form builds the whole form from com/* components and the
|
||||
;; shared single-modal-card chrome; account-row* renders one expense-account row. No
|
||||
;; cursor, no wizard -- the flat :bulk-state drives everything and every interaction
|
||||
;; swaps the whole #bulk-code-form (whole-form swap doctrine).
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(defn- account-row*
|
||||
"One expense-account row (advanced grid) from a plain account map -- no cursor. The
|
||||
location cell swaps just itself (#account-location-<index>); the remove button swaps
|
||||
the whole #bulk-code-form. Built from com/* Hiccup components."
|
||||
[{:keys [value client-id index errors]}]
|
||||
(let [account-val (let [av (:account value)]
|
||||
(if (map? av) (:db/id av) av))
|
||||
changed-url (bidi/path-for ssr-routes/only-routes ::route/bulk-code-form-changed)]
|
||||
(com/data-grid-row
|
||||
(-> {:class "account-row"
|
||||
:id (str "account-row-" index)
|
||||
:x-data (hx/json {:show (boolean (not (:new? value)))
|
||||
:accountId account-val})
|
||||
:data-key "show"
|
||||
:x-ref "p"}
|
||||
hx/alpine-mount-then-appear)
|
||||
(com/hidden {:name (account-field-name index :db/id)
|
||||
:value (:db/id value)})
|
||||
(com/data-grid-cell
|
||||
{}
|
||||
(com/validated-field
|
||||
{:errors (get-in errors [:accounts index :account])}
|
||||
(account-typeahead* {:value account-val
|
||||
:client-id client-id
|
||||
:name (account-field-name index :account)
|
||||
:x-model "accountId"})))
|
||||
(com/data-grid-cell
|
||||
{:id (str "account-location-" index)}
|
||||
(com/validated-field
|
||||
(merge {:errors (get-in errors [:accounts index :location])}
|
||||
{:x-hx-val:account-id "accountId"
|
||||
:hx-vals (hx/json (cond-> {:name (account-field-name index :location)}
|
||||
client-id (assoc :client-id client-id)))
|
||||
:x-dispatch:changed "accountId"
|
||||
:hx-trigger "changed"
|
||||
:hx-post changed-url
|
||||
:hx-target (str "#account-location-" index)
|
||||
:hx-select (str "#account-location-" index)
|
||||
:hx-swap "outerHTML"
|
||||
:hx-include "closest form"})
|
||||
(location-select* {:name (account-field-name index :location)
|
||||
:account-location (:account/location (when (nat-int? account-val)
|
||||
(dc/pull (dc/db conn) '[:account/location] account-val)))
|
||||
:client-locations (pull-attr (dc/db conn) :client/locations client-id)
|
||||
:value (:location value)})))
|
||||
(com/data-grid-cell
|
||||
{}
|
||||
(com/validated-field
|
||||
{:errors (get-in errors [:accounts index :percentage])}
|
||||
(com/money-input {:name (account-field-name index :percentage)
|
||||
:class "w-16"
|
||||
:value (some-> (:percentage value) (* 100) long)})))
|
||||
(com/data-grid-cell
|
||||
{:class "align-top"}
|
||||
(com/a-icon-button {:hx-post changed-url
|
||||
:hx-vals (hx/json {:op "remove-account" :row-index (or index 0)})
|
||||
:hx-target "#bulk-code-form"
|
||||
:hx-select "#bulk-code-form"
|
||||
:hx-swap "outerHTML"
|
||||
:hx-include "closest form"
|
||||
:class "account-remove-action"}
|
||||
svg/x)))))
|
||||
|
||||
(defn render-form
|
||||
"The whole bulk-code form as Hiccup -- no wizard, no cursor. The resolved (not-locked)
|
||||
transaction id set rides in hidden ids[] fields so the selection survives
|
||||
form-changed / submit posts. The vendor change, add/remove row, and submit all swap the
|
||||
whole #bulk-code-form (whole-form swap doctrine)."
|
||||
[request]
|
||||
(let [bulk-state (:bulk-state request)
|
||||
errors (or (:form-errors request) {})
|
||||
client-id (single-client-id request)
|
||||
ids (:ids bulk-state)
|
||||
accounts (vec (:accounts bulk-state))
|
||||
vendor-val (:vendor bulk-state)
|
||||
status-val (some-> (:approval-status bulk-state) name)
|
||||
changed-url (bidi/path-for ssr-routes/only-routes ::route/bulk-code-form-changed)
|
||||
submit-url (bidi/path-for ssr-routes/only-routes ::route/bulk-code-submit)
|
||||
errors-str (when-let [e (seq (:errors errors))]
|
||||
(str/join ", " (filter string? e)))]
|
||||
[:form {:id "bulk-code-form"
|
||||
:hx-ext "response-targets"
|
||||
:hx-swap "outerHTML"
|
||||
:hx-target-400 "#form-errors .error-content"
|
||||
:hx-trigger "submit"
|
||||
:hx-target "this"
|
||||
:hx-post submit-url}
|
||||
(map-indexed (fn [i id] (com/hidden {:name (str "ids[" i "]") :value id})) ids)
|
||||
[:div {"@click.outside" "open=false" :id "bulkcodemodal"}
|
||||
(com/single-modal-card
|
||||
{}
|
||||
[:div.p-2 (str "Bulk editing " (count ids) " transactions")]
|
||||
[:div.space-y-4.p-4
|
||||
[:div.grid.grid-cols-2.gap-4
|
||||
[:div {:hx-trigger "change"
|
||||
:hx-post changed-url
|
||||
:hx-vals (hx/json {:op "vendor-changed"})
|
||||
:hx-target "#bulk-code-form"
|
||||
:hx-select "#bulk-code-form"
|
||||
:hx-swap "outerHTML"
|
||||
:hx-sync "this:replace"
|
||||
:hx-include "closest form"}
|
||||
(com/validated-field
|
||||
{:label "Vendor" :errors (:vendor errors)}
|
||||
[:div.w-96
|
||||
(com/typeahead {:name (path->name2 :vendor)
|
||||
:id (path->name2 :vendor)
|
||||
:placeholder "Search for vendor..."
|
||||
:url (bidi/path-for ssr-routes/only-routes :vendor-search)
|
||||
:value vendor-val
|
||||
:content-fn (fn [c] (pull-attr (dc/db conn) :vendor/name c))})])]
|
||||
[:div
|
||||
(com/validated-field
|
||||
{:label "Status" :errors (:approval-status errors)}
|
||||
(com/select {:name (path->name2 :approval-status)
|
||||
:value status-val
|
||||
:options approval-status-options}))]
|
||||
[:div.col-span-2.pt-4
|
||||
[:h3.text-lg.font-medium.mb-3 "Expense Accounts"]
|
||||
[:div.group
|
||||
[:div#account-entries.space-y-3
|
||||
(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"})]}
|
||||
(map-indexed (fn [i a]
|
||||
(account-row* {:value a :client-id client-id :index i :errors errors}))
|
||||
accounts)
|
||||
(com/data-grid-row
|
||||
{:class "new-row"}
|
||||
(com/data-grid-cell
|
||||
{:colspan 4}
|
||||
(com/a-button {:hx-post changed-url
|
||||
:hx-vals (hx/json {:op "new-account"})
|
||||
:hx-target "#bulk-code-form"
|
||||
:hx-select "#bulk-code-form"
|
||||
:hx-swap "outerHTML"
|
||||
:hx-include "closest form"
|
||||
:color :secondary}
|
||||
"New account"))))]
|
||||
(com/errors {:errors (:accounts errors)})]]]]
|
||||
[:div.flex.justify-end
|
||||
[:div.flex.items-baseline.gap-x-4
|
||||
(com/form-errors {:errors (seq (:errors errors))})
|
||||
(com/button {:color :primary :x-ref "next" :class "w-32 wizard-save-action"} "Save")]])]]))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; edit-form-changed ops (whole-form re-render). Replaces the per-operation
|
||||
;; bulk-code-new-account / bulk-code-vendor-changed routes.
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(defn- vendor-default-account
|
||||
"Returns the vendor's standard default account. For single-client contexts,
|
||||
the account name is clientized (tailored to the customer). For multi-client
|
||||
contexts, the raw account name is used."
|
||||
[vendor-id client-id]
|
||||
(when vendor-id
|
||||
(let [vendor (edit/get-vendor vendor-id)
|
||||
account (:vendor/default-account vendor)]
|
||||
(if client-id
|
||||
(d-accounts/clientize account client-id)
|
||||
account))))
|
||||
|
||||
(defn- build-default-account-row [account]
|
||||
{:db/id (str (java.util.UUID/randomUUID))
|
||||
:account (:db/id account)
|
||||
:location (or (:account/location account) "Shared")
|
||||
:percentage 1.0})
|
||||
|
||||
(defn apply-vendor-changed
|
||||
"bulk-code-form-changed op: when the accounts are empty and a vendor with a default
|
||||
account is chosen, pre-populate a single 100% default-account row."
|
||||
[request]
|
||||
(let [bulk-state (:bulk-state request)
|
||||
client-id (single-client-id request)
|
||||
vendor-id (->db-id (:vendor bulk-state))
|
||||
accounts (:accounts bulk-state)]
|
||||
(if (and (empty? accounts) vendor-id)
|
||||
(if-let [default-account (vendor-default-account vendor-id client-id)]
|
||||
(assoc-in request [:bulk-state :accounts] [(build-default-account-row default-account)])
|
||||
request)
|
||||
request)))
|
||||
|
||||
(defn apply-new-account
|
||||
"bulk-code-form-changed op: append a fresh (blank, Shared) account row."
|
||||
[request]
|
||||
(let [accounts (vec (:accounts (:bulk-state request)))
|
||||
new-account {:db/id (str (java.util.UUID/randomUUID))
|
||||
:new? true
|
||||
:location "Shared"}]
|
||||
(assoc-in request [:bulk-state :accounts] (conj accounts new-account))))
|
||||
|
||||
(defn apply-remove-account
|
||||
"bulk-code-form-changed op: remove the account row at form-param row-index."
|
||||
[request]
|
||||
(let [row-index (some-> request :form-params (get "row-index") Integer/parseInt)
|
||||
accounts (vec (:accounts (:bulk-state request)))
|
||||
updated-accounts (if (and row-index (< row-index (count accounts)))
|
||||
(vec (concat (subvec accounts 0 row-index)
|
||||
(subvec accounts (inc row-index))))
|
||||
accounts)]
|
||||
(assoc-in request [:bulk-state :accounts] updated-accounts)))
|
||||
|
||||
(defn bulk-code-form-changed-handler
|
||||
"Single whole-form re-render endpoint. Dispatches on the `op` form-param (vendor
|
||||
change, add/remove row), then re-renders the whole form. A missing/unknown op (e.g.
|
||||
an account selection driving the location swap) just re-renders."
|
||||
[request]
|
||||
(let [op (get-in request [:form-params "op"])
|
||||
request' (case op
|
||||
"vendor-changed" (apply-vendor-changed request)
|
||||
"new-account" (apply-new-account request)
|
||||
"remove-account" (apply-remove-account request)
|
||||
request)]
|
||||
(html-response (render-form request'))))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Submit
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(defn maybe-code-accounts [transaction account-rules valid-locations]
|
||||
(with-precision 2
|
||||
@@ -151,263 +376,95 @@
|
||||
[])]
|
||||
accounts)))
|
||||
|
||||
(defrecord AccountsStep [linear-wizard]
|
||||
mm/ModalWizardStep
|
||||
(step-name [_]
|
||||
"Bulk Code")
|
||||
(step-key [_]
|
||||
:accounts)
|
||||
|
||||
(edit-path [_ _]
|
||||
[])
|
||||
|
||||
(step-schema [_]
|
||||
(mm/form-schema linear-wizard))
|
||||
|
||||
(render-step [this {{:keys [snapshot] :as multi-form-state} :multi-form-state :as request}]
|
||||
(let [_ (alog/peek ::SEARCH_PARAMS (:search-params snapshot))
|
||||
selected-ids (selected->ids (assoc request :query-params (:search-params snapshot)) (:search-params snapshot))
|
||||
all-ids (all-ids-not-locked selected-ids)]
|
||||
(mm/default-render-step
|
||||
linear-wizard this
|
||||
:head [:div.p-2 "Bulk editing " (count all-ids) " transactions"]
|
||||
:body (mm/default-step-body
|
||||
{}
|
||||
[:div
|
||||
#_(com/hidden {:name "ids" :value (pr-str ids)})
|
||||
|
||||
[:div.space-y-4.p-4
|
||||
[:div.grid.grid-cols-2.gap-4
|
||||
|
||||
;; Vendor field
|
||||
[:div {:hx-trigger "change"
|
||||
:hx-post (bidi/path-for ssr-routes/only-routes ::route/bulk-code-vendor-changed)
|
||||
:hx-target "#account-entries"
|
||||
:hx-swap "innerHTML"
|
||||
:hx-include "closest form"}
|
||||
(fc/with-field :vendor
|
||||
(com/validated-field {:label "Vendor"
|
||||
:errors (fc/field-errors)}
|
||||
(com/typeahead {:name (fc/field-name)
|
||||
:placeholder "Search for vendor..."
|
||||
:url (bidi/path-for ssr-routes/only-routes :vendor-search)
|
||||
:value (fc/field-value)
|
||||
:content-fn (fn [c] (pull-attr (dc/db conn) :vendor/name c))})))]
|
||||
|
||||
;; Status field
|
||||
[:div
|
||||
(fc/with-field :approval-status
|
||||
(com/validated-field {:label "Status"
|
||||
:errors (fc/field-errors)}
|
||||
(com/select {:name (fc/field-name)
|
||||
:value (some-> (fc/field-value)
|
||||
name)
|
||||
:options [["" "No Change"]
|
||||
["approved" "Approved"]
|
||||
["unapproved" "Unapproved"]
|
||||
["suppressed" "Suppressed"]
|
||||
["requires_feedback" "Requires Feedback"]]})))]
|
||||
|
||||
;; Accounts section
|
||||
[:div.col-span-2.pt-4
|
||||
[:h3.text-lg.font-medium.mb-3 "Expense Accounts"]
|
||||
|
||||
[:div#account-entries.space-y-3
|
||||
(fc/with-field :accounts
|
||||
(com/validated-field
|
||||
{:errors (fc/field-errors)}
|
||||
(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-account-row* {:value %}))
|
||||
|
||||
(com/data-grid-new-row {:colspan 4
|
||||
:hx-get (bidi/path-for ssr-routes/only-routes
|
||||
::route/bulk-code-new-account)
|
||||
:row-offset 0
|
||||
:index (count (fc/field-value))}
|
||||
"New account"))))]]]]])
|
||||
|
||||
;; Button to add more accounts
|
||||
|
||||
:footer
|
||||
(mm/default-step-footer linear-wizard this :validation-route ::route/new-wizard-navigate
|
||||
:next-button (com/button {:color :primary :x-ref "next" :class "w-32"} "Save"))
|
||||
:validation-route ::route/new-wizard-navigate))))
|
||||
|
||||
(defn assert-percentages-add-up [{:keys [accounts]}]
|
||||
(let [account-total (reduce + 0 (map (fn [x] (:percentage x)) accounts))]
|
||||
(when-not (dollars= 1.0 account-total)
|
||||
(form-validation-error (str "Expense account total (" account-total ") does not equal 100%")))))
|
||||
|
||||
(defrecord BulkCodeWizard [_ current-step]
|
||||
mm/LinearModalWizard
|
||||
(hydrate-from-request
|
||||
[this request]
|
||||
this)
|
||||
(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 :accounts)))
|
||||
(render-wizard [this {:keys [multi-form-state] :as request}]
|
||||
(mm/default-render-wizard
|
||||
this request
|
||||
:form-params
|
||||
(-> mm/default-form-props
|
||||
(assoc :hx-put
|
||||
(str (bidi/path-for ssr-routes/only-routes ::route/bulk-code-submit))))
|
||||
:render-timeline? false))
|
||||
(steps [_]
|
||||
[:accounts])
|
||||
(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]
|
||||
(get {:accounts (->AccountsStep this)}
|
||||
step-key)))
|
||||
(form-schema [_]
|
||||
bulk-code-schema)
|
||||
(submit [this {:keys [multi-form-state request-method identity] :as request}]
|
||||
(let [ids (selected->ids (assoc request :query-params (:search-params (:snapshot multi-form-state))) (:search-params (:snapshot multi-form-state)))
|
||||
all-ids (all-ids-not-locked ids)
|
||||
vendor (-> request :multi-form-state :snapshot :vendor)
|
||||
approval-status (-> request :multi-form-state :snapshot :approval-status)
|
||||
accounts (-> request :multi-form-state :snapshot :accounts)]
|
||||
(when (seq accounts)
|
||||
(assert-percentages-add-up (:snapshot multi-form-state)))
|
||||
(alog/peek ::ACCOUNTS (-> request :multi-form-state :snapshot))
|
||||
(defn submit
|
||||
"Validates the posted bulk-code form (schema field errors via wrap-form-4xx-2, then the
|
||||
percentage-sum and per-account location checks as form errors), then applies the chosen
|
||||
vendor / status / account-coding across every selected (not-locked) transaction."
|
||||
[request]
|
||||
(let [{:keys [ids vendor approval-status accounts]} (:bulk-state request)]
|
||||
(assert-schema bulk-code-schema (select-keys (:bulk-state request) bulk-code-form-keys))
|
||||
(when (seq accounts)
|
||||
(assert-percentages-add-up {:accounts accounts}))
|
||||
(let [db (dc/db conn)
|
||||
transactions (pull-many db [:db/id :transaction/amount {:transaction/client [:db/id]}] (vec ids))
|
||||
|
||||
;; Get transactions and filter for locked ones
|
||||
(let [db (dc/db conn)
|
||||
transactions (pull-many db [:db/id :transaction/amount {:transaction/client [:db/id]}] (vec all-ids))
|
||||
;; Get client locations
|
||||
client->locations (->> (map (comp :db/id :transaction/client) transactions)
|
||||
(distinct)
|
||||
(dc/q '[:find (pull ?e [:db/id :client/locations])
|
||||
:in $ [?e ...]]
|
||||
db)
|
||||
(map (fn [[client]]
|
||||
[(:db/id client) (:client/locations client)]))
|
||||
(into {}))]
|
||||
|
||||
;; Get client locations
|
||||
client->locations (->> (map (comp :db/id :transaction/client) transactions)
|
||||
(distinct)
|
||||
(dc/q '[:find (pull ?e [:db/id :client/locations])
|
||||
:in $ [?e ...]]
|
||||
db)
|
||||
(map (fn [[client]]
|
||||
[(:db/id client) (:client/locations client)]))
|
||||
(into {}))]
|
||||
;; Validate account locations
|
||||
(doseq [a accounts
|
||||
:let [{:keys [:account/location :account/name]} (dc/pull db
|
||||
[:account/location :account/name]
|
||||
(:account a))]]
|
||||
(when (and location (not= location (:location a)))
|
||||
(form-validation-error (str "Account " name " uses location " (:location a) ", but is supposed to be " location)))
|
||||
(doseq [[_ locations] client->locations]
|
||||
(when (and (not location)
|
||||
(not (get (into #{"Shared"} locations)
|
||||
(:location a))))
|
||||
(form-validation-error (str "Account " name " uses location " (:location a) ", but doesn't belong to the client.")))))
|
||||
|
||||
;; Validate account locations
|
||||
(doseq [a accounts
|
||||
:let [{:keys [:account/location :account/name]} (dc/pull db
|
||||
[:account/location :account/name]
|
||||
(:account a))]]
|
||||
(when (and location (not= location (:location a)))
|
||||
(form-validation-error (str "Account " name " uses location " (:location a) ", but is supposed to be " location)))
|
||||
(doseq [[_ locations] client->locations]
|
||||
(when (and (not location)
|
||||
(not (get (into #{"Shared"} locations)
|
||||
(:location a))))
|
||||
(form-validation-error (str "Account " name " uses location " (:location a) ", but doesn't belong to the client.")))))
|
||||
(audit-transact-batch
|
||||
(map (fn [t]
|
||||
(let [locations (client->locations (-> t :transaction/client :db/id))]
|
||||
[:upsert-transaction (cond-> t
|
||||
approval-status
|
||||
(assoc :transaction/approval-status approval-status)
|
||||
|
||||
(audit-transact-batch
|
||||
(map (fn [t]
|
||||
(let [locations (client->locations (-> t :transaction/client :db/id))]
|
||||
[:upsert-transaction (cond-> t
|
||||
approval-status
|
||||
(assoc :transaction/approval-status approval-status)
|
||||
vendor
|
||||
(assoc :transaction/vendor vendor)
|
||||
|
||||
vendor
|
||||
(assoc :transaction/vendor vendor)
|
||||
(seq accounts)
|
||||
(assoc :transaction/accounts
|
||||
(maybe-code-accounts t accounts locations)))]))
|
||||
transactions)
|
||||
(:identity request))
|
||||
|
||||
(seq accounts)
|
||||
(assoc :transaction/accounts
|
||||
(maybe-code-accounts t accounts locations)))]))
|
||||
transactions)
|
||||
(:identity request))
|
||||
;; Return success modal
|
||||
(html-response
|
||||
(com/success-modal {:title "Transactions Coded"}
|
||||
[:p (str "Successfully coded " (count ids) " transactions.")])
|
||||
:headers {"hx-trigger" "refreshTable, reset-selection"}))))
|
||||
|
||||
;; Return success modal
|
||||
(html-response
|
||||
(com/success-modal {:title "Transactions Coded"}
|
||||
[:p (str "Successfully coded " (count all-ids) " transactions.")])
|
||||
:headers {"hx-trigger" "refreshTable"})))))
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Handlers + routes
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(defn- vendor-default-account [vendor-id client-id]
|
||||
"Returns the vendor's standard default account. For single-client contexts,
|
||||
the account name is clientized (tailored to the customer). For multi-client
|
||||
contexts, the raw account name is used."
|
||||
(when vendor-id
|
||||
(let [vendor (edit/get-vendor vendor-id)
|
||||
account (:vendor/default-account vendor)]
|
||||
(if client-id
|
||||
(d-accounts/clientize account client-id)
|
||||
account))))
|
||||
(defn open-handler
|
||||
"Initial modal open (GET). The #transitioner shell the modal stack expects, wrapping the
|
||||
whole bulk-code form."
|
||||
[request]
|
||||
(modal-response
|
||||
[:div {:id "transitioner" :class "flex-1"}
|
||||
(render-form request)]))
|
||||
|
||||
(defn- build-default-account-row [account]
|
||||
{:db/id (str (java.util.UUID/randomUUID))
|
||||
:account (:db/id account)
|
||||
:location (or (:account/location account) "Shared")
|
||||
:percentage 1.0})
|
||||
|
||||
(defn- render-accounts-section [request]
|
||||
(let [multi-form-state (:multi-form-state request)]
|
||||
(html-response
|
||||
[:div
|
||||
(fc/start-form multi-form-state
|
||||
(when (:form-errors request) {:step-params (:form-errors request)})
|
||||
(fc/with-field :step-params
|
||||
(fc/with-field :accounts
|
||||
(com/validated-field
|
||||
{:errors (fc/field-errors)}
|
||||
(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-account-row* {:value %}))
|
||||
(com/data-grid-new-row {:colspan 4
|
||||
:hx-get (bidi/path-for ssr-routes/only-routes
|
||||
::route/bulk-code-new-account)
|
||||
:row-offset 0
|
||||
:index (count (fc/field-value))}
|
||||
"New account"))))))])))
|
||||
|
||||
(defn- single-client-id [request]
|
||||
"Returns the client ID if the user has access to exactly one client, nil otherwise."
|
||||
(when (= 1 (count (:clients request)))
|
||||
(-> request :clients first :db/id)))
|
||||
|
||||
(defn vendor-changed-handler [request]
|
||||
(let [snapshot (:snapshot (:multi-form-state request))
|
||||
step-params (:step-params (:multi-form-state request))
|
||||
client-id (single-client-id request)
|
||||
vendor-id (or (:vendor step-params) (:vendor snapshot))
|
||||
updated-step-params (if (and (empty? (:accounts step-params))
|
||||
vendor-id)
|
||||
(if-let [default-account (vendor-default-account vendor-id client-id)]
|
||||
(assoc step-params :accounts [(build-default-account-row default-account)])
|
||||
step-params)
|
||||
step-params)]
|
||||
(render-accounts-section (assoc-in request [:multi-form-state :step-params] updated-step-params))))
|
||||
|
||||
(def bulk-code-wizard (->BulkCodeWizard nil nil))
|
||||
(defn- render-form-response
|
||||
"wrap-form-4xx-2 form-handler: re-render the whole form with field/form errors."
|
||||
[request]
|
||||
(html-response (render-form request)
|
||||
:headers {"HX-reswap" "outerHTML"}))
|
||||
|
||||
(def key->handler
|
||||
(apply-middleware-to-all-handlers
|
||||
{::route/bulk-code (-> mm/open-wizard-handler
|
||||
(mm/wrap-wizard bulk-code-wizard)
|
||||
(mm/wrap-init-multi-form-state initial-bulk-edit-state))
|
||||
::route/bulk-code-new-account (->
|
||||
(add-new-entity-handler [:step-params :accounts]
|
||||
(fn render [cursor request]
|
||||
(transaction-account-row*
|
||||
{:value cursor}))
|
||||
(fn build-new-row [base _]
|
||||
(assoc base :location "Shared")))
|
||||
(wrap-schema-enforce :query-schema [:map
|
||||
[:client-id {:optional true}
|
||||
[:maybe entity-id]]]))
|
||||
::route/bulk-code-vendor-changed (-> vendor-changed-handler
|
||||
(mm/wrap-wizard bulk-code-wizard)
|
||||
(mm/wrap-decode-multi-form-state))
|
||||
::route/bulk-code-submit (-> mm/submit-handler
|
||||
(wrap-wizard bulk-code-wizard)
|
||||
(mm/wrap-decode-multi-form-state))}
|
||||
{::route/bulk-code (-> open-handler
|
||||
(wrap-bulk-state))
|
||||
::route/bulk-code-form-changed (-> bulk-code-form-changed-handler
|
||||
(wrap-bulk-state))
|
||||
::route/bulk-code-submit (-> submit
|
||||
(wrap-form-4xx-2 render-form-response)
|
||||
(wrap-bulk-state))}
|
||||
(fn [h]
|
||||
(-> h
|
||||
(wrap-copy-qp-pqp)
|
||||
@@ -418,4 +475,4 @@
|
||||
(wrap-schema-enforce :query-schema query-schema)
|
||||
(wrap-schema-enforce :hx-schema query-schema)
|
||||
(wrap-must {:activity :bulk-code :subject :transaction})
|
||||
(wrap-client-redirect-unauthenticated)))))
|
||||
(wrap-client-redirect-unauthenticated)))))
|
||||
|
||||
@@ -36,9 +36,9 @@
|
||||
[:import-batch-id {:optional true} [:maybe entity-id]]
|
||||
[:unresolved {:optional true}
|
||||
[:maybe [:boolean {:decode/string {:enter #(cond (= % "on") true
|
||||
(= % "") false
|
||||
:else
|
||||
(boolean %))}}]]]
|
||||
(= % "true") true
|
||||
(boolean? %) %
|
||||
:else false)}}]]]
|
||||
[:description {:optional true} [:maybe [:string {:decode/string strip}]]]
|
||||
[:memo {:optional true} [:maybe [:string {:decode/string strip}]]]
|
||||
[:vendor {:optional true :default nil} [:maybe [:entity-map {:pull [:db/id :vendor/name]}]]]
|
||||
@@ -50,9 +50,9 @@
|
||||
[:location {:optional true} [:maybe [:string {:decode/string strip}]]]
|
||||
[:potential-duplicates {:optional true}
|
||||
[:maybe [:boolean {:decode/string {:enter #(cond (= % "on") true
|
||||
(= % "") false
|
||||
:else
|
||||
(boolean %))}}]]]
|
||||
(= % "true") true
|
||||
(boolean? %) %
|
||||
:else false)}}]]]
|
||||
#_[:status {:optional true} [:maybe (ref->enum-schema "transaction-status")]]
|
||||
[:exact-match-id {:optional true} [:maybe entity-id]]
|
||||
[:all-selected {:optional true :default nil} [:maybe :boolean]]
|
||||
@@ -421,6 +421,35 @@
|
||||
(import-batch-id* request)
|
||||
(exact-match-id* request)]])
|
||||
|
||||
(def non-date-filter-params
|
||||
"Query-param keys that represent transaction filters other than the date range."
|
||||
[:vendor :account :bank-account :description :memo :location
|
||||
:amount-gte :amount-lte :linked-to :unresolved :potential-duplicates
|
||||
:import-batch-id :exact-match-id])
|
||||
|
||||
(defn- filter-value-active? [v]
|
||||
(cond
|
||||
(nil? v) false
|
||||
(false? v) false
|
||||
(string? v) (not (str/blank? v))
|
||||
:else true))
|
||||
|
||||
(defn non-date-filters-active? [request]
|
||||
(boolean (some (comp filter-value-active? #(get (:query-params request) %))
|
||||
non-date-filter-params)))
|
||||
|
||||
(defn clear-filters-href
|
||||
"URL for the transactions page with every non-date filter cleared, preserving
|
||||
the active date range (and an implied status, if any)."
|
||||
[request]
|
||||
(let [qp (:query-params request)
|
||||
status (:status qp)]
|
||||
(str (hu/url (bidi/path-for ssr-routes/only-routes ::route/page)
|
||||
(cond-> {}
|
||||
(:start-date qp) (assoc "start-date" (atime/unparse (:start-date qp) atime/normal-date))
|
||||
(:end-date qp) (assoc "end-date" (atime/unparse (:end-date qp) atime/normal-date))
|
||||
(keyword? status) (assoc "status" (name status)))))))
|
||||
|
||||
(def grid-page
|
||||
(helper/build {:id "entity-table"
|
||||
:nav com/main-aside-nav
|
||||
@@ -434,26 +463,34 @@
|
||||
(assoc-in (exact-match-id* request) [1 :hx-swap-oob] true)
|
||||
(some-> (import-batch-id* request) (assoc-in [1 :hx-swap-oob] true))])
|
||||
:action-buttons (fn [request]
|
||||
[(com/button {:color :primary
|
||||
:hx-get (bidi/path-for ssr-routes/only-routes ::route/bulk-code)
|
||||
:hx-target "#modal-holder"
|
||||
"x-bind:hx-vals" "JSON.stringify({selected: $data.selected, 'all-selected': $data.all_selected})"
|
||||
"hx-include" "#transaction-filters"}
|
||||
"Code")
|
||||
(com/button {:color :primary
|
||||
:hx-post (bidi/path-for ssr-routes/only-routes ::route/bulk-delete)
|
||||
:hx-target "#modal-holder"
|
||||
"x-bind:hx-vals" "JSON.stringify({selected: $data.selected, 'all-selected': $data.all_selected})"
|
||||
"hx-include" "#transaction-filters"
|
||||
:hx-confirm "Are you sure you want to delete these transactions?"}
|
||||
"Delete")
|
||||
(com/button {:color :primary
|
||||
:hx-post (bidi/path-for ssr-routes/only-routes ::route/bulk-delete)
|
||||
:hx-target "#modal-holder"
|
||||
"x-bind:hx-vals" "JSON.stringify({selected: $data.selected, 'all-selected': $data.all_selected})"
|
||||
"hx-include" "#transaction-filters"
|
||||
:hx-confirm "Are you sure you want to suppress these transactions?"}
|
||||
"Suppress")])
|
||||
(cond-> [(com/button {:color :primary
|
||||
:hx-get (bidi/path-for ssr-routes/only-routes ::route/bulk-code)
|
||||
:hx-target "#modal-holder"
|
||||
"x-bind:hx-vals" "JSON.stringify({selected: $data.selected, 'all-selected': $data.all_selected})"
|
||||
"x-bind:disabled" "selected.length === 0 && !all_selected"
|
||||
"hx-include" "#transaction-filters"}
|
||||
"Code")
|
||||
(com/button {:color :primary
|
||||
:hx-post (bidi/path-for ssr-routes/only-routes ::route/bulk-delete)
|
||||
:hx-target "#modal-holder"
|
||||
"x-bind:hx-vals" "JSON.stringify({selected: $data.selected, 'all-selected': $data.all_selected})"
|
||||
"x-bind:disabled" "selected.length === 0 && !all_selected"
|
||||
"hx-include" "#transaction-filters"
|
||||
:hx-confirm "Are you sure you want to delete these transactions?"}
|
||||
"Delete")
|
||||
(com/button {:color :primary
|
||||
:hx-post (bidi/path-for ssr-routes/only-routes ::route/bulk-delete)
|
||||
:hx-target "#modal-holder"
|
||||
"x-bind:hx-vals" "JSON.stringify({selected: $data.selected, 'all-selected': $data.all_selected})"
|
||||
"x-bind:disabled" "selected.length === 0 && !all_selected"
|
||||
"hx-include" "#transaction-filters"
|
||||
:hx-confirm "Are you sure you want to suppress these transactions?"}
|
||||
"Suppress")]
|
||||
(non-date-filters-active? request)
|
||||
(conj (com/a-button {:color :secondary
|
||||
:hx-boost "true"
|
||||
:href (clear-filters-href request)}
|
||||
"Clear filters"))))
|
||||
:row-buttons (fn [request entity]
|
||||
(let [client (:transaction/client entity)
|
||||
locked-until (:client/locked-until client)
|
||||
@@ -499,6 +536,17 @@
|
||||
(= 1 (count (:client/locations (:client args))))))
|
||||
:render (fn [x] [:div.flex.items-center.gap-2 (-> x :transaction/client :client/name)])
|
||||
:render-csv (fn [x] (-> x :transaction/client :client/name))}
|
||||
{:key "bank-account"
|
||||
:name "Bank Account"
|
||||
:show-starting "lg"
|
||||
:render (fn [x]
|
||||
(let [ba (:transaction/bank-account x)]
|
||||
(or (:bank-account/name ba)
|
||||
(:bank-account/numeric-code ba))))
|
||||
:render-csv (fn [x]
|
||||
(let [ba (:transaction/bank-account x)]
|
||||
(or (:bank-account/name ba)
|
||||
(:bank-account/numeric-code ba))))}
|
||||
{:key "vendor"
|
||||
:name "Vendor"
|
||||
:sort-key "vendor"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -211,7 +211,7 @@
|
||||
(com/data-grid-cell {} (fc/with-field :description-original
|
||||
(com/text-input {:value (fc/field-value) :name (fc/field-name)})))
|
||||
(com/data-grid-cell {} (fc/with-field :amount
|
||||
(com/money-input {:value (fc/field-value) :name (fc/field-name) :class "w-28"})))
|
||||
(com/text-input {:value (fc/field-value) :name (fc/field-name) :class "w-28 text-right" :inputmode "decimal"})))
|
||||
(com/data-grid-cell {} (fc/with-field :bank-account-code
|
||||
(com/text-input {:value (fc/field-value) :name (fc/field-name) :class "w-28"})))
|
||||
(com/data-grid-cell {} (fc/with-field :client-code
|
||||
|
||||
@@ -5,13 +5,13 @@
|
||||
[hiccup2.core :as hiccup]
|
||||
[auto-ap.ssr.components :as com]))
|
||||
(defn html-page [hiccup]
|
||||
{:status 200
|
||||
{:status 200
|
||||
:headers {"Content-Type" "text/html"}
|
||||
:body (str
|
||||
"<!DOCTYPE html>"
|
||||
(hiccup/html
|
||||
{}
|
||||
hiccup))})
|
||||
:body (str
|
||||
"<!DOCTYPE html>"
|
||||
(hiccup/html
|
||||
{}
|
||||
hiccup))})
|
||||
|
||||
(defn base-page [request contents page-name]
|
||||
(html-page
|
||||
@@ -28,7 +28,7 @@
|
||||
[:script {:src "https://cdn.jsdelivr.net/npm/@ryangjchandler/alpine-tooltip@1.x.x/dist/cdn.min.js" :defer true}]
|
||||
[:link {:rel "stylesheet" :href "/css/tippy/tippy.css"}]
|
||||
[:link {:rel "stylesheet" :href "/css/tippy/light.css"}]
|
||||
[:script {:src "/js/htmx.js"
|
||||
[:script {:src "/js/htmx.js"
|
||||
:crossorigin= "anonymous"}]
|
||||
|
||||
[:script {:src "https://cdn.jsdelivr.net/npm/sortablejs@1.15.2/Sortable.min.js"}]
|
||||
@@ -41,7 +41,7 @@
|
||||
[:script {:src "https://cdnjs.cloudflare.com/ajax/libs/Chart.js/4.4.1/chart.umd.min.js" :integrity "sha512-CQBWl4fJHWbryGE+Pc7UAxWMUMNMWzWxF4SQo9CgkJIN1kx6djDQZjh3Y8SZ1d+6I+1zze6Z7kHXO7q3UyZAWw==" :crossorigin "anonymous" :referrerpolicy "no-referrer"}]
|
||||
|
||||
[:script {:src "https://unpkg.com/dropzone@5.9.3/dist/min/dropzone.min.js" :defer true}]
|
||||
[:link {:rel "stylesheet" :href "https://unpkg.com/dropzone@5/dist/min/dropzone.min.css" :type "text/css" :defer true}]
|
||||
[:link {:rel "stylesheet" :href "https://unpkg.com/dropzone@5/dist/min/dropzone.min.css" :type "text/css" :defer true}]
|
||||
[:script {:defer true :src "/js/alpine-vals.js"}]
|
||||
[:script {:defer true :src "https://cdn.jsdelivr.net/npm/@ryangjchandler/alpine-clipboard@2.x.x/dist/alpine-clipboard.js"}]
|
||||
[:script {:defer true :src "https://cdn.jsdelivr.net/npm/@alpinejs/focus@3.x.x/dist/cdn.min.js"}]
|
||||
@@ -92,14 +92,14 @@ input[type=number] {
|
||||
"x-transition:leave-end" "!bg-opacity-0"}
|
||||
|
||||
[:div {:class "flex h-full w-full justify-stretch md:justify-center items-stretch md:items-center "
|
||||
"x-trap.inert.noscroll" "open"
|
||||
"x-trap.inert" "open"
|
||||
"x-show" "open"
|
||||
"x-transition:enter" "ease-out duration-300"
|
||||
"x-trap.inert.noscroll" "open"
|
||||
"x-trap.inert" "open"
|
||||
"x-show" "open"
|
||||
"x-transition:enter" "ease-out duration-300"
|
||||
"x-transition:enter-start" "!bg-opacity-0 !translate-y-32"
|
||||
"x-transition:enter-end" "!bg-opacity-100 !translate-y-0"
|
||||
"x-transition:leave" "duration-300"
|
||||
"x-transition:enter-end" "!bg-opacity-100 !translate-y-0"
|
||||
"x-transition:leave" "duration-300"
|
||||
"x-transition:leave-start" "!opacity-100 !translate-y-0"
|
||||
"x-transition:leave-end" "!opacity-0 !translate-y-32"}
|
||||
"x-transition:leave-end" "!opacity-0 !translate-y-32"}
|
||||
|
||||
[:div#modal-content.flex.items-center.justify-center {:class "md:p-12"}]]]]]]))
|
||||
|
||||
@@ -9,15 +9,29 @@
|
||||
(with-open [s (ServerSocket. 0)]
|
||||
(.getLocalPort s)))
|
||||
|
||||
(defn- mcp-repl-task [& _args]
|
||||
"Start nREPL server and HTTP server on random ports.
|
||||
(defn- read-port [path]
|
||||
"Read a previously-recorded port from `path`, or nil if missing/unparseable."
|
||||
(let [f (io/file path)]
|
||||
(when (.exists f)
|
||||
(try (Integer/parseInt (.trim ^String (slurp f)))
|
||||
(catch Exception _ nil)))))
|
||||
|
||||
Writes ports to nrepl-port and .http-port files.
|
||||
Connect with: clj-nrepl-eval -p $(cat nrepl-port)"
|
||||
(let [nrepl-port (available-port)
|
||||
http-port (available-port)]
|
||||
(spit "nrepl-port" (str nrepl-port))
|
||||
(spit ".http-port" (str http-port))
|
||||
(defn- stable-port [path]
|
||||
"Reuse the port recorded in `path` if present, otherwise pick a random
|
||||
available one. Always (re)writes the file so the port stays stable for this
|
||||
worktree across REPL restarts and reloads."
|
||||
(let [port (or (read-port path) (available-port))]
|
||||
(spit path (str port))
|
||||
port))
|
||||
|
||||
(defn- mcp-repl-task [& _args]
|
||||
"Start nREPL server and HTTP server.
|
||||
|
||||
Reuses the ports recorded in nrepl-port and .http-port if present (keeping
|
||||
them stable per worktree), otherwise picks random available ports and records
|
||||
them. Connect with: clj-nrepl-eval -p $(cat nrepl-port)"
|
||||
(let [nrepl-port (stable-port "nrepl-port")
|
||||
http-port (stable-port ".http-port")]
|
||||
(println (format "nREPL port: %d (nrepl-port)" nrepl-port))
|
||||
(println (format "HTTP port: %d (.http-port)" http-port))
|
||||
(nrepl/start-server :port nrepl-port)
|
||||
|
||||
@@ -4,9 +4,6 @@
|
||||
:post ::save}
|
||||
"/table" ::table
|
||||
|
||||
"/navigate" ::navigate
|
||||
"/bank-accounts/sort" ::sort-bank-accounts
|
||||
"/discard" ::discard
|
||||
"/square-locations" ::refresh-square-locations
|
||||
|
||||
"/location/new" ::new-location
|
||||
@@ -15,6 +12,13 @@
|
||||
"/email-contact/new" ::new-email-contact
|
||||
"/group/new" ::new-group
|
||||
"/feature-flag/new" ::new-feature-flag
|
||||
|
||||
"/bank-account/new" ::new-bank-account
|
||||
"/bank-account/edit" ::edit-bank-account
|
||||
"/bank-account/accept" {:post ::accept-bank-account}
|
||||
"/bank-account/discard" ::discard-bank-account
|
||||
"/bank-accounts/sort" ::sort-bank-accounts
|
||||
|
||||
"/new" {:get ::new-dialog}
|
||||
["/" [#"\d+" :db/id] "/sales-powerquery"] ::biweekly-sales-powerquery
|
||||
["/" [#"\d+" :db/id] "/edit"] ::edit-dialog})
|
||||
|
||||
@@ -10,7 +10,6 @@
|
||||
"/account/typeahead" ::account-typeahead
|
||||
"/test" ::test
|
||||
"/new" {:get ::new-dialog}
|
||||
"/navigate" ::navigate
|
||||
["/" [#"\d+" :db/id] "/edit"] ::edit-dialog
|
||||
["/" [#"\d+" :db/id] "/delete"] ::delete
|
||||
["/" [#"\d+" :db/id] "/run"] {:get ::execute-dialog
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
"/account-override" ::new-account-override
|
||||
"/account-typeahead" ::account-typeahead
|
||||
"/validate" ::validate
|
||||
"/navigat" ::navigate
|
||||
"/new" {:get ::new}
|
||||
"/merge" {:get ::merge
|
||||
:put ::merge-submit}
|
||||
|
||||
@@ -15,7 +15,6 @@
|
||||
:put ::new-invoice-submit
|
||||
"/due-date" ::due-date
|
||||
"/scheduled-payment-date" ::scheduled-payment-date
|
||||
"/navigate" ::new-wizard-navigate
|
||||
"/account/new" ::new-wizard-new-account
|
||||
"/account/location-select" ::location-select
|
||||
"/account/prediction" ::account-prediction
|
||||
@@ -26,16 +25,12 @@
|
||||
|
||||
"/pay" {:get ::pay-wizard
|
||||
"/using-credit" ::pay-using-credit
|
||||
|
||||
"/navigate" ::pay-wizard-navigate
|
||||
:post ::pay-submit}
|
||||
"/bulk-delete" {:get ::bulk-delete
|
||||
:delete ::bulk-delete-confirm}
|
||||
"/bulk-edit" {:get ::bulk-edit
|
||||
:put ::bulk-edit-submit
|
||||
"/account" ::bulk-edit-new-account
|
||||
"/total" ::bulk-edit-total
|
||||
"/balance" ::bulk-edit-balance}
|
||||
"/form-changed" ::bulk-edit-form-changed}
|
||||
["/" [#"\d+" :db/id]] {:delete ::delete
|
||||
"/undo-autopay" ::undo-autopay
|
||||
"/unvoid" ::unvoid
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
"/line-item" {:get ::new-line-item}}
|
||||
|
||||
"/external-new" ::external-page
|
||||
"/bulk-delete" ::bulk-delete
|
||||
"/external-import-new" {"" ::external-import-page
|
||||
"/parse" ::external-import-parse
|
||||
"/import" ::external-import-import}
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
(ns auto-ap.routes.pos.sales-summaries)
|
||||
(def routes {"" {:get ::page
|
||||
:put ::edit-wizard-submit}
|
||||
(def routes {"" {:get ::page
|
||||
:put ::edit-wizard-submit}
|
||||
"/table" ::table
|
||||
["/" [#"\d+" :db/id]] {:get ::edit-wizard}
|
||||
"/edit/navigate" ::edit-wizard-navigate
|
||||
"/edit/sales-summary-item" ::new-summary-item
|
||||
"/edit/item-account" ::edit-item-account
|
||||
"/edit/save-item-account" ::save-item-account
|
||||
"/edit/form-changed" ::form-changed
|
||||
"/edit/item-account" ::edit-item-account
|
||||
"/edit/save-item-account" ::save-item-account
|
||||
"/edit/cancel-item-account" ::cancel-item-account})
|
||||
|
||||
@@ -1,16 +1,14 @@
|
||||
(ns auto-ap.routes.transactions)
|
||||
|
||||
(def routes {"" {:get ::page
|
||||
:put ::edit-wizard-navigate
|
||||
"/unapproved" ::unapproved-page
|
||||
"/requires-feedback" ::requires-feedback-page
|
||||
"/approved" ::approved-page
|
||||
"/bulk-delete" ::bulk-delete
|
||||
"/bulk-suppress" ::bulk-suppress
|
||||
"/bulk-code" {:get ::bulk-code
|
||||
:put ::bulk-code-submit
|
||||
"/new-account" ::bulk-code-new-account
|
||||
"/vendor-changed" ::bulk-code-vendor-changed}}
|
||||
:post ::bulk-code-submit
|
||||
"/form-changed" ::bulk-code-form-changed}}
|
||||
"/new" {:get ::new
|
||||
:post ::new-submit
|
||||
"/location-select" ::location-select
|
||||
@@ -28,13 +26,8 @@
|
||||
|
||||
["/" [#"\d+" :db/id]] {"/edit" {:get ::edit-wizard}}
|
||||
"/edit-submit" ::edit-submit
|
||||
"/edit-vendor-changed" ::edit-vendor-changed
|
||||
"/location-select" ::location-select
|
||||
"/account-total" ::account-total
|
||||
"/account-balance" ::account-balance
|
||||
"/toggle-amount-mode" ::toggle-amount-mode
|
||||
"/edit-wizard-new-account" ::edit-wizard-new-account
|
||||
"/edit-wizard-toggle-mode" ::edit-wizard-toggle-mode
|
||||
"/edit-form-changed" ::edit-form-changed
|
||||
"/match-payment" ::link-payment
|
||||
"/match-autopay-invoices" ::link-autopay-invoices
|
||||
"/match-unpaid-invoices" ::link-unpaid-invoices
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
[auto-ap.datomic :refer [conn audit-transact transact-schema install-functions]]
|
||||
[auto-ap.datomic.accounts :as a]
|
||||
[auto-ap.integration.util :refer [wrap-setup test-client test-vendor test-bank-account test-account
|
||||
setup-test-data admin-token]]
|
||||
setup-test-data admin-token user-token]]
|
||||
[auto-ap.ssr.ledger :as sut]
|
||||
[auto-ap.ssr.utils :refer [main-transformer]]
|
||||
[auto-ap.ssr.ledger.common :as common]
|
||||
@@ -557,3 +557,65 @@
|
||||
:identity (admin-token)})]
|
||||
(is (= (format "#entity-table tr[data-id=\"%d\"]" invoice-id)
|
||||
(get-in response [:headers "hx-retarget"])))))))))
|
||||
|
||||
;; =============================================================================
|
||||
;; Bulk Delete - all-ids-not-locked, bulk-delete
|
||||
;; =============================================================================
|
||||
|
||||
(defn- create-journal-entry [client-id date external-id]
|
||||
(let [temp (str (java.util.UUID/randomUUID))
|
||||
tx @(dc/transact conn [{:db/id temp
|
||||
:journal-entry/client client-id
|
||||
:journal-entry/date date
|
||||
:journal-entry/external-id external-id
|
||||
:journal-entry/source "manual"
|
||||
:journal-entry/amount 100.0}])]
|
||||
(get-in tx [:tempids temp])))
|
||||
|
||||
(deftest all-ids-not-locked-test
|
||||
(testing "Should exclude entries dated before the client's locked-until date"
|
||||
(let [tempids (setup-test-data [(test-client :db/id "lock-client"
|
||||
:client/code "LOCKTEST"
|
||||
:client/locked-until #inst "2099-01-01")])
|
||||
client-id (get tempids "lock-client")
|
||||
locked-id (create-journal-entry client-id #inst "2020-01-01" "ext-locked")
|
||||
open-id (create-journal-entry client-id #inst "2099-06-01" "ext-open")
|
||||
result (set (sut/all-ids-not-locked [locked-id open-id]))]
|
||||
(is (contains? result open-id))
|
||||
(is (not (contains? result locked-id))))))
|
||||
|
||||
(deftest bulk-delete-test
|
||||
(testing "Admin can delete selected ledger entries"
|
||||
(let [tempids (setup-test-data [(test-client :db/id "bd-client"
|
||||
:client/code "BDTEST")])
|
||||
client-id (get tempids "bd-client")
|
||||
id1 (create-journal-entry client-id #inst "2021-01-01" "ext-bd-1")
|
||||
id2 (create-journal-entry client-id #inst "2021-02-01" "ext-bd-2")
|
||||
response (sut/bulk-delete {:identity (admin-token)
|
||||
:form-params {:selected [id1 id2]}})
|
||||
db-after (dc/db conn)]
|
||||
(is (= 200 (:status response)))
|
||||
;; modal-response retargets to the persistent #modal-content shell (innerHTML)
|
||||
;; so the modal-holder survives repeated deletes; it also appends modalopen.
|
||||
(is (= "invalidated, reset-selection, modalopen" (get-in response [:headers "hx-trigger"])))
|
||||
(is (= "#modal-content" (get-in response [:headers "hx-retarget"])))
|
||||
(is (= "innerHTML" (get-in response [:headers "hx-reswap"])))
|
||||
(is (nil? (:journal-entry/external-id (dc/pull db-after [:journal-entry/external-id] id1))))
|
||||
(is (nil? (:journal-entry/external-id (dc/pull db-after [:journal-entry/external-id] id2))))))
|
||||
|
||||
(testing "Should preserve entries in a locked period even when selected"
|
||||
(let [tempids (setup-test-data [(test-client :db/id "bd-lock-client"
|
||||
:client/code "BDLOCK"
|
||||
:client/locked-until #inst "2099-01-01")])
|
||||
client-id (get tempids "bd-lock-client")
|
||||
locked-id (create-journal-entry client-id #inst "2020-01-01" "ext-bd-locked")
|
||||
open-id (create-journal-entry client-id #inst "2099-06-01" "ext-bd-open")
|
||||
_ (sut/bulk-delete {:identity (admin-token)
|
||||
:form-params {:selected [locked-id open-id]}})
|
||||
db-after (dc/db conn)]
|
||||
(is (some? (:journal-entry/external-id (dc/pull db-after [:journal-entry/external-id] locked-id))))
|
||||
(is (nil? (:journal-entry/external-id (dc/pull db-after [:journal-entry/external-id] open-id))))))
|
||||
|
||||
(testing "Non-admin cannot bulk-delete"
|
||||
(is (thrown? Exception (sut/bulk-delete {:identity (user-token)
|
||||
:form-params {:selected [1]}})))))
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -6,7 +6,6 @@
|
||||
[auto-ap.integration.util :refer [setup-test-data test-client test-bank-account test-transaction test-payment test-invoice]]
|
||||
[auto-ap.routes.transactions :as route]
|
||||
[auto-ap.ssr.transaction.edit :as edit]
|
||||
[auto-ap.ssr.components.multi-modal :as mm]
|
||||
[auto-ap.ssr.utils :refer [wrap-entity wrap-schema-enforce]]
|
||||
[auto-ap.permissions :refer [wrap-must]]
|
||||
[auto-ap.datomic.transactions :as d-transactions]
|
||||
@@ -29,6 +28,8 @@
|
||||
(def test-transaction-id (atom nil))
|
||||
(def test-account-ids (atom {}))
|
||||
(def test-client-ids (atom {}))
|
||||
(def test-sales-summary-id (atom nil))
|
||||
(def test-rule-id (atom nil))
|
||||
|
||||
(defn admin-identity []
|
||||
(case @test-identity-mode
|
||||
@@ -66,8 +67,10 @@
|
||||
(let [tx-result @(dc/transact conn
|
||||
[(assoc (test-client :db/id "client-id"
|
||||
:client/code "TEST"
|
||||
:client/name "Test Client"
|
||||
:client/locations ["DT"])
|
||||
:client/bank-accounts [(test-bank-account :db/id "bank-account-id" :bank-account/code "TEST-CHK")])
|
||||
:client/bank-accounts [(test-bank-account :db/id "bank-account-id" :bank-account/code "TEST-CHK"
|
||||
:bank-account/visible true :bank-account/name "Test Checking")])
|
||||
(test-client :db/id "client-id-2"
|
||||
:client/code "TEST2"
|
||||
:client/locations ["NY"])
|
||||
@@ -100,6 +103,9 @@
|
||||
{:db/id "vendor-id"
|
||||
:vendor/name "Test Vendor"
|
||||
:vendor/default-account "account-id"}
|
||||
{:db/id "vendor-id-2"
|
||||
:vendor/name "Second Vendor"
|
||||
:vendor/default-account "account-id-2"}
|
||||
(test-transaction :db/id "transaction-id"
|
||||
:transaction/client "client-id"
|
||||
:transaction/bank-account "bank-account-id"
|
||||
@@ -157,8 +163,46 @@
|
||||
:invoice/invoice-number "UNPAID-001"
|
||||
:invoice/expense-accounts [{:invoice-expense-account/account "account-id"
|
||||
:invoice-expense-account/amount 150.0
|
||||
:invoice-expense-account/location "DT"}])])
|
||||
:invoice-expense-account/location "DT"}])
|
||||
;; Sales summary for the POS sales-summary edit modal e2e
|
||||
;; (balanced: $500 credit = $500 debit).
|
||||
{:db/id "sales-summary-id"
|
||||
:sales-summary/client "client-id"
|
||||
:sales-summary/date #inst "2026-06-20T00:00:00Z"
|
||||
:sales-summary/items [{:db/id "ss-item-credit"
|
||||
:sales-summary-item/category "Food Sales"
|
||||
:sales-summary-item/sort-order 0
|
||||
:sales-summary-item/manual? false
|
||||
:ledger-mapped/ledger-side :ledger-side/credit
|
||||
:ledger-mapped/amount 500.0
|
||||
:ledger-mapped/account "account-id"}
|
||||
{:db/id "ss-item-debit"
|
||||
:sales-summary-item/category "Cash Deposit"
|
||||
:sales-summary-item/sort-order 1
|
||||
:sales-summary-item/manual? false
|
||||
:ledger-mapped/ledger-side :ledger-side/debit
|
||||
:ledger-mapped/amount 500.0
|
||||
:ledger-mapped/account "account-id-2"}]}])
|
||||
tempids (:tempids tx-result)
|
||||
;; A pre-existing transaction rule (for the wizard edit flow), in a SEPARATE
|
||||
;; transaction so the first one's tempid->entity-id allocation (and thus the TEST
|
||||
;; transaction grid order other specs depend on) is byte-identical to before.
|
||||
;; Under client TEST2 so it stays out of the single-client TEST views. We do NOT
|
||||
;; seed a recent matching transaction: a date-NOW txn perturbs an unrelated
|
||||
;; transaction-edit save spec, and the rule test step's query/render is reused
|
||||
;; unchanged by the migration, so characterizing that the preview table renders is
|
||||
;; sufficient parity (the specific match count is not what the migration risks).
|
||||
rule-tx (:tempids
|
||||
@(dc/transact conn
|
||||
[{:db/id "rule-id"
|
||||
:transaction-rule/client (get tempids "client-id-2")
|
||||
:transaction-rule/description "ZZRULEMATCH"
|
||||
:transaction-rule/note "ZZRULEMATCH"
|
||||
:transaction-rule/transaction-approval-status :transaction-approval-status/approved
|
||||
:transaction-rule/accounts [{:db/id "rule-acct"
|
||||
:transaction-rule-account/account (get tempids "account-id")
|
||||
:transaction-rule-account/location "Shared"
|
||||
:transaction-rule-account/percentage 1.0}]}]))
|
||||
tx-entity-id (get tempids "transaction-id")]
|
||||
(println "Test transaction entity ID:" tx-entity-id)
|
||||
(reset! test-account-ids
|
||||
@@ -166,10 +210,13 @@
|
||||
:second-account (get tempids "account-id-2")
|
||||
:fixed-location-account (get tempids "account-id-fixed-loc")
|
||||
:ap-account (get tempids "ap-account-id")
|
||||
:vendor (get tempids "vendor-id")})
|
||||
:vendor (get tempids "vendor-id")
|
||||
:vendor2 (get tempids "vendor-id-2")})
|
||||
(reset! test-client-ids
|
||||
{:test (get tempids "client-id")
|
||||
:test2 (get tempids "client-id-2")})
|
||||
(reset! test-sales-summary-id (get tempids "sales-summary-id"))
|
||||
(reset! test-rule-id (get rule-tx "rule-id"))
|
||||
tx-entity-id))
|
||||
|
||||
(defn test-info-handler [request]
|
||||
@@ -178,7 +225,10 @@
|
||||
:body (cheshire.core/generate-string
|
||||
{:transactionId @test-transaction-id
|
||||
:accounts @test-account-ids
|
||||
:clientIds @test-client-ids
|
||||
:clientMode @test-identity-mode
|
||||
:salesSummaryId @test-sales-summary-id
|
||||
:ruleId @test-rule-id
|
||||
:clients (mapv :client/code (:clients request))})})
|
||||
|
||||
(defn test-set-client-mode-handler [request]
|
||||
@@ -194,6 +244,22 @@
|
||||
:body (cheshire.core/generate-string
|
||||
{:mode mode})}))
|
||||
|
||||
(defn reset-test-data! []
|
||||
"Recreate and re-seed the in-memory test database, returning to the same
|
||||
baseline the server starts with. Used by the /test-reset endpoint so each
|
||||
browser test can start from a clean, deterministic dataset."
|
||||
(reset! test-identity-mode :single-client)
|
||||
(let [conn (create-test-db)
|
||||
tx-id (seed-test-data conn)]
|
||||
(reset! test-transaction-id tx-id)
|
||||
tx-id))
|
||||
|
||||
(defn test-reset-handler [_request]
|
||||
{:status 200
|
||||
:headers {"Content-Type" "application/json"}
|
||||
:body (cheshire.core/generate-string {:ok true
|
||||
:transactionId (reset-test-data!)})})
|
||||
|
||||
(defn wrap-test-info [handler]
|
||||
(fn [request]
|
||||
(cond
|
||||
@@ -201,6 +267,8 @@
|
||||
(test-info-handler request)
|
||||
(= "/test-set-client-mode" (:uri request))
|
||||
(test-set-client-mode-handler request)
|
||||
(= "/test-reset" (:uri request))
|
||||
(test-reset-handler request)
|
||||
:else
|
||||
(handler request))))
|
||||
|
||||
@@ -232,8 +300,9 @@
|
||||
(let [test-conn (create-test-db)
|
||||
tx-id (seed-test-data test-conn)]
|
||||
(reset! test-transaction-id tx-id)
|
||||
(let [server (run-jetty (test-app) {:port 3333 :join? false})]
|
||||
(println "Test server started on http://localhost:3333")
|
||||
(let [port (Integer/parseInt (or (System/getenv "TEST_SERVER_PORT") "3333"))
|
||||
server (run-jetty (test-app) {:port port :join? false})]
|
||||
(println (str "Test server started on http://localhost:" port))
|
||||
(println "Transaction entity ID:" tx-id)
|
||||
server)))
|
||||
|
||||
|
||||
Reference in New Issue
Block a user