refactor(ssr): full Selmer migration of Transaction Edit; remove the wizard
Migrate every part of the Transaction Edit modal's HTML to Selmer templates
(zero Hiccup in the render path) and delete the mm multi-modal "wizard"
abstraction entirely -- there was only ever one step.
- New auto-ap.ssr.components.selmer (sc) + ~22 shared component partials under
resources/templates/components/ (typeahead, button-group, radio-card,
data-grid, validated-field, modal, buttons, inputs, SVGs). Each wrapper renders
its own partial; dynamic HTMX/Alpine attrs bridge via attrs->str -> {{attrs|safe}}.
- 15 modal templates under resources/templates/transaction-edit/.
- Delete EditWizard/LinksStep records + all mm/* usage. Plain handlers: flat
wrap-decode-edit (fields renamed off step-params[...], stray keys stripped),
flat wrap-derive-state, *errors*-based field errors, generic wrap-form-4xx-2.
- Drop the edit-wizard-navigate route (routes ~12 -> 5).
- Fix: stray `method` (tab button-group hidden) leaked into the upsert -> 500;
strip decoded map to schema keys.
- e2e selectors updated (#wizard-form->#edit-form, #wizardmodal->#editmodal,
step-params[...] field names). Parity: swap 6/6, edit 8/8, suite 38/1
(1 pre-existing unrelated nav test).
- ssr-form-migration skill updated with the learnings (composition mechanics,
sc/* library, drop-the-wizard recipe, scorecard row, 3 new gotchas).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -149,3 +149,34 @@ divergence). Scorecard heuristic 1: faked roots → 0.
|
|||||||
```
|
```
|
||||||
TODO Phase 2: the simple/advanced toggle becomes a `?mode=` re-render (plain form), not a
|
TODO Phase 2: the simple/advanced toggle becomes a `?mode=` re-render (plain form), not a
|
||||||
dedicated route.
|
dedicated route.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The Selmer component library (`auto-ap.ssr.components.selmer` / `sc`) — Phase 2-final
|
||||||
|
|
||||||
|
Every shared component the modal renders through is now a thin Clojure wrapper over a
|
||||||
|
partial under `resources/templates/components/`. **Reuse these before reaching for the
|
||||||
|
Hiccup `com/*` versions in a migrated modal.** Each wrapper builds a context (reusing the
|
||||||
|
real class helpers so output matches modulo Tailwind order) and renders its own partial via
|
||||||
|
the interop bridge; dynamic HTMX/Alpine attrs go through `sc/attrs->str` →
|
||||||
|
`{{ attrs|safe }}`. See `selmer-conventions.md` for the mechanics.
|
||||||
|
|
||||||
|
| Wrapper | Partial | Notes |
|
||||||
|
|---------|---------|-------|
|
||||||
|
| `sc/hidden` / `sc/text-input` / `sc/money-input` | `hidden`/`text-input`/`money-input`.html | leaf inputs; class via `inputs/default-input-classes` + `use-size` |
|
||||||
|
| `sc/validated-field` | `validated-field.html` | label + body + always-present error `<p>`; pass-through attrs land on the wrapping div (the per-row location cell hangs its swap wiring here) |
|
||||||
|
| `sc/button` / `sc/a-button` / `sc/a-icon-button` | `button`/`a-button`/`a-icon-button`.html | spinner via `{% include "spinner.html" %}`; class via `btn/bg-colors` |
|
||||||
|
| `sc/badge` / `sc/link` | `badge`/`link`.html | |
|
||||||
|
| `sc/button-group` / `sc/button-group-button` | `button-group(+button).html` | the group does **not** mutate children's classes (the Hiccup `group-` added rounded-l/r) — add rounding in the caller/template (tabs do) |
|
||||||
|
| `sc/radio-card` | `radio-card.html` | reproduces the `select-keys [:hx-post :hx-target :hx-swap :hx-include :hx-trigger]` filter (drops `:hx-vals`/`:hx-select`) **and** the dangling-`[:h3]` quirk: only the `<ul>` renders |
|
||||||
|
| `sc/data-grid` (+ `-header`/`-row`/`-cell`) | `data-grid*.html` | table shell + optional `footer-tbody` (the swappable totals tbody) |
|
||||||
|
| `sc/typeahead` | `typeahead.html` | full Alpine + tippy; resolves `{value,label}` server-side via `content-fn`; every `tippy?.` null-guard preserved; hidden posting `<input>` with `:value="value.value"` + the `x-init` watcher |
|
||||||
|
| `sc/modal` | `modal.html` | the `@click.outside="open=false"` wrapper |
|
||||||
|
| SVGs | `spinner`/`svg-x`/`svg-external-link`/`svg-drop-down`.html | static, `{% include %}`d so the markup isn't duplicated |
|
||||||
|
|
||||||
|
Modal-specific structure lives under `resources/templates/transaction-edit/`
|
||||||
|
(`edit-form`, `edit-modal`, `links-body`, `manual-coding`, `simple-mode`, `account-totals`,
|
||||||
|
`details-panel`, the four match panels, `transitioner`). The render fns in `edit.clj`
|
||||||
|
gather data, call `sc/*`, and interpolate the fragments into these layout templates as
|
||||||
|
`{{ frag|safe }}`. **Verify each wrapper by class-set equality + e2e, never byte-parity**
|
||||||
|
(`hh/add-class` is set-based, so class order differs from the Hiccup output).
|
||||||
|
|||||||
@@ -13,9 +13,13 @@ implement the multi-step protocol (`mm/ModalWizardStep` + friends), serialize an
|
|||||||
snapshot into hidden fields, and register 10–20 stacked-middleware routes — all for one
|
snapshot into hidden fields, and register 10–20 stacked-middleware routes — all for one
|
||||||
step. That is pure overhead to delete.
|
step. That is pure overhead to delete.
|
||||||
|
|
||||||
|
> **Done — Transaction Edit is now a plain form.** `LinksStep`/`EditWizard` and all `mm/*`
|
||||||
|
> usage were deleted from `transaction/edit.clj`; the worked example below is realized, not
|
||||||
|
> aspirational. See "Single-step → plain form (realized)".
|
||||||
|
|
||||||
## The machinery being replaced
|
## The machinery being replaced
|
||||||
|
|
||||||
`transaction/edit.clj` today still carries the old shape, useful as the "before":
|
The old shape (kept here as the "before"):
|
||||||
|
|
||||||
```clojure
|
```clojure
|
||||||
(defrecord LinksStep [linear-wizard]
|
(defrecord LinksStep [linear-wizard]
|
||||||
@@ -49,6 +53,33 @@ A `?mode=` toggle is just the `GET` re-rendering with a different query param
|
|||||||
plain form. An add-row interaction is one extra `POST` that appends a fresh row and
|
plain form. An add-row interaction is one extra `POST` that appends a fresh row and
|
||||||
re-renders (the `+1` route).
|
re-renders (the `+1` route).
|
||||||
|
|
||||||
|
### Single-step → plain form (realized: Transaction Edit)
|
||||||
|
|
||||||
|
What replacing the wizard actually looked like, end to end:
|
||||||
|
|
||||||
|
1. **Delete the records + middleware.** `EditWizard`/`LinksStep`, `mm/open-wizard-handler`,
|
||||||
|
`mm/next-handler`, `mm/submit-handler`, `mm/wrap-wizard`, `mm/wrap-decode-multi-form-state`,
|
||||||
|
and the `edit-wizard-navigate` route all go. `render-step` becomes a plain `render-form`.
|
||||||
|
2. **Rename the fields off `step-params[...]`.** Field names are now the schema path
|
||||||
|
directly (`(path->name2 :transaction/accounts 0 :transaction-account/account)` →
|
||||||
|
`transaction/accounts[0][transaction-account/account]`). They decode straight into the
|
||||||
|
form schema via the unchanged `wrap-nested-form-params` + `mc/decode` — no two-key
|
||||||
|
snapshot/step-params decode. **Strip stray keys after decode** (`select-keys` to the
|
||||||
|
schema's keys) or a non-schema input like the tab group's `method` hidden 500s the save
|
||||||
|
(see `gotchas.md`).
|
||||||
|
3. **Flat state.** `wrap-derive-state` builds a plain `{:snapshot :edit-path :step-params}`
|
||||||
|
map (not the `MultiStepFormState` record): `entity-only` fields from the entity, editable
|
||||||
|
fields from the live posted form (absent = cleared). The ~34 `:snapshot` reads keep
|
||||||
|
working.
|
||||||
|
4. **Validation/error flow without `wrap-ensure-step`.** Reuse the generic
|
||||||
|
`wrap-form-4xx-2` directly: `(-> submit-edit (wrap-form-4xx-2 render-form-response) …)`.
|
||||||
|
`submit-edit` runs `assert-schema` then dispatches the save; on a throw, `wrap-form-4xx-2`
|
||||||
|
re-renders the whole form with `:form-errors` keyed by schema paths. A `*errors*` dynamic
|
||||||
|
var (bound by `render-form`) replaces the form-cursor's `*form-errors*` for field lookups.
|
||||||
|
5. **Routes shrink to** `edit-wizard` (GET open), `edit-submit` (POST), `edit-form-changed`
|
||||||
|
(POST whole-form re-render for dependent changes), `location-select` (GET),
|
||||||
|
`unlink-payment` (POST).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Genuinely multi-step → data-driven engine with session-stored step state
|
## Genuinely multi-step → data-driven engine with session-stored step state
|
||||||
|
|||||||
@@ -148,6 +148,52 @@ hides every test after the first failure, so fixing one unmasks the next):
|
|||||||
Actions")')`) hang once the modal is single-page. Drop the navigation; the action tabs
|
Actions")')`) hang once the modal is single-page. Drop the navigation; the action tabs
|
||||||
are present immediately.
|
are present immediately.
|
||||||
|
|
||||||
|
## Flat decode leaks stray form fields into the saved entity (the `method` 500)
|
||||||
|
|
||||||
|
Dropping the wizard's `step-params[...]` field-name prefix and decoding posted params
|
||||||
|
**straight into the form schema** means the decode now captures **every** posted field, not
|
||||||
|
just the namespaced ones. A single stray field breaks the save:
|
||||||
|
|
||||||
|
- The tab switcher is `(com/button-group {:name "method"} …)`, which emits
|
||||||
|
`<input type="hidden" name="method">`. Under the wizard, `method` lived *outside*
|
||||||
|
`step-params[...]` so it never entered the decoded map. After the rename it decodes to
|
||||||
|
`:method ""` (malli `:map` is open and passes unknown keys), rides into `snapshot` →
|
||||||
|
`tx-data`, and `:upsert-transaction` rejects it → **HTTP 500 on save**.
|
||||||
|
- Symptom: the save POST fires (confirm with a `println` in the submit handler) but the
|
||||||
|
modal never closes, because the 500 trips `htmx:response-error`. The server error may go
|
||||||
|
to mulog, not stdout — an empty stdout log does **not** mean "no error." Reproduce the
|
||||||
|
exact POST with `curl` (add/remove one field) to isolate the offender fast.
|
||||||
|
|
||||||
|
**Fix:** strip the decoded map to the schema's known top-level keys before threading on
|
||||||
|
(`select-keys decoded edit-form-keys`); keep that allowlist next to the schema. Nested
|
||||||
|
account sub-maps decode fine — only the top level needs the guard.
|
||||||
|
|
||||||
|
## REPL reload does not refresh a running jetty's routes — restart the JVM
|
||||||
|
|
||||||
|
`handler/match->handler-lookup` is a top-level `def` capturing `(merge ssr/key->handler …)`
|
||||||
|
at load, through a chain of module-level `def`s (`edit` → `ssr.transaction` → `ssr.core` →
|
||||||
|
`handler`). Reloading the leaf `edit.clj` updates it but **not** the captured merges, and a
|
||||||
|
jetty started `(run-jetty app …)` holds a static `app` that doesn't re-deref the lookup per
|
||||||
|
request. Net: after a handler/route/record change, an already-running dev server keeps
|
||||||
|
serving the **old** code — `curl` shows the pre-change response (e.g. the old wizard
|
||||||
|
transitioner) while your REPL renders the new one. **Restart in a fresh JVM** for
|
||||||
|
route/record/middleware changes. For e2e, the Playwright test server
|
||||||
|
(`lein run -m auto-ap.test-server`) is a fresh JVM compiling from disk — but kill any stale
|
||||||
|
`:3333` first (`reuseExistingServer` reuses it), and kill **by port**
|
||||||
|
(`ss -tlnp | grep :3333`), never `pkill -f test-server` (it matches its own command line).
|
||||||
|
|
||||||
|
## Full-suite e2e flakes are shared-seed interference
|
||||||
|
|
||||||
|
The test server seeds once at boot; edit tests **save** (mutate) those seed transactions.
|
||||||
|
Run in parallel, workers race the same rows and earlier saves pollute later reads → phantom
|
||||||
|
failures that pass in isolation. Clean signal: restart (re-seed) + **`--workers=1`**.
|
||||||
|
Baseline is **38 pass / 1 fail**, the 1 being the pre-existing
|
||||||
|
`transaction-navigation.spec.ts:92` date-range test (unrelated to the edit modal).
|
||||||
|
|
||||||
## Scorecard exceptions (ratchet violations with a reason)
|
## Scorecard exceptions (ratchet violations with a reason)
|
||||||
|
|
||||||
_None yet._ Append here if a migration must let a metric regress for a documented reason.
|
**Heuristic 9 (Hiccup in render path) — partial exception (Phase 2-final).** The post-save
|
||||||
|
`com/success-modal` confirmation dialogs in `save-handler` keep ~6 `[:p …]` Hiccup lines.
|
||||||
|
They are terminal responses (shown after the form closes), reuse a shared dialog component,
|
||||||
|
and sit outside the form's interactive render path. Migrating them means porting the shared
|
||||||
|
`success-modal` to Selmer — a Phase 11 cross-cutting task, not a single-modal one.
|
||||||
|
|||||||
@@ -40,6 +40,14 @@ Each migration appends one row (after-numbers), referencing the before in the di
|
|||||||
|-------|-------|-----|--------|-----------------|-------------|-----------------|-----|-----------|-------------------------|
|
|-------|-------|-----|--------|-----------------|-------------|-----------------|-----|-----------|-------------------------|
|
||||||
| 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** |
|
| 2 | Transaction Edit `transaction/edit.clj` | 1593 | **~5** | **0** | **0** | **0 round-trip** | 0 | 8 (shared) | location-select / **1 Selmer** |
|
||||||
|
| 2-final | Transaction Edit (full Selmer + wizard removed) | 1548 | **5** | 0 | 0 | 0 | 0 | **0** | full `sc/*` lib / **~30 partials** |
|
||||||
|
|
||||||
|
### New heuristics introduced at 2-final (full Selmer)
|
||||||
|
|
||||||
|
| # | Heuristic | Measure | Target |
|
||||||
|
|---|-----------|---------|--------|
|
||||||
|
| 9 | Hiccup HTML tags in the render path | `grep -cE '\[:(div\|span\|p\|a\|button\|input\|h[1-6]\|ul\|li\|label\|select\|option\|t(able\|head\|body\|r\|d\|h)\|form\|svg\|template)'` over the modal's render fns | → 0 (success-modal confirmation dialogs may keep the shared Hiccup component) |
|
||||||
|
| 10 | mm wizard coupling | `grep -c 'mm/' the modal file` + `grep -c 'defrecord.*Wizard\|ModalWizardStep'` | → 0 for a single-step modal |
|
||||||
|
|
||||||
> **Phase 2 progress.** Achieved with parity held (swap spec **6/6**, transaction-edit
|
> **Phase 2 progress.** Achieved with parity held (swap spec **6/6**, transaction-edit
|
||||||
> spec **8/8**, full suite **38 pass / 1 unrelated fail / 0 skip**, up from 30/2/7):
|
> spec **8/8**, full suite **38 pass / 1 unrelated fail / 0 skip**, up from 30/2/7):
|
||||||
@@ -59,3 +67,18 @@ Each migration appends one row (after-numbers), referencing the before in the di
|
|||||||
> Selmer sweep of the shared `com/typeahead`/`com/select`/`com/button-group-button` (those
|
> 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 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).
|
> shared components move to Selmer — not a single-modal task, per Open decision 2).
|
||||||
|
|
||||||
|
> **Phase 2-final — full Selmer + wizard removed.** Every component the modal renders
|
||||||
|
> through was ported to a Selmer partial under `resources/templates/components/` with a
|
||||||
|
> thin Clojure wrapper in `auto-ap.ssr.components.selmer` (`sc/*`); the modal's own
|
||||||
|
> structure lives under `resources/templates/transaction-edit/`. The `mm` wizard
|
||||||
|
> abstraction (`EditWizard`/`LinksStep` records, `MultiStepFormState`, `step-params[...]`
|
||||||
|
> field names, `wrap-wizard`/`wrap-decode-multi-form-state` middleware) was deleted — there
|
||||||
|
> was only ever one step, so it was pure overhead. Result: heuristic 8 (mixed hx-) and 9
|
||||||
|
> (Hiccup in render) and 10 (mm coupling) all → **0**; the `edit-wizard-navigate` route is
|
||||||
|
> gone (routes 5). Parity held: swap spec **6/6**, transaction-edit spec **8/8**, full
|
||||||
|
> suite **38 pass / 1 pre-existing unrelated fail** (serial, fresh seed). The only Hiccup
|
||||||
|
> left in the file is the post-save `com/success-modal` confirmation dialogs (terminal,
|
||||||
|
> shared component — out of the form's render path). See `form-vs-wizard.md` (drop-the-
|
||||||
|
> wizard test), `selmer-conventions.md` (composition mechanics), and `gotchas.md`
|
||||||
|
> (stray-field decode leak; jetty reload staleness).
|
||||||
|
|||||||
@@ -75,11 +75,60 @@ Lessons:
|
|||||||
- **Embed via the bridge.** `render->hiccup` lets the Selmer fragment sit inside the
|
- **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.
|
still-Hiccup row (`com/validated-field` wraps it) — strangler, one component at a time.
|
||||||
|
|
||||||
## Composition
|
## Composition — verified mechanics (selmer 1.12.61)
|
||||||
|
|
||||||
Selmer composes via `{% include %}` and `{% block %}`. Prefer small per-component templates
|
Proven by REPL before the full migration (do the same before relying on any of these):
|
||||||
referenced by classpath path. Keep `|safe` to values the server fully controls (rendered
|
|
||||||
Hiccup, JSON for `x-data`), never raw user input.
|
- **`{% include %}` works in FILE renders only.** `sel/render` = `selmer/render-file`, and
|
||||||
|
include/extends/block are *parse-stage* tags. Rendering a template **string** that
|
||||||
|
contains `{% include %}` throws `unrecognized tag: :include` (the runtime expr-tag has a
|
||||||
|
nil handler). So includes only work from a `.html` file, never from `render-str`.
|
||||||
|
- **`{% include %}` sees `{% for %}` loop bindings.** Inside `{% for row in rows %}…{% include "components/x.html" %}…{% endfor %}` the partial can read `row.*`. Good for repeated
|
||||||
|
rows — though Clojure-composing the rows (below) is usually simpler.
|
||||||
|
- **`{% include … with k=v %}` is IGNORED** in 1.12.61 (the `with` clause is dropped). To
|
||||||
|
parametrize, either wrap with the block tag `{% with k=v %}{% include … %}{% endwith %}`
|
||||||
|
(works), or — preferred — let a Clojure wrapper render the partial with an explicit ctx.
|
||||||
|
|
||||||
|
## The Clojure-wrapper composition pattern (`auto-ap.ssr.components.selmer` / `sc`)
|
||||||
|
|
||||||
|
Because `{% include with %}` can't pass args and the server computes most values anyway,
|
||||||
|
each shared component is a **thin Clojure wrapper that renders its own partial** (the
|
||||||
|
proven `location-select*` shape, generalised). The element *structure* lives 100% in the
|
||||||
|
`.html`; the only Clojure is data assembly. The modal's render fns compose these wrappers
|
||||||
|
and assemble a view-model, interpolating the pre-rendered fragments as `{{ frag|safe }}`.
|
||||||
|
|
||||||
|
```clojure
|
||||||
|
(sc/hidden {:name … :value …}) ; -> render "components/hidden.html"
|
||||||
|
(sc/validated-field {:label … :errors …} body…)
|
||||||
|
(sc/typeahead {:name … :url … :value … :content-fn …}) ; resolves label server-side
|
||||||
|
(sc/data-grid {:headers […] :footer-tbody …} rows…)
|
||||||
|
```
|
||||||
|
|
||||||
|
### `attrs->str` — the dynamic-attribute bridge
|
||||||
|
|
||||||
|
HTMX/Alpine attributes vary per call site, so a fixed template can't enumerate them.
|
||||||
|
`sc/attrs->str` serialises an attribute *map* to an HTML attribute *string* injected with
|
||||||
|
`{{ attrs|safe }}`: `<input type="hidden"{{ attrs|safe }}>`. Rules mirror hiccup2 — nil/false
|
||||||
|
dropped, `true` → bare boolean attr, else `name="escaped"` (via `hu/escape-html`, so JSON
|
||||||
|
`x-data` and `x-init` quotes become `"`/`'` and the browser decodes them back).
|
||||||
|
Keyword keys keep their full name incl. namespaced/colon forms (`:x-hx-val:account-id` →
|
||||||
|
`x-hx-val:account-id`). This keeps templates free of per-attribute `{% if %}` ladders while
|
||||||
|
still emitting 100% Selmer markup — `attrs->str` serialises data, it does not build Hiccup.
|
||||||
|
|
||||||
|
### Reuse the real class helpers
|
||||||
|
|
||||||
|
Wrappers reuse `inputs/default-input-classes`, `inputs/use-size`, `hh/add-class`,
|
||||||
|
`btn/bg-colors` so output matches the Hiccup component **modulo Tailwind class order**.
|
||||||
|
Verify by class-**set** equality + e2e, never byte-parity (see the cookbook entries).
|
||||||
|
|
||||||
|
### Trivial wrapper divs
|
||||||
|
|
||||||
|
A bare `<div class="w-72">…</div>` around a fragment is composed with a `wrap-div` string
|
||||||
|
helper (or put the class in the parent template), not a Hiccup vector — string composition
|
||||||
|
of a structural wrapper is not Hiccup and avoids a micro-template per div.
|
||||||
|
|
||||||
|
Keep `|safe` to values the server fully controls (rendered fragments, JSON for `x-data`),
|
||||||
|
never raw user input.
|
||||||
|
|
||||||
## Scope (Open decision 2)
|
## Scope (Open decision 2)
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { test, expect } from '@playwright/test';
|
|||||||
// re-renders the entire form, and the client selects what to swap back -- with
|
// re-renders the entire form, and the client selects what to swap back -- with
|
||||||
// no out-of-band swaps and no morph extension:
|
// no out-of-band swaps and no morph extension:
|
||||||
// - discrete changes (vendor, account, location, mode, add/remove row) swap
|
// - discrete changes (vendor, account, location, mode, add/remove row) swap
|
||||||
// all of #wizard-form (the active action/tab round-trips through the form,
|
// all of #edit-form (the active action/tab round-trips through the form,
|
||||||
// so it survives the swap);
|
// so it survives the swap);
|
||||||
// - typed fields never swap the input the user is in -- the amount field swaps
|
// - 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
|
// only the #account-totals tbody (a sibling of the input rows), and the memo
|
||||||
@@ -32,7 +32,7 @@ async function openManualAdvanced(page: any, transactionIndex = 0) {
|
|||||||
.nth(transactionIndex)
|
.nth(transactionIndex)
|
||||||
.click();
|
.click();
|
||||||
await page.waitForSelector('#modal-holder[x-show="open"]', { state: 'visible' });
|
await page.waitForSelector('#modal-holder[x-show="open"]', { state: 'visible' });
|
||||||
await page.waitForSelector('#wizardmodal');
|
await page.waitForSelector('#editmodal');
|
||||||
await page.click('button:has-text("Manual")');
|
await page.click('button:has-text("Manual")');
|
||||||
|
|
||||||
// First transaction has no accounts so it opens in "simple" mode. Switch to
|
// First transaction has no accounts so it opens in "simple" mode. Switch to
|
||||||
@@ -107,7 +107,7 @@ test.describe('Transaction Edit whole-form swap', () => {
|
|||||||
.toBeGreaterThan(0);
|
.toBeGreaterThan(0);
|
||||||
|
|
||||||
// The form must survive the swap intact.
|
// The form must survive the swap intact.
|
||||||
await expect(page.locator('#wizard-form')).toHaveCount(1);
|
await expect(page.locator('#edit-form')).toHaveCount(1);
|
||||||
expect(errors, errors.join('\n')).toEqual([]);
|
expect(errors, errors.join('\n')).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -192,7 +192,7 @@ test.describe('Transaction Edit whole-form swap', () => {
|
|||||||
await page.goto('/transaction2');
|
await page.goto('/transaction2');
|
||||||
await page.waitForSelector('table tbody tr');
|
await page.waitForSelector('table tbody tr');
|
||||||
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('#editmodal');
|
||||||
|
|
||||||
const memo = page.locator('#edit-memo');
|
const memo = page.locator('#edit-memo');
|
||||||
await memo.waitFor();
|
await memo.waitFor();
|
||||||
@@ -301,7 +301,7 @@ test.describe('Transaction Edit whole-form swap', () => {
|
|||||||
await page.goto('/transaction2');
|
await page.goto('/transaction2');
|
||||||
await page.waitForSelector('table tbody tr');
|
await page.waitForSelector('table tbody tr');
|
||||||
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('#editmodal');
|
||||||
await page.click('button:has-text("Manual")');
|
await page.click('button:has-text("Manual")');
|
||||||
await page.waitForSelector('div[hx-vals*="vendor-changed"]');
|
await page.waitForSelector('div[hx-vals*="vendor-changed"]');
|
||||||
|
|
||||||
@@ -350,7 +350,7 @@ test.describe('Transaction Edit whole-form swap', () => {
|
|||||||
await page.goto('/transaction2');
|
await page.goto('/transaction2');
|
||||||
await page.waitForSelector('table tbody tr');
|
await page.waitForSelector('table tbody tr');
|
||||||
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('#editmodal');
|
||||||
await page.click('button:has-text("Manual")');
|
await page.click('button:has-text("Manual")');
|
||||||
await page.waitForSelector('div[hx-vals*="vendor-changed"]');
|
await page.waitForSelector('div[hx-vals*="vendor-changed"]');
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ async function openEditModal(page: any, transactionIndex: number = 0) {
|
|||||||
|
|
||||||
// Wait for the modal to open
|
// Wait for the modal to open
|
||||||
await page.waitForSelector('#modal-holder[x-show="open"]', { state: 'visible' });
|
await page.waitForSelector('#modal-holder[x-show="open"]', { state: 'visible' });
|
||||||
await page.waitForSelector('#wizardmodal');
|
await page.waitForSelector('#editmodal');
|
||||||
|
|
||||||
// The modal is now single-page (Edit Transaction). Click "Manual" tab to ensure
|
// The modal is now single-page (Edit Transaction). Click "Manual" tab to ensure
|
||||||
// the manual account coding form is active.
|
// the manual account coding form is active.
|
||||||
@@ -144,7 +144,7 @@ async function saveTransaction(page: any) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function toggleToPercentMode(page: any) {
|
async function toggleToPercentMode(page: any) {
|
||||||
const percentRadio = page.locator('input[name="step-params[amount-mode]"][value="%"]');
|
const percentRadio = page.locator('input[name="amount-mode"][value="%"]');
|
||||||
await percentRadio.click();
|
await percentRadio.click();
|
||||||
|
|
||||||
// Wait for HTMX to swap the grid body
|
// Wait for HTMX to swap the grid body
|
||||||
@@ -155,7 +155,7 @@ async function toggleToPercentMode(page: any) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function toggleToDollarMode(page: any) {
|
async function toggleToDollarMode(page: any) {
|
||||||
const dollarRadio = page.locator('input[name="step-params[amount-mode]"][value="$"]');
|
const dollarRadio = page.locator('input[name="amount-mode"][value="$"]');
|
||||||
await dollarRadio.click();
|
await dollarRadio.click();
|
||||||
|
|
||||||
// Wait for HTMX to swap the grid body
|
// Wait for HTMX to swap the grid body
|
||||||
@@ -235,7 +235,7 @@ test.describe('Transaction Edit Full Workflow', () => {
|
|||||||
await openEditModal(page, 0);
|
await openEditModal(page, 0);
|
||||||
await page.waitForTimeout(500);
|
await page.waitForTimeout(500);
|
||||||
|
|
||||||
const dollarRadio = page.locator('input[name="step-params[amount-mode]"][value="$"]');
|
const dollarRadio = page.locator('input[name="amount-mode"][value="$"]');
|
||||||
await expect(dollarRadio).toBeChecked();
|
await expect(dollarRadio).toBeChecked();
|
||||||
|
|
||||||
const val0 = await (await findAccountRow(page, 0)).locator('.account-amount-field').inputValue();
|
const val0 = await (await findAccountRow(page, 0)).locator('.account-amount-field').inputValue();
|
||||||
@@ -272,7 +272,7 @@ test.describe('Transaction Edit Validation', () => {
|
|||||||
await expect(page.locator('#modal-holder[x-show="open"]')).toBeVisible();
|
await expect(page.locator('#modal-holder[x-show="open"]')).toBeVisible();
|
||||||
|
|
||||||
// The form should still be present
|
// The form should still be present
|
||||||
const form = page.locator('#wizard-form');
|
const form = page.locator('#edit-form');
|
||||||
await expect(form).toBeVisible();
|
await expect(form).toBeVisible();
|
||||||
|
|
||||||
// Verify the account row is still there with our $50 value
|
// Verify the account row is still there with our $50 value
|
||||||
@@ -304,7 +304,7 @@ async function openEditModalForTransaction(page: any, description: string) {
|
|||||||
// navigation), so the action tabs -- including "Link to payment" -- are available
|
// navigation), so the action tabs -- including "Link to payment" -- are available
|
||||||
// immediately; callers click the tab they need.
|
// immediately; callers click the tab they need.
|
||||||
await page.waitForSelector('#modal-holder[x-show="open"]', { state: 'visible' });
|
await page.waitForSelector('#modal-holder[x-show="open"]', { state: 'visible' });
|
||||||
await page.waitForSelector('#wizardmodal');
|
await page.waitForSelector('#editmodal');
|
||||||
}
|
}
|
||||||
|
|
||||||
async function selectVendorFromTypeahead(page: any, vendorName: string) {
|
async function selectVendorFromTypeahead(page: any, vendorName: string) {
|
||||||
@@ -447,7 +447,7 @@ async function openManualVendorSection(page: any, transactionIndex: number) {
|
|||||||
await editButton.click();
|
await editButton.click();
|
||||||
|
|
||||||
await page.waitForSelector('#modal-holder[x-show="open"]', { state: 'visible' });
|
await page.waitForSelector('#modal-holder[x-show="open"]', { state: 'visible' });
|
||||||
await page.waitForSelector('#wizardmodal');
|
await page.waitForSelector('#editmodal');
|
||||||
await page.click('button:has-text("Manual")');
|
await page.click('button:has-text("Manual")');
|
||||||
await page.waitForSelector('div[hx-vals*="vendor-changed"]');
|
await page.waitForSelector('div[hx-vals*="vendor-changed"]');
|
||||||
}
|
}
|
||||||
@@ -472,7 +472,7 @@ test.describe('Transaction Edit Vendor Selection', () => {
|
|||||||
// 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-vals*="vendor-changed"] input[type="hidden"][name="step-params[transaction/vendor]"]'
|
'div[hx-vals*="vendor-changed"] input[type="hidden"][name="transaction/vendor"]'
|
||||||
)
|
)
|
||||||
.first();
|
.first();
|
||||||
await expect(hidden).toHaveValue(vendorId.toString());
|
await expect(hidden).toHaveValue(vendorId.toString());
|
||||||
|
|||||||
1
resources/templates/components/a-button.html
Normal file
1
resources/templates/components/a-button.html
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<a class="{{ classes }}"{{ attrs|safe }}>{% if indicator %}<div class="htmx-indicator flex items-center">{% include "templates/components/spinner.html" %}<div class="ml-3">Loading...</div></div>{% endif %}<div class="inline-flex gap-2 items-center justify-center{% if indicator %} htmx-indicator-hidden{% endif %}">{{ body|safe }}</div></a>
|
||||||
1
resources/templates/components/a-icon-button.html
Normal file
1
resources/templates/components/a-icon-button.html
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<a class="{{ classes }}"{{ attrs|safe }}><div class="h-4 w-4">{{ body|safe }}</div></a>
|
||||||
1
resources/templates/components/badge.html
Normal file
1
resources/templates/components/badge.html
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<div class="{{ classes }}"{{ attrs|safe }}>{{ body|safe }}</div>
|
||||||
1
resources/templates/components/button-group-button.html
Normal file
1
resources/templates/components/button-group-button.html
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<button class="{{ classes }}"{{ attrs|safe }}>{{ body|safe }}</button>
|
||||||
1
resources/templates/components/button-group.html
Normal file
1
resources/templates/components/button-group.html
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<div class="inline-flex rounded-md shadow-sm" role="group" hx-on:click="this.querySelector("input").value = event.target.value; this.querySelector("input").dispatchEvent(new Event('change', {bubbles: true}));"><input type="hidden" name="{{ name }}">{{ body|safe }}</div>
|
||||||
1
resources/templates/components/button.html
Normal file
1
resources/templates/components/button.html
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<button class="{{ classes }}"{{ attrs|safe }}><div class="htmx-indicator flex items-center absolute inset-0 justify-center">{% include "templates/components/spinner.html" %}{% if loading_label %}<div class="ml-3">Loading...</div>{% endif %}</div><div class="htmx-indicator-invisible inline-flex gap-2 items-center justify-center">{{ body|safe }}</div></button>
|
||||||
1
resources/templates/components/data-grid-cell.html
Normal file
1
resources/templates/components/data-grid-cell.html
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<td class="px-4 py-2{% if klass %} {{ klass }}{% endif %}"{{ attrs|safe }}>{{ body|safe }}</td>
|
||||||
1
resources/templates/components/data-grid-header.html
Normal file
1
resources/templates/components/data-grid-header.html
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<th class="px-4 py-3{% if klass %} {{ klass }}{% endif %}" scope="col" @click="{{ click|safe }}"{{ attrs|safe }}>{% if sort_key %}<a href="#">{{ body|safe }}</a>{% else %}{{ body|safe }}{% endif %}</th>
|
||||||
1
resources/templates/components/data-grid-row.html
Normal file
1
resources/templates/components/data-grid-row.html
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<tr class="{{ classes }}"{{ attrs|safe }}>{{ body|safe }}</tr>
|
||||||
1
resources/templates/components/data-grid.html
Normal file
1
resources/templates/components/data-grid.html
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<div class="shrink overflow-y-scroll"><table class="{{ table_class }}"{{ table_attrs|safe }}><thead class="{{ thead_class }}"><tr>{{ headers|safe }}</tr></thead><tbody>{{ rows|safe }}</tbody>{{ footer_tbody|safe }}</table></div>
|
||||||
3
resources/templates/components/hidden.html
Normal file
3
resources/templates/components/hidden.html
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
{# Hidden input. The Clojure wrapper (sc/hidden) serializes the full attribute map
|
||||||
|
(name, value, optional id/form/class/Alpine :value bind) into `attrs`. #}
|
||||||
|
<input type="hidden"{{ attrs|safe }}>
|
||||||
1
resources/templates/components/link.html
Normal file
1
resources/templates/components/link.html
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<a class="{{ classes }}"{{ attrs|safe }}>{{ body|safe }}</a>
|
||||||
1
resources/templates/components/modal.html
Normal file
1
resources/templates/components/modal.html
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<div class="{{ classes }}" @click.outside="open=false"{{ attrs|safe }}>{{ body|safe }}</div>
|
||||||
2
resources/templates/components/money-input.html
Normal file
2
resources/templates/components/money-input.html
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
{# Money input (number, step=0.01, right-aligned). sc/money-input builds `attrs`. #}
|
||||||
|
<input{{ attrs|safe }}>
|
||||||
1
resources/templates/components/radio-card.html
Normal file
1
resources/templates/components/radio-card.html
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<ul class="{{ ul_class }}">{% for opt in options %}<li class="{{ li_class }}"><div class="{{ div_class }}"><input id="{{ opt.id }}" type="radio" value="{{ opt.value }}" name="{{ name }}" class="{{ input_class }}"{{ input_attrs|safe }}{% if opt.checked %} checked{% endif %}><label for="{{ opt.id }}" class="{{ label_class }}">{{ opt.content|safe }}</label></div></li>{% endfor %}</ul>
|
||||||
1
resources/templates/components/spinner.html
Normal file
1
resources/templates/components/spinner.html
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg aria-hidden="true" class="animate-spin inline w-4 h-4 text-white" fill="none" role="status" viewbox="0 0 100 101" xmlns="http://www.w3.org/2000/svg"><path d="M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z" fill="#E5E7EB"></path><path d="M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z" fill="currentColor"></path></svg>
|
||||||
|
After Width: | Height: | Size: 1.2 KiB |
1
resources/templates/components/svg-drop-down.html
Normal file
1
resources/templates/components/svg-drop-down.html
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg aria-hidden="true" fill="none" stroke="currentColor" viewbox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M19 9l-7 7-7-7" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"></path></svg>
|
||||||
|
After Width: | Height: | Size: 216 B |
1
resources/templates/components/svg-external-link.html
Normal file
1
resources/templates/components/svg-external-link.html
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><defs></defs><title>navigation-next</title><path d="M23,9.5H12.387a4,4,0,0,0-4,4v2" fill="none" stroke-linecap="round" stroke-linejoin="round" stroke="currentColor"></path><polyline fill="none" points="19 13.498 23 9.498 19 5.498" stroke-linecap="round" stroke-linejoin="round" stroke="currentColor"></polyline><path d="M14.387,13v5.5a1,1,0,0,1-1,1h-12a1,1,0,0,1-1-1V6.5a1,1,0,0,1,1-1h12a1,1,0,0,1,1,1V7" fill="none" stroke-linecap="round" stroke-linejoin="round" stroke="currentColor"></path></svg>
|
||||||
|
After Width: | Height: | Size: 560 B |
1
resources/templates/components/svg-x.html
Normal file
1
resources/templates/components/svg-x.html
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg viewbox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><defs></defs><title>delete-2</title><circle cx="12" cy="12" fill="none" r="11.5" stroke-linecap="round" stroke-linejoin="round" stroke="currentColor"></circle><line fill="none" stroke-linecap="round" stroke-linejoin="round" stroke="currentColor" x1="7" x2="17" y1="7" y2="17"></line><line fill="none" stroke-linecap="round" stroke-linejoin="round" stroke="currentColor" x1="17" x2="7" y1="7" y2="17"></line></svg>
|
||||||
|
After Width: | Height: | Size: 474 B |
3
resources/templates/components/text-input.html
Normal file
3
resources/templates/components/text-input.html
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
{# Text input. sc/text-input builds the full attr map (type/autocomplete/class+size
|
||||||
|
already merged, reusing inputs/default-input-classes + hh/add-class) into `attrs`. #}
|
||||||
|
<input{{ attrs|safe }}>
|
||||||
4
resources/templates/components/typeahead.html
Normal file
4
resources/templates/components/typeahead.html
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
{# Click-to-select typeahead (Alpine + tippy). Survives whole-form swaps; null-guarded
|
||||||
|
tippy?. / $refs.input? throughout. The Clojure wrapper (sc/typeahead) resolves the
|
||||||
|
initial {value,label} server-side and builds x_data + the hidden-input attrs. #}
|
||||||
|
<div class="relative" x-data="{{ x_data }}" x-modelable="value.value"{% if x_model %} x-model="{{ x_model }}"{% endif %}{% if key %} key="{{ key }}"{% endif %}>{% if disabled %}<span x-text="value.label"></span>{% else %}<a class="{{ a_class }}" x-tooltip.on.click="{content: ()=>($refs.dropdown?.innerHTML ?? ''), placement: 'bottom', onMount(i) {htmx.process(i.popper); }, popperOptions: {strategy: 'fixed', modifiers: [{name: 'flip', options: {fallbackPlacements: ['top']}}]}, theme: 'dropdown', allowHTML: true, interactive:true}" @keydown.down.prevent.stop="tippy?.show();" @keydown.backspace="tippy?.hide(); value = {value: '', label: '' }" tabindex="0" x-init="{{ a_xinit }}" x-ref="input"><input{{ hidden_attrs|safe }}><div class="flex w-full justify-items-stretch"><span class="flex-grow text-left" x-text="value.label"></span><div class="w-3 h-3 m-1 inline ml-1 justify-self-end text-gray-500 self-center">{% include "templates/components/svg-drop-down.html" %}</div><div x-show="value.warning"><div class="peer absolute inline-flex items-center z-10 justify-center w-6 h-6 text-xs font-black text-white border-3 border-white rounded-full -top-2 -right-2 dark:border-gray-900 bg-red-300" x-tooltip="value.warning">!</div></div></div></a>{% endif %}<template x-ref="dropdown"><ul class="dropdown-contents bg-gray-100 dark:bg-gray-600 ring-1" @keydown.escape="$refs.input?.__x_tippy?.hide(); value = {value: '', label: '' }; " x-destroy="if ($refs.input) {$refs.input.focus();}"><input type="text" autofocus class="{{ search_class }}" x-model="search" placeholder="{{ placeholder }}" @change.stop="" @keydown.down.prevent="active ++; active = active >= elements.length - 1 ? elements.length - 1 : active" @keydown.up.prevent="active --; active = active < 0 ? 0 : active" @keydown.enter.prevent.stop="$refs.input?.__x_tippy?.hide(); value = elements.length > 0 ? $data.elements[active >= 0 ? active : 0] : {'value': '', label: ''}; $refs.input?.focus()" x-init="$el.focus(); $watch('search', s => { if($el.value.length > 2) {fetch(addQueryParam(baseUrl, 'q', s)).then(data=>data.json()).then(data => {elements = data; active=-1; $refs.input?.__x_tippy?.popperInstance?.update()}) }})"><div class="dropdown-options rounded-b-lg overflow-hidden"><template x-for="(element, index) in elements"><li><a class="px-4 py-2 flex gap-2 items-center outline-0 focus:bg-neutral-100 hover:bg-neutral-100 whitespace-nowrap [&.active]:bg-primary-500 [&.active]:dark:bg-primary-700 text-gray-800 dark:text-gray-100" href="#" :class="active == index ? 'active' : ''" @mouseover="active = index" @mouseout="active = -1" @click.prevent="value = element; $refs.input?.__x_tippy?.hide(); setTimeout(() => $refs.input?.focus(), 10)" x-html="element.label"></a></li></template><template x-if="elements.length == 0"><li class="px-4 py-2 flex gap-2 items-center outline-0 focus:bg-neutral-100 hover:bg-neutral-100 whitespace-nowrap [&.active]:bg-primary-500 text-gray-800 dark:text-gray-100 text-xs ">No results found</li></template></div></ul></template></div>
|
||||||
6
resources/templates/components/validated-field.html
Normal file
6
resources/templates/components/validated-field.html
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
{# Field wrapper with label + always-present error <p> (the errors- variant of field-).
|
||||||
|
`classes` already folds group / has-error / caller class via hh/add-class; `attrs`
|
||||||
|
carries any pass-through div attributes (the per-row location cell hangs its hx-* /
|
||||||
|
x-dispatch swap wiring here); `body` is the pre-rendered inner control HTML;
|
||||||
|
`errors_str` is the comma-joined string errors (empty when none). #}
|
||||||
|
<div class="{{ classes }}"{{ attrs|safe }}>{% if label %}<label class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">{{ label }}</label>{% endif %}{{ body|safe }}<p class="mt-2 text-xs text-red-600 dark:text-red-500 h-4">{{ errors_str }}</p></div>
|
||||||
3
resources/templates/transaction-edit/account-totals.html
Normal file
3
resources/templates/transaction-edit/account-totals.html
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
{# Totals live in their own swappable <tbody> so an amount edit refreshes them with a
|
||||||
|
targeted swap, never replacing the input-bearing rows above (caret survives). #}
|
||||||
|
<tbody id="account-totals">{{ rows|safe }}</tbody>
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
<div x-data="{{ x_data }}">{{ status_hidden|safe }}<div class="inline-flex rounded-md shadow-sm" role="group">{{ buttons|safe }}</div></div>
|
||||||
2
resources/templates/transaction-edit/details-panel.html
Normal file
2
resources/templates/transaction-edit/details-panel.html
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
{# Read-only transaction summary shown in the modal's left side panel. #}
|
||||||
|
<div class="p-4 space-y-4"><h3 class="text-sm font-semibold text-gray-900 uppercase tracking-wider">Details</h3><div class="space-y-3"><div><div class="text-xs font-medium text-gray-500">Amount</div><div class="text-sm font-medium text-gray-900">{{ amount }}</div></div><div><div class="text-xs font-medium text-gray-500">Date</div><div class="text-sm text-gray-900">{{ date }}</div></div><div><div class="text-xs font-medium text-gray-500">Bank Account</div><div class="text-sm text-gray-900">{{ bank_account }}</div></div><div><div class="text-xs font-medium text-gray-500">Post Date</div><div class="text-sm text-gray-900">{{ post_date }}</div></div><div><div class="text-xs font-medium text-gray-500">Description</div><div class="text-sm text-gray-900 truncate cursor-help" title="{{ description_original }}">{{ description_simple }}</div></div><div><div class="text-xs font-medium text-gray-500">Check Number</div><div class="text-sm text-gray-900">{{ check_number }}</div></div><div><div class="text-xs font-medium text-gray-500">Status</div><div class="text-sm text-gray-900">{{ status }}</div></div><div><div class="text-xs font-medium text-gray-500">Transaction Type</div><div class="text-sm text-gray-900">{{ type }}</div></div></div></div>
|
||||||
4
resources/templates/transaction-edit/edit-form.html
Normal file
4
resources/templates/transaction-edit/edit-form.html
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
{# Top-level plain form. The entity id rides in a hidden field; all other state is the
|
||||||
|
live form, re-derived against the entity each request (no serialized snapshot, no
|
||||||
|
wizard step-params). #}
|
||||||
|
<form id="edit-form"{{ form_attrs|safe }}><input type="hidden" name="db/id" value="{{ db_id }}">{{ modal|safe }}</form>
|
||||||
4
resources/templates/transaction-edit/edit-modal.html
Normal file
4
resources/templates/transaction-edit/edit-modal.html
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
{# Modal card chrome (header / optional side panel / body / footer). Single-step, so
|
||||||
|
no timeline, no back/next nav -- just the Done button in the footer. Enter triggers
|
||||||
|
the save button via $refs.next. #}
|
||||||
|
<div class="modal-card bg-white rounded-lg shadow dark:bg-gray-700 dark:text-white modal-content flex flex-col max-h-screen max-w-screen md:w-[950px] md:h-[650px] w-full h-full last-modal-step transition duration-150" @keydown.enter.prevent.stop="if ($refs.next) {$refs.next.click()}" x-data=""><div class="flex items-start justify-between p-4 border-b rounded-t dark:border-gray-600 shrink-0">{{ head|safe }}</div><div class="flex shrink overflow-auto grow">{% if side_panel %}<div class="grow-0 w-64 bg-gray-50 border-r hidden md:block overflow-y-auto max-h-full">{{ side_panel|safe }}</div>{% endif %}<div class="px-6 py-2 space-y-6 overflow-y-scroll w-full shrink grow">{{ body|safe }}</div></div><div class="p-4 border-t">{{ footer|safe }}</div></div>
|
||||||
1
resources/templates/transaction-edit/invoice-option.html
Normal file
1
resources/templates/transaction-edit/invoice-option.html
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<div class="ml-3"><span class="block text-sm font-medium">{{ number }}</span><span class="block text-sm text-gray-500">{{ vendor }}</span><span class="block text-sm text-gray-500">{{ date }}</span><span class="block text-sm font-medium">{{ amount }}</span></div>
|
||||||
1
resources/templates/transaction-edit/linked-payment.html
Normal file
1
resources/templates/transaction-edit/linked-payment.html
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<div class="my-4 p-4 bg-blue-50 rounded"><h3 class="text-lg font-bold mb-2">Linked Payment{{ external_link|safe }}</h3><div class="space-y-2"><div class="flex justify-between"><div class="font-medium">Payment #</div><div>{{ number }}</div></div><div class="flex justify-between"><div class="font-medium">Vendor</div><div>{{ vendor }}</div></div><div class="flex justify-between"><div class="font-medium">Amount</div><div>{{ amount }}</div></div><div class="flex justify-between"><div class="font-medium">Status</div><div>{{ status }}</div></div><div class="flex justify-between"><div class="font-medium">Date</div><div>{{ date }}</div></div>{{ payment_id_hidden|safe }}<div class="mt-4"{{ unlink_attrs|safe }}>{{ unlink_button|safe }}</div></div></div>
|
||||||
3
resources/templates/transaction-edit/links-body.html
Normal file
3
resources/templates/transaction-edit/links-body.html
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
{# The single step's body: memo + the activeForm tab switcher (link payment / unpaid /
|
||||||
|
autopay / rule / manual) + the five x-show panels. Fragments are pre-rendered. #}
|
||||||
|
<div class="space-y-1"><div>{{ memo_field|safe }}<div x-data="{{ x_data }}" @unlinked="canChange=true"><div class="flex space-x-2 mb-4">{{ action_hidden|safe }}{{ tabs|safe }}</div><div x-show="activeForm === 'link-payment'" x-transition:enter="transition ease-out duration-500" x-transition:enter-start="opacity-0 transform scale-95" x-transition:enter-end="opacity-100 transform scale-100">{{ panel_payment|safe }}</div><div x-show="activeForm === 'link-unpaid-invoices'" x-transition:enter="transition ease-out duration-500" x-transition:enter-start="opacity-0 transform scale-95" x-transition:enter-end="opacity-100 transform scale-100">{{ panel_unpaid|safe }}</div><div x-show="activeForm === 'link-autopay-invoices'" x-transition:enter="transition ease-out duration-500" x-transition:enter-start="opacity-0 transform scale-95" x-transition:enter-end="opacity-100 transform scale-100">{{ panel_autopay|safe }}</div><div x-show="activeForm === 'apply-rule'" x-transition:enter="transition ease-out duration-500" x-transition:enter-start="opacity-0 transform scale-95" x-transition:enter-end="opacity-100 transform scale-100">{{ panel_rule|safe }}</div><div x-show="activeForm === 'manual'" x-transition:enter="transition ease-out duration-500" x-transition:enter-start="opacity-0 transform scale-95" x-transition:enter-end="opacity-100 transform scale-100"><div>{{ panel_manual|safe }}</div></div></div></div></div>
|
||||||
3
resources/templates/transaction-edit/manual-coding.html
Normal file
3
resources/templates/transaction-edit/manual-coding.html
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
{# Vendor field (a change repopulates the default account via a whole-form swap) + either
|
||||||
|
the simple single-row coding or the advanced account grid. #}
|
||||||
|
<div id="manual-coding-section">{{ mode_hidden|safe }}<div{{ vendor_changed_attrs|safe }}>{{ vendor_field|safe }}</div>{% if is_simple %}<div x-data="{{ simple_xdata }}">{{ simple_mode|safe }}</div>{% else %}<div>{{ toggle_link|safe }}{{ accounts_field|safe }}</div>{% endif %}</div>
|
||||||
1
resources/templates/transaction-edit/panel-empty.html
Normal file
1
resources/templates/transaction-edit/panel-empty.html
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<div class="text-center py-4 text-gray-500">{{ message }}</div>
|
||||||
3
resources/templates/transaction-edit/panel-list.html
Normal file
3
resources/templates/transaction-edit/panel-list.html
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
{# Shared shell for the autopay/unpaid/rule match panels: heading + action hidden +
|
||||||
|
prompt label + a radio-card of options. #}
|
||||||
|
<div><h3 class="text-lg font-bold mb-4">{{ heading }}</h3>{{ action_hidden|safe }}<div class="space-y-2"><label class="block text-sm font-medium mb-1">{{ prompt }}</label>{{ radio|safe }}</div></div>
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
{# Outer wrapper for the payment tab; the unlink-payment route swaps #payment-matches. #}
|
||||||
|
<div id="payment-matches">{{ inner|safe }}</div>
|
||||||
1
resources/templates/transaction-edit/rule-option.html
Normal file
1
resources/templates/transaction-edit/rule-option.html
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<div class="ml-3"><span class="block text-sm font-medium">{{ note }}</span><span class="block text-sm text-gray-500">{{ description }}</span></div>
|
||||||
4
resources/templates/transaction-edit/simple-mode.html
Normal file
4
resources/templates/transaction-edit/simple-mode.html
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
{# Simple mode: a single account row (account typeahead + location select) rendered at a
|
||||||
|
fixed index 0, plus the link to switch to the advanced grid. Selecting the account
|
||||||
|
swaps just the location cell (#simple-account-location). #}
|
||||||
|
<div><span>{{ row_id_hidden|safe }}<div class="flex gap-2 mt-2">{{ account_field|safe }}<div id="simple-account-location">{{ location_field|safe }}</div>{{ amount_hidden|safe }}</div></span><div class="mt-1"><a class="text-sm text-blue-600 hover:underline cursor-pointer"{{ toggle_attrs|safe }}>Switch to advanced mode</a></div></div>
|
||||||
3
resources/templates/transaction-edit/transitioner.html
Normal file
3
resources/templates/transaction-edit/transitioner.html
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
{# Wrapper the modal stack expects around the opened form (the wizard transition hooks
|
||||||
|
are gone -- there is only one step). #}
|
||||||
|
<div id="transitioner" class="flex-1">{{ body|safe }}</div>
|
||||||
292
src/clj/auto_ap/ssr/components/selmer.clj
Normal file
292
src/clj/auto_ap/ssr/components/selmer.clj
Normal file
@@ -0,0 +1,292 @@
|
|||||||
|
(ns auto-ap.ssr.components.selmer
|
||||||
|
"Selmer-rendered versions of the shared SSR components used by the Transaction Edit
|
||||||
|
modal (see .claude/skills/ssr-form-migration). Each wrapper assembles a plain-data
|
||||||
|
context and renders its own template under resources/templates/components/ via the
|
||||||
|
interop bridge -- the element structure lives entirely in the .html templates; the
|
||||||
|
only Clojure is data assembly. Dynamic HTMX/Alpine attributes (which vary per call
|
||||||
|
site) are serialized to an attribute string by `attrs->str` and injected with
|
||||||
|
{{ attrs|safe }}, so the templates stay free of per-attribute {% if %} ladders.
|
||||||
|
|
||||||
|
Reuses class logic from auto-ap.ssr.components.inputs so output matches the Hiccup
|
||||||
|
components byte-for-byte modulo Tailwind class ordering (verify by string-match +
|
||||||
|
e2e, never byte-parity -- see selmer-conventions.md)."
|
||||||
|
(:require
|
||||||
|
[auto-ap.ssr.components.buttons :as btn]
|
||||||
|
[auto-ap.ssr.components.inputs :as inputs]
|
||||||
|
[auto-ap.ssr.hiccup-helper :as hh]
|
||||||
|
[auto-ap.ssr.hx :as hx]
|
||||||
|
[auto-ap.ssr.selmer :as sel]
|
||||||
|
[clojure.string :as str]
|
||||||
|
[hiccup.util :as hu]))
|
||||||
|
|
||||||
|
(defn- attr-name [k]
|
||||||
|
(if (keyword? k) (subs (str k) 1) (str k)))
|
||||||
|
|
||||||
|
(defn attrs->str
|
||||||
|
"Serialize an attribute map to an HTML attribute string with a leading space, so it
|
||||||
|
concatenates after fixed template attributes: <input type=\"text\"{{ attrs|safe }}>.
|
||||||
|
nil/false values are dropped, true renders a bare boolean attribute, everything else
|
||||||
|
renders name=\"escaped-value\". Mirrors how hiccup2 emits attributes."
|
||||||
|
[m]
|
||||||
|
(->> m
|
||||||
|
(keep (fn [[k v]]
|
||||||
|
(cond
|
||||||
|
(nil? v) nil
|
||||||
|
(false? v) nil
|
||||||
|
(true? v) (str " " (attr-name k))
|
||||||
|
:else (str " " (attr-name k) "=\""
|
||||||
|
(hu/escape-html (if (keyword? v) (name v) (str v)))
|
||||||
|
"\""))))
|
||||||
|
(apply str)))
|
||||||
|
|
||||||
|
(defn render
|
||||||
|
"Render a component partial and trim outer whitespace (so {# comments #} and the
|
||||||
|
file's trailing newline don't leak into the embedding tree). Returns a raw-wrapped
|
||||||
|
string ready to drop into Hiccup or another Selmer context value."
|
||||||
|
[template ctx]
|
||||||
|
(sel/raw (str/trim (sel/render template ctx))))
|
||||||
|
|
||||||
|
(defn- body->html
|
||||||
|
"Render child content (Hiccup vectors and/or raw Selmer fragments) to an HTML string."
|
||||||
|
[body]
|
||||||
|
(->> (if (sequential? body) body [body])
|
||||||
|
(remove nil?)
|
||||||
|
(map sel/hiccup->html)
|
||||||
|
(apply str)))
|
||||||
|
|
||||||
|
;; --- leaf inputs -----------------------------------------------------------------
|
||||||
|
|
||||||
|
(defn hidden [{:keys [name value] :as params}]
|
||||||
|
(render "templates/components/hidden.html"
|
||||||
|
{:attrs (attrs->str (merge {:name name}
|
||||||
|
(when (some? value) {:value value})
|
||||||
|
(dissoc params :name :value)))}))
|
||||||
|
|
||||||
|
(defn text-input [{:keys [size] :as params}]
|
||||||
|
(let [attrs (-> params
|
||||||
|
(dissoc :error? :size)
|
||||||
|
(assoc :type "text" :autocomplete "off")
|
||||||
|
(update :class #(-> ""
|
||||||
|
(hh/add-class inputs/default-input-classes)
|
||||||
|
(hh/add-class %)))
|
||||||
|
(update :class #(str % (inputs/use-size size))))]
|
||||||
|
(render "templates/components/text-input.html" {:attrs (attrs->str attrs)})))
|
||||||
|
|
||||||
|
(defn money-input [{:keys [size] :as params}]
|
||||||
|
(let [attrs (-> params
|
||||||
|
(dissoc :size)
|
||||||
|
(update :class (fnil hh/add-class "") inputs/default-input-classes)
|
||||||
|
(update :class hh/add-class "appearance-none text-right")
|
||||||
|
(update :class #(str % (inputs/use-size size)))
|
||||||
|
(assoc :type "number" :step "0.01"))]
|
||||||
|
(render "templates/components/money-input.html" {:attrs (attrs->str attrs)})))
|
||||||
|
|
||||||
|
;; --- field wrapper ---------------------------------------------------------------
|
||||||
|
|
||||||
|
(defn validated-field
|
||||||
|
"Selmer port of com/validated-field (the errors- variant of field-): label + body +
|
||||||
|
an always-present error <p>. Pass-through attrs land on the wrapping div (the account
|
||||||
|
row's location cell hangs its swap wiring here)."
|
||||||
|
[{:keys [label errors] :as params} & body]
|
||||||
|
(let [classes (cond-> (or (:class params) "")
|
||||||
|
(sequential? errors) (hh/add-class "has-error")
|
||||||
|
:always (hh/add-class "group"))
|
||||||
|
attrs (dissoc params :label :errors :error-source :error-key :class)
|
||||||
|
errors-str (when (sequential? errors)
|
||||||
|
(str/join ", " (filter string? errors)))]
|
||||||
|
(render "templates/components/validated-field.html"
|
||||||
|
{:label label
|
||||||
|
:classes classes
|
||||||
|
:attrs (attrs->str attrs)
|
||||||
|
:body (body->html body)
|
||||||
|
:errors_str (or errors-str "")})))
|
||||||
|
|
||||||
|
;; --- buttons / badges / links ----------------------------------------------------
|
||||||
|
|
||||||
|
(defn badge [{:keys [color] :as params} & children]
|
||||||
|
(let [classes (-> (hh/add-class
|
||||||
|
"absolute inline-flex items-center z-10 justify-center w-6 h-6 text-xs font-black text-white \n border-3 border-white rounded-full -top-2 -right-2 dark:border-gray-900"
|
||||||
|
(:class params))
|
||||||
|
(hh/add-class (or (some-> color (#(str "bg-" % "-300"))) "bg-red-300")))]
|
||||||
|
(render "templates/components/badge.html"
|
||||||
|
{:classes classes
|
||||||
|
:attrs (attrs->str (dissoc params :class))
|
||||||
|
:body (body->html children)})))
|
||||||
|
|
||||||
|
(defn link [{:keys [class] :as params} & children]
|
||||||
|
(render "templates/components/link.html"
|
||||||
|
{:classes (str class " font-medium text-blue-600 dark:text-blue-500 hover:underline cursor-pointer")
|
||||||
|
:attrs (attrs->str (dissoc params :class))
|
||||||
|
:body (body->html children)}))
|
||||||
|
|
||||||
|
(defn button [{:keys [color disabled minimal-loading?] :as params} & children]
|
||||||
|
(let [classes (cond-> (:class params)
|
||||||
|
true (str " focus:ring-4 font-bold rounded-lg text-xs p-3 text-center mr-2 inline-flex items-center relative justify-center disabled:opacity-50"
|
||||||
|
(btn/bg-colors color disabled))
|
||||||
|
(not disabled) (str " hover:scale-105 transition duration-100")
|
||||||
|
disabled (str " cursor-not-allowed")
|
||||||
|
(some? color) (str " text-white ")
|
||||||
|
(nil? color) (str " bg-white dark:bg-gray-600 border-gray-300 dark:border-gray-700 text-gray-500 hover:text-gray-800 dark:text-gray-400 dark:hover:text-gray-100 font-medium border border-gray-300 dark:border-gray-700"))]
|
||||||
|
(render "templates/components/button.html"
|
||||||
|
{:classes classes
|
||||||
|
:attrs (attrs->str (dissoc params :class))
|
||||||
|
:loading_label (not minimal-loading?)
|
||||||
|
:body (body->html children)})))
|
||||||
|
|
||||||
|
(defn a-button [{:keys [color disabled] :as params} & children]
|
||||||
|
(let [indicator? (:indicator? params true)
|
||||||
|
classes (cond-> (:class params)
|
||||||
|
true (str " focus:ring-4 font-bold rounded-lg text-xs p-3 text-center mr-2 inline-flex items-center hover:scale-105 transition duration-100 justify-center")
|
||||||
|
(= :secondary color) (str " text-white bg-blue-500 hover:bg-blue-600 focus:ring-blue-300 dark:bg-blue-600 dark:hover:bg-blue-700")
|
||||||
|
(= :primary color) (str " text-white bg-green-500 hover:bg-green-600 focus:ring-green-300 dark:bg-green-600 dark:hover:bg-green-700 ")
|
||||||
|
(= :secondary-light color) (str " text-blue-800 bg-white-200 border-gray-100 border hover:bg-blue-100 focus:ring-blue-100 dark:bg-blue-400 dark:hover:bg-blue-800 ")
|
||||||
|
(some? color) (str " text-white " (btn/bg-colors color disabled))
|
||||||
|
(nil? color) (str " bg-white dark:bg-gray-600 border-gray-300 dark:border-gray-700 text-gray-500 hover:text-gray-800 dark:text-gray-400 dark:hover:text-gray-100 font-medium border border-gray-300 dark:border-gray-700"))]
|
||||||
|
(render "templates/components/a-button.html"
|
||||||
|
{:classes classes
|
||||||
|
:attrs (attrs->str (-> (dissoc params :class)
|
||||||
|
(assoc :tabindex 0 :href (:href params "#"))))
|
||||||
|
:indicator indicator?
|
||||||
|
:body (body->html children)})))
|
||||||
|
|
||||||
|
(defn a-icon-button [{:keys [class] :as params} & children]
|
||||||
|
(let [class-str (or class "")
|
||||||
|
has-padding? (re-find #"\bp[xy]?-\d+(\.\d+)?\b" class-str)
|
||||||
|
classes (str class-str (if has-padding? "" " p-3")
|
||||||
|
" inline-flex items-center justify-center bg-white dark:bg-gray-600 items-center text-sm font-medium border border-gray-300 dark:border-gray-700 text-center text-gray-500 hover:text-gray-800 rounded-lg dark:text-gray-400 dark:hover:text-gray-100")]
|
||||||
|
(render "templates/components/a-icon-button.html"
|
||||||
|
{:classes classes
|
||||||
|
:attrs (attrs->str (-> (dissoc params :class)
|
||||||
|
(assoc :href (or (:href params) ""))))
|
||||||
|
:body (body->html children)})))
|
||||||
|
|
||||||
|
(defn button-group-button [{:keys [size] :or {size :normal} :as params} & children]
|
||||||
|
(let [classes (cond-> (:class params)
|
||||||
|
true (str " font-medium text-gray-900 bg-white border border-gray-200 hover:bg-gray-100 hover:text-primary-700 focus:z-10 focus:ring-2 focus:ring-green-700 focus:text-green-700 dark:bg-gray-700 dark:border-gray-600 dark:text-white dark:hover:text-white dark:hover:bg-gray-600 dark:focus:ring-green-500 dark:focus:text-white disabled:opacity-50")
|
||||||
|
(= :small size) (str " text-xs px-3 py-2")
|
||||||
|
(= :normal size) (str " text-sm px-4 py-2"))]
|
||||||
|
(render "templates/components/button-group-button.html"
|
||||||
|
{:classes classes
|
||||||
|
:attrs (attrs->str (-> (dissoc params :class :size)
|
||||||
|
(assoc :type (or (:type params) "button"))))
|
||||||
|
:body (body->html children)})))
|
||||||
|
|
||||||
|
(defn button-group [{:keys [name]} & children]
|
||||||
|
(render "templates/components/button-group.html"
|
||||||
|
{:name name
|
||||||
|
:body (body->html children)}))
|
||||||
|
|
||||||
|
;; --- radio-card ------------------------------------------------------------------
|
||||||
|
|
||||||
|
(defn radio-card
|
||||||
|
"Selmer port of com/radio-card. NB: the Hiccup radio-card- has a dangling [:h3 title]
|
||||||
|
the let discards, so only the <ul> renders -- reproduced here. Only the documented
|
||||||
|
htmx keys ride onto each <input> (the same select-keys filter; :hx-vals / :hx-select
|
||||||
|
are intentionally dropped, matching existing behavior)."
|
||||||
|
[{:keys [options name title size orientation width] :or {size :medium width "w-48"}
|
||||||
|
selected-value :value :as params}]
|
||||||
|
(let [htmx-attrs (select-keys params [:hx-post :hx-target :hx-swap :hx-include :hx-trigger])
|
||||||
|
sel (cond-> selected-value (keyword? selected-value) clojure.core/name)
|
||||||
|
ul-class (cond-> " text-sm font-medium text-gray-900 bg-white border border-gray-200 rounded-lg dark:bg-gray-700 dark:border-gray-600 dark:text-white"
|
||||||
|
(= orientation :horizontal) (-> (hh/add-class "flex gap-2 flex-wrap")
|
||||||
|
(hh/remove-wildcard ["w-" "rounded-lg" "border" "bg-"]))
|
||||||
|
:always (str " " width " "))
|
||||||
|
li-class (cond-> "w-full border-b border-gray-200 rounded-t-lg dark:border-gray-600"
|
||||||
|
(= orientation :horizontal) (-> (hh/remove-wildcard ["w-full" "rounded-"])
|
||||||
|
(hh/add-class "w-auto shrink-0 block rounded-lg border border-gray-200 dark:border-gray-600 px-3")))
|
||||||
|
div-class (cond-> "flex items-center"
|
||||||
|
(not= orientation :horizontal) (hh/add-class "pl-3"))
|
||||||
|
input-class (cond-> "w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-700 dark:focus:ring-offset-gray-700 focus:ring-2 dark:bg-gray-600 dark:border-gray-500"
|
||||||
|
(= size :small) (str " text-xs")
|
||||||
|
(= size :medium) (str " text-sm"))
|
||||||
|
label-class (cond-> "w-full ml-2 font-medium text-gray-900 dark:text-gray-300"
|
||||||
|
(= size :small) (str " text-xs py-2")
|
||||||
|
(= size :medium) (str " text-sm py-3")
|
||||||
|
(= orientation :horizontal) (hh/remove-class "w-full"))]
|
||||||
|
(render "templates/components/radio-card.html"
|
||||||
|
{:ul_class ul-class :li_class li-class :div_class div-class
|
||||||
|
:input_class input-class :label_class label-class
|
||||||
|
:name name
|
||||||
|
:input_attrs (attrs->str htmx-attrs)
|
||||||
|
:options (for [{:keys [value content]} options]
|
||||||
|
{:id (str "list-" name "-" value)
|
||||||
|
:value value
|
||||||
|
:checked (= sel value)
|
||||||
|
:content (body->html content)})})))
|
||||||
|
|
||||||
|
;; --- data grid -------------------------------------------------------------------
|
||||||
|
|
||||||
|
(defn data-grid-header [params & body]
|
||||||
|
(render "templates/components/data-grid-header.html"
|
||||||
|
{:klass (:class params)
|
||||||
|
:click (format "$dispatch('sorted', {key: '%s'})" (:sort-key params))
|
||||||
|
:sort_key (:sort-key params)
|
||||||
|
:attrs (attrs->str (cond-> {} (:style params) (assoc :style (:style params))))
|
||||||
|
:body (body->html body)}))
|
||||||
|
|
||||||
|
(defn data-grid-row [params & body]
|
||||||
|
(render "templates/components/data-grid-row.html"
|
||||||
|
{:classes (str (:class params) " border-b dark:border-gray-600 group hover:bg-gray-100 dark:hover:bg-gray-700")
|
||||||
|
:attrs (attrs->str (dissoc params :class))
|
||||||
|
:body (body->html body)}))
|
||||||
|
|
||||||
|
(defn data-grid-cell [params & body]
|
||||||
|
(render "templates/components/data-grid-cell.html"
|
||||||
|
{:klass (:class params)
|
||||||
|
:attrs (attrs->str (dissoc params :class))
|
||||||
|
:body (body->html body)}))
|
||||||
|
|
||||||
|
(defn data-grid
|
||||||
|
"Table shell: outer scroll div > table > thead(headers) > tbody(rows) + optional
|
||||||
|
footer-tbody. `headers`, `rows`, and `footer-tbody` are pre-rendered fragments."
|
||||||
|
[{:keys [headers footer-tbody] :as params} & rows]
|
||||||
|
(render "templates/components/data-grid.html"
|
||||||
|
{:table_class "w-full text-sm text-left text-gray-500 dark:text-gray-400 shrink"
|
||||||
|
:table_attrs (attrs->str (dissoc params :headers :thead-params :footer-tbody))
|
||||||
|
:thead_class "text-xs text-gray-800 uppercase bg-gray-50 dark:bg-gray-700 dark:text-gray-400 group-[.raw]:sticky group-[.raw]:z-10 group-[.raw]:top-0"
|
||||||
|
:headers (body->html headers)
|
||||||
|
:rows (body->html rows)
|
||||||
|
:footer_tbody (when footer-tbody (body->html footer-tbody))}))
|
||||||
|
|
||||||
|
;; --- modal + typeahead -----------------------------------------------------------
|
||||||
|
|
||||||
|
(defn modal [{:as params} & children]
|
||||||
|
(render "templates/components/modal.html"
|
||||||
|
{:classes (hh/add-class "" (:class params ""))
|
||||||
|
:attrs (attrs->str (dissoc params :handle-unexpected-error? :class))
|
||||||
|
:body (body->html children)}))
|
||||||
|
|
||||||
|
(defn typeahead
|
||||||
|
"Selmer port of com/typeahead. Resolves the initial {value,label} server-side via
|
||||||
|
value-fn/content-fn (DB lookups), builds the Alpine x-data, and serializes the
|
||||||
|
hidden posting-input attributes. Preserves every tippy?. null-guard."
|
||||||
|
[{:keys [value value-fn content-fn x-model x-init id class placeholder disabled url]
|
||||||
|
:as params}]
|
||||||
|
(let [vf (or value-fn identity)
|
||||||
|
cf (or content-fn identity)
|
||||||
|
vval (vf value)
|
||||||
|
vlabel (cf value)
|
||||||
|
x-data (hx/json {:baseUrl (str url)
|
||||||
|
:value {:value vval :label vlabel}
|
||||||
|
:tippy nil :search "" :active -1
|
||||||
|
:elements (if vval [{:value vval :label vlabel}] [])})
|
||||||
|
a-class (-> (hh/add-class (or class "") inputs/default-input-classes)
|
||||||
|
(hh/add-class "cursor-pointer"))
|
||||||
|
a-xinit (str "$nextTick(() => tippy = $el.__x_tippy); " x-init)
|
||||||
|
search-class (-> (or class "")
|
||||||
|
(hh/add-class inputs/default-input-classes)
|
||||||
|
(hh/replace-wildcard ["rounded" "border"] "border-bottom bg-gray-100 rounded-t-lg w-full"))
|
||||||
|
hidden-attrs (-> params
|
||||||
|
(dissoc :class :value-fn :content-fn :placeholder :x-model)
|
||||||
|
(assoc "x-ref" "hidden" :type "hidden" ":value" "value.value"
|
||||||
|
:x-init "$watch('value', v => { $el.value = (v && v.value != null) ? v.value : ''; $nextTick(() => $dispatch('change')); }); "))]
|
||||||
|
(render "templates/components/typeahead.html"
|
||||||
|
{:x_data x-data
|
||||||
|
:x_model x-model
|
||||||
|
:key (when id (str id "--" vval))
|
||||||
|
:disabled disabled
|
||||||
|
:a_class a-class
|
||||||
|
:a_xinit a-xinit
|
||||||
|
:search_class search-class
|
||||||
|
:placeholder placeholder
|
||||||
|
:hidden_attrs (attrs->str hidden-attrs)})))
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,6 @@
|
|||||||
(ns auto-ap.routes.transactions)
|
(ns auto-ap.routes.transactions)
|
||||||
|
|
||||||
(def routes {"" {:get ::page
|
(def routes {"" {:get ::page
|
||||||
:put ::edit-wizard-navigate
|
|
||||||
"/unapproved" ::unapproved-page
|
"/unapproved" ::unapproved-page
|
||||||
"/requires-feedback" ::requires-feedback-page
|
"/requires-feedback" ::requires-feedback-page
|
||||||
"/approved" ::approved-page
|
"/approved" ::approved-page
|
||||||
|
|||||||
Reference in New Issue
Block a user