Compare commits
10 Commits
staging
...
32056bf396
| Author | SHA1 | Date | |
|---|---|---|---|
| 32056bf396 | |||
| 69eed1f8a6 | |||
| ed3344438b | |||
| bdb286ca71 | |||
| 3ecd115f76 | |||
| 246df6996e | |||
| 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,103 @@
|
||||
# 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
|
||||
```
|
||||
|
||||
## 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.
|
||||
115
.claude/skills/ssr-form-migration/reference/form-vs-wizard.md
Normal file
115
.claude/skills/ssr-form-migration/reference/form-vs-wizard.md
Normal file
@@ -0,0 +1,115 @@
|
||||
# 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.
|
||||
|
||||
## The machinery being replaced
|
||||
|
||||
`transaction/edit.clj` today still carries the old shape, useful 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).
|
||||
|
||||
---
|
||||
|
||||
## 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.
|
||||
88
.claude/skills/ssr-form-migration/reference/gotchas.md
Normal file
88
.claude/skills/ssr-form-migration/reference/gotchas.md
Normal file
@@ -0,0 +1,88 @@
|
||||
# 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.
|
||||
|
||||
## Scorecard exceptions (ratchet violations with a reason)
|
||||
|
||||
_None yet._ Append here if a migration must let a metric regress for a documented reason.
|
||||
@@ -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.
|
||||
46
.claude/skills/ssr-form-migration/reference/scorecard.md
Normal file
46
.claude/skills/ssr-form-migration/reference/scorecard.md
Normal file
@@ -0,0 +1,46 @@
|
||||
# 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 |
|
||||
|
||||
> Phase 1 is distillation only — no app code changed. The Transaction Edit row is the
|
||||
> **before** baseline that Phase 2 must beat (target: routes → ~3, no-cursor → 0, faked
|
||||
> roots → 0, snapshot merges → 0, LOC ↓, mixed hx- → 0). The `0` OOB is already achieved
|
||||
> by the merged reference and must not regress.
|
||||
@@ -0,0 +1,68 @@
|
||||
# Selmer template conventions
|
||||
|
||||
> **Status: STUB — validated in Phase 2.** This file describes the target. The Selmer
|
||||
> dependency, render helper, and interop bridge are added in Phase 2 (Transaction Edit);
|
||||
> rewrite this file from the *real, verified* example once that lands, and record each
|
||||
> converted component in `component-cookbook.md`.
|
||||
|
||||
## 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:
|
||||
|
||||
```clojure
|
||||
;; All of these appear in one component today:
|
||||
:x-ref "input" "x-ref" "hidden"
|
||||
:x-model "value.value" "x-model" "search"
|
||||
"@keydown.down.prevent.stop" "tippy.show();" ; handlers MUST be strings
|
||||
:x-init "..." ; structural attrs are keywords
|
||||
```
|
||||
|
||||
In a Selmer template the same markup is unambiguous plain HTML:
|
||||
|
||||
```html
|
||||
{# 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.
|
||||
|
||||
## Render helper + interop bridge (the Phase 2 foundation)
|
||||
|
||||
```clojure
|
||||
(defn render [tpl ctx] (selmer/render-file tpl ctx))
|
||||
(defn hiccup->html [h] (hiccup/html h)) ; embed hiccup inside selmer via {{ frag|safe }}
|
||||
;; selmer fragment inside hiccup: [:div (hiccup/raw (render "..." ctx))]
|
||||
```
|
||||
|
||||
The bridge must work **both ways** during the strangler transition: a Hiccup component
|
||||
renders inside a Selmer template (pass `(hiccup->html h)` into the context, render with
|
||||
`|safe`), and a Selmer fragment renders inside a Hiccup tree (`(hiccup/raw (render ...))`).
|
||||
Prove both in Phase 2 before broad use.
|
||||
|
||||
## Composition
|
||||
|
||||
Selmer composes via `{% include %}` and `{% block %}`. Prefer small per-component
|
||||
templates that the cookbook references by path. Keep `|safe` to values the server fully
|
||||
controls (rendered Hiccup, JSON for `x-data`), never raw user input.
|
||||
|
||||
## Scope (Open decision 2)
|
||||
|
||||
Hybrid: convert interactive/attribute-heavy components first; static markup may stay
|
||||
Hiccup. Revisit a fuller sweep in Phase 11.
|
||||
|
||||
## 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.
|
||||
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.
|
||||
127
.claude/skills/ssr-form-migration/reference/test-recipes.md
Normal file
127
.claude/skills/ssr-form-migration/reference/test-recipes.md
Normal file
@@ -0,0 +1,127 @@
|
||||
# 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. The `transaction-edit.spec.ts` `Shared Location` failure must be understood/fixed
|
||||
to unmask the other 7 before that file can serve as a full parity gate — it is **not**
|
||||
a regression to introduce, but it does cap the available characterization coverage today.
|
||||
Never drop below 30 passing on the full suite.
|
||||
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 #wizard-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('#wizardmodal');
|
||||
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-post*="edit-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-vendor-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('#wizard-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('#wizardmodal');
|
||||
|
||||
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('#wizardmodal');
|
||||
await page.click('button:has-text("Manual")');
|
||||
await page.waitForSelector('div[hx-post*="edit-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-post*="edit-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-vendor-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('#wizardmodal');
|
||||
await page.click('button:has-text("Manual")');
|
||||
await page.waitForSelector('div[hx-post*="edit-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-post*="edit-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([]);
|
||||
});
|
||||
});
|
||||
@@ -18,7 +18,15 @@ async function openEditModal(page: any, transactionIndex: number = 0) {
|
||||
// The modal is now single-page (Edit Transaction). Click "Manual" tab to ensure
|
||||
// the manual account coding form is active.
|
||||
await page.click('button:has-text("Manual")');
|
||||
|
||||
|
||||
// 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
|
||||
await page.waitForSelector('#account-grid-body');
|
||||
}
|
||||
@@ -33,68 +41,33 @@ async function getTestInfo(page: any) {
|
||||
}
|
||||
|
||||
async function selectAccountFromTypeahead(page: any, rowIndex: number, accountName: string) {
|
||||
// The account search uses Solr which isn't available in tests.
|
||||
// Instead, we directly set the hidden input value via JavaScript.
|
||||
|
||||
// Get all rows except the new-row, total, balance, and transaction total rows
|
||||
const allRows = page.locator('#account-grid-body tbody tr');
|
||||
const rowCount = await allRows.count();
|
||||
|
||||
// Find the row that has a hidden input for account (actual account rows)
|
||||
let accountRow = null;
|
||||
let accountRowIndex = 0;
|
||||
for (let i = 0; i < rowCount; i++) {
|
||||
const row = allRows.nth(i);
|
||||
const hasAccountInput = await row.locator('input[name*="transaction-account/account"]').count() > 0;
|
||||
if (hasAccountInput) {
|
||||
if (accountRowIndex === rowIndex) {
|
||||
accountRow = row;
|
||||
break;
|
||||
}
|
||||
accountRowIndex++;
|
||||
}
|
||||
}
|
||||
|
||||
if (!accountRow) {
|
||||
throw new Error(`Could not find account row at index ${rowIndex}`);
|
||||
}
|
||||
|
||||
// Find the hidden input for the account
|
||||
const hiddenInput = accountRow.locator('input[type="hidden"][name*="transaction-account/account"]').first();
|
||||
|
||||
// Get account IDs from test-info endpoint
|
||||
const testInfo = await getTestInfo(page);
|
||||
// Account search is backed by Solr (unavailable in tests). Drive the typeahead the
|
||||
// way a user does, using the Alpine v3 API: open the tippy dropdown, inject a result
|
||||
// into the component's `elements`, then click it. This runs the real click handler,
|
||||
// Alpine reactivity and the HTMX swap exactly as in production -- unlike poking the
|
||||
// long-removed Alpine v2 `__x` internal, which silently no-ops on Alpine v3 and left
|
||||
// the posted account empty.
|
||||
const accountKey = accountName === 'Test' ? 'test-account' : 'second-account';
|
||||
const label = `${accountName} Account`;
|
||||
const testInfo = await getTestInfo(page);
|
||||
const accountId = testInfo.accounts[accountKey];
|
||||
|
||||
if (!accountId) {
|
||||
throw new Error(`Could not find account with name ${accountName}`);
|
||||
}
|
||||
|
||||
// Set the hidden input value and trigger change
|
||||
// Also update Alpine.js data to prevent it from overwriting our value
|
||||
await hiddenInput.evaluate((el: HTMLInputElement, value: string) => {
|
||||
// Set the DOM value
|
||||
el.value = value;
|
||||
|
||||
// Update Alpine.js component data
|
||||
const alpineEl = el.closest('[x-data]');
|
||||
if (alpineEl && (alpineEl as any).__x) {
|
||||
(alpineEl as any).__x.$data.value.value = parseInt(value);
|
||||
(alpineEl as any).__x.$data.value.label = 'Selected Account';
|
||||
}
|
||||
|
||||
// Also update any parent Alpine model (accountId)
|
||||
const rowEl = el.closest('tr[x-data]');
|
||||
if (rowEl && (rowEl as any).__x) {
|
||||
(rowEl as any).__x.$data.accountId = parseInt(value);
|
||||
}
|
||||
|
||||
el.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
}, accountId.toString());
|
||||
|
||||
// Wait for any HTMX updates
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
const row = page.locator('#account-grid-body tbody tr.account-row').nth(rowIndex);
|
||||
const typeahead = row.locator('div.relative[x-data]').first();
|
||||
await typeahead.locator('a[x-ref="input"]').click();
|
||||
const search = page.locator('[data-tippy-root] input[x-model="search"]').first();
|
||||
await search.waitFor({ state: 'visible' });
|
||||
await search.fill('te');
|
||||
await typeahead.evaluate((el: any, opt: { id: number; label: string }) => {
|
||||
(window as any).Alpine.$data(el).elements = [{ value: opt.id, label: opt.label }];
|
||||
}, { id: accountId, label });
|
||||
await page.locator('[data-tippy-root] a', { hasText: label }).first().click();
|
||||
|
||||
// Wait for the change-gated whole-form swap to settle.
|
||||
await page.waitForTimeout(400);
|
||||
}
|
||||
|
||||
async function findAccountRow(page: any, rowIndex: number) {
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import { defineConfig, devices } from '@playwright/test';
|
||||
|
||||
// Allow pointing the suite at an already-running test server (e.g. one booted from a
|
||||
// specific worktree on a non-default port) via BASE_URL. When BASE_URL is set we skip
|
||||
// the auto-started webServer entirely, so parallel worktrees don't fight over :3333.
|
||||
const baseURL = process.env.BASE_URL ?? 'http://localhost:3333';
|
||||
const useExternalServer = !!process.env.BASE_URL;
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './e2e',
|
||||
fullyParallel: true,
|
||||
@@ -8,15 +14,17 @@ export default defineConfig({
|
||||
workers: process.env.CI ? 1 : undefined,
|
||||
reporter: 'html',
|
||||
use: {
|
||||
baseURL: 'http://localhost:3333',
|
||||
baseURL,
|
||||
trace: 'on-first-retry',
|
||||
},
|
||||
webServer: {
|
||||
command: 'lein run -m auto-ap.test-server',
|
||||
url: 'http://localhost:3333/test-info',
|
||||
reuseExistingServer: !process.env.CI,
|
||||
timeout: 120000,
|
||||
},
|
||||
webServer: useExternalServer
|
||||
? undefined
|
||||
: {
|
||||
command: 'lein run -m auto-ap.test-server',
|
||||
url: 'http://localhost:3333/test-info',
|
||||
reuseExistingServer: !process.env.CI,
|
||||
timeout: 120000,
|
||||
},
|
||||
projects: [
|
||||
{
|
||||
name: 'chromium',
|
||||
|
||||
@@ -96,6 +96,7 @@
|
||||
[org.clojure/core.async]]
|
||||
|
||||
[hiccup "2.0.0-alpha2"]
|
||||
[selmer "1.12.61"]
|
||||
|
||||
;; needed for java 11
|
||||
[javax.xml.bind/jaxb-api "2.4.0-b180830.0359"]
|
||||
|
||||
@@ -416,4 +416,64 @@ htmx.onLoad(function(content) {
|
||||
console.error('Failed to copy text to clipboard:', err);
|
||||
}
|
||||
}
|
||||
/*
|
||||
(function() {
|
||||
var lastFocusedSelector = null;
|
||||
var lastCursorPosition = null;
|
||||
|
||||
document.addEventListener('htmx:beforeSwap', function(evt) {
|
||||
var active = document.activeElement;
|
||||
if (active && active !== document.body) {
|
||||
// Build a selector to find this element after swap
|
||||
if (active.id) {
|
||||
lastFocusedSelector = '#' + active.id;
|
||||
} else if (active.name) {
|
||||
lastFocusedSelector = '[name="' + active.name + '"]';
|
||||
} else {
|
||||
lastFocusedSelector = null;
|
||||
}
|
||||
|
||||
// Save cursor position for text inputs. selectionStart is null on
|
||||
// inputs that don't support selection (number, date, select, etc.),
|
||||
// and calling setSelectionRange on those throws, so only capture it
|
||||
// when it's an actual numeric caret position.
|
||||
if (typeof active.selectionStart === 'number') {
|
||||
lastCursorPosition = {
|
||||
start: active.selectionStart,
|
||||
end: active.selectionEnd,
|
||||
direction: active.selectionDirection
|
||||
};
|
||||
} else {
|
||||
lastCursorPosition = null;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('htmx:afterSwap', function(evt) {
|
||||
if (lastFocusedSelector) {
|
||||
setTimeout(function() {
|
||||
var el = document.querySelector(lastFocusedSelector);
|
||||
// If morph already kept focus on the right element there's nothing
|
||||
// to do; only restore when focus was actually lost by the swap.
|
||||
if (el && el.focus && document.activeElement !== el) {
|
||||
el.focus();
|
||||
if (lastCursorPosition && el.setSelectionRange) {
|
||||
try {
|
||||
el.setSelectionRange(
|
||||
lastCursorPosition.start,
|
||||
lastCursorPosition.end,
|
||||
lastCursorPosition.direction
|
||||
);
|
||||
} catch (e) { }
|
||||
}
|
||||
}
|
||||
lastFocusedSelector = null;
|
||||
lastCursorPosition = null;
|
||||
}, 10);
|
||||
}
|
||||
});
|
||||
})();
|
||||
|
||||
*/
|
||||
|
||||
|
||||
|
||||
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>
|
||||
@@ -45,10 +45,10 @@
|
||||
[:label {:for "checkbox-all", :class "sr-only"} "checkbox"]]])
|
||||
|
||||
(defn data-grid-
|
||||
[{:keys [headers thead-params id] :as params} & rest]
|
||||
[{:keys [headers thead-params id footer-tbody] :as params} & rest]
|
||||
[:div.shrink.overflow-y-scroll
|
||||
[:table (merge {:class "w-full text-sm text-left text-gray-500 dark:text-gray-400 shrink"}
|
||||
(dissoc params :headers :thead-params))
|
||||
(dissoc params :headers :thead-params :footer-tbody))
|
||||
[:thead (update thead-params :class #(-> "text-xs text-gray-800 uppercase bg-gray-50 dark:bg-gray-700 dark:text-gray-400 group-[.raw]:sticky group-[.raw]:z-10 group-[.raw]:top-0"
|
||||
(hh/add-class (or % ""))))
|
||||
(into
|
||||
@@ -56,7 +56,11 @@
|
||||
headers)]
|
||||
(into
|
||||
[:tbody {}]
|
||||
rest)]])
|
||||
rest)
|
||||
;; Optional second <tbody> (valid HTML) so callers can keep a stable,
|
||||
;; separately-swappable region in the same table -- e.g. totals rows that
|
||||
;; update without touching the input-bearing rows above them.
|
||||
footer-tbody]])
|
||||
|
||||
;; needed for tailwind
|
||||
;; lg:table-cell md:table-cell
|
||||
|
||||
@@ -51,23 +51,28 @@
|
||||
{:x-init "$el.indeterminate = true"}))]))
|
||||
|
||||
(defn typeahead- [params]
|
||||
[:div.relative {:x-data (hx/json {:baseUrl (str (:url params))
|
||||
:value {:value ((:value-fn params identity) (:value params)) :label ((:content-fn params identity) (:value params))}
|
||||
:tippy nil
|
||||
:search ""
|
||||
:active -1
|
||||
:elements (if ((:value-fn params identity) (:value params))
|
||||
[{:value ((:value-fn params identity) (:value params)) :label ((:content-fn params identity) (:value params))}]
|
||||
[])})
|
||||
:x-modelable "value.value"
|
||||
:x-model (:x-model params)}
|
||||
[:div.relative (cond-> {:x-data (hx/json {:baseUrl (str (:url params))
|
||||
:value {:value ((:value-fn params identity) (:value params)) :label ((:content-fn params identity) (:value params))}
|
||||
:tippy nil
|
||||
:search ""
|
||||
:active -1
|
||||
:elements (if ((:value-fn params identity) (:value params))
|
||||
[{:value ((:value-fn params identity) (:value params)) :label ((:content-fn params identity) (:value params))}]
|
||||
[])})
|
||||
:x-modelable "value.value"
|
||||
:x-model (:x-model params)}
|
||||
;; Key the component by its current value so alpine-morph re-initialises
|
||||
;; it (rather than preserving stale Alpine x-data) whenever the *server*
|
||||
;; changes the value -- e.g. the default account a vendor selection
|
||||
;; populates. alpine-morph keys off the `key` attribute, not `id`.
|
||||
(:id params) (assoc :key (str (:id params) "--" ((:value-fn params identity) (:value params)))))
|
||||
(if (:disabled params)
|
||||
[:span {:x-text "value.label"}]
|
||||
[:a {:class (-> (hh/add-class (or (:class params) "") default-input-classes)
|
||||
(hh/add-class "cursor-pointer"))
|
||||
"x-tooltip.on.click" "{content: ()=>$refs.dropdown.innerHTML, placement: 'bottom', onMount(i) {htmx.process(i.popper); }, popperOptions: {strategy: 'fixed', modifiers: [{name: 'flip', options: {fallbackPlacements: ['top']}}]}, theme: 'dropdown', allowHTML: true, interactive:true}"
|
||||
"@keydown.down.prevent.stop" "tippy.show();"
|
||||
"@keydown.backspace" "tippy.hide(); value = {value: '', label: '' }"
|
||||
"x-tooltip.on.click" "{content: ()=>($refs.dropdown?.innerHTML ?? ''), placement: 'bottom', onMount(i) {htmx.process(i.popper); }, popperOptions: {strategy: 'fixed', modifiers: [{name: 'flip', options: {fallbackPlacements: ['top']}}]}, theme: 'dropdown', allowHTML: true, interactive:true}"
|
||||
"@keydown.down.prevent.stop" "tippy?.show();"
|
||||
"@keydown.backspace" "tippy?.hide(); value = {value: '', label: '' }"
|
||||
:tabindex 0
|
||||
:x-init (str "$nextTick(() => tippy = $el.__x_tippy); " (:x-init params))
|
||||
:x-ref "input"}
|
||||
@@ -94,7 +99,7 @@
|
||||
|
||||
[:template {:x-ref "dropdown"}
|
||||
[:ul.dropdown-contents {:class "bg-gray-100 dark:bg-gray-600 ring-1"
|
||||
"@keydown.escape" "tippy.hide(); value = {value: '', label: '' }; "
|
||||
"@keydown.escape" "$refs.input?.__x_tippy?.hide(); value = {value: '', label: '' }; "
|
||||
:x-destroy "if ($refs.input) {$refs.input.focus();}"}
|
||||
[:input {:type "text"
|
||||
:autofocus true
|
||||
@@ -107,8 +112,8 @@
|
||||
"@change.stop" ""
|
||||
"@keydown.down.prevent" "active ++; active = active >= elements.length - 1 ? elements.length - 1 : active"
|
||||
"@keydown.up.prevent" "active --; active = active < 0 ? 0 : active"
|
||||
"@keydown.enter.prevent.stop" "tippy.hide(); value = elements.length > 0 ? $data.elements[active >= 0 ? active : 0] : {'value': '', label: ''}; $refs.input.focus()"
|
||||
"x-init" "$el.focus(); $watch('search', s => { if($el.value.length > 2) {fetch(addQueryParam(baseUrl, 'q', s)).then(data=>data.json()).then(data => {elements = data; active=-1; tippy.popperInstance.update()}) }})"}]
|
||||
"@keydown.enter.prevent.stop" "$refs.input?.__x_tippy?.hide(); value = elements.length > 0 ? $data.elements[active >= 0 ? active : 0] : {'value': '', label: ''}; $refs.input?.focus()"
|
||||
"x-init" "$el.focus(); $watch('search', s => { if($el.value.length > 2) {fetch(addQueryParam(baseUrl, 'q', s)).then(data=>data.json()).then(data => {elements = data; active=-1; $refs.input?.__x_tippy?.popperInstance?.update()}) }})"}]
|
||||
[:div.dropdown-options {:class "rounded-b-lg overflow-hidden"}
|
||||
[:template {:x-for "(element, index) in elements"}
|
||||
[:li [:a {:class "px-4 py-2 flex gap-2 items-center outline-0 focus:bg-neutral-100 hover:bg-neutral-100 whitespace-nowrap [&.active]:bg-primary-500 [&.active]:dark:bg-primary-700 text-gray-800 dark:text-gray-100"
|
||||
@@ -117,7 +122,7 @@
|
||||
|
||||
"@mouseover" "active = index"
|
||||
"@mouseout" "active = -1"
|
||||
"@click.prevent" "value = element; tippy.hide(); setTimeout(() => $refs.input.focus(), 10)"
|
||||
"@click.prevent" "value = element; $refs.input?.__x_tippy?.hide(); setTimeout(() => $refs.input?.focus(), 10)"
|
||||
"x-html" "element.label"}]]]
|
||||
[:template {:x-if "elements.length == 0"}
|
||||
[:li {:class "px-4 py-2 flex gap-2 items-center outline-0 focus:bg-neutral-100 hover:bg-neutral-100 whitespace-nowrap [&.active]:bg-primary-500 text-gray-800 dark:text-gray-100 text-xs "}
|
||||
@@ -126,7 +131,7 @@
|
||||
(defn multi-typeahead-dropdown- [params]
|
||||
[:template {:x-ref "dropdown"}
|
||||
[:ul.dropdown-contents {:class "bg-gray-100 dark:bg-gray-600 rounded-lg shadow-2xl w-max z-50 ring-1 p-4"
|
||||
"@keydown.escape.prevent" "tippy.hide();"
|
||||
"@keydown.escape.prevent" "$refs.input?.__x_tippy?.hide();"
|
||||
:x-destroy "if ($refs.input) {$refs.input.focus();}"}
|
||||
[:div {:class (-> "relative"
|
||||
#_(hh/replace-wildcard ["rounded" "border"] "border-bottom bg-gray-100 rounded-t-lg w-full"))}
|
||||
@@ -240,9 +245,9 @@
|
||||
[:span {:x-text "value.label"}]
|
||||
[:a {:class (-> (hh/add-class (or (:class params) "") default-input-classes)
|
||||
(hh/add-class "cursor-pointer"))
|
||||
"x-tooltip.on.click.prevent" "{content: ()=>$refs.dropdown.innerHTML, placement: 'bottom', onMount(i) {htmx.process(i.popper); }, popperOptions: {strategy: 'fixed', modifiers: [{name: 'flip', options: {fallbackPlacements: ['top']}}]}, theme: 'dropdown', allowHTML: true, interactive:true}"
|
||||
"@keydown.down.prevent.stop" "tippy.show();"
|
||||
"@keydown.backspace" "tippy.hide(); value=new Set( []);"
|
||||
"x-tooltip.on.click.prevent" "{content: ()=>($refs.dropdown?.innerHTML ?? ''), placement: 'bottom', onMount(i) {htmx.process(i.popper); }, popperOptions: {strategy: 'fixed', modifiers: [{name: 'flip', options: {fallbackPlacements: ['top']}}]}, theme: 'dropdown', allowHTML: true, interactive:true}"
|
||||
"@keydown.down.prevent.stop" "$refs.input?.__x_tippy?.show();"
|
||||
"@keydown.backspace" "$refs.input?.__x_tippy?.hide(); value=new Set( []);"
|
||||
:tabindex 0
|
||||
:x-init (str "$nextTick(() => tippy = $el.__x_tippy); " (:x-init params))
|
||||
:x-ref "input"}
|
||||
@@ -325,7 +330,7 @@
|
||||
(-> params
|
||||
(update :class (fnil hh/add-class "") default-input-classes)
|
||||
(assoc :x-model "value")
|
||||
(assoc "x-tooltip.on.focus" "{content: ()=>$refs.tooltip.innerHTML, theme: 'light', onMount(i) { htmx.process(i.popper); }, allowHTML: true, interactive:true}")
|
||||
(assoc "x-tooltip.on.focus" "{content: ()=>($refs.tooltip?.innerHTML ?? ''), theme: 'light', onMount(i) { htmx.process(i.popper); }, allowHTML: true, interactive:true}")
|
||||
|
||||
(assoc :x-init "$nextTick(() => tippy = $el.__x_tippy); ")
|
||||
(assoc :type "text")
|
||||
@@ -333,7 +338,7 @@
|
||||
(assoc "autocomplete" "off")
|
||||
(assoc "@change" "value = $event.target.value;")
|
||||
|
||||
(assoc "@keydown.escape" "tippy.hide(); ")
|
||||
(assoc "@keydown.escape" "$el?.__x_tippy?.hide(); ")
|
||||
#_(assoc "hx-on" (hiccup/raw "changeDate: htmx.trigger(this, \"change\") "))
|
||||
(update :class #(str % (use-size size) " w-full"))
|
||||
(dissoc :size))]
|
||||
|
||||
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)))
|
||||
@@ -42,6 +42,8 @@
|
||||
[iol-ion.tx :refer [random-tempid]]
|
||||
[malli.core :as mc]))
|
||||
|
||||
(declare render-full-form)
|
||||
|
||||
(def transaction-approval-status
|
||||
{:transaction-approval-status/unapproved "Unapproved"
|
||||
:transaction-approval-status/approved "Approved"
|
||||
@@ -82,6 +84,7 @@
|
||||
[:transaction/vendor {:optional true} [:maybe entity-id]]
|
||||
[:transaction/approval-status {:optional true} [:maybe (ref->enum-schema "transaction-approval-status")]]
|
||||
[:amount-mode {:optional true} [:maybe [:enum "$" "%"]]]
|
||||
[:mode {:optional true} [:maybe [:enum "simple" "advanced"]]]
|
||||
[:transaction/accounts {:optional true}
|
||||
[:maybe
|
||||
[:vector {:coerce? true}
|
||||
@@ -221,22 +224,27 @@
|
||||
:name (fc/field-name)
|
||||
:x-model "simpleAccountId"})]))
|
||||
(fc/with-field :transaction-account/location
|
||||
(com/validated-field
|
||||
{:label "Location"
|
||||
:errors (fc/field-errors)
|
||||
:x-hx-val:account-id "simpleAccountId"
|
||||
:hx-vals (hx/json (cond-> {:name (fc/field-name)}
|
||||
client-id (assoc :client-id client-id)))
|
||||
:x-dispatch:changed "simpleAccountId"
|
||||
:hx-trigger "changed"
|
||||
:hx-get (bidi/path-for ssr-routes/only-routes ::route/location-select)
|
||||
:hx-target "find *"
|
||||
:hx-swap "outerHTML"}
|
||||
(location-select*
|
||||
{:name (fc/field-name)
|
||||
:account-location (:account/location account-id)
|
||||
:client-locations (pull-attr (dc/db conn) :client/locations client-id)
|
||||
:value location-val})))
|
||||
;; Selecting the account only affects the valid Location options, so the
|
||||
;; change swaps just this cell -- nothing else needs to re-render.
|
||||
[:div {:id "simple-account-location"}
|
||||
(com/validated-field
|
||||
{:label "Location"
|
||||
:errors (fc/field-errors)
|
||||
:x-hx-val:account-id "simpleAccountId"
|
||||
:hx-vals (hx/json (cond-> {:name (fc/field-name)}
|
||||
client-id (assoc :client-id client-id)))
|
||||
:x-dispatch:changed "simpleAccountId"
|
||||
:hx-trigger "changed"
|
||||
:hx-post (bidi/path-for ssr-routes/only-routes ::route/edit-form-changed)
|
||||
:hx-target "#simple-account-location"
|
||||
:hx-select "#simple-account-location"
|
||||
:hx-swap "outerHTML"
|
||||
:hx-include "closest form"}
|
||||
(location-select*
|
||||
{:name (fc/field-name)
|
||||
:account-location (:account/location account-id)
|
||||
:client-locations (pull-attr (dc/db conn) :client/locations client-id)
|
||||
:value location-val}))])
|
||||
(fc/with-field :transaction-account/amount
|
||||
(com/hidden {:name (fc/field-name)
|
||||
:value total}))]]))
|
||||
@@ -244,7 +252,8 @@
|
||||
[:a.text-sm.text-blue-600.hover:underline.cursor-pointer
|
||||
{:hx-post (bidi/path-for ssr-routes/only-routes ::route/edit-wizard-toggle-mode)
|
||||
:hx-include "closest form"
|
||||
:hx-target "#manual-coding-section"
|
||||
:hx-target "#wizard-form"
|
||||
:hx-select "#wizard-form"
|
||||
:hx-swap "outerHTML"}
|
||||
"Switch to advanced mode"]]]))
|
||||
|
||||
@@ -256,9 +265,10 @@
|
||||
:advanced
|
||||
:simple)))
|
||||
|
||||
(defn transaction-account-row* [{:keys [value client-id amount-mode total]}]
|
||||
(defn transaction-account-row* [{:keys [value client-id amount-mode total index]}]
|
||||
(com/data-grid-row
|
||||
(-> {:class "account-row"
|
||||
:id (str "account-row-" index)
|
||||
:x-data (hx/json {:show (boolean (not (fc/field-value (:new? value))))
|
||||
:accountId (fc/field-value (:transaction-account/account value))})
|
||||
:data-key "show"
|
||||
@@ -278,7 +288,9 @@
|
||||
:x-model "accountId"}))))
|
||||
(fc/with-field :transaction-account/location
|
||||
(com/data-grid-cell
|
||||
{}
|
||||
{:id (str "account-location-" index)}
|
||||
;; Selecting an account only affects this row's valid Location options, so the
|
||||
;; change swaps just this cell -- nothing else needs to re-render.
|
||||
(com/validated-field
|
||||
{:errors (fc/field-errors)
|
||||
:x-hx-val:account-id "accountId"
|
||||
@@ -286,9 +298,11 @@
|
||||
client-id (assoc :client-id client-id)))
|
||||
:x-dispatch:changed "accountId"
|
||||
:hx-trigger "changed"
|
||||
:hx-get (bidi/path-for ssr-routes/only-routes ::route/location-select)
|
||||
:hx-target "find *"
|
||||
:hx-swap "outerHTML"}
|
||||
: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"}
|
||||
(location-select* {:name (fc/field-name)
|
||||
:account-location (:account/location (cond->> (:transaction-account/account @value)
|
||||
(nat-int? (:transaction-account/account @value)) (dc/pull (dc/db conn)
|
||||
@@ -300,71 +314,29 @@
|
||||
{}
|
||||
(com/validated-field
|
||||
{:errors (fc/field-errors)}
|
||||
(if (= "%" amount-mode)
|
||||
(com/text-input {:name (fc/field-name)
|
||||
:class "w-16 account-amount-field"
|
||||
:value (fc/field-value)
|
||||
:type "number"
|
||||
:step "0.01"})
|
||||
(com/money-input {:name (fc/field-name)
|
||||
(let [amount-attrs {:name (fc/field-name)
|
||||
:id (str "account-amount-" index)
|
||||
:class "w-16 account-amount-field"
|
||||
:value (fc/field-value)})))))
|
||||
:value (fc/field-value)
|
||||
:hx-post (bidi/path-for ssr-routes/only-routes ::route/edit-form-changed)
|
||||
;; Typing an amount posts the whole form but swaps back only the
|
||||
;; #account-totals tbody -- a sibling of the input-bearing rows, so
|
||||
;; the amount input is never replaced and the caret survives.
|
||||
:hx-target "#account-totals"
|
||||
:hx-select "#account-totals"
|
||||
:hx-swap "outerHTML"
|
||||
:hx-trigger "keyup changed delay:300ms"
|
||||
:hx-include "closest form"}]
|
||||
(if (= "%" amount-mode)
|
||||
(com/text-input (assoc amount-attrs :type "number" :step "0.01"))
|
||||
(com/money-input amount-attrs))))))
|
||||
(com/data-grid-cell {:class "align-top"}
|
||||
(com/a-icon-button {"@click.prevent.stop" "show=false; setTimeout(() => $refs.p.remove(), 500)"
|
||||
:class "account-remove-action"} svg/x))))
|
||||
|
||||
(defn- account-field-name [index field]
|
||||
(str "step-params[transaction/accounts][" index "]["
|
||||
(if (keyword? field)
|
||||
(str (when (namespace field)
|
||||
(str (namespace field) "/"))
|
||||
(name field))
|
||||
field)
|
||||
"]"))
|
||||
|
||||
(defn transaction-account-row-no-cursor* [{:keys [account index client-id amount-mode total]}]
|
||||
(com/data-grid-row
|
||||
(-> {:class "account-row"
|
||||
:x-data (hx/json {:show true
|
||||
:accountId (:transaction-account/account account)})
|
||||
:data-key "show"
|
||||
:x-ref "p"}
|
||||
hx/alpine-mount-then-appear)
|
||||
(com/hidden {:name (account-field-name index :db/id)
|
||||
:value (or (:db/id account) "")})
|
||||
(com/data-grid-cell
|
||||
{}
|
||||
(com/validated-field
|
||||
{}
|
||||
(account-typeahead* {:value (:transaction-account/account account)
|
||||
:client-id client-id
|
||||
:name (account-field-name index :transaction-account/account)
|
||||
:x-model "accountId"})))
|
||||
(com/data-grid-cell
|
||||
{}
|
||||
(com/validated-field
|
||||
{}
|
||||
(location-select* {:name (account-field-name index :transaction-account/location)
|
||||
:account-location (:account/location (cond->> (:transaction-account/account account)
|
||||
(nat-int? (:transaction-account/account account)) (dc/pull (dc/db conn)
|
||||
'[:account/location])))
|
||||
:client-locations (pull-attr (dc/db conn) :client/locations client-id)
|
||||
:value (:transaction-account/location account)})))
|
||||
(com/data-grid-cell
|
||||
{}
|
||||
(com/validated-field
|
||||
{}
|
||||
(if (= "%" amount-mode)
|
||||
(com/text-input {:name (account-field-name index :transaction-account/amount)
|
||||
:class "w-16 account-amount-field"
|
||||
:value (:transaction-account/amount account)
|
||||
:type "number"
|
||||
:step "0.01"})
|
||||
(com/money-input {:name (account-field-name index :transaction-account/amount)
|
||||
:class "w-16 account-amount-field"
|
||||
:value (:transaction-account/amount account)}))))
|
||||
(com/data-grid-cell {:class "align-top"}
|
||||
(com/a-icon-button {"@click.prevent.stop" "show=false; setTimeout(() => $refs.p.remove(), 500)"
|
||||
(com/a-icon-button {:hx-post (bidi/path-for ssr-routes/only-routes ::route/edit-wizard-remove-account)
|
||||
:hx-vals (hx/json {:row-index (or index 0)})
|
||||
:hx-target "#wizard-form"
|
||||
:hx-select "#wizard-form"
|
||||
:hx-swap "outerHTML"
|
||||
:hx-include "closest form"
|
||||
:class "account-remove-action"} svg/x))))
|
||||
|
||||
(defn location-select [{{:keys [name account-id client-id value] :as qp} :query-params}]
|
||||
@@ -450,52 +422,52 @@
|
||||
:name "step-params[amount-mode]"
|
||||
:orientation :horizontal
|
||||
:hx-post (bidi/path-for ssr-routes/only-routes ::route/toggle-amount-mode)
|
||||
:hx-target "#account-grid-body"
|
||||
:hx-target "#wizard-form"
|
||||
:hx-select "#wizard-form"
|
||||
:hx-swap "outerHTML"
|
||||
:hx-include "closest form"}))
|
||||
(com/data-grid-header {:class "w-16"})]}
|
||||
(fc/cursor-map #(transaction-account-row* {:value %
|
||||
:client-id (-> request :entity :transaction/client :db/id)
|
||||
:amount-mode amount-mode
|
||||
:total total}))
|
||||
(com/data-grid-header {:class "w-16"})]
|
||||
;; Totals live in their own <tbody id="account-totals"> so the amount
|
||||
;; field refreshes them with a plain targeted swap, never swapping the
|
||||
;; input-bearing rows above (which would drop the caret).
|
||||
:footer-tbody
|
||||
[:tbody {:id "account-totals"}
|
||||
(com/data-grid-row {:class "account-total-row"}
|
||||
(com/data-grid-cell {})
|
||||
(com/data-grid-cell {:class "text-right"} [:span.font-bold.text-right "TOTAL"])
|
||||
(com/data-grid-cell {:id "total"
|
||||
:class "text-right"}
|
||||
(account-total* request))
|
||||
(com/data-grid-cell {}))
|
||||
(com/data-grid-row {:class "account-balance-row"}
|
||||
(com/data-grid-cell {})
|
||||
(com/data-grid-cell {:class "text-right"} [:span.font-bold.text-right "BALANCE"])
|
||||
(com/data-grid-cell {:id "balance"
|
||||
:class "text-right"}
|
||||
(account-balance* request))
|
||||
(com/data-grid-cell {}))
|
||||
(com/data-grid-row {:class "account-grand-total-row"}
|
||||
(com/data-grid-cell {})
|
||||
(com/data-grid-cell {:class "text-right"} [:span.font-bold.text-right "TRANSACTION TOTAL"])
|
||||
(com/data-grid-cell {:class "text-right"}
|
||||
(format "$%,.2f" total))
|
||||
(com/data-grid-cell {}))]}
|
||||
(fc/cursor-map (fn [cursor]
|
||||
(transaction-account-row* {:value cursor
|
||||
:client-id (-> request :entity :transaction/client :db/id)
|
||||
:amount-mode amount-mode
|
||||
:total total
|
||||
:index (last (cursor/path cursor))})))
|
||||
|
||||
(com/data-grid-new-row {:colspan 4
|
||||
:hx-get (bidi/path-for ssr-routes/only-routes
|
||||
::route/edit-wizard-new-account)
|
||||
:row-offset 0
|
||||
:index (count (:transaction/accounts snapshot))
|
||||
:tr-params {:hx-vals (hx/json {:client-id (:transaction/client snapshot)})}}
|
||||
"New account")
|
||||
(com/data-grid-row {:class "account-total-row"}
|
||||
(com/data-grid-cell {})
|
||||
(com/data-grid-cell {:class "text-right"} [:span.font-bold.text-right "TOTAL"])
|
||||
(com/data-grid-cell {:id "total"
|
||||
:class "text-right"
|
||||
:hx-trigger "change from:closest form target:.amount-field"
|
||||
:hx-put (bidi.bidi/path-for ssr-routes/only-routes ::route/account-total)
|
||||
:hx-target "this"
|
||||
:hx-swap "innerHTML"}
|
||||
(account-total* request))
|
||||
(com/data-grid-cell {}))
|
||||
|
||||
(com/data-grid-row {:class "account-balance-row"}
|
||||
(com/data-grid-cell {})
|
||||
(com/data-grid-cell {:class "text-right"} [:span.font-bold.text-right "BALANCE"])
|
||||
(com/data-grid-cell {:id "total"
|
||||
:class "text-right"
|
||||
:hx-trigger "change from:closest form target:.amount-field"
|
||||
:hx-put (bidi.bidi/path-for ssr-routes/only-routes ::route/account-balance)
|
||||
:hx-target "this"
|
||||
:hx-swap "innerHTML"}
|
||||
(account-balance* request))
|
||||
(com/data-grid-cell {}))
|
||||
|
||||
(com/data-grid-row {:class "account-grand-total-row"}
|
||||
(com/data-grid-cell {})
|
||||
(com/data-grid-cell {:class "text-right"} [:span.font-bold.text-right "TRANSACTION TOTAL"])
|
||||
(com/data-grid-cell {:class "text-right"}
|
||||
(format "$%,.2f" total))
|
||||
(com/data-grid-cell {})))))
|
||||
(com/data-grid-row {:class "new-row"}
|
||||
(com/data-grid-cell {:colspan 4}
|
||||
(com/a-button {:hx-post (bidi/path-for ssr-routes/only-routes ::route/edit-wizard-new-account)
|
||||
:hx-target "#wizard-form"
|
||||
:hx-select "#wizard-form"
|
||||
:hx-swap "outerHTML"
|
||||
:hx-include "closest form"
|
||||
:color :secondary}
|
||||
"New account"))))))
|
||||
|
||||
(defn manual-coding-section*
|
||||
"Renders the vendor field + account/location section for the manual tab.
|
||||
@@ -509,10 +481,11 @@
|
||||
(seq (:transaction/accounts snapshot)))
|
||||
row-count (count all-accounts)]
|
||||
[:div#manual-coding-section
|
||||
(com/hidden {:name "mode" :value (name mode)})
|
||||
(com/hidden {:name "step-params[mode]" :value (name mode)})
|
||||
[:div {:hx-trigger "change"
|
||||
:hx-post (bidi/path-for ssr-routes/only-routes ::route/edit-vendor-changed)
|
||||
:hx-target "#manual-coding-section"
|
||||
:hx-target "#wizard-form"
|
||||
:hx-select "#wizard-form"
|
||||
:hx-swap "outerHTML"
|
||||
:hx-sync "this:replace"
|
||||
:hx-include "closest form"}
|
||||
@@ -528,17 +501,18 @@
|
||||
:value (fc/field-value)
|
||||
:content-fn (fn [c] (pull-attr (dc/db conn) :vendor/name c))})]))]
|
||||
(if (= mode :simple)
|
||||
[:div {:x-data (hx/json {:simpleAccountId
|
||||
(let [av (-> (first all-accounts) :transaction-account/account)]
|
||||
(if (map? av) (:db/id av) av))})}
|
||||
(simple-mode-fields* request)]
|
||||
(let [simple-account-id (let [av (-> (first all-accounts) :transaction-account/account)]
|
||||
(if (map? av) (:db/id av) av))]
|
||||
[:div {:x-data (hx/json {:simpleAccountId simple-account-id})}
|
||||
(simple-mode-fields* request)])
|
||||
[:div
|
||||
(when (<= row-count 1)
|
||||
[:div.mb-2
|
||||
[:a.text-sm.text-blue-600.hover:underline.cursor-pointer
|
||||
{:hx-post (bidi/path-for ssr-routes/only-routes ::route/edit-wizard-toggle-mode)
|
||||
:hx-include "closest form"
|
||||
:hx-target "#manual-coding-section"
|
||||
:hx-target "#wizard-form"
|
||||
:hx-select "#wizard-form"
|
||||
:hx-swap "outerHTML"}
|
||||
"Switch to simple mode"]])
|
||||
(fc/with-field :transaction/accounts
|
||||
@@ -557,59 +531,7 @@
|
||||
(assoc-in [:multi-form-state :snapshot :transaction/accounts] accounts)
|
||||
(assoc-in [:multi-form-state :snapshot :amount-mode] new-mode))]
|
||||
(html-response
|
||||
[:div#account-grid-body
|
||||
(com/data-grid {:headers [(com/data-grid-header {} "Account")
|
||||
(com/data-grid-header {:class "w-32"} "Location")
|
||||
(com/data-grid-header {:class "w-16"}
|
||||
(com/radio-card {:options [{:value "$" :content "$"}
|
||||
{:value "%" :content "%"}]
|
||||
:value new-mode
|
||||
:name "step-params[amount-mode]"
|
||||
:orientation :horizontal
|
||||
:hx-post (bidi/path-for ssr-routes/only-routes ::route/toggle-amount-mode)
|
||||
:hx-target "#account-grid-body"
|
||||
:hx-swap "outerHTML"
|
||||
:hx-include "closest form"}))
|
||||
(com/data-grid-header {:class "w-16"})]}
|
||||
(map-indexed (fn [idx account]
|
||||
(transaction-account-row-no-cursor*
|
||||
{:account account
|
||||
:index idx
|
||||
:client-id (-> updated-request :entity :transaction/client :db/id)
|
||||
:amount-mode new-mode
|
||||
:total total}))
|
||||
accounts)
|
||||
(com/data-grid-new-row {:colspan 4
|
||||
:hx-get (bidi/path-for ssr-routes/only-routes
|
||||
::route/edit-wizard-new-account)
|
||||
:row-offset 0
|
||||
:index (count accounts)
|
||||
:tr-params {:hx-vals (hx/json {:client-id (:transaction/client snapshot)})}}
|
||||
"New account")
|
||||
(com/data-grid-row {}
|
||||
(com/data-grid-cell {})
|
||||
(com/data-grid-cell {:class "text-right"} [:span.font-bold.text-right "TOTAL"])
|
||||
(com/data-grid-cell {:id "total"
|
||||
:class "text-right"}
|
||||
(format "$%,.2f" (double (reduce + 0.0 (map :transaction-account/amount accounts)))))
|
||||
(com/data-grid-cell {}))
|
||||
(com/data-grid-row {}
|
||||
(com/data-grid-cell {})
|
||||
(com/data-grid-cell {:class "text-right"} [:span.font-bold.text-right "BALANCE"])
|
||||
(com/data-grid-cell {:id "total"
|
||||
:class "text-right"}
|
||||
(let [account-total (double (reduce + 0.0 (map :transaction-account/amount accounts)))
|
||||
balance (- total account-total)]
|
||||
[:span {:class (when-not (dollars= 0.0 balance)
|
||||
"text-red-300")}
|
||||
(format "$%,.2f" (double balance))]))
|
||||
(com/data-grid-cell {}))
|
||||
(com/data-grid-row {}
|
||||
(com/data-grid-cell {})
|
||||
(com/data-grid-cell {:class "text-right"} [:span.font-bold.text-right "TRANSACTION TOTAL"])
|
||||
(com/data-grid-cell {:class "text-right"}
|
||||
(format "$%,.2f" total))
|
||||
(com/data-grid-cell {})))])))
|
||||
(render-full-form updated-request))))
|
||||
|
||||
(defn transaction-details-panel [tx]
|
||||
[:div.p-4.space-y-4
|
||||
@@ -617,7 +539,7 @@
|
||||
[:div.space-y-3
|
||||
[:div
|
||||
[:div.text-xs.font-medium.text-gray-500 "Amount"]
|
||||
[:div.text-sm.font-medium.text-gray-900 (format "$%,.2f" (Math/abs (:transaction/amount tx)))]]
|
||||
[:div.text-sm.font-medium.text-gray-900 (format "$%,.2f" (Math/abs (or (:transaction/amount tx) 0.0)))]]
|
||||
[:div
|
||||
[:div.text-xs.font-medium.text-gray-500 "Date"]
|
||||
[:div.text-sm.text-gray-900 (some-> tx :transaction/date coerce/to-date-time (atime/unparse-local atime/normal-date))]]
|
||||
@@ -903,8 +825,12 @@
|
||||
{:label "Memo"
|
||||
:errors (fc/field-errors)}
|
||||
[:div.w-96
|
||||
;; Memo affects nothing else, so it issues no request at all -- its
|
||||
;; value just rides along in the form (posted with the next dependent
|
||||
;; change, and merged into the snapshot on save).
|
||||
(com/text-input {:value (-> (fc/field-value))
|
||||
:name (fc/field-name)
|
||||
:id "edit-memo"
|
||||
:error? (fc/field-errors)
|
||||
:placeholder "Optional note"})]))
|
||||
[:div {:x-data (hx/json {:activeForm (if (:transaction/payment (:entity request))
|
||||
@@ -1255,7 +1181,10 @@
|
||||
[{:as request
|
||||
transaction :entity
|
||||
:keys [multi-form-state]}]
|
||||
(let [tx-data (-> multi-form-state :snapshot (dissoc :action))
|
||||
(let [;; :mode is a UI-only field (simple/advanced); :action/:amount-mode are control
|
||||
;; fields. None are Datomic attributes, so strip them before building the upsert
|
||||
;; (otherwise :upsert-transaction fails with :db.error/not-an-entity :mode).
|
||||
tx-data (-> multi-form-state :snapshot (dissoc :action :mode))
|
||||
tx-id (:db/id tx-data)
|
||||
client-id (->db-id (:transaction/client tx-data))
|
||||
existing-tx (d-transactions/get-by-id tx-id)
|
||||
@@ -1387,7 +1316,8 @@
|
||||
:form-params
|
||||
(-> mm/default-form-props
|
||||
(assoc :hx-post
|
||||
(str (bidi/path-for ssr-routes/only-routes ::route/edit-submit))))
|
||||
(str (bidi/path-for ssr-routes/only-routes ::route/edit-submit))
|
||||
:hx-ext "response-targets"))
|
||||
:render-timeline? false))
|
||||
(steps [_]
|
||||
[:links])
|
||||
@@ -1430,6 +1360,18 @@
|
||||
(fc/with-field :transaction/accounts
|
||||
(account-grid-body* request)))))
|
||||
|
||||
(defn render-full-form
|
||||
"Helper to render the complete transaction edit form for whole-form re-rendering."
|
||||
[request]
|
||||
(mm/render-wizard edit-wizard request))
|
||||
|
||||
(defn edit-form-changed-handler
|
||||
"Generic handler that re-renders the whole form. Used when any field changes
|
||||
and we need the server to re-compute dependent fields."
|
||||
[request]
|
||||
(html-response
|
||||
(render-full-form request)))
|
||||
|
||||
(defn edit-vendor-changed-handler [request]
|
||||
(let [multi-form-state (:multi-form-state request)
|
||||
snapshot (:snapshot multi-form-state)
|
||||
@@ -1439,7 +1381,7 @@
|
||||
"simple"))
|
||||
client-id (or (:transaction/client snapshot)
|
||||
(-> request :entity :transaction/client :db/id))
|
||||
vendor-id (or (:transaction/vendor step-params)
|
||||
vendor-id (or (->db-id (:transaction/vendor step-params))
|
||||
(->db-id (get step-params "transaction/vendor"))
|
||||
(:transaction/vendor snapshot))
|
||||
total (Math/abs (or (-> request :entity :transaction/amount)
|
||||
@@ -1466,16 +1408,14 @@
|
||||
(let [new-account (cond-> {:db/id (str (java.util.UUID/randomUUID))
|
||||
:transaction-account/location (or (:account/location default-account) "Shared")
|
||||
:transaction-account/amount (if (= amount-mode "%") 100.0 total)}
|
||||
default-account (assoc :transaction-account/account (:db/id default-account)))]
|
||||
default-account (assoc :transaction-account/account (:db/id default-account)))]
|
||||
(-> request
|
||||
(assoc-in [:multi-form-state :snapshot :transaction/accounts] [new-account])
|
||||
(assoc-in [:multi-form-state :step-params :transaction/accounts] [new-account])))
|
||||
request)
|
||||
(assoc-in [:multi-form-state :step-params :transaction/vendor] vendor-id))]
|
||||
(html-response
|
||||
(fc/start-form (:multi-form-state render-request) nil
|
||||
(fc/with-field :step-params
|
||||
(manual-coding-section* mode render-request))))))
|
||||
(render-full-form render-request))))
|
||||
|
||||
(defn edit-wizard-toggle-mode-handler [request]
|
||||
(let [step-params (-> request :multi-form-state :step-params)
|
||||
@@ -1493,7 +1433,9 @@
|
||||
(assoc-in [:multi-form-state :snapshot :transaction/accounts]
|
||||
(vec accounts))
|
||||
(assoc-in [:multi-form-state :step-params :transaction/accounts]
|
||||
(vec accounts))))
|
||||
(vec accounts))
|
||||
(assoc-in [:multi-form-state :step-params :mode]
|
||||
(name target-mode))))
|
||||
;; advanced→simple: take first row only
|
||||
(let [first-row (first (or (seq (:transaction/accounts step-params))
|
||||
(seq (:transaction/accounts snapshot))))]
|
||||
@@ -1501,11 +1443,46 @@
|
||||
(assoc-in [:multi-form-state :snapshot :transaction/accounts]
|
||||
(if first-row [first-row] []))
|
||||
(assoc-in [:multi-form-state :step-params :transaction/accounts]
|
||||
(if first-row [first-row] [])))))]
|
||||
(if first-row [first-row] []))
|
||||
(assoc-in [:multi-form-state :step-params :mode]
|
||||
(name target-mode)))))]
|
||||
(html-response
|
||||
(fc/start-form (:multi-form-state render-request) nil
|
||||
(fc/with-field :step-params
|
||||
(manual-coding-section* target-mode render-request))))))
|
||||
(render-full-form render-request))))
|
||||
|
||||
(defn edit-wizard-new-account-handler
|
||||
"Adds a new account row and re-renders the whole form."
|
||||
[request]
|
||||
(let [snapshot (-> request :multi-form-state :snapshot)
|
||||
amount-mode (or (:amount-mode snapshot) "$")
|
||||
total (Math/abs (or (:transaction/amount snapshot) 0.0))
|
||||
new-account {:db/id (str (java.util.UUID/randomUUID))
|
||||
:new? true
|
||||
:transaction-account/location "Shared"
|
||||
:transaction-account/amount (if (= amount-mode "%") 100.0 total)}
|
||||
accounts (vec (or (:transaction/accounts snapshot) []))
|
||||
updated-accounts (conj accounts new-account)
|
||||
updated-request (-> request
|
||||
(assoc-in [:multi-form-state :snapshot :transaction/accounts] updated-accounts)
|
||||
(assoc-in [:multi-form-state :step-params :transaction/accounts] updated-accounts))]
|
||||
(html-response
|
||||
(render-full-form updated-request))))
|
||||
|
||||
(defn edit-wizard-remove-account-handler
|
||||
"Removes an account row and re-renders the whole form.
|
||||
Expects a row-index in the form params."
|
||||
[request]
|
||||
(let [row-index (some-> request :form-params (get "row-index") Integer/parseInt)
|
||||
snapshot (-> request :multi-form-state :snapshot)
|
||||
accounts (vec (or (:transaction/accounts snapshot) []))
|
||||
updated-accounts (if (and row-index (< row-index (count accounts)))
|
||||
(vec (concat (subvec accounts 0 row-index)
|
||||
(subvec accounts (inc row-index))))
|
||||
accounts)
|
||||
updated-request (-> request
|
||||
(assoc-in [:multi-form-state :snapshot :transaction/accounts] updated-accounts)
|
||||
(assoc-in [:multi-form-state :step-params :transaction/accounts] updated-accounts))]
|
||||
(html-response
|
||||
(render-full-form updated-request))))
|
||||
|
||||
(def key->handler
|
||||
(apply-middleware-to-all-handlers
|
||||
@@ -1544,6 +1521,10 @@
|
||||
(mm/wrap-wizard edit-wizard)
|
||||
(wrap-entity [:multi-form-state :snapshot :db/id] d-transactions/default-read)
|
||||
(mm/wrap-decode-multi-form-state))
|
||||
::route/edit-form-changed (-> edit-form-changed-handler
|
||||
(mm/wrap-wizard edit-wizard)
|
||||
(wrap-entity [:multi-form-state :snapshot :db/id] d-transactions/default-read)
|
||||
(mm/wrap-decode-multi-form-state))
|
||||
::route/toggle-amount-mode (-> toggle-amount-mode
|
||||
(mm/wrap-wizard edit-wizard)
|
||||
(wrap-entity [:multi-form-state :snapshot :db/id] d-transactions/default-read)
|
||||
@@ -1552,22 +1533,14 @@
|
||||
(mm/wrap-wizard edit-wizard)
|
||||
(wrap-entity [:multi-form-state :snapshot :db/id] d-transactions/default-read)
|
||||
(mm/wrap-decode-multi-form-state))
|
||||
::route/edit-wizard-new-account (->
|
||||
(add-new-entity-handler [:step-params :transaction/accounts]
|
||||
(fn render [cursor request]
|
||||
(let [snapshot (-> request :multi-form-state :snapshot)
|
||||
amount-mode (or (:amount-mode snapshot) "$")
|
||||
total (Math/abs (or (:transaction/amount snapshot) 0.0))]
|
||||
(transaction-account-row*
|
||||
{:value cursor
|
||||
:client-id (:client-id (:query-params request))
|
||||
:amount-mode amount-mode
|
||||
:total total})))
|
||||
(fn build-new-row [base _]
|
||||
(assoc base :transaction-account/location "Shared")))
|
||||
(wrap-schema-enforce :query-schema [:map
|
||||
[:client-id {:optional true}
|
||||
[:maybe entity-id]]]))
|
||||
::route/edit-wizard-new-account (-> edit-wizard-new-account-handler
|
||||
(mm/wrap-wizard edit-wizard)
|
||||
(wrap-entity [:multi-form-state :snapshot :db/id] d-transactions/default-read)
|
||||
(mm/wrap-decode-multi-form-state))
|
||||
::route/edit-wizard-remove-account (-> edit-wizard-remove-account-handler
|
||||
(mm/wrap-wizard edit-wizard)
|
||||
(wrap-entity [:multi-form-state :snapshot :db/id] d-transactions/default-read)
|
||||
(mm/wrap-decode-multi-form-state))
|
||||
|
||||
::route/unlink-payment (-> unlink-payment
|
||||
(wrap-must {:activity :edit :subject :transaction} (fn get-client [request] (-> request :entity :transaction/client)))
|
||||
|
||||
@@ -5,13 +5,13 @@
|
||||
[hiccup2.core :as hiccup]
|
||||
[auto-ap.ssr.components :as com]))
|
||||
(defn html-page [hiccup]
|
||||
{:status 200
|
||||
{:status 200
|
||||
:headers {"Content-Type" "text/html"}
|
||||
:body (str
|
||||
"<!DOCTYPE html>"
|
||||
(hiccup/html
|
||||
{}
|
||||
hiccup))})
|
||||
:body (str
|
||||
"<!DOCTYPE html>"
|
||||
(hiccup/html
|
||||
{}
|
||||
hiccup))})
|
||||
|
||||
(defn base-page [request contents page-name]
|
||||
(html-page
|
||||
@@ -28,7 +28,7 @@
|
||||
[:script {:src "https://cdn.jsdelivr.net/npm/@ryangjchandler/alpine-tooltip@1.x.x/dist/cdn.min.js" :defer true}]
|
||||
[:link {:rel "stylesheet" :href "/css/tippy/tippy.css"}]
|
||||
[:link {:rel "stylesheet" :href "/css/tippy/light.css"}]
|
||||
[:script {:src "/js/htmx.js"
|
||||
[:script {:src "/js/htmx.js"
|
||||
:crossorigin= "anonymous"}]
|
||||
|
||||
[:script {:src "https://cdn.jsdelivr.net/npm/sortablejs@1.15.2/Sortable.min.js"}]
|
||||
@@ -41,7 +41,7 @@
|
||||
[:script {:src "https://cdnjs.cloudflare.com/ajax/libs/Chart.js/4.4.1/chart.umd.min.js" :integrity "sha512-CQBWl4fJHWbryGE+Pc7UAxWMUMNMWzWxF4SQo9CgkJIN1kx6djDQZjh3Y8SZ1d+6I+1zze6Z7kHXO7q3UyZAWw==" :crossorigin "anonymous" :referrerpolicy "no-referrer"}]
|
||||
|
||||
[:script {:src "https://unpkg.com/dropzone@5.9.3/dist/min/dropzone.min.js" :defer true}]
|
||||
[:link {:rel "stylesheet" :href "https://unpkg.com/dropzone@5/dist/min/dropzone.min.css" :type "text/css" :defer true}]
|
||||
[:link {:rel "stylesheet" :href "https://unpkg.com/dropzone@5/dist/min/dropzone.min.css" :type "text/css" :defer true}]
|
||||
[:script {:defer true :src "/js/alpine-vals.js"}]
|
||||
[:script {:defer true :src "https://cdn.jsdelivr.net/npm/@ryangjchandler/alpine-clipboard@2.x.x/dist/alpine-clipboard.js"}]
|
||||
[:script {:defer true :src "https://cdn.jsdelivr.net/npm/@alpinejs/focus@3.x.x/dist/cdn.min.js"}]
|
||||
@@ -92,14 +92,14 @@ input[type=number] {
|
||||
"x-transition:leave-end" "!bg-opacity-0"}
|
||||
|
||||
[:div {:class "flex h-full w-full justify-stretch md:justify-center items-stretch md:items-center "
|
||||
"x-trap.inert.noscroll" "open"
|
||||
"x-trap.inert" "open"
|
||||
"x-show" "open"
|
||||
"x-transition:enter" "ease-out duration-300"
|
||||
"x-trap.inert.noscroll" "open"
|
||||
"x-trap.inert" "open"
|
||||
"x-show" "open"
|
||||
"x-transition:enter" "ease-out duration-300"
|
||||
"x-transition:enter-start" "!bg-opacity-0 !translate-y-32"
|
||||
"x-transition:enter-end" "!bg-opacity-100 !translate-y-0"
|
||||
"x-transition:leave" "duration-300"
|
||||
"x-transition:enter-end" "!bg-opacity-100 !translate-y-0"
|
||||
"x-transition:leave" "duration-300"
|
||||
"x-transition:leave-start" "!opacity-100 !translate-y-0"
|
||||
"x-transition:leave-end" "!opacity-0 !translate-y-32"}
|
||||
"x-transition:leave-end" "!opacity-0 !translate-y-32"}
|
||||
|
||||
[:div#modal-content.flex.items-center.justify-center {:class "md:p-12"}]]]]]]))
|
||||
|
||||
@@ -33,7 +33,9 @@
|
||||
"/account-total" ::account-total
|
||||
"/account-balance" ::account-balance
|
||||
"/toggle-amount-mode" ::toggle-amount-mode
|
||||
"/edit-form-changed" ::edit-form-changed
|
||||
"/edit-wizard-new-account" ::edit-wizard-new-account
|
||||
"/edit-wizard-remove-account" ::edit-wizard-remove-account
|
||||
"/edit-wizard-toggle-mode" ::edit-wizard-toggle-mode
|
||||
"/match-payment" ::link-payment
|
||||
"/match-autopay-invoices" ::link-autopay-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"
|
||||
:vendor/name "Test Vendor"
|
||||
:vendor/default-account "account-id"}
|
||||
{:db/id "vendor-id-2"
|
||||
:vendor/name "Second Vendor"
|
||||
:vendor/default-account "account-id-2"}
|
||||
(test-transaction :db/id "transaction-id"
|
||||
:transaction/client "client-id"
|
||||
:transaction/bank-account "bank-account-id"
|
||||
@@ -166,7 +169,8 @@
|
||||
:second-account (get tempids "account-id-2")
|
||||
:fixed-location-account (get tempids "account-id-fixed-loc")
|
||||
:ap-account (get tempids "ap-account-id")
|
||||
:vendor (get tempids "vendor-id")})
|
||||
:vendor (get tempids "vendor-id")
|
||||
:vendor2 (get tempids "vendor-id-2")})
|
||||
(reset! test-client-ids
|
||||
{:test (get tempids "client-id")
|
||||
:test2 (get tempids "client-id-2")})
|
||||
|
||||
Reference in New Issue
Block a user