Compare commits
25 Commits
docs/ssr-r
...
integreat-
| Author | SHA1 | Date | |
|---|---|---|---|
| a01dfc197e | |||
| c892719bd1 | |||
| d0fad63e24 | |||
| 0b5bfd9c84 | |||
| 38ad665726 | |||
| 798b350c81 | |||
| 0f5650b73e | |||
| 1d5a95196f | |||
| 07159dc221 | |||
| 57f3b63b6a | |||
| a7ccdb12f3 | |||
| 32056bf396 | |||
| 69eed1f8a6 | |||
| ed3344438b | |||
| bdb286ca71 | |||
| 3ecd115f76 | |||
| 246df6996e | |||
| 85aaf7b759 | |||
| 3641846f70 | |||
| 8215e6376d | |||
| 3759258ebe | |||
| 482b4802ff | |||
| 5f1bb6db82 | |||
| a2684bf5c1 | |||
| cdb6bb6fe3 |
122
.claude/skills/ssr-form-migration/SKILL.md
Normal file
122
.claude/skills/ssr-form-migration/SKILL.md
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
---
|
||||||
|
name: ssr-form-migration
|
||||||
|
description: Migrate an SSR form or wizard modal to the whole-form HTMX swap doctrine, top-rooted render functions, the data-driven session-backed wizard engine, and (where it helps) Selmer templates. Use when simplifying a server-rendered form/wizard modal in this codebase, removing EDN-snapshot round-trips, deleting *-no-cursor* duplicate render fns, collapsing per-interaction routes, or replacing the multi-step wizard protocol machinery.
|
||||||
|
---
|
||||||
|
|
||||||
|
# SSR Form & Wizard Migration
|
||||||
|
|
||||||
|
A repeatable method for making a server-rendered form/wizard modal **simpler** without
|
||||||
|
changing user-facing behavior. Distilled from the first proven migration — the
|
||||||
|
`transaction/edit.clj` modal, which already runs on the whole-form `hx-select` swap
|
||||||
|
approach with **zero out-of-band swaps**. Every migration *reads this skill first* and
|
||||||
|
*extends it last* (the Growth contract below). If migration N+1 is not easier than N,
|
||||||
|
the skill-update step was skipped — treat that as a bug.
|
||||||
|
|
||||||
|
The four patterns every migration moves code toward live in `reference/`:
|
||||||
|
|
||||||
|
- `reference/swap-doctrine.md` — the four-rule HTMX swap priority order + the focus
|
||||||
|
invariant + Alpine-survives-swap hardening + target-selector strategy.
|
||||||
|
- `reference/render-functions.md` — one render fn per component, taking explicit data
|
||||||
|
**or a top-rooted cursor**; no faked cursor positions, no `*-no-cursor*` twins.
|
||||||
|
- `reference/form-vs-wizard.md` — single-step → plain form; multi-step → data-driven
|
||||||
|
engine with **per-step state in the Ring session** (the Django `formtools` model).
|
||||||
|
- `reference/selmer-conventions.md` — plain-HTML attributes via Selmer, the
|
||||||
|
Hiccup↔Selmer interop bridge, include/block patterns.
|
||||||
|
|
||||||
|
Growing cookbooks (append every migration):
|
||||||
|
`component-cookbook.md`, `gotchas.md`, `test-recipes.md`, `scorecard.md`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The per-migration playbook
|
||||||
|
|
||||||
|
Run this loop for each modal. The phase notes in the migration plan list only what is
|
||||||
|
*specific* to a modal; this loop is the constant.
|
||||||
|
|
||||||
|
1. **Read the skill.** Skim `reference/` and note which `component-cookbook.md`
|
||||||
|
entries and `gotchas.md` you can reuse. Start from the cookbook, not a blank file.
|
||||||
|
|
||||||
|
2. **Classify** (`reference/form-vs-wizard.md`).
|
||||||
|
- Single logical step (even with a `?mode=` toggle or add/remove rows) → **plain
|
||||||
|
form**: no server-side wizard state, no snapshot, no protocol.
|
||||||
|
- Genuinely multiple steps the user advances through → **wizard**: the data-driven
|
||||||
|
engine + per-step session storage.
|
||||||
|
- When in doubt, it's a form.
|
||||||
|
|
||||||
|
3. **Baseline the scorecard** (`scorecard.md`, heuristics in §6 of the plan). Record
|
||||||
|
before-numbers with cheap tools:
|
||||||
|
```bash
|
||||||
|
F=src/clj/auto_ap/ssr/<modal>.clj
|
||||||
|
wc -l $F # LOC (heuristic 4)
|
||||||
|
grep -c 'defn.*-no-cursor' $F # *-no-cursor* twins (heuristic 1)
|
||||||
|
grep -cE 'with-cursor|MapCursor\.' $F # faked cursor re-roots (heuristic 1)
|
||||||
|
grep -c 'hx-swap-oob' $F # OOB swaps (heuristic 7)
|
||||||
|
grep -cE '"hx-[a-z]' $F # mixed string hx- attrs (heuristic 8)
|
||||||
|
# route count: count this modal's entries in src/cljc/auto_ap/routes/*.cljc
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Characterize behavior (test-first).** Write or confirm a Playwright spec that
|
||||||
|
captures *current* behavior before you touch anything — focus/caret survival across
|
||||||
|
swaps, each field round-trip, validation errors, and the real save. This spec is the
|
||||||
|
parity contract; it must stay green through every commit. See `test-recipes.md`.
|
||||||
|
|
||||||
|
5. **Consolidate render functions** (`reference/render-functions.md`). Make each render
|
||||||
|
fn take explicit data or a **top-rooted cursor**. Delete `*-no-cursor*` duplicates
|
||||||
|
and any `with-cursor`/`MapCursor` rebind that fakes a deep starting position
|
||||||
|
(heuristics 1, 2). Using a cursor is fine; faking where it *starts* is not.
|
||||||
|
|
||||||
|
6. **Templatize in Selmer** (`reference/selmer-conventions.md`) where the component is
|
||||||
|
interactive/attribute-heavy. Reuse cookbook bits; add new ones back (heuristics 5, 8).
|
||||||
|
Static markup may stay Hiccup — Selmer scope is hybrid (Open decision 2).
|
||||||
|
|
||||||
|
7. **Wire HTMX per the swap doctrine** (`reference/swap-doctrine.md`). Keep the focus
|
||||||
|
invariant intact. No OOB except a genuinely disjoint region, documented (heuristic 7).
|
||||||
|
|
||||||
|
8. **Collapse routes** to 2 (`GET` open, `POST` submit) — `+1` for an add-row endpoint,
|
||||||
|
`+1` for the single `*-form-changed` whole-form re-render endpoint (heuristic 6).
|
||||||
|
|
||||||
|
9. **Verify.** Modal e2e green + full e2e suite at-or-above baseline; assert DB
|
||||||
|
mutations by querying Datomic, not markup; REPL-check the pure render/data fns.
|
||||||
|
Re-measure the scorecard — **no metric may regress for the touched modal** without a
|
||||||
|
written exception in `gotchas.md`.
|
||||||
|
|
||||||
|
10. **Commit** one reversible feature commit. The message includes the scorecard delta
|
||||||
|
and the reused/new cookbook entries.
|
||||||
|
|
||||||
|
11. **Feed the skill** (the Growth contract). *Not optional.*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Growth contract — the last task of every migration
|
||||||
|
|
||||||
|
- Converted a component? → add its before/after to `component-cookbook.md`.
|
||||||
|
- Hit a surprise? → one entry in `gotchas.md`.
|
||||||
|
- Found a test pattern? → `test-recipes.md`.
|
||||||
|
- Playbook step missing or wrong? → fix this `SKILL.md`.
|
||||||
|
- Measured the scorecard? → append the row to `scorecard.md`.
|
||||||
|
|
||||||
|
**Success signal:** each migration reuses more cookbook entries and starts from a better
|
||||||
|
scorecard baseline than the previous one.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Non-negotiables
|
||||||
|
|
||||||
|
- **Focus invariant:** the input the user is typing in is *never* inside the region its
|
||||||
|
own request swaps. Violating this drops the caret. (Proven by the
|
||||||
|
`transaction-edit-swap.spec.ts` caret tests.)
|
||||||
|
- **No new OOB swaps.** If tempted to OOB something inside the same feature, restructure
|
||||||
|
the DOM so the dependent element shares an ancestor with the trigger and use an
|
||||||
|
ordinary swap (e.g. totals in a sibling `<tbody>`).
|
||||||
|
- **Behavior parity is proven by tests, not by reading.** The full e2e suite stays green
|
||||||
|
after every migration.
|
||||||
|
- **Don't game the heuristics.** They're directional evidence paired with the e2e parity
|
||||||
|
gate; review the trend, not single numbers.
|
||||||
|
|
||||||
|
## Project conventions that bite (see `gotchas.md`)
|
||||||
|
|
||||||
|
- Edit Clojure with the clojure-mcp tools (`clojure_edit`, `clojure_edit_replace_sexp`),
|
||||||
|
not the raw file editor. `clj-paren-repair` then `lein cljfmt fix` when a file won't
|
||||||
|
compile.
|
||||||
|
- Run tests via the `clojure-eval` skill / `clj-nrepl-eval -p PORT`, not `lein test`.
|
||||||
|
- Temp files go in `./tmp/`.
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
# Component cookbook
|
||||||
|
|
||||||
|
GROWS every migration. Each entry: what it is, the swap rule it uses, and the canonical
|
||||||
|
snippet. Reuse these before writing anything new; the success signal is *more reuse each
|
||||||
|
migration*.
|
||||||
|
|
||||||
|
Seeded from `transaction/edit.clj` (Hiccup form — Selmer versions land in Phase 2).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## typeahead (account / vendor) — Alpine + tippy, survives swaps
|
||||||
|
|
||||||
|
Used for account and vendor selection. Click-to-select (not a live text caret), so a
|
||||||
|
whole-form swap on change is safe. Null-guard `tippy?`/`$refs.input?`.
|
||||||
|
|
||||||
|
```clojure
|
||||||
|
(defn account-typeahead* [{:keys [name value client-id x-model]}]
|
||||||
|
[:div.flex.flex-col
|
||||||
|
(com/typeahead {:name name
|
||||||
|
:placeholder "Search..."
|
||||||
|
:url (hu/url (bidi/path-for ssr-routes/only-routes :account-search)
|
||||||
|
(cond-> {:purpose "transaction"} client-id (assoc :client-id client-id)))
|
||||||
|
:id name
|
||||||
|
:x-model x-model ; binds selected value into the row's Alpine scope
|
||||||
|
:value value
|
||||||
|
:content-fn (fn [v] (:account/name (d-accounts/clientize ... v client-id)))})])
|
||||||
|
```
|
||||||
|
Reuse note: `:x-model` lets the *parent* row read the selected id (e.g. `accountId`) to
|
||||||
|
gate a targeted location swap. See account-row.
|
||||||
|
|
||||||
|
## account-row — cursor render fn + per-row targeted location swap + whole-form remove
|
||||||
|
|
||||||
|
The canonical "row in a repeated grid" pattern. One render fn, top-rooted cursor.
|
||||||
|
- account typeahead binds `accountId` into row Alpine scope;
|
||||||
|
- **location cell** swaps *only itself* (`#account-location-<index>`) on `changed`
|
||||||
|
(swap-doctrine Rule 2);
|
||||||
|
- **amount cell** swaps *only* `#account-totals` (Rule 4, sibling tbody);
|
||||||
|
- **remove** swaps the whole form (Rule 3).
|
||||||
|
|
||||||
|
```clojure
|
||||||
|
(defn transaction-account-row* [{:keys [value client-id amount-mode index]}]
|
||||||
|
(com/data-grid-row
|
||||||
|
(-> {:class "account-row" :id (str "account-row-" index)
|
||||||
|
:x-data (hx/json {:show ... :accountId (fc/field-value (:transaction-account/account value))})
|
||||||
|
:data-key "show" :x-ref "p"}
|
||||||
|
hx/alpine-mount-then-appear)
|
||||||
|
(fc/with-field :db/id (com/hidden {:name (fc/field-name) :value (fc/field-value)}))
|
||||||
|
(fc/with-field :transaction-account/account
|
||||||
|
(com/data-grid-cell {} (com/validated-field {:errors (fc/field-errors)}
|
||||||
|
(account-typeahead* {:value (fc/field-value) :client-id client-id
|
||||||
|
:name (fc/field-name) :x-model "accountId"}))))
|
||||||
|
(fc/with-field :transaction-account/location
|
||||||
|
(com/data-grid-cell {:id (str "account-location-" index)} ...Rule 2 targeted swap...))
|
||||||
|
(fc/with-field :transaction-account/amount
|
||||||
|
(com/data-grid-cell {} ...Rule 4 totals swap...))
|
||||||
|
(com/data-grid-cell {:class "align-top"} ...Rule 3 whole-form remove...)))
|
||||||
|
```
|
||||||
|
TODO Phase 2: drop the `transaction-account-row-no-cursor*` twin; this is the only kept form.
|
||||||
|
|
||||||
|
## totals in a sibling `<tbody>` — Rule 4 instead of OOB
|
||||||
|
|
||||||
|
Running totals live in their own `<tbody id="account-totals">`, a sibling of the
|
||||||
|
input-bearing rows, so an amount edit refreshes them with a plain targeted swap and never
|
||||||
|
replaces the amount input (caret survives).
|
||||||
|
|
||||||
|
```clojure
|
||||||
|
(com/data-grid
|
||||||
|
{:footer-tbody
|
||||||
|
[:tbody {:id "account-totals"}
|
||||||
|
(com/data-grid-row {:class "account-total-row"} ... (account-total* request) ...)
|
||||||
|
(com/data-grid-row {:class "account-balance-row"} ... (account-balance* request) ...)]}
|
||||||
|
...input rows...)
|
||||||
|
```
|
||||||
|
|
||||||
|
## money-input / text-input amount field — Rule 4 targeted totals swap
|
||||||
|
|
||||||
|
```clojure
|
||||||
|
(com/money-input
|
||||||
|
{:name (fc/field-name) :id (str "account-amount-" index) :class "w-16 account-amount-field"
|
||||||
|
:hx-post (bidi/path-for ssr-routes/only-routes ::route/edit-form-changed)
|
||||||
|
:hx-target "#account-totals" :hx-select "#account-totals" :hx-swap "outerHTML"
|
||||||
|
:hx-trigger "keyup changed delay:300ms" :hx-include "closest form"})
|
||||||
|
```
|
||||||
|
`%` mode swaps to `com/text-input {:type "number" :step "0.01"}` with the same swap attrs.
|
||||||
|
|
||||||
|
## memo field — Rule 1, no request
|
||||||
|
|
||||||
|
```clojure
|
||||||
|
(com/text-input {:value (fc/field-value) :name (fc/field-name) :id "edit-memo"
|
||||||
|
:placeholder "Optional note"}) ; no hx-* — rides along to save
|
||||||
|
```
|
||||||
|
|
||||||
|
## location-select — first Selmer-migrated component (validated)
|
||||||
|
|
||||||
|
The account row's location `<select>`, rendered from a Selmer template instead of
|
||||||
|
`com/select`. The first interactive modal component off Hiccup; proves the render-file
|
||||||
|
path + interop bridge on real, e2e-covered markup (swap 6/6, transaction-edit 8/8).
|
||||||
|
|
||||||
|
```clojure
|
||||||
|
;; templates/components/location-select.html — plain HTML, {% for %} + {% if selected %}
|
||||||
|
(defn location-select* [{:keys [name client-locations value ...]}]
|
||||||
|
(let [options (cond ...) ; [[value label] ...]
|
||||||
|
selected (or value (ffirst options))
|
||||||
|
classes (str/join " " (conj (vec inputs/default-input-classes) "w-full"))]
|
||||||
|
(sel/render->hiccup "templates/components/location-select.html"
|
||||||
|
{:name name :classes classes
|
||||||
|
:options (for [[v l] options] {:value v :label l :selected (= v selected)})})))
|
||||||
|
```
|
||||||
|
Reuse: pass `inputs/default-input-classes` in (don't hard-code); embed via
|
||||||
|
`render->hiccup` so it drops into the still-Hiccup row. See `selmer-conventions.md`.
|
||||||
|
|
||||||
|
## fixed-index row from explicit data — de-faking a deep cursor
|
||||||
|
|
||||||
|
When a row always lives at a known index (e.g. simple mode renders exactly `accounts[0]`),
|
||||||
|
render it from **explicit data with explicit field names** instead of faking a cursor
|
||||||
|
rooted there. Build the name the same way the cursor would (`path->name2`) and read errors
|
||||||
|
from the same path — no `with-cursor`/`MapCursor` rebind, no `with-field-default` (which
|
||||||
|
*mutates* the cursor and breaks swap behavior, see `gotchas.md`).
|
||||||
|
|
||||||
|
```clojure
|
||||||
|
(defn- account-field-name [index field] ; == path->name2 for this path
|
||||||
|
(str "step-params[transaction/accounts][" index "]["
|
||||||
|
(if (keyword? field)
|
||||||
|
(str (when (namespace field) (str (namespace field) "/")) (name field))
|
||||||
|
field) "]"))
|
||||||
|
|
||||||
|
(defn- account-field-errors [index field]
|
||||||
|
(when (bound? #'fc/*form-errors*)
|
||||||
|
(get-in fc/*form-errors* [:step-params :transaction/accounts index field])))
|
||||||
|
|
||||||
|
;; render the row directly -- no fc/with-field / fc/with-cursor wrappers
|
||||||
|
[:span
|
||||||
|
(com/hidden {:name (account-field-name 0 :db/id) :value row-id})
|
||||||
|
(com/validated-field {:errors (account-field-errors 0 :transaction-account/account)}
|
||||||
|
(account-typeahead* {:name (account-field-name 0 :transaction-account/account) ...}))
|
||||||
|
...]
|
||||||
|
```
|
||||||
|
Verify byte-parity against the cursor version (the swap spec's simple-mode tests catch
|
||||||
|
divergence). Scorecard heuristic 1: faked roots → 0.
|
||||||
|
|
||||||
|
## mode toggle ($/% radio, simple/advanced link) — Rule 3, whole-form swap
|
||||||
|
|
||||||
|
```clojure
|
||||||
|
(com/radio-card {:options [{:value "$" :content "$"} {:value "%" :content "%"}]
|
||||||
|
:value amount-mode :name "step-params[amount-mode]"
|
||||||
|
:hx-post (bidi/path-for ssr-routes/only-routes ::route/toggle-amount-mode)
|
||||||
|
:hx-target "#wizard-form" :hx-select "#wizard-form" :hx-swap "outerHTML"
|
||||||
|
:hx-include "closest form"})
|
||||||
|
```
|
||||||
|
TODO Phase 2: the simple/advanced toggle becomes a `?mode=` re-render (plain form), not a
|
||||||
|
dedicated route.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The Selmer component library (`auto-ap.ssr.components.selmer` / `sc`) — Phase 2-final
|
||||||
|
|
||||||
|
Every shared component the modal renders through is now a thin Clojure wrapper over a
|
||||||
|
partial under `resources/templates/components/`. **Reuse these before reaching for the
|
||||||
|
Hiccup `com/*` versions in a migrated modal.** Each wrapper builds a context (reusing the
|
||||||
|
real class helpers so output matches modulo Tailwind order) and renders its own partial via
|
||||||
|
the interop bridge; dynamic HTMX/Alpine attrs go through `sc/attrs->str` →
|
||||||
|
`{{ attrs|safe }}`. See `selmer-conventions.md` for the mechanics.
|
||||||
|
|
||||||
|
| Wrapper | Partial | Notes |
|
||||||
|
|---------|---------|-------|
|
||||||
|
| `sc/hidden` / `sc/text-input` / `sc/money-input` | `hidden`/`text-input`/`money-input`.html | leaf inputs; class via `inputs/default-input-classes` + `use-size` |
|
||||||
|
| `sc/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).
|
||||||
146
.claude/skills/ssr-form-migration/reference/form-vs-wizard.md
Normal file
146
.claude/skills/ssr-form-migration/reference/form-vs-wizard.md
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
# 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. **This engine is built in Phase 6** (Transaction Rule) — until then
|
||||||
|
this file describes the target; validate `components/wizard_state.clj` +
|
||||||
|
`components/wizard2.clj` against it when they land, and update this doc from the real
|
||||||
|
implementation.
|
||||||
199
.claude/skills/ssr-form-migration/reference/gotchas.md
Normal file
199
.claude/skills/ssr-form-migration/reference/gotchas.md
Normal file
@@ -0,0 +1,199 @@
|
|||||||
|
# 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. Clean signal: restart (re-seed) + **`--workers=1`**.
|
||||||
|
Baseline is **38 pass / 1 fail**, the 1 being the pre-existing
|
||||||
|
`transaction-navigation.spec.ts:92` date-range test (unrelated to the edit modal).
|
||||||
|
|
||||||
|
## Scorecard exceptions (ratchet violations with a reason)
|
||||||
|
|
||||||
|
**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.
|
||||||
@@ -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.
|
||||||
84
.claude/skills/ssr-form-migration/reference/scorecard.md
Normal file
84
.claude/skills/ssr-form-migration/reference/scorecard.md
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
# Quality scorecard (the ratchet)
|
||||||
|
|
||||||
|
Cheap to measure (`grep -c`, `wc -l`, `clj-kondo`), recorded **before/after each
|
||||||
|
migration** in the commit message and in the results table below. **No metric may regress
|
||||||
|
for the touched modal** without a written exception in `gotchas.md`. These are directional
|
||||||
|
evidence, not targets to game — always paired with the e2e parity gate.
|
||||||
|
|
||||||
|
## Heuristics
|
||||||
|
|
||||||
|
| # | Heuristic | Measure | Target |
|
||||||
|
|---|-----------|---------|--------|
|
||||||
|
| 1 | Faked cursor positions (not cursors themselves) | `grep -cE 'with-cursor\|MapCursor\.'` re-roots + `grep -c 'defn.*-no-cursor'` | → 0 (top-rooted cursors are fine) |
|
||||||
|
| 2 | Implicit state merges (snapshot/cursor) | count merge sites | → 0 (forms); explicit `put-step` only (wizards) |
|
||||||
|
| 3 | Branching complexity | `clj-kondo`, or count `cond`/`condp`/`case`/nested `if` + max depth | net ↓ |
|
||||||
|
| 4 | Lines of code | `wc -l` on the modal's file(s) | net ↓ |
|
||||||
|
| 5 | Reuse / cross-form similarity | cookbook components reused; duplicated-block count | reuse ↑, dup ↓ |
|
||||||
|
| 6 | Route count | count routes for the modal | → 2 (+1 for add-row) |
|
||||||
|
| 7 | OOB swaps | `grep -c hx-swap-oob` | → 0 unless a justified disjoint-region case is documented |
|
||||||
|
| 8 | Attribute consistency | mixed `:x-`/`"x-"` encodings in migrated template | → 0 |
|
||||||
|
|
||||||
|
## How to measure (copy/paste)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
F=src/clj/auto_ap/ssr/<modal>.clj
|
||||||
|
echo "LOC $(wc -l < $F)"
|
||||||
|
echo "no-cursor twins $(grep -c 'defn.*-no-cursor' $F)"
|
||||||
|
echo "faked-cursor roots $(grep -cE 'with-cursor|MapCursor\.' $F)"
|
||||||
|
echo "snapshot merges $(grep -c ':multi-form-state :snapshot' $F)"
|
||||||
|
echo "branch forms $(grep -cE '\(cond |\(condp |\(case |\(when-not ' $F)"
|
||||||
|
echo "hx-swap-oob $(grep -c 'hx-swap-oob' $F)"
|
||||||
|
echo "mixed string hx- $(grep -cE '\"hx-[a-z]' $F)"
|
||||||
|
# route count: count this modal's entries in src/cljc/auto_ap/routes/*.cljc
|
||||||
|
```
|
||||||
|
|
||||||
|
## Results
|
||||||
|
|
||||||
|
Each migration appends one row (after-numbers), referencing the before in the diff.
|
||||||
|
|
||||||
|
| Phase | Modal | LOC | Routes | no-cursor twins | faked roots | snapshot merges | OOB | mixed hx- | cookbook reused / added |
|
||||||
|
|-------|-------|-----|--------|-----------------|-------------|-----------------|-----|-----------|-------------------------|
|
||||||
|
| 1 (baseline) | Transaction Edit `transaction/edit.clj` | 1608 | ~12 | 1 | 2 | ~75 | 0 | 8 | — / seeded 7 entries |
|
||||||
|
| 2 | Transaction Edit `transaction/edit.clj` | 1593 | **~5** | **0** | **0** | **0 round-trip** | 0 | 8 (shared) | location-select / **1 Selmer** |
|
||||||
|
| 2-final | Transaction Edit (full Selmer + wizard removed) | 1548 | **5** | 0 | 0 | 0 | 0 | **0** | full `sc/*` lib / **~30 partials** |
|
||||||
|
|
||||||
|
### 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).
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
# Selmer template conventions
|
||||||
|
|
||||||
|
> **Validated** in the Transaction Edit migration: `location-select*` now renders from
|
||||||
|
> `resources/templates/components/location-select.html` via the interop bridge, embedded
|
||||||
|
> back into the Hiccup account row. Verified: swap spec 6/6, transaction-edit 8/8 (the
|
||||||
|
> Shared Location test selects through the Selmer `<select>`, saves, and spreads to DT).
|
||||||
|
|
||||||
|
## Why Selmer for interactive components
|
||||||
|
|
||||||
|
In Hiccup the same Alpine/HTMX attribute is sometimes a keyword and sometimes a string in
|
||||||
|
the same file — there's no rule a reader (or an LLM) can rely on. The real
|
||||||
|
`com/typeahead-` mixes them in one map:
|
||||||
|
|
||||||
|
```clojure
|
||||||
|
:x-modelable "value.value" ; keyword key
|
||||||
|
"x-ref" "hidden" ; string key
|
||||||
|
"@keydown.down.prevent.stop" "tippy?.show();" ; handlers MUST be strings
|
||||||
|
:x-init "..." ; structural attrs are keywords
|
||||||
|
```
|
||||||
|
|
||||||
|
In a Selmer template the same markup is unambiguous plain HTML:
|
||||||
|
|
||||||
|
```html
|
||||||
|
{# templates/components/typeahead.html #}
|
||||||
|
<div class="relative" x-data="{{ x_data|safe }}" x-model="{{ x_model }}">
|
||||||
|
<a class="{{ classes }}" x-ref="input" tabindex="0"
|
||||||
|
@keydown.down.prevent.stop="tippy?.show()"
|
||||||
|
@keydown.backspace="tippy?.hide(); value = {value:'', label:''}">
|
||||||
|
<span x-text="value.label"></span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
Note the `tippy?.` null-guard carried over from the swap doctrine — Selmer doesn't change
|
||||||
|
the Alpine-survives-swap requirement.
|
||||||
|
|
||||||
|
## The render helper + interop bridge (`auto-ap.ssr.selmer`)
|
||||||
|
|
||||||
|
```clojure
|
||||||
|
(sel/render template ctx) ; selmer/render-file -> HTML string (classpath-relative path)
|
||||||
|
(sel/render-str template ctx) ; render from a string (tests/REPL)
|
||||||
|
(sel/hiccup->html h) ; Hiccup -> string, for {{ frag|safe }} inside a template
|
||||||
|
(sel/raw html-string) ; wrap a rendered string so hiccup2 emits it verbatim
|
||||||
|
(sel/render->hiccup template ctx); render + raw, ready to drop into a Hiccup tree
|
||||||
|
```
|
||||||
|
|
||||||
|
The bridge works **both ways** (proven in `selmer_test`): a Hiccup component renders inside
|
||||||
|
a Selmer template (`hiccup->html` + `|safe`), and a Selmer fragment renders inside a Hiccup
|
||||||
|
tree (`render->hiccup`, which `raw`-wraps so hiccup2 doesn't double-escape).
|
||||||
|
|
||||||
|
## The worked example — `location-select*`
|
||||||
|
|
||||||
|
Template (`resources/templates/components/location-select.html`): plain HTML, an
|
||||||
|
`{% for %}` over option maps, `{% if opt.selected %}`.
|
||||||
|
|
||||||
|
```clojure
|
||||||
|
;; Clojure side: build the data, compute classes (reuse inputs/default-input-classes so
|
||||||
|
;; styling can't drift), render, and return a Hiccup-embeddable fragment.
|
||||||
|
(defn location-select* [{:keys [name client-locations value ...]}]
|
||||||
|
(let [options (cond ...) ; [[value label] ...]
|
||||||
|
selected (or value (ffirst options))
|
||||||
|
classes (str/join " " (conj (vec inputs/default-input-classes) "w-full"))]
|
||||||
|
(sel/render->hiccup "templates/components/location-select.html"
|
||||||
|
{:name name :classes classes
|
||||||
|
:options (for [[v l] options] {:value v :label l :selected (= v selected)})})))
|
||||||
|
```
|
||||||
|
|
||||||
|
Lessons:
|
||||||
|
- **Pass computed values in, don't hard-code.** Reuse the Clojure source of truth
|
||||||
|
(`inputs/default-input-classes`) as a context value rather than copying class strings
|
||||||
|
into the template — otherwise styling drifts from the shared components.
|
||||||
|
- **Verify by string-match + e2e, not byte-parity.** `hh/add-class` is set-based, so class
|
||||||
|
*order* differs from the old `com/select` output; CSS is order-independent and the e2e
|
||||||
|
proves behavior. (`testing-conventions`: don't assert on exact markup.)
|
||||||
|
- **Embed via the bridge.** `render->hiccup` lets the Selmer fragment sit inside the
|
||||||
|
still-Hiccup row (`com/validated-field` wraps it) — strangler, one component at a time.
|
||||||
|
|
||||||
|
## Composition — verified mechanics (selmer 1.12.61)
|
||||||
|
|
||||||
|
Proven by REPL before the full migration (do the same before relying on any of these):
|
||||||
|
|
||||||
|
- **`{% include %}` works in FILE renders only.** `sel/render` = `selmer/render-file`, and
|
||||||
|
include/extends/block are *parse-stage* tags. Rendering a template **string** that
|
||||||
|
contains `{% include %}` throws `unrecognized tag: :include` (the runtime expr-tag has a
|
||||||
|
nil handler). So includes only work from a `.html` file, never from `render-str`.
|
||||||
|
- **`{% include %}` sees `{% for %}` loop bindings.** Inside `{% for row in rows %}…{% include "components/x.html" %}…{% endfor %}` the partial can read `row.*`. Good for repeated
|
||||||
|
rows — though Clojure-composing the rows (below) is usually simpler.
|
||||||
|
- **`{% include … with k=v %}` is IGNORED** in 1.12.61 (the `with` clause is dropped). To
|
||||||
|
parametrize, either wrap with the block tag `{% with k=v %}{% include … %}{% endwith %}`
|
||||||
|
(works), or — preferred — let a Clojure wrapper render the partial with an explicit ctx.
|
||||||
|
|
||||||
|
## The Clojure-wrapper composition pattern (`auto-ap.ssr.components.selmer` / `sc`)
|
||||||
|
|
||||||
|
Because `{% include with %}` can't pass args and the server computes most values anyway,
|
||||||
|
each shared component is a **thin Clojure wrapper that renders its own partial** (the
|
||||||
|
proven `location-select*` shape, generalised). The element *structure* lives 100% in the
|
||||||
|
`.html`; the only Clojure is data assembly. The modal's render fns compose these wrappers
|
||||||
|
and assemble a view-model, interpolating the pre-rendered fragments as `{{ frag|safe }}`.
|
||||||
|
|
||||||
|
```clojure
|
||||||
|
(sc/hidden {:name … :value …}) ; -> render "components/hidden.html"
|
||||||
|
(sc/validated-field {:label … :errors …} body…)
|
||||||
|
(sc/typeahead {:name … :url … :value … :content-fn …}) ; resolves label server-side
|
||||||
|
(sc/data-grid {:headers […] :footer-tbody …} rows…)
|
||||||
|
```
|
||||||
|
|
||||||
|
### `attrs->str` — the dynamic-attribute bridge
|
||||||
|
|
||||||
|
HTMX/Alpine attributes vary per call site, so a fixed template can't enumerate them.
|
||||||
|
`sc/attrs->str` serialises an attribute *map* to an HTML attribute *string* injected with
|
||||||
|
`{{ attrs|safe }}`: `<input type="hidden"{{ attrs|safe }}>`. Rules mirror hiccup2 — nil/false
|
||||||
|
dropped, `true` → bare boolean attr, else `name="escaped"` (via `hu/escape-html`, so JSON
|
||||||
|
`x-data` and `x-init` quotes become `"`/`'` and the browser decodes them back).
|
||||||
|
Keyword keys keep their full name incl. namespaced/colon forms (`:x-hx-val:account-id` →
|
||||||
|
`x-hx-val:account-id`). This keeps templates free of per-attribute `{% if %}` ladders while
|
||||||
|
still emitting 100% Selmer markup — `attrs->str` serialises data, it does not build Hiccup.
|
||||||
|
|
||||||
|
### Reuse the real class helpers
|
||||||
|
|
||||||
|
Wrappers reuse `inputs/default-input-classes`, `inputs/use-size`, `hh/add-class`,
|
||||||
|
`btn/bg-colors` so output matches the Hiccup component **modulo Tailwind class order**.
|
||||||
|
Verify by class-**set** equality + e2e, never byte-parity (see the cookbook entries).
|
||||||
|
|
||||||
|
### Trivial wrapper divs
|
||||||
|
|
||||||
|
A bare `<div class="w-72">…</div>` around a fragment is composed with a `wrap-div` string
|
||||||
|
helper (or put the class in the parent template), not a Hiccup vector — string composition
|
||||||
|
of a structural wrapper is not Hiccup and avoids a micro-template per div.
|
||||||
|
|
||||||
|
Keep `|safe` to values the server fully controls (rendered fragments, JSON for `x-data`),
|
||||||
|
never raw user input.
|
||||||
|
|
||||||
|
## Scope (Open decision 2)
|
||||||
|
|
||||||
|
Hybrid, and the boundary is real: **the modal's attribute-heavy components delegate to the
|
||||||
|
shared `com/typeahead` / `com/select` / `com/button-group-button`.** Converting those is a
|
||||||
|
*cross-cutting* change (every modal uses them), so it belongs to the Phase 11 Selmer sweep,
|
||||||
|
not a single modal. `location-select*` is the first, self-contained proof; the shared
|
||||||
|
components follow when the sweep promotes them to Selmer partials.
|
||||||
|
|
||||||
|
## Attribute-consistency scorecard (heuristic 8)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
grep -cE '"x-[a-z]|"hx-[a-z]|"@' <migrated-template> # → 0 mixed encodings in Selmer
|
||||||
|
```
|
||||||
|
A migrated Selmer template has no mixed `:x-`/`"x-"` encodings because everything is plain
|
||||||
|
HTML. (The Hiccup `"@click"`/`":class"` offenders that remain in `edit.clj` live in the
|
||||||
|
shared-component call sites — they clear when those components move to Selmer.)
|
||||||
149
.claude/skills/ssr-form-migration/reference/swap-doctrine.md
Normal file
149
.claude/skills/ssr-form-migration/reference/swap-doctrine.md
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
# Whole-form HTMX swap doctrine
|
||||||
|
|
||||||
|
Every interactive control picks a swap strategy in this **priority order** (prefer the
|
||||||
|
earliest rule that works). Worked examples are the real `transaction/edit.clj` swaps.
|
||||||
|
|
||||||
|
## Rule 1 — No request when the field affects nothing else
|
||||||
|
|
||||||
|
Its value rides along in the form and is read on submit. No `hx-*` at all.
|
||||||
|
|
||||||
|
```clojure
|
||||||
|
;; transaction/edit.clj — the memo field. Editing it issues NO request; the value
|
||||||
|
;; just rides along until save. The e2e proves zero POSTs fire while typing.
|
||||||
|
(com/text-input {:value (fc/field-value)
|
||||||
|
:name (fc/field-name)
|
||||||
|
:id "edit-memo"
|
||||||
|
:placeholder "Optional note"})
|
||||||
|
```
|
||||||
|
|
||||||
|
## Rule 2 — Targeted swap of a single isolated cell when the effect is purely local
|
||||||
|
|
||||||
|
Give the cell a stable id, keep it **out of the typed input's subtree**, and post the
|
||||||
|
whole form but `hx-select` back only that cell.
|
||||||
|
|
||||||
|
```clojure
|
||||||
|
;; transaction/edit.clj — selecting an account only changes that row's valid Location
|
||||||
|
;; options, so the change swaps just this cell. Nothing else re-renders.
|
||||||
|
[:div {:id (str "account-location-" index)} ; stable, per-row id
|
||||||
|
(com/validated-field
|
||||||
|
{:x-hx-val:account-id "accountId"
|
||||||
|
:x-dispatch:changed "accountId" ; Alpine fires `changed` when account changes
|
||||||
|
:hx-trigger "changed"
|
||||||
|
:hx-post (bidi/path-for ssr-routes/only-routes ::route/edit-form-changed)
|
||||||
|
:hx-target (str "#account-location-" index)
|
||||||
|
:hx-select (str "#account-location-" index)
|
||||||
|
:hx-swap "outerHTML"
|
||||||
|
:hx-include "closest form"} ; whole form posts; only this cell swaps back
|
||||||
|
(location-select* {...}))]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Rule 3 — Whole-form swap when the change touches interdependent state
|
||||||
|
|
||||||
|
Vendor change, add/remove row, mode toggle, $/% radio. The form's hidden state rides
|
||||||
|
along, so one swap keeps everything consistent — **no out-of-band swaps**.
|
||||||
|
|
||||||
|
```clojure
|
||||||
|
;; transaction/edit.clj — vendor change rebuilds the whole manual-coding section
|
||||||
|
;; (vendor default account, terms, etc. are interdependent).
|
||||||
|
[:div {:hx-trigger "change"
|
||||||
|
:hx-post (bidi/path-for ssr-routes/only-routes ::route/edit-vendor-changed)
|
||||||
|
:hx-target "#wizard-form"
|
||||||
|
:hx-select "#wizard-form"
|
||||||
|
:hx-swap "outerHTML"
|
||||||
|
:hx-sync "this:replace"
|
||||||
|
:hx-include "closest form"}
|
||||||
|
...]
|
||||||
|
```
|
||||||
|
|
||||||
|
The active tab/action round-trips through the form (it's a hidden field bound to Alpine
|
||||||
|
`activeForm`), so it **survives** the whole-form swap — that's why a whole-form swap is
|
||||||
|
safe here even though the user is "on" a tab.
|
||||||
|
|
||||||
|
## Rule 4 — OOB only for genuinely disjoint DOM regions
|
||||||
|
|
||||||
|
A global flash/toast, a nav badge, a modal at the document root. **If tempted to OOB
|
||||||
|
something inside the same feature, restructure instead**: give the dependent element a
|
||||||
|
common ancestor with the trigger and use an ordinary swap.
|
||||||
|
|
||||||
|
Worked example — running **totals live in their own sibling `<tbody>`** so an amount edit
|
||||||
|
swaps the totals without ever replacing the amount input:
|
||||||
|
|
||||||
|
```clojure
|
||||||
|
;; The totals tbody is a sibling of the input-bearing rows.
|
||||||
|
(com/data-grid
|
||||||
|
{:footer-tbody [:tbody {:id "account-totals"} ...totals rows...]}
|
||||||
|
...account rows with inputs...)
|
||||||
|
|
||||||
|
;; The amount input posts the whole form but hx-selects ONLY #account-totals.
|
||||||
|
(com/money-input
|
||||||
|
{:name (fc/field-name)
|
||||||
|
:id (str "account-amount-" index)
|
||||||
|
:class "w-16 account-amount-field"
|
||||||
|
:hx-post (bidi/path-for ssr-routes/only-routes ::route/edit-form-changed)
|
||||||
|
:hx-target "#account-totals" ; a SIBLING of this input's row...
|
||||||
|
:hx-select "#account-totals" ; ...so the input is never in the swapped region
|
||||||
|
:hx-swap "outerHTML"
|
||||||
|
:hx-trigger "keyup changed delay:300ms"
|
||||||
|
:hx-include "closest form"})
|
||||||
|
```
|
||||||
|
|
||||||
|
`grep -c hx-swap-oob` on a migrated modal must be `0` unless a justified disjoint-region
|
||||||
|
case is documented here and in `gotchas.md`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The focus invariant (must always hold)
|
||||||
|
|
||||||
|
> The input the user is typing in is never inside the region its own request swaps.
|
||||||
|
|
||||||
|
This is *the* reason the doctrine works. The amount field swaps a sibling tbody; the memo
|
||||||
|
field swaps nothing; the account typeahead's change swaps the whole form but the typeahead
|
||||||
|
isn't an active text caret at that moment (it's a click-to-select). The
|
||||||
|
`transaction-edit-swap.spec.ts` `sameNode` assertions exist to catch any violation.
|
||||||
|
|
||||||
|
## Alpine components must survive swaps
|
||||||
|
|
||||||
|
When a whole-form swap replaces a region containing Alpine/tippy components, they get
|
||||||
|
re-initialised from the server-provided values. Two hardening moves:
|
||||||
|
|
||||||
|
1. **Null-guard every reference** that depends on Alpine/tippy being initialised:
|
||||||
|
```clojure
|
||||||
|
"@keydown.down.prevent.stop" "tippy?.show()"
|
||||||
|
"@keydown.enter.prevent.stop" "$refs.input?.__x_tippy?.hide(); ..."
|
||||||
|
```
|
||||||
|
(`$refs.input?` / `tippy?` — the `?` matters; a swap can run a handler before re-init.)
|
||||||
|
|
||||||
|
2. **Let the server value win.** Because the section is rebuilt fresh on each swap, the
|
||||||
|
server-driven value (e.g. a vendor's default account) lands without keying tricks —
|
||||||
|
no preserved stale Alpine state to fight. The "changing the vendor a *second* time
|
||||||
|
still updates it" e2e is the regression guard for this.
|
||||||
|
|
||||||
|
If you *do* preserve a component across a morph/replace, key it by its server value so
|
||||||
|
a server-driven change forces re-init: `(assoc attrs :key (str id "--" current-value))`.
|
||||||
|
|
||||||
|
Use `hx/alpine-mount-then-appear` for rows that should mount-then-transition-in (it sets
|
||||||
|
`x-data {data-key false}`, `x-init $nextTick(() => key=true)`, `x-show key`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Selector strategy for targeted swaps (a consideration, not a mandate)
|
||||||
|
|
||||||
|
Rules 2 and 4 need a stable `hx-target`/`hx-select`. Per-element unique ids
|
||||||
|
(`#account-location-0`) work and are what transaction-edit uses today. They get noisy in
|
||||||
|
deeply repeated/nested structures. When you hit that (Phase 5 / the wizards), consider:
|
||||||
|
|
||||||
|
- **Semantic markup + data-attributes** — mark rows/cells with their identity and target
|
||||||
|
by attribute, no per-element ids:
|
||||||
|
```html
|
||||||
|
<tr data-row="account" data-index="0">
|
||||||
|
<td data-cell="location"> … </td>
|
||||||
|
</tr>
|
||||||
|
<!-- hx-target="[data-row='account'][data-index='0'] [data-cell='location']" -->
|
||||||
|
```
|
||||||
|
- **A `form-path -> selector` function**, derived the same way a cursor path is, so the
|
||||||
|
server and the markup agree on the target by construction. A render fn at form-path
|
||||||
|
`[:accounts 0 :location]` computes its own stable selector from that path.
|
||||||
|
|
||||||
|
**Decision status:** still per-element ids. The first modal to hit nested repeated swaps
|
||||||
|
(Invoice Bulk Edit, Phase 5) settles the convention and records it here + in
|
||||||
|
`component-cookbook.md` for the wizards to reuse.
|
||||||
137
.claude/skills/ssr-form-migration/reference/test-recipes.md
Normal file
137
.claude/skills/ssr-form-migration/reference/test-recipes.md
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
# Test recipes
|
||||||
|
|
||||||
|
GROWS every migration. How to characterize and verify a modal. Consistent with the
|
||||||
|
project `testing-conventions` skill: test user-observable behavior, assert DB state
|
||||||
|
directly, don't test the means.
|
||||||
|
|
||||||
|
## The three test layers
|
||||||
|
|
||||||
|
1. **Characterization e2e first (Playwright).** Before changing a modal, write/confirm a
|
||||||
|
spec capturing *current* behavior — focus/caret survival across swaps, each field
|
||||||
|
round-trip, validation errors, the real save. This is the parity contract; keep it
|
||||||
|
green through every commit.
|
||||||
|
2. **Pure-function checks via REPL.** Once render/data-prep fns are pure, exercise them
|
||||||
|
with `clojure-eval` / `clj-nrepl-eval -p <port>`. Assert on returned data; for markup
|
||||||
|
use string matches (`(re-find #"accounts\[0\]\[account\]" (str html))`) — this style
|
||||||
|
survives the Selmer switch. Avoid brittle structural assertions.
|
||||||
|
3. **DB-state assertions for mutations.** If a submit writes Datomic, verify by querying
|
||||||
|
the DB, not by asserting on markup.
|
||||||
|
|
||||||
|
## Running e2e
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx playwright test # full suite
|
||||||
|
npx playwright test e2e/transaction-edit-swap.spec.ts # one spec
|
||||||
|
```
|
||||||
|
- Config: `playwright.config.ts`, `baseURL http://localhost:3333`, `webServer:
|
||||||
|
lein run -m auto-ap.test-server`, `reuseExistingServer: !CI`.
|
||||||
|
- **The server must be from the worktree you're testing.** `reuseExistingServer` will
|
||||||
|
silently reuse *any* server on `:3333` — including another worktree's. Confirm with
|
||||||
|
`ls -la /proc/$(lsof -ti :3333)/cwd` (or restart on a clean port) before trusting a run.
|
||||||
|
- The test-server port is hardcoded (`test_server.clj` `run-jetty {:port 3333}`); to run a
|
||||||
|
second server from another worktree, change that or parameterise it.
|
||||||
|
|
||||||
|
## Driving a typeahead in e2e (Solr unavailable in tests)
|
||||||
|
|
||||||
|
```js
|
||||||
|
await typeahead.locator('a[x-ref="input"]').click(); // open tippy dropdown
|
||||||
|
const search = page.locator('[data-tippy-root] input[x-model="search"]').first();
|
||||||
|
await search.fill('te'); // under 3-char Solr threshold
|
||||||
|
await typeahead.evaluate((el, id) => { // inject a clickable result
|
||||||
|
window.Alpine.$data(el).elements = [{ value: id, label: 'Test Account' }];
|
||||||
|
}, accountId);
|
||||||
|
await page.locator('[data-tippy-root] a', { hasText: 'Test Account' }).first().click();
|
||||||
|
```
|
||||||
|
Entity ids come from `GET /test-info` (`{accounts:{test-account, vendor, vendor2, ...}}`).
|
||||||
|
|
||||||
|
## Proving the focus invariant (caret survival) — the key swap test
|
||||||
|
|
||||||
|
```js
|
||||||
|
// before the debounced swap lands, capture the live focused node...
|
||||||
|
await page.evaluate(() => { window.__focused = document.activeElement; });
|
||||||
|
await swap; // waitForResponse on the *-form-changed POST
|
||||||
|
const ok = await page.evaluate(() => {
|
||||||
|
const a = document.activeElement;
|
||||||
|
return { sameNode: a === window.__focused, value: a?.value, caret: a?.selectionStart };
|
||||||
|
});
|
||||||
|
// ...assert the SAME node survived with value + caret intact.
|
||||||
|
```
|
||||||
|
`trackErrors(page)` (collect `pageerror` + `console.error`, assert `[]`) catches a swap
|
||||||
|
that throws on a stale `$refs`/`tippy` — pair it with every swap test.
|
||||||
|
|
||||||
|
## Asserting "no request" (Rule 1 fields)
|
||||||
|
|
||||||
|
```js
|
||||||
|
let posts = 0;
|
||||||
|
page.on('request', r => { if (r.url().includes('edit-form-changed') && r.method()==='POST') posts++; });
|
||||||
|
// ...type in the memo...
|
||||||
|
expect(posts).toBe(0); // memo affects nothing → issues no request
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## E2E baseline (the regression gate — never drop below this)
|
||||||
|
|
||||||
|
The full suite must stay green after every migration. Specs touching the migrated modals:
|
||||||
|
|
||||||
|
| Spec | Tests | Role |
|
||||||
|
|------|-------|------|
|
||||||
|
| `e2e/transaction-edit-swap.spec.ts` | 8 | **Phase 2 parity contract** — whole-form `hx-select` swaps, caret survival, no-request memo, vendor re-select |
|
||||||
|
| `e2e/transaction-edit.spec.ts` | 15 | transaction edit behavior |
|
||||||
|
| `e2e/bulk-code-transactions.spec.ts` | 18 | Phase 3 (bulk code) |
|
||||||
|
| `e2e/transaction-import.spec.ts` | 4 | import |
|
||||||
|
| `e2e/transaction-navigation.spec.ts` | 13 | navigation |
|
||||||
|
|
||||||
|
### Running e2e from a non-default worktree (recipe)
|
||||||
|
|
||||||
|
`:3333` is often taken by another worktree's server. To run this worktree's code:
|
||||||
|
|
||||||
|
1. Boot the test server in-process on this worktree's REPL at an alternate port — no
|
||||||
|
second JVM, and it live-reloads as you edit:
|
||||||
|
```clojure
|
||||||
|
(require '[auto-ap.test-server :as ts] '[ring.adapter.jetty :refer [run-jetty]]
|
||||||
|
'[datomic.api :as dc])
|
||||||
|
;; reseed helper — call before each full run so state doesn't leak between runs
|
||||||
|
(defn reseed! []
|
||||||
|
(try (.stop (:server test-srv)) (catch Throwable _))
|
||||||
|
(try (dc/delete-database "datomic:mem://playwright-test") (catch Throwable _))
|
||||||
|
(def test-srv (let [c (ts/create-test-db) id (ts/seed-test-data c)]
|
||||||
|
(reset! ts/test-transaction-id id)
|
||||||
|
{:server (run-jetty (ts/test-app) {:port 3334 :join? false}) :tx-id id})))
|
||||||
|
(reseed!)
|
||||||
|
```
|
||||||
|
2. `playwright.config.ts` honors `BASE_URL`; setting it also disables the auto-started
|
||||||
|
webServer (so worktrees don't fight over :3333):
|
||||||
|
```bash
|
||||||
|
BASE_URL=http://localhost:3334 npx playwright test --workers=1 --reporter=line
|
||||||
|
```
|
||||||
|
3. **Reseed (`reseed!`) before each full run.** One long-lived in-process server persists
|
||||||
|
its in-mem DB across separate `npx playwright` invocations; the swap spec's
|
||||||
|
`clearAccounts`/save mutate the shared transaction and leak into later specs. The
|
||||||
|
normal harness avoids this by booting a fresh server per `npx playwright test`.
|
||||||
|
|
||||||
|
### Pass/fail baseline — measured on the merged hx-select reference (Phase 2 start)
|
||||||
|
|
||||||
|
Server: in-process from `integreat-execute-refactor` on `:3334`, `--workers=1`, fresh seed.
|
||||||
|
|
||||||
|
| Spec | Result |
|
||||||
|
|------|--------|
|
||||||
|
| `transaction-edit-swap.spec.ts` | **6 / 6 pass** — the whole-form swap parity contract |
|
||||||
|
| `transaction-edit.spec.ts` | **1 fail (masks 7 via `mode: 'serial'`)** — `Shared Location … spread on save and reopen` fails: the save POST returns a validation error (amount/balance test-data assumption: "$200 = full amount of the 2nd transaction" doesn't hold), so the modal stays open. **Pre-existing on the merged reference, not introduced by this work.** |
|
||||||
|
| Full suite (39) | **30 pass / 2 fail / 7 skipped.** 2nd failure: `transaction-navigation.spec.ts` date-range persistence (`date-range=all` expected, got `month`) — drift from the base branch's "require Apply for date-range filters" change, unrelated to forms. |
|
||||||
|
|
||||||
|
**Gate for the Transaction Edit refactor:** the 6/6 swap-doctrine spec + REPL pure-fn
|
||||||
|
checks.
|
||||||
|
|
||||||
|
### Current state — after the Phase 2 modal work (never drop below this)
|
||||||
|
|
||||||
|
Full suite (workers=1, fresh seed): **38 passed / 1 failed / 0 skipped.**
|
||||||
|
|
||||||
|
- `transaction-edit-swap.spec.ts` — **6/6** (parity contract held through every change).
|
||||||
|
- `transaction-edit.spec.ts` — **8/8** (was 1 pass + 7 masked). Greened by: the `:mode`
|
||||||
|
500 fix, the Alpine-v3 typeahead helper, rewriting the percentage-split test to avoid
|
||||||
|
the snapshot-drops-live-values ordering trap, reading the real transaction total instead
|
||||||
|
of a hard-coded `400`, and dropping the removed `"Transaction Actions"` wizard-nav step.
|
||||||
|
- Remaining 1 failure: `transaction-navigation.spec.ts:92` date-range-preset persistence —
|
||||||
|
**unrelated to forms** (drift from the base branch's "require Apply for date-range
|
||||||
|
filters" change). Pre-existing; out of scope for this migration.
|
||||||
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([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -13,12 +13,20 @@ async function openEditModal(page: any, transactionIndex: number = 0) {
|
|||||||
|
|
||||||
// Wait for the modal to open
|
// Wait for the modal to open
|
||||||
await page.waitForSelector('#modal-holder[x-show="open"]', { state: 'visible' });
|
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 modal is now single-page (Edit Transaction). Click "Manual" tab to ensure
|
||||||
// the manual account coding form is active.
|
// the manual account coding form is active.
|
||||||
await page.click('button:has-text("Manual")');
|
await page.click('button:has-text("Manual")');
|
||||||
|
|
||||||
|
// Transactions with 0-1 accounts open in "simple" mode, which has no account
|
||||||
|
// grid. Switch to "advanced" mode (a whole-form morph 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 to appear
|
// Wait for the manual form to appear
|
||||||
await page.waitForSelector('#account-grid-body');
|
await page.waitForSelector('#account-grid-body');
|
||||||
}
|
}
|
||||||
@@ -33,68 +41,33 @@ async function getTestInfo(page: any) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function selectAccountFromTypeahead(page: any, rowIndex: number, accountName: string) {
|
async function selectAccountFromTypeahead(page: any, rowIndex: number, accountName: string) {
|
||||||
// The account search uses Solr which isn't available in tests.
|
// Account search is backed by Solr (unavailable in tests). Drive the typeahead the
|
||||||
// Instead, we directly set the hidden input value via JavaScript.
|
// 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,
|
||||||
// Get all rows except the new-row, total, balance, and transaction total rows
|
// Alpine reactivity and the HTMX swap exactly as in production -- unlike poking the
|
||||||
const allRows = page.locator('#account-grid-body tbody tr');
|
// long-removed Alpine v2 `__x` internal, which silently no-ops on Alpine v3 and left
|
||||||
const rowCount = await allRows.count();
|
// the posted account empty.
|
||||||
|
|
||||||
// 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);
|
|
||||||
const accountKey = accountName === 'Test' ? 'test-account' : 'second-account';
|
const accountKey = accountName === 'Test' ? 'test-account' : 'second-account';
|
||||||
|
const label = `${accountName} Account`;
|
||||||
|
const testInfo = await getTestInfo(page);
|
||||||
const accountId = testInfo.accounts[accountKey];
|
const accountId = testInfo.accounts[accountKey];
|
||||||
|
|
||||||
if (!accountId) {
|
if (!accountId) {
|
||||||
throw new Error(`Could not find account with name ${accountName}`);
|
throw new Error(`Could not find account with name ${accountName}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set the hidden input value and trigger change
|
const row = page.locator('#account-grid-body tbody tr.account-row').nth(rowIndex);
|
||||||
// Also update Alpine.js data to prevent it from overwriting our value
|
const typeahead = row.locator('div.relative[x-data]').first();
|
||||||
await hiddenInput.evaluate((el: HTMLInputElement, value: string) => {
|
await typeahead.locator('a[x-ref="input"]').click();
|
||||||
// Set the DOM value
|
const search = page.locator('[data-tippy-root] input[x-model="search"]').first();
|
||||||
el.value = value;
|
await search.waitFor({ state: 'visible' });
|
||||||
|
await search.fill('te');
|
||||||
|
await typeahead.evaluate((el: any, opt: { id: number; label: string }) => {
|
||||||
|
(window as any).Alpine.$data(el).elements = [{ value: opt.id, label: opt.label }];
|
||||||
|
}, { id: accountId, label });
|
||||||
|
await page.locator('[data-tippy-root] a', { hasText: label }).first().click();
|
||||||
|
|
||||||
// Update Alpine.js component data
|
// Wait for the change-gated whole-form swap to settle.
|
||||||
const alpineEl = el.closest('[x-data]');
|
await page.waitForTimeout(400);
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function findAccountRow(page: any, rowIndex: number) {
|
async function findAccountRow(page: any, rowIndex: number) {
|
||||||
@@ -151,14 +124,13 @@ async function getAccountLocation(page: any, rowIndex: number): Promise<string>
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function removeAllAccounts(page: any) {
|
async function removeAllAccounts(page: any) {
|
||||||
const accountRows = page.locator('#account-grid-body tbody tr.account-row');
|
// Re-query each iteration: every remove is a whole-form swap that re-renders the rows,
|
||||||
const rowCount = await accountRows.count();
|
// so a row index captured up front goes stale. Click the last remove button until none
|
||||||
|
// remain.
|
||||||
for (let i = rowCount - 1; i >= 0; i--) {
|
for (let guard = 0; guard < 20; guard++) {
|
||||||
const row = accountRows.nth(i);
|
const removeButtons = page.locator('#account-grid-body .account-remove-action');
|
||||||
const removeButton = row.locator('.account-remove-action');
|
if (await removeButtons.count() === 0) break;
|
||||||
await removeButton.click();
|
await removeButtons.last().click();
|
||||||
// Wait for the Alpine.js removal animation (500ms + buffer)
|
|
||||||
await page.waitForTimeout(700);
|
await page.waitForTimeout(700);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -172,23 +144,23 @@ async function saveTransaction(page: any) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function toggleToPercentMode(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();
|
await percentRadio.click();
|
||||||
|
|
||||||
// Wait for HTMX to swap the grid body
|
// Wait for HTMX to swap the grid body
|
||||||
await page.waitForResponse(response =>
|
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);
|
await page.waitForTimeout(200);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function toggleToDollarMode(page: any) {
|
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();
|
await dollarRadio.click();
|
||||||
|
|
||||||
// Wait for HTMX to swap the grid body
|
// Wait for HTMX to swap the grid body
|
||||||
await page.waitForResponse(response =>
|
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);
|
await page.waitForTimeout(200);
|
||||||
}
|
}
|
||||||
@@ -237,78 +209,39 @@ test.describe('Transaction Edit Shared Location', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test.describe('Transaction Edit Full Workflow', () => {
|
test.describe('Transaction Edit Full Workflow', () => {
|
||||||
test('should code transaction with vendor using percentage, then split 50/50, then switch to dollars', async ({ page }) => {
|
test('splits a transaction 50/50 in percentage mode and stores it as dollars', async ({ page }) => {
|
||||||
// Step 1: Open edit modal and code with 100% to one account
|
// Transaction 0 is $100. Code it 50% / 50% across two accounts in percentage mode and
|
||||||
await openEditModal(page);
|
// verify the save-time %->$ conversion stores/displays $50 + $50 on reopen.
|
||||||
|
//
|
||||||
// Switch to percentage mode first (this re-renders the grid from server state)
|
// 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);
|
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);
|
await addNewAccount(page);
|
||||||
}
|
|
||||||
|
|
||||||
// Select the account
|
|
||||||
await selectAccountFromTypeahead(page, 0, 'Test');
|
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');
|
await setAccountAmount(page, 0, '50');
|
||||||
|
|
||||||
// Add a second account at 50%
|
|
||||||
await addNewAccount(page);
|
await addNewAccount(page);
|
||||||
await page.waitForTimeout(1000);
|
|
||||||
await selectAccountFromTypeahead(page, 1, 'Second');
|
await selectAccountFromTypeahead(page, 1, 'Second');
|
||||||
await setAccountAmount(page, 1, '50');
|
await setAccountAmount(page, 1, '50');
|
||||||
|
|
||||||
// Save
|
|
||||||
await saveTransaction(page);
|
await saveTransaction(page);
|
||||||
|
|
||||||
// Step 3: Re-open and verify dollar amounts
|
// Reopen: dollar mode is the default, and each account is the converted $50.
|
||||||
await openEditModal(page);
|
await openEditModal(page, 0);
|
||||||
|
|
||||||
// The accounts should be persisted from the previous save
|
|
||||||
// Wait for accounts to load
|
|
||||||
await page.waitForTimeout(500);
|
await page.waitForTimeout(500);
|
||||||
|
|
||||||
// Verify we're in dollar mode (default)
|
const dollarRadio = page.locator('input[name="amount-mode"][value="$"]');
|
||||||
const dollarRadio = page.locator('input[name="step-params[amount-mode]"][value="$"]');
|
|
||||||
await expect(dollarRadio).toBeChecked();
|
await expect(dollarRadio).toBeChecked();
|
||||||
|
|
||||||
// Verify amounts are in dollars (converted from percentages on save)
|
const val0 = await (await findAccountRow(page, 0)).locator('.account-amount-field').inputValue();
|
||||||
const row0 = await findAccountRow(page, 0);
|
const val1 = await (await findAccountRow(page, 1)).locator('.account-amount-field').inputValue();
|
||||||
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();
|
|
||||||
|
|
||||||
expect(parseFloat(val0)).toBeCloseTo(50.0, 1);
|
expect(parseFloat(val0)).toBeCloseTo(50.0, 1);
|
||||||
expect(parseFloat(val1)).toBeCloseTo(50.0, 1);
|
expect(parseFloat(val1)).toBeCloseTo(50.0, 1);
|
||||||
|
|
||||||
// Save
|
|
||||||
await saveTransaction(page);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -339,7 +272,7 @@ test.describe('Transaction Edit Validation', () => {
|
|||||||
await expect(page.locator('#modal-holder[x-show="open"]')).toBeVisible();
|
await expect(page.locator('#modal-holder[x-show="open"]')).toBeVisible();
|
||||||
|
|
||||||
// The form should still be present
|
// The form should still be present
|
||||||
const form = page.locator('#wizard-form');
|
const form = page.locator('#edit-form');
|
||||||
await expect(form).toBeVisible();
|
await expect(form).toBeVisible();
|
||||||
|
|
||||||
// Verify the account row is still there with our $50 value
|
// Verify the account row is still there with our $50 value
|
||||||
@@ -367,15 +300,11 @@ async function openEditModalForTransaction(page: any, description: string) {
|
|||||||
const editButton = row.locator('button[hx-get*="/transaction2/"][hx-get*="/edit"]').first();
|
const editButton = row.locator('button[hx-get*="/transaction2/"][hx-get*="/edit"]').first();
|
||||||
await editButton.click();
|
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('#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' });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function selectVendorFromTypeahead(page: any, vendorName: string) {
|
async function selectVendorFromTypeahead(page: any, vendorName: string) {
|
||||||
@@ -386,7 +315,7 @@ async function selectVendorFromTypeahead(page: any, vendorName: string) {
|
|||||||
throw new Error(`Could not find vendor with name ${vendorName}`);
|
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();
|
const vendorHidden = vendorContainer.locator('input[type="hidden"]').first();
|
||||||
|
|
||||||
await vendorHidden.evaluate((el: HTMLInputElement, value: string) => {
|
await vendorHidden.evaluate((el: HTMLInputElement, value: string) => {
|
||||||
@@ -401,7 +330,7 @@ async function selectVendorFromTypeahead(page: any, vendorName: string) {
|
|||||||
el.dispatchEvent(new Event('change', { bubbles: true }));
|
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);
|
await page.waitForTimeout(500);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -449,9 +378,17 @@ test.describe('Transaction Edit Vendor Pre-population', () => {
|
|||||||
const testInfo = await getTestInfo(page);
|
const testInfo = await getTestInfo(page);
|
||||||
expect(accountValue).toBe(testInfo.accounts['test-account'].toString());
|
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 amountInput = page.locator('.account-amount-field').first();
|
||||||
const amountValue = await amountInput.inputValue();
|
const amountValue = await amountInput.inputValue();
|
||||||
expect(parseFloat(amountValue)).toBeCloseTo(400.0, 1);
|
expect(parseFloat(amountValue)).toBeCloseTo(txTotal, 1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -461,11 +398,11 @@ test.describe('Transaction Edit Vendor Pre-population', () => {
|
|||||||
// `elements` instead of being fetched. Everything else -- the dropdown's own
|
// `elements` instead of being fetched. Everything else -- the dropdown's own
|
||||||
// search input firing a native `change` on blur, the `value = element` click
|
// search input firing a native `change` on blur, the `value = element` click
|
||||||
// handler, the Alpine reactivity, and the HTMX round-trip to
|
// 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
|
// regressed: a stale native `change` from the search input used to win the race
|
||||||
// and revert the vendor to its previous value.
|
// and revert the vendor to its previous value.
|
||||||
async function selectVendorViaDropdown(page: any, vendorId: number, vendorName: string) {
|
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();
|
const typeahead = wrapper.locator('div.relative[x-data]').first();
|
||||||
|
|
||||||
// Open the dropdown (tippy renders the popper into [data-tippy-root]).
|
// Open the dropdown (tippy renders the popper into [data-tippy-root]).
|
||||||
@@ -493,7 +430,7 @@ async function selectVendorViaDropdown(page: any, vendorId: number, vendorName:
|
|||||||
|
|
||||||
await page.waitForResponse(
|
await page.waitForResponse(
|
||||||
(response: any) =>
|
(response: any) =>
|
||||||
response.url().includes('/edit-vendor-changed') && response.status() === 200
|
response.url().includes('/edit-form-changed') && response.status() === 200
|
||||||
);
|
);
|
||||||
await page.waitForTimeout(500);
|
await page.waitForTimeout(500);
|
||||||
}
|
}
|
||||||
@@ -510,9 +447,9 @@ async function openManualVendorSection(page: any, transactionIndex: number) {
|
|||||||
await editButton.click();
|
await editButton.click();
|
||||||
|
|
||||||
await page.waitForSelector('#modal-holder[x-show="open"]', { state: 'visible' });
|
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.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', () => {
|
test.describe('Transaction Edit Vendor Selection', () => {
|
||||||
@@ -528,14 +465,14 @@ test.describe('Transaction Edit Vendor Selection', () => {
|
|||||||
// round-trip. Before the fix this reverted to blank because a stale
|
// round-trip. Before the fix this reverted to blank because a stale
|
||||||
// `change` event submitted the previous vendor and its response won.
|
// `change` event submitted the previous vendor and its response won.
|
||||||
const label = page
|
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();
|
.first();
|
||||||
await expect(label).toHaveText('Test Vendor');
|
await expect(label).toHaveText('Test Vendor');
|
||||||
|
|
||||||
// The server-rendered hidden input must carry the newly selected vendor id.
|
// The server-rendered hidden input must carry the newly selected vendor id.
|
||||||
const hidden = page
|
const hidden = page
|
||||||
.locator(
|
.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();
|
.first();
|
||||||
await expect(hidden).toHaveValue(vendorId.toString());
|
await expect(hidden).toHaveValue(vendorId.toString());
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
import { defineConfig, devices } from '@playwright/test';
|
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({
|
export default defineConfig({
|
||||||
testDir: './e2e',
|
testDir: './e2e',
|
||||||
fullyParallel: true,
|
fullyParallel: true,
|
||||||
@@ -8,10 +14,12 @@ export default defineConfig({
|
|||||||
workers: process.env.CI ? 1 : undefined,
|
workers: process.env.CI ? 1 : undefined,
|
||||||
reporter: 'html',
|
reporter: 'html',
|
||||||
use: {
|
use: {
|
||||||
baseURL: 'http://localhost:3333',
|
baseURL,
|
||||||
trace: 'on-first-retry',
|
trace: 'on-first-retry',
|
||||||
},
|
},
|
||||||
webServer: {
|
webServer: useExternalServer
|
||||||
|
? undefined
|
||||||
|
: {
|
||||||
command: 'lein run -m auto-ap.test-server',
|
command: 'lein run -m auto-ap.test-server',
|
||||||
url: 'http://localhost:3333/test-info',
|
url: 'http://localhost:3333/test-info',
|
||||||
reuseExistingServer: !process.env.CI,
|
reuseExistingServer: !process.env.CI,
|
||||||
|
|||||||
@@ -96,6 +96,7 @@
|
|||||||
[org.clojure/core.async]]
|
[org.clojure/core.async]]
|
||||||
|
|
||||||
[hiccup "2.0.0-alpha2"]
|
[hiccup "2.0.0-alpha2"]
|
||||||
|
[selmer "1.12.61"]
|
||||||
|
|
||||||
;; needed for java 11
|
;; needed for java 11
|
||||||
[javax.xml.bind/jaxb-api "2.4.0-b180830.0359"]
|
[javax.xml.bind/jaxb-api "2.4.0-b180830.0359"]
|
||||||
|
|||||||
@@ -416,4 +416,64 @@ htmx.onLoad(function(content) {
|
|||||||
console.error('Failed to copy text to clipboard:', err);
|
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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
1
resources/templates/components/a-button.html
Normal file
1
resources/templates/components/a-button.html
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<a class="{{ classes }}"{{ attrs|safe }}>{% if indicator %}<div class="htmx-indicator flex items-center">{% include "templates/components/spinner.html" %}<div class="ml-3">Loading...</div></div>{% endif %}<div class="inline-flex gap-2 items-center justify-center{% if indicator %} htmx-indicator-hidden{% endif %}">{{ body|safe }}</div></a>
|
||||||
1
resources/templates/components/a-icon-button.html
Normal file
1
resources/templates/components/a-icon-button.html
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<a class="{{ classes }}"{{ attrs|safe }}><div class="h-4 w-4">{{ body|safe }}</div></a>
|
||||||
1
resources/templates/components/badge.html
Normal file
1
resources/templates/components/badge.html
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<div class="{{ classes }}"{{ attrs|safe }}>{{ body|safe }}</div>
|
||||||
1
resources/templates/components/button-group-button.html
Normal file
1
resources/templates/components/button-group-button.html
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<button class="{{ classes }}"{{ attrs|safe }}>{{ body|safe }}</button>
|
||||||
1
resources/templates/components/button-group.html
Normal file
1
resources/templates/components/button-group.html
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<div class="inline-flex rounded-md shadow-sm" role="group" hx-on:click="this.querySelector("input").value = event.target.value; this.querySelector("input").dispatchEvent(new Event('change', {bubbles: true}));"><input type="hidden" name="{{ name }}">{{ body|safe }}</div>
|
||||||
1
resources/templates/components/button.html
Normal file
1
resources/templates/components/button.html
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<button class="{{ classes }}"{{ attrs|safe }}><div class="htmx-indicator flex items-center absolute inset-0 justify-center">{% include "templates/components/spinner.html" %}{% if loading_label %}<div class="ml-3">Loading...</div>{% endif %}</div><div class="htmx-indicator-invisible inline-flex gap-2 items-center justify-center">{{ body|safe }}</div></button>
|
||||||
1
resources/templates/components/data-grid-cell.html
Normal file
1
resources/templates/components/data-grid-cell.html
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<td class="px-4 py-2{% if klass %} {{ klass }}{% endif %}"{{ attrs|safe }}>{{ body|safe }}</td>
|
||||||
1
resources/templates/components/data-grid-header.html
Normal file
1
resources/templates/components/data-grid-header.html
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<th class="px-4 py-3{% if klass %} {{ klass }}{% endif %}" scope="col" @click="{{ click|safe }}"{{ attrs|safe }}>{% if sort_key %}<a href="#">{{ body|safe }}</a>{% else %}{{ body|safe }}{% endif %}</th>
|
||||||
1
resources/templates/components/data-grid-row.html
Normal file
1
resources/templates/components/data-grid-row.html
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<tr class="{{ classes }}"{{ attrs|safe }}>{{ body|safe }}</tr>
|
||||||
1
resources/templates/components/data-grid.html
Normal file
1
resources/templates/components/data-grid.html
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<div class="shrink overflow-y-scroll"><table class="{{ table_class }}"{{ table_attrs|safe }}><thead class="{{ thead_class }}"><tr>{{ headers|safe }}</tr></thead><tbody>{{ rows|safe }}</tbody>{{ footer_tbody|safe }}</table></div>
|
||||||
3
resources/templates/components/hidden.html
Normal file
3
resources/templates/components/hidden.html
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
{# Hidden input. The Clojure wrapper (sc/hidden) serializes the full attribute map
|
||||||
|
(name, value, optional id/form/class/Alpine :value bind) into `attrs`. #}
|
||||||
|
<input type="hidden"{{ attrs|safe }}>
|
||||||
1
resources/templates/components/link.html
Normal file
1
resources/templates/components/link.html
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<a class="{{ classes }}"{{ attrs|safe }}>{{ body|safe }}</a>
|
||||||
8
resources/templates/components/location-select.html
Normal file
8
resources/templates/components/location-select.html
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{# Location <select> for a transaction account row. Plain-HTML attributes -- the Selmer
|
||||||
|
migration target (no Hiccup keyword/string attribute ambiguity). Rendered into the
|
||||||
|
surrounding Hiccup row via the auto-ap.ssr.selmer interop bridge. #}
|
||||||
|
<select name="{{ name }}" class="{{ classes }}">
|
||||||
|
{% for opt in options %}
|
||||||
|
<option value="{{ opt.value }}"{% if opt.selected %} selected{% endif %}>{{ opt.label }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
1
resources/templates/components/modal.html
Normal file
1
resources/templates/components/modal.html
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<div class="{{ classes }}" @click.outside="open=false"{{ attrs|safe }}>{{ body|safe }}</div>
|
||||||
2
resources/templates/components/money-input.html
Normal file
2
resources/templates/components/money-input.html
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
{# Money input (number, step=0.01, right-aligned). sc/money-input builds `attrs`. #}
|
||||||
|
<input{{ attrs|safe }}>
|
||||||
1
resources/templates/components/radio-card.html
Normal file
1
resources/templates/components/radio-card.html
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<ul class="{{ ul_class }}">{% for opt in options %}<li class="{{ li_class }}"><div class="{{ div_class }}"><input id="{{ opt.id }}" type="radio" value="{{ opt.value }}" name="{{ name }}" class="{{ input_class }}"{{ input_attrs|safe }}{% if opt.checked %} checked{% endif %}><label for="{{ opt.id }}" class="{{ label_class }}">{{ opt.content|safe }}</label></div></li>{% endfor %}</ul>
|
||||||
1
resources/templates/components/spinner.html
Normal file
1
resources/templates/components/spinner.html
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg aria-hidden="true" class="animate-spin inline w-4 h-4 text-white" fill="none" role="status" viewbox="0 0 100 101" xmlns="http://www.w3.org/2000/svg"><path d="M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z" fill="#E5E7EB"></path><path d="M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z" fill="currentColor"></path></svg>
|
||||||
|
After Width: | Height: | Size: 1.2 KiB |
1
resources/templates/components/svg-drop-down.html
Normal file
1
resources/templates/components/svg-drop-down.html
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg aria-hidden="true" fill="none" stroke="currentColor" viewbox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M19 9l-7 7-7-7" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"></path></svg>
|
||||||
|
After Width: | Height: | Size: 216 B |
1
resources/templates/components/svg-external-link.html
Normal file
1
resources/templates/components/svg-external-link.html
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><defs></defs><title>navigation-next</title><path d="M23,9.5H12.387a4,4,0,0,0-4,4v2" fill="none" stroke-linecap="round" stroke-linejoin="round" stroke="currentColor"></path><polyline fill="none" points="19 13.498 23 9.498 19 5.498" stroke-linecap="round" stroke-linejoin="round" stroke="currentColor"></polyline><path d="M14.387,13v5.5a1,1,0,0,1-1,1h-12a1,1,0,0,1-1-1V6.5a1,1,0,0,1,1-1h12a1,1,0,0,1,1,1V7" fill="none" stroke-linecap="round" stroke-linejoin="round" stroke="currentColor"></path></svg>
|
||||||
|
After Width: | Height: | Size: 560 B |
1
resources/templates/components/svg-x.html
Normal file
1
resources/templates/components/svg-x.html
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg viewbox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><defs></defs><title>delete-2</title><circle cx="12" cy="12" fill="none" r="11.5" stroke-linecap="round" stroke-linejoin="round" stroke="currentColor"></circle><line fill="none" stroke-linecap="round" stroke-linejoin="round" stroke="currentColor" x1="7" x2="17" y1="7" y2="17"></line><line fill="none" stroke-linecap="round" stroke-linejoin="round" stroke="currentColor" x1="17" x2="7" y1="7" y2="17"></line></svg>
|
||||||
|
After Width: | Height: | Size: 474 B |
3
resources/templates/components/text-input.html
Normal file
3
resources/templates/components/text-input.html
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
{# Text input. sc/text-input builds the full attr map (type/autocomplete/class+size
|
||||||
|
already merged, reusing inputs/default-input-classes + hh/add-class) into `attrs`. #}
|
||||||
|
<input{{ attrs|safe }}>
|
||||||
4
resources/templates/components/typeahead.html
Normal file
4
resources/templates/components/typeahead.html
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
{# Click-to-select typeahead (Alpine + tippy). Survives whole-form swaps; null-guarded
|
||||||
|
tippy?. / $refs.input? throughout. The Clojure wrapper (sc/typeahead) resolves the
|
||||||
|
initial {value,label} server-side and builds x_data + the hidden-input attrs. #}
|
||||||
|
<div class="relative" x-data="{{ x_data }}" x-modelable="value.value"{% if x_model %} x-model="{{ x_model }}"{% endif %}{% if key %} key="{{ key }}"{% endif %}>{% if disabled %}<span x-text="value.label"></span>{% else %}<a class="{{ a_class }}" x-tooltip.on.click="{content: ()=>($refs.dropdown?.innerHTML ?? ''), placement: 'bottom', onMount(i) {htmx.process(i.popper); }, popperOptions: {strategy: 'fixed', modifiers: [{name: 'flip', options: {fallbackPlacements: ['top']}}]}, theme: 'dropdown', allowHTML: true, interactive:true}" @keydown.down.prevent.stop="tippy?.show();" @keydown.backspace="tippy?.hide(); value = {value: '', label: '' }" tabindex="0" x-init="{{ a_xinit }}" x-ref="input"><input{{ hidden_attrs|safe }}><div class="flex w-full justify-items-stretch"><span class="flex-grow text-left" x-text="value.label"></span><div class="w-3 h-3 m-1 inline ml-1 justify-self-end text-gray-500 self-center">{% include "templates/components/svg-drop-down.html" %}</div><div x-show="value.warning"><div class="peer absolute inline-flex items-center z-10 justify-center w-6 h-6 text-xs font-black text-white border-3 border-white rounded-full -top-2 -right-2 dark:border-gray-900 bg-red-300" x-tooltip="value.warning">!</div></div></div></a>{% endif %}<template x-ref="dropdown"><ul class="dropdown-contents bg-gray-100 dark:bg-gray-600 ring-1" @keydown.escape="$refs.input?.__x_tippy?.hide(); value = {value: '', label: '' }; " x-destroy="if ($refs.input) {$refs.input.focus();}"><input type="text" autofocus class="{{ search_class }}" x-model="search" placeholder="{{ placeholder }}" @change.stop="" @keydown.down.prevent="active ++; active = active >= elements.length - 1 ? elements.length - 1 : active" @keydown.up.prevent="active --; active = active < 0 ? 0 : active" @keydown.enter.prevent.stop="$refs.input?.__x_tippy?.hide(); value = elements.length > 0 ? $data.elements[active >= 0 ? active : 0] : {'value': '', label: ''}; $refs.input?.focus()" x-init="$el.focus(); $watch('search', s => { if($el.value.length > 2) {fetch(addQueryParam(baseUrl, 'q', s)).then(data=>data.json()).then(data => {elements = data; active=-1; $refs.input?.__x_tippy?.popperInstance?.update()}) }})"><div class="dropdown-options rounded-b-lg overflow-hidden"><template x-for="(element, index) in elements"><li><a class="px-4 py-2 flex gap-2 items-center outline-0 focus:bg-neutral-100 hover:bg-neutral-100 whitespace-nowrap [&.active]:bg-primary-500 [&.active]:dark:bg-primary-700 text-gray-800 dark:text-gray-100" href="#" :class="active == index ? 'active' : ''" @mouseover="active = index" @mouseout="active = -1" @click.prevent="value = element; $refs.input?.__x_tippy?.hide(); setTimeout(() => $refs.input?.focus(), 10)" x-html="element.label"></a></li></template><template x-if="elements.length == 0"><li class="px-4 py-2 flex gap-2 items-center outline-0 focus:bg-neutral-100 hover:bg-neutral-100 whitespace-nowrap [&.active]:bg-primary-500 text-gray-800 dark:text-gray-100 text-xs ">No results found</li></template></div></ul></template></div>
|
||||||
6
resources/templates/components/validated-field.html
Normal file
6
resources/templates/components/validated-field.html
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
{# Field wrapper with label + always-present error <p> (the errors- variant of field-).
|
||||||
|
`classes` already folds group / has-error / caller class via hh/add-class; `attrs`
|
||||||
|
carries any pass-through div attributes (the per-row location cell hangs its hx-* /
|
||||||
|
x-dispatch swap wiring here); `body` is the pre-rendered inner control HTML;
|
||||||
|
`errors_str` is the comma-joined string errors (empty when none). #}
|
||||||
|
<div class="{{ classes }}"{{ attrs|safe }}>{% if label %}<label class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">{{ label }}</label>{% endif %}{{ body|safe }}<p class="mt-2 text-xs text-red-600 dark:text-red-500 h-4">{{ errors_str }}</p></div>
|
||||||
7
resources/templates/interop-smoke.html
Normal file
7
resources/templates/interop-smoke.html
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<div id="interop-smoke" class="p-2">
|
||||||
|
<h3>{{ title }}</h3>
|
||||||
|
{# a Hiccup-rendered component, passed in pre-rendered and emitted verbatim #}
|
||||||
|
{{ hiccup_frag|safe }}
|
||||||
|
<input x-ref="input" x-model="value.value"
|
||||||
|
@keydown.down.prevent.stop="tippy?.show()" />
|
||||||
|
</div>
|
||||||
3
resources/templates/transaction-edit/account-totals.html
Normal file
3
resources/templates/transaction-edit/account-totals.html
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
{# Totals live in their own swappable <tbody> so an amount edit refreshes them with a
|
||||||
|
targeted swap, never replacing the input-bearing rows above (caret survives). #}
|
||||||
|
<tbody id="account-totals">{{ rows|safe }}</tbody>
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
<div x-data="{{ x_data }}">{{ status_hidden|safe }}<div class="inline-flex rounded-md shadow-sm" role="group">{{ buttons|safe }}</div></div>
|
||||||
2
resources/templates/transaction-edit/details-panel.html
Normal file
2
resources/templates/transaction-edit/details-panel.html
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
{# Read-only transaction summary shown in the modal's left side panel. #}
|
||||||
|
<div class="p-4 space-y-4"><h3 class="text-sm font-semibold text-gray-900 uppercase tracking-wider">Details</h3><div class="space-y-3"><div><div class="text-xs font-medium text-gray-500">Amount</div><div class="text-sm font-medium text-gray-900">{{ amount }}</div></div><div><div class="text-xs font-medium text-gray-500">Date</div><div class="text-sm text-gray-900">{{ date }}</div></div><div><div class="text-xs font-medium text-gray-500">Bank Account</div><div class="text-sm text-gray-900">{{ bank_account }}</div></div><div><div class="text-xs font-medium text-gray-500">Post Date</div><div class="text-sm text-gray-900">{{ post_date }}</div></div><div><div class="text-xs font-medium text-gray-500">Description</div><div class="text-sm text-gray-900 truncate cursor-help" title="{{ description_original }}">{{ description_simple }}</div></div><div><div class="text-xs font-medium text-gray-500">Check Number</div><div class="text-sm text-gray-900">{{ check_number }}</div></div><div><div class="text-xs font-medium text-gray-500">Status</div><div class="text-sm text-gray-900">{{ status }}</div></div><div><div class="text-xs font-medium text-gray-500">Transaction Type</div><div class="text-sm text-gray-900">{{ type }}</div></div></div></div>
|
||||||
4
resources/templates/transaction-edit/edit-form.html
Normal file
4
resources/templates/transaction-edit/edit-form.html
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
{# Top-level plain form. The entity id rides in a hidden field; all other state is the
|
||||||
|
live form, re-derived against the entity each request (no serialized snapshot, no
|
||||||
|
wizard step-params). #}
|
||||||
|
<form id="edit-form"{{ form_attrs|safe }}><input type="hidden" name="db/id" value="{{ db_id }}">{{ modal|safe }}</form>
|
||||||
4
resources/templates/transaction-edit/edit-modal.html
Normal file
4
resources/templates/transaction-edit/edit-modal.html
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
{# Modal card chrome (header / optional side panel / body / footer). Single-step, so
|
||||||
|
no timeline, no back/next nav -- just the Done button in the footer. Enter triggers
|
||||||
|
the save button via $refs.next. #}
|
||||||
|
<div class="modal-card bg-white rounded-lg shadow dark:bg-gray-700 dark:text-white modal-content flex flex-col max-h-screen max-w-screen md:w-[950px] md:h-[650px] w-full h-full last-modal-step transition duration-150" @keydown.enter.prevent.stop="if ($refs.next) {$refs.next.click()}" x-data=""><div class="flex items-start justify-between p-4 border-b rounded-t dark:border-gray-600 shrink-0">{{ head|safe }}</div><div class="flex shrink overflow-auto grow">{% if side_panel %}<div class="grow-0 w-64 bg-gray-50 border-r hidden md:block overflow-y-auto max-h-full">{{ side_panel|safe }}</div>{% endif %}<div class="px-6 py-2 space-y-6 overflow-y-scroll w-full shrink grow">{{ body|safe }}</div></div><div class="p-4 border-t">{{ footer|safe }}</div></div>
|
||||||
1
resources/templates/transaction-edit/invoice-option.html
Normal file
1
resources/templates/transaction-edit/invoice-option.html
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<div class="ml-3"><span class="block text-sm font-medium">{{ number }}</span><span class="block text-sm text-gray-500">{{ vendor }}</span><span class="block text-sm text-gray-500">{{ date }}</span><span class="block text-sm font-medium">{{ amount }}</span></div>
|
||||||
1
resources/templates/transaction-edit/linked-payment.html
Normal file
1
resources/templates/transaction-edit/linked-payment.html
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<div class="my-4 p-4 bg-blue-50 rounded"><h3 class="text-lg font-bold mb-2">Linked Payment{{ external_link|safe }}</h3><div class="space-y-2"><div class="flex justify-between"><div class="font-medium">Payment #</div><div>{{ number }}</div></div><div class="flex justify-between"><div class="font-medium">Vendor</div><div>{{ vendor }}</div></div><div class="flex justify-between"><div class="font-medium">Amount</div><div>{{ amount }}</div></div><div class="flex justify-between"><div class="font-medium">Status</div><div>{{ status }}</div></div><div class="flex justify-between"><div class="font-medium">Date</div><div>{{ date }}</div></div>{{ payment_id_hidden|safe }}<div class="mt-4"{{ unlink_attrs|safe }}>{{ unlink_button|safe }}</div></div></div>
|
||||||
3
resources/templates/transaction-edit/links-body.html
Normal file
3
resources/templates/transaction-edit/links-body.html
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
{# The single step's body: memo + the activeForm tab switcher (link payment / unpaid /
|
||||||
|
autopay / rule / manual) + the five x-show panels. Fragments are pre-rendered. #}
|
||||||
|
<div class="space-y-1"><div>{{ memo_field|safe }}<div x-data="{{ x_data }}" @unlinked="canChange=true"><div class="flex space-x-2 mb-4">{{ action_hidden|safe }}{{ tabs|safe }}</div><div x-show="activeForm === 'link-payment'" x-transition:enter="transition ease-out duration-500" x-transition:enter-start="opacity-0 transform scale-95" x-transition:enter-end="opacity-100 transform scale-100">{{ panel_payment|safe }}</div><div x-show="activeForm === 'link-unpaid-invoices'" x-transition:enter="transition ease-out duration-500" x-transition:enter-start="opacity-0 transform scale-95" x-transition:enter-end="opacity-100 transform scale-100">{{ panel_unpaid|safe }}</div><div x-show="activeForm === 'link-autopay-invoices'" x-transition:enter="transition ease-out duration-500" x-transition:enter-start="opacity-0 transform scale-95" x-transition:enter-end="opacity-100 transform scale-100">{{ panel_autopay|safe }}</div><div x-show="activeForm === 'apply-rule'" x-transition:enter="transition ease-out duration-500" x-transition:enter-start="opacity-0 transform scale-95" x-transition:enter-end="opacity-100 transform scale-100">{{ panel_rule|safe }}</div><div x-show="activeForm === 'manual'" x-transition:enter="transition ease-out duration-500" x-transition:enter-start="opacity-0 transform scale-95" x-transition:enter-end="opacity-100 transform scale-100"><div>{{ panel_manual|safe }}</div></div></div></div></div>
|
||||||
3
resources/templates/transaction-edit/manual-coding.html
Normal file
3
resources/templates/transaction-edit/manual-coding.html
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
{# Vendor field (a change repopulates the default account via a whole-form swap) + either
|
||||||
|
the simple single-row coding or the advanced account grid. #}
|
||||||
|
<div id="manual-coding-section">{{ mode_hidden|safe }}<div{{ vendor_changed_attrs|safe }}>{{ vendor_field|safe }}</div>{% if is_simple %}<div x-data="{{ simple_xdata }}">{{ simple_mode|safe }}</div>{% else %}<div>{{ toggle_link|safe }}{{ accounts_field|safe }}</div>{% endif %}</div>
|
||||||
1
resources/templates/transaction-edit/panel-empty.html
Normal file
1
resources/templates/transaction-edit/panel-empty.html
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<div class="text-center py-4 text-gray-500">{{ message }}</div>
|
||||||
3
resources/templates/transaction-edit/panel-list.html
Normal file
3
resources/templates/transaction-edit/panel-list.html
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
{# Shared shell for the autopay/unpaid/rule match panels: heading + action hidden +
|
||||||
|
prompt label + a radio-card of options. #}
|
||||||
|
<div><h3 class="text-lg font-bold mb-4">{{ heading }}</h3>{{ action_hidden|safe }}<div class="space-y-2"><label class="block text-sm font-medium mb-1">{{ prompt }}</label>{{ radio|safe }}</div></div>
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
{# Outer wrapper for the payment tab; the unlink-payment route swaps #payment-matches. #}
|
||||||
|
<div id="payment-matches">{{ inner|safe }}</div>
|
||||||
1
resources/templates/transaction-edit/rule-option.html
Normal file
1
resources/templates/transaction-edit/rule-option.html
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<div class="ml-3"><span class="block text-sm font-medium">{{ note }}</span><span class="block text-sm text-gray-500">{{ description }}</span></div>
|
||||||
4
resources/templates/transaction-edit/simple-mode.html
Normal file
4
resources/templates/transaction-edit/simple-mode.html
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
{# Simple mode: a single account row (account typeahead + location select) rendered at a
|
||||||
|
fixed index 0, plus the link to switch to the advanced grid. Selecting the account
|
||||||
|
swaps just the location cell (#simple-account-location). #}
|
||||||
|
<div><span>{{ row_id_hidden|safe }}<div class="flex gap-2 mt-2">{{ account_field|safe }}<div id="simple-account-location">{{ location_field|safe }}</div>{{ amount_hidden|safe }}</div></span><div class="mt-1"><a class="text-sm text-blue-600 hover:underline cursor-pointer"{{ toggle_attrs|safe }}>Switch to advanced mode</a></div></div>
|
||||||
3
resources/templates/transaction-edit/transitioner.html
Normal file
3
resources/templates/transaction-edit/transitioner.html
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
{# Wrapper the modal stack expects around the opened form (the wizard transition hooks
|
||||||
|
are gone -- there is only one step). #}
|
||||||
|
<div id="transitioner" class="flex-1">{{ body|safe }}</div>
|
||||||
@@ -62,7 +62,15 @@
|
|||||||
(.setHandler server stats-handler))
|
(.setHandler server stats-handler))
|
||||||
(.setStopAtShutdown server true))
|
(.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"))))
|
(mount/defstate port :start (Integer/parseInt (str (or *http-port-override* (env :port) "3000"))))
|
||||||
|
|
||||||
|
|||||||
@@ -35,7 +35,7 @@
|
|||||||
default-grid-fields-schema)]))
|
default-grid-fields-schema)]))
|
||||||
|
|
||||||
(defn filters [request]
|
(defn filters [request]
|
||||||
[:form {"hx-trigger" "change delay:500ms, keyup changed from:.hot-filter delay:1000ms"
|
[:form {"hx-trigger" "datesApplied, change delay:500ms, keyup changed from:.hot-filter delay:1000ms"
|
||||||
"hx-get" (bidi/path-for ssr-routes/only-routes
|
"hx-get" (bidi/path-for ssr-routes/only-routes
|
||||||
::route/table)
|
::route/table)
|
||||||
"hx-target" "#entity-table"
|
"hx-target" "#entity-table"
|
||||||
|
|||||||
@@ -45,10 +45,10 @@
|
|||||||
[:label {:for "checkbox-all", :class "sr-only"} "checkbox"]]])
|
[:label {:for "checkbox-all", :class "sr-only"} "checkbox"]]])
|
||||||
|
|
||||||
(defn data-grid-
|
(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
|
[:div.shrink.overflow-y-scroll
|
||||||
[:table (merge {:class "w-full text-sm text-left text-gray-500 dark:text-gray-400 shrink"}
|
[: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"
|
[: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 % ""))))
|
(hh/add-class (or % ""))))
|
||||||
(into
|
(into
|
||||||
@@ -56,7 +56,11 @@
|
|||||||
headers)]
|
headers)]
|
||||||
(into
|
(into
|
||||||
[:tbody {}]
|
[: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
|
;; needed for tailwind
|
||||||
;; lg:table-cell md:table-cell
|
;; lg:table-cell md:table-cell
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
[clj-time.core :as t]
|
[clj-time.core :as t]
|
||||||
[clj-time.periodic :as per]))
|
[clj-time.periodic :as per]))
|
||||||
|
|
||||||
(defn date-range-field [{:keys [value id apply-button?]}]
|
(defn date-range-field [{:keys [value id]}]
|
||||||
[:div {:id id}
|
[:div {:id id}
|
||||||
(com/field {:label "Date Range"}
|
(com/field {:label "Date Range"}
|
||||||
[:div.space-y-4
|
[:div.space-y-4
|
||||||
@@ -17,7 +17,7 @@
|
|||||||
(com/button-group-button {:size :small :value "week" :hx-trigger "click"} "Week")
|
(com/button-group-button {:size :small :value "week" :hx-trigger "click"} "Week")
|
||||||
(com/button-group-button {:size :small :value "month" :hx-trigger "click"} "Month")
|
(com/button-group-button {:size :small :value "month" :hx-trigger "click"} "Month")
|
||||||
(com/button-group-button {:size :small :value "year" :hx-trigger "click"} "Year"))]
|
(com/button-group-button {:size :small :value "year" :hx-trigger "click"} "Year"))]
|
||||||
[:div.flex.space-x-1.items-baseline.w-full.justify-start
|
[:div.flex.space-x-1.items-baseline.w-full.justify-start {"@change.stop" ""}
|
||||||
(com/date-input {:name "start-date"
|
(com/date-input {:name "start-date"
|
||||||
:value (some-> (:start value)
|
:value (some-> (:start value)
|
||||||
(atime/unparse-local atime/normal-date))
|
(atime/unparse-local atime/normal-date))
|
||||||
@@ -31,9 +31,8 @@
|
|||||||
:placeholder "Date"
|
:placeholder "Date"
|
||||||
:size :small
|
:size :small
|
||||||
:class "shrink date-filter-input"})
|
:class "shrink date-filter-input"})
|
||||||
(when apply-button?
|
|
||||||
(but/button- {:color :secondary
|
(but/button- {:color :secondary
|
||||||
:size :small
|
:size :small
|
||||||
:type "button"
|
:type "button"
|
||||||
"x-on:click" "$dispatch('datesApplied')"}
|
"x-on:click" "$dispatch('datesApplied')"}
|
||||||
"Apply"))]])])
|
"Apply")]])])
|
||||||
|
|||||||
@@ -51,7 +51,7 @@
|
|||||||
{:x-init "$el.indeterminate = true"}))]))
|
{:x-init "$el.indeterminate = true"}))]))
|
||||||
|
|
||||||
(defn typeahead- [params]
|
(defn typeahead- [params]
|
||||||
[:div.relative {:x-data (hx/json {:baseUrl (str (:url params))
|
[:div.relative (cond-> {:x-data (hx/json {:baseUrl (str (:url params))
|
||||||
:value {:value ((:value-fn params identity) (:value params)) :label ((:content-fn params identity) (:value params))}
|
:value {:value ((:value-fn params identity) (:value params)) :label ((:content-fn params identity) (:value params))}
|
||||||
:tippy nil
|
:tippy nil
|
||||||
:search ""
|
:search ""
|
||||||
@@ -61,13 +61,18 @@
|
|||||||
[])})
|
[])})
|
||||||
:x-modelable "value.value"
|
:x-modelable "value.value"
|
||||||
:x-model (:x-model params)}
|
: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)
|
(if (:disabled params)
|
||||||
[:span {:x-text "value.label"}]
|
[:span {:x-text "value.label"}]
|
||||||
[:a {:class (-> (hh/add-class (or (:class params) "") default-input-classes)
|
[:a {:class (-> (hh/add-class (or (:class params) "") default-input-classes)
|
||||||
(hh/add-class "cursor-pointer"))
|
(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}"
|
"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.down.prevent.stop" "tippy?.show();"
|
||||||
"@keydown.backspace" "tippy.hide(); value = {value: '', label: '' }"
|
"@keydown.backspace" "tippy?.hide(); value = {value: '', label: '' }"
|
||||||
:tabindex 0
|
:tabindex 0
|
||||||
:x-init (str "$nextTick(() => tippy = $el.__x_tippy); " (:x-init params))
|
:x-init (str "$nextTick(() => tippy = $el.__x_tippy); " (:x-init params))
|
||||||
:x-ref "input"}
|
:x-ref "input"}
|
||||||
@@ -94,7 +99,7 @@
|
|||||||
|
|
||||||
[:template {:x-ref "dropdown"}
|
[:template {:x-ref "dropdown"}
|
||||||
[:ul.dropdown-contents {:class "bg-gray-100 dark:bg-gray-600 ring-1"
|
[: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();}"}
|
:x-destroy "if ($refs.input) {$refs.input.focus();}"}
|
||||||
[:input {:type "text"
|
[:input {:type "text"
|
||||||
:autofocus true
|
:autofocus true
|
||||||
@@ -107,8 +112,8 @@
|
|||||||
"@change.stop" ""
|
"@change.stop" ""
|
||||||
"@keydown.down.prevent" "active ++; active = active >= elements.length - 1 ? elements.length - 1 : active"
|
"@keydown.down.prevent" "active ++; active = active >= elements.length - 1 ? elements.length - 1 : active"
|
||||||
"@keydown.up.prevent" "active --; active = active < 0 ? 0 : 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()"
|
"@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; tippy.popperInstance.update()}) }})"}]
|
"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"}
|
[:div.dropdown-options {:class "rounded-b-lg overflow-hidden"}
|
||||||
[:template {:x-for "(element, index) in elements"}
|
[: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"
|
[: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"
|
"@mouseover" "active = index"
|
||||||
"@mouseout" "active = -1"
|
"@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"}]]]
|
"x-html" "element.label"}]]]
|
||||||
[:template {:x-if "elements.length == 0"}
|
[: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 "}
|
[: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]
|
(defn multi-typeahead-dropdown- [params]
|
||||||
[:template {:x-ref "dropdown"}
|
[: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"
|
[: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();}"}
|
:x-destroy "if ($refs.input) {$refs.input.focus();}"}
|
||||||
[:div {:class (-> "relative"
|
[:div {:class (-> "relative"
|
||||||
#_(hh/replace-wildcard ["rounded" "border"] "border-bottom bg-gray-100 rounded-t-lg w-full"))}
|
#_(hh/replace-wildcard ["rounded" "border"] "border-bottom bg-gray-100 rounded-t-lg w-full"))}
|
||||||
@@ -240,9 +245,9 @@
|
|||||||
[:span {:x-text "value.label"}]
|
[:span {:x-text "value.label"}]
|
||||||
[:a {:class (-> (hh/add-class (or (:class params) "") default-input-classes)
|
[:a {:class (-> (hh/add-class (or (:class params) "") default-input-classes)
|
||||||
(hh/add-class "cursor-pointer"))
|
(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}"
|
"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.down.prevent.stop" "$refs.input?.__x_tippy?.show();"
|
||||||
"@keydown.backspace" "tippy.hide(); value=new Set( []);"
|
"@keydown.backspace" "$refs.input?.__x_tippy?.hide(); value=new Set( []);"
|
||||||
:tabindex 0
|
:tabindex 0
|
||||||
:x-init (str "$nextTick(() => tippy = $el.__x_tippy); " (:x-init params))
|
:x-init (str "$nextTick(() => tippy = $el.__x_tippy); " (:x-init params))
|
||||||
:x-ref "input"}
|
:x-ref "input"}
|
||||||
@@ -325,7 +330,7 @@
|
|||||||
(-> params
|
(-> params
|
||||||
(update :class (fnil hh/add-class "") default-input-classes)
|
(update :class (fnil hh/add-class "") default-input-classes)
|
||||||
(assoc :x-model "value")
|
(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 :x-init "$nextTick(() => tippy = $el.__x_tippy); ")
|
||||||
(assoc :type "text")
|
(assoc :type "text")
|
||||||
@@ -333,7 +338,7 @@
|
|||||||
(assoc "autocomplete" "off")
|
(assoc "autocomplete" "off")
|
||||||
(assoc "@change" "value = $event.target.value;")
|
(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\") "))
|
#_(assoc "hx-on" (hiccup/raw "changeDate: htmx.trigger(this, \"change\") "))
|
||||||
(update :class #(str % (use-size size) " w-full"))
|
(update :class #(str % (use-size size) " w-full"))
|
||||||
(dissoc :size))]
|
(dissoc :size))]
|
||||||
|
|||||||
292
src/clj/auto_ap/ssr/components/selmer.clj
Normal file
292
src/clj/auto_ap/ssr/components/selmer.clj
Normal file
@@ -0,0 +1,292 @@
|
|||||||
|
(ns auto-ap.ssr.components.selmer
|
||||||
|
"Selmer-rendered versions of the shared SSR components used by the Transaction Edit
|
||||||
|
modal (see .claude/skills/ssr-form-migration). Each wrapper assembles a plain-data
|
||||||
|
context and renders its own template under resources/templates/components/ via the
|
||||||
|
interop bridge -- the element structure lives entirely in the .html templates; the
|
||||||
|
only Clojure is data assembly. Dynamic HTMX/Alpine attributes (which vary per call
|
||||||
|
site) are serialized to an attribute string by `attrs->str` and injected with
|
||||||
|
{{ attrs|safe }}, so the templates stay free of per-attribute {% if %} ladders.
|
||||||
|
|
||||||
|
Reuses class logic from auto-ap.ssr.components.inputs so output matches the Hiccup
|
||||||
|
components byte-for-byte modulo Tailwind class ordering (verify by string-match +
|
||||||
|
e2e, never byte-parity -- see selmer-conventions.md)."
|
||||||
|
(:require
|
||||||
|
[auto-ap.ssr.components.buttons :as btn]
|
||||||
|
[auto-ap.ssr.components.inputs :as inputs]
|
||||||
|
[auto-ap.ssr.hiccup-helper :as hh]
|
||||||
|
[auto-ap.ssr.hx :as hx]
|
||||||
|
[auto-ap.ssr.selmer :as sel]
|
||||||
|
[clojure.string :as str]
|
||||||
|
[hiccup.util :as hu]))
|
||||||
|
|
||||||
|
(defn- attr-name [k]
|
||||||
|
(if (keyword? k) (subs (str k) 1) (str k)))
|
||||||
|
|
||||||
|
(defn attrs->str
|
||||||
|
"Serialize an attribute map to an HTML attribute string with a leading space, so it
|
||||||
|
concatenates after fixed template attributes: <input type=\"text\"{{ attrs|safe }}>.
|
||||||
|
nil/false values are dropped, true renders a bare boolean attribute, everything else
|
||||||
|
renders name=\"escaped-value\". Mirrors how hiccup2 emits attributes."
|
||||||
|
[m]
|
||||||
|
(->> m
|
||||||
|
(keep (fn [[k v]]
|
||||||
|
(cond
|
||||||
|
(nil? v) nil
|
||||||
|
(false? v) nil
|
||||||
|
(true? v) (str " " (attr-name k))
|
||||||
|
:else (str " " (attr-name k) "=\""
|
||||||
|
(hu/escape-html (if (keyword? v) (name v) (str v)))
|
||||||
|
"\""))))
|
||||||
|
(apply str)))
|
||||||
|
|
||||||
|
(defn render
|
||||||
|
"Render a component partial and trim outer whitespace (so {# comments #} and the
|
||||||
|
file's trailing newline don't leak into the embedding tree). Returns a raw-wrapped
|
||||||
|
string ready to drop into Hiccup or another Selmer context value."
|
||||||
|
[template ctx]
|
||||||
|
(sel/raw (str/trim (sel/render template ctx))))
|
||||||
|
|
||||||
|
(defn- body->html
|
||||||
|
"Render child content (Hiccup vectors and/or raw Selmer fragments) to an HTML string."
|
||||||
|
[body]
|
||||||
|
(->> (if (sequential? body) body [body])
|
||||||
|
(remove nil?)
|
||||||
|
(map sel/hiccup->html)
|
||||||
|
(apply str)))
|
||||||
|
|
||||||
|
;; --- leaf inputs -----------------------------------------------------------------
|
||||||
|
|
||||||
|
(defn hidden [{:keys [name value] :as params}]
|
||||||
|
(render "templates/components/hidden.html"
|
||||||
|
{:attrs (attrs->str (merge {:name name}
|
||||||
|
(when (some? value) {:value value})
|
||||||
|
(dissoc params :name :value)))}))
|
||||||
|
|
||||||
|
(defn text-input [{:keys [size] :as params}]
|
||||||
|
(let [attrs (-> params
|
||||||
|
(dissoc :error? :size)
|
||||||
|
(assoc :type "text" :autocomplete "off")
|
||||||
|
(update :class #(-> ""
|
||||||
|
(hh/add-class inputs/default-input-classes)
|
||||||
|
(hh/add-class %)))
|
||||||
|
(update :class #(str % (inputs/use-size size))))]
|
||||||
|
(render "templates/components/text-input.html" {:attrs (attrs->str attrs)})))
|
||||||
|
|
||||||
|
(defn money-input [{:keys [size] :as params}]
|
||||||
|
(let [attrs (-> params
|
||||||
|
(dissoc :size)
|
||||||
|
(update :class (fnil hh/add-class "") inputs/default-input-classes)
|
||||||
|
(update :class hh/add-class "appearance-none text-right")
|
||||||
|
(update :class #(str % (inputs/use-size size)))
|
||||||
|
(assoc :type "number" :step "0.01"))]
|
||||||
|
(render "templates/components/money-input.html" {:attrs (attrs->str attrs)})))
|
||||||
|
|
||||||
|
;; --- field wrapper ---------------------------------------------------------------
|
||||||
|
|
||||||
|
(defn validated-field
|
||||||
|
"Selmer port of com/validated-field (the errors- variant of field-): label + body +
|
||||||
|
an always-present error <p>. Pass-through attrs land on the wrapping div (the account
|
||||||
|
row's location cell hangs its swap wiring here)."
|
||||||
|
[{:keys [label errors] :as params} & body]
|
||||||
|
(let [classes (cond-> (or (:class params) "")
|
||||||
|
(sequential? errors) (hh/add-class "has-error")
|
||||||
|
:always (hh/add-class "group"))
|
||||||
|
attrs (dissoc params :label :errors :error-source :error-key :class)
|
||||||
|
errors-str (when (sequential? errors)
|
||||||
|
(str/join ", " (filter string? errors)))]
|
||||||
|
(render "templates/components/validated-field.html"
|
||||||
|
{:label label
|
||||||
|
:classes classes
|
||||||
|
:attrs (attrs->str attrs)
|
||||||
|
:body (body->html body)
|
||||||
|
:errors_str (or errors-str "")})))
|
||||||
|
|
||||||
|
;; --- buttons / badges / links ----------------------------------------------------
|
||||||
|
|
||||||
|
(defn badge [{:keys [color] :as params} & children]
|
||||||
|
(let [classes (-> (hh/add-class
|
||||||
|
"absolute inline-flex items-center z-10 justify-center w-6 h-6 text-xs font-black text-white \n border-3 border-white rounded-full -top-2 -right-2 dark:border-gray-900"
|
||||||
|
(:class params))
|
||||||
|
(hh/add-class (or (some-> color (#(str "bg-" % "-300"))) "bg-red-300")))]
|
||||||
|
(render "templates/components/badge.html"
|
||||||
|
{:classes classes
|
||||||
|
:attrs (attrs->str (dissoc params :class))
|
||||||
|
:body (body->html children)})))
|
||||||
|
|
||||||
|
(defn link [{:keys [class] :as params} & children]
|
||||||
|
(render "templates/components/link.html"
|
||||||
|
{:classes (str class " font-medium text-blue-600 dark:text-blue-500 hover:underline cursor-pointer")
|
||||||
|
:attrs (attrs->str (dissoc params :class))
|
||||||
|
:body (body->html children)}))
|
||||||
|
|
||||||
|
(defn button [{:keys [color disabled minimal-loading?] :as params} & children]
|
||||||
|
(let [classes (cond-> (:class params)
|
||||||
|
true (str " focus:ring-4 font-bold rounded-lg text-xs p-3 text-center mr-2 inline-flex items-center relative justify-center disabled:opacity-50"
|
||||||
|
(btn/bg-colors color disabled))
|
||||||
|
(not disabled) (str " hover:scale-105 transition duration-100")
|
||||||
|
disabled (str " cursor-not-allowed")
|
||||||
|
(some? color) (str " text-white ")
|
||||||
|
(nil? color) (str " bg-white dark:bg-gray-600 border-gray-300 dark:border-gray-700 text-gray-500 hover:text-gray-800 dark:text-gray-400 dark:hover:text-gray-100 font-medium border border-gray-300 dark:border-gray-700"))]
|
||||||
|
(render "templates/components/button.html"
|
||||||
|
{:classes classes
|
||||||
|
:attrs (attrs->str (dissoc params :class))
|
||||||
|
:loading_label (not minimal-loading?)
|
||||||
|
:body (body->html children)})))
|
||||||
|
|
||||||
|
(defn a-button [{:keys [color disabled] :as params} & children]
|
||||||
|
(let [indicator? (:indicator? params true)
|
||||||
|
classes (cond-> (:class params)
|
||||||
|
true (str " focus:ring-4 font-bold rounded-lg text-xs p-3 text-center mr-2 inline-flex items-center hover:scale-105 transition duration-100 justify-center")
|
||||||
|
(= :secondary color) (str " text-white bg-blue-500 hover:bg-blue-600 focus:ring-blue-300 dark:bg-blue-600 dark:hover:bg-blue-700")
|
||||||
|
(= :primary color) (str " text-white bg-green-500 hover:bg-green-600 focus:ring-green-300 dark:bg-green-600 dark:hover:bg-green-700 ")
|
||||||
|
(= :secondary-light color) (str " text-blue-800 bg-white-200 border-gray-100 border hover:bg-blue-100 focus:ring-blue-100 dark:bg-blue-400 dark:hover:bg-blue-800 ")
|
||||||
|
(some? color) (str " text-white " (btn/bg-colors color disabled))
|
||||||
|
(nil? color) (str " bg-white dark:bg-gray-600 border-gray-300 dark:border-gray-700 text-gray-500 hover:text-gray-800 dark:text-gray-400 dark:hover:text-gray-100 font-medium border border-gray-300 dark:border-gray-700"))]
|
||||||
|
(render "templates/components/a-button.html"
|
||||||
|
{:classes classes
|
||||||
|
:attrs (attrs->str (-> (dissoc params :class)
|
||||||
|
(assoc :tabindex 0 :href (:href params "#"))))
|
||||||
|
:indicator indicator?
|
||||||
|
:body (body->html children)})))
|
||||||
|
|
||||||
|
(defn a-icon-button [{:keys [class] :as params} & children]
|
||||||
|
(let [class-str (or class "")
|
||||||
|
has-padding? (re-find #"\bp[xy]?-\d+(\.\d+)?\b" class-str)
|
||||||
|
classes (str class-str (if has-padding? "" " p-3")
|
||||||
|
" inline-flex items-center justify-center bg-white dark:bg-gray-600 items-center text-sm font-medium border border-gray-300 dark:border-gray-700 text-center text-gray-500 hover:text-gray-800 rounded-lg dark:text-gray-400 dark:hover:text-gray-100")]
|
||||||
|
(render "templates/components/a-icon-button.html"
|
||||||
|
{:classes classes
|
||||||
|
:attrs (attrs->str (-> (dissoc params :class)
|
||||||
|
(assoc :href (or (:href params) ""))))
|
||||||
|
:body (body->html children)})))
|
||||||
|
|
||||||
|
(defn button-group-button [{:keys [size] :or {size :normal} :as params} & children]
|
||||||
|
(let [classes (cond-> (:class params)
|
||||||
|
true (str " font-medium text-gray-900 bg-white border border-gray-200 hover:bg-gray-100 hover:text-primary-700 focus:z-10 focus:ring-2 focus:ring-green-700 focus:text-green-700 dark:bg-gray-700 dark:border-gray-600 dark:text-white dark:hover:text-white dark:hover:bg-gray-600 dark:focus:ring-green-500 dark:focus:text-white disabled:opacity-50")
|
||||||
|
(= :small size) (str " text-xs px-3 py-2")
|
||||||
|
(= :normal size) (str " text-sm px-4 py-2"))]
|
||||||
|
(render "templates/components/button-group-button.html"
|
||||||
|
{:classes classes
|
||||||
|
:attrs (attrs->str (-> (dissoc params :class :size)
|
||||||
|
(assoc :type (or (:type params) "button"))))
|
||||||
|
:body (body->html children)})))
|
||||||
|
|
||||||
|
(defn button-group [{:keys [name]} & children]
|
||||||
|
(render "templates/components/button-group.html"
|
||||||
|
{:name name
|
||||||
|
:body (body->html children)}))
|
||||||
|
|
||||||
|
;; --- radio-card ------------------------------------------------------------------
|
||||||
|
|
||||||
|
(defn radio-card
|
||||||
|
"Selmer port of com/radio-card. NB: the Hiccup radio-card- has a dangling [:h3 title]
|
||||||
|
the let discards, so only the <ul> renders -- reproduced here. Only the documented
|
||||||
|
htmx keys ride onto each <input> (the same select-keys filter; :hx-vals / :hx-select
|
||||||
|
are intentionally dropped, matching existing behavior)."
|
||||||
|
[{:keys [options name title size orientation width] :or {size :medium width "w-48"}
|
||||||
|
selected-value :value :as params}]
|
||||||
|
(let [htmx-attrs (select-keys params [:hx-post :hx-target :hx-swap :hx-include :hx-trigger])
|
||||||
|
sel (cond-> selected-value (keyword? selected-value) clojure.core/name)
|
||||||
|
ul-class (cond-> " text-sm font-medium text-gray-900 bg-white border border-gray-200 rounded-lg dark:bg-gray-700 dark:border-gray-600 dark:text-white"
|
||||||
|
(= orientation :horizontal) (-> (hh/add-class "flex gap-2 flex-wrap")
|
||||||
|
(hh/remove-wildcard ["w-" "rounded-lg" "border" "bg-"]))
|
||||||
|
:always (str " " width " "))
|
||||||
|
li-class (cond-> "w-full border-b border-gray-200 rounded-t-lg dark:border-gray-600"
|
||||||
|
(= orientation :horizontal) (-> (hh/remove-wildcard ["w-full" "rounded-"])
|
||||||
|
(hh/add-class "w-auto shrink-0 block rounded-lg border border-gray-200 dark:border-gray-600 px-3")))
|
||||||
|
div-class (cond-> "flex items-center"
|
||||||
|
(not= orientation :horizontal) (hh/add-class "pl-3"))
|
||||||
|
input-class (cond-> "w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-700 dark:focus:ring-offset-gray-700 focus:ring-2 dark:bg-gray-600 dark:border-gray-500"
|
||||||
|
(= size :small) (str " text-xs")
|
||||||
|
(= size :medium) (str " text-sm"))
|
||||||
|
label-class (cond-> "w-full ml-2 font-medium text-gray-900 dark:text-gray-300"
|
||||||
|
(= size :small) (str " text-xs py-2")
|
||||||
|
(= size :medium) (str " text-sm py-3")
|
||||||
|
(= orientation :horizontal) (hh/remove-class "w-full"))]
|
||||||
|
(render "templates/components/radio-card.html"
|
||||||
|
{:ul_class ul-class :li_class li-class :div_class div-class
|
||||||
|
:input_class input-class :label_class label-class
|
||||||
|
:name name
|
||||||
|
:input_attrs (attrs->str htmx-attrs)
|
||||||
|
:options (for [{:keys [value content]} options]
|
||||||
|
{:id (str "list-" name "-" value)
|
||||||
|
:value value
|
||||||
|
:checked (= sel value)
|
||||||
|
:content (body->html content)})})))
|
||||||
|
|
||||||
|
;; --- data grid -------------------------------------------------------------------
|
||||||
|
|
||||||
|
(defn data-grid-header [params & body]
|
||||||
|
(render "templates/components/data-grid-header.html"
|
||||||
|
{:klass (:class params)
|
||||||
|
:click (format "$dispatch('sorted', {key: '%s'})" (:sort-key params))
|
||||||
|
:sort_key (:sort-key params)
|
||||||
|
:attrs (attrs->str (cond-> {} (:style params) (assoc :style (:style params))))
|
||||||
|
:body (body->html body)}))
|
||||||
|
|
||||||
|
(defn data-grid-row [params & body]
|
||||||
|
(render "templates/components/data-grid-row.html"
|
||||||
|
{:classes (str (:class params) " border-b dark:border-gray-600 group hover:bg-gray-100 dark:hover:bg-gray-700")
|
||||||
|
:attrs (attrs->str (dissoc params :class))
|
||||||
|
:body (body->html body)}))
|
||||||
|
|
||||||
|
(defn data-grid-cell [params & body]
|
||||||
|
(render "templates/components/data-grid-cell.html"
|
||||||
|
{:klass (:class params)
|
||||||
|
:attrs (attrs->str (dissoc params :class))
|
||||||
|
:body (body->html body)}))
|
||||||
|
|
||||||
|
(defn data-grid
|
||||||
|
"Table shell: outer scroll div > table > thead(headers) > tbody(rows) + optional
|
||||||
|
footer-tbody. `headers`, `rows`, and `footer-tbody` are pre-rendered fragments."
|
||||||
|
[{:keys [headers footer-tbody] :as params} & rows]
|
||||||
|
(render "templates/components/data-grid.html"
|
||||||
|
{:table_class "w-full text-sm text-left text-gray-500 dark:text-gray-400 shrink"
|
||||||
|
:table_attrs (attrs->str (dissoc params :headers :thead-params :footer-tbody))
|
||||||
|
:thead_class "text-xs text-gray-800 uppercase bg-gray-50 dark:bg-gray-700 dark:text-gray-400 group-[.raw]:sticky group-[.raw]:z-10 group-[.raw]:top-0"
|
||||||
|
:headers (body->html headers)
|
||||||
|
:rows (body->html rows)
|
||||||
|
:footer_tbody (when footer-tbody (body->html footer-tbody))}))
|
||||||
|
|
||||||
|
;; --- modal + typeahead -----------------------------------------------------------
|
||||||
|
|
||||||
|
(defn modal [{:as params} & children]
|
||||||
|
(render "templates/components/modal.html"
|
||||||
|
{:classes (hh/add-class "" (:class params ""))
|
||||||
|
:attrs (attrs->str (dissoc params :handle-unexpected-error? :class))
|
||||||
|
:body (body->html children)}))
|
||||||
|
|
||||||
|
(defn typeahead
|
||||||
|
"Selmer port of com/typeahead. Resolves the initial {value,label} server-side via
|
||||||
|
value-fn/content-fn (DB lookups), builds the Alpine x-data, and serializes the
|
||||||
|
hidden posting-input attributes. Preserves every tippy?. null-guard."
|
||||||
|
[{:keys [value value-fn content-fn x-model x-init id class placeholder disabled url]
|
||||||
|
:as params}]
|
||||||
|
(let [vf (or value-fn identity)
|
||||||
|
cf (or content-fn identity)
|
||||||
|
vval (vf value)
|
||||||
|
vlabel (cf value)
|
||||||
|
x-data (hx/json {:baseUrl (str url)
|
||||||
|
:value {:value vval :label vlabel}
|
||||||
|
:tippy nil :search "" :active -1
|
||||||
|
:elements (if vval [{:value vval :label vlabel}] [])})
|
||||||
|
a-class (-> (hh/add-class (or class "") inputs/default-input-classes)
|
||||||
|
(hh/add-class "cursor-pointer"))
|
||||||
|
a-xinit (str "$nextTick(() => tippy = $el.__x_tippy); " x-init)
|
||||||
|
search-class (-> (or class "")
|
||||||
|
(hh/add-class inputs/default-input-classes)
|
||||||
|
(hh/replace-wildcard ["rounded" "border"] "border-bottom bg-gray-100 rounded-t-lg w-full"))
|
||||||
|
hidden-attrs (-> params
|
||||||
|
(dissoc :class :value-fn :content-fn :placeholder :x-model)
|
||||||
|
(assoc "x-ref" "hidden" :type "hidden" ":value" "value.value"
|
||||||
|
:x-init "$watch('value', v => { $el.value = (v && v.value != null) ? v.value : ''; $nextTick(() => $dispatch('change')); }); "))]
|
||||||
|
(render "templates/components/typeahead.html"
|
||||||
|
{:x_data x-data
|
||||||
|
:x_model x-model
|
||||||
|
:key (when id (str id "--" vval))
|
||||||
|
:disabled disabled
|
||||||
|
:a_class a-class
|
||||||
|
:a_xinit a-xinit
|
||||||
|
:search_class search-class
|
||||||
|
:placeholder placeholder
|
||||||
|
:hidden_attrs (attrs->str hidden-attrs)})))
|
||||||
@@ -56,7 +56,7 @@
|
|||||||
[:div {:id "exact-match-id-tag"}]))
|
[:div {:id "exact-match-id-tag"}]))
|
||||||
|
|
||||||
(defn filters [request]
|
(defn filters [request]
|
||||||
[:form#invoice-filters {"hx-trigger" "change delay:500ms, keyup changed from:.hot-filter delay:1000ms"
|
[:form#invoice-filters {"hx-trigger" "datesApplied, change delay:500ms, keyup changed from:.hot-filter delay:1000ms"
|
||||||
"hx-get" (bidi/path-for ssr-routes/only-routes
|
"hx-get" (bidi/path-for ssr-routes/only-routes
|
||||||
::route/import-table)
|
::route/import-table)
|
||||||
"hx-target" "#entity-table"
|
"hx-target" "#entity-table"
|
||||||
|
|||||||
@@ -53,7 +53,7 @@
|
|||||||
[:div {:id "exact-match-id-tag"}]))
|
[:div {:id "exact-match-id-tag"}]))
|
||||||
|
|
||||||
(defn filters [request]
|
(defn filters [request]
|
||||||
[:form#payment-filters {"hx-trigger" "change delay:500ms, keyup changed from:.hot-filter delay:1000ms"
|
[:form#payment-filters {"hx-trigger" "datesApplied, change delay:500ms, keyup changed from:.hot-filter delay:1000ms"
|
||||||
"hx-get" (bidi/path-for ssr-routes/only-routes
|
"hx-get" (bidi/path-for ssr-routes/only-routes
|
||||||
::route/table)
|
::route/table)
|
||||||
"hx-target" "#entity-table"
|
"hx-target" "#entity-table"
|
||||||
|
|||||||
@@ -29,7 +29,7 @@
|
|||||||
default-grid-fields-schema)]))
|
default-grid-fields-schema)]))
|
||||||
|
|
||||||
(defn filters [params]
|
(defn filters [params]
|
||||||
[:form {"hx-trigger" "change delay:500ms, keyup changed from:.hot-filter delay:1000ms"
|
[:form {"hx-trigger" "datesApplied, change delay:500ms, keyup changed from:.hot-filter delay:1000ms"
|
||||||
"hx-get" (bidi/path-for ssr-routes/only-routes
|
"hx-get" (bidi/path-for ssr-routes/only-routes
|
||||||
:pos-cash-drawer-shift-table)
|
:pos-cash-drawer-shift-table)
|
||||||
"hx-target" "#cash-drawer-shift-table"
|
"hx-target" "#cash-drawer-shift-table"
|
||||||
|
|||||||
@@ -34,7 +34,7 @@
|
|||||||
default-grid-fields-schema)]))
|
default-grid-fields-schema)]))
|
||||||
|
|
||||||
(defn filters [request]
|
(defn filters [request]
|
||||||
[:form {"hx-trigger" "change delay:500ms, keyup changed from:.hot-filter delay:1000ms"
|
[:form {"hx-trigger" "datesApplied, change delay:500ms, keyup changed from:.hot-filter delay:1000ms"
|
||||||
"hx-get" (bidi/path-for ssr-routes/only-routes
|
"hx-get" (bidi/path-for ssr-routes/only-routes
|
||||||
:pos-expected-deposit-table)
|
:pos-expected-deposit-table)
|
||||||
"hx-target" "#expected-deposit-table"
|
"hx-target" "#expected-deposit-table"
|
||||||
|
|||||||
@@ -29,7 +29,7 @@
|
|||||||
[:client {:optional true :default nil} [:maybe [:entity-map {:pull [:db/id :client/name]}]]]]
|
[:client {:optional true :default nil} [:maybe [:entity-map {:pull [:db/id :client/name]}]]]]
|
||||||
default-grid-fields-schema)]))
|
default-grid-fields-schema)]))
|
||||||
(defn filters [request]
|
(defn filters [request]
|
||||||
[:form {"hx-trigger" "change delay:500ms, keyup changed from:.hot-filter delay:1000ms"
|
[:form {"hx-trigger" "datesApplied, change delay:500ms, keyup changed from:.hot-filter delay:1000ms"
|
||||||
"hx-get" (bidi/path-for ssr-routes/only-routes
|
"hx-get" (bidi/path-for ssr-routes/only-routes
|
||||||
:pos-refund-table)
|
:pos-refund-table)
|
||||||
"hx-target" "#refund-table"
|
"hx-target" "#refund-table"
|
||||||
|
|||||||
@@ -34,7 +34,7 @@
|
|||||||
default-grid-fields-schema)]))
|
default-grid-fields-schema)]))
|
||||||
|
|
||||||
(defn filters [request]
|
(defn filters [request]
|
||||||
[:form {"hx-trigger" "change delay:500ms, keyup changed from:.hot-filter delay:1000ms"
|
[:form {"hx-trigger" "datesApplied, change delay:500ms, keyup changed from:.hot-filter delay:1000ms"
|
||||||
"hx-get" (bidi/path-for ssr-routes/only-routes
|
"hx-get" (bidi/path-for ssr-routes/only-routes
|
||||||
:pos-sales-table)
|
:pos-sales-table)
|
||||||
"hx-target" "#sales-table"
|
"hx-target" "#sales-table"
|
||||||
|
|||||||
@@ -44,7 +44,7 @@
|
|||||||
default-grid-fields-schema)]))
|
default-grid-fields-schema)]))
|
||||||
|
|
||||||
(defn filters [request]
|
(defn filters [request]
|
||||||
[:form {"hx-trigger" "change delay:500ms, keyup changed from:.hot-filter delay:1000ms"
|
[:form {"hx-trigger" "datesApplied, change delay:500ms, keyup changed from:.hot-filter delay:1000ms"
|
||||||
"hx-get" (bidi/path-for ssr-routes/only-routes
|
"hx-get" (bidi/path-for ssr-routes/only-routes
|
||||||
::route/table)
|
::route/table)
|
||||||
"hx-target" "#entity-table"
|
"hx-target" "#entity-table"
|
||||||
|
|||||||
@@ -22,7 +22,7 @@
|
|||||||
;; always should be fast
|
;; always should be fast
|
||||||
|
|
||||||
(defn filters [request]
|
(defn filters [request]
|
||||||
[:form {"hx-trigger" "change delay:500ms, keyup changed from:.hot-filter delay:1000ms"
|
[:form {"hx-trigger" "datesApplied, change delay:500ms, keyup changed from:.hot-filter delay:1000ms"
|
||||||
"hx-get" (bidi/path-for ssr-routes/only-routes
|
"hx-get" (bidi/path-for ssr-routes/only-routes
|
||||||
:pos-tender-table)
|
:pos-tender-table)
|
||||||
"hx-target" "#tender-table"
|
"hx-target" "#tender-table"
|
||||||
|
|||||||
43
src/clj/auto_ap/ssr/selmer.clj
Normal file
43
src/clj/auto_ap/ssr/selmer.clj
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
(ns auto-ap.ssr.selmer
|
||||||
|
"Selmer rendering + the Hiccup<->Selmer interop bridge for the SSR form/wizard
|
||||||
|
migration (see .claude/skills/ssr-form-migration). Interactive, attribute-heavy
|
||||||
|
components render from Selmer templates with plain-HTML Alpine/HTMX attributes;
|
||||||
|
the bridge lets a Selmer template embed Hiccup output and lets a Selmer fragment
|
||||||
|
sit inside a Hiccup tree during the strangler transition.
|
||||||
|
|
||||||
|
Templates live under resources/templates/ and are referenced by classpath-relative
|
||||||
|
path, e.g. (render \"templates/components/typeahead.html\" ctx)."
|
||||||
|
(:require
|
||||||
|
[hiccup.util :as hu]
|
||||||
|
[hiccup2.core :as h2]
|
||||||
|
[selmer.parser :as selmer]))
|
||||||
|
|
||||||
|
(defn hiccup->html
|
||||||
|
"Render a Hiccup form to an HTML string so it can be embedded in a Selmer
|
||||||
|
context value and emitted with the |safe filter: {{ frag|safe }}."
|
||||||
|
[hiccup]
|
||||||
|
(str (h2/html {} hiccup)))
|
||||||
|
|
||||||
|
(defn raw
|
||||||
|
"Wrap an already-rendered HTML string (e.g. from `render`) so hiccup2 emits it
|
||||||
|
verbatim instead of escaping it. Use to drop a Selmer fragment into a Hiccup tree:
|
||||||
|
[:div (sel/raw (sel/render \"...\" ctx))]."
|
||||||
|
[^String html]
|
||||||
|
(hu/raw-string html))
|
||||||
|
|
||||||
|
(defn render
|
||||||
|
"Render a Selmer template file (classpath-relative path) with `ctx`, returning an
|
||||||
|
HTML string. Hiccup values in `ctx` should be pre-rendered via `hiccup->html` and
|
||||||
|
referenced with |safe in the template."
|
||||||
|
[template ctx]
|
||||||
|
(selmer/render-file template ctx))
|
||||||
|
|
||||||
|
(defn render-str
|
||||||
|
"Render a Selmer template given as a string (handy for tests/REPL)."
|
||||||
|
[template ctx]
|
||||||
|
(selmer/render template ctx))
|
||||||
|
|
||||||
|
(defn render->hiccup
|
||||||
|
"Render a Selmer template file and wrap the result for safe embedding in Hiccup."
|
||||||
|
[template ctx]
|
||||||
|
(raw (render template ctx)))
|
||||||
@@ -316,7 +316,7 @@
|
|||||||
:content (:bank-account/name ba)}))}))))])
|
:content (:bank-account/name ba)}))}))))])
|
||||||
|
|
||||||
(defn filters [request]
|
(defn filters [request]
|
||||||
[:form#transaction-filters {"hx-trigger" "change delay:500ms, keyup changed from:.hot-filter delay:1000ms"
|
[:form#transaction-filters {"hx-trigger" "datesApplied, change delay:500ms, keyup changed from:.hot-filter delay:1000ms"
|
||||||
"hx-get" (bidi/path-for ssr-routes/only-routes
|
"hx-get" (bidi/path-for ssr-routes/only-routes
|
||||||
::route/table)
|
::route/table)
|
||||||
"hx-target" "#entity-table"
|
"hx-target" "#entity-table"
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -9,15 +9,29 @@
|
|||||||
(with-open [s (ServerSocket. 0)]
|
(with-open [s (ServerSocket. 0)]
|
||||||
(.getLocalPort s)))
|
(.getLocalPort s)))
|
||||||
|
|
||||||
(defn- mcp-repl-task [& _args]
|
(defn- read-port [path]
|
||||||
"Start nREPL server and HTTP server on random ports.
|
"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.
|
(defn- stable-port [path]
|
||||||
Connect with: clj-nrepl-eval -p $(cat nrepl-port)"
|
"Reuse the port recorded in `path` if present, otherwise pick a random
|
||||||
(let [nrepl-port (available-port)
|
available one. Always (re)writes the file so the port stays stable for this
|
||||||
http-port (available-port)]
|
worktree across REPL restarts and reloads."
|
||||||
(spit "nrepl-port" (str nrepl-port))
|
(let [port (or (read-port path) (available-port))]
|
||||||
(spit ".http-port" (str http-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 "nREPL port: %d (nrepl-port)" nrepl-port))
|
||||||
(println (format "HTTP port: %d (.http-port)" http-port))
|
(println (format "HTTP port: %d (.http-port)" http-port))
|
||||||
(nrepl/start-server :port nrepl-port)
|
(nrepl/start-server :port nrepl-port)
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
(ns auto-ap.routes.transactions)
|
(ns auto-ap.routes.transactions)
|
||||||
|
|
||||||
(def routes {"" {:get ::page
|
(def routes {"" {:get ::page
|
||||||
:put ::edit-wizard-navigate
|
|
||||||
"/unapproved" ::unapproved-page
|
"/unapproved" ::unapproved-page
|
||||||
"/requires-feedback" ::requires-feedback-page
|
"/requires-feedback" ::requires-feedback-page
|
||||||
"/approved" ::approved-page
|
"/approved" ::approved-page
|
||||||
@@ -28,13 +27,8 @@
|
|||||||
|
|
||||||
["/" [#"\d+" :db/id]] {"/edit" {:get ::edit-wizard}}
|
["/" [#"\d+" :db/id]] {"/edit" {:get ::edit-wizard}}
|
||||||
"/edit-submit" ::edit-submit
|
"/edit-submit" ::edit-submit
|
||||||
"/edit-vendor-changed" ::edit-vendor-changed
|
|
||||||
"/location-select" ::location-select
|
"/location-select" ::location-select
|
||||||
"/account-total" ::account-total
|
"/edit-form-changed" ::edit-form-changed
|
||||||
"/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
|
|
||||||
"/match-payment" ::link-payment
|
"/match-payment" ::link-payment
|
||||||
"/match-autopay-invoices" ::link-autopay-invoices
|
"/match-autopay-invoices" ::link-autopay-invoices
|
||||||
"/match-unpaid-invoices" ::link-unpaid-invoices
|
"/match-unpaid-invoices" ::link-unpaid-invoices
|
||||||
|
|||||||
36
test/clj/auto_ap/ssr/selmer_test.clj
Normal file
36
test/clj/auto_ap/ssr/selmer_test.clj
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
(ns auto-ap.ssr.selmer-test
|
||||||
|
(:require
|
||||||
|
[auto-ap.ssr.selmer :as sut]
|
||||||
|
[clojure.string :as str]
|
||||||
|
[clojure.test :refer [deftest is testing]]
|
||||||
|
[hiccup2.core :as h2]))
|
||||||
|
|
||||||
|
(deftest hiccup->html
|
||||||
|
(testing "renders a Hiccup form to an HTML string"
|
||||||
|
(is (= "<span class=\"label\">A & B</span>"
|
||||||
|
(sut/hiccup->html [:span.label "A & B"])))))
|
||||||
|
|
||||||
|
(deftest selmer-embeds-hiccup
|
||||||
|
(testing "a Hiccup component renders inside a Selmer template via |safe"
|
||||||
|
(let [frag (sut/hiccup->html [:span.badge "from hiccup"])
|
||||||
|
out (sut/render-str "<div>{{frag|safe}}</div>" {:frag frag})]
|
||||||
|
(is (str/includes? out "<span class=\"badge\">from hiccup</span>"))
|
||||||
|
;; without |safe the markup would be escaped; |safe keeps it verbatim
|
||||||
|
(is (not (str/includes? out "<span"))))))
|
||||||
|
|
||||||
|
(deftest selmer-fragment-inside-hiccup
|
||||||
|
(testing "a Selmer fragment renders inside a Hiccup tree without double-escaping"
|
||||||
|
(let [sel (sut/render-str "<a href=\"{{url}}\">{{label}}</a>" {:url "/x" :label "Go"})
|
||||||
|
out (str (h2/html {} [:div (sut/raw sel)]))]
|
||||||
|
(is (= "<div><a href=\"/x\">Go</a></div>" out)))))
|
||||||
|
|
||||||
|
(deftest render-file-from-classpath
|
||||||
|
(testing "render-file resolves a template under resources/templates and keeps plain-HTML Alpine/HTMX attrs"
|
||||||
|
(let [out (sut/render "templates/interop-smoke.html"
|
||||||
|
{:title "Interop OK"
|
||||||
|
:hiccup_frag (sut/hiccup->html [:span.badge "from hiccup"])})]
|
||||||
|
(is (str/includes? out "Interop OK"))
|
||||||
|
(is (str/includes? out "from hiccup"))
|
||||||
|
;; plain-HTML attributes (the whole point of Selmer) survive unambiguously
|
||||||
|
(is (str/includes? out "x-model=\"value.value\""))
|
||||||
|
(is (str/includes? out "tippy?.show()")))))
|
||||||
@@ -100,6 +100,9 @@
|
|||||||
{:db/id "vendor-id"
|
{:db/id "vendor-id"
|
||||||
:vendor/name "Test Vendor"
|
:vendor/name "Test Vendor"
|
||||||
:vendor/default-account "account-id"}
|
: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"
|
(test-transaction :db/id "transaction-id"
|
||||||
:transaction/client "client-id"
|
:transaction/client "client-id"
|
||||||
:transaction/bank-account "bank-account-id"
|
:transaction/bank-account "bank-account-id"
|
||||||
@@ -166,7 +169,8 @@
|
|||||||
:second-account (get tempids "account-id-2")
|
:second-account (get tempids "account-id-2")
|
||||||
:fixed-location-account (get tempids "account-id-fixed-loc")
|
:fixed-location-account (get tempids "account-id-fixed-loc")
|
||||||
:ap-account (get tempids "ap-account-id")
|
: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
|
(reset! test-client-ids
|
||||||
{:test (get tempids "client-id")
|
{:test (get tempids "client-id")
|
||||||
:test2 (get tempids "client-id-2")})
|
:test2 (get tempids "client-id-2")})
|
||||||
|
|||||||
Reference in New Issue
Block a user