Compare commits
10 Commits
32056bf396
...
c892719bd1
| Author | SHA1 | Date | |
|---|---|---|---|
| c892719bd1 | |||
| d0fad63e24 | |||
| 0b5bfd9c84 | |||
| 38ad665726 | |||
| 798b350c81 | |||
| 0f5650b73e | |||
| 1d5a95196f | |||
| 07159dc221 | |||
| 57f3b63b6a | |||
| a7ccdb12f3 |
@@ -90,6 +90,54 @@ replaces the amount input (caret survives).
|
|||||||
:placeholder "Optional note"}) ; no hx-* — rides along to save
|
:placeholder "Optional note"}) ; no hx-* — rides along to save
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## location-select — first Selmer-migrated component (validated)
|
||||||
|
|
||||||
|
The account row's location `<select>`, rendered from a Selmer template instead of
|
||||||
|
`com/select`. The first interactive modal component off Hiccup; proves the render-file
|
||||||
|
path + interop bridge on real, e2e-covered markup (swap 6/6, transaction-edit 8/8).
|
||||||
|
|
||||||
|
```clojure
|
||||||
|
;; templates/components/location-select.html — plain HTML, {% for %} + {% if selected %}
|
||||||
|
(defn location-select* [{:keys [name client-locations value ...]}]
|
||||||
|
(let [options (cond ...) ; [[value label] ...]
|
||||||
|
selected (or value (ffirst options))
|
||||||
|
classes (str/join " " (conj (vec inputs/default-input-classes) "w-full"))]
|
||||||
|
(sel/render->hiccup "templates/components/location-select.html"
|
||||||
|
{:name name :classes classes
|
||||||
|
:options (for [[v l] options] {:value v :label l :selected (= v selected)})})))
|
||||||
|
```
|
||||||
|
Reuse: pass `inputs/default-input-classes` in (don't hard-code); embed via
|
||||||
|
`render->hiccup` so it drops into the still-Hiccup row. See `selmer-conventions.md`.
|
||||||
|
|
||||||
|
## fixed-index row from explicit data — de-faking a deep cursor
|
||||||
|
|
||||||
|
When a row always lives at a known index (e.g. simple mode renders exactly `accounts[0]`),
|
||||||
|
render it from **explicit data with explicit field names** instead of faking a cursor
|
||||||
|
rooted there. Build the name the same way the cursor would (`path->name2`) and read errors
|
||||||
|
from the same path — no `with-cursor`/`MapCursor` rebind, no `with-field-default` (which
|
||||||
|
*mutates* the cursor and breaks swap behavior, see `gotchas.md`).
|
||||||
|
|
||||||
|
```clojure
|
||||||
|
(defn- account-field-name [index field] ; == path->name2 for this path
|
||||||
|
(str "step-params[transaction/accounts][" index "]["
|
||||||
|
(if (keyword? field)
|
||||||
|
(str (when (namespace field) (str (namespace field) "/")) (name field))
|
||||||
|
field) "]"))
|
||||||
|
|
||||||
|
(defn- account-field-errors [index field]
|
||||||
|
(when (bound? #'fc/*form-errors*)
|
||||||
|
(get-in fc/*form-errors* [:step-params :transaction/accounts index field])))
|
||||||
|
|
||||||
|
;; render the row directly -- no fc/with-field / fc/with-cursor wrappers
|
||||||
|
[:span
|
||||||
|
(com/hidden {:name (account-field-name 0 :db/id) :value row-id})
|
||||||
|
(com/validated-field {:errors (account-field-errors 0 :transaction-account/account)}
|
||||||
|
(account-typeahead* {:name (account-field-name 0 :transaction-account/account) ...}))
|
||||||
|
...]
|
||||||
|
```
|
||||||
|
Verify byte-parity against the cursor version (the swap spec's simple-mode tests catch
|
||||||
|
divergence). Scorecard heuristic 1: faked roots → 0.
|
||||||
|
|
||||||
## mode toggle ($/% radio, simple/advanced link) — Rule 3, whole-form swap
|
## mode toggle ($/% radio, simple/advanced link) — Rule 3, whole-form swap
|
||||||
|
|
||||||
```clojure
|
```clojure
|
||||||
|
|||||||
@@ -83,6 +83,71 @@ validation re-render; a `#error {…}` stack means a 500. Then serialize the for
|
|||||||
before save (`new FormData(document.querySelector('#wizard-form'))`) to see exactly what
|
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.
|
posts. This is how the `:mode` 500 and the empty-account bugs above were isolated.
|
||||||
|
|
||||||
|
## De-faking a cursor is not a drop-in — `with-field-default` mutates
|
||||||
|
|
||||||
|
Tempting fix for a faked deep cursor (`with-cursor` + synthetic `MapCursor` at index 0):
|
||||||
|
replace it with `(fc/with-field-default 0 {})` to advance naturally. **It broke the
|
||||||
|
simple-mode swap** (`transaction-edit-swap` test 1 threw). `with-field-default` calls
|
||||||
|
`cursor/transact!` — it *mutates the form cursor* (assoc-ing the default row) as a render
|
||||||
|
side effect, which changes simple-mode behavior. The read-only synthetic `MapCursor` did
|
||||||
|
not. Lesson: removing a faked cursor on these modals is **not** a one-liner — it's part of
|
||||||
|
the larger render-fn extraction (render the row from explicit data, construct field names
|
||||||
|
directly, look up errors explicitly), done when the simple/advanced rows are reworked into
|
||||||
|
pure render fns / Selmer. Don't swap one cursor primitive for another and assume parity;
|
||||||
|
verify against the swap spec, and expect the de-fake to come with the render-fn rewrite.
|
||||||
|
|
||||||
|
## Snapshot operations read stale state and drop live form values (heuristic 2)
|
||||||
|
|
||||||
|
The whole-form operation handlers (`apply-new-account`, `apply-remove-account`,
|
||||||
|
`apply-toggle-amount-mode`) rebuild the account rows from the **decoded `:snapshot`** (the
|
||||||
|
hidden EDN field), not from the live posted `:step-params`. So any value the user has typed
|
||||||
|
but that hasn't been re-serialised into the snapshot yet — e.g. an amount typed right
|
||||||
|
before clicking "New account" — is **silently lost** when the operation re-renders. This is
|
||||||
|
the snapshot round-trip fragility the migration removes (heuristic 2: → 0 merges; state
|
||||||
|
should ride in the form, not a parallel snapshot). It bit the percentage-split e2e: typing
|
||||||
|
50% then adding a row reverted the first row to its snapshot value, yielding a 66.67/33.33
|
||||||
|
split. Two ways it shows up and how to handle until the snapshot is gone:
|
||||||
|
|
||||||
|
**Fixed (Stage 1):** the operation handlers read the live `:step-params` rows (already
|
||||||
|
schema-decoded by `mm/wrap-wizard`) so typed values survive add/remove/toggle.
|
||||||
|
|
||||||
|
**Done (Stage 2 — the snapshot round-trip is gone).** The EDN `snapshot` hidden field +
|
||||||
|
custom readers + `merge-multi-form-state` are removed. A `db/id` hidden rides in the form;
|
||||||
|
`wrap-derive-state` rebuilds `:multi-form-state` per request from `entity ∪ step-params`,
|
||||||
|
and `EditWizard.render-wizard` renders a plain form (no snapshot/edit-path/current-step
|
||||||
|
hiddens). The ~34 `:snapshot` reads still work — `:snapshot` is now a derived map, not a
|
||||||
|
round-tripped blob.
|
||||||
|
|
||||||
|
**Trap that cost hours — derive `entity ∪ step-params` correctly.** First cut was
|
||||||
|
`(merge base step-params)`. Bug: `base` always carries the entity's *persisted* accounts,
|
||||||
|
so after the user removes every row (step-params has no accounts key) the merge falls back
|
||||||
|
to base → the persisted accounts **resurrect** on the next operation. Fix: editable fields
|
||||||
|
(accounts, vendor, memo, approval, action, mode, amount-mode) come **only** from the live
|
||||||
|
form (absent = cleared); only entity-only fields (`db/id`, client, amount, description,
|
||||||
|
status, type) come from the entity. Lesson: with a posted form, "field absent" means
|
||||||
|
*cleared*, not "use the persisted value" — never merge the entity's editable fields back in.
|
||||||
|
|
||||||
|
**Verify the snapshot removal on a FRESH server, and don't trust a long-lived in-process
|
||||||
|
test server.** Protocol/defrecord (`EditWizard.render-wizard`) and middleware reloads do
|
||||||
|
**not** fully take in a running REPL — the server kept rendering the old snapshot field
|
||||||
|
after `:reload`, and an in-process server that isn't reseeded between `npx playwright`
|
||||||
|
invocations accumulates state that makes order-dependent tests flake. Both produced hours
|
||||||
|
of phantom failures. Restart the REPL clean (or reseed) before trusting an e2e result; CI
|
||||||
|
boots a fresh server per run, so the fresh-server number (38 pass / 1 unrelated) is the real one.
|
||||||
|
|
||||||
|
## Characterization tests rot against table order and removed wizard chrome
|
||||||
|
|
||||||
|
Two stale-test traps surfaced once the masking failure was fixed (a `mode: 'serial'` file
|
||||||
|
hides every test after the first failure, so fixing one unmasks the next):
|
||||||
|
|
||||||
|
- **Hard-coded amounts per table row index** (`openEditModal(page, 3)` then
|
||||||
|
`expect(amount).toBeCloseTo(400)`) break because same-date seed transactions have no
|
||||||
|
pinned row order. Read the actual value (e.g. the grid's `.account-grand-total-row`)
|
||||||
|
instead of hard-coding.
|
||||||
|
- **Helpers that navigate the old multi-step wizard** (`click('button:has-text("Transaction
|
||||||
|
Actions")')`) hang once the modal is single-page. Drop the navigation; the action tabs
|
||||||
|
are present immediately.
|
||||||
|
|
||||||
## Scorecard exceptions (ratchet violations with a reason)
|
## Scorecard exceptions (ratchet violations with a reason)
|
||||||
|
|
||||||
_None yet._ Append here if a migration must let a metric regress for a documented reason.
|
_None yet._ Append here if a migration must let a metric regress for a documented reason.
|
||||||
|
|||||||
@@ -39,8 +39,23 @@ Each migration appends one row (after-numbers), referencing the before in the di
|
|||||||
| Phase | Modal | LOC | Routes | no-cursor twins | faked roots | snapshot merges | OOB | mixed hx- | cookbook reused / added |
|
| 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 |
|
| 1 (baseline) | Transaction Edit `transaction/edit.clj` | 1608 | ~12 | 1 | 2 | ~75 | 0 | 8 | — / seeded 7 entries |
|
||||||
|
| 2 | Transaction Edit `transaction/edit.clj` | 1593 | **~5** | **0** | **0** | **0 round-trip** | 0 | 8 (shared) | location-select / **1 Selmer** |
|
||||||
|
|
||||||
> Phase 1 is distillation only — no app code changed. The Transaction Edit row is the
|
> **Phase 2 progress.** Achieved with parity held (swap spec **6/6**, transaction-edit
|
||||||
> **before** baseline that Phase 2 must beat (target: routes → ~3, no-cursor → 0, faked
|
> spec **8/8**, full suite **38 pass / 1 unrelated fail / 0 skip**, up from 30/2/7):
|
||||||
> roots → 0, snapshot merges → 0, LOC ↓, mixed hx- → 0). The `0` OOB is already achieved
|
> - deleted the dead `*-no-cursor*` twin (no-cursor 1→0);
|
||||||
> by the merged reference and must not regress.
|
> - **de-faked the simple-mode cursor** (faked roots 2→0) via explicit data + explicit
|
||||||
|
> field names (`account-field-name`) + explicit error lookup — the render-fn rewrite the
|
||||||
|
> `with-field-default` shortcut couldn't do;
|
||||||
|
> - **collapsed the 5 manual-coding operation routes into one `edit-form-changed`
|
||||||
|
> dispatcher** (routes ~12→~5; the operations are now pure `apply-*` fns);
|
||||||
|
> - fixed a real production bug (`:mode` → 500 on every advanced manual save);
|
||||||
|
> - greened `transaction-edit.spec.ts` (8/8) and matured the skill.
|
||||||
|
>
|
||||||
|
> **Phase 2 complete.** The wizard→plain-form rewrite removed the snapshot round-trip
|
||||||
|
> (heuristic 2 → 0) and the first interactive component (`location-select`) is migrated to
|
||||||
|
> a Selmer template (`selmer-conventions.md` validated). Remaining for *later phases*: drop
|
||||||
|
> the now-thin `mm/ModalWizardStep` protocol wrappers, and the cross-cutting Phase 11
|
||||||
|
> Selmer sweep of the shared `com/typeahead`/`com/select`/`com/button-group-button` (those
|
||||||
|
> shared call sites hold the last 8 mixed `@`/`:`-attr offenders; they clear when the
|
||||||
|
> shared components move to Selmer — not a single-modal task, per Open decision 2).
|
||||||
|
|||||||
@@ -1,21 +1,21 @@
|
|||||||
# Selmer template conventions
|
# Selmer template conventions
|
||||||
|
|
||||||
> **Status: STUB — validated in Phase 2.** This file describes the target. The Selmer
|
> **Validated** in the Transaction Edit migration: `location-select*` now renders from
|
||||||
> dependency, render helper, and interop bridge are added in Phase 2 (Transaction Edit);
|
> `resources/templates/components/location-select.html` via the interop bridge, embedded
|
||||||
> rewrite this file from the *real, verified* example once that lands, and record each
|
> back into the Hiccup account row. Verified: swap spec 6/6, transaction-edit 8/8 (the
|
||||||
> converted component in `component-cookbook.md`.
|
> Shared Location test selects through the Selmer `<select>`, saves, and spreads to DT).
|
||||||
|
|
||||||
## Why Selmer for interactive components
|
## Why Selmer for interactive components
|
||||||
|
|
||||||
In Hiccup the same Alpine/HTMX attribute is sometimes a keyword and sometimes a string in
|
In Hiccup the same Alpine/HTMX attribute is sometimes a keyword and sometimes a string in
|
||||||
the same file — there's no rule a reader (or an LLM) can rely on:
|
the same file — there's no rule a reader (or an LLM) can rely on. The real
|
||||||
|
`com/typeahead-` mixes them in one map:
|
||||||
|
|
||||||
```clojure
|
```clojure
|
||||||
;; All of these appear in one component today:
|
:x-modelable "value.value" ; keyword key
|
||||||
:x-ref "input" "x-ref" "hidden"
|
"x-ref" "hidden" ; string key
|
||||||
:x-model "value.value" "x-model" "search"
|
"@keydown.down.prevent.stop" "tippy?.show();" ; handlers MUST be strings
|
||||||
"@keydown.down.prevent.stop" "tippy.show();" ; handlers MUST be strings
|
:x-init "..." ; structural attrs are keywords
|
||||||
:x-init "..." ; structural attrs are keywords
|
|
||||||
```
|
```
|
||||||
|
|
||||||
In a Selmer template the same markup is unambiguous plain HTML:
|
In a Selmer template the same markup is unambiguous plain HTML:
|
||||||
@@ -28,36 +28,66 @@ In a Selmer template the same markup is unambiguous plain HTML:
|
|||||||
@keydown.backspace="tippy?.hide(); value = {value:'', label:''}">
|
@keydown.backspace="tippy?.hide(); value = {value:'', label:''}">
|
||||||
<span x-text="value.label"></span>
|
<span x-text="value.label"></span>
|
||||||
</a>
|
</a>
|
||||||
...
|
|
||||||
</div>
|
</div>
|
||||||
```
|
```
|
||||||
|
|
||||||
Note the `tippy?.` null-guard carried over from the swap doctrine — Selmer doesn't change
|
Note the `tippy?.` null-guard carried over from the swap doctrine — Selmer doesn't change
|
||||||
the Alpine-survives-swap requirement.
|
the Alpine-survives-swap requirement.
|
||||||
|
|
||||||
## Render helper + interop bridge (the Phase 2 foundation)
|
## The render helper + interop bridge (`auto-ap.ssr.selmer`)
|
||||||
|
|
||||||
```clojure
|
```clojure
|
||||||
(defn render [tpl ctx] (selmer/render-file tpl ctx))
|
(sel/render template ctx) ; selmer/render-file -> HTML string (classpath-relative path)
|
||||||
(defn hiccup->html [h] (hiccup/html h)) ; embed hiccup inside selmer via {{ frag|safe }}
|
(sel/render-str template ctx) ; render from a string (tests/REPL)
|
||||||
;; selmer fragment inside hiccup: [:div (hiccup/raw (render "..." ctx))]
|
(sel/hiccup->html h) ; Hiccup -> string, for {{ frag|safe }} inside a template
|
||||||
|
(sel/raw html-string) ; wrap a rendered string so hiccup2 emits it verbatim
|
||||||
|
(sel/render->hiccup template ctx); render + raw, ready to drop into a Hiccup tree
|
||||||
```
|
```
|
||||||
|
|
||||||
The bridge must work **both ways** during the strangler transition: a Hiccup component
|
The bridge works **both ways** (proven in `selmer_test`): a Hiccup component renders inside
|
||||||
renders inside a Selmer template (pass `(hiccup->html h)` into the context, render with
|
a Selmer template (`hiccup->html` + `|safe`), and a Selmer fragment renders inside a Hiccup
|
||||||
`|safe`), and a Selmer fragment renders inside a Hiccup tree (`(hiccup/raw (render ...))`).
|
tree (`render->hiccup`, which `raw`-wraps so hiccup2 doesn't double-escape).
|
||||||
Prove both in Phase 2 before broad use.
|
|
||||||
|
## The worked example — `location-select*`
|
||||||
|
|
||||||
|
Template (`resources/templates/components/location-select.html`): plain HTML, an
|
||||||
|
`{% for %}` over option maps, `{% if opt.selected %}`.
|
||||||
|
|
||||||
|
```clojure
|
||||||
|
;; Clojure side: build the data, compute classes (reuse inputs/default-input-classes so
|
||||||
|
;; styling can't drift), render, and return a Hiccup-embeddable fragment.
|
||||||
|
(defn location-select* [{:keys [name client-locations value ...]}]
|
||||||
|
(let [options (cond ...) ; [[value label] ...]
|
||||||
|
selected (or value (ffirst options))
|
||||||
|
classes (str/join " " (conj (vec inputs/default-input-classes) "w-full"))]
|
||||||
|
(sel/render->hiccup "templates/components/location-select.html"
|
||||||
|
{:name name :classes classes
|
||||||
|
:options (for [[v l] options] {:value v :label l :selected (= v selected)})})))
|
||||||
|
```
|
||||||
|
|
||||||
|
Lessons:
|
||||||
|
- **Pass computed values in, don't hard-code.** Reuse the Clojure source of truth
|
||||||
|
(`inputs/default-input-classes`) as a context value rather than copying class strings
|
||||||
|
into the template — otherwise styling drifts from the shared components.
|
||||||
|
- **Verify by string-match + e2e, not byte-parity.** `hh/add-class` is set-based, so class
|
||||||
|
*order* differs from the old `com/select` output; CSS is order-independent and the e2e
|
||||||
|
proves behavior. (`testing-conventions`: don't assert on exact markup.)
|
||||||
|
- **Embed via the bridge.** `render->hiccup` lets the Selmer fragment sit inside the
|
||||||
|
still-Hiccup row (`com/validated-field` wraps it) — strangler, one component at a time.
|
||||||
|
|
||||||
## Composition
|
## Composition
|
||||||
|
|
||||||
Selmer composes via `{% include %}` and `{% block %}`. Prefer small per-component
|
Selmer composes via `{% include %}` and `{% block %}`. Prefer small per-component templates
|
||||||
templates that the cookbook references by path. Keep `|safe` to values the server fully
|
referenced by classpath path. Keep `|safe` to values the server fully controls (rendered
|
||||||
controls (rendered Hiccup, JSON for `x-data`), never raw user input.
|
Hiccup, JSON for `x-data`), never raw user input.
|
||||||
|
|
||||||
## Scope (Open decision 2)
|
## Scope (Open decision 2)
|
||||||
|
|
||||||
Hybrid: convert interactive/attribute-heavy components first; static markup may stay
|
Hybrid, and the boundary is real: **the modal's attribute-heavy components delegate to the
|
||||||
Hiccup. Revisit a fuller sweep in Phase 11.
|
shared `com/typeahead` / `com/select` / `com/button-group-button`.** Converting those is a
|
||||||
|
*cross-cutting* change (every modal uses them), so it belongs to the Phase 11 Selmer sweep,
|
||||||
|
not a single modal. `location-select*` is the first, self-contained proof; the shared
|
||||||
|
components follow when the sweep promotes them to Selmer partials.
|
||||||
|
|
||||||
## Attribute-consistency scorecard (heuristic 8)
|
## Attribute-consistency scorecard (heuristic 8)
|
||||||
|
|
||||||
@@ -65,4 +95,5 @@ Hiccup. Revisit a fuller sweep in Phase 11.
|
|||||||
grep -cE '"x-[a-z]|"hx-[a-z]|"@' <migrated-template> # → 0 mixed encodings in Selmer
|
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
|
A migrated Selmer template has no mixed `:x-`/`"x-"` encodings because everything is plain
|
||||||
HTML.
|
HTML. (The Hiccup `"@click"`/`":class"` offenders that remain in `edit.clj` live in the
|
||||||
|
shared-component call sites — they clear when those components move to Selmer.)
|
||||||
|
|||||||
@@ -121,7 +121,17 @@ Server: in-process from `integreat-execute-refactor` on `:3334`, `--workers=1`,
|
|||||||
| 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. |
|
| 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
|
**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
|
checks.
|
||||||
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.
|
### Current state — after the Phase 2 modal work (never drop below this)
|
||||||
Never drop below 30 passing on the full suite.
|
|
||||||
|
Full suite (workers=1, fresh seed): **38 passed / 1 failed / 0 skipped.**
|
||||||
|
|
||||||
|
- `transaction-edit-swap.spec.ts` — **6/6** (parity contract held through every change).
|
||||||
|
- `transaction-edit.spec.ts` — **8/8** (was 1 pass + 7 masked). Greened by: the `:mode`
|
||||||
|
500 fix, the Alpine-v3 typeahead helper, rewriting the percentage-split test to avoid
|
||||||
|
the snapshot-drops-live-values ordering trap, reading the real transaction total instead
|
||||||
|
of a hard-coded `400`, and dropping the removed `"Transaction Actions"` wizard-nav step.
|
||||||
|
- Remaining 1 failure: `transaction-navigation.spec.ts:92` date-range-preset persistence —
|
||||||
|
**unrelated to forms** (drift from the base branch's "require Apply for date-range
|
||||||
|
filters" change). Pre-existing; out of scope for this migration.
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ async function openManualAdvanced(page: any, transactionIndex = 0) {
|
|||||||
// (Solr is unavailable in tests), click it, and wait for the whole-form swap.
|
// (Solr is unavailable in tests), click it, and wait for the whole-form swap.
|
||||||
async function selectVendor(page: any, vendorId: number, label: string) {
|
async function selectVendor(page: any, vendorId: number, label: string) {
|
||||||
const vendor = page
|
const vendor = page
|
||||||
.locator('div[hx-post*="edit-vendor-changed"]')
|
.locator('div[hx-vals*="vendor-changed"]')
|
||||||
.first()
|
.first()
|
||||||
.locator('div.relative[x-data]')
|
.locator('div.relative[x-data]')
|
||||||
.first();
|
.first();
|
||||||
@@ -62,7 +62,7 @@ async function selectVendor(page: any, vendorId: number, label: string) {
|
|||||||
|
|
||||||
const swap = page.waitForResponse(
|
const swap = page.waitForResponse(
|
||||||
(r: any) =>
|
(r: any) =>
|
||||||
r.url().includes('edit-vendor-changed') &&
|
r.url().includes('edit-form-changed') &&
|
||||||
r.request().method() === 'POST' &&
|
r.request().method() === 'POST' &&
|
||||||
r.status() === 200
|
r.status() === 200
|
||||||
);
|
);
|
||||||
@@ -303,7 +303,7 @@ test.describe('Transaction Edit whole-form swap', () => {
|
|||||||
await page.locator('button[hx-get*="/transaction2/"][hx-get*="/edit"]').nth(0).click();
|
await page.locator('button[hx-get*="/transaction2/"][hx-get*="/edit"]').nth(0).click();
|
||||||
await page.waitForSelector('#wizardmodal');
|
await page.waitForSelector('#wizardmodal');
|
||||||
await page.click('button:has-text("Manual")');
|
await page.click('button:has-text("Manual")');
|
||||||
await page.waitForSelector('div[hx-post*="edit-vendor-changed"]');
|
await page.waitForSelector('div[hx-vals*="vendor-changed"]');
|
||||||
|
|
||||||
const testInfo = await (await page.request.get('/test-info')).json();
|
const testInfo = await (await page.request.get('/test-info')).json();
|
||||||
const vendorId: number = testInfo.accounts.vendor;
|
const vendorId: number = testInfo.accounts.vendor;
|
||||||
@@ -311,7 +311,7 @@ test.describe('Transaction Edit whole-form swap', () => {
|
|||||||
|
|
||||||
// Drive the vendor typeahead like a user: open dropdown, inject a result
|
// Drive the vendor typeahead like a user: open dropdown, inject a result
|
||||||
// (Solr is unavailable in tests), click it.
|
// (Solr is unavailable in tests), click it.
|
||||||
const vendor = page.locator('div[hx-post*="edit-vendor-changed"]').first().locator('div.relative[x-data]').first();
|
const vendor = page.locator('div[hx-vals*="vendor-changed"]').first().locator('div.relative[x-data]').first();
|
||||||
await vendor.locator('a[x-ref="input"]').click();
|
await vendor.locator('a[x-ref="input"]').click();
|
||||||
const search = page.locator('[data-tippy-root] input[x-model="search"]').first();
|
const search = page.locator('[data-tippy-root] input[x-model="search"]').first();
|
||||||
await search.waitFor({ state: 'visible' });
|
await search.waitFor({ state: 'visible' });
|
||||||
@@ -322,7 +322,7 @@ test.describe('Transaction Edit whole-form swap', () => {
|
|||||||
|
|
||||||
const swap = page.waitForResponse(
|
const swap = page.waitForResponse(
|
||||||
(r: any) =>
|
(r: any) =>
|
||||||
r.url().includes('edit-vendor-changed') &&
|
r.url().includes('edit-form-changed') &&
|
||||||
r.request().method() === 'POST' &&
|
r.request().method() === 'POST' &&
|
||||||
r.status() === 200
|
r.status() === 200
|
||||||
);
|
);
|
||||||
@@ -352,7 +352,7 @@ test.describe('Transaction Edit whole-form swap', () => {
|
|||||||
await page.locator('button[hx-get*="/transaction2/"][hx-get*="/edit"]').nth(0).click();
|
await page.locator('button[hx-get*="/transaction2/"][hx-get*="/edit"]').nth(0).click();
|
||||||
await page.waitForSelector('#wizardmodal');
|
await page.waitForSelector('#wizardmodal');
|
||||||
await page.click('button:has-text("Manual")');
|
await page.click('button:has-text("Manual")');
|
||||||
await page.waitForSelector('div[hx-post*="edit-vendor-changed"]');
|
await page.waitForSelector('div[hx-vals*="vendor-changed"]');
|
||||||
|
|
||||||
const testInfo = await (await page.request.get('/test-info')).json();
|
const testInfo = await (await page.request.get('/test-info')).json();
|
||||||
const vendor1: number = testInfo.accounts.vendor;
|
const vendor1: number = testInfo.accounts.vendor;
|
||||||
@@ -361,7 +361,7 @@ test.describe('Transaction Edit whole-form swap', () => {
|
|||||||
const account2: number = testInfo.accounts['second-account'];
|
const account2: number = testInfo.accounts['second-account'];
|
||||||
|
|
||||||
const vendorLabel = page
|
const vendorLabel = page
|
||||||
.locator('div[hx-post*="edit-vendor-changed"] span[x-text="value.label"]')
|
.locator('div[hx-vals*="vendor-changed"] span[x-text="value.label"]')
|
||||||
.first();
|
.first();
|
||||||
const accountHidden = page
|
const accountHidden = page
|
||||||
.locator('input[type="hidden"][name*="transaction-account/account"]')
|
.locator('input[type="hidden"][name*="transaction-account/account"]')
|
||||||
|
|||||||
@@ -124,14 +124,13 @@ async function getAccountLocation(page: any, rowIndex: number): Promise<string>
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function removeAllAccounts(page: any) {
|
async function removeAllAccounts(page: any) {
|
||||||
const accountRows = page.locator('#account-grid-body tbody tr.account-row');
|
// Re-query each iteration: every remove is a whole-form swap that re-renders the rows,
|
||||||
const rowCount = await accountRows.count();
|
// so a row index captured up front goes stale. Click the last remove button until none
|
||||||
|
// remain.
|
||||||
for (let i = rowCount - 1; i >= 0; i--) {
|
for (let guard = 0; guard < 20; guard++) {
|
||||||
const row = accountRows.nth(i);
|
const removeButtons = page.locator('#account-grid-body .account-remove-action');
|
||||||
const removeButton = row.locator('.account-remove-action');
|
if (await removeButtons.count() === 0) break;
|
||||||
await removeButton.click();
|
await removeButtons.last().click();
|
||||||
// Wait for the Alpine.js removal animation (500ms + buffer)
|
|
||||||
await page.waitForTimeout(700);
|
await page.waitForTimeout(700);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -150,7 +149,7 @@ async function toggleToPercentMode(page: any) {
|
|||||||
|
|
||||||
// Wait for HTMX to swap the grid body
|
// Wait for HTMX to swap the grid body
|
||||||
await page.waitForResponse(response =>
|
await page.waitForResponse(response =>
|
||||||
response.url().includes('/toggle-amount-mode') && response.status() === 200
|
response.url().includes('/edit-form-changed') && response.status() === 200
|
||||||
);
|
);
|
||||||
await page.waitForTimeout(200);
|
await page.waitForTimeout(200);
|
||||||
}
|
}
|
||||||
@@ -161,7 +160,7 @@ async function toggleToDollarMode(page: any) {
|
|||||||
|
|
||||||
// Wait for HTMX to swap the grid body
|
// Wait for HTMX to swap the grid body
|
||||||
await page.waitForResponse(response =>
|
await page.waitForResponse(response =>
|
||||||
response.url().includes('/toggle-amount-mode') && response.status() === 200
|
response.url().includes('/edit-form-changed') && response.status() === 200
|
||||||
);
|
);
|
||||||
await page.waitForTimeout(200);
|
await page.waitForTimeout(200);
|
||||||
}
|
}
|
||||||
@@ -210,78 +209,39 @@ test.describe('Transaction Edit Shared Location', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test.describe('Transaction Edit Full Workflow', () => {
|
test.describe('Transaction Edit Full Workflow', () => {
|
||||||
test('should code transaction with vendor using percentage, then split 50/50, then switch to dollars', async ({ page }) => {
|
test('splits a transaction 50/50 in percentage mode and stores it as dollars', async ({ page }) => {
|
||||||
// Step 1: Open edit modal and code with 100% to one account
|
// Transaction 0 is $100. Code it 50% / 50% across two accounts in percentage mode and
|
||||||
await openEditModal(page);
|
// verify the save-time %->$ conversion stores/displays $50 + $50 on reopen.
|
||||||
|
//
|
||||||
// Switch to percentage mode first (this re-renders the grid from server state)
|
// This intentionally types a percentage and THEN adds another row -- a whole-form
|
||||||
|
// operation. The operation handlers now rebuild from the live posted form, not the
|
||||||
|
// stale snapshot, so the first row's typed 50% survives (it used to revert, yielding a
|
||||||
|
// 66.67/33.33 split).
|
||||||
|
await openEditModal(page, 0);
|
||||||
|
await removeAllAccounts(page);
|
||||||
await toggleToPercentMode(page);
|
await toggleToPercentMode(page);
|
||||||
|
|
||||||
// Check if there's already an account from previous tests
|
await addNewAccount(page);
|
||||||
const allRows = page.locator('#account-grid-body tbody tr');
|
|
||||||
const hasExistingAccount = await allRows.locator('input[name*="transaction-account/account"]').count() > 0;
|
|
||||||
|
|
||||||
if (!hasExistingAccount) {
|
|
||||||
// Add a new account row if none exist
|
|
||||||
await addNewAccount(page);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Select the account
|
|
||||||
await selectAccountFromTypeahead(page, 0, 'Test');
|
await selectAccountFromTypeahead(page, 0, 'Test');
|
||||||
|
|
||||||
// Set amount to 100%
|
|
||||||
await setAccountAmount(page, 0, '100');
|
|
||||||
|
|
||||||
// Save the transaction
|
|
||||||
await saveTransaction(page);
|
|
||||||
|
|
||||||
// Step 2: Re-open and split 50/50 with two accounts
|
|
||||||
await openEditModal(page);
|
|
||||||
|
|
||||||
// Note: amount-mode is UI-only state, so it resets to $ when re-opening
|
|
||||||
// Switch back to percentage mode
|
|
||||||
await toggleToPercentMode(page);
|
|
||||||
|
|
||||||
// The existing account from step 1 should already be there
|
|
||||||
// Change its amount from 100% to 50%
|
|
||||||
await setAccountAmount(page, 0, '50');
|
await setAccountAmount(page, 0, '50');
|
||||||
|
|
||||||
// Add a second account at 50%
|
|
||||||
await addNewAccount(page);
|
await addNewAccount(page);
|
||||||
await page.waitForTimeout(1000);
|
|
||||||
await selectAccountFromTypeahead(page, 1, 'Second');
|
await selectAccountFromTypeahead(page, 1, 'Second');
|
||||||
await setAccountAmount(page, 1, '50');
|
await setAccountAmount(page, 1, '50');
|
||||||
|
|
||||||
// Save
|
|
||||||
await saveTransaction(page);
|
await saveTransaction(page);
|
||||||
|
|
||||||
// Step 3: Re-open and verify dollar amounts
|
// Reopen: dollar mode is the default, and each account is the converted $50.
|
||||||
await openEditModal(page);
|
await openEditModal(page, 0);
|
||||||
|
|
||||||
// The accounts should be persisted from the previous save
|
|
||||||
// Wait for accounts to load
|
|
||||||
await page.waitForTimeout(500);
|
await page.waitForTimeout(500);
|
||||||
|
|
||||||
// Verify we're in dollar mode (default)
|
|
||||||
const dollarRadio = page.locator('input[name="step-params[amount-mode]"][value="$"]');
|
const dollarRadio = page.locator('input[name="step-params[amount-mode]"][value="$"]');
|
||||||
await expect(dollarRadio).toBeChecked();
|
await expect(dollarRadio).toBeChecked();
|
||||||
|
|
||||||
// Verify amounts are in dollars (converted from percentages on save)
|
const val0 = await (await findAccountRow(page, 0)).locator('.account-amount-field').inputValue();
|
||||||
const row0 = await findAccountRow(page, 0);
|
const val1 = await (await findAccountRow(page, 1)).locator('.account-amount-field').inputValue();
|
||||||
const row1 = await findAccountRow(page, 1);
|
|
||||||
|
|
||||||
const amount0 = row0.locator('.account-amount-field');
|
|
||||||
const amount1 = row1.locator('.account-amount-field');
|
|
||||||
|
|
||||||
// Each should be $50.00 (or close to it)
|
|
||||||
const val0 = await amount0.inputValue();
|
|
||||||
const val1 = await amount1.inputValue();
|
|
||||||
|
|
||||||
expect(parseFloat(val0)).toBeCloseTo(50.0, 1);
|
expect(parseFloat(val0)).toBeCloseTo(50.0, 1);
|
||||||
expect(parseFloat(val1)).toBeCloseTo(50.0, 1);
|
expect(parseFloat(val1)).toBeCloseTo(50.0, 1);
|
||||||
|
|
||||||
// Save
|
|
||||||
await saveTransaction(page);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -340,15 +300,11 @@ async function openEditModalForTransaction(page: any, description: string) {
|
|||||||
const editButton = row.locator('button[hx-get*="/transaction2/"][hx-get*="/edit"]').first();
|
const editButton = row.locator('button[hx-get*="/transaction2/"][hx-get*="/edit"]').first();
|
||||||
await editButton.click();
|
await editButton.click();
|
||||||
|
|
||||||
// Wait for the modal to open
|
// Wait for the modal to open. The modal is single-page now (no multi-step wizard
|
||||||
|
// navigation), so the action tabs -- including "Link to payment" -- are available
|
||||||
|
// immediately; callers click the tab they need.
|
||||||
await page.waitForSelector('#modal-holder[x-show="open"]', { state: 'visible' });
|
await page.waitForSelector('#modal-holder[x-show="open"]', { state: 'visible' });
|
||||||
await page.waitForSelector('#wizardmodal');
|
await page.waitForSelector('#wizardmodal');
|
||||||
|
|
||||||
// Click Next to go to the links step (button says "Transaction Actions")
|
|
||||||
await page.click('button:has-text("Transaction Actions")');
|
|
||||||
|
|
||||||
// Wait for the links step to load
|
|
||||||
await page.waitForSelector('text=Transaction Actions', { state: 'visible' });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function selectVendorFromTypeahead(page: any, vendorName: string) {
|
async function selectVendorFromTypeahead(page: any, vendorName: string) {
|
||||||
@@ -359,7 +315,7 @@ async function selectVendorFromTypeahead(page: any, vendorName: string) {
|
|||||||
throw new Error(`Could not find vendor with name ${vendorName}`);
|
throw new Error(`Could not find vendor with name ${vendorName}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const vendorContainer = page.locator('div[hx-post*="edit-vendor-changed"]').first();
|
const vendorContainer = page.locator('div[hx-vals*="vendor-changed"]').first();
|
||||||
const vendorHidden = vendorContainer.locator('input[type="hidden"]').first();
|
const vendorHidden = vendorContainer.locator('input[type="hidden"]').first();
|
||||||
|
|
||||||
await vendorHidden.evaluate((el: HTMLInputElement, value: string) => {
|
await vendorHidden.evaluate((el: HTMLInputElement, value: string) => {
|
||||||
@@ -374,7 +330,7 @@ async function selectVendorFromTypeahead(page: any, vendorName: string) {
|
|||||||
el.dispatchEvent(new Event('change', { bubbles: true }));
|
el.dispatchEvent(new Event('change', { bubbles: true }));
|
||||||
});
|
});
|
||||||
|
|
||||||
await page.waitForResponse(response => response.url().includes('/edit-vendor-changed') && response.status() === 200);
|
await page.waitForResponse(response => response.url().includes('/edit-form-changed') && response.status() === 200);
|
||||||
await page.waitForTimeout(500);
|
await page.waitForTimeout(500);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -422,9 +378,17 @@ test.describe('Transaction Edit Vendor Pre-population', () => {
|
|||||||
const testInfo = await getTestInfo(page);
|
const testInfo = await getTestInfo(page);
|
||||||
expect(accountValue).toBe(testInfo.accounts['test-account'].toString());
|
expect(accountValue).toBe(testInfo.accounts['test-account'].toString());
|
||||||
|
|
||||||
|
// The populated account amount should equal this transaction's amount (the vendor
|
||||||
|
// default fills the single row with the whole amount). Read the actual amount from
|
||||||
|
// the grid's transaction-total row rather than hard-coding it -- table row order is
|
||||||
|
// not pinned across same-date seed transactions.
|
||||||
|
const txTotalText = await page.locator('.account-grand-total-row').innerText();
|
||||||
|
const txTotal = parseFloat(txTotalText.replace(/[^0-9.]/g, ''));
|
||||||
|
expect(txTotal).toBeGreaterThan(0);
|
||||||
|
|
||||||
const amountInput = page.locator('.account-amount-field').first();
|
const amountInput = page.locator('.account-amount-field').first();
|
||||||
const amountValue = await amountInput.inputValue();
|
const amountValue = await amountInput.inputValue();
|
||||||
expect(parseFloat(amountValue)).toBeCloseTo(400.0, 1);
|
expect(parseFloat(amountValue)).toBeCloseTo(txTotal, 1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -434,11 +398,11 @@ test.describe('Transaction Edit Vendor Pre-population', () => {
|
|||||||
// `elements` instead of being fetched. Everything else -- the dropdown's own
|
// `elements` instead of being fetched. Everything else -- the dropdown's own
|
||||||
// search input firing a native `change` on blur, the `value = element` click
|
// search input firing a native `change` on blur, the `value = element` click
|
||||||
// handler, the Alpine reactivity, and the HTMX round-trip to
|
// handler, the Alpine reactivity, and the HTMX round-trip to
|
||||||
// `edit-vendor-changed` -- runs exactly as in production. This is the flow that
|
// `edit-form-changed` (op=vendor-changed) -- runs exactly as in production. This is the flow that
|
||||||
// regressed: a stale native `change` from the search input used to win the race
|
// regressed: a stale native `change` from the search input used to win the race
|
||||||
// and revert the vendor to its previous value.
|
// and revert the vendor to its previous value.
|
||||||
async function selectVendorViaDropdown(page: any, vendorId: number, vendorName: string) {
|
async function selectVendorViaDropdown(page: any, vendorId: number, vendorName: string) {
|
||||||
const wrapper = page.locator('div[hx-post*="edit-vendor-changed"]').first();
|
const wrapper = page.locator('div[hx-vals*="vendor-changed"]').first();
|
||||||
const typeahead = wrapper.locator('div.relative[x-data]').first();
|
const typeahead = wrapper.locator('div.relative[x-data]').first();
|
||||||
|
|
||||||
// Open the dropdown (tippy renders the popper into [data-tippy-root]).
|
// Open the dropdown (tippy renders the popper into [data-tippy-root]).
|
||||||
@@ -466,7 +430,7 @@ async function selectVendorViaDropdown(page: any, vendorId: number, vendorName:
|
|||||||
|
|
||||||
await page.waitForResponse(
|
await page.waitForResponse(
|
||||||
(response: any) =>
|
(response: any) =>
|
||||||
response.url().includes('/edit-vendor-changed') && response.status() === 200
|
response.url().includes('/edit-form-changed') && response.status() === 200
|
||||||
);
|
);
|
||||||
await page.waitForTimeout(500);
|
await page.waitForTimeout(500);
|
||||||
}
|
}
|
||||||
@@ -485,7 +449,7 @@ async function openManualVendorSection(page: any, transactionIndex: number) {
|
|||||||
await page.waitForSelector('#modal-holder[x-show="open"]', { state: 'visible' });
|
await page.waitForSelector('#modal-holder[x-show="open"]', { state: 'visible' });
|
||||||
await page.waitForSelector('#wizardmodal');
|
await page.waitForSelector('#wizardmodal');
|
||||||
await page.click('button:has-text("Manual")');
|
await page.click('button:has-text("Manual")');
|
||||||
await page.waitForSelector('div[hx-post*="edit-vendor-changed"]');
|
await page.waitForSelector('div[hx-vals*="vendor-changed"]');
|
||||||
}
|
}
|
||||||
|
|
||||||
test.describe('Transaction Edit Vendor Selection', () => {
|
test.describe('Transaction Edit Vendor Selection', () => {
|
||||||
@@ -501,14 +465,14 @@ test.describe('Transaction Edit Vendor Selection', () => {
|
|||||||
// round-trip. Before the fix this reverted to blank because a stale
|
// round-trip. Before the fix this reverted to blank because a stale
|
||||||
// `change` event submitted the previous vendor and its response won.
|
// `change` event submitted the previous vendor and its response won.
|
||||||
const label = page
|
const label = page
|
||||||
.locator('div[hx-post*="edit-vendor-changed"] span[x-text="value.label"]')
|
.locator('div[hx-vals*="vendor-changed"] span[x-text="value.label"]')
|
||||||
.first();
|
.first();
|
||||||
await expect(label).toHaveText('Test Vendor');
|
await expect(label).toHaveText('Test Vendor');
|
||||||
|
|
||||||
// The server-rendered hidden input must carry the newly selected vendor id.
|
// The server-rendered hidden input must carry the newly selected vendor id.
|
||||||
const hidden = page
|
const hidden = page
|
||||||
.locator(
|
.locator(
|
||||||
'div[hx-post*="edit-vendor-changed"] input[type="hidden"][name="step-params[transaction/vendor]"]'
|
'div[hx-vals*="vendor-changed"] input[type="hidden"][name="step-params[transaction/vendor]"]'
|
||||||
)
|
)
|
||||||
.first();
|
.first();
|
||||||
await expect(hidden).toHaveValue(vendorId.toString());
|
await expect(hidden).toHaveValue(vendorId.toString());
|
||||||
|
|||||||
8
resources/templates/components/location-select.html
Normal file
8
resources/templates/components/location-select.html
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{# Location <select> for a transaction account row. Plain-HTML attributes -- the Selmer
|
||||||
|
migration target (no Hiccup keyword/string attribute ambiguity). Rendered into the
|
||||||
|
surrounding Hiccup row via the auto-ap.ssr.selmer interop bridge. #}
|
||||||
|
<select name="{{ name }}" class="{{ classes }}">
|
||||||
|
{% for opt in options %}
|
||||||
|
<option value="{{ opt.value }}"{% if opt.selected %} selected{% endif %}>{{ opt.label }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
@@ -21,11 +21,13 @@
|
|||||||
[auto-ap.ssr-routes :as ssr-routes]
|
[auto-ap.ssr-routes :as ssr-routes]
|
||||||
[auto-ap.ssr.common-handlers :refer [add-new-entity-handler]]
|
[auto-ap.ssr.common-handlers :refer [add-new-entity-handler]]
|
||||||
[auto-ap.ssr.components :as com]
|
[auto-ap.ssr.components :as com]
|
||||||
|
[auto-ap.ssr.components.inputs :as inputs]
|
||||||
[auto-ap.ssr.grid-page-helper :as helper]
|
[auto-ap.ssr.grid-page-helper :as helper]
|
||||||
[auto-ap.ssr.transaction.common :refer [grid-page]]
|
[auto-ap.ssr.transaction.common :refer [grid-page]]
|
||||||
[auto-ap.ssr.components.multi-modal :as mm]
|
[auto-ap.ssr.components.multi-modal :as mm]
|
||||||
[auto-ap.ssr.form-cursor :as fc]
|
[auto-ap.ssr.form-cursor :as fc]
|
||||||
[auto-ap.ssr.hx :as hx]
|
[auto-ap.ssr.hx :as hx]
|
||||||
|
[auto-ap.ssr.selmer :as sel]
|
||||||
[auto-ap.ssr.svg :as svg]
|
[auto-ap.ssr.svg :as svg]
|
||||||
[auto-ap.ssr.utils
|
[auto-ap.ssr.utils
|
||||||
:refer [->db-id apply-middleware-to-all-handlers check-allowance
|
:refer [->db-id apply-middleware-to-all-handlers check-allowance
|
||||||
@@ -36,6 +38,7 @@
|
|||||||
[bidi.bidi :as bidi]
|
[bidi.bidi :as bidi]
|
||||||
[clj-time.coerce :as coerce]
|
[clj-time.coerce :as coerce]
|
||||||
[clojure.edn :as edn]
|
[clojure.edn :as edn]
|
||||||
|
[clojure.string :as str]
|
||||||
[datomic.api :as dc]
|
[datomic.api :as dc]
|
||||||
[hiccup.util :as hu]
|
[hiccup.util :as hu]
|
||||||
[iol-ion.query :refer [dollars=]]
|
[iol-ion.query :refer [dollars=]]
|
||||||
@@ -153,20 +156,29 @@
|
|||||||
(:vendor/default-account clientized))))
|
(:vendor/default-account clientized))))
|
||||||
|
|
||||||
(defn location-select*
|
(defn location-select*
|
||||||
|
"The location <select> for an account row, rendered from a Selmer template
|
||||||
|
(templates/components/location-select.html) -- the first interactive modal component
|
||||||
|
migrated off Hiccup. Same options/selection/styling as the old com/select, emitted as
|
||||||
|
plain HTML and embedded back into the Hiccup row via the interop bridge."
|
||||||
[{:keys [name account-location client-locations value]}]
|
[{:keys [name account-location client-locations value]}]
|
||||||
(let [options (into (cond account-location
|
(let [options (cond account-location
|
||||||
[[account-location account-location]]
|
[[account-location account-location]]
|
||||||
|
|
||||||
(seq client-locations)
|
(seq client-locations)
|
||||||
(into [["Shared" "Shared"]]
|
(into [["Shared" "Shared"]]
|
||||||
(for [cl client-locations]
|
(for [cl client-locations]
|
||||||
[cl cl]))
|
[cl cl]))
|
||||||
:else
|
|
||||||
[["Shared" "Shared"]]))]
|
:else
|
||||||
(com/select {:options options
|
[["Shared" "Shared"]])
|
||||||
:name name
|
selected (or value (ffirst options))
|
||||||
:value (or value (ffirst options))
|
classes (str/join " " (conj (vec inputs/default-input-classes) "w-full"))]
|
||||||
:class "w-full"})))
|
(sel/render->hiccup
|
||||||
|
"templates/components/location-select.html"
|
||||||
|
{:name name
|
||||||
|
:classes classes
|
||||||
|
:options (for [[v label] options]
|
||||||
|
{:value v :label label :selected (= v selected)})})))
|
||||||
|
|
||||||
(defn account-typeahead*
|
(defn account-typeahead*
|
||||||
[{:keys [name value client-id x-model]}]
|
[{:keys [name value client-id x-model]}]
|
||||||
@@ -183,10 +195,32 @@
|
|||||||
(:account/name (d-accounts/clientize (dc/pull (dc/db conn) d-accounts/default-read value)
|
(:account/name (d-accounts/clientize (dc/pull (dc/db conn) d-accounts/default-read value)
|
||||||
client-id)))})])
|
client-id)))})])
|
||||||
|
|
||||||
|
(defn- account-field-name
|
||||||
|
"Explicit form-field name for account row `index`, field `field` -- the same string
|
||||||
|
the form cursor produces at path [:step-params :transaction/accounts index field]
|
||||||
|
(via path->name2), without faking a deep cursor to get there."
|
||||||
|
[index field]
|
||||||
|
(str "step-params[transaction/accounts][" index "]["
|
||||||
|
(if (keyword? field)
|
||||||
|
(str (when (namespace field) (str (namespace field) "/")) (name field))
|
||||||
|
field)
|
||||||
|
"]"))
|
||||||
|
|
||||||
|
(defn- account-field-errors
|
||||||
|
"Errors for account row `index`, field `field`, read straight from the form errors
|
||||||
|
at the same path the cursor would walk -- avoids re-rooting a cursor to look them up."
|
||||||
|
[index field]
|
||||||
|
(when (bound? #'fc/*form-errors*)
|
||||||
|
(get-in fc/*form-errors* [:step-params :transaction/accounts index field])))
|
||||||
|
|
||||||
(defn simple-mode-fields*
|
(defn simple-mode-fields*
|
||||||
"Renders the simple-mode account + location row and the toggle-to-advanced link.
|
"Renders the simple-mode account + location row and the toggle-to-advanced link.
|
||||||
Must be called within a fc/start-form + fc/with-field :step-params context.
|
Must be called within a fc/start-form + fc/with-field :step-params context.
|
||||||
Caller must establish Alpine x-data with simpleAccountId in scope."
|
Caller must establish Alpine x-data with simpleAccountId in scope.
|
||||||
|
|
||||||
|
The single account row is rendered from explicit data with explicit field names
|
||||||
|
(account-field-name 0 ...) rather than faking a synthetic MapCursor rooted at
|
||||||
|
accounts[0] -- the row always lives at index 0 in simple mode."
|
||||||
[request]
|
[request]
|
||||||
(let [snapshot (-> request :multi-form-state :snapshot)
|
(let [snapshot (-> request :multi-form-state :snapshot)
|
||||||
step-params (-> request :multi-form-state :step-params)
|
step-params (-> request :multi-form-state :step-params)
|
||||||
@@ -204,53 +238,45 @@
|
|||||||
(:transaction/amount snapshot)
|
(:transaction/amount snapshot)
|
||||||
0.0))]
|
0.0))]
|
||||||
[:div
|
[:div
|
||||||
(fc/with-field :transaction/accounts
|
[:span
|
||||||
(fc/with-cursor (let [cur fc/*current*]
|
(com/hidden {:name (account-field-name 0 :db/id)
|
||||||
(if (sequential? @cur)
|
:value row-id})
|
||||||
(nth cur 0 nil)
|
[:div.flex.gap-2.mt-2
|
||||||
(auto_ap.cursor.MapCursor. {} (cursor/state cur) (conj (cursor/path cur) 0))))
|
(com/validated-field
|
||||||
[:span
|
{:label "Account"
|
||||||
(fc/with-field :db/id
|
:errors (account-field-errors 0 :transaction-account/account)}
|
||||||
(com/hidden {:name (fc/field-name)
|
[:div.w-72
|
||||||
:value row-id}))
|
(account-typeahead* {:value account-val
|
||||||
[:div.flex.gap-2.mt-2
|
:client-id client-id
|
||||||
(fc/with-field :transaction-account/account
|
:name (account-field-name 0 :transaction-account/account)
|
||||||
(com/validated-field
|
:x-model "simpleAccountId"})])
|
||||||
{:label "Account"
|
;; Selecting the account only affects the valid Location options, so the
|
||||||
:errors (fc/field-errors)}
|
;; change swaps just this cell -- nothing else needs to re-render.
|
||||||
[:div.w-72
|
[:div {:id "simple-account-location"}
|
||||||
(account-typeahead* {:value account-val
|
(com/validated-field
|
||||||
:client-id client-id
|
{:label "Location"
|
||||||
:name (fc/field-name)
|
:errors (account-field-errors 0 :transaction-account/location)
|
||||||
:x-model "simpleAccountId"})]))
|
:x-hx-val:account-id "simpleAccountId"
|
||||||
(fc/with-field :transaction-account/location
|
:hx-vals (hx/json (cond-> {:name (account-field-name 0 :transaction-account/location)}
|
||||||
;; Selecting the account only affects the valid Location options, so the
|
client-id (assoc :client-id client-id)))
|
||||||
;; change swaps just this cell -- nothing else needs to re-render.
|
:x-dispatch:changed "simpleAccountId"
|
||||||
[:div {:id "simple-account-location"}
|
:hx-trigger "changed"
|
||||||
(com/validated-field
|
:hx-post (bidi/path-for ssr-routes/only-routes ::route/edit-form-changed)
|
||||||
{:label "Location"
|
:hx-target "#simple-account-location"
|
||||||
:errors (fc/field-errors)
|
:hx-select "#simple-account-location"
|
||||||
:x-hx-val:account-id "simpleAccountId"
|
:hx-swap "outerHTML"
|
||||||
:hx-vals (hx/json (cond-> {:name (fc/field-name)}
|
:hx-include "closest form"}
|
||||||
client-id (assoc :client-id client-id)))
|
(location-select*
|
||||||
:x-dispatch:changed "simpleAccountId"
|
{:name (account-field-name 0 :transaction-account/location)
|
||||||
:hx-trigger "changed"
|
:account-location (:account/location account-id)
|
||||||
:hx-post (bidi/path-for ssr-routes/only-routes ::route/edit-form-changed)
|
:client-locations (pull-attr (dc/db conn) :client/locations client-id)
|
||||||
:hx-target "#simple-account-location"
|
:value location-val}))]
|
||||||
:hx-select "#simple-account-location"
|
(com/hidden {:name (account-field-name 0 :transaction-account/amount)
|
||||||
:hx-swap "outerHTML"
|
:value total})]]
|
||||||
: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}))]]))
|
|
||||||
[:div.mt-1
|
[:div.mt-1
|
||||||
[:a.text-sm.text-blue-600.hover:underline.cursor-pointer
|
[: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-post (bidi/path-for ssr-routes/only-routes ::route/edit-form-changed)
|
||||||
|
:hx-vals (hx/json {:op "toggle-mode"})
|
||||||
:hx-include "closest form"
|
:hx-include "closest form"
|
||||||
:hx-target "#wizard-form"
|
:hx-target "#wizard-form"
|
||||||
:hx-select "#wizard-form"
|
:hx-select "#wizard-form"
|
||||||
@@ -331,8 +357,8 @@
|
|||||||
(com/text-input (assoc amount-attrs :type "number" :step "0.01"))
|
(com/text-input (assoc amount-attrs :type "number" :step "0.01"))
|
||||||
(com/money-input amount-attrs))))))
|
(com/money-input amount-attrs))))))
|
||||||
(com/data-grid-cell {:class "align-top"}
|
(com/data-grid-cell {:class "align-top"}
|
||||||
(com/a-icon-button {:hx-post (bidi/path-for ssr-routes/only-routes ::route/edit-wizard-remove-account)
|
(com/a-icon-button {:hx-post (bidi/path-for ssr-routes/only-routes ::route/edit-form-changed)
|
||||||
:hx-vals (hx/json {:row-index (or index 0)})
|
:hx-vals (hx/json {:op "remove-account" :row-index (or index 0)})
|
||||||
:hx-target "#wizard-form"
|
:hx-target "#wizard-form"
|
||||||
:hx-select "#wizard-form"
|
:hx-select "#wizard-form"
|
||||||
:hx-swap "outerHTML"
|
:hx-swap "outerHTML"
|
||||||
@@ -374,12 +400,6 @@
|
|||||||
"text-red-300")}
|
"text-red-300")}
|
||||||
(format "$%,.2f" balance)]))
|
(format "$%,.2f" balance)]))
|
||||||
|
|
||||||
(defn account-total [request]
|
|
||||||
(html-response (account-total* request)))
|
|
||||||
|
|
||||||
(defn account-balance [request]
|
|
||||||
(html-response (account-balance* request)))
|
|
||||||
|
|
||||||
(defn ->percentage [amount total]
|
(defn ->percentage [amount total]
|
||||||
(when (and amount total (not= total 0))
|
(when (and amount total (not= total 0))
|
||||||
(* 100.0 (/ amount total))))
|
(* 100.0 (/ amount total))))
|
||||||
@@ -421,7 +441,8 @@
|
|||||||
:value amount-mode
|
:value amount-mode
|
||||||
:name "step-params[amount-mode]"
|
:name "step-params[amount-mode]"
|
||||||
:orientation :horizontal
|
:orientation :horizontal
|
||||||
:hx-post (bidi/path-for ssr-routes/only-routes ::route/toggle-amount-mode)
|
:hx-vals (hx/json {:op "toggle-amount-mode"})
|
||||||
|
:hx-post (bidi/path-for ssr-routes/only-routes ::route/edit-form-changed)
|
||||||
:hx-target "#wizard-form"
|
:hx-target "#wizard-form"
|
||||||
:hx-select "#wizard-form"
|
:hx-select "#wizard-form"
|
||||||
:hx-swap "outerHTML"
|
:hx-swap "outerHTML"
|
||||||
@@ -461,7 +482,8 @@
|
|||||||
|
|
||||||
(com/data-grid-row {:class "new-row"}
|
(com/data-grid-row {:class "new-row"}
|
||||||
(com/data-grid-cell {:colspan 4}
|
(com/data-grid-cell {:colspan 4}
|
||||||
(com/a-button {:hx-post (bidi/path-for ssr-routes/only-routes ::route/edit-wizard-new-account)
|
(com/a-button {:hx-post (bidi/path-for ssr-routes/only-routes ::route/edit-form-changed)
|
||||||
|
:hx-vals (hx/json {:op "new-account"})
|
||||||
:hx-target "#wizard-form"
|
:hx-target "#wizard-form"
|
||||||
:hx-select "#wizard-form"
|
:hx-select "#wizard-form"
|
||||||
:hx-swap "outerHTML"
|
:hx-swap "outerHTML"
|
||||||
@@ -483,7 +505,8 @@
|
|||||||
[:div#manual-coding-section
|
[:div#manual-coding-section
|
||||||
(com/hidden {:name "step-params[mode]" :value (name mode)})
|
(com/hidden {:name "step-params[mode]" :value (name mode)})
|
||||||
[:div {:hx-trigger "change"
|
[:div {:hx-trigger "change"
|
||||||
:hx-post (bidi/path-for ssr-routes/only-routes ::route/edit-vendor-changed)
|
:hx-post (bidi/path-for ssr-routes/only-routes ::route/edit-form-changed)
|
||||||
|
:hx-vals (hx/json {:op "vendor-changed"})
|
||||||
:hx-target "#wizard-form"
|
:hx-target "#wizard-form"
|
||||||
:hx-select "#wizard-form"
|
:hx-select "#wizard-form"
|
||||||
:hx-swap "outerHTML"
|
:hx-swap "outerHTML"
|
||||||
@@ -509,7 +532,8 @@
|
|||||||
(when (<= row-count 1)
|
(when (<= row-count 1)
|
||||||
[:div.mb-2
|
[:div.mb-2
|
||||||
[:a.text-sm.text-blue-600.hover:underline.cursor-pointer
|
[: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-post (bidi/path-for ssr-routes/only-routes ::route/edit-form-changed)
|
||||||
|
:hx-vals (hx/json {:op "toggle-mode"})
|
||||||
:hx-include "closest form"
|
:hx-include "closest form"
|
||||||
:hx-target "#wizard-form"
|
:hx-target "#wizard-form"
|
||||||
:hx-select "#wizard-form"
|
:hx-select "#wizard-form"
|
||||||
@@ -521,17 +545,22 @@
|
|||||||
[:div#account-grid-body
|
[:div#account-grid-body
|
||||||
(account-grid-body* request)]))])]))
|
(account-grid-body* request)]))])]))
|
||||||
|
|
||||||
(defn toggle-amount-mode [request]
|
(defn apply-toggle-amount-mode
|
||||||
|
"edit-form-changed op: convert account amounts between $ and % and record the new mode."
|
||||||
|
[request]
|
||||||
(let [snapshot (-> request :multi-form-state :snapshot)
|
(let [snapshot (-> request :multi-form-state :snapshot)
|
||||||
|
step-params (-> request :multi-form-state :step-params)
|
||||||
old-mode (or (:amount-mode snapshot) "$")
|
old-mode (or (:amount-mode snapshot) "$")
|
||||||
new-mode (or (get-in request [:multi-form-state :step-params :amount-mode]) "$")
|
new-mode (or (:amount-mode step-params) "$")
|
||||||
total (Math/abs (or (:transaction/amount snapshot) 0.0))
|
total (Math/abs (or (:transaction/amount snapshot) 0.0))
|
||||||
accounts (convert-accounts-mode (:transaction/accounts snapshot) old-mode new-mode total)
|
;; Convert the LIVE rows (step-params), not the stale snapshot, so amounts the
|
||||||
updated-request (-> request
|
;; user typed before toggling survive. step-params is already schema-decoded.
|
||||||
(assoc-in [:multi-form-state :snapshot :transaction/accounts] accounts)
|
accounts (convert-accounts-mode (or (seq (:transaction/accounts step-params))
|
||||||
(assoc-in [:multi-form-state :snapshot :amount-mode] new-mode))]
|
(:transaction/accounts snapshot))
|
||||||
(html-response
|
old-mode new-mode total)]
|
||||||
(render-full-form updated-request))))
|
(-> request
|
||||||
|
(assoc-in [:multi-form-state :snapshot :transaction/accounts] accounts)
|
||||||
|
(assoc-in [:multi-form-state :snapshot :amount-mode] new-mode))))
|
||||||
|
|
||||||
(defn transaction-details-panel [tx]
|
(defn transaction-details-panel [tx]
|
||||||
[:div.p-4.space-y-4
|
[:div.p-4.space-y-4
|
||||||
@@ -756,7 +785,9 @@
|
|||||||
[:div.mt-4 {:hx-post (bidi/path-for ssr-routes/only-routes ::route/unlink-payment)
|
[:div.mt-4 {:hx-post (bidi/path-for ssr-routes/only-routes ::route/unlink-payment)
|
||||||
:hx-trigger "unlinkPayment"
|
:hx-trigger "unlinkPayment"
|
||||||
:hx-target "#payment-matches"
|
:hx-target "#payment-matches"
|
||||||
:hx-include "this"
|
;; include the whole form so the db/id hidden rides along (the plain
|
||||||
|
;; form derives state from db/id instead of a serialized snapshot)
|
||||||
|
:hx-include "closest form"
|
||||||
:hx-swap "outerHTML"
|
:hx-swap "outerHTML"
|
||||||
:hx-confirm "Are you sure you want to unlink this payment?"}
|
:hx-confirm "Are you sure you want to unlink this payment?"}
|
||||||
|
|
||||||
@@ -1310,15 +1341,20 @@
|
|||||||
(if current-step
|
(if current-step
|
||||||
(mm/get-step this current-step)
|
(mm/get-step this current-step)
|
||||||
(mm/get-step this :links)))
|
(mm/get-step this :links)))
|
||||||
(render-wizard [this {:keys [multi-form-state] :as request}]
|
(render-wizard [this {:keys [multi-form-state form-errors] :as request}]
|
||||||
(mm/default-render-wizard
|
;; Plain-form render: no snapshot / edit-path / current-step hidden fields. The entity
|
||||||
this request
|
;; id rides in the form; all other state is the live form (step-params) re-derived
|
||||||
:form-params
|
;; against the entity on each request (see wrap-derive-state).
|
||||||
(-> mm/default-form-props
|
(let [step (mm/get-current-step this)]
|
||||||
(assoc :hx-post
|
[:form#wizard-form (-> mm/default-form-props
|
||||||
(str (bidi/path-for ssr-routes/only-routes ::route/edit-submit))
|
(assoc :hx-post (str (bidi/path-for ssr-routes/only-routes ::route/edit-submit))
|
||||||
:hx-ext "response-targets"))
|
:hx-ext "response-targets"))
|
||||||
:render-timeline? false))
|
(fc/start-form multi-form-state (when form-errors {:step-params form-errors})
|
||||||
|
(list
|
||||||
|
(com/hidden {:name "db/id" :value (-> multi-form-state :snapshot :db/id)})
|
||||||
|
(fc/with-field :step-params
|
||||||
|
(com/modal {:id "wizardmodal"}
|
||||||
|
(mm/render-step step request)))))]))
|
||||||
(steps [_]
|
(steps [_]
|
||||||
[:links])
|
[:links])
|
||||||
(get-step [this step-key]
|
(get-step [this step-key]
|
||||||
@@ -1333,46 +1369,60 @@
|
|||||||
|
|
||||||
(def edit-wizard (->EditWizard nil nil))
|
(def edit-wizard (->EditWizard nil nil))
|
||||||
|
|
||||||
(defn initial-edit-wizard-state [request]
|
(defn entity->base
|
||||||
(let [tx-id (-> request :route-params :db/id)
|
"The persisted transaction, shaped like the form's base state (what the old snapshot was
|
||||||
entity (dc/pull (dc/db conn)
|
seeded with). The plain form derives its state fresh from this + the live posted form,
|
||||||
'[:db/id
|
instead of round-tripping an EDN snapshot hidden field."
|
||||||
:transaction/vendor
|
[tx-id]
|
||||||
:transaction/client
|
(-> (dc/pull (dc/db conn)
|
||||||
:transaction/description-original
|
'[:db/id
|
||||||
:transaction/status
|
:transaction/vendor
|
||||||
:transaction/type
|
:transaction/client
|
||||||
:transaction/memo
|
:transaction/description-original
|
||||||
{[:transaction/approval-status :xform iol-ion.query/ident] [:db/ident]}
|
:transaction/status
|
||||||
:transaction/amount
|
:transaction/type
|
||||||
:transaction/accounts]
|
:transaction/memo
|
||||||
tx-id)
|
{[:transaction/approval-status :xform iol-ion.query/ident] [:db/ident]}
|
||||||
entity (-> entity
|
:transaction/amount
|
||||||
(update :transaction/vendor :db/id)
|
:transaction/accounts]
|
||||||
(update :transaction/client :db/id))]
|
tx-id)
|
||||||
(mm/->MultiStepFormState entity
|
(update :transaction/vendor :db/id)
|
||||||
[]
|
(update :transaction/client :db/id)))
|
||||||
entity)))
|
|
||||||
|
|
||||||
(defn- render-account-grid-body [request]
|
(defn wrap-derive-state
|
||||||
(fc/start-form (:multi-form-state request) nil
|
"Plain-form replacement for the EDN-snapshot round-trip. Builds :multi-form-state from
|
||||||
(fc/with-field :step-params
|
the entity (loaded by the db/id hidden field, or the route on initial open) overlaid
|
||||||
(fc/with-field :transaction/accounts
|
with the live posted step-params -- no serialized snapshot. Runs after wrap-decode /
|
||||||
(account-grid-body* request)))))
|
wrap-wizard, which provide nested + schema-typed step-params. The 30-odd `:snapshot`
|
||||||
|
reads keep working: snapshot is now `entity ∪ step-params`, derived per request."
|
||||||
|
[handler]
|
||||||
|
(fn [request]
|
||||||
|
(let [tx-id (->db-id (or (some-> request :form-params (get "db/id"))
|
||||||
|
(-> request :route-params :db/id)))
|
||||||
|
base (entity->base tx-id)
|
||||||
|
posted (-> request :multi-form-state :step-params)
|
||||||
|
;; Fields the form does NOT edit always come from the entity. Everything else is
|
||||||
|
;; the live posted form, which is authoritative even when ABSENT -- an absent
|
||||||
|
;; field means the user cleared it (e.g. removed all account rows), not "fall
|
||||||
|
;; back to the entity's persisted value". Merging base's editable fields back in
|
||||||
|
;; would resurrect persisted accounts after a remove-all.
|
||||||
|
entity-only (select-keys base [:db/id :transaction/client :transaction/amount
|
||||||
|
:transaction/description-original
|
||||||
|
:transaction/status :transaction/type])
|
||||||
|
;; On initial open there is no posted form -> render the entity. On every post
|
||||||
|
;; the form is authoritative for the editable fields.
|
||||||
|
step-params (if (seq posted) posted base)
|
||||||
|
snapshot (if (seq posted) (merge entity-only posted) base)]
|
||||||
|
(handler (-> request
|
||||||
|
(assoc :entity (d-transactions/get-by-id tx-id))
|
||||||
|
(assoc :multi-form-state (mm/->MultiStepFormState snapshot [] step-params)))))))
|
||||||
|
|
||||||
(defn render-full-form
|
(defn render-full-form
|
||||||
"Helper to render the complete transaction edit form for whole-form re-rendering."
|
"Helper to render the complete transaction edit form for whole-form re-rendering."
|
||||||
[request]
|
[request]
|
||||||
(mm/render-wizard edit-wizard request))
|
(mm/render-wizard edit-wizard request))
|
||||||
|
|
||||||
(defn edit-form-changed-handler
|
(defn apply-vendor-changed [request]
|
||||||
"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)
|
(let [multi-form-state (:multi-form-state request)
|
||||||
snapshot (:snapshot multi-form-state)
|
snapshot (:snapshot multi-form-state)
|
||||||
step-params (:step-params multi-form-state)
|
step-params (:step-params multi-form-state)
|
||||||
@@ -1414,10 +1464,9 @@
|
|||||||
(assoc-in [:multi-form-state :step-params :transaction/accounts] [new-account])))
|
(assoc-in [:multi-form-state :step-params :transaction/accounts] [new-account])))
|
||||||
request)
|
request)
|
||||||
(assoc-in [:multi-form-state :step-params :transaction/vendor] vendor-id))]
|
(assoc-in [:multi-form-state :step-params :transaction/vendor] vendor-id))]
|
||||||
(html-response
|
render-request))
|
||||||
(render-full-form render-request))))
|
|
||||||
|
|
||||||
(defn edit-wizard-toggle-mode-handler [request]
|
(defn apply-toggle-mode [request]
|
||||||
(let [step-params (-> request :multi-form-state :step-params)
|
(let [step-params (-> request :multi-form-state :step-params)
|
||||||
snapshot (-> request :multi-form-state :snapshot)
|
snapshot (-> request :multi-form-state :snapshot)
|
||||||
current-mode (keyword (or (:mode step-params) "simple"))
|
current-mode (keyword (or (:mode step-params) "simple"))
|
||||||
@@ -1446,34 +1495,39 @@
|
|||||||
(if first-row [first-row] []))
|
(if first-row [first-row] []))
|
||||||
(assoc-in [:multi-form-state :step-params :mode]
|
(assoc-in [:multi-form-state :step-params :mode]
|
||||||
(name target-mode)))))]
|
(name target-mode)))))]
|
||||||
(html-response
|
render-request))
|
||||||
(render-full-form render-request))))
|
|
||||||
|
|
||||||
(defn edit-wizard-new-account-handler
|
(defn apply-new-account
|
||||||
"Adds a new account row and re-renders the whole form."
|
"edit-form-changed op: append a fresh account row."
|
||||||
[request]
|
[request]
|
||||||
(let [snapshot (-> request :multi-form-state :snapshot)
|
(let [snapshot (-> request :multi-form-state :snapshot)
|
||||||
amount-mode (or (:amount-mode snapshot) "$")
|
step-params (-> request :multi-form-state :step-params)
|
||||||
|
amount-mode (or (:amount-mode step-params) (:amount-mode snapshot) "$")
|
||||||
total (Math/abs (or (:transaction/amount snapshot) 0.0))
|
total (Math/abs (or (:transaction/amount snapshot) 0.0))
|
||||||
new-account {:db/id (str (java.util.UUID/randomUUID))
|
new-account {:db/id (str (java.util.UUID/randomUUID))
|
||||||
:new? true
|
:new? true
|
||||||
:transaction-account/location "Shared"
|
:transaction-account/location "Shared"
|
||||||
:transaction-account/amount (if (= amount-mode "%") 100.0 total)}
|
:transaction-account/amount (if (= amount-mode "%") 100.0 total)}
|
||||||
accounts (vec (or (:transaction/accounts snapshot) []))
|
;; Append to the LIVE rows (step-params) so values typed before clicking
|
||||||
|
;; "New account" are not reverted to the stale snapshot.
|
||||||
|
accounts (vec (or (seq (:transaction/accounts step-params))
|
||||||
|
(:transaction/accounts snapshot) []))
|
||||||
updated-accounts (conj accounts new-account)
|
updated-accounts (conj accounts new-account)
|
||||||
updated-request (-> request
|
updated-request (-> request
|
||||||
(assoc-in [:multi-form-state :snapshot :transaction/accounts] updated-accounts)
|
(assoc-in [:multi-form-state :snapshot :transaction/accounts] updated-accounts)
|
||||||
(assoc-in [:multi-form-state :step-params :transaction/accounts] updated-accounts))]
|
(assoc-in [:multi-form-state :step-params :transaction/accounts] updated-accounts))]
|
||||||
(html-response
|
updated-request))
|
||||||
(render-full-form updated-request))))
|
|
||||||
|
|
||||||
(defn edit-wizard-remove-account-handler
|
(defn apply-remove-account
|
||||||
"Removes an account row and re-renders the whole form.
|
"edit-form-changed op: remove the account row at form-param row-index."
|
||||||
Expects a row-index in the form params."
|
|
||||||
[request]
|
[request]
|
||||||
(let [row-index (some-> request :form-params (get "row-index") Integer/parseInt)
|
(let [row-index (some-> request :form-params (get "row-index") Integer/parseInt)
|
||||||
snapshot (-> request :multi-form-state :snapshot)
|
snapshot (-> request :multi-form-state :snapshot)
|
||||||
accounts (vec (or (:transaction/accounts snapshot) []))
|
step-params (-> request :multi-form-state :step-params)
|
||||||
|
;; Remove from the LIVE rows (step-params) so the surviving rows keep the values
|
||||||
|
;; the user typed, rather than reverting to the stale snapshot.
|
||||||
|
accounts (vec (or (seq (:transaction/accounts step-params))
|
||||||
|
(:transaction/accounts snapshot) []))
|
||||||
updated-accounts (if (and row-index (< row-index (count accounts)))
|
updated-accounts (if (and row-index (< row-index (count accounts)))
|
||||||
(vec (concat (subvec accounts 0 row-index)
|
(vec (concat (subvec accounts 0 row-index)
|
||||||
(subvec accounts (inc row-index))))
|
(subvec accounts (inc row-index))))
|
||||||
@@ -1481,31 +1535,43 @@
|
|||||||
updated-request (-> request
|
updated-request (-> request
|
||||||
(assoc-in [:multi-form-state :snapshot :transaction/accounts] updated-accounts)
|
(assoc-in [:multi-form-state :snapshot :transaction/accounts] updated-accounts)
|
||||||
(assoc-in [:multi-form-state :step-params :transaction/accounts] updated-accounts))]
|
(assoc-in [:multi-form-state :step-params :transaction/accounts] updated-accounts))]
|
||||||
|
updated-request))
|
||||||
|
|
||||||
|
(defn edit-form-changed-handler
|
||||||
|
"Single whole-form re-render endpoint. Dispatches on the `op` form-param to apply the
|
||||||
|
relevant state mutation (vendor change, mode toggle, add/remove row, $/% toggle), then
|
||||||
|
re-renders the whole form. A missing/unknown op (a plain dependent-field change) just
|
||||||
|
re-renders. Replaces the per-operation edit-wizard-* / toggle-amount-mode routes."
|
||||||
|
[request]
|
||||||
|
(let [op (get-in request [:form-params "op"])
|
||||||
|
request' (case op
|
||||||
|
"vendor-changed" (apply-vendor-changed request)
|
||||||
|
"toggle-mode" (apply-toggle-mode request)
|
||||||
|
"new-account" (apply-new-account request)
|
||||||
|
"remove-account" (apply-remove-account request)
|
||||||
|
"toggle-amount-mode" (apply-toggle-amount-mode request)
|
||||||
|
request)]
|
||||||
(html-response
|
(html-response
|
||||||
(render-full-form updated-request))))
|
(render-full-form request'))))
|
||||||
|
|
||||||
(def key->handler
|
(def key->handler
|
||||||
(apply-middleware-to-all-handlers
|
(apply-middleware-to-all-handlers
|
||||||
{::route/edit-wizard (-> mm/open-wizard-handler
|
{::route/edit-wizard (-> mm/open-wizard-handler
|
||||||
(wrap-must {:activity :edit :subject :transaction} (fn get-client [request] (-> request :entity :transaction/client)))
|
(wrap-must {:activity :edit :subject :transaction} (fn get-client [request] (-> request :entity :transaction/client)))
|
||||||
|
(wrap-derive-state)
|
||||||
(mm/wrap-wizard edit-wizard)
|
(mm/wrap-wizard edit-wizard)
|
||||||
(mm/wrap-init-multi-form-state initial-edit-wizard-state)
|
(mm/wrap-decode-multi-form-state)
|
||||||
(wrap-entity [:route-params :db/id] d-transactions/default-read)
|
|
||||||
(wrap-schema-enforce :route-schema [:map [:db/id entity-id]]))
|
(wrap-schema-enforce :route-schema [:map [:db/id entity-id]]))
|
||||||
::route/edit-wizard-navigate (-> mm/next-handler
|
::route/edit-wizard-navigate (-> mm/next-handler
|
||||||
(wrap-must {:activity :edit :subject :transaction} (fn get-client [request] (-> request :entity :transaction/client)))
|
(wrap-must {:activity :edit :subject :transaction} (fn get-client [request] (-> request :entity :transaction/client)))
|
||||||
(wrap-entity [:multi-form-state :snapshot :db/id] d-transactions/default-read)
|
(wrap-derive-state)
|
||||||
(mm/wrap-wizard edit-wizard)
|
(mm/wrap-wizard edit-wizard)
|
||||||
(mm/wrap-decode-multi-form-state))
|
(mm/wrap-decode-multi-form-state))
|
||||||
::route/edit-submit (-> mm/submit-handler
|
::route/edit-submit (-> mm/submit-handler
|
||||||
(wrap-must {:activity :edit :subject :transaction} (fn get-client [request] (-> request :entity :transaction/client)))
|
(wrap-must {:activity :edit :subject :transaction} (fn get-client [request] (-> request :entity :transaction/client)))
|
||||||
(wrap-entity [:multi-form-state :snapshot :db/id] d-transactions/default-read)
|
(wrap-derive-state)
|
||||||
(mm/wrap-wizard edit-wizard)
|
(mm/wrap-wizard edit-wizard)
|
||||||
(mm/wrap-decode-multi-form-state))
|
(mm/wrap-decode-multi-form-state))
|
||||||
::route/edit-vendor-changed (-> edit-vendor-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/location-select (-> location-select
|
::route/location-select (-> location-select
|
||||||
(wrap-schema-enforce :query-schema [:map
|
(wrap-schema-enforce :query-schema [:map
|
||||||
[:name :string]
|
[:name :string]
|
||||||
@@ -1513,42 +1579,15 @@
|
|||||||
[:maybe entity-id]]
|
[:maybe entity-id]]
|
||||||
[:account-id {:optional true}
|
[:account-id {:optional true}
|
||||||
[:maybe entity-id]]]))
|
[:maybe entity-id]]]))
|
||||||
::route/account-total (-> account-total
|
|
||||||
(mm/wrap-wizard edit-wizard)
|
|
||||||
(wrap-entity [:multi-form-state :snapshot :db/id] d-transactions/default-read)
|
|
||||||
(mm/wrap-decode-multi-form-state))
|
|
||||||
::route/account-balance (-> account-balance
|
|
||||||
(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
|
::route/edit-form-changed (-> edit-form-changed-handler
|
||||||
|
(wrap-derive-state)
|
||||||
(mm/wrap-wizard edit-wizard)
|
(mm/wrap-wizard edit-wizard)
|
||||||
(wrap-entity [:multi-form-state :snapshot :db/id] d-transactions/default-read)
|
|
||||||
(mm/wrap-decode-multi-form-state))
|
(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)
|
|
||||||
(mm/wrap-decode-multi-form-state))
|
|
||||||
::route/edit-wizard-toggle-mode (-> edit-wizard-toggle-mode-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-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
|
::route/unlink-payment (-> unlink-payment
|
||||||
(wrap-must {:activity :edit :subject :transaction} (fn get-client [request] (-> request :entity :transaction/client)))
|
(wrap-must {:activity :edit :subject :transaction} (fn get-client [request] (-> request :entity :transaction/client)))
|
||||||
(wrap-entity [:multi-form-state :snapshot :db/id] d-transactions/default-read)
|
(wrap-derive-state)
|
||||||
(mm/wrap-wizard edit-wizard)
|
(mm/wrap-wizard edit-wizard)
|
||||||
(mm/wrap-decode-multi-form-state)
|
(mm/wrap-decode-multi-form-state))}
|
||||||
#_(wrap-schema-enforce :form-schema
|
|
||||||
save-schema))}
|
|
||||||
(fn [h]
|
(fn [h]
|
||||||
(-> h
|
(-> h
|
||||||
(wrap-client-redirect-unauthenticated)))))
|
(wrap-client-redirect-unauthenticated)))))
|
||||||
|
|||||||
@@ -28,15 +28,8 @@
|
|||||||
|
|
||||||
["/" [#"\d+" :db/id]] {"/edit" {:get ::edit-wizard}}
|
["/" [#"\d+" :db/id]] {"/edit" {:get ::edit-wizard}}
|
||||||
"/edit-submit" ::edit-submit
|
"/edit-submit" ::edit-submit
|
||||||
"/edit-vendor-changed" ::edit-vendor-changed
|
|
||||||
"/location-select" ::location-select
|
"/location-select" ::location-select
|
||||||
"/account-total" ::account-total
|
|
||||||
"/account-balance" ::account-balance
|
|
||||||
"/toggle-amount-mode" ::toggle-amount-mode
|
|
||||||
"/edit-form-changed" ::edit-form-changed
|
"/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-payment" ::link-payment
|
||||||
"/match-autopay-invoices" ::link-autopay-invoices
|
"/match-autopay-invoices" ::link-autopay-invoices
|
||||||
"/match-unpaid-invoices" ::link-unpaid-invoices
|
"/match-unpaid-invoices" ::link-unpaid-invoices
|
||||||
|
|||||||
Reference in New Issue
Block a user