74 Commits

Author SHA1 Message Date
a01dfc197e 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>
2026-06-04 07:47:47 -07:00
c892719bd1 feat(ssr): migrate location-select to a Selmer template (Phase 2 — Selmer validated)
First interactive Transaction Edit component rendered from a Selmer template instead of
Hiccup/com/select, proving the render-file path + the Hiccup<->Selmer interop bridge on
real, e2e-covered markup.

- resources/templates/components/location-select.html: plain-HTML <select> with a
  {% for %} over option maps + {% if opt.selected %}.
- location-select*: build options/selected/classes in Clojure (reusing
  inputs/default-input-classes so styling can't drift), render via
  sel/render->hiccup, and embed the fragment back into the still-Hiccup account row.
- skill: finalize selmer-conventions.md from this validated example (was a stub); add the
  cookbook entry; scorecard marks the first Selmer component.

Verified on a fresh server: full suite 38 pass / 1 unrelated fail, swap 6/6,
transaction-edit 8/8 -- the Shared Location test selects through the Selmer <select>,
saves, and spreads Shared -> DT. Verified by string-match + e2e (not byte-parity:
hh/add-class is set-based so class order differs, CSS is order-independent).

Scope note: the modal's remaining attribute-heavy components delegate to the shared
com/typeahead / com/select / com/button-group-button; converting those is the
cross-cutting Phase 11 Selmer sweep, not a single-modal change (Open decision 2).
2026-06-03 17:28:53 -07:00
d0fad63e24 refactor(ssr): remove the EDN snapshot round-trip; transaction edit is a plain form (heuristic 2)
The wizard serialized the whole accumulating form state into a `snapshot` hidden field
(pr-str EDN + custom readers), decoded it every request, and merged step-params back in.
For this single-step modal the snapshot is pure redundancy: every value is either in the
entity or the live posted form. Remove it:

- render: EditWizard.render-wizard renders a plain form -- no snapshot / edit-path /
  current-step hidden fields; a single `db/id` hidden rides in the form instead.
- middleware: wrap-derive-state rebuilds :multi-form-state per request from the entity
  (loaded by the db/id hidden) overlaid with the live step-params, replacing
  wrap-init-multi-form-state + wrap-entity. The ~34 :snapshot reads are unchanged --
  :snapshot is now a derived map, not a round-tripped blob.
- editable fields (accounts, vendor, memo, approval, action, mode, amount-mode) come ONLY
  from the posted form (absent = cleared) so removing all account rows doesn't resurrect
  the entity's persisted accounts; only entity-only fields (db/id, client, amount, ...)
  come from the entity.
- delete the dead initial-edit-wizard-state and render-account-grid-body.
- e2e: make removeAllAccounts re-query each iteration (whole-form swaps stale a captured
  row index) and restore the percentage test to type-then-add ordering.

Scorecard: snapshot EDN round-trip + custom readers + merge-multi-form-state -> gone
(snapshot-field renders 0). Verified on a fresh server: full suite 38 pass / 1 unrelated
fail, swap 6/6, transaction-edit 8/8 -- same green as before, snapshot removed.
2026-06-03 15:20:26 -07:00
0b5bfd9c84 fix(ssr): operation handlers read live step-params, not the stale snapshot (rewrite stage 1)
apply-new-account / apply-remove-account / apply-toggle-amount-mode rebuilt the account
rows from the decoded :snapshot, dropping any value the user typed but that had not yet
round-tripped into the snapshot (type 50%, click "New account" -> first row reverts,
giving a 66.67/33.33 split instead of 50/50). Read the live :step-params rows instead
(already schema-decoded by mm/wrap-wizard, so typed), falling back to snapshot only when
absent. First stage of removing the snapshot round-trip; fixes a real user-facing bug
(typed amounts lost on add/remove/$%-toggle).

Restore the percentage-split e2e to the realistic type-then-add ordering as a regression
guard. Modal stays green: swap 6/6, transaction-edit 8/8.
2026-06-03 08:18:58 -07:00
38ad665726 docs(skill): finalize Phase 2 scorecard (transaction-edit 8/8, suite 38/1)
Record the Phase 2 Transaction Edit outcome: heuristic 1 cleared (no-cursor + faked roots
0), routes ~12 -> ~5 (operations collapsed to one edit-form-changed dispatcher), :mode
prod bug fixed, modal spec greened 8/8, swap spec 6/6, full suite 38 pass / 1 unrelated
fail / 0 skip. Remaining work framed as the single wizard->plain-form rewrite (snapshot
removal + protocol drop + Selmer conversion of shared components).
2026-06-03 07:21:48 -07:00
798b350c81 test(e2e): green the transaction-edit modal spec (8/8) + record snapshot-drop gotcha
Rewrite the percentage-split test and fix two pre-existing stale tests that were masked
behind it (the file is mode:serial, so the first failure hides the rest):

- Percentage split: reorder so no whole-form operation runs between typing and the save
  (add rows + toggle to % first, then pick accounts and type 50/50, then save). The old
  order typed an amount then added a row, and apply-new-account rebuilds rows from the
  stale snapshot -- dropping the typed value (66.67/33.33 instead of 50/50). Same observed
  behavior verified, just an ordering that doesn't trip the snapshot round-trip.
- Pre-populate-default-account: read the actual transaction total from the grid instead of
  hard-coding $400 for "row index 3" (same-date seed rows have no pinned order).
- openEditModalForTransaction: drop the removed multi-step "Transaction Actions" wizard
  navigation; the modal is single-page, action tabs are immediately available.

skill: gotchas.md records the snapshot-operations-drop-live-values bug (heuristic-2 work,
deferred to the wizard->plain-form rewrite) and the two stale-test traps; test-recipes.md
updates the baseline to 38 pass / 1 fail / 0 skip (transaction-edit 8/8, swap 6/6; the one
failure is the unrelated navigation date-range test).
2026-06-03 07:20:49 -07:00
0f5650b73e refactor(ssr): collapse 5 manual-coding operation routes into edit-form-changed (heuristic 6)
The 5 manual-coding operations (vendor change, simple/advanced toggle, add row, remove
row, $/% toggle) each had their own route + handler, all doing "mutate form state ->
render-full-form". Fold them into the single edit-form-changed endpoint, which now
dispatches on an `op` form-param to the relevant pure apply-* mutation fn (apply-vendor-
changed / apply-toggle-mode / apply-new-account / apply-remove-account /
apply-toggle-amount-mode) then re-renders. A missing/unknown op (a plain dependent-field
change, e.g. account->location or amount->totals) just re-renders, as before.

- edit.clj: 6 handlers -> 1 dispatcher + 5 pure apply-* fns; markup posts to
  edit-form-changed with :hx-vals {:op "..."}.
- routes/transactions.cljc: remove the 5 now-unused route keys.
- e2e specs: retarget the vendor selector by op (div[hx-vals*="vendor-changed"]) and
  point the toggle-amount-mode / vendor response waits at edit-form-changed, since the
  old per-op route names are gone. (Behavioral assertions unchanged.)

Scorecard: manual-coding routes ~10 -> ~5 (operations now one dispatcher). Parity held:
swap spec 6/6, full suite 32 pass (Shared Location green; no new regression).
2026-06-03 07:07:52 -07:00
1d5a95196f docs(skill): add cookbook entry for de-faking a fixed-index row from explicit data
Captures the reusable pattern proven on simple-mode-fields*: render a known-index row
(accounts[0]) from explicit data with explicit field names (path->name2 equivalent) +
explicit error lookup, instead of faking a deep cursor. Pairs with the gotcha that
with-field-default mutates the cursor. Growth contract for the de-fake commit.
2026-06-03 06:34:43 -07:00
07159dc221 refactor(ssr): drop dead account-total / account-balance routes (heuristic 6)
The hx-select reference moved running totals into an inline #account-totals tbody that
refreshes via edit-form-changed, so nothing posts to ::route/account-total or
::route/account-balance anymore -- their route handlers were referenced only by their own
registrations. Remove the two handler fns, their route registrations, and the now-unused
route keys from routes/transactions.cljc. The pure account-total* / account-balance* fns
(used inline to render the totals) are untouched.

Scorecard: modal routes ~12 -> ~10. Full suite 31 pass / no regression.
2026-06-03 06:33:52 -07:00
57f3b63b6a refactor(ssr): de-fake simple-mode account cursor via explicit render (heuristic 1)
simple-mode-fields* rendered its single account row by rebinding the form cursor to a
synthetic MapCursor rooted at accounts[0] (faking a deep starting position). Replace that
with explicit-data rendering: account-field-name builds the exact field names the cursor
would produce at [:step-params :transaction/accounts 0 field] (via path->name2), and
account-field-errors reads errors from the same path -- no re-rooted cursor.

This is the render-fn rewrite the earlier with-field-default shortcut couldn't be (that
mutated the cursor and broke the simple-mode swap). Scorecard: faked cursor roots 2 -> 0
(both heuristic-1 items now clear for this modal). Parity held: swap spec 6/6 (its vendor
tests run in simple mode), Shared Location save green, full suite 31 pass / no regression.
2026-06-03 06:29:25 -07:00
a7ccdb12f3 docs(skill): record faked-cursor de-fake learning + Phase 2 scorecard progress
- gotchas.md: de-faking a cursor is not a drop-in -- with-field-default mutates the
  cursor (transact!) as a render side effect and broke the simple-mode swap; the de-fake
  belongs with the render-fn rewrite, verified against the swap spec.
- scorecard.md: append the Phase 2 (in-progress) Transaction Edit row -- no-cursor 1->0,
  LOC 1608->1555, parity held (swap 6/6 + Shared Location). Faked roots / snapshot /
  Selmer / route-collapse remain as the wholesale-rendering continuation of Phase 2.
2026-06-03 06:20:04 -07:00
32056bf396 refactor(ssr): delete dead transaction-account-row-no-cursor* twin (heuristic 1)
transaction-account-row-no-cursor* and its only helper account-field-name were
unreferenced anywhere in src/ or test/ -- the *-no-cursor* duplicate the plan targets
for removal. The live row renderer is the top-rooted cursor form transaction-account-row*
(driven by fc/cursor-map from the accounts cursor). Deleting the twin: no-cursor twins
1 -> 0, ~53 LOC removed. Swap spec stays 6/6.
2026-06-03 06:15:13 -07:00
69eed1f8a6 fix(ssr): strip UI-only :mode before transaction upsert (500 on advanced manual save)
The :manual save handler builds its tx-data from the wizard snapshot and stripped the
control fields :action and :amount-mode, but not :mode (simple/advanced) added by the
recent manual-coding work. manual-coding-section* emits step-params[mode] on every
render, so EVERY advanced manual save posted :mode "advanced" into :upsert-transaction
and 500'd with ":db.error/not-an-entity :mode". Strip :mode alongside :action so the
upsert only sees real schema attributes.

Also fix the e2e helper that masked this: selectAccountFromTypeahead poked the Alpine v2
internal `el.__x.$data`, which is undefined on Alpine v3 (this app loads alpinejs@3.x),
so it silently no-op'd and the account posted empty. Drive the typeahead via the real
Alpine v3 path (Alpine.$data + tippy dropdown + click), mirroring transaction-edit-swap.

Unmasks the previously-failing "Shared Location spread on save" test (was first in a
serial file, hiding 7 siblings). Verified: that test passes; transaction-edit-swap stays
6/6. Skill gotchas.md records the :mode-strip rule, the Alpine-v3 API requirement, and
the modal-won't-close diagnosis recipe.
2026-06-03 06:05:42 -07:00
ed3344438b test(e2e): make Playwright BASE_URL-overridable + record Phase 2 e2e baseline
- playwright.config.ts: honor BASE_URL env (and skip the auto-started webServer when
  set) so a server booted from a specific worktree on a non-default port can be tested
  without fighting over :3333.
- skill test-recipes.md: record the recipe for running e2e from a non-default worktree
  (in-process test server + reseed helper) and the measured baseline on the merged
  hx-select reference: swap-doctrine 6/6 green; transaction-edit.spec.ts has a
  pre-existing Shared-Location save failure that masks 7 via serial mode; full suite
  30 pass / 2 fail / 7 skip. Gate for the refactor = swap spec + REPL pure-fn checks.
2026-06-03 00:18:31 -07:00
bdb286ca71 feat(ssr): add Selmer dependency + Hiccup<->Selmer interop bridge (Phase 2 foundation)
The strangler foundation for migrating interactive SSR components from Hiccup to
Selmer (plain-HTML Alpine/HTMX attributes instead of mixed keyword/string encodings).

- project.clj: add [selmer "1.12.61"].
- auto-ap.ssr.selmer: render / render-str (selmer/render-file + string), hiccup->html
  (Hiccup -> string for {{ frag|safe }}), raw (wrap a rendered fragment for embedding
  in a Hiccup tree without double-escaping), render->hiccup.
- resources/templates/interop-smoke.html: proves render-file from the classpath and
  that plain-HTML alpine attrs (x-model, @keydown, tippy?.show()) pass through verbatim.
- selmer_test: 4 tests / 8 assertions covering both interop directions; all green.

Proven via REPL + tests: a Hiccup component renders inside a Selmer template, and a
Selmer fragment renders inside a Hiccup tree. Both valid during the transition.
2026-06-03 00:09:12 -07:00
3ecd115f76 docs(skill): distil ssr-form-migration skill from transaction-edit reference (Phase 1)
Capture the proven whole-form hx-select swap method as a reusable skill so every
later modal migration is cheaper and consistent. No app code changes.

- SKILL.md: the per-migration playbook (classify → baseline → characterize →
  consolidate render fns → templatize → wire HTMX → collapse routes → verify →
  commit → feed skill) + Growth contract + non-negotiables.
- reference/swap-doctrine.md: the four swap rules, focus invariant, Alpine-survives-
  swap hardening, target-selector strategy — worked from the real edit.clj swaps
  (memo no-request, account→location targeted cell, amount→totals sibling-tbody,
  vendor/mode/row whole-form). 0 OOB.
- reference/render-functions.md: explicit-data or top-rooted cursor; the MapCursor
  fake + transaction-account-row-no-cursor* twin as the smell to remove.
- reference/form-vs-wizard.md: classification + the data-driven session-backed
  (formtools SessionStorage) engine that replaces the snapshot round-trip + protocol.
- reference/selmer-conventions.md: STUB, validated in Phase 2.
- component-cookbook.md / gotchas.md / test-recipes.md / scorecard.md: seeded from
  what transaction-edit proves (7 cookbook entries, caret-survival + typeahead test
  recipes, scorecard baseline LOC 1608 / ~12 routes / 1 no-cursor twin / 2 faked
  roots / 0 OOB).

Scorecard (Transaction Edit baseline, before Phase 2): LOC 1608, routes ~12,
no-cursor twins 1, faked-cursor roots 2, snapshot merges ~75, OOB 0, mixed hx- 8.
2026-06-03 00:05:11 -07:00
246df6996e Merge branch 'integreat-render-hx-select' into integreat-execute-refactor 2026-06-02 23:58:36 -07:00
85aaf7b759 mcp repl fixes 2026-06-02 23:40:05 -07:00
3641846f70 Merge pull request 'docs: SSR rendering modernization rollout plan' (#12) from docs/ssr-rendering-modernization-plan into staging
Reviewed-on: #12
2026-06-02 23:26:45 -07:00
d360316590 docs: add swap-target selector strategy consideration
Note in 3.1 that targeted hx-select/hx-target swaps in repeated/nested
structures may want a consistent scheme -- semantic markup + data-attributes,
or a form-path->selector helper (mirroring cursors) -- instead of hand-minting
a unique id per element. Framed as a consideration for advanced cases, with a
Phase 5 task to settle the convention into the skill cookbook.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 23:17:52 -07:00
8215e6376d Merge pull request 'fix(ssr): require Apply for all date-range filters' (#13) from integreat-fix-other-dates into staging
Reviewed-on: #13
2026-06-02 22:42:49 -07:00
3759258ebe fix(ssr): require Apply for all date-range filters
Most grid pages auto-submitted their date-range filter on every change
event, which fired mid-typing and re-rendered the date inputs, breaking
manual date entry. Invoices and ledgers already gated date submission
behind an explicit Apply button; this brings the other ten pages in line.

- date-range component: stop `change` from the date inputs bubbling to
  the form (@change.stop) and always render the Apply button, so typed or
  picked dates submit only via the Apply button's `datesApplied` event.
  The All/Week/Month/Year presets and all other filters are unaffected.
- payments, invoice import, transactions, import batches, sales
  summaries, expected deposits, cash drawer shifts, refunds, tenders,
  sales orders: add `datesApplied` to the form hx-trigger.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 22:42:17 -07:00
0e02c489e0 docs: multi-step wizards use session-stored step state (Django formtools)
Replace the EDN snapshot + piecewise merge for multi-step wizards with per-step
form state stored in the session, combined only at the end -- the Django
formtools WizardView / SessionStorage model. Cite the inspiration and refs.

Adds rationale 2.4, reworks the engine snippet in 3.3 to thread session state
keyed by wizard-id (no snapshot, no merge), and updates goal 3, the Phase 6
engine tasks, the risk row, and Open decision 1 accordingly.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 22:09:40 -07:00
917b7f3857 docs: clarify cursors are fine; only faked positions are the smell
Reframe goal 2, the rationale (2.2), the render-function pattern (3.2), and
scorecard heuristic 1 so the target is top-rooted cursors. Cursors stay; what
we remove is faking a cursor to start deeper in the tree and the duplicate
*-no-cursor* variants that fakery forces.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 22:02:25 -07:00
a8d8a8d111 docs: make SSR migration plan self-contained and executable
Rewrite the plan to stand on its own: state the goals and target patterns
directly (illustrated with code snippets) instead of reconciling experimental
workstreams. Spell out every migration as concrete, checkboxed tasks an agent
can execute, with per-modal rationale and specifics.

Reorder so the first step distils the proven transaction-edit migration into a
ssr-form-migration skill (Phase 1), then trials that skill on the same modal as
its first test subject (Phase 2), then rolls out simplest-first with every
phase feeding the skill. Adds an explicit migration inventory, per-migration
playbook, quality scorecard, and test-first strategy.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 21:56:12 -07:00
360847fa58 docs: add SSR rendering modernization rollout plan
Synthesize three SSR refactor exercises into one low-risk, compounding
rollout plan: the render-whole-form HTMX swap doctrine, the critique-wizard
architecture simplification, and a Hiccup -> Selmer templating migration.

Includes a code-quality ratchet (per-migration scorecard), an explicit
test-first strategy with an e2e regression gate, simplest-first phasing, and
a self-reinforcing ssr-form-migration skill so each migration makes the next
cheaper.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 21:39:04 -07:00
55650c2dab Merge pull request 'refactor(charts): unify on Chart.js, remove Chartist' (#11) from integreat-unify-charts into staging
Reviewed-on: #11
2026-06-02 09:23:29 -07:00
19186097d5 fix(ssr): stop content-card forcing always-on scrollbars; add tmp/ scratch dir
content-card used `overflow-scroll`, which renders scrollbar tracks even
when the content fits — visible as superfluous bars around the admin chart
cards. Switch to `overflow-auto` so scrollbars only appear when content
genuinely overflows (e.g. wide data tables still scroll).

Also add a gitignored ./tmp/ scratch directory (tracked via .gitkeep) and
document in AGENTS.md that temp files belong there.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 09:16:16 -07:00
1f6395382d refactor(charts): unify on Chart.js, remove Chartist
The admin page was the only consumer of Chartist while the dashboard and
expense report already use Chart.js. Convert the admin "Growth in clients"
(bar) and "Changes by hour" (line) charts to Chart.js using the same
Alpine x-data/x-init canvas pattern as the dashboard, and drop the global
Chartist CSS/JS includes from the base page.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 07:55:47 -07:00
d52159637e fixes 2026-06-02 07:15:42 -07:00
3648597031 update 2026-06-02 07:14:37 -07:00
901d9eb508 date-choosing 2026-06-02 07:13:29 -07:00
569e52d1c1 Merge pull request 'feat(transactions): port manual bank-transaction import to SSR' (#9) from integreat-add-transaction-manual into staging
Reviewed-on: #9
2026-06-01 21:06:52 -07:00
482b4802ff Make swaps precise: drop no-op requests, swap only the affected field
Refine per-trigger granularity now that the swap target is explicit:

- Memo issues no request at all -- it affects nothing else, so its value just
  rides along in the form and is merged into the snapshot on save. (Changing the
  Location *value* likewise issues no request -- it never did; that cell's request
  is the account->location dependency.)
- Account select swaps only that row's Location cell (#account-location-<index> /
  #simple-account-location) instead of the whole form. Selecting an account only
  affects the valid Location options (computed from the posted account-id), so a
  precise cell swap is safe -- no snapshot dependency.

Account-structural changes (vendor, add/remove row, mode toggle, $/% radio) keep
swapping the whole form: their accounts+amount-mode state is interdependent and
round-trips through the single form-level snapshot hidden field, so a whole-form
swap is what keeps it consistent with zero OOB.

Update the memo test to assert it fires no request and keeps its value/caret.
Full e2e suite: 27 passed / 2 failed (same pre-existing, unrelated failures).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 20:12:22 -07:00
9cc3418b1b fix(review): apply autofix feedback
- Alphabetize the import.clj :require block (AGENTS.md Import Formatting).
- Remove unused imports (digest, strip) flagged by clj-kondo.
- Make the client-not-found classify-table test independent: it previously
  reused the bank-account-not-found input and added zero marginal coverage;
  now seeds an orphan bank account so only the client error fires.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 11:34:31 -07:00
a1098b28f8 feat(transactions): port manual bank-transaction import to SSR
Implement the SSR/alpine/htmx manual transaction import, wiring the
already-declared but unhandled ::external-import-page/parse/import routes.
Mirrors the SSR ledger import: paste the exact master-branch Yodlee
positional-column TSV, review parsed rows in an editable grid with per-row
error/warning badges, and import. Every master validation is preserved and
the existing import.transactions engine is reused unchanged
(via import.manual/import-batch), so core components are untouched.

- New ns auto-ap.ssr.transaction.import (page, paste/parse, editable grid,
  two-tier validation, import handler) + admin-only transactions Import nav.
- Two-tier validation: fixable problems (bad date/amount, unknown client or
  bank-account code, missing fields) are hard errors that block the whole
  batch; inherent skip-conditions (non-POSTED, before start-date/locked,
  already-imported) are warnings computed from the engine's own
  categorize-transaction so the grid preview matches the import result.
- Tests: failing-first Playwright e2e (e2e/transaction-import.spec.ts) plus
  unit/integration coverage (ssr/transaction/import_test.clj, 10 tests).
- Deterministic bank-account code in the e2e seed.

Plan: docs/plans/2026-06-01-001-feat-manual-transaction-import-ssr-plan.md

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 11:18:28 -07:00
5f1bb6db82 Whole-form hx-select swaps with zero out-of-band swaps
Replace the section-swap + OOB approach with uniform whole-form swaps,
eliminating both out-of-band swaps:

- Discrete edits (vendor, account, location, mode, add/remove row) now swap all
  of #wizard-form via hx-select. The active action/tab already round-trips
  (:action is in edit-form-schema and the tab x-data inits from it), so a
  whole-form swap re-creates the tab state from the server value and the active
  tab is preserved -- no #wizard-snapshot OOB needed, since the snapshot hidden
  field rides along inside the form.
- Move the totals into their own <tbody id="account-totals"> (new optional
  :footer-tbody param on data-grid-) so the amount field updates them with a
  plain targeted swap instead of an OOB swap of #total,#balance. The totals tbody
  is a sibling of the input rows, so the amount input is never replaced.
- Memo unchanged (hx-swap=none).

Net: 0 hx-select-oob, 0 morph. The focus invariant is unchanged -- the typed
field is never inside a region it swaps. Tab clicks stay Alpine (instant); only
the action value round-trips. Revert the now-unneeded #wizard-snapshot id.

Full e2e suite: 27 passed / 2 failed (same pre-existing, unrelated failures).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 11:01:37 -07:00
a2684bf5c1 Replace alpine-morph with targeted hx-select / OOB swaps
Drop the whole-form alpine-morph swap in favour of posting the whole form
but swapping back only what changed, never the input the user is editing --
so focus and caret survive a plain swap with no morph extension.

- Discrete changes (vendor, account, location, mode, add/remove row) swap the
  #manual-coding-section fragment via hx-select, plus an OOB refresh of the
  #wizard-snapshot hidden field so the round-tripped wizard state stays in sync
  (the snapshot lives at #wizard-form level, outside the swapped fragment, and
  the new/remove-account handlers read it).
- The amount field OOB-swaps only #total/#balance (hx-swap=none); memo posts
  with hx-swap=none. Neither input is ever replaced.
- Give the BALANCE cell a unique id (#balance) so the OOB selector is unambiguous.
- Remove the alpine-morph ext + @alpinejs/morph plugin and all the key/x-data
  re-init tricks they required. Rebuilding the fragment fresh makes vendor->account
  population and repeat vendor changes work without any keying.
- Rename e2e/transaction-edit-morph.spec.ts -> -swap.spec.ts; assertions unchanged
  (focus/caret preservation, vendor->account, repeat vendor changes all hold).

Full e2e suite: 27 passed / 2 failed (both pre-existing and unrelated -- the
legacy save-flow test and the date-range filter test).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 08:10:05 -07:00
cdb6bb6fe3 Whole-form htmx + Alpine morph for transaction edit
Re-render the entire #wizard-form on each field edit and swap with
hx-swap="morph" so the focused input keeps focus/caret/value while typing.

- Field-level routes return the full form and target #wizard-form
- Key state-owning wrappers (account rows, simple-mode wrapper, vendor
  typeahead) so server-driven value changes re-init across the morph
- Guard tippy/$refs access in typeahead against stale post-morph state
- Round-trip simple/advanced mode via step-params[mode]
- Add e2e/transaction-edit-morph.spec.ts covering focus/caret preservation,
  vendor->account population, and repeated vendor changes
- Seed a second vendor/account for test isolation

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 07:40:30 -07:00
b6649a3d1d fixes 2026-05-31 08:37:44 -07:00
38ae6f460f Cleanup of simple/advanced mode 2026-05-31 08:30:11 -07:00
e156d8bfd8 fixes vendor selection bug 2026-05-30 09:21:39 -07:00
5c2cf8a631 agent changes 2026-05-30 00:08:27 -07:00
b8a0e9c3dc merged. 2026-05-29 17:32:33 -07:00
9659164fdc instructions 2026-05-29 11:07:44 -07:00
8f0a474fa8 resources 2026-05-29 10:55:34 -07:00
6814cf1b15 better login page 2026-05-29 10:55:14 -07:00
3441ae63b4 fix: normalize account ref map to db/id in simple mode rendering
When a transaction is pre-coded, the snapshot stores :transaction-account/account
as a Datomic ref map {:db/id N} rather than a bare integer. simple-mode-fields*
and the simpleAccountId Alpine initializer both need the integer id, not the map,
to correctly populate the account typeahead value and the x-hx-val binding.
2026-05-28 00:10:15 -07:00
79ddda624a fix: change toggle-mode links from hx-get to hx-post so form data is submitted 2026-05-27 23:53:43 -07:00
cbb9bc750d fix: prefer step-params over snapshot for row-count and existing-row 2026-05-27 23:49:59 -07:00
a7ac7eae35 refactor: remove dead code and redundant assertions in simple/advanced mode tests 2026-05-27 23:44:18 -07:00
c6699dd05a test: complete AC17 fields-present assertions 2026-05-27 23:39:35 -07:00
1be83a01f5 test: add missing AC tests and fix AC numbering 2026-05-27 23:36:08 -07:00
ebd91f1911 test: strengthen assertions and add AC6 save round-trip test 2026-05-27 23:25:47 -07:00
c9a587a8c5 test: add tests for simple/advanced mode in transaction edit modal 2026-05-27 23:17:10 -07:00
9997d60de1 fix: handle absent :transaction/accounts in simple-mode-fields* (ValCursor crash) 2026-05-27 23:07:52 -07:00
06fb0ea067 feat: update edit-vendor-changed-handler to support simple/advanced mode 2026-05-27 22:03:13 -07:00
9a7d0b8b18 feat: add edit-wizard-toggle-mode-handler 2026-05-27 21:59:33 -07:00
70a3db9a64 fix: repair vendor-changed swap target and remove unused declare 2026-05-27 21:57:11 -07:00
4e22fb1d82 feat: extract manual-coding-section* with simple/advanced mode selection 2026-05-27 21:52:09 -07:00
a88dcf4122 fix: safe cursor indexing in simple-mode-fields* 2026-05-27 21:44:54 -07:00
00b5303c28 feat: add simple-mode-fields* for transaction edit modal 2026-05-27 21:40:04 -07:00
ab1a2c3368 feat: add edit-wizard-toggle-mode route 2026-05-27 21:35:21 -07:00
724b6d82f5 docs: add implementation plan for transaction edit simple/advanced mode 2026-05-27 21:33:17 -07:00
6500c44909 docs: fix spec inconsistencies from self-review 2026-05-27 21:10:20 -07:00
2e4152e3fc docs: add spec for transaction edit modal simple/advanced mode 2026-05-27 21:09:37 -07:00
6ce6a6e0c7 restored default. 2026-05-27 14:14:12 -07:00
17eebe5628 Add http-port override, login UI improvements, and dev tooling 2026-05-27 14:13:43 -07:00
e5a2d0bbba Simplify sysco line item allocation: use actual amounts, default unmatched to food cost (50000) 2026-05-27 13:22:29 -07:00
7db1e07512 new glimpse test. 2026-05-27 10:14:54 -07:00
df32100ca2 should fix invoices 2026-05-27 08:48:12 -07:00
daea729e8e Fixes logging issues. 2026-05-27 08:42:42 -07:00
de933699aa Merge branch 'integreat-assorted' into staging 2026-05-26 23:20:45 -07:00
4fca49bff0 fixes a number of issues 2026-05-26 23:20:31 -07:00
114 changed files with 8388 additions and 1693 deletions

View File

@@ -0,0 +1,177 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS

View File

@@ -0,0 +1,42 @@
---
name: frontend-design
description: Create distinctive, production-grade frontend interfaces with high design quality. Use this skill when the user asks to build web components, pages, artifacts, posters, or applications (examples include websites, landing pages, dashboards, React components, HTML/CSS layouts, or when styling/beautifying any web UI). Generates creative, polished code and UI design that avoids generic AI aesthetics.
license: Complete terms in LICENSE.txt
---
This skill guides creation of distinctive, production-grade frontend interfaces that avoid generic "AI slop" aesthetics. Implement real working code with exceptional attention to aesthetic details and creative choices.
The user provides frontend requirements: a component, page, application, or interface to build. They may include context about the purpose, audience, or technical constraints.
## Design Thinking
Before coding, understand the context and commit to a BOLD aesthetic direction:
- **Purpose**: What problem does this interface solve? Who uses it?
- **Tone**: Pick an extreme: brutally minimal, maximalist chaos, retro-futuristic, organic/natural, luxury/refined, playful/toy-like, editorial/magazine, brutalist/raw, art deco/geometric, soft/pastel, industrial/utilitarian, etc. There are so many flavors to choose from. Use these for inspiration but design one that is true to the aesthetic direction.
- **Constraints**: Technical requirements (framework, performance, accessibility).
- **Differentiation**: What makes this UNFORGETTABLE? What's the one thing someone will remember?
**CRITICAL**: Choose a clear conceptual direction and execute it with precision. Bold maximalism and refined minimalism both work - the key is intentionality, not intensity.
Then implement working code (HTML/CSS/JS, React, Vue, etc.) that is:
- Production-grade and functional
- Visually striking and memorable
- Cohesive with a clear aesthetic point-of-view
- Meticulously refined in every detail
## Frontend Aesthetics Guidelines
Focus on:
- **Typography**: Choose fonts that are beautiful, unique, and interesting. Avoid generic fonts like Arial and Inter; opt instead for distinctive choices that elevate the frontend's aesthetics; unexpected, characterful font choices. Pair a distinctive display font with a refined body font.
- **Color & Theme**: Commit to a cohesive aesthetic. Use CSS variables for consistency. Dominant colors with sharp accents outperform timid, evenly-distributed palettes.
- **Motion**: Use animations for effects and micro-interactions. Prioritize CSS-only solutions for HTML. Use Motion library for React when available. Focus on high-impact moments: one well-orchestrated page load with staggered reveals (animation-delay) creates more delight than scattered micro-interactions. Use scroll-triggering and hover states that surprise.
- **Spatial Composition**: Unexpected layouts. Asymmetry. Overlap. Diagonal flow. Grid-breaking elements. Generous negative space OR controlled density.
- **Backgrounds & Visual Details**: Create atmosphere and depth rather than defaulting to solid colors. Add contextual effects and textures that match the overall aesthetic. Apply creative forms like gradient meshes, noise textures, geometric patterns, layered transparencies, dramatic shadows, decorative borders, custom cursors, and grain overlays.
NEVER use generic AI-generated aesthetics like overused font families (Inter, Roboto, Arial, system fonts), cliched color schemes (particularly purple gradients on white backgrounds), predictable layouts and component patterns, and cookie-cutter design that lacks context-specific character.
Interpret creatively and make unexpected choices that feel genuinely designed for the context. No design should be the same. Vary between light and dark themes, different fonts, different aesthetics. NEVER converge on common choices (Space Grotesk, for example) across generations.
**IMPORTANT**: Match implementation complexity to the aesthetic vision. Maximalist designs need elaborate code with extensive animations and effects. Minimalist or refined designs need restraint, precision, and careful attention to spacing, typography, and subtle details. Elegance comes from executing the vision well.
Remember: Claude is capable of extraordinary creative work. Don't hold back, show what can truly be created when thinking outside the box and committing fully to a distinctive vision.

View File

@@ -0,0 +1 @@
../../.agents/skills/agent-browser

View File

@@ -0,0 +1,122 @@
---
name: ssr-form-migration
description: Migrate an SSR form or wizard modal to the whole-form HTMX swap doctrine, top-rooted render functions, the data-driven session-backed wizard engine, and (where it helps) Selmer templates. Use when simplifying a server-rendered form/wizard modal in this codebase, removing EDN-snapshot round-trips, deleting *-no-cursor* duplicate render fns, collapsing per-interaction routes, or replacing the multi-step wizard protocol machinery.
---
# SSR Form & Wizard Migration
A repeatable method for making a server-rendered form/wizard modal **simpler** without
changing user-facing behavior. Distilled from the first proven migration — the
`transaction/edit.clj` modal, which already runs on the whole-form `hx-select` swap
approach with **zero out-of-band swaps**. Every migration *reads this skill first* and
*extends it last* (the Growth contract below). If migration N+1 is not easier than N,
the skill-update step was skipped — treat that as a bug.
The four patterns every migration moves code toward live in `reference/`:
- `reference/swap-doctrine.md` — the four-rule HTMX swap priority order + the focus
invariant + Alpine-survives-swap hardening + target-selector strategy.
- `reference/render-functions.md` — one render fn per component, taking explicit data
**or a top-rooted cursor**; no faked cursor positions, no `*-no-cursor*` twins.
- `reference/form-vs-wizard.md` — single-step → plain form; multi-step → data-driven
engine with **per-step state in the Ring session** (the Django `formtools` model).
- `reference/selmer-conventions.md` — plain-HTML attributes via Selmer, the
Hiccup↔Selmer interop bridge, include/block patterns.
Growing cookbooks (append every migration):
`component-cookbook.md`, `gotchas.md`, `test-recipes.md`, `scorecard.md`.
---
## The per-migration playbook
Run this loop for each modal. The phase notes in the migration plan list only what is
*specific* to a modal; this loop is the constant.
1. **Read the skill.** Skim `reference/` and note which `component-cookbook.md`
entries and `gotchas.md` you can reuse. Start from the cookbook, not a blank file.
2. **Classify** (`reference/form-vs-wizard.md`).
- Single logical step (even with a `?mode=` toggle or add/remove rows) → **plain
form**: no server-side wizard state, no snapshot, no protocol.
- Genuinely multiple steps the user advances through → **wizard**: the data-driven
engine + per-step session storage.
- When in doubt, it's a form.
3. **Baseline the scorecard** (`scorecard.md`, heuristics in §6 of the plan). Record
before-numbers with cheap tools:
```bash
F=src/clj/auto_ap/ssr/<modal>.clj
wc -l $F # LOC (heuristic 4)
grep -c 'defn.*-no-cursor' $F # *-no-cursor* twins (heuristic 1)
grep -cE 'with-cursor|MapCursor\.' $F # faked cursor re-roots (heuristic 1)
grep -c 'hx-swap-oob' $F # OOB swaps (heuristic 7)
grep -cE '"hx-[a-z]' $F # mixed string hx- attrs (heuristic 8)
# route count: count this modal's entries in src/cljc/auto_ap/routes/*.cljc
```
4. **Characterize behavior (test-first).** Write or confirm a Playwright spec that
captures *current* behavior before you touch anything — focus/caret survival across
swaps, each field round-trip, validation errors, and the real save. This spec is the
parity contract; it must stay green through every commit. See `test-recipes.md`.
5. **Consolidate render functions** (`reference/render-functions.md`). Make each render
fn take explicit data or a **top-rooted cursor**. Delete `*-no-cursor*` duplicates
and any `with-cursor`/`MapCursor` rebind that fakes a deep starting position
(heuristics 1, 2). Using a cursor is fine; faking where it *starts* is not.
6. **Templatize in Selmer** (`reference/selmer-conventions.md`) where the component is
interactive/attribute-heavy. Reuse cookbook bits; add new ones back (heuristics 5, 8).
Static markup may stay Hiccup — Selmer scope is hybrid (Open decision 2).
7. **Wire HTMX per the swap doctrine** (`reference/swap-doctrine.md`). Keep the focus
invariant intact. No OOB except a genuinely disjoint region, documented (heuristic 7).
8. **Collapse routes** to 2 (`GET` open, `POST` submit) — `+1` for an add-row endpoint,
`+1` for the single `*-form-changed` whole-form re-render endpoint (heuristic 6).
9. **Verify.** Modal e2e green + full e2e suite at-or-above baseline; assert DB
mutations by querying Datomic, not markup; REPL-check the pure render/data fns.
Re-measure the scorecard — **no metric may regress for the touched modal** without a
written exception in `gotchas.md`.
10. **Commit** one reversible feature commit. The message includes the scorecard delta
and the reused/new cookbook entries.
11. **Feed the skill** (the Growth contract). *Not optional.*
---
## Growth contract — the last task of every migration
- Converted a component? → add its before/after to `component-cookbook.md`.
- Hit a surprise? → one entry in `gotchas.md`.
- Found a test pattern? → `test-recipes.md`.
- Playbook step missing or wrong? → fix this `SKILL.md`.
- Measured the scorecard? → append the row to `scorecard.md`.
**Success signal:** each migration reuses more cookbook entries and starts from a better
scorecard baseline than the previous one.
---
## Non-negotiables
- **Focus invariant:** the input the user is typing in is *never* inside the region its
own request swaps. Violating this drops the caret. (Proven by the
`transaction-edit-swap.spec.ts` caret tests.)
- **No new OOB swaps.** If tempted to OOB something inside the same feature, restructure
the DOM so the dependent element shares an ancestor with the trigger and use an
ordinary swap (e.g. totals in a sibling `<tbody>`).
- **Behavior parity is proven by tests, not by reading.** The full e2e suite stays green
after every migration.
- **Don't game the heuristics.** They're directional evidence paired with the e2e parity
gate; review the trend, not single numbers.
## Project conventions that bite (see `gotchas.md`)
- Edit Clojure with the clojure-mcp tools (`clojure_edit`, `clojure_edit_replace_sexp`),
not the raw file editor. `clj-paren-repair` then `lein cljfmt fix` when a file won't
compile.
- Run tests via the `clojure-eval` skill / `clj-nrepl-eval -p PORT`, not `lein test`.
- Temp files go in `./tmp/`.

View File

@@ -0,0 +1,182 @@
# Component cookbook
GROWS every migration. Each entry: what it is, the swap rule it uses, and the canonical
snippet. Reuse these before writing anything new; the success signal is *more reuse each
migration*.
Seeded from `transaction/edit.clj` (Hiccup form — Selmer versions land in Phase 2).
---
## typeahead (account / vendor) — Alpine + tippy, survives swaps
Used for account and vendor selection. Click-to-select (not a live text caret), so a
whole-form swap on change is safe. Null-guard `tippy?`/`$refs.input?`.
```clojure
(defn account-typeahead* [{:keys [name value client-id x-model]}]
[:div.flex.flex-col
(com/typeahead {:name name
:placeholder "Search..."
:url (hu/url (bidi/path-for ssr-routes/only-routes :account-search)
(cond-> {:purpose "transaction"} client-id (assoc :client-id client-id)))
:id name
:x-model x-model ; binds selected value into the row's Alpine scope
:value value
:content-fn (fn [v] (:account/name (d-accounts/clientize ... v client-id)))})])
```
Reuse note: `:x-model` lets the *parent* row read the selected id (e.g. `accountId`) to
gate a targeted location swap. See account-row.
## account-row — cursor render fn + per-row targeted location swap + whole-form remove
The canonical "row in a repeated grid" pattern. One render fn, top-rooted cursor.
- account typeahead binds `accountId` into row Alpine scope;
- **location cell** swaps *only itself* (`#account-location-<index>`) on `changed`
(swap-doctrine Rule 2);
- **amount cell** swaps *only* `#account-totals` (Rule 4, sibling tbody);
- **remove** swaps the whole form (Rule 3).
```clojure
(defn transaction-account-row* [{:keys [value client-id amount-mode index]}]
(com/data-grid-row
(-> {:class "account-row" :id (str "account-row-" index)
:x-data (hx/json {:show ... :accountId (fc/field-value (:transaction-account/account value))})
:data-key "show" :x-ref "p"}
hx/alpine-mount-then-appear)
(fc/with-field :db/id (com/hidden {:name (fc/field-name) :value (fc/field-value)}))
(fc/with-field :transaction-account/account
(com/data-grid-cell {} (com/validated-field {:errors (fc/field-errors)}
(account-typeahead* {:value (fc/field-value) :client-id client-id
:name (fc/field-name) :x-model "accountId"}))))
(fc/with-field :transaction-account/location
(com/data-grid-cell {:id (str "account-location-" index)} ...Rule 2 targeted swap...))
(fc/with-field :transaction-account/amount
(com/data-grid-cell {} ...Rule 4 totals swap...))
(com/data-grid-cell {:class "align-top"} ...Rule 3 whole-form remove...)))
```
TODO Phase 2: drop the `transaction-account-row-no-cursor*` twin; this is the only kept form.
## totals in a sibling `<tbody>` — Rule 4 instead of OOB
Running totals live in their own `<tbody id="account-totals">`, a sibling of the
input-bearing rows, so an amount edit refreshes them with a plain targeted swap and never
replaces the amount input (caret survives).
```clojure
(com/data-grid
{:footer-tbody
[:tbody {:id "account-totals"}
(com/data-grid-row {:class "account-total-row"} ... (account-total* request) ...)
(com/data-grid-row {:class "account-balance-row"} ... (account-balance* request) ...)]}
...input rows...)
```
## money-input / text-input amount field — Rule 4 targeted totals swap
```clojure
(com/money-input
{:name (fc/field-name) :id (str "account-amount-" index) :class "w-16 account-amount-field"
:hx-post (bidi/path-for ssr-routes/only-routes ::route/edit-form-changed)
:hx-target "#account-totals" :hx-select "#account-totals" :hx-swap "outerHTML"
:hx-trigger "keyup changed delay:300ms" :hx-include "closest form"})
```
`%` mode swaps to `com/text-input {:type "number" :step "0.01"}` with the same swap attrs.
## memo field — Rule 1, no request
```clojure
(com/text-input {:value (fc/field-value) :name (fc/field-name) :id "edit-memo"
:placeholder "Optional note"}) ; no hx-* — rides along to save
```
## location-select — first Selmer-migrated component (validated)
The account row's location `<select>`, rendered from a Selmer template instead of
`com/select`. The first interactive modal component off Hiccup; proves the render-file
path + interop bridge on real, e2e-covered markup (swap 6/6, transaction-edit 8/8).
```clojure
;; templates/components/location-select.html — plain HTML, {% for %} + {% if selected %}
(defn location-select* [{:keys [name client-locations value ...]}]
(let [options (cond ...) ; [[value label] ...]
selected (or value (ffirst options))
classes (str/join " " (conj (vec inputs/default-input-classes) "w-full"))]
(sel/render->hiccup "templates/components/location-select.html"
{:name name :classes classes
:options (for [[v l] options] {:value v :label l :selected (= v selected)})})))
```
Reuse: pass `inputs/default-input-classes` in (don't hard-code); embed via
`render->hiccup` so it drops into the still-Hiccup row. See `selmer-conventions.md`.
## fixed-index row from explicit data — de-faking a deep cursor
When a row always lives at a known index (e.g. simple mode renders exactly `accounts[0]`),
render it from **explicit data with explicit field names** instead of faking a cursor
rooted there. Build the name the same way the cursor would (`path->name2`) and read errors
from the same path — no `with-cursor`/`MapCursor` rebind, no `with-field-default` (which
*mutates* the cursor and breaks swap behavior, see `gotchas.md`).
```clojure
(defn- account-field-name [index field] ; == path->name2 for this path
(str "step-params[transaction/accounts][" index "]["
(if (keyword? field)
(str (when (namespace field) (str (namespace field) "/")) (name field))
field) "]"))
(defn- account-field-errors [index field]
(when (bound? #'fc/*form-errors*)
(get-in fc/*form-errors* [:step-params :transaction/accounts index field])))
;; render the row directly -- no fc/with-field / fc/with-cursor wrappers
[:span
(com/hidden {:name (account-field-name 0 :db/id) :value row-id})
(com/validated-field {:errors (account-field-errors 0 :transaction-account/account)}
(account-typeahead* {:name (account-field-name 0 :transaction-account/account) ...}))
...]
```
Verify byte-parity against the cursor version (the swap spec's simple-mode tests catch
divergence). Scorecard heuristic 1: faked roots → 0.
## mode toggle ($/% radio, simple/advanced link) — Rule 3, whole-form swap
```clojure
(com/radio-card {:options [{:value "$" :content "$"} {:value "%" :content "%"}]
:value amount-mode :name "step-params[amount-mode]"
:hx-post (bidi/path-for ssr-routes/only-routes ::route/toggle-amount-mode)
:hx-target "#wizard-form" :hx-select "#wizard-form" :hx-swap "outerHTML"
:hx-include "closest form"})
```
TODO Phase 2: the simple/advanced toggle becomes a `?mode=` re-render (plain form), not a
dedicated route.
---
## The Selmer component library (`auto-ap.ssr.components.selmer` / `sc`) — Phase 2-final
Every shared component the modal renders through is now a thin Clojure wrapper over a
partial under `resources/templates/components/`. **Reuse these before reaching for the
Hiccup `com/*` versions in a migrated modal.** Each wrapper builds a context (reusing the
real class helpers so output matches modulo Tailwind order) and renders its own partial via
the interop bridge; dynamic HTMX/Alpine attrs go through `sc/attrs->str`
`{{ attrs|safe }}`. See `selmer-conventions.md` for the mechanics.
| Wrapper | Partial | Notes |
|---------|---------|-------|
| `sc/hidden` / `sc/text-input` / `sc/money-input` | `hidden`/`text-input`/`money-input`.html | leaf inputs; class via `inputs/default-input-classes` + `use-size` |
| `sc/validated-field` | `validated-field.html` | label + body + always-present error `<p>`; pass-through attrs land on the wrapping div (the per-row location cell hangs its swap wiring here) |
| `sc/button` / `sc/a-button` / `sc/a-icon-button` | `button`/`a-button`/`a-icon-button`.html | spinner via `{% include "spinner.html" %}`; class via `btn/bg-colors` |
| `sc/badge` / `sc/link` | `badge`/`link`.html | |
| `sc/button-group` / `sc/button-group-button` | `button-group(+button).html` | the group does **not** mutate children's classes (the Hiccup `group-` added rounded-l/r) — add rounding in the caller/template (tabs do) |
| `sc/radio-card` | `radio-card.html` | reproduces the `select-keys [:hx-post :hx-target :hx-swap :hx-include :hx-trigger]` filter (drops `:hx-vals`/`:hx-select`) **and** the dangling-`[:h3]` quirk: only the `<ul>` renders |
| `sc/data-grid` (+ `-header`/`-row`/`-cell`) | `data-grid*.html` | table shell + optional `footer-tbody` (the swappable totals tbody) |
| `sc/typeahead` | `typeahead.html` | full Alpine + tippy; resolves `{value,label}` server-side via `content-fn`; every `tippy?.` null-guard preserved; hidden posting `<input>` with `:value="value.value"` + the `x-init` watcher |
| `sc/modal` | `modal.html` | the `@click.outside="open=false"` wrapper |
| SVGs | `spinner`/`svg-x`/`svg-external-link`/`svg-drop-down`.html | static, `{% include %}`d so the markup isn't duplicated |
Modal-specific structure lives under `resources/templates/transaction-edit/`
(`edit-form`, `edit-modal`, `links-body`, `manual-coding`, `simple-mode`, `account-totals`,
`details-panel`, the four match panels, `transitioner`). The render fns in `edit.clj`
gather data, call `sc/*`, and interpolate the fragments into these layout templates as
`{{ frag|safe }}`. **Verify each wrapper by class-set equality + e2e, never byte-parity**
(`hh/add-class` is set-based, so class order differs from the Hiccup output).

View File

@@ -0,0 +1,146 @@
# Forms vs. wizards (and the data-driven wizard engine)
## Classify first
| Signal | Classification |
|--------|----------------|
| One logical step — even with a `?mode=` toggle, $/% radio, or add/remove rows | **plain form** |
| The user genuinely advances through ordered steps, each validated before the next | **wizard** |
| In doubt | **form** |
Most "wizards" in this codebase are single-step forms wearing wizard costumes: they
implement the multi-step protocol (`mm/ModalWizardStep` + friends), serialize an EDN
snapshot into hidden fields, and register 1020 stacked-middleware routes — all for one
step. That is pure overhead to delete.
> **Done — Transaction Edit is now a plain form.** `LinksStep`/`EditWizard` and all `mm/*`
> usage were deleted from `transaction/edit.clj`; the worked example below is realized, not
> aspirational. See "Single-step → plain form (realized)".
## The machinery being replaced
The old shape (kept here as the "before"):
```clojure
(defrecord LinksStep [linear-wizard]
mm/ModalWizardStep
(step-name [_] "Transaction Actions")
(step-key [_] :links)
(edit-path [_ _] [])
(step-schema [_] (mm/form-schema linear-wizard))
(render-step [this {{:keys [snapshot step-params]} :multi-form-state :as request}] ...))
```
…plus the snapshot round-trip: the whole accumulating form state is serialized to hidden
fields (custom EDN readers), then rebuilt every request by merging the posted pieces back
into the snapshot (`:multi-form-state :snapshot` is read ~75× in `edit.clj`). The
serialization needs custom readers, the merge is error-prone, and the payload grows each
step.
---
## Single-step → plain form
Two routes: `GET` (render) and `POST` (validate + save). State is plain form fields + an
entity id. No snapshot, no server state, no protocol.
```clojure
{::route/edit (fn [req] (html-response (render-edit-form {:entity (get-entity req)})))
::route/edit-submit (fn [req] (validate-and-save req))}
```
A `?mode=` toggle is just the `GET` re-rendering with a different query param — still a
plain form. An add-row interaction is one extra `POST` that appends a fresh row and
re-renders (the `+1` route).
### Single-step → plain form (realized: Transaction Edit)
What replacing the wizard actually looked like, end to end:
1. **Delete the records + middleware.** `EditWizard`/`LinksStep`, `mm/open-wizard-handler`,
`mm/next-handler`, `mm/submit-handler`, `mm/wrap-wizard`, `mm/wrap-decode-multi-form-state`,
and the `edit-wizard-navigate` route all go. `render-step` becomes a plain `render-form`.
2. **Rename the fields off `step-params[...]`.** Field names are now the schema path
directly (`(path->name2 :transaction/accounts 0 :transaction-account/account)`
`transaction/accounts[0][transaction-account/account]`). They decode straight into the
form schema via the unchanged `wrap-nested-form-params` + `mc/decode` — no two-key
snapshot/step-params decode. **Strip stray keys after decode** (`select-keys` to the
schema's keys) or a non-schema input like the tab group's `method` hidden 500s the save
(see `gotchas.md`).
3. **Flat state.** `wrap-derive-state` builds a plain `{:snapshot :edit-path :step-params}`
map (not the `MultiStepFormState` record): `entity-only` fields from the entity, editable
fields from the live posted form (absent = cleared). The ~34 `:snapshot` reads keep
working.
4. **Validation/error flow without `wrap-ensure-step`.** Reuse the generic
`wrap-form-4xx-2` directly: `(-> submit-edit (wrap-form-4xx-2 render-form-response) …)`.
`submit-edit` runs `assert-schema` then dispatches the save; on a throw, `wrap-form-4xx-2`
re-renders the whole form with `:form-errors` keyed by schema paths. A `*errors*` dynamic
var (bound by `render-form`) replaces the form-cursor's `*form-errors*` for field lookups.
5. **Routes shrink to** `edit-wizard` (GET open), `edit-submit` (POST), `edit-form-changed`
(POST whole-form re-render for dependent changes), `location-select` (GET),
`unlink-payment` (POST).
---
## Genuinely multi-step → data-driven engine with session-stored step state
> **Inspiration — Django `formtools` `WizardView`.** Django's wizard does *not* round-trip
> a serialized blob of the whole form through the page. Each step's validated data is
> written to a **storage backend (the user session by default)** under that step's key,
> and the steps are combined only at the very end via `get_all_cleaned_data()`. We adopt
> the same model: **replace the EDN snapshot + piecewise merging with per-step form state
> stored in the Ring session.** A step writes its own data under its own key; nothing is
> merged into a snapshot and nothing about other steps rides through the form.
> Refs: `formtools.wizard.views.WizardView`, `SessionStorage`, `get_all_cleaned_data()`
> (https://django-formtools.readthedocs.io/en/latest/wizard.html).
A wizard is **data**:
```clojure
(def vendor-wizard-config
{:steps [{:key :info :schema info-schema :fields [...] :render render-info-step
:next (fn [data] :terms)}
{:key :terms :schema terms-schema :fields [...] :render render-terms-step
:next (fn [data] :done)}]
:init-fn (fn [req] {...})
:submit-route "/admin/vendor/wizard/submit"
:done-fn (fn [all-data req] (save! all-data) (html-response "Saved"))})
```
with a tiny engine (no protocols) whose state lives **in the session**, keyed by a wizard
instance id, each step's data under its own step key — the formtools `SessionStorage`
model. No snapshot, no custom EDN readers, no merge-into-snapshot:
```clojure
;; Storage backed by the Ring session. Path: [:wizards <wizard-id> :step-data <step-key>]
(defn create-wizard! [session config]
(let [id (str (java.util.UUID/randomUUID))]
[id (assoc-in session [:wizards id]
{:current-step (-> config :steps first :key) :step-data {}})]))
(defn put-step [session id k data] (assoc-in session [:wizards id :step-data k] data)) ; replace, not merge
(defn set-step [session id k] (assoc-in session [:wizards id :current-step] k))
(defn get-all [session id] (->> (get-in session [:wizards id :step-data]) vals (apply merge)))
(defn forget [session id] (update session :wizards dissoc id))
```
The render emits only a **reference token** (`wizard-id`, `current-step`) in the form —
never the form's state. The submit handler validates the posted step, `put-step`s it,
computes `:next`, and either advances (`set-step`) or finishes (`get-all` + `:done-fn` +
`forget`). Every fn returns the updated session for the handler to thread into the Ring
response (`(assoc resp :session session')`).
**Two routes per wizard:** open (`partial open-wizard config`) and submit
(`partial handle-step-submit config`). State is namespaced by `wizard-id` inside the
session, so multiple in-flight wizards (and browser tabs) don't collide, and it is
discarded on completion (`forget`).
### Storage lifetime (Open decision 1)
State lives in the Ring session, scoped to true multi-step wizards (plain forms hold
none). Lifetime follows the session; `forget` on completion prevents session bloat. For
long-lived wizards, confirm the session backend (in-memory vs. durable) is acceptable or
pick a durable store. **This engine is built in Phase 6** (Transaction Rule) — until then
this file describes the target; validate `components/wizard_state.clj` +
`components/wizard2.clj` against it when they land, and update this doc from the real
implementation.

View File

@@ -0,0 +1,199 @@
# Gotchas
GROWS every migration. One entry per surprise. Also the home for any **written exception**
to the scorecard ratchet (a metric that regressed for a documented reason).
---
## Stale `$refs` / `tippy` after a swap
A whole-form swap can run an Alpine event handler *before* the component re-initialises,
so a handler that dereferences `$refs.input.__x_tippy` or calls `tippy.show()` throws.
**Always null-guard:** `$refs.input?.__x_tippy?.hide()`, `tippy?.show()`. The
`transaction-edit-swap.spec.ts` `trackErrors()` helper fails the test on any `pageerror`
or `console.error`, which is exactly how a stale-ref throw surfaces.
## Let the server value win — don't preserve Alpine state across a server-driven change
When a server change should update a component (e.g. choosing a vendor sets its default
account), rebuild that section fresh on the swap so the server-provided value lands
without keying tricks. The bug this prevents: "changing the vendor a *second* time doesn't
update the account" because preserved Alpine state shadowed the new server value. If you
*must* preserve a component, key it by value so a change forces re-init:
`(assoc attrs :key (str id "--" current-value))`.
## Focus dies if the typed input is inside its own swapped region
The single most important invariant. Amount field → swap a sibling tbody, not the row.
Memo → swap nothing. If a caret test (`sameNode`) fails, the input is in its own swap
region — re-target to a sibling/ancestor that excludes it.
## Faked cursors breed duplicate render fns
A `with-cursor`/`MapCursor` re-root to fake a deep start forces a `*-no-cursor*` twin.
Removing the fake lets you delete the twin. Don't "fix" a faked cursor in place — top-root
it and collapse to one render fn. (See `render-functions.md`.)
## Edit Clojure with clojure-mcp tools, not the file editor
`clojure_edit` / `clojure_edit_replace_sexp`. If a file won't compile: `clj-paren-repair`
the file, then retry; if still broken, `lein cljfmt check`. Run tests via `clojure-eval` /
`clj-nrepl-eval -p PORT`, never `lein test` (slow, last resort).
## Solr/typeahead in tests
Account/vendor search is backed by Solr, unavailable in tests. To drive a typeahead in
e2e: type under the 3-char threshold, then inject a result into Alpine state
(`Alpine.$data(el).elements = [{value, label}]`) and click it — the real click handler,
`tippy.hide()`, Alpine reactivity, and the HTMX swap all run as in production. Entity ids
come from `GET /test-info`.
---
## UI-only control fields must be stripped before a Datomic upsert
The wizard snapshot/step-params carry UI control fields that are **not** schema
attributes — `:action`, `:amount-mode`, and (added by the simple/advanced work) `:mode`.
The `:manual` save handler stripped `:action`/`:amount-mode` but not `:mode`, so every
*advanced* manual save passed `:mode "advanced"` into `:upsert-transaction` and 500'd with
`:db.error/not-an-entity :mode`. Lesson: when a save derives its tx-data from the form
snapshot, **strip every non-schema control key** before transacting. The session-backed
wizard engine (Phase 6) avoids this class of bug by storing per-step *validated* data
only — UI control fields never enter the combined data. This was a real production bug
surfaced by the e2e gate, not a test artifact.
## E2E helpers must use the Alpine **v3** API, not the v2 `__x` internal
The app loads Alpine v3 (`cdn.jsdelivr.net/npm/alpinejs@3.x.x`). The v2 internal
`el.__x.$data` is **gone**`el.__x` is `undefined`, so any helper that pokes it silently
no-ops. A stale `selectAccountFromTypeahead` did this and left the posted account empty
(account-controlled by `x-model`, so the raw DOM `.value` you set is overwritten from
Alpine's empty state). Drive components the real way instead: `window.Alpine.$data(el)`,
open the tippy dropdown, inject `elements`, click the result — exactly as
`transaction-edit-swap.spec.ts` does. Probe with
`{ hasLegacy__x: !!el.__x, hasAlpineData: !!window.Alpine.$data(el) }`.
## Diagnosing a "modal won't close after save"
The edit modal closes on an `hx-trigger: modalclose` from a *successful* save; a
validation failure re-renders the `#wizard-form` (200), and a server exception returns 500
(caught by `wrap-error`). To find which: capture POST responses in Playwright
(`page.on('response', …)`), read the `edit-submit` body — a `<form id="wizard-form">` means
validation re-render; a `#error {…}` stack means a 500. Then serialize the form right
before save (`new FormData(document.querySelector('#wizard-form'))`) to see exactly what
posts. This is how the `:mode` 500 and the empty-account bugs above were isolated.
## De-faking a cursor is not a drop-in — `with-field-default` mutates
Tempting fix for a faked deep cursor (`with-cursor` + synthetic `MapCursor` at index 0):
replace it with `(fc/with-field-default 0 {})` to advance naturally. **It broke the
simple-mode swap** (`transaction-edit-swap` test 1 threw). `with-field-default` calls
`cursor/transact!` — it *mutates the form cursor* (assoc-ing the default row) as a render
side effect, which changes simple-mode behavior. The read-only synthetic `MapCursor` did
not. Lesson: removing a faked cursor on these modals is **not** a one-liner — it's part of
the larger render-fn extraction (render the row from explicit data, construct field names
directly, look up errors explicitly), done when the simple/advanced rows are reworked into
pure render fns / Selmer. Don't swap one cursor primitive for another and assume parity;
verify against the swap spec, and expect the de-fake to come with the render-fn rewrite.
## Snapshot operations read stale state and drop live form values (heuristic 2)
The whole-form operation handlers (`apply-new-account`, `apply-remove-account`,
`apply-toggle-amount-mode`) rebuild the account rows from the **decoded `:snapshot`** (the
hidden EDN field), not from the live posted `:step-params`. So any value the user has typed
but that hasn't been re-serialised into the snapshot yet — e.g. an amount typed right
before clicking "New account" — is **silently lost** when the operation re-renders. This is
the snapshot round-trip fragility the migration removes (heuristic 2: → 0 merges; state
should ride in the form, not a parallel snapshot). It bit the percentage-split e2e: typing
50% then adding a row reverted the first row to its snapshot value, yielding a 66.67/33.33
split. Two ways it shows up and how to handle until the snapshot is gone:
**Fixed (Stage 1):** the operation handlers read the live `:step-params` rows (already
schema-decoded by `mm/wrap-wizard`) so typed values survive add/remove/toggle.
**Done (Stage 2 — the snapshot round-trip is gone).** The EDN `snapshot` hidden field +
custom readers + `merge-multi-form-state` are removed. A `db/id` hidden rides in the form;
`wrap-derive-state` rebuilds `:multi-form-state` per request from `entity step-params`,
and `EditWizard.render-wizard` renders a plain form (no snapshot/edit-path/current-step
hiddens). The ~34 `:snapshot` reads still work — `:snapshot` is now a derived map, not a
round-tripped blob.
**Trap that cost hours — derive `entity step-params` correctly.** First cut was
`(merge base step-params)`. Bug: `base` always carries the entity's *persisted* accounts,
so after the user removes every row (step-params has no accounts key) the merge falls back
to base → the persisted accounts **resurrect** on the next operation. Fix: editable fields
(accounts, vendor, memo, approval, action, mode, amount-mode) come **only** from the live
form (absent = cleared); only entity-only fields (`db/id`, client, amount, description,
status, type) come from the entity. Lesson: with a posted form, "field absent" means
*cleared*, not "use the persisted value" — never merge the entity's editable fields back in.
**Verify the snapshot removal on a FRESH server, and don't trust a long-lived in-process
test server.** Protocol/defrecord (`EditWizard.render-wizard`) and middleware reloads do
**not** fully take in a running REPL — the server kept rendering the old snapshot field
after `:reload`, and an in-process server that isn't reseeded between `npx playwright`
invocations accumulates state that makes order-dependent tests flake. Both produced hours
of phantom failures. Restart the REPL clean (or reseed) before trusting an e2e result; CI
boots a fresh server per run, so the fresh-server number (38 pass / 1 unrelated) is the real one.
## Characterization tests rot against table order and removed wizard chrome
Two stale-test traps surfaced once the masking failure was fixed (a `mode: 'serial'` file
hides every test after the first failure, so fixing one unmasks the next):
- **Hard-coded amounts per table row index** (`openEditModal(page, 3)` then
`expect(amount).toBeCloseTo(400)`) break because same-date seed transactions have no
pinned row order. Read the actual value (e.g. the grid's `.account-grand-total-row`)
instead of hard-coding.
- **Helpers that navigate the old multi-step wizard** (`click('button:has-text("Transaction
Actions")')`) hang once the modal is single-page. Drop the navigation; the action tabs
are present immediately.
## Flat decode leaks stray form fields into the saved entity (the `method` 500)
Dropping the wizard's `step-params[...]` field-name prefix and decoding posted params
**straight into the form schema** means the decode now captures **every** posted field, not
just the namespaced ones. A single stray field breaks the save:
- The tab switcher is `(com/button-group {:name "method"} …)`, which emits
`<input type="hidden" name="method">`. Under the wizard, `method` lived *outside*
`step-params[...]` so it never entered the decoded map. After the rename it decodes to
`:method ""` (malli `:map` is open and passes unknown keys), rides into `snapshot` →
`tx-data`, and `:upsert-transaction` rejects it → **HTTP 500 on save**.
- Symptom: the save POST fires (confirm with a `println` in the submit handler) but the
modal never closes, because the 500 trips `htmx:response-error`. The server error may go
to mulog, not stdout — an empty stdout log does **not** mean "no error." Reproduce the
exact POST with `curl` (add/remove one field) to isolate the offender fast.
**Fix:** strip the decoded map to the schema's known top-level keys before threading on
(`select-keys decoded edit-form-keys`); keep that allowlist next to the schema. Nested
account sub-maps decode fine — only the top level needs the guard.
## REPL reload does not refresh a running jetty's routes — restart the JVM
`handler/match->handler-lookup` is a top-level `def` capturing `(merge ssr/key->handler …)`
at load, through a chain of module-level `def`s (`edit` → `ssr.transaction` → `ssr.core` →
`handler`). Reloading the leaf `edit.clj` updates it but **not** the captured merges, and a
jetty started `(run-jetty app …)` holds a static `app` that doesn't re-deref the lookup per
request. Net: after a handler/route/record change, an already-running dev server keeps
serving the **old** code — `curl` shows the pre-change response (e.g. the old wizard
transitioner) while your REPL renders the new one. **Restart in a fresh JVM** for
route/record/middleware changes. For e2e, the Playwright test server
(`lein run -m auto-ap.test-server`) is a fresh JVM compiling from disk — but kill any stale
`:3333` first (`reuseExistingServer` reuses it), and kill **by port**
(`ss -tlnp | grep :3333`), never `pkill -f test-server` (it matches its own command line).
## Full-suite e2e flakes are shared-seed interference
The test server seeds once at boot; edit tests **save** (mutate) those seed transactions.
Run in parallel, workers race the same rows and earlier saves pollute later reads → phantom
failures that pass in isolation. Clean signal: restart (re-seed) + **`--workers=1`**.
Baseline is **38 pass / 1 fail**, the 1 being the pre-existing
`transaction-navigation.spec.ts:92` date-range test (unrelated to the edit modal).
## Scorecard exceptions (ratchet violations with a reason)
**Heuristic 9 (Hiccup in render path) — partial exception (Phase 2-final).** The post-save
`com/success-modal` confirmation dialogs in `save-handler` keep ~6 `[:p …]` Hiccup lines.
They are terminal responses (shown after the form closes), reuse a shared dialog component,
and sit outside the form's interactive render path. Migrating them means porting the shared
`success-modal` to Selmer — a Phase 11 cross-cutting task, not a single-modal one.

View File

@@ -0,0 +1,85 @@
# Render functions: explicit data, or a top-rooted cursor
**One function, data in, markup out.** The data can arrive as a plain map *or* via a
cursor — as long as the cursor was rooted at the top of the form and walked down to here,
never faked to start at this depth. The rule is about *where the cursor starts*, not
whether you use one.
## GOOD — explicit data, pure, testable without setup
```clojure
(defn account-row [{:keys [account index client-id amount-mode]}]
(com/data-grid-row
(com/hidden {:name (str "accounts[" index "][db/id]")
:value (or (:db/id account) "")})
(com/data-grid-cell
(account-typeahead* {:value (:transaction-account/account account)
:name (str "accounts[" index "][account]")
:client-id client-id}))
...))
```
## ALSO FINE — a cursor that started at the form root and was advanced naturally
```clojure
;; The top-level render walks the cursor; the row fn receives the dereferenced row
;; (or the advanced cursor). No rebinding of *current*/*prefix* to fake depth.
(defn account-rows [accounts-cursor]
(for [row-cursor (fc/each accounts-cursor)] ; advanced from the root, not faked
(account-row {:account @row-cursor :index (fc/index row-cursor) ...})))
```
`transaction/edit.clj`'s `transaction-account-row*` is the cursor form done right: the
caller (`account-grid-body*`) holds a top-rooted cursor via `fc/cursor-map` and hands each
row cursor to one render fn.
---
## The SMELL this migration removes
### 1. Faking the cursor's starting position
A "form cursor" is fine. The pain is **rebinding the dynamic root deeper in the tree** so
a deeply nested render fn can run against a fragment. Real example from
`transaction/edit.clj`'s `simple-mode-fields*` (the thing to delete):
```clojure
;; SMELL: re-roots the cursor to a synthetic MapCursor pointed at accounts[0] so a
;; fragment can render "deep". Fragile, and the source of the *-no-cursor* twin below.
(fc/with-field :transaction/accounts
(fc/with-cursor (let [cur fc/*current*]
(if (sequential? @cur)
(nth cur 0 nil)
(auto_ap.cursor.MapCursor. {} (cursor/state cur)
(conj (cursor/path cur) 0))))
...))
```
Target: the cursor begins at the top level of what the form consumes and walks down
naturally. Because the **whole form is re-rendered each time** (swap doctrine), there is
no longer any reason to fake a deep starting position.
### 2. The `*-no-cursor*` twin
Faking the deep cursor forces a *second copy of the same markup* — one that reads the
faked cursor and one that takes plain params for the cases where the fake can't be set up.
`transaction/edit.clj` has exactly this pair:
```clojure
(defn transaction-account-row* [{:keys [value index client-id ...]}] ...) ; cursor form
(defn transaction-account-row-no-cursor* [{:keys [account index client-id ...]}] ...) ; duplicate markup
```
**Fix:** keep one render fn. If a caller already holds a top-rooted cursor, advance it and
hand the row data (or the advanced cursor) to that one fn. Delete the `*-no-cursor*` copy.
Heuristic 1 targets `grep -c 'defn.*-no-cursor'` → 0 and faked-cursor re-roots → 0.
## Scorecard hooks (heuristics 1, 2)
```bash
grep -c 'defn.*-no-cursor' $F # → 0
grep -cE 'with-cursor|MapCursor\.' $F # faked re-roots → 0 (top-rooted cursors are fine)
```
Top-rooted cursors do **not** count against heuristic 1 — only *re-roots that fake depth*
and the `*-no-cursor*` twins do.

View File

@@ -0,0 +1,84 @@
# Quality scorecard (the ratchet)
Cheap to measure (`grep -c`, `wc -l`, `clj-kondo`), recorded **before/after each
migration** in the commit message and in the results table below. **No metric may regress
for the touched modal** without a written exception in `gotchas.md`. These are directional
evidence, not targets to game — always paired with the e2e parity gate.
## Heuristics
| # | Heuristic | Measure | Target |
|---|-----------|---------|--------|
| 1 | Faked cursor positions (not cursors themselves) | `grep -cE 'with-cursor\|MapCursor\.'` re-roots + `grep -c 'defn.*-no-cursor'` | → 0 (top-rooted cursors are fine) |
| 2 | Implicit state merges (snapshot/cursor) | count merge sites | → 0 (forms); explicit `put-step` only (wizards) |
| 3 | Branching complexity | `clj-kondo`, or count `cond`/`condp`/`case`/nested `if` + max depth | net ↓ |
| 4 | Lines of code | `wc -l` on the modal's file(s) | net ↓ |
| 5 | Reuse / cross-form similarity | cookbook components reused; duplicated-block count | reuse ↑, dup ↓ |
| 6 | Route count | count routes for the modal | → 2 (+1 for add-row) |
| 7 | OOB swaps | `grep -c hx-swap-oob` | → 0 unless a justified disjoint-region case is documented |
| 8 | Attribute consistency | mixed `:x-`/`"x-"` encodings in migrated template | → 0 |
## How to measure (copy/paste)
```bash
F=src/clj/auto_ap/ssr/<modal>.clj
echo "LOC $(wc -l < $F)"
echo "no-cursor twins $(grep -c 'defn.*-no-cursor' $F)"
echo "faked-cursor roots $(grep -cE 'with-cursor|MapCursor\.' $F)"
echo "snapshot merges $(grep -c ':multi-form-state :snapshot' $F)"
echo "branch forms $(grep -cE '\(cond |\(condp |\(case |\(when-not ' $F)"
echo "hx-swap-oob $(grep -c 'hx-swap-oob' $F)"
echo "mixed string hx- $(grep -cE '\"hx-[a-z]' $F)"
# route count: count this modal's entries in src/cljc/auto_ap/routes/*.cljc
```
## Results
Each migration appends one row (after-numbers), referencing the before in the diff.
| Phase | Modal | LOC | Routes | no-cursor twins | faked roots | snapshot merges | OOB | mixed hx- | cookbook reused / added |
|-------|-------|-----|--------|-----------------|-------------|-----------------|-----|-----------|-------------------------|
| 1 (baseline) | Transaction Edit `transaction/edit.clj` | 1608 | ~12 | 1 | 2 | ~75 | 0 | 8 | — / seeded 7 entries |
| 2 | Transaction Edit `transaction/edit.clj` | 1593 | **~5** | **0** | **0** | **0 round-trip** | 0 | 8 (shared) | location-select / **1 Selmer** |
| 2-final | Transaction Edit (full Selmer + wizard removed) | 1548 | **5** | 0 | 0 | 0 | 0 | **0** | full `sc/*` lib / **~30 partials** |
### New heuristics introduced at 2-final (full Selmer)
| # | Heuristic | Measure | Target |
|---|-----------|---------|--------|
| 9 | Hiccup HTML tags in the render path | `grep -cE '\[:(div\|span\|p\|a\|button\|input\|h[1-6]\|ul\|li\|label\|select\|option\|t(able\|head\|body\|r\|d\|h)\|form\|svg\|template)'` over the modal's render fns | → 0 (success-modal confirmation dialogs may keep the shared Hiccup component) |
| 10 | mm wizard coupling | `grep -c 'mm/' the modal file` + `grep -c 'defrecord.*Wizard\|ModalWizardStep'` | → 0 for a single-step modal |
> **Phase 2 progress.** Achieved with parity held (swap spec **6/6**, transaction-edit
> spec **8/8**, full suite **38 pass / 1 unrelated fail / 0 skip**, up from 30/2/7):
> - deleted the dead `*-no-cursor*` twin (no-cursor 1→0);
> - **de-faked the simple-mode cursor** (faked roots 2→0) via explicit data + explicit
> field names (`account-field-name`) + explicit error lookup — the render-fn rewrite the
> `with-field-default` shortcut couldn't do;
> - **collapsed the 5 manual-coding operation routes into one `edit-form-changed`
> dispatcher** (routes ~12→~5; the operations are now pure `apply-*` fns);
> - fixed a real production bug (`:mode` → 500 on every advanced manual save);
> - greened `transaction-edit.spec.ts` (8/8) and matured the skill.
>
> **Phase 2 complete.** The wizard→plain-form rewrite removed the snapshot round-trip
> (heuristic 2 → 0) and the first interactive component (`location-select`) is migrated to
> a Selmer template (`selmer-conventions.md` validated). Remaining for *later phases*: drop
> the now-thin `mm/ModalWizardStep` protocol wrappers, and the cross-cutting Phase 11
> Selmer sweep of the shared `com/typeahead`/`com/select`/`com/button-group-button` (those
> shared call sites hold the last 8 mixed `@`/`:`-attr offenders; they clear when the
> shared components move to Selmer — not a single-modal task, per Open decision 2).
> **Phase 2-final — full Selmer + wizard removed.** Every component the modal renders
> through was ported to a Selmer partial under `resources/templates/components/` with a
> thin Clojure wrapper in `auto-ap.ssr.components.selmer` (`sc/*`); the modal's own
> structure lives under `resources/templates/transaction-edit/`. The `mm` wizard
> abstraction (`EditWizard`/`LinksStep` records, `MultiStepFormState`, `step-params[...]`
> field names, `wrap-wizard`/`wrap-decode-multi-form-state` middleware) was deleted — there
> was only ever one step, so it was pure overhead. Result: heuristic 8 (mixed hx-) and 9
> (Hiccup in render) and 10 (mm coupling) all → **0**; the `edit-wizard-navigate` route is
> gone (routes 5). Parity held: swap spec **6/6**, transaction-edit spec **8/8**, full
> suite **38 pass / 1 pre-existing unrelated fail** (serial, fresh seed). The only Hiccup
> left in the file is the post-save `com/success-modal` confirmation dialogs (terminal,
> shared component — out of the form's render path). See `form-vs-wizard.md` (drop-the-
> wizard test), `selmer-conventions.md` (composition mechanics), and `gotchas.md`
> (stray-field decode leak; jetty reload staleness).

View File

@@ -0,0 +1,148 @@
# Selmer template conventions
> **Validated** in the Transaction Edit migration: `location-select*` now renders from
> `resources/templates/components/location-select.html` via the interop bridge, embedded
> back into the Hiccup account row. Verified: swap spec 6/6, transaction-edit 8/8 (the
> Shared Location test selects through the Selmer `<select>`, saves, and spreads to DT).
## Why Selmer for interactive components
In Hiccup the same Alpine/HTMX attribute is sometimes a keyword and sometimes a string in
the same file — there's no rule a reader (or an LLM) can rely on. The real
`com/typeahead-` mixes them in one map:
```clojure
:x-modelable "value.value" ; keyword key
"x-ref" "hidden" ; string key
"@keydown.down.prevent.stop" "tippy?.show();" ; handlers MUST be strings
:x-init "..." ; structural attrs are keywords
```
In a Selmer template the same markup is unambiguous plain HTML:
```html
{# templates/components/typeahead.html #}
<div class="relative" x-data="{{ x_data|safe }}" x-model="{{ x_model }}">
<a class="{{ classes }}" x-ref="input" tabindex="0"
@keydown.down.prevent.stop="tippy?.show()"
@keydown.backspace="tippy?.hide(); value = {value:'', label:''}">
<span x-text="value.label"></span>
</a>
</div>
```
Note the `tippy?.` null-guard carried over from the swap doctrine — Selmer doesn't change
the Alpine-survives-swap requirement.
## The render helper + interop bridge (`auto-ap.ssr.selmer`)
```clojure
(sel/render template ctx) ; selmer/render-file -> HTML string (classpath-relative path)
(sel/render-str template ctx) ; render from a string (tests/REPL)
(sel/hiccup->html h) ; Hiccup -> string, for {{ frag|safe }} inside a template
(sel/raw html-string) ; wrap a rendered string so hiccup2 emits it verbatim
(sel/render->hiccup template ctx); render + raw, ready to drop into a Hiccup tree
```
The bridge works **both ways** (proven in `selmer_test`): a Hiccup component renders inside
a Selmer template (`hiccup->html` + `|safe`), and a Selmer fragment renders inside a Hiccup
tree (`render->hiccup`, which `raw`-wraps so hiccup2 doesn't double-escape).
## The worked example — `location-select*`
Template (`resources/templates/components/location-select.html`): plain HTML, an
`{% for %}` over option maps, `{% if opt.selected %}`.
```clojure
;; Clojure side: build the data, compute classes (reuse inputs/default-input-classes so
;; styling can't drift), render, and return a Hiccup-embeddable fragment.
(defn location-select* [{:keys [name client-locations value ...]}]
(let [options (cond ...) ; [[value label] ...]
selected (or value (ffirst options))
classes (str/join " " (conj (vec inputs/default-input-classes) "w-full"))]
(sel/render->hiccup "templates/components/location-select.html"
{:name name :classes classes
:options (for [[v l] options] {:value v :label l :selected (= v selected)})})))
```
Lessons:
- **Pass computed values in, don't hard-code.** Reuse the Clojure source of truth
(`inputs/default-input-classes`) as a context value rather than copying class strings
into the template — otherwise styling drifts from the shared components.
- **Verify by string-match + e2e, not byte-parity.** `hh/add-class` is set-based, so class
*order* differs from the old `com/select` output; CSS is order-independent and the e2e
proves behavior. (`testing-conventions`: don't assert on exact markup.)
- **Embed via the bridge.** `render->hiccup` lets the Selmer fragment sit inside the
still-Hiccup row (`com/validated-field` wraps it) — strangler, one component at a time.
## Composition — verified mechanics (selmer 1.12.61)
Proven by REPL before the full migration (do the same before relying on any of these):
- **`{% include %}` works in FILE renders only.** `sel/render` = `selmer/render-file`, and
include/extends/block are *parse-stage* tags. Rendering a template **string** that
contains `{% include %}` throws `unrecognized tag: :include` (the runtime expr-tag has a
nil handler). So includes only work from a `.html` file, never from `render-str`.
- **`{% include %}` sees `{% for %}` loop bindings.** Inside `{% for row in rows %}…{% include "components/x.html" %}…{% endfor %}` the partial can read `row.*`. Good for repeated
rows — though Clojure-composing the rows (below) is usually simpler.
- **`{% include … with k=v %}` is IGNORED** in 1.12.61 (the `with` clause is dropped). To
parametrize, either wrap with the block tag `{% with k=v %}{% include … %}{% endwith %}`
(works), or — preferred — let a Clojure wrapper render the partial with an explicit ctx.
## The Clojure-wrapper composition pattern (`auto-ap.ssr.components.selmer` / `sc`)
Because `{% include with %}` can't pass args and the server computes most values anyway,
each shared component is a **thin Clojure wrapper that renders its own partial** (the
proven `location-select*` shape, generalised). The element *structure* lives 100% in the
`.html`; the only Clojure is data assembly. The modal's render fns compose these wrappers
and assemble a view-model, interpolating the pre-rendered fragments as `{{ frag|safe }}`.
```clojure
(sc/hidden {:name :value }) ; -> render "components/hidden.html"
(sc/validated-field {:label :errors } body)
(sc/typeahead {:name :url :value :content-fn }) ; resolves label server-side
(sc/data-grid {:headers [] :footer-tbody } rows)
```
### `attrs->str` — the dynamic-attribute bridge
HTMX/Alpine attributes vary per call site, so a fixed template can't enumerate them.
`sc/attrs->str` serialises an attribute *map* to an HTML attribute *string* injected with
`{{ attrs|safe }}`: `<input type="hidden"{{ attrs|safe }}>`. Rules mirror hiccup2 — nil/false
dropped, `true` → bare boolean attr, else `name="escaped"` (via `hu/escape-html`, so JSON
`x-data` and `x-init` quotes become `&quot;`/`&apos;` and the browser decodes them back).
Keyword keys keep their full name incl. namespaced/colon forms (`:x-hx-val:account-id`
`x-hx-val:account-id`). This keeps templates free of per-attribute `{% if %}` ladders while
still emitting 100% Selmer markup — `attrs->str` serialises data, it does not build Hiccup.
### Reuse the real class helpers
Wrappers reuse `inputs/default-input-classes`, `inputs/use-size`, `hh/add-class`,
`btn/bg-colors` so output matches the Hiccup component **modulo Tailwind class order**.
Verify by class-**set** equality + e2e, never byte-parity (see the cookbook entries).
### Trivial wrapper divs
A bare `<div class="w-72">…</div>` around a fragment is composed with a `wrap-div` string
helper (or put the class in the parent template), not a Hiccup vector — string composition
of a structural wrapper is not Hiccup and avoids a micro-template per div.
Keep `|safe` to values the server fully controls (rendered fragments, JSON for `x-data`),
never raw user input.
## Scope (Open decision 2)
Hybrid, and the boundary is real: **the modal's attribute-heavy components delegate to the
shared `com/typeahead` / `com/select` / `com/button-group-button`.** Converting those is a
*cross-cutting* change (every modal uses them), so it belongs to the Phase 11 Selmer sweep,
not a single modal. `location-select*` is the first, self-contained proof; the shared
components follow when the sweep promotes them to Selmer partials.
## Attribute-consistency scorecard (heuristic 8)
```bash
grep -cE '"x-[a-z]|"hx-[a-z]|"@' <migrated-template> # → 0 mixed encodings in Selmer
```
A migrated Selmer template has no mixed `:x-`/`"x-"` encodings because everything is plain
HTML. (The Hiccup `"@click"`/`":class"` offenders that remain in `edit.clj` live in the
shared-component call sites — they clear when those components move to Selmer.)

View File

@@ -0,0 +1,149 @@
# Whole-form HTMX swap doctrine
Every interactive control picks a swap strategy in this **priority order** (prefer the
earliest rule that works). Worked examples are the real `transaction/edit.clj` swaps.
## Rule 1 — No request when the field affects nothing else
Its value rides along in the form and is read on submit. No `hx-*` at all.
```clojure
;; transaction/edit.clj — the memo field. Editing it issues NO request; the value
;; just rides along until save. The e2e proves zero POSTs fire while typing.
(com/text-input {:value (fc/field-value)
:name (fc/field-name)
:id "edit-memo"
:placeholder "Optional note"})
```
## Rule 2 — Targeted swap of a single isolated cell when the effect is purely local
Give the cell a stable id, keep it **out of the typed input's subtree**, and post the
whole form but `hx-select` back only that cell.
```clojure
;; transaction/edit.clj — selecting an account only changes that row's valid Location
;; options, so the change swaps just this cell. Nothing else re-renders.
[:div {:id (str "account-location-" index)} ; stable, per-row id
(com/validated-field
{:x-hx-val:account-id "accountId"
:x-dispatch:changed "accountId" ; Alpine fires `changed` when account changes
:hx-trigger "changed"
:hx-post (bidi/path-for ssr-routes/only-routes ::route/edit-form-changed)
:hx-target (str "#account-location-" index)
:hx-select (str "#account-location-" index)
:hx-swap "outerHTML"
:hx-include "closest form"} ; whole form posts; only this cell swaps back
(location-select* {...}))]
```
## Rule 3 — Whole-form swap when the change touches interdependent state
Vendor change, add/remove row, mode toggle, $/% radio. The form's hidden state rides
along, so one swap keeps everything consistent — **no out-of-band swaps**.
```clojure
;; transaction/edit.clj — vendor change rebuilds the whole manual-coding section
;; (vendor default account, terms, etc. are interdependent).
[:div {:hx-trigger "change"
:hx-post (bidi/path-for ssr-routes/only-routes ::route/edit-vendor-changed)
:hx-target "#wizard-form"
:hx-select "#wizard-form"
:hx-swap "outerHTML"
:hx-sync "this:replace"
:hx-include "closest form"}
...]
```
The active tab/action round-trips through the form (it's a hidden field bound to Alpine
`activeForm`), so it **survives** the whole-form swap — that's why a whole-form swap is
safe here even though the user is "on" a tab.
## Rule 4 — OOB only for genuinely disjoint DOM regions
A global flash/toast, a nav badge, a modal at the document root. **If tempted to OOB
something inside the same feature, restructure instead**: give the dependent element a
common ancestor with the trigger and use an ordinary swap.
Worked example — running **totals live in their own sibling `<tbody>`** so an amount edit
swaps the totals without ever replacing the amount input:
```clojure
;; The totals tbody is a sibling of the input-bearing rows.
(com/data-grid
{:footer-tbody [:tbody {:id "account-totals"} ...totals rows...]}
...account rows with inputs...)
;; The amount input posts the whole form but hx-selects ONLY #account-totals.
(com/money-input
{:name (fc/field-name)
:id (str "account-amount-" index)
:class "w-16 account-amount-field"
:hx-post (bidi/path-for ssr-routes/only-routes ::route/edit-form-changed)
:hx-target "#account-totals" ; a SIBLING of this input's row...
:hx-select "#account-totals" ; ...so the input is never in the swapped region
:hx-swap "outerHTML"
:hx-trigger "keyup changed delay:300ms"
:hx-include "closest form"})
```
`grep -c hx-swap-oob` on a migrated modal must be `0` unless a justified disjoint-region
case is documented here and in `gotchas.md`.
---
## The focus invariant (must always hold)
> The input the user is typing in is never inside the region its own request swaps.
This is *the* reason the doctrine works. The amount field swaps a sibling tbody; the memo
field swaps nothing; the account typeahead's change swaps the whole form but the typeahead
isn't an active text caret at that moment (it's a click-to-select). The
`transaction-edit-swap.spec.ts` `sameNode` assertions exist to catch any violation.
## Alpine components must survive swaps
When a whole-form swap replaces a region containing Alpine/tippy components, they get
re-initialised from the server-provided values. Two hardening moves:
1. **Null-guard every reference** that depends on Alpine/tippy being initialised:
```clojure
"@keydown.down.prevent.stop" "tippy?.show()"
"@keydown.enter.prevent.stop" "$refs.input?.__x_tippy?.hide(); ..."
```
(`$refs.input?` / `tippy?` — the `?` matters; a swap can run a handler before re-init.)
2. **Let the server value win.** Because the section is rebuilt fresh on each swap, the
server-driven value (e.g. a vendor's default account) lands without keying tricks —
no preserved stale Alpine state to fight. The "changing the vendor a *second* time
still updates it" e2e is the regression guard for this.
If you *do* preserve a component across a morph/replace, key it by its server value so
a server-driven change forces re-init: `(assoc attrs :key (str id "--" current-value))`.
Use `hx/alpine-mount-then-appear` for rows that should mount-then-transition-in (it sets
`x-data {data-key false}`, `x-init $nextTick(() => key=true)`, `x-show key`).
---
## Selector strategy for targeted swaps (a consideration, not a mandate)
Rules 2 and 4 need a stable `hx-target`/`hx-select`. Per-element unique ids
(`#account-location-0`) work and are what transaction-edit uses today. They get noisy in
deeply repeated/nested structures. When you hit that (Phase 5 / the wizards), consider:
- **Semantic markup + data-attributes** — mark rows/cells with their identity and target
by attribute, no per-element ids:
```html
<tr data-row="account" data-index="0">
<td data-cell="location"> … </td>
</tr>
<!-- hx-target="[data-row='account'][data-index='0'] [data-cell='location']" -->
```
- **A `form-path -> selector` function**, derived the same way a cursor path is, so the
server and the markup agree on the target by construction. A render fn at form-path
`[:accounts 0 :location]` computes its own stable selector from that path.
**Decision status:** still per-element ids. The first modal to hit nested repeated swaps
(Invoice Bulk Edit, Phase 5) settles the convention and records it here + in
`component-cookbook.md` for the wizards to reuse.

View File

@@ -0,0 +1,137 @@
# Test recipes
GROWS every migration. How to characterize and verify a modal. Consistent with the
project `testing-conventions` skill: test user-observable behavior, assert DB state
directly, don't test the means.
## The three test layers
1. **Characterization e2e first (Playwright).** Before changing a modal, write/confirm a
spec capturing *current* behavior — focus/caret survival across swaps, each field
round-trip, validation errors, the real save. This is the parity contract; keep it
green through every commit.
2. **Pure-function checks via REPL.** Once render/data-prep fns are pure, exercise them
with `clojure-eval` / `clj-nrepl-eval -p <port>`. Assert on returned data; for markup
use string matches (`(re-find #"accounts\[0\]\[account\]" (str html))`) — this style
survives the Selmer switch. Avoid brittle structural assertions.
3. **DB-state assertions for mutations.** If a submit writes Datomic, verify by querying
the DB, not by asserting on markup.
## Running e2e
```bash
npx playwright test # full suite
npx playwright test e2e/transaction-edit-swap.spec.ts # one spec
```
- Config: `playwright.config.ts`, `baseURL http://localhost:3333`, `webServer:
lein run -m auto-ap.test-server`, `reuseExistingServer: !CI`.
- **The server must be from the worktree you're testing.** `reuseExistingServer` will
silently reuse *any* server on `:3333` — including another worktree's. Confirm with
`ls -la /proc/$(lsof -ti :3333)/cwd` (or restart on a clean port) before trusting a run.
- The test-server port is hardcoded (`test_server.clj` `run-jetty {:port 3333}`); to run a
second server from another worktree, change that or parameterise it.
## Driving a typeahead in e2e (Solr unavailable in tests)
```js
await typeahead.locator('a[x-ref="input"]').click(); // open tippy dropdown
const search = page.locator('[data-tippy-root] input[x-model="search"]').first();
await search.fill('te'); // under 3-char Solr threshold
await typeahead.evaluate((el, id) => { // inject a clickable result
window.Alpine.$data(el).elements = [{ value: id, label: 'Test Account' }];
}, accountId);
await page.locator('[data-tippy-root] a', { hasText: 'Test Account' }).first().click();
```
Entity ids come from `GET /test-info` (`{accounts:{test-account, vendor, vendor2, ...}}`).
## Proving the focus invariant (caret survival) — the key swap test
```js
// before the debounced swap lands, capture the live focused node...
await page.evaluate(() => { window.__focused = document.activeElement; });
await swap; // waitForResponse on the *-form-changed POST
const ok = await page.evaluate(() => {
const a = document.activeElement;
return { sameNode: a === window.__focused, value: a?.value, caret: a?.selectionStart };
});
// ...assert the SAME node survived with value + caret intact.
```
`trackErrors(page)` (collect `pageerror` + `console.error`, assert `[]`) catches a swap
that throws on a stale `$refs`/`tippy` — pair it with every swap test.
## Asserting "no request" (Rule 1 fields)
```js
let posts = 0;
page.on('request', r => { if (r.url().includes('edit-form-changed') && r.method()==='POST') posts++; });
// ...type in the memo...
expect(posts).toBe(0); // memo affects nothing → issues no request
```
---
## E2E baseline (the regression gate — never drop below this)
The full suite must stay green after every migration. Specs touching the migrated modals:
| Spec | Tests | Role |
|------|-------|------|
| `e2e/transaction-edit-swap.spec.ts` | 8 | **Phase 2 parity contract** — whole-form `hx-select` swaps, caret survival, no-request memo, vendor re-select |
| `e2e/transaction-edit.spec.ts` | 15 | transaction edit behavior |
| `e2e/bulk-code-transactions.spec.ts` | 18 | Phase 3 (bulk code) |
| `e2e/transaction-import.spec.ts` | 4 | import |
| `e2e/transaction-navigation.spec.ts` | 13 | navigation |
### Running e2e from a non-default worktree (recipe)
`:3333` is often taken by another worktree's server. To run this worktree's code:
1. Boot the test server in-process on this worktree's REPL at an alternate port — no
second JVM, and it live-reloads as you edit:
```clojure
(require '[auto-ap.test-server :as ts] '[ring.adapter.jetty :refer [run-jetty]]
'[datomic.api :as dc])
;; reseed helper — call before each full run so state doesn't leak between runs
(defn reseed! []
(try (.stop (:server test-srv)) (catch Throwable _))
(try (dc/delete-database "datomic:mem://playwright-test") (catch Throwable _))
(def test-srv (let [c (ts/create-test-db) id (ts/seed-test-data c)]
(reset! ts/test-transaction-id id)
{:server (run-jetty (ts/test-app) {:port 3334 :join? false}) :tx-id id})))
(reseed!)
```
2. `playwright.config.ts` honors `BASE_URL`; setting it also disables the auto-started
webServer (so worktrees don't fight over :3333):
```bash
BASE_URL=http://localhost:3334 npx playwright test --workers=1 --reporter=line
```
3. **Reseed (`reseed!`) before each full run.** One long-lived in-process server persists
its in-mem DB across separate `npx playwright` invocations; the swap spec's
`clearAccounts`/save mutate the shared transaction and leak into later specs. The
normal harness avoids this by booting a fresh server per `npx playwright test`.
### Pass/fail baseline — measured on the merged hx-select reference (Phase 2 start)
Server: in-process from `integreat-execute-refactor` on `:3334`, `--workers=1`, fresh seed.
| Spec | Result |
|------|--------|
| `transaction-edit-swap.spec.ts` | **6 / 6 pass** — the whole-form swap parity contract |
| `transaction-edit.spec.ts` | **1 fail (masks 7 via `mode: 'serial'`)** — `Shared Location … spread on save and reopen` fails: the save POST returns a validation error (amount/balance test-data assumption: "$200 = full amount of the 2nd transaction" doesn't hold), so the modal stays open. **Pre-existing on the merged reference, not introduced by this work.** |
| Full suite (39) | **30 pass / 2 fail / 7 skipped.** 2nd failure: `transaction-navigation.spec.ts` date-range persistence (`date-range=all` expected, got `month`) — drift from the base branch's "require Apply for date-range filters" change, unrelated to forms. |
**Gate for the Transaction Edit refactor:** the 6/6 swap-doctrine spec + REPL pure-fn
checks.
### Current state — after the Phase 2 modal work (never drop below this)
Full suite (workers=1, fresh seed): **38 passed / 1 failed / 0 skipped.**
- `transaction-edit-swap.spec.ts` — **6/6** (parity contract held through every change).
- `transaction-edit.spec.ts` — **8/8** (was 1 pass + 7 masked). Greened by: the `:mode`
500 fix, the Alpine-v3 typeahead helper, rewriting the percentage-split test to avoid
the snapshot-drops-live-values ordering trap, reading the real transaction total instead
of a hard-coded `400`, and dropping the removed `"Transaction Actions"` wizard-nav step.
- Remaining 1 failure: `transaction-navigation.spec.ts:92` date-range-preset persistence —
**unrelated to forms** (drift from the base branch's "require Apply for date-range
filters" change). Pre-existing; out of scope for this migration.

5
.gitignore vendored
View File

@@ -8,6 +8,8 @@ pom.xml.asc
*.class *.class
/.lein-* /.lein-*
/.nrepl-port /.nrepl-port
nrepl-port
.http-port
resources/public/js/compiled resources/public/js/compiled
*.log *.log
examples/ examples/
@@ -49,3 +51,6 @@ sysco-poller/**/*.csv
.tmp/** .tmp/**
playwright-report/** playwright-report/**
test-results/** test-results/**
# Scratch dir for temp files (screenshots, logs, etc.); keep the dir, ignore contents
/tmp/*
!/tmp/.gitkeep

8
.opencode/opencode.json Normal file
View File

@@ -0,0 +1,8 @@
{
"$schema": "https://opencode.ai/config.json",
"permission": {
"edit": {
"src/*": "deny"
}
}
}

View File

@@ -1,5 +1,9 @@
# Integreat Development Guide # Integreat Development Guide
## Temporary Files
Write any temporary files (screenshots, scratch logs, generated artifacts, etc.) to the `./tmp/` directory at the repo root. Its contents are gitignored (only `.gitkeep` is tracked), so nothing there will be accidentally committed. Do not scatter temp files elsewhere in the repo or in the system `/tmp`.
## Build & Run Commands ## Build & Run Commands
### Build ### Build
@@ -20,6 +24,11 @@ clj-nrepl-eval -p PORT "(+ 1 2 3)" # evaluate clojure code
``` ```
### Editing clojure
When editing clojure, use the clojure-mcp editing tools, or ask @clojure-author to make the change. It is critical that you
do not use the file editing tools unless absolutely necessary.
Often times, if a file won't compile, first clj-paren-repair on the file, then try again. If it doesn't wor still, try cljfmt check. Often times, if a file won't compile, first clj-paren-repair on the file, then try again. If it doesn't wor still, try cljfmt check.
### Running the Application ### Running the Application
@@ -29,6 +38,14 @@ INTEGREAT_JOB="" lein run # Default: port 3000
PORT=3449 lein run PORT=3449 lein run
``` ```
If you want to start the server, you should run `lein mcp-repl` which will output a nrepl-server port file and http-server port file.
## Browser Automation
When using the **agent-browser** skill for testing or automation:
- Navigate to `/dev-login` to simulate an admin user and fake a session
- Do not open directly to a specific page unless explicitly instructed to; instead, start on the dashboard and navigate from there
## Test Execution ## Test Execution
prefer using clojure-eval skill prefer using clojure-eval skill

125
CLAUDE.md
View File

@@ -1,125 +1,2 @@
# CLAUDE.md # CLAUDE.md
@AGENTS.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
Integreat is a full-stack web application for accounts payable (AP) and accounting automation. It integrates with multiple financial data sources (Plaid, Yodlee, Square, Intuit QBO) to manage invoices, bank accounts, transactions, and vendor information.
**Tech Stack:**
- Backend: Clojure 1.10.1 with Ring (Jetty), Datomic database, GraphQL (Lacinia, in process of depracation)
- Frontend, two versions:
* ClojureScript with Reagent, Re-frame, (almost depracated)
* Server side: HTMX, TailwindCSS, alpinejs (current)
- Java: Amazon Corretto 11 (required for Clojure 1.10.1)
## Development Commands
**Build:**
```bash
lein build # Create uberjar
lein uberjar # Standalone JAR
npm install # Install Node.js dependencies (for frontend build)
```
### Development
```bash
docker-compose up -d # Start Datomic, Solr services
lein repl # Start Clojure REPL (nREPL on port 9000), typically one will be running for you already
lein cljfmt check # Check code formatting
lein cljfmt fix # Auto-format code
clj-paren-repair [FILE ...] # fix parentheses in files
clj-nrepl-eval -p PORT "(+ 1 2 3)" # evaluate clojure code
```
Often times, if a file won't compile, first clj-paren-repair on the file, then try again. If it doesn't wor still, try cljfmt check.
**Running the Application:**
**As Web Server:**
```bash
INTEGREAT_JOB="" lein run # Default: port 3000
# Or with custom port:
PORT=3449 lein run
```
**As Background Job:**
Set `INTEGREAT_JOB` environment variable to one of:
- `square-import-job` - Square POS transaction sync
- `yodlee2` - Yodlee bank account sync
- `plaid` - Plaid bank linking
- `intuit` - Intuit QBO sync
- `import-uploaded-invoices` - Process uploaded invoice PDFs
- `ezcater-upsert` - EZcater PO sync
- `ledger_reconcile` - Ledger reconciliation
- `bulk_journal_import` - Journal entry import
- (no job) - Run web server + nREPL
## Architecture
**Request Flow:**
1. Ring middleware pipeline processes requests
2. Authentication/authorization middleware (Buddy) wraps handlers
3. Bidi routes dispatch to handlers
4. SSR (server-side rendering) generates HTML with Hiccup for main views
5. For interactive pages, HTMX handles partial updates
6. Client-side uses alpinejs as a bonus
**Multi-tenancy:**
- Client-based filtering via `:client/code` and `:client/groups`
- Client selection via `X-Clients` header or session
- Role-based permissions: admin, standard user, vendor
**Key Directories:**
- `src/clj/auto_ap/` - Backend Clojure code
- `src/clj/auto_ap/server.clj` - Main entry point, job dispatcher, Mount lifecycle
- `src/clj/auto_ap/handler.clj` - Ring app, middleware stack
- `src/clj/auto_ap/datomic/` - Datomic schema and queries
- `src/clj/auto_ap/ssr/` - Server-side rendered page handlers (Hiccup templates)
- `src/clj/auto_ap/routes/` - HTTP route definitions
- `src/clj/auto_ap/jobs/` - Background batch jobs
- `src/clj/auto_ap/graphql/` - GraphQL type definitions and resolvers
- `src/cljs/auto_ap/` - Frontend ClojureScript for old, depracated version
- `test/clj/auto_ap/` - Unit/integration tests
## Database
- Datomic schema defined in `resources/schema.edn`
- Key entity patterns:
- `:client/code`, `:client/groups` for multi-tenancy
- `:vendor/*`, `:invoice/*`, `:transaction/*`, `:account/*` for standard entities
- `:db/type/ref` for relationships, many with `:db/cardinality :db.cardinality/many`
## Configuration
- Dev config: `config/dev.edn` (set via `-Dconfig=config/dev.edn`)
- Env vars: `INTEGREAT_JOB`, `PORT`
- Docker: Uses Alpine-based Amazon Corretto 11 image
## Important Patterns
- **Middleware stack** in `handler.clj`: route matching → logging → client hydration → session/auth → idle timeout → error handling → gzip
- **Client context** added by middleware: `:identity`, `:clients`, `:client`, `:matched-route`
- **Job dispatching** in `server.clj`: checks `INTEGREAT_JOB` env var to run specific background jobs or start web server
- **Test selectors**: namespaces ending in `integration` or `functional` are selected by `lein test :integration` / `lein test :functional`
## Clojure REPL Evaluation
The command `clj-nrepl-eval` is installed on your path for evaluating Clojure code via nREPL.
**Discover nREPL servers:**
`clj-nrepl-eval --discover-ports`
**Evaluate code:**
`clj-nrepl-eval -p <port> "<clojure-code>"`
With timeout (milliseconds)
`clj-nrepl-eval -p <port> --timeout 5000 "<clojure-code>"`
The REPL session persists between evaluations - namespaces and state are maintained.
Always use `:reload` when requiring namespaces to pick up changes.

106
config/core.clj Normal file
View File

@@ -0,0 +1,106 @@
(ns config.core
(:require [clojure.java.io :as io]
[clojure.edn :as edn]
[clojure.string :as s]
[clojure.tools.logging :as log])
(:import java.io.PushbackReader))
(defn parse-number [^String v]
(try
(Long/parseLong v)
(catch NumberFormatException _
(BigInteger. v))))
;originally found in cprop https://github.com/tolitius/cprop/blob/6963f8e04fd093744555f990c93747e0e5889395/src/cprop/source.cljc#L26
(defn str->value
"ENV vars and system properties are strings. str->value will convert:
the numbers to longs, the alphanumeric values to strings, and will use Clojure reader for the rest
in case reader can't read OR it reads a symbol, the value will be returned as is (a string)"
[v]
(cond
(re-matches #"[0-9]+" v) (parse-number v)
(re-matches #"^(true|false)$" v) (Boolean/parseBoolean v)
(re-matches #"\w+" v) v
:else
(try
(let [parsed (edn/read-string v)]
(if (symbol? parsed) v parsed))
(catch Throwable _ v))))
(defn keywordize [s]
(-> (s/lower-case s)
(s/replace "_" "-")
(s/replace "." "-")
(keyword)))
(defn read-system-env []
(->> (System/getenv)
(map (fn [[k v]] [(keywordize k) (str->value v)]))
(into {})))
(defn read-system-props []
(->> (System/getProperties)
(map (fn [[k v]] [(keywordize k) (str->value v)]))
(into {})))
(defn read-env-file [f]
(try
(when-let [env-file (io/file f)]
(when (.exists env-file)
(edn/read-string (slurp env-file))))
(catch Exception e
(log/warn (str "WARNING: failed to parse " f " " (.getLocalizedMessage e))))))
(defn read-config-file [f]
(try
(when-let [url (or (io/resource f) (io/file f))]
(with-open [r (-> url io/reader PushbackReader.)]
(edn/read r)))
(catch java.io.FileNotFoundException _)
(catch Exception e
(log/warn (str "failed to parse " f " " (.getLocalizedMessage e))))))
(defn contains-in?
"checks whether the nested key exists in a map"
[m k-path]
(let [one-before (get-in m (drop-last k-path))]
(when (map? one-before) ;; in case k-path is "longer" than a map: {:a {:b {:c 42}}} => [:a :b :c :d]
(contains? one-before (last k-path)))))
;; author of "deep-merge-with" is Chris Houser: https://github.com/clojure/clojure-contrib/commit/19613025d233b5f445b1dd3460c4128f39218741
(defn deep-merge-with
"Like merge-with, but merges maps recursively, appling the given fn
only when there's a non-map at a particular level.
(deepmerge + {:a {:b {:c 1 :d {:x 1 :y 2}} :e 3} :f 4}
{:a {:b {:c 2 :d {:z 9} :z 3} :e 100}})
-> {:a {:b {:z 3, :c 3, :d {:z 9, :x 1, :y 2}}, :e 103}, :f 4}"
[f & maps]
(apply
(fn m [& maps]
(if (every? map? maps)
(apply merge-with m maps)
(apply f maps)))
(remove nil? maps)))
(defn merge-maps [& m]
(reduce #(deep-merge-with (fn [_ v] v) %1 %2) m))
(defn load-env
"Generate a map of environment variables."
[& configs]
(let [env-props (merge-maps (read-system-env) (read-system-props))]
(apply
merge-maps
(read-config-file "config.edn")
(read-env-file ".lein-env")
(read-env-file (io/resource ".boot-env"))
(read-env-file (:config env-props))
env-props
configs)))
(defonce
^{:doc "A map of environment variables."}
env (load-env))
(defn reload-env []
(alter-var-root #'env (fn [_] (load-env))))

View File

@@ -11,7 +11,7 @@
:requests-queue-url "https://sqs.us-east-1.amazonaws.com/679918342773/integreat-background-request-prod" :requests-queue-url "https://sqs.us-east-1.amazonaws.com/679918342773/integreat-background-request-prod"
:invoice-email "invoices@mail.app.integreatconsult.com" :invoice-email "invoices@mail.app.integreatconsult.com"
:import-failure-destination-email "ben@integreatconsult.com" :import-failure-destination-email "ben@integreatconsult.com"
:data-bucket "data.app-new.app.integreatconsult.com" :data-bucket "data.prod.app.integreatconsult.com"
:yodlee-cobrand-name "qstartus12" :yodlee-cobrand-name "qstartus12"
:yodlee-cobrand-login "qstartus12" :yodlee-cobrand-login "qstartus12"
:yodlee-cobrand-password "MPD@mg78hd" :yodlee-cobrand-password "MPD@mg78hd"

View File

@@ -0,0 +1,176 @@
---
date: 2026-06-01
topic: manual-transaction-import-ssr
focus: Port the master-branch "manual import transactions" feature to the SSR/alpine/htmx stack, modeled on the SSR ledger import, preserving all validations, starting from a failing e2e test, with minimal core-component change.
mode: repo-grounded
---
# Ideation: Porting Manual Bank-Transaction Import to SSR
## Grounding Context (Codebase)
Three reference points were read in full:
**1. The master feature (what we must reproduce).**
- UI: `src/cljs/auto_ap/views/pages/transactions/manual.cljs` — a re-frame modal titled "Import Transactions" with a single `<textarea>` ("Yodlee manual import table"). User pastes tab-separated Yodlee data, clicks "Import". POSTs `(:data)` as EDN to `/api/transactions/batch-upload`.
- Route/handler: `src/clj/auto_ap/routes/invoices.clj:241` `batch-upload-transactions``assert-admin`, then `manual/import-batch (manual/tabulate-data data)`.
- Parsing: `src/clj/auto_ap/import/manual.clj``tabulate-data` reads CSV with `\tab` separator, drops the header row, and maps **fixed positional columns**: `[:status :raw-date :description-original :high-level-category nil nil :amount nil nil nil nil nil :bank-account-code :client-code]`.
- Per-row mapping/validation: `manual->transaction` uses `import.manual.common/assoc-or-error` to accumulate errors for: **client lookup** (by bank-account-code → client), **bank-account lookup** (by code), **date parse** (`parse-date`, requires `MM/dd/yyyy`), **amount parse** (`parse-amount`).
- Engine: `import.manual/import-batch` builds lookups, calls `t/start-import-batch :import-source/manual`, applies `t/apply-synthetic-ids` (dedupe key), imports only rows with no `:errors`, returns stats `{:import-batch/imported ... :failed-validation N :sample-error "..."}`.
- Deeper validations live in `src/clj/auto_ap/import/transactions.clj` `categorize-transaction`: status must be `"POSTED"`, transaction date must be after `bank-account/start-date` and after `client/locked-until`, duplicate detection via `:transaction/id` (extant cache), missing client/bank-account/id → `:error`, plus suppression. Synthetic-id (`apply-synthetic-ids`/`synthetic-key`) gives idempotent re-import.
**2. The SSR ledger import (the pattern to emulate).**
- `src/clj/auto_ap/ssr/ledger.clj` implements a **dedicated two-stage page**, not a modal:
- `external-import-text-form*` (~L246): alpine `x-data {clipboard}`, a hidden `text-area` bound `x-model`, an `hx-post` to `::route/external-import-parse` triggered on a `"pasted"` event; a "Load from clipboard" button reads `navigator.clipboard`.
- `external-import-parse` (~L373): re-renders `external-import-form*` with `:just-parsed? true`.
- `external-import-table-form*` (~L117): renders parsed rows into an **editable** `com/data-grid-card` — each cell is a `fc/with-field` text/money input (form-cursor round-trips values + errors); per-row error/warning badge with tooltip; "Import" `hx-post`s to `::route/external-import-import`.
- Validation: malli `parse-form-schema` (~L341) with `:decode/string tsv->import-data` + `:decode/arbitrary` (vector→map) enforces shape (min-length, `clj-date-schema`, `money`, location max 2). Business validation in `add-errors`/`table->entries` (~L380504) accumulates `[message status]` pairs (`:error`/`:warn`) at entry and line-item level; `flatten-errors` maps them to form-cursor field paths `[[:table idx] message status]`.
- `import-ledger` (~L554): splits good/ignored(warn-only)/bad entries; `throw+` `:field-validation` with `:form-errors` if any bad; upserts hidden vendors; retracts+re-inserts good entries (idempotent via `:journal-entry/external-id`); touches solr; returns `{:successful :ignored :form-errors}`. `external-import-import` wraps it in an `html-response` with an `hx-trigger` notification.
**3. THE KEY DISCOVERY — scaffolding already exists, handlers do not.**
- `src/cljc/auto_ap/routes/transactions.cljc` **already declares** the route names, mirroring ledger exactly:
```
"/external-new" ::external-page
"/external-import-new" {"" ::external-import-page
"/parse" ::external-import-parse
"/import" ::external-import-import}
```
- But `src/clj/auto_ap/ssr/transaction.clj` `key->handler` (L101) wires **only** `page/table/csv/bank-account-filter/bulk-delete` (+ `edit/` + `bulk-code/`). **No handler exists for any `external-import-*` route.** The aside nav (`ssr/components/aside.clj`) wires the *ledger* import nav (`::ledger-routes/external-import-page`) but there is no transaction-import nav entry. So the routes are declared dead ends; the gap is handlers + UI + validation + nav + tests.
**Test conventions.** `test/clj/auto_ap/ssr/ledger_test.clj` is the template: pure-fn tests (`tsv->import-data`, `trim-header`, `line->id`, `flatten-errors`), validation tests (`add-errors` per error type), tx-building tests (`entry->tx`), and end-to-end import tests (`import-ledger` against Datomic via `wrap-setup` + `setup-test-data` + `admin-token`). `test/clj/auto_ap/test-server.clj` is a Playwright/browser e2e harness with `wrap-test-auth` and seeded `setup-test-data`. Existing engine unit tests: `test/clj/auto_ap/import/transactions_test.clj` already covers `categorize-transaction`.
## Topic Axes
- **Input/paste fidelity** — keeping the exact Yodlee positional-column paste payload (req #1)
- **UI flow & surface** — modal vs. dedicated two-stage paste→review→import page (req #2)
- **Validation architecture** — where shape vs. business rules live, and how errors surface (req #3)
- **Core/backend reuse** — how much of `import.transactions` / `import.manual` is reused vs. reimplemented (req #5)
- **Test strategy** — failing-first e2e, then unit coverage (req #4)
## The Central Design Fork
Requirements #1 ("paste the exact same content") and #2 ("follow ledger patterns") pull in slightly different directions, but resolve cleanly:
- **Input format stays identical** — the user still copies the same Yodlee table and pastes the same tab-separated positional columns. We reuse `manual/columns` + `manual/tabulate-data` for the *paste shape*; we do **not** switch to ledger's named-header columns.
- **Everything downstream follows ledger** — dedicated page, clipboard paste, editable review grid, per-row error/warning badges, re-validate loop, notification on import.
The one decision genuinely worth the user's input is **how much of the ledger UX to adopt**: the full editable review grid (idea #5, higher value, more work) vs. a lighter paste→validate→summary page closer to master (still SSR, less scope). Both are presented below as ranked survivors; this is the seed question for brainstorming.
## Ranked Ideas
### 1. Wire the pre-scaffolded routes into a dedicated two-stage SSR import page
**Description:** Implement `external-import-page` / `external-import-parse` / `external-import-import` handlers in a new `src/clj/auto_ap/ssr/transaction/import.clj`, add them to `ssr/transaction.clj` `key->handler` (with the same middleware stack: `wrap-must {:activity :view :subject :transaction}`, `wrap-client-redirect-unauthenticated`, etc.), and add a transaction "Import" nav entry in `aside.clj` parallel to the ledger one. The page structure mirrors `ledger/external-import-page`: breadcrumb → clipboard script → paste form → review table.
**Axis:** UI flow & surface
**Basis:** `direct:` `routes/transactions.cljc` already declares `::external-import-page/parse/import` but `ssr/transaction.clj:101 key->handler` wires none of them; `ssr/ledger.clj:686 key->handler` shows the exact wiring shape to copy.
**Rationale:** The routing contract already exists and is unused — the cheapest, lowest-risk foundation, and it's what makes req #2 ("like the ledger import") literally true at the routing/page level.
**Downsides:** Adds a new namespace; needs a nav entry and middleware parity to avoid auth gaps.
**Confidence:** 95%
**Complexity:** Low
**Status:** Unexplored
### 2. Preserve the exact Yodlee positional paste format (req #1)
**Description:** Reuse `import.manual/columns` + `tabulate-data` (or a malli `:decode/string` wrapper around them) for parsing the pasted TSV, instead of ledger's named-header `tsv->import-data`. Keep the same column positions (`:status :raw-date :description-original ... :amount ... :bank-account-code :client-code`) so users paste identical content.
**Axis:** Input/paste fidelity
**Basis:** `direct:` Requirement #1 ("Paste the exact same type of content as was in the master branch version") + `import/manual.clj:10` fixed `columns` vector; the master textarea label is literally "Yodlee manual import table".
**Rationale:** Users' upstream copy/paste habit (and the Yodlee export shape) is the contract that must not break; the ledger named-header approach would silently change what content is valid.
**Downsides:** Positional columns are brittle if Yodlee changes column order — but that's already the status quo, not a regression.
**Confidence:** 90%
**Complexity:** Low
**Status:** Unexplored
### 3. Reuse the `import.transactions` engine as the backend (req #5)
**Description:** The import handler maps parsed rows → `:transaction/*` maps via the existing `manual->transaction` shape, then drives `import.transactions/start-import-batch :import-source/manual` + `import-transaction!` + `finish!` + `get-stats` — exactly as `import.manual/import-batch` does today. The SSR layer is presentation + pre-validation only; the categorization, rule-matching, payment/deposit clearing, synthetic-id dedupe, and audit-transact paths are untouched.
**Axis:** Core/backend reuse
**Basis:** `direct:` Requirement #5 ("Minimally, if at all, change any core components") + `import/manual.clj:32 import-batch` already encapsulates the whole engine call; `import/transactions.clj` is covered by `transactions_test.clj`.
**Rationale:** This is the battle-tested core. Re-implementing it ledger-style would risk dropping validations (`categorize-transaction`) and duplicate a large, audited code path. Wrapping it keeps core change near zero.
**Downsides:** `import-batch` returns summary stats, not per-row form-cursor errors — so the pre-validation layer (idea #4) must surface row errors *before* the engine runs.
**Confidence:** 90%
**Complexity:** Medium
**Status:** Unexplored
### 4. Two-tier validation: malli shape-parse + a transaction `add-errors` business layer
**Description:** Mirror ledger's split. Tier 1: a malli `parse-form-schema` decodes the pasted TSV and coerces/validates *shape* (date parses as `MM/dd/yyyy`, amount parses, required fields present) — reusing `manual.common/parse-date`/`parse-amount` as `:decode`/predicate fns. Tier 2: a transaction-specific `add-errors`/`table->entries` adds *business* errors/warnings by reusing the predicates already encoded in `categorize-transaction`: client-not-found (`:error`), bank-account-not-found (`:error`), date before `bank-account/start-date` (`:warn`/`:not-ready`), client locked-until (`:warn`), status not `POSTED` (`:not-ready`), already-imported/extant (`:warn`). Errors are `[message status]` pairs surfaced via `flatten-errors` → form-cursor field paths.
**Axis:** Validation architecture
**Basis:** `direct:` Requirement #3 ("every validation maintained… but doesn't have to follow the same structure — make it like the ledger import"). Maps master validations (`manual.clj:23-30`, `transactions.clj:191-225 categorize-transaction`) onto ledger's `add-errors`/`flatten-errors` shape (`ledger.clj:380-519`).
**Rationale:** Gives the ledger-style inline error UX while guaranteeing 1:1 validation parity — each master check becomes an explicit `add-errors` clause, which is also directly unit-testable (one test per error type, like `ledger_test/add-errors-test`).
**Downsides:** Some checks (extant/duplicate, start-date, locked-until) need a DB read at validation time that master does lazily inside the engine — must decide whether to pre-check or let the engine's stats report them. Risk of double-validation drift if engine and pre-validator disagree.
**Confidence:** 80%
**Complexity:** High
**Status:** Unexplored
### 5. Editable review grid with per-row error/warning badges and a re-validate loop
**Description:** After paste+parse, render rows into a `com/data-grid-card` where each field (date, amount, description, bank-account-code, client-code) is an editable `fc/with-field` input, with `com/validated-field` error display and a per-row alert badge+tooltip — exactly like `ledger/external-import-table-form*`. The user can fix a wrong client/bank-account code or date inline and re-submit; only clean rows import, warn-only rows are skipped, error rows block. A "Show table" toggle keeps the default view compact.
**Axis:** UI flow & surface / validation surfacing
**Basis:** `direct:` Requirement #2 ("follow slightly better design patterns, like how the ledger import works"). `ledger.clj:117-244` is the editable-grid implementation to copy; master has no inline correction at all (fire-and-forget modal + summary stats).
**Rationale:** This is the concrete UX upgrade over master and the main reason to model on ledger — turning "paste, pray, read a stats blob" into "paste, see exactly which rows are wrong and why, fix them, import."
**Downsides:** Highest-effort idea; form-cursor round-tripping of an editable grid is the trickiest part of the ledger code. If scope must shrink, a read-only review table + summary (lighter survivor) is the fallback.
**Confidence:** 75%
**Complexity:** High
**Status:** Unexplored
### 6. Preserve idempotent re-import via synthetic-id duplicate detection, surfaced as "already imported"
**Description:** Keep `apply-synthetic-ids`/`synthetic-key` so re-pasting the same export is idempotent (the engine categorizes extant rows as `:extant` and skips them). Surface this in the review grid as a `:warn`-level "already imported" badge rather than silently dropping it, so the user understands why a row didn't import.
**Axis:** Validation architecture
**Basis:** `direct:` `import/transactions.clj:405-421 apply-synthetic-ids` + `categorize-transaction:192-225` extant handling. This is an existing master behavior that req #3 requires us to maintain.
**Rationale:** Duplicate-safety is an easy validation to lose in a port; making it visible (vs. master's opaque stats) is a small, high-trust UX win that costs almost nothing on top of idea #5.
**Downsides:** Requires a DB read of existing `:transaction/id`s at validation time (or reading it back from engine stats post-import).
**Confidence:** 80%
**Complexity:** Low
**Status:** Unexplored
### 7. Start with a failing Playwright e2e, then backfill ledger_test-style unit coverage (req #4)
**Description:** First commit: a failing e2e (against `test-server`) that dev-logs in, navigates dashboard → transactions → Import, pastes a known-good Yodlee TSV into the paste box, asserts parsed rows render, clicks Import, and asserts the transactions appear / a "N imported" notification fires. It fails initially (no handler/nav). Then make it pass incrementally: route+page (idea #1) → parse (#2/#4 tier 1) → review grid (#5) → import via engine (#3) → business validation (#4 tier 2). Backstop with unit tests mirroring `ledger_test.clj`: `tabulate-data`/parse, each `add-errors` validation clause, and an end-to-end `import` test against Datomic. Reuse the existing `transactions_test.clj` `categorize-transaction` coverage as the validation-parity oracle.
**Axis:** Test strategy
**Basis:** `direct:` Requirement #4 ("Write detailed acceptance criteria, and start with a failing e2e test, making it pass over time"). `test-server.clj` already provides the browser harness + test auth; `ledger_test.clj` provides the unit-test template.
**Rationale:** A red e2e pins down the acceptance contract before any handler exists and gives an unambiguous "done" signal; the unit layer locks in validation parity clause-by-clause so req #3 can't silently regress.
**Downsides:** e2e clipboard paste may need a direct `type`-into-textarea path (or a test seam) since `navigator.clipboard.read()` is awkward to drive headless — plan a paste fallback the test can use.
**Confidence:** 85%
**Complexity:** Medium
**Status:** Unexplored
## Draft Acceptance Criteria (seed for brainstorm/plan, per req #4)
**Routing & access**
- [ ] `GET /transactions/external-import-new` renders an import page (admin-gated, same middleware as other transaction routes); 401/redirect for unauthenticated.
- [ ] A "Import" nav entry appears under the transactions section, active on the import route.
**Paste & parse (req #1)**
- [ ] Pasting the exact master Yodlee TSV (same positional columns) parses into the same field set as `manual/tabulate-data`.
- [ ] Header row is dropped; blank rows ignored.
- [ ] `POST …/parse` re-renders the page with a "N rows found" banner and the review table.
**Validation parity (req #3)** — each must produce a visible, row-attributed message:
- [ ] Client not found for bank-account-code → error.
- [ ] Bank account not found by code → error.
- [ ] Date not `MM/dd/yyyy` / unparseable → error.
- [ ] Amount unparseable → error.
- [ ] Status ≠ `POSTED` → not-imported (warn/not-ready).
- [ ] Date before `bank-account/start-date` → not-imported (not-ready).
- [ ] Date on/before `client/locked-until` → not-imported (not-ready).
- [ ] Already-imported (synthetic-id extant) row → skipped, surfaced as warn.
- [ ] Missing client / bank-account / id → error.
**Import (req #5, minimal core change)**
- [ ] `POST …/import` runs the existing `import.transactions` engine via the `:import-source/manual` batch path; no change to `categorize-transaction`/`import-transaction!`/`apply-synthetic-ids`.
- [ ] Only clean rows import; warn-only rows skipped; any error blocks (or imports clean rows + reports errors — match master's "import valid, report failed-validation").
- [ ] Success notification reports counts (imported / skipped / errors), mirroring master's stats.
- [ ] Re-importing the same paste is idempotent (no duplicates).
**Tests (req #4)**
- [ ] A Playwright e2e covering paste → review → import → assert, committed red first, green at the end.
- [ ] Unit tests per validation clause + a Datomic-backed end-to-end import test, modeled on `ledger_test.clj`.
## Failing-First e2e: concrete starting point
Add `test/clj/auto_ap/ssr/transaction/import_test.clj` (unit) and a Playwright spec driven through `test-server`. The e2e is the first artifact and is expected to fail because no `external-import` handler is wired in `ssr/transaction.clj`. Make it green by walking ideas #1 → #2 → #5 → #3 → #4 in that order; the unit suite grows alongside #4.
## Rejection Summary
| # | Idea | Reason Rejected |
|---|------|-----------------|
| 1 | Switch paste format to ledger's named-header columns | Violates req #1 — users paste the exact Yodlee positional export; changing valid input is a silent regression |
| 2 | Keep it a re-frame/CLJS modal | Branch eliminated the CLJS app; contradicts the whole port. (An SSR htmx *modal* was considered but rejected vs. the dedicated page — ledger uses a page and the editable review grid needs the room) |
| 3 | Reimplement validation entirely ledger-style, ignoring `import.transactions` | Duplicates audited `categorize-transaction` logic, risks dropping validations (req #3), and churns core (violates req #5) |
| 4 | Async/streaming import for large pastes | Scope overrun — master is synchronous; YAGNI for the manual paste workflow |
| 5 | Add CSV file-upload alongside paste | Scope overrun — not part of the master manual-import feature |
| 6 | Replace `import-batch` stats with a bespoke result type | Unnecessary core change; the existing stats map already carries imported/failed/sample-error |

View File

@@ -0,0 +1,275 @@
---
date: 2026-06-01
type: feat
status: active
plan_id: 2026-06-01-001
title: "feat: Port manual bank-transaction import to SSR (alpine/htmx)"
depth: standard
origin: docs/ideation/2026-06-01-manual-transaction-import-ssr-ideation.md
---
# feat: Port Manual Bank-Transaction Import to SSR
## Summary
Port the master-branch "manual import transactions" feature into the SSR/alpinejs/htmx stack by implementing the `external-import` handlers that `src/cljc/auto_ap/routes/transactions.cljc` already declares but that no handler currently serves. The feature is a dedicated two-stage page — paste the same Yodlee positional-column TSV → an editable review grid with per-row error/warning badges → import — modeled directly on the SSR ledger import (`src/clj/auto_ap/ssr/ledger.clj`). Validation follows the ledger's `add-errors` shape but preserves every master validation, and the actual write reuses the existing `auto-ap.import.transactions` engine unchanged.
---
## Problem Frame
On `master`, admins import bank transactions by pasting a tab-separated Yodlee export into a re-frame modal (`src/cljs/auto_ap/views/pages/transactions/manual.cljs`) that POSTs EDN to `/api/transactions/batch-upload`. This branch removed the ClojureScript React app and re-implemented the transactions surface server-side, but the manual-import feature was never ported — so admins on this branch cannot manually import transactions at all.
The route names are already scaffolded (`::external-page`, `::external-import-page`, `::external-import-parse`, `::external-import-import` in `routes/transactions.cljc`) but `src/clj/auto_ap/ssr/transaction.clj` wires no handlers for them — they are declared dead ends. The work is to fill that gap with handlers + UI + validation + nav + tests, mirroring the already-shipped ledger import.
---
## Scope Boundaries
**In scope**
- A dedicated SSR import page at `/transaction2/external-import-new` (+ `/parse`, `/import` sub-routes), admin-gated with the same middleware posture as other transaction routes and the ledger import.
- Reuse of the exact Yodlee positional-column paste format (no named-header columns).
- An editable review grid with inline per-field editing and per-row error/warning badges (ledger-style, form-cursor driven).
- Two-tier validation preserving every master validation, with the agreed severity split.
- Import via the existing `auto-ap.import.transactions` engine, block-whole-batch on hard errors.
- A transactions-section "Import" nav entry and an import-result notification.
- A Playwright e2e (committed failing first) plus unit/integration tests modeled on `test/clj/auto_ap/ssr/ledger_test.clj`.
### Deferred to Follow-Up Work
- CSV file upload as an alternative to paste.
- Asynchronous/streaming import for very large pastes.
- Any change to `categorize-transaction` or engine internals.
**Outside this change**
- Named-header column format (rejected — would silently change valid input).
- A bespoke import-result type replacing the engine's stats map.
---
## Key Technical Decisions
1. **Full editable review grid, block-whole-batch on hard error** (from brainstorm). Any remaining hard error blocks the entire import (ledger behavior: `throw+ {:type :field-validation ...}`, re-render the grid with errors highlighted); warn-level rows skip just that row and the rest import. Rationale: with an editable grid, the user can fix fixable problems inline, so "nothing imports until clean-or-skippable" is the coherent contract.
2. **Severity split between fixable errors and inherent warnings.**
- **Hard errors (block, must fix inline):** unparseable/invalid date (must match `MM/dd/yyyy`), unparseable amount, unknown client code (no client for the bank-account-code), unknown bank-account code, missing required fields.
- **Warnings (skip that row, import the rest):** status ≠ `"POSTED"`, transaction date before `bank-account/start-date`, date on/before `client/locked-until`, already-imported (synthetic-id `extant`).
Rationale: fixable problems are correctable by editing a cell; inherent skip-conditions are facts about the data/account that editing cannot change, so they should not block the batch — this also reproduces master's "import valid, report the rest" outcome for those rows.
3. **Reuse the exact Yodlee positional paste format** (req #1). Parse with the master positional `columns` mapping (`auto-ap.import.manual/columns` + `tabulate-data` shape), not ledger's named-header `tsv->import-data`. Rationale: admins paste an unchanged Yodlee export; changing valid input is a silent regression.
4. **Reuse the `import.transactions` engine unchanged** (req #5). The import handler maps reviewed rows → `:transaction/*` maps (the `auto-ap.import.manual/manual->transaction` shape) and drives `start-import-batch :import-source/manual``import-transaction!``finish!``get-stats`, with `apply-synthetic-ids` for dedupe — exactly as `auto-ap.import.manual/import-batch` does today. The SSR layer is presentation + pre-validation only.
5. **Preview/engine parity via shared predicates** (the key design tension). The warn-level conditions shown in the grid before import (`not-ready` from start-date/locked-until, `extant`/already-imported, non-`POSTED`) and the engine's write-time `categorize-transaction` decisions must not drift. Decision: the pre-validation layer computes warn conditions by calling the **same** predicate functions the engine uses (`auto-ap.import.transactions/categorize-transaction` and its inputs — `get-existing` for extant, the bank-account `start-date`/`locked-until` checks), rather than re-deriving parallel logic. The grid is advisory display; the engine remains authoritative at write time, and because both read the same functions they agree. Hard-error (fixable) validations have no engine equivalent and live only in the pre-validation layer / malli schema.
6. **Testable paste path.** The ledger import populates a hidden textarea from `navigator.clipboard` via an alpine `@click`/`paste` handler, which is awkward to drive in headless Playwright. Decision: keep the "Load from clipboard" affordance, but ensure the paste textarea is fillable and that a `pasted`/`change` trigger fires the parse `hx-post`, so the e2e can set the value and dispatch the event without the clipboard API. (Implementation detail of how the trigger is wired is deferred to execution.)
---
## High-Level Technical Design
Two-stage flow mirroring `ssr/ledger.clj`, on the transactions surface:
```
GET /transaction2/external-import-new -> external-import-page (paste form + empty review area)
POST /transaction2/external-import-new/parse -> external-import-parse (decode TSV -> validate -> render editable grid)
POST /transaction2/external-import-new/import -> external-import-import (re-validate -> if any hard error: re-render grid (blocked);
else run import.transactions engine on clean rows,
skip warn rows, return notification with stats)
```
*This illustrates the intended approach and is directional guidance for review, not implementation specification. The implementing agent should treat it as context, not code to reproduce.*
Validation is two-tier:
- **Tier 1 (shape, hard errors):** a malli parse-form-schema decodes the pasted TSV positionally (reusing the master column order) and coerces/flags shape problems — date parses as `MM/dd/yyyy`, amount parses, required fields present.
- **Tier 2 (business):** a transaction `add-errors`/`table->entries` pass attaches `[message status]` pairs (`:error` / `:warn`) per row, with the hard/warn split from Decision 2, computing warn conditions from the shared engine predicates (Decision 5). `flatten-errors` maps them onto form-cursor field paths for the editable grid.
---
## Implementation Units
Build order is **failing-e2e-first** (req #4): U1 lands the red acceptance test, then U2U7 turn it green incrementally. Each feature-bearing unit also grows the unit-test suite in `test/clj/auto_ap/ssr/transaction/import_test.clj`.
### U1. Failing e2e acceptance test + deterministic import seed
**Goal:** Commit the end-to-end acceptance test (expected to fail) that defines "done", plus the deterministic test fixture it needs.
**Requirements:** req #4; advances acceptance criteria AC-1, AC-2, AC-9, AC-10.
**Dependencies:** none.
**Files:**
- `e2e/transaction-import.spec.ts` (new)
- `test/clj/auto_ap/test_server.clj` (modify `seed-test-data` to give the seeded bank account a **fixed** `:bank-account/code`, e.g. `"TEST-CHK"`, since `test-bank-account` otherwise assigns a random code)
**Approach:** Mirror `e2e/transaction-navigation.spec.ts` conventions (`x-clients: "mine"` header, `page.goto`, locators). The spec navigates to `/transaction2/external-import-new`, fills the paste box with a known-good Yodlee TSV whose bank-account-code/client-code match the seed (`TEST` client, `TEST-CHK` bank account), triggers parse, asserts parsed rows render in the review grid, clicks Import, and asserts a success notification with an imported count and that the imported transaction is visible on `/transaction2`. Include a second scenario pasting a row with an unknown client code and asserting a blocking error badge + that nothing imports. Drive paste by filling the textarea and dispatching the parse trigger (Decision 6), not the clipboard API.
**Patterns to follow:** `e2e/transaction-navigation.spec.ts`, `e2e/bulk-code-transactions.spec.ts`; seed shape in `test/clj/auto_ap/test_server.clj` `seed-test-data`.
**Test scenarios:**
- Covers AE/AC-1, AC-2: paste valid TSV → rows render → import → "N imported" notification → transaction appears on the list page.
- Covers AC-9: paste TSV with an unknown client code → row shows a blocking error badge, Import is blocked, no transaction created.
- Edge: empty paste → no rows / friendly empty state (assert no crash).
**Verification:** `npx playwright test e2e/transaction-import.spec.ts` runs and **fails** at this unit (no handler yet); the seed change does not break existing e2e specs (`npx playwright test` green except the new file).
**Execution note:** Start red. This is the acceptance contract; do not weaken it to pass — make U2U7 satisfy it.
### U2. Wire routes and render the import page shell
**Goal:** Make `/transaction2/external-import-new` serve a real page with the correct admin middleware; wire `parse`/`import` routes to placeholder handlers.
**Requirements:** AC-1, AC-12 (auth); req #2.
**Dependencies:** U1.
**Files:**
- `src/clj/auto_ap/ssr/transaction/import.clj` (new — namespace for the import handlers)
- `src/clj/auto_ap/ssr/transaction.clj` (merge the new `key->handler` entries into the existing map at the `key->handler` def)
**Approach:** Create `external-import-page` returning a `base-page` + `com/page` with breadcrumb ("Transactions" → "Import"), the clipboard helper script, and a forms container (initially just the paste form placeholder). Wire `::route/external-import-page`, `::route/external-import-parse`, `::route/external-import-import` into the transaction `key->handler` with the same middleware chain ledger uses for its import routes (`wrap-schema-enforce`/`wrap-form-4xx-2`/`wrap-schema-decode`/`wrap-nested-form-params` on parse/import) under the transaction page middleware (`wrap-must {:activity :import :subject :transaction}` analogous to ledger's `:subject :ledger`, `wrap-client-redirect-unauthenticated`). Confirm the correct `:activity`/`:subject` against the permissions model.
**Patterns to follow:** `src/clj/auto_ap/ssr/ledger.clj` `external-import-page` and `key->handler` (~lines 276318, 686718); `src/clj/auto_ap/ssr/transaction.clj` existing `key->handler` (~line 101).
**Test scenarios:**
- Happy path: `GET` the page as admin → 200, renders the paste form container.
- Error/auth: unauthenticated request → redirect/401 per `wrap-client-redirect-unauthenticated`.
**Verification:** Page loads at the route in the running app and in `test_server`; the e2e gets past navigation (still fails later in the flow).
### U3. Paste + parse using the master positional column format
**Goal:** Parse the pasted Yodlee TSV (exact master columns) into rows and render them; wire the paste form's `pasted`-triggered `hx-post` to the parse handler.
**Requirements:** req #1, req #2; AC-1, AC-3, AC-4.
**Dependencies:** U2.
**Files:**
- `src/clj/auto_ap/ssr/transaction/import.clj` (add `external-import-text-form*`, `external-import-parse`, the parse malli schema, and a positional `tsv->rows` decode)
- `test/clj/auto_ap/ssr/transaction/import_test.clj` (new)
**Approach:** Reuse the master column order from `auto-ap.import.manual/columns` and `tabulate-data` (CSV read with `\tab`, drop header) to map positional columns. Define a malli `parse-form-schema` (ledger-style) whose `:table` field uses a `:decode/string` that runs the positional parse and a per-row `:decode/arbitrary` to build row maps; encode Tier-1 shape constraints (date `MM/dd/yyyy`, amount parses, required fields) reusing `auto-ap.import.manual.common/parse-date`/`parse-amount` semantics. `external-import-parse` re-renders the forms fragment with `:just-parsed? true`. Keep the paste textarea fillable and fire the parse trigger on a `pasted`/`change` event (Decision 6).
**Patterns to follow:** `ssr/ledger.clj` `external-import-text-form*`, `external-import-parse`, `tsv->import-data`, `parse-form-schema` (~lines 246375); `import/manual.clj` `columns`/`tabulate-data`; `import/manual/common.clj` `parse-date`/`parse-amount`.
**Test scenarios:**
- Happy path: a known Yodlee TSV string decodes to the expected row count with the expected field keys/values (positional mapping correct).
- Header handling: first row dropped; blank rows ignored.
- Edge: amount with currency formatting parses; amount unparseable flagged at Tier 1.
- Edge: date not `MM/dd/yyyy` flagged at Tier 1; valid date parses.
- Covers AC-3: pasting the exact master column layout yields the same field set master's `tabulate-data` produced.
**Verification:** After paste, the parsed rows render (read-only at this unit is acceptable); parse unit tests green.
### U4. Editable review grid with per-row error/warning badges
**Goal:** Render parsed rows into an editable `data-grid` where each field is editable and per-row error/warning badges show, with a "Show table" toggle and an Import button.
**Requirements:** req #2; AC-5, AC-6.
**Dependencies:** U3.
**Files:**
- `src/clj/auto_ap/ssr/transaction/import.clj` (add `external-import-table-form*`, `external-import-form*`)
**Approach:** Mirror `ledger/external-import-table-form*` using form-cursor (`fc/start-form`, `fc/with-field`, `fc/cursor-map`, `fc/field-value`/`field-name`/`field-errors`) and `com/data-grid-card` / `com/validated-field` / `com/text-input` / `com/money-input`. Columns reflect the transaction row shape (date, description, amount, bank-account-code, client-code, status). Per-row badge summarizes that row's error/warn state with a tooltip listing messages (red for `:error`, yellow for `:warn`). A parsed-summary banner shows row count + error/warning pill counts. Values round-trip on re-submit so inline edits persist.
**Patterns to follow:** `ssr/ledger.clj` `external-import-table-form*` and `external-import-form*` (~lines 117274).
**Test scenarios:**
- Test expectation: none for pure rendering structure beyond what U5 exercises — but include: rows with no errors render without a badge; rows with errors render a red badge; rows with only warnings render a yellow badge (assert via the rendered hiccup/markup in a handler-level test once U5 attaches errors).
**Verification:** Parsed grid is visibly editable; badges appear once U5 attaches errors; e2e can see rows.
### U5. Two-tier validation preserving every master validation
**Goal:** Attach hard-error and warning statuses to rows per the severity split, reusing the engine's predicates for the warn conditions so the preview matches the engine.
**Requirements:** req #3, req #5 (Decision 5); AC-7, AC-8, AC-9.
**Dependencies:** U3 (Tier 1 shape errors), U4 (badges to display them).
**Files:**
- `src/clj/auto_ap/ssr/transaction/import.clj` (add `add-errors`, `table->entries`, `flatten-errors`, `entry-error-types` analogues)
- `test/clj/auto_ap/ssr/transaction/import_test.clj` (extend)
**Approach:** Build a transaction `add-errors` that, given lookups (client-by-bank-account-code, bank-account-by-code, bank-account `start-date`/`locked-until`, existing transaction ids), assigns:
- **Hard errors:** unknown client code, unknown bank-account code, missing required fields. (Tier-1 date/amount errors already present from U3.)
- **Warnings:** status ≠ `POSTED`; date before `bank-account/start-date`; date on/before `client/locked-until`; already-imported (synthetic-id present in existing ids).
Compute the warn conditions by calling the same functions the engine uses — `auto-ap.import.transactions/categorize-transaction` (and its inputs `get-existing`, the `apply-synthetic-ids` key) — rather than parallel logic (Decision 5). `flatten-errors` maps `[message status]` onto form-cursor field paths so badges render against the right rows. Map every master validation explicitly (see `import/manual.clj manual->transaction` and `import/transactions.clj categorize-transaction`).
**Patterns to follow:** `ssr/ledger.clj` `add-errors`/`table->entries`/`flatten-errors`/`entry-error-types` (~lines 380523); `import/transactions.clj` `categorize-transaction`/`get-existing`/`apply-synthetic-ids`.
**Test scenarios (one per validation, modeled on `ledger_test/add-errors-test`):**
- Hard error: unknown client code → `:error` with a clear message.
- Hard error: unknown bank-account code → `:error`.
- Hard error: missing required field → `:error`.
- (Tier 1) invalid date / unparseable amount → `:error`.
- Warning: status ≠ `POSTED``:warn`, row skipped.
- Warning: date before `bank-account/start-date``:warn`.
- Warning: date on/before `client/locked-until``:warn`.
- Warning: already-imported (extant synthetic id) → `:warn`.
- Parity: a row the grid marks clean is categorized `:import` by `categorize-transaction`; a row marked warn-skip is categorized to the matching non-`:import` action (assert grid preview agrees with engine).
- Pass-through: a fully valid row has no errors/warnings.
**Verification:** Validation unit tests green; badges reflect the correct severities in the grid.
### U6. Import via the existing engine, block-on-error, with notification
**Goal:** Implement `external-import-import`: block the whole batch if any hard error remains; otherwise run the `import.transactions` engine on clean rows (skipping warn rows) and return a result notification.
**Requirements:** req #5, Decisions 1 & 4; AC-2, AC-9, AC-10, AC-11.
**Dependencies:** U5.
**Files:**
- `src/clj/auto_ap/ssr/transaction/import.clj` (add `rows->transactions`, `import-transactions`, `external-import-import`)
**Approach:** Re-validate submitted (possibly edited) rows via U5. If any `:error` rows remain, `throw+ {:type :field-validation :form-errors ... :form-params ...}` and re-render the grid with errors (the `wrap-form-4xx-2` middleware handles re-render) — nothing imports. Otherwise map clean rows → `:transaction/*` maps using the `auto-ap.import.manual/manual->transaction` shape, apply `apply-synthetic-ids`, then drive `start-import-batch :import-source/manual``import-transaction!` (per row) → `finish!``get-stats`. Warn-only rows are excluded from the engine input (skipped). Return `html-response` re-rendering the form with an `hx-trigger` notification reporting counts from the engine stats (imported / skipped / not-ready / extant), mirroring master's stats surface.
**Patterns to follow:** `ssr/ledger.clj` `import-ledger` + `external-import-import` (~lines 554684); `import/manual.clj` `import-batch` (engine driving); `import/manual.clj` `manual->transaction`.
**Test scenarios (modeled on `ledger_test` `import-ledger-*` tests, against Datomic via `wrap-setup`/`setup-test-data`/`admin-token`):**
- Happy path: all-clean batch → engine imports all rows; stats report the imported count; transactions exist in the DB afterward.
- Block-on-error: a batch with one hard-error row → throws `:field-validation`; **no** transactions are created (assert DB unchanged).
- Warning skip: a batch with one warn-only row (e.g., non-`POSTED`) and clean rows → clean rows import, warn row skipped, stats reflect the skip.
- Idempotency: importing the same paste twice → second run imports 0 new (extant/synthetic-id dedupe); no duplicates.
- Integration: imported transaction carries `:import-source/manual` and is categorized/coded by the engine as it would be for any import (engine unchanged).
**Verification:** Import unit/integration tests green; the e2e's import step succeeds and the transaction appears on the list page.
### U7. Transactions "Import" nav entry + final polish
**Goal:** Add an "Import" entry to the transactions section nav (parallel to the ledger import nav) and finish the parsed-summary banner / notification copy.
**Requirements:** req #2; AC-1, AC-11.
**Dependencies:** U2 (route exists), U6 (notification exists).
**Files:**
- `src/clj/auto_ap/ssr/components/aside.clj` (add a transactions "Import" nav button + mark active on `::transaction-routes/external-import-page`)
**Approach:** Mirror the ledger import nav entry in `aside.clj` — add a sub-menu button under the transactions section linking to `::transaction-routes/external-import-page`, active-highlighted on that matched route. Confirm the banner shows row counts + error/warning pills (from U4) and the success notification copy matches the engine stats.
**Patterns to follow:** `ssr/components/aside.clj` ledger import nav (~lines 360366) and the transactions sub-menu (~lines 285298).
**Test scenarios:**
- Test expectation: none (navigation markup) — covered indirectly by the e2e navigating via the nav link; optionally assert the nav button renders with the correct href on the import route.
**Verification:** Full `e2e/transaction-import.spec.ts` passes; nav link is present and active on the import page.
---
## Acceptance Criteria
**Routing & access**
- **AC-1.** `GET /transaction2/external-import-new` renders the import page for an admin; an "Import" nav entry under the transactions section links to it and is active there.
- **AC-12.** Unauthenticated access redirects/401s per the standard transaction-route middleware.
**Paste & parse (req #1)**
- **AC-3.** Pasting the exact master Yodlee positional TSV parses into the same field set as `auto-ap.import.manual/tabulate-data`; the header row is dropped and blank rows ignored.
- **AC-4.** `POST .../parse` re-renders the page with a "N rows found" banner and the review grid.
**Review grid (req #2)**
- **AC-5.** Parsed rows render in an editable grid; each field is editable and inline edits persist across re-submit.
- **AC-6.** Each row shows an error badge (red) when it has a hard error, a warning badge (yellow) when it has only warnings, and no badge when clean; badges list messages on hover.
**Validation parity (req #3)** — each produces a visible, row-attributed message:
- **AC-7.** Hard errors block: client not found, bank account not found, date not `MM/dd/yyyy`, amount unparseable, missing required field.
- **AC-8.** Warnings skip just that row: status ≠ `POSTED`, date before `bank-account/start-date`, date on/before `client/locked-until`, already-imported.
- **AC-9.** With any remaining hard error, clicking Import blocks the whole batch (nothing imports) and re-renders the grid with errors highlighted.
**Import (req #5)**
- **AC-2.** `POST .../import` imports the clean rows via the existing `import.transactions` engine on the `:import-source/manual` batch path; the success notification reports counts (imported / skipped / not-ready / extant).
- **AC-10.** Re-importing the same paste is idempotent — no duplicate transactions (synthetic-id dedupe preserved).
- **AC-11.** `categorize-transaction` and the engine internals are unchanged by this work.
**Tests (req #4)**
- The Playwright e2e `e2e/transaction-import.spec.ts` exists, was committed failing first, and passes at the end.
- Unit/integration tests in `test/clj/auto_ap/ssr/transaction/import_test.clj` cover each validation clause and the end-to-end import flow against Datomic.
---
## Test Strategy
- **e2e (Playwright):** `e2e/transaction-import.spec.ts`, driven through `test/clj/auto_ap/test_server.clj` (real routes, injected test auth). Committed red in U1, green by U7.
- **Unit/integration (clojure.test):** `test/clj/auto_ap/ssr/transaction/import_test.clj`, modeled on `test/clj/auto_ap/ssr/ledger_test.clj` — pure parse/format tests, one validation test per clause, and Datomic-backed import tests via `wrap-setup` / `setup-test-data` / `admin-token`. Run with `clj-nrepl-eval -p PORT "(clojure.test/run-tests 'auto-ap.ssr.transaction.import-test)"` per AGENTS.md (preferred over `lein test`).
- **Validation-parity oracle:** existing `test/clj/auto_ap/import/transactions_test.clj` (`categorize-transaction`) backs Decision 5 — the warn-condition predicates the grid reuses are already under test.
---
## System-Wide Impact
- **Editing discipline (AGENTS.md):** all Clojure edits go through the clojure-mcp editing tools (or `@clojure-author`), not raw file edits; use `clojure-eval`/`clj-nrepl-eval` to compile-check and run tests. Run `lein cljfmt fix` before committing; `clj-paren-repair` on any file that won't compile.
- **Shared component reuse:** the feature composes existing `ssr/components` (`data-grid-card`, `validated-field`, `text-input`, `money-input`, `button`, `checkbox`, `errors`, `pill`, `form-errors`) and `ssr/form-cursor` — no core-component changes expected (req #5). If a component genuinely needs a new option, prefer an additive, backward-compatible change and flag it.
- **Test fixture change:** giving the seeded bank account a deterministic `:bank-account/code` in `test_server.clj` could affect other e2e specs that assume the random code; U1 verifies the existing suite stays green.
- **Permissions:** confirm the `wrap-must` `:activity`/`:subject` for the import routes matches the permission model (ledger uses `{:activity :import :subject :ledger}`); use the transaction equivalent.
---
## Risks & Mitigations
- **Preview/engine drift (highest risk).** Mitigated by Decision 5 — share the engine's predicates for warn conditions; the parity test in U5 asserts the grid and `categorize-transaction` agree.
- **Headless clipboard paste.** Mitigated by Decision 6 — fillable textarea + explicit parse trigger so the e2e never needs `navigator.clipboard`.
- **form-cursor round-tripping of an editable grid** is the trickiest ledger mechanic to copy; mitigate by mirroring `external-import-table-form*` closely and testing edit-persist-on-resubmit early (U4).
- **Positional column brittleness** is inherited from master (Yodlee column order); not a regression, and out of scope to fix here.
---
## Deferred Implementation Notes (execution-time unknowns)
- Exact helper/function names in the new `import.clj` namespace.
- The precise malli `:decode` wiring for positional parsing (reuse vs. thin wrapper around `tabulate-data`).
- The exact `:activity`/`:subject` keyword for the import-route `wrap-must` (verify against the permissions model).
- The seeded bank-account code value and whether any existing e2e needs adjustment after making it deterministic.
- Final notification/banner copy.

View File

@@ -0,0 +1,777 @@
# SSR Form & Wizard Simplification — Migration Plan
> **Status:** Planning / for execution by an agent or engineer.
> **Owner:** Bryce
> **Type:** Refactor (no user-facing behavior change; parity required).
This plan describes a series of low-risk migrations that make the server-side
rendered (SSR) forms and wizards substantially simpler. It is self-contained:
every concept needed to execute is stated here, illustrated with code snippets.
The work is sequenced so each migration is small, reversible, and *teaches a
skill* that makes the next migration cheaper.
---
## 1. Goals
1. **Render forms by re-rendering the whole form** (or a precise, isolated
fragment) over HTMX, using hx-select to choose elements, instead of mutating
the DOM in place. This removes the class of bugs around stale state, lost
focus/caret, and out-of-band patching.
2. **Root cursors at the top; never fake their position.** Cursors are fine and
stay — a render function may take an explicit data map *or* a cursor. What we
remove is the practice of **faking a cursor to start deeper** in the tree to
satisfy a partial render, and the duplicate `*-no-cursor*` variants that
fakery forces. The target: a cursor always begins at the top level of what the
form consumes and walks down naturally from there. (Because the whole form is
re-rendered each time, there is no longer any reason to fake a deep starting
position.)
3. **Stop forcing single-step forms through wizard machinery.** Most "wizards"
are single-step; they become plain forms. Genuine multi-step flows use a
small data-driven engine instead of protocols + middleware stacking, and
**store each step's data in the session** (combined only at the end) instead
of round-tripping and merging an EDN snapshot — the Django `formtools` model.
4. **Render HTML with Selmer templates** (Jinja-style) instead of Hiccup for the
interactive, attribute-heavy components, so Alpine/HTMX attributes are
first-class HTML rather than a mix of Clojure keywords and strings.
5. **Capture the migration method in a skill** that is created after the first
successful migration and extended by every migration thereafter.
Net effect target: large reduction in lines of code, route count, and branching
complexity, with measurably more reuse across similar forms.
---
## 2. Why — the current pain (rationale)
### 2.1 In-place DOM mutation is fragile
Re-rendering only fragments and patching the rest (via morph or out-of-band
swaps) means the server and the DOM can disagree. Keeping a focused input alive
through a patch requires keying tricks and guards. Re-rendering the **whole
form** and letting the typed value ride along in the form is simpler and
correct, *provided the input the user is typing in is never inside the region
being swapped*.
### 2.2 Faking cursor positions forces duplicate functions
A "form cursor" itself is fine. The pain comes from **faking the cursor's
starting position** — rebinding the dynamic root deeper in the tree so a deeply
nested render function can run against a fragment. That fakery is fragile and
hard to follow, and it has spawned duplicate render functions: one that reads the
faked cursor and one that takes plain params for the cases where the fake can't
be set up.
```clojure
;; SMELL: this render fn assumes the cursor was faked to start deep at an account,
;; so it only works when *current*/*prefix* were rebound to point there first.
(defn account-row* [{:keys [value client-id]}]
(com/data-grid-row
(fc/with-field :transaction-account/account
(com/data-grid-cell
(account-typeahead* {:value (fc/field-value) :name (fc/field-name)})))
...))
;; SMELL: a second copy of the same markup, just to avoid the faked-deep cursor
(defn account-row-no-cursor* [{:keys [account index client-id]}]
...)
```
**Target:** the cursor starts at the top of the form's data and walks down
naturally; a row render either takes explicit row data or receives a cursor the
caller advanced step-by-step from the root — never one teleported to a deep node.
### 2.3 Single-step forms wear wizard costumes
Several forms implement a multi-step wizard protocol (5 protocols, 15+ methods),
serialize an EDN snapshot with custom readers into hidden fields, and register
1020 routes with stacked middleware — all for a single-step form. That is pure
overhead.
### 2.4 Multi-step wizards round-trip and merge a snapshot
The genuine multi-step wizards carry the whole accumulating form state as an EDN
snapshot in hidden fields, then rebuild it each request by merging the posted
pieces back into the snapshot. The serialization needs custom readers, the merge
logic is error-prone, and the page payload grows with every step. The fix is to
**store each step's data in the session under its own key and combine only at the
end** — the Django `formtools` model (§3.3) — so no snapshot is built or merged.
### 2.5 Hiccup makes Alpine/HTMX attributes ambiguous
The same attribute is sometimes a keyword and sometimes a string in the same
file, and event handlers must be strings while structural Alpine attrs are
keywords. There is no rule a reader (or an LLM) can rely on:
```clojure
;; Both of these appear in one component file today:
:x-ref "input" ; keyword key
"x-ref" "hidden" ; string key
:x-model "value.value"
"x-model" "search"
"@keydown.down.prevent.stop" "tippy.show();" ; handlers must be strings
:x-init "..." ; structural attrs are keywords
```
In a Selmer template the same markup is unambiguous plain HTML:
```html
<input x-ref="input" x-model="value.value"
@keydown.down.prevent.stop="tippy?.show()" />
```
---
## 3. Target state (the patterns, with snippets)
These four patterns are what every migration moves code *toward*. The skill
(§5) holds the canonical, growing version of each.
### 3.1 Whole-form HTMX swap doctrine
Decide per interactive control, in this priority order:
1. **No request** when the field affects nothing else. Its value rides along in
the form and is read on submit.
```html
<!-- a memo / free-text field that influences nothing -->
<input name="memo" /> <!-- no hx-* at all -->
```
2. **Targeted swap of a single isolated cell** when a field's effect is purely
local. Give the cell a stable id and keep it out of the typed input's subtree.
```html
<!-- selecting an account only changes the valid Location options -->
<select name="accounts[0][account]"
hx-post="/transaction/edit-form-changed"
hx-target="#account-location-0"
hx-select="#account-location-0"
hx-swap="outerHTML" hx-trigger="changed">
</select>
<div id="account-location-0"> ...location options... </div>
```
3. **Whole-form swap** when the change touches interdependent state (vendor,
add/remove row, mode toggle, $/% radio). The form's hidden state rides along,
so one swap keeps everything consistent — **no out-of-band swaps**.
```html
<form id="wizard-form"
hx-post="/transaction/edit-form-changed"
hx-target="#wizard-form" hx-select="#wizard-form" hx-swap="outerHTML">
...
</form>
```
4. **Out-of-band (OOB) swap only for genuinely disjoint DOM regions** — a global
flash/toast, a nav badge, a modal mounted at the document root. If you are
tempted to OOB something *inside the same feature*, that is a signal to
**restructure the DOM so the dependent element shares a common ancestor** with
the trigger, and use an ordinary swap. Example: put running totals in a
sibling `<tbody>` so an amount edit can swap totals without replacing the
amount input:
```clojure
;; totals live in their own tbody, a sibling of the input rows
(com/data-grid- {:rows ...
:footer-tbody [:tbody {:id "account-totals"} ...]})
;; the amount input swaps ONLY the totals tbody (never itself)
[:input {:name "accounts[0][amount]"
:hx-post "/transaction/edit-form-changed"
:hx-target "#account-totals" :hx-select "#account-totals"
:hx-swap "outerHTML" :hx-trigger "keyup changed delay:300ms"}]
```
**Focus invariant (must always hold):** the input the user is typing in is never
inside the region its own request swaps.
**Alpine components must survive swaps.** Null-guard every reference that depends
on Alpine/tippy being initialised, and key a component by its server-provided
value so a server-driven change re-initialises it instead of preserving stale
state:
```clojure
;; null-guard:
"@keydown.enter.prevent.stop" "$refs.input?.__x_tippy?.hide(); ..."
;; key by current value so morph/replace re-inits on server change:
(assoc attrs :key (str id "--" current-value))
```
**Selector strategy for targeted swaps (a consideration, not a mandate).**
Rules 2 and 4 above need a stable `hx-target`/`hx-select`. The obvious approach
— a unique `id` on every swappable element — gets noisy in repeated structures
(e.g. a table of financial accounts where choosing an account must swap *that
row's* dropdown). When you reach those advanced cases, consider a more
consistent scheme instead of hand-minting ids everywhere:
- **Semantic markup + data-attributes** to craft a fine-grained selector without
per-element ids. For example, mark rows/cells with their identity and target
by attribute:
```html
<tr data-row="account" data-index="0">
<td data-cell="account">
<select hx-post="/transaction/edit-form-changed"
hx-target="[data-row='account'][data-index='0'] [data-cell='location']"
hx-select="[data-row='account'][data-index='0'] [data-cell='location']"
hx-swap="outerHTML" hx-trigger="changed">…</select>
</td>
<td data-cell="location">…</td>
</tr>
```
- **A `form-path -> id` (or `-> selector`) function**, derived the same way a
cursor path is, so the server and the markup agree on the target by
construction rather than by convention. A render fn at form-path
`[:accounts 0 :location]` would compute its own stable selector (id or
data-attribute query) from that path, mirroring §3.2's top-rooted cursor.
The aim is *consistency and predictability* of swap targets in repeated/nested
structures — pick whichever keeps targets unambiguous and easy to generate. Note
this in `reference/swap-doctrine.md` and let the first modal that hits nested
repeated swaps (Phase 5 / the wizards) settle on a convention for the cookbook.
### 3.2 Render functions: explicit data, or a top-rooted cursor
One function, data in, markup out. The data can arrive as a plain map or via a
cursor — **as long as the cursor was rooted at the top of the form and walked
down to here**, never faked to start at this depth.
```clojure
;; GOOD: pure, works everywhere, testable without setup
(defn account-row [{:keys [account index client-id amount-mode]}]
(com/data-grid-row
(com/hidden {:name (str "accounts[" index "][db/id]")
:value (or (:db/id account) "")})
(com/data-grid-cell
(account-typeahead* {:value (:transaction-account/account account)
:name (str "accounts[" index "][account]")
:client-id client-id}))
...))
```
```clojure
;; ALSO FINE: a cursor that started at the form root and was advanced naturally.
;; The top-level render walks the cursor; the row fn receives the dereferenced
;; row (or the advanced cursor) — no rebinding of *current*/*prefix* to fake depth.
(defn account-rows [accounts-cursor]
(for [row-cursor (fc/each accounts-cursor)] ; advanced from the root, not faked
(account-row {:account @row-cursor :index (fc/index row-cursor) ...})))
```
The rule is about *where the cursor starts*, not whether you use one. If a caller
already holds a top-rooted cursor, advance it and hand the row data (or the
advanced cursor) to one render function. Never rebind the cursor to teleport to a
deep node, and never keep a second `*-no-cursor*` copy of the markup.
### 3.3 Forms vs. wizards (and the data-driven wizard engine)
- **Single-step → plain form.** Two routes: `GET` (render) and `POST` (validate
+ save). State is plain form fields + an entity id. No snapshot, no server
state, no protocol.
```clojure
{::route/edit (fn [req] (html-response (render-edit-form {:entity (get-entity req)})))
::route/edit-submit (fn [req] (validate-and-save req))}
```
- **Genuinely multi-step → data-driven engine with session-stored step state.**
> **Inspiration — Django `formtools` `WizardView`.** Django's wizard does *not*
> round-trip a serialized blob of the whole form through the page. Each step's
> validated (cleaned) data is written to a **storage backend (the user session
> by default)** under that step's key, and the steps are combined only at the
> very end via `get_all_cleaned_data()`. We adopt the same model: **replace the
> EDN snapshot + piecewise merging with per-step form state stored in the
> session.** A step writes its own data under its own key; nothing is merged
> into a snapshot and nothing about other steps rides through the form.
> Refs: `formtools.wizard.views.WizardView`, its `storage` backends
> (`SessionStorage`), and `get_all_cleaned_data()`
> (https://django-formtools.readthedocs.io/en/latest/wizard.html).
A wizard is *data*:
```clojure
(def vendor-wizard-config
{:steps [{:key :info :schema info-schema :fields [...] :render render-info-step
:next (fn [data] :terms)}
{:key :terms :schema terms-schema :fields [...] :render render-terms-step
:next (fn [data] :done)}]
:init-fn (fn [req] {...})
:submit-route "/admin/vendor/wizard/submit"
:done-fn (fn [all-data req] (save! all-data) (html-response "Saved"))})
```
with a tiny engine (no protocols) whose state lives **in the session**, keyed
by a wizard instance id, with each step's data stored under its own step key —
the formtools `SessionStorage` model. No snapshot, no custom EDN readers, no
merge-into-snapshot:
```clojure
;; Storage backed by the Ring session (replaces the hidden EDN snapshot).
;; Path in session: [:wizards <wizard-id> :step-data <step-key>]
(defn create-wizard! [session config]
(let [id (str (java.util.UUID/randomUUID))]
[id (assoc-in session [:wizards id]
{:current-step (-> config :steps first :key) :step-data {}})]))
(defn put-step [session id k data] (assoc-in session [:wizards id :step-data k] data)) ; replace, not merge
(defn set-step [session id k] (assoc-in session [:wizards id :current-step] k))
(defn get-all [session id] (->> (get-in session [:wizards id :step-data]) vals (apply merge)))
(defn forget [session id] (update session :wizards dissoc id))
(defn render-wizard [{:keys [wizard-id config session request]}]
(let [{:keys [current-step step-data]} (get-in session [:wizards wizard-id])
step (first (filter #(= (:key %) current-step) (:steps config)))]
[:form#wizard-form {:hx-post (:submit-route config)
:hx-target "#wizard-form" :hx-select "#wizard-form" :hx-swap "outerHTML"}
;; only a reference token rides in the form -- not the form's state
(com/hidden {:name "wizard-id" :value wizard-id})
(com/hidden {:name "current-step" :value (name current-step)})
((:render step) (assoc request :step-data (get step-data current-step {})))]))
;; Handlers thread the (possibly updated) session back into the Ring response.
(defn handle-step-submit [config {:keys [session] :as request}]
(let [{:strs [wizard-id current-step]} (:form-params request)
step (first (filter #(= (:key %) (keyword current-step)) (:steps config)))
data (select-keys (:form-params request) (map name (:fields step)))]
(if-let [errors (mc/explain (:schema step) data)]
(-> (render-wizard {:wizard-id wizard-id :config config :session session
:request (assoc request :errors errors)})
html-response)
(let [session' (put-step session wizard-id (keyword current-step) data)
nxt ((:next step) data)]
(if (= nxt :done)
(-> ((:done-fn config) (get-all session' wizard-id) request) ; combine only at the end
(assoc :session (forget session' wizard-id)))
(let [session'' (set-step session' wizard-id nxt)]
(-> (html-response (render-wizard {:wizard-id wizard-id :config config
:session session'' :request request}))
(assoc :session session''))))))))
```
Two routes per wizard: open (`partial open-wizard config`) and submit
(`partial handle-step-submit config`). State is namespaced by `wizard-id` inside
the session, so multiple in-flight wizards (and tabs) don't collide, and it is
discarded on completion (`forget`). See Open decision 1 for the storage-backend
choice (Ring session store vs. a durable store for long-lived wizards).
### 3.4 Selmer templates
Interactive components render from Selmer templates with plain-HTML attributes.
Selmer composes via `{% include %}` and `{% block %}`; an interop bridge lets a
Selmer template embed Hiccup output (and vice versa) during the transition.
```html
{# templates/components/typeahead.html #}
<div class="relative" x-data="{{ x_data|safe }}" x-model="{{ x_model }}">
<a class="{{ classes }}" x-ref="input" tabindex="0"
@keydown.down.prevent.stop="tippy?.show()"
@keydown.backspace="tippy?.hide(); value = {value:'', label:''}">
<span x-text="value.label"></span>
</a>
...
</div>
```
```clojure
;; render helper + interop bridge
(defn render [tpl ctx] (selmer/render-file tpl ctx))
(defn hiccup->html [h] (hiccup/html h)) ; embed hiccup inside selmer via {{ frag|safe }}
;; selmer fragment inside hiccup: [:div (hiccup/raw (render "..." ctx))]
```
---
## 4. Principles
1. **Strangler, not big-bang.** New engine, Selmer renderer, and the swap
doctrine live alongside the old code. Migrate one modal at a time behind its
own route. Old machinery is deleted only when its last caller is gone.
2. **Simplest first.** Each migration is small and reversible (one commit).
Start with the already-proven modal, then the smallest fresh ones, and leave
the largest/most complex for last — by which point the skill is mature.
3. **Skill-driven and self-reinforcing.** After the first successful migration,
distil the method into a skill (§5). Every subsequent migration *reads* the
skill first and *extends* it last.
4. **Quality must measurably improve.** Each migration records a scorecard (§6);
no metric may regress for the touched modal.
5. **Behavior parity is proven by tests, not by reading** (§7). The full e2e
suite must stay green after every migration.
---
## 5. The skill: `ssr-form-migration`
**When it is created:** in **Phase 1**, immediately after — and distilled from —
the first successful modal migration (the transaction-edit modal, whose
whole-form swap implementation already exists and serves as the reference). The
skill is *not* written speculatively; it encodes a method that already worked.
**Where:** `.claude/skills/ssr-form-migration/` (matches the existing project
convention, e.g. `.claude/skills/testing-conventions/SKILL.md`).
**Structure:**
```
.claude/skills/ssr-form-migration/
SKILL.md # the playbook (§8): classify → migrate → verify → record
reference/
swap-doctrine.md # §3.1 rules, focus invariant, OOB-vs-hoist, Alpine hardening,
# target-selector strategy (semantic/data-attr/form-path->id)
render-functions.md # §3.2 explicit-data or top-rooted cursor; no faked positions
form-vs-wizard.md # §3.3 classification + the data-driven engine
selmer-conventions.md # §3.4 attr style, interop bridge, include/block patterns
component-cookbook.md # GROWS: typeahead, account-row, totals, money-input, mode-toggle…
gotchas.md # GROWS: stale $refs, key-by-value, wizard-id GC, coercion…
test-recipes.md # GROWS: how to e2e a swap; assert a Selmer render; fixture a wizard-id
scorecard.md # the §6 heuristics + a running table of every migration's numbers
```
**Growth contract — the last task of every migration:**
- Converted a component? → add its before/after to `component-cookbook.md`.
- Hit a surprise? → one entry in `gotchas.md`.
- Found a test pattern? → `test-recipes.md`.
- Playbook step missing/wrong? → fix `SKILL.md`.
- Measured the scorecard? → append the row to `scorecard.md`.
**Success signal:** each migration should reuse more cookbook entries and start
from a better scorecard baseline than the previous one. If migration N+1 is not
easier than N, the skill-update step is being skipped — treat that as a bug.
---
## 6. Quality scorecard (the ratchet)
Cheap to measure (`grep -c`, `wc -l`, `clj-kondo`), recorded before/after each
migration in the commit message and `scorecard.md`. **No metric may regress for
the touched modal.**
| # | Heuristic | Measure | Target |
|---|-----------|---------|--------|
| 1 | Faked cursor positions (not cursors themselves) | count cursor-root rebinds (`binding` of `*current*`/`*prefix*`/`*form-data*`, or `with-field`/`with-*` used to *re-root* deeper) + `grep -c '\-no-cursor'` | → 0 (top-rooted cursors are fine) |
| 2 | Implicit state merges (snapshot/cursor) | count merge sites | → 0 (forms); explicit `update-step!` only (wizards) |
| 3 | Branching complexity | `clj-kondo`, or count `cond`/`condp`/`case`/nested `if` + max depth | net ↓ |
| 4 | Lines of code | `wc -l` on the modal's file(s) | net ↓ |
| 5 | Reuse / cross-form similarity | cookbook components reused; duplicated-block count | reuse ↑, dup ↓ |
| 6 | Route count | count routes for the modal | → 2 (+1 for add-row) |
| 7 | OOB swaps | `grep -c hx-swap-oob` | → 0 unless a justified disjoint-region case is documented |
| 8 | Attribute consistency | mixed `:x-`/`"x-"` encodings in migrated template | → 0 |
These are directional evidence, not targets to game. Pair them with the e2e
parity gate (§7) so "simpler" can never mean "broken."
---
## 7. Testing strategy
Consistent with the project's `testing-conventions` skill (test user-observable
behavior; assert DB state directly; don't test the means).
1. **Characterization e2e first.** Before changing a modal, write/confirm a
Playwright spec capturing its current behavior — focus/caret survival across
swaps, the field round-trip, validation errors, and the actual save. This
spec is the parity contract the refactor must keep green.
2. **Pure-function checks via REPL.** Once render fns are pure, exercise the
data-prep functions with `clojure-eval` / `clj-nrepl-eval`. Assert on returned
data; for markup use string matches (`(re-find #"accounts\[0\]\[account\]" (str html))`)
— this style survives the Selmer switch. Avoid brittle structural assertions.
3. **DB-state assertions for mutations.** If a submit writes Datomic, verify by
querying the DB, not by asserting on markup.
**Regression gate:** the full e2e suite must stay green after every migration.
Record the current pass/fail baseline in `test-recipes.md` at the first
migration and never drop below it.
---
## 8. Per-migration playbook (the repeatable loop)
This is the canonical loop each modal phase follows; it lives in `SKILL.md`.
Modal phases below list only what is *specific* to that modal plus this loop.
1. [ ] **Read the skill.** Note applicable cookbook entries and gotchas.
2. [ ] **Classify.** Single-step → plain form (no server state). Multi-step →
wizard (engine + server state). When in doubt, it's a form.
3. [ ] **Baseline the scorecard (§6).** Record before-numbers.
4. [ ] **Characterize behavior (test-first).** Write/confirm the e2e spec.
5. [ ] **Consolidate render functions** so they take explicit data or a
top-rooted cursor — remove faked cursor positions and `*-no-cursor*`
duplicates (heuristics 1, 2). Using a cursor is fine; faking its start is not.
6. [ ] **Templatize in Selmer**; reuse cookbook bits, add new ones back
(heuristics 5, 8).
7. [ ] **Wire HTMX per the swap doctrine** (§3.1); focus invariant intact; OOB
only for disjoint regions (heuristic 7).
8. [ ] **Collapse routes** to 2 (+1 for add-row) (heuristic 6).
9. [ ] **Verify:** modal e2e + full suite green; assert DB mutations; REPL-check
pure fns. Re-measure scorecard — no regressions.
10. [ ] **Commit** one reversible feature commit; message includes the scorecard
delta and reused/new cookbook entries.
11. [ ] **Feed the skill** (cookbook / gotchas / test-recipes / scorecard /
SKILL.md). *Not optional.*
---
## 9. Phases & tasks
> Migration target inventory (verify line counts at execution time):
| Modal | File | Steps | Target | Phase |
|-------|------|-------|--------|-------|
| Transaction Edit | `transaction/edit.clj` | 1 (mode toggle) | form | 2 (skill trial) |
| Transaction Bulk Code | `transaction/bulk_code.clj` | 1 | form | 3 |
| Sales Summary Edit | `pos/sales_summaries.clj` | 1 | form | 4 |
| Invoice Bulk Edit | `invoices.clj` | 1 | form | 5 |
| Transaction Rule | `admin/transaction_rules.clj` | 2 | wizard | 6 |
| Invoice Pay | `invoices.clj` | 2 | wizard | 7 |
| New Invoice | `invoice/new_invoice_wizard.clj` | 3 | wizard | 8 |
| Vendor | `admin/vendors.clj` | 5 | wizard | 9 |
| Client | `admin/clients.clj` | 7 | wizard | 10 |
---
### Phase 1 — Distil the skill (no app code changes)
**Rationale:** the transaction-edit modal has already been migrated to the
whole-form swap approach successfully. Capture that working method as a skill
*now*, so every later migration is cheaper and consistent. (If the reference
implementation is not yet on the working branch, merge it first — that is an
acceptable prerequisite.)
- [ ] Create `.claude/skills/ssr-form-migration/SKILL.md` with the playbook (§8).
- [ ] Write `reference/swap-doctrine.md` from §3.1 (the four rules, focus
invariant, OOB-vs-hoist, Alpine hardening), using the real transaction-edit
swaps as worked examples.
- [ ] Write `reference/render-functions.md` from §3.2 (explicit data or a
top-rooted cursor; remove faked positions and `*-no-cursor*` duplicates).
- [ ] Write `reference/form-vs-wizard.md` from §3.3 (classification + engine).
- [ ] Stub `reference/selmer-conventions.md` from §3.4, marked "validated in
Phase 2."
- [ ] Seed `component-cookbook.md` with whatever transaction-edit already proved
(e.g. the hardened typeahead, the totals-in-sibling-`<tbody>` pattern).
- [ ] Seed `gotchas.md` (stale `$refs`, key-by-value).
- [ ] Seed `test-recipes.md`; record the **current full e2e pass/fail baseline**.
- [ ] Create `scorecard.md` with the §6 table and an empty results table.
- [ ] **Exit criteria:** an agent can read `SKILL.md` and the references and
understand the whole method without this plan.
---
### Phase 2 — Trial the skill on Transaction Edit (first test subject)
**Rationale:** validate the freshly written skill against the one modal whose
"correct" outcome we already know. This is also where Selmer + pure functions
are completed for this modal and the Selmer conventions get written from a real,
verified example. Target type: **plain form** (single step with a mode toggle —
the toggle is just a `GET` with a `?mode=` query param that re-renders the form).
**Foundation (do once, here):**
- [ ] Add the `selmer` dependency to `project.clj`.
- [ ] Build the render helper (`selmer/render-file`) and the **interop bridge**
(Hiccup→string for embedding in Selmer, and Selmer fragment inside Hiccup).
- [ ] Prove interop: a throwaway Selmer page renders inside the existing layout,
and a Hiccup component renders inside a Selmer template.
**Modal migration (run the §8 loop), specifics:**
- [ ] Confirm/author the characterization e2e spec covering: typing in memo keeps
focus; selecting an account updates only its Location options; changing vendor
/ adding / removing a row / toggling mode / toggling $-vs-% re-renders the
whole form correctly; amount edits update totals without losing the amount
caret; save round-trips.
- [ ] Extract pure render fns: `render-simple-fields`, `render-advanced-fields`,
`account-row`, `account-totals` (remove any `*-no-cursor*` duplicates).
- [ ] Convert those render fns to Selmer templates; record each as a cookbook
entry; finalize `selmer-conventions.md`.
- [ ] Verify the swaps match the doctrine (whole-form for structural changes,
targeted cell for account→location, sibling-`<tbody>` for totals, no request
for memo); confirm `grep -c hx-swap-oob` is 0.
- [ ] Collapse routes: `GET /transaction/edit` (with `?mode=`), `POST
/transaction/edit`, plus the single `edit-form-changed` re-render endpoint.
- [ ] Verify (modal e2e + full suite green; DB save asserted).
- [ ] **Feed the skill:** refine `SKILL.md` and references from anything the
trial revealed; append the scorecard row (this is the baseline others beat).
- [ ] **Exit criteria:** skill-driven migration reproduces the known-good
behavior; Selmer conventions are validated; cookbook has ≥3 reusable entries.
---
### Phase 3 — Transaction Bulk Code (plain form)
**Rationale:** the smallest *fresh* modal — first real test of "read the skill,
apply it cold." Single-step form currently wearing a wizard costume.
- [ ] Run the §8 loop.
- [ ] Classify as plain form; delete the wizard protocol/record and snapshot.
- [ ] Extract `render-bulk-code-fields`; reuse cookbook typeahead/money-input.
- [ ] Search params preserved as plain hidden fields (no EDN snapshot).
- [ ] Collapse 4 wizard routes → 2 (`GET` open, `POST` submit).
- [ ] Verify bulk-code applies correctly (assert DB) + full suite green.
- [ ] Feed the skill; append scorecard row.
- [ ] **Exit criteria:** ≥2 cookbook entries reused; LOC, route-count, and
faked-cursor count all down vs. baseline.
---
### Phase 4 — Sales Summary Edit (plain form)
**Rationale:** another single-step form; reinforces the cold-apply loop.
- [ ] Run the §8 loop.
- [ ] Classify as plain form; remove wizard record + `wrap-init-multi-form-state`.
- [ ] Extract `render-sales-summary-fields` (pure); reuse cookbook entries.
- [ ] Collapse 3 wizard routes → 2.
- [ ] Verify edit saves (assert DB) + full suite green.
- [ ] Feed the skill; append scorecard row.
---
### Phase 5 — Invoice Bulk Edit (plain form with rows + totals)
**Rationale:** first single-step form with dynamic account rows and live totals
— exercises the add-row endpoint and the totals-in-sibling-`<tbody>` swap
(instead of OOB).
- [ ] Run the §8 loop.
- [ ] Extract `bulk-edit-account-row` (pure); reuse the `account-row`/`totals`
cookbook entries from Phase 2.
- [ ] Add-row: a `POST` that appends a fresh row; totals re-render via the
sibling-`<tbody>` swap, **not** OOB.
- [ ] **Settle a target-selector convention** for repeated/nested rows (§3.1
"Selector strategy"): semantic data-attributes and/or a `form-path -> selector`
helper, rather than hand-minted ids per element. Record the chosen convention
in `reference/swap-doctrine.md` + `component-cookbook.md` so later wizards reuse it.
- [ ] Collapse 4 wizard routes → 3 (open, submit, add-row).
- [ ] Verify add/remove rows + totals + apply (assert DB) + full suite green.
- [ ] Feed the skill; append scorecard row.
- [ ] **Exit criteria:** `grep -c hx-swap-oob` is 0; row/totals patterns are
confirmed reusable across two modals now.
---
### Phase 6 — Build the wizard engine + migrate Transaction Rule (2-step wizard)
**Rationale:** the first genuinely multi-step modal, and the simplest one — the
right place to introduce the data-driven engine (§3.3) and **session-stored
per-step state** (the Django `formtools` model), replacing the EDN snapshot +
merge.
**Engine (do once, here):**
- [ ] Create `components/wizard_state.clj` backed by the **Ring session**:
`create-wizard!`, `put-step` (replace step data, do **not** merge into a
snapshot), `set-step`, `get-all` (combine only at the end), `forget`. State is
namespaced by `wizard-id` inside the session (`[:wizards <id> ...]`) so tabs
and concurrent wizards don't collide. Each fn returns the updated session for
the handler to thread into the Ring response. Test the lifecycle via REPL.
- [ ] Create `components/wizard2.clj` (`render-wizard`, `handle-step-submit`,
`open-wizard`) — engine threads session through and only `wizard-id` rides in
the form. Test render + step navigation + that no snapshot is emitted.
- [ ] Document the engine usage and the formtools inspiration in
`reference/form-vs-wizard.md`.
**Modal migration (run the §8 loop), specifics:**
- [ ] Extract `render-edit-step` and `render-test-step` (the test step shows a
results table); keep `validate-transaction-rule` as the step `:schema`/custom check.
- [ ] Define `transaction-rule-wizard-config` with both steps + `:done-fn`.
- [ ] Collapse routes → 2 (open, submit).
- [ ] Verify create / edit / run-test (assert DB) + full suite green.
- [ ] Feed the skill; append scorecard row.
- [ ] **Exit criteria:** engine proven on a real 2-step flow; state TTL works.
---
### Phase 7 — Invoice Pay (2-step wizard)
**Rationale:** 2 steps with conditional rendering by payment method (e.g.,
handwrite-check fields) — exercises the engine's `:next`/conditional branching.
- [ ] Run the §8 loop.
- [ ] Extract `render-choose-method-step` and `render-payment-details-step`.
- [ ] Build `pay-wizard-config`; move setup logic into `:init-fn` (e.g. the
`invoice-by-id` lookup); branch `:next` on payment method.
- [ ] Collapse routes → 2.
- [ ] Verify each payment method path (assert DB) + full suite green.
- [ ] Feed the skill; append scorecard row.
---
### Phase 8 — New Invoice (3-step wizard)
**Rationale:** a true 3-step wizard with a conditional accounts step — the
reference multi-step shape.
- [ ] Run the §8 loop.
- [ ] Extract `render-basic-details-step`, `render-accounts-step`,
`render-submit-step`; reuse the expense-account row cookbook entry.
- [ ] Define step schemas separately; `:next` from basic-details skips accounts
when not customizing.
- [ ] `:init-fn` sets defaults (e.g. date = now).
- [ ] Add-row for expense accounts via the sibling-`<tbody>` totals pattern.
- [ ] Collapse routes → 2 (+1 add-row).
- [ ] Verify create with/without custom accounts (assert DB) + full suite green.
- [ ] Feed the skill; append scorecard row.
---
### Phase 9 — Vendor (5-step wizard)
**Rationale:** larger multi-step; by now the engine and cookbook are mature.
- [ ] Run the §8 loop.
- [ ] Extract the 5 step render fns: `render-info-step`, `render-terms-step`,
`render-account-step`, `render-address-step`, `render-legal-step`.
- [ ] Build `vendor-wizard-config`; handle `:new` vs `:edit` via `:init-fn`
(empty vs. loaded entity).
- [ ] Replace the conditional `hx-post`/`hx-put` logic with the engine's submit.
- [ ] Collapse routes → 2.
- [ ] Verify create + edit across all steps (assert DB) + full suite green.
- [ ] Feed the skill; append scorecard row.
---
### Phase 10 — Client (7-step wizard) — largest, last
**Rationale:** the biggest, most complex modal (nested bank accounts, location
matches, emails, contact methods). Deliberately last, when the skill is richest.
- [ ] Run the §8 loop; split extraction into sub-tasks per step.
- [ ] Extract the 7 step render fns (`:info`, `:matches`, `:contact`,
`:bank-accounts`, `:integrations`, `:cash-flow`, `:other-settings`).
- [ ] Convert each `add-new-entity-handler` (bank accounts, location matches,
emails, contact methods) to an add-row `POST` using the cookbook row pattern;
drop `fc/with-field-default` nesting.
- [ ] Build `client-wizard-config`; `:new` vs `:edit` via `:init-fn`.
- [ ] Collapse routes → 2 (+ add-row endpoints as needed).
- [ ] Verify create + edit across all 7 steps thoroughly (assert DB) + full
suite green.
- [ ] Feed the skill; append scorecard row.
---
### Phase 11 — Cleanup
**Rationale:** remove the now-dead old machinery.
- [ ] Delete the legacy wizard module (protocols + middleware) once no caller
remains; remove any v1→v2 shim.
- [ ] Remove the Alpine morph dependency/extension if unreferenced.
- [ ] Decide (Open decision 3) whether to extend Selmer to the remaining static
Hiccup, now that the skill makes it cheap.
- [ ] Promote recurring cookbook entries into shared Selmer partials/components.
- [ ] Final scorecard review: confirm the suite-wide LOC/route/complexity drop.
---
## 10. Risks & mitigations
| Risk | Mitigation |
|------|------------|
| In-flight wizard state lost (restart / session expiry) | State lives in the session (formtools model), scoped to true multi-step wizards; plain forms hold none. Lifetime follows the session; for long-lived wizards choose a durable session backend or store (Open decision 1). `forget` on completion prevents session bloat. |
| Mixed Hiccup/Selmer interop gets messy | Build + prove the interop bridge in Phase 2 before broad use; strangler keeps both valid. |
| Selmer loses Hiccup's structural testability | Lean on e2e + DB assertions; unit-test the data-prep functions, not markup. |
| Large files hide behavior (`clients.clj`, `edit.clj`) | They go last, after the skill is rich; characterization e2e first; split per step. |
| Alpine components break across swaps | Codify hardening (null-guarded `tippy?`/`$refs`, key-by-value) as a cookbook entry applied everywhere. |
| Heuristics get gamed (LOC golfing, fake route counts) | Directional evidence only; always paired with the e2e parity gate; review the trend, not single numbers. |
| Skill-update step skipped under pressure | Required commit-message line (scorecard delta + reused/new entries); if N+1 isn't easier, flag it. |
| Quality regresses silently | Ratchet rule: no metric may regress for a touched modal without a written exception in `gotchas.md`. |
---
## 11. Open decisions
1. **Wizard state storage** — store multi-step state in the **Ring session**
(Django `formtools` `SessionStorage` model), keyed by `wizard-id`, none for
plain forms? Confirm the session backend in use (in-memory vs. durable) is
acceptable for in-flight wizard lifetime, or pick a durable store for
long-lived flows. *(recommended: session storage, scoped to multi-step
wizards only)*
2. **Selmer scope** — convert only interactive/attribute-heavy components first
(hybrid), or all SSR files (full sweep)? *(recommended: hybrid, revisit in
Phase 11)*
3. **Whole-form vs. targeted granularity defaults** — confirm the §3.1 priority
order (no-request → targeted cell → whole-form → OOB-only-if-disjoint) as the
project default. *(recommended: yes)*
4. **First step** — start by distilling the skill (Phase 1) with the reference
implementation merged as a prerequisite, rather than treating the merge
itself as step one. *(recommended: yes)*

View File

@@ -0,0 +1,601 @@
# Transaction Edit Modal: Simple / Advanced Mode Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Replace the always-visible account split table in the transaction edit modal with a simple mode (single account + location fields) that is the default for uncoded or single-account transactions, with a toggle to the full advanced split table.
**Architecture:** HTMX-driven server-side swap. A new `edit-wizard-toggle-mode` GET endpoint re-renders the manual coding section in the requested mode. Mode is carried via a hidden `<input name="mode">` field included in all HTMX requests. `edit-vendor-changed` is updated to branch on mode. The `LinksStep` render function selects initial mode based on account row count.
**Tech Stack:** Clojure/Hiccup server-side rendering, HTMX, Alpine.js, Bidi routing, Datomic
**Spec:** `docs/superpowers/specs/2026-05-27-transaction-edit-simple-advanced-mode-design.md`
---
## File Map
| File | What changes |
|------|-------------|
| `src/cljc/auto_ap/routes/transactions.cljc` | Add `::edit-wizard-toggle-mode` route |
| `src/clj/auto_ap/ssr/transaction/edit.clj` | Add `simple-mode-fields*`, `manual-coding-section*`, `edit-wizard-toggle-mode-handler`; update `LinksStep` render; update `edit-vendor-changed-handler`; register new route handler |
---
## Task 1: Add the toggle-mode route
**Files:**
- Modify: `src/cljc/auto_ap/routes/transactions.cljc`
- [ ] **Step 1: Add the route entry**
Open `src/cljc/auto_ap/routes/transactions.cljc`. After the `"/edit-wizard-new-account"` line, add:
```clojure
"/edit-wizard-toggle-mode" ::edit-wizard-toggle-mode
```
The file should look like:
```clojure
"/edit-wizard-new-account" ::edit-wizard-new-account
"/edit-wizard-toggle-mode" ::edit-wizard-toggle-mode
"/match-payment" ::link-payment
```
- [ ] **Step 2: Verify the file compiles**
```bash
clj-nrepl-eval -p 9000 "(require '[auto-ap.routes.transactions] :reload)"
```
Expected: no errors.
- [ ] **Step 3: Commit**
```bash
git add src/cljc/auto_ap/routes/transactions.cljc
git commit -m "feat: add edit-wizard-toggle-mode route"
```
---
## Task 2: Add `simple-mode-fields*` — the simple-mode account/location UI
**Files:**
- Modify: `src/clj/auto_ap/ssr/transaction/edit.clj`
This function renders the account typeahead + location select + toggle link for simple mode. It goes near the existing `account-typeahead*` and `location-select*` helpers (around line 180).
- [ ] **Step 1: Add `simple-mode-fields*` after `account-typeahead*` (around line 180)**
```clojure
(defn simple-mode-fields*
"Renders the simple-mode account + location row and the toggle link.
request must have :multi-form-state and :entity bound."
[request]
(let [snapshot (-> request :multi-form-state :snapshot)
client-id (or (-> request :entity :transaction/client :db/id)
(:transaction/client snapshot))
existing-row (first (:transaction/accounts snapshot))
account-val (:transaction-account/account existing-row)
location-val (or (:transaction-account/location existing-row) "Shared")
account-id (when (nat-int? account-val)
(dc/pull (dc/db conn) '[:account/location] account-val))
row-id (or (:db/id existing-row) (str (java.util.UUID/randomUUID)))]
[:div
;; hidden inputs to encode the single row as transaction/accounts[0]
(fc/with-field :transaction/accounts
(fc/with-cursor-index 0
[:span
(fc/with-field :db/id
(com/hidden {:name (fc/field-name) :value row-id}))
[:div.flex.gap-2.mt-2
(fc/with-field :transaction-account/account
(com/validated-field
{:label "Account" :errors (fc/field-errors)}
[:div.w-72
(account-typeahead* {:value account-val
:client-id client-id
:name (fc/field-name)
:x-model "simpleAccountId"})]))
(fc/with-field :transaction-account/location
(com/validated-field
{:label "Location"
:errors (fc/field-errors)
:x-hx-val:account-id "simpleAccountId"
:hx-vals (hx/json (cond-> {:name (fc/field-name)}
client-id (assoc :client-id client-id)))
:x-dispatch:changed "simpleAccountId"
:hx-trigger "changed"
:hx-get (bidi/path-for ssr-routes/only-routes ::route/location-select)
:hx-target "find *"
:hx-swap "outerHTML"}
(location-select*
{:name (fc/field-name)
:account-location (:account/location account-id)
:client-locations (pull-attr (dc/db conn) :client/locations client-id)
:value location-val})))
;; hidden amount — full transaction total
(fc/with-field :transaction-account/amount
(let [total (Math/abs (or (-> request :entity :transaction/amount)
(:transaction/amount snapshot)
0.0))]
(com/hidden {:name (fc/field-name) :value total})))]))
;; toggle link
[:div.mt-1
[:a.text-sm.text-blue-600.hover:underline.cursor-pointer
{:hx-get (bidi/path-for ssr-routes/only-routes ::route/edit-wizard-toggle-mode)
:hx-include "closest form"
:hx-target "#manual-coding-section"
:hx-swap "outerHTML"}
"Switch to advanced mode"]]]))
```
- [ ] **Step 2: Verify the file has no parse errors**
```bash
clj-nrepl-eval -p 9000 "(require '[auto-ap.ssr.transaction.edit] :reload)"
```
Expected: no errors.
- [ ] **Step 3: Commit**
```bash
git add src/clj/auto_ap/ssr/transaction/edit.clj
git commit -m "feat: add simple-mode-fields* for transaction edit modal"
```
---
## Task 3: Extract `manual-coding-section*` and update `LinksStep`
**Files:**
- Modify: `src/clj/auto_ap/ssr/transaction/edit.clj`
Currently the manual coding block (vendor field + account grid) is inlined inside `LinksStep/render-step`. Extract it into `manual-coding-section*` which selects mode and renders accordingly. This also adds the `mode` hidden input and wraps the section in `#manual-coding-section`.
- [ ] **Step 1: Add `manual-mode-initial` helper** (determines initial mode from snapshot)
Add this function after `simple-mode-fields*`:
```clojure
(defn- manual-mode-initial
"Returns :simple or :advanced based on existing account row count."
[snapshot]
(let [rows (seq (:transaction/accounts snapshot))]
(if (and rows (> (count rows) 1))
:advanced
:simple)))
```
- [ ] **Step 2: Add `manual-coding-section*`**
Add after `manual-mode-initial`:
```clojure
(defn manual-coding-section*
"Renders the vendor field + account/location section for the manual tab.
mode is :simple or :advanced."
[mode request]
(let [snapshot (-> request :multi-form-state :snapshot)
row-count (count (:transaction/accounts snapshot))]
[:div#manual-coding-section
;; hidden mode input — carried by all hx-include=\"closest form\" calls
(com/hidden {:name "mode" :value (name mode)})
;; vendor field
[:div {:hx-trigger "change"
:hx-post (bidi/path-for ssr-routes/only-routes ::route/edit-vendor-changed)
:hx-target "#manual-coding-section"
:hx-swap "outerHTML"
:hx-include "closest form"}
(fc/with-field :transaction/vendor
(com/validated-field
{:label "Vendor" :errors (fc/field-errors)}
[:div.w-96
(com/typeahead {:name (fc/field-name)
:error? (fc/error?)
:class "w-96"
:placeholder "Search..."
:url (bidi/path-for ssr-routes/only-routes :vendor-search)
:value (fc/field-value)
:content-fn (fn [c] (pull-attr (dc/db conn) :vendor/name c))})]))]
;; account/location section
(if (= mode :simple)
[:div {:x-data (hx/json {:simpleAccountId
(-> snapshot :transaction/accounts first
:transaction-account/account)})}
(fc/start-form (:multi-form-state request) nil
(fc/with-field :step-params
(simple-mode-fields* request)))]
;; advanced mode
[:div
(when (<= row-count 1)
[:div.mb-2
[:a.text-sm.text-blue-600.hover:underline.cursor-pointer
{:hx-get (bidi/path-for ssr-routes/only-routes ::route/edit-wizard-toggle-mode)
:hx-include "closest form"
:hx-target "#manual-coding-section"
:hx-swap "outerHTML"}
"Switch to simple mode"]])
(fc/start-form (:multi-form-state request) nil
(fc/with-field :step-params
(fc/with-field :transaction/accounts
[:div#account-grid-body
(account-grid-body* request)])))])]))
```
- [ ] **Step 3: Update `LinksStep/render-step` to use `manual-coding-section*`**
In `LinksStep/render-step` (around line 826), replace the entire `[:div {}` block inside `[:div {:x-show "activeForm === 'manual'" ...}]` (which currently contains the vendor typeahead + approval status + `account-grid-body*`) with:
```clojure
[: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 {}
(manual-coding-section* (manual-mode-initial snapshot) request)
;; Approval status field
(fc/with-field :transaction/approval-status
(com/validated-field
{:label "Status"
:errors (fc/field-errors)}
(let [current-value (name (or (fc/field-value) :transaction-approval-status/unapproved))]
[:div {:x-data (hx/json {:approvalStatus current-value})}
(com/hidden {:name (fc/field-name)
:value current-value
":value" "approvalStatus"})
[:div {:class "inline-flex rounded-md shadow-sm", :role "group"}
(com/button-group-button {"@click" "approvalStatus = 'approved'"
":class" "{ '!bg-primary-200 text-primary-800': approvalStatus === 'approved' }"
:class "rounded-l-lg"}
"Approved")
(com/button-group-button {"@click" "approvalStatus = 'unapproved'"
":class" "{ '!bg-primary-200 text-primary-800': approvalStatus === 'unapproved' }"
:class "rounded-r-lg"}
"Unapproved")
(com/button-group-button {"@click" "approvalStatus = 'suppressed'"
":class" "{ '!bg-primary-200 text-primary-800': approvalStatus === 'suppressed' }"
:class "rounded-r-lg"}
"Client Review")]]]))]]]
```
Also remove the now-redundant `(fc/with-field :transaction/accounts ...)` wrapper that previously wrapped `account-grid-body*` (it is now handled inside `manual-coding-section*`).
- [ ] **Step 4: Verify the file compiles**
```bash
clj-nrepl-eval -p 9000 "(require '[auto-ap.ssr.transaction.edit] :reload)"
```
Expected: no errors.
- [ ] **Step 5: Commit**
```bash
git add src/clj/auto_ap/ssr/transaction/edit.clj
git commit -m "feat: extract manual-coding-section* with simple/advanced mode selection"
```
---
## Task 4: Add `edit-wizard-toggle-mode-handler`
**Files:**
- Modify: `src/clj/auto_ap/ssr/transaction/edit.clj`
This handler re-renders `#manual-coding-section` in the opposite mode. It reads `mode` from the form params (via `step-params` in the decoded multi-form-state) and flips it.
- [ ] **Step 1: Add the handler function** (add after `edit-vendor-changed-handler`):
```clojure
(defn edit-wizard-toggle-mode-handler [request]
(let [step-params (-> request :multi-form-state :step-params)
current-mode (keyword (or (:mode step-params) "simple"))
target-mode (if (= current-mode :simple) :advanced :simple)
snapshot (-> request :multi-form-state :snapshot)
;; When switching simple→advanced, promote simple-mode values into accounts
render-request
(if (and (= target-mode :advanced)
(= current-mode :simple))
;; carry the simple-mode single row into snapshot so the table shows it
(let [accounts (or (seq (:transaction/accounts step-params))
(seq (:transaction/accounts snapshot)))]
(-> request
(assoc-in [:multi-form-state :snapshot :transaction/accounts]
(vec accounts))
(assoc-in [:multi-form-state :step-params :transaction/accounts]
(vec accounts))))
;; advanced→simple: take first row only
(let [first-row (first (or (seq (:transaction/accounts step-params))
(seq (:transaction/accounts snapshot))))]
(-> request
(assoc-in [:multi-form-state :snapshot :transaction/accounts]
(if first-row [first-row] []))
(assoc-in [:multi-form-state :step-params :transaction/accounts]
(if first-row [first-row] [])))))]
(html-response
(fc/start-form (:multi-form-state render-request) nil
(fc/with-field :step-params
(manual-coding-section* target-mode render-request))))))
```
- [ ] **Step 2: Register the handler in `key->handler`**
In the `key->handler` map (around line 1357), add after the `::route/edit-wizard-new-account` entry:
```clojure
::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))
```
- [ ] **Step 3: Verify the file compiles**
```bash
clj-nrepl-eval -p 9000 "(require '[auto-ap.ssr.transaction.edit] :reload)"
```
Expected: no errors.
- [ ] **Step 4: Commit**
```bash
git add src/clj/auto_ap/ssr/transaction/edit.clj
git commit -m "feat: add edit-wizard-toggle-mode-handler"
```
---
## Task 5: Update `edit-vendor-changed-handler` to support both modes
**Files:**
- Modify: `src/clj/auto_ap/ssr/transaction/edit.clj`
Currently this handler always returns `[:div#account-grid-body ...]`. It must now return `#manual-coding-section` in the correct mode.
- [ ] **Step 1: Replace `edit-vendor-changed-handler`**
Replace the entire `edit-vendor-changed-handler` function body with:
```clojure
(defn edit-vendor-changed-handler [request]
(let [multi-form-state (:multi-form-state request)
snapshot (:snapshot multi-form-state)
step-params (:step-params multi-form-state)
mode (keyword (or (:mode step-params) "simple"))
client-id (or (:transaction/client snapshot)
(-> request :entity :transaction/client :db/id))
vendor-id (or (:transaction/vendor step-params)
(:transaction/vendor snapshot))
total (Math/abs (or (-> request :entity :transaction/amount)
(:transaction/amount snapshot)
0.0))
amount-mode (or (:amount-mode snapshot) "$")
existing-accounts (or (seq (:transaction/accounts step-params))
(seq (:transaction/accounts snapshot)))
default-account (when (and (empty? existing-accounts) vendor-id client-id)
(vendor-default-account vendor-id client-id))
render-request
(if (and (empty? existing-accounts) vendor-id client-id)
(let [new-account (cond-> {:db/id (str (java.util.UUID/randomUUID))
:transaction-account/location (or (:account/location default-account) "Shared")
:transaction-account/amount (if (= amount-mode "%") 100.0 total)}
default-account (assoc :transaction-account/account (:db/id default-account)))]
(-> request
(assoc-in [:multi-form-state :snapshot :transaction/accounts] [new-account])
(assoc-in [:multi-form-state :step-params :transaction/accounts] [new-account])))
request)]
(html-response
(fc/start-form (:multi-form-state render-request) nil
(fc/with-field :step-params
(manual-coding-section* mode render-request))))))
```
Note: the `hx-target` on the vendor field in `manual-coding-section*` must point to `#manual-coding-section` (not `#account-grid-body`) — this was set correctly in Task 3.
- [ ] **Step 2: Verify the file compiles**
```bash
clj-nrepl-eval -p 9000 "(require '[auto-ap.ssr.transaction.edit] :reload)"
```
Expected: no errors.
- [ ] **Step 3: Commit**
```bash
git add src/clj/auto_ap/ssr/transaction/edit.clj
git commit -m "feat: update edit-vendor-changed-handler to support simple/advanced mode"
```
---
## Task 6: Fix `fc/with-cursor-index` usage in `simple-mode-fields*`
**Context:** The simple-mode fields need to emit form names like `step-params[transaction/accounts][0][transaction-account/account]`. The existing `fc/cursor-map` used in `account-grid-body*` handles this automatically. For the single-row simple mode we need to manually set index 0.
Look up how `fc/with-cursor-index` (or equivalent) works in `src/clj/auto_ap/ssr/form_cursor.clj` before writing the code in Task 2. If no such helper exists, use `fc/cursor-nth` or replicate the index manually via the form cursor API.
- [ ] **Step 1: Inspect the form cursor API**
```bash
clj-nrepl-eval -p 9000 "(clj-mcp.repl-tools/list-vars 'auto-ap.ssr.form-cursor)"
```
Note the available functions.
- [ ] **Step 2: Update `simple-mode-fields*` if needed**
If `fc/with-cursor-index` does not exist, replace the `fc/with-cursor-index 0` call in Task 2 with the correct form-cursor idiom. The key requirement is that the hidden `db/id`, `transaction-account/account`, `transaction-account/location`, and `transaction-account/amount` fields emit names matching index 0 of `transaction/accounts`.
A known working pattern from `account-grid-body*`:
```clojure
(fc/cursor-map #(transaction-account-row* {:value % ...}))
```
For simple mode with a single synthetic row, build a one-element vector in the snapshot and let `fc/cursor-map` iterate it — but render a flat div instead of a table. Or pass the cursor manually:
```clojure
(fc/with-field :transaction/accounts
(let [row-cursor (fc/cursor-nth 0)] ; adjust to actual API
(fc/with-cursor row-cursor
...field rendering...)))
```
Verify field names are correct in a browser after implementation.
- [ ] **Step 3: Verify the file compiles**
```bash
clj-nrepl-eval -p 9000 "(require '[auto-ap.ssr.transaction.edit] :reload)"
```
- [ ] **Step 4: Commit if any changes were made**
```bash
git add src/clj/auto_ap/ssr/transaction/edit.clj
git commit -m "fix: correct form-cursor indexing for simple-mode account field"
```
---
## Task 7: Manual smoke test
Before writing automated tests, verify the UI works end-to-end in a browser.
- [ ] **Step 1: Start the application**
```bash
INTEGREAT_JOB="" lein run
```
- [ ] **Step 2: Open a transaction with no accounts**
Navigate to a transaction with no coded accounts. Open the edit modal. Verify it opens in simple mode with blank account and location fields.
- [ ] **Step 3: Test vendor selection in simple mode**
Select a vendor. Verify the account field is populated with the vendor's default account and the location is set appropriately.
- [ ] **Step 4: Test toggle to advanced**
Click "Switch to advanced mode". Verify the full split table appears with one pre-populated row.
- [ ] **Step 5: Test toggle back to simple**
With 1 row, click "Switch to simple mode". Verify the single account/location fields appear with that row's values.
- [ ] **Step 6: Test with a split transaction**
Open a transaction that already has 2+ accounts. Verify it opens in advanced mode. Verify the "Switch to simple mode" link is absent.
- [ ] **Step 7: Test save round-trip**
In simple mode, set a vendor, account, and location. Save. Re-open. Verify the same values are pre-populated in simple mode.
---
## Task 8: Write e2e tests
**Files:**
- Create: `test/clj/auto_ap/ssr/transaction/edit_simple_advanced_mode_test.clj`
Check how existing e2e tests are structured first:
```bash
clj-nrepl-eval -p 9000 "(clj-mcp.repl-tools/list-ns)"
```
Look for test namespaces matching `auto-ap.ssr.transaction.*` or `auto-ap.e2e.*`. Follow the same fixture/helper patterns.
The test file should cover all 20 acceptance criteria from the spec. Group them with `testing` blocks:
```clojure
(ns auto-ap.ssr.transaction.edit-simple-advanced-mode-test
(:require
[clojure.test :refer [deftest is testing use-fixtures]]
;; ... project-specific test helpers ...
))
(deftest simple-advanced-mode-initial-state
(testing "AC1: uncoded transaction opens in simple mode"
;; create a transaction with no accounts
;; open edit modal
;; verify #manual-coding-section has mode=simple hidden input
;; verify no #account-grid-body present
(is ...))
(testing "AC2: single-account transaction opens in simple mode with values pre-populated"
...)
(testing "AC3: multi-account transaction opens in advanced mode"
...))
(deftest simple-mode-vendor-selection
(testing "AC4: selecting vendor populates account and location"
...)
(testing "AC5: selecting vendor does not overwrite manually chosen account"
...))
(deftest mode-toggle
(testing "AC9: switching to advanced carries account/location into first row"
...)
(testing "AC10: switching to advanced from blank simple gives empty table"
...)
(testing "AC11: switch-to-simple link visible with 0 or 1 rows"
...)
(testing "AC12: switch-to-simple link absent with 2+ rows"
...)
(testing "AC13: switching to simple pre-populates from first row"
...))
(deftest save-round-trip
(testing "AC6: save in simple mode persists vendor/account/location"
...)
(testing "AC18: switching modes mid-edit then saving produces valid transaction"
...)
(testing "AC19: split transaction re-opens in advanced mode with splits intact"
...)
(testing "AC20: single-account transaction re-opens in simple mode"
...))
```
Fill in actual test bodies using the project's test infrastructure (browser automation or ring mock depending on what exists).
- [ ] **Step 1: Check existing test conventions**
```bash
clj-nrepl-eval -p 9000 "(require '[auto-ap.ssr.testing-conventions] :reload)"
```
Also load the testing-conventions skill for guidance:
```
Load skill: testing-conventions
```
- [ ] **Step 2: Write the test file** following project conventions
- [ ] **Step 3: Run the tests**
```bash
clj-nrepl-eval -p 9000 "(clojure.test/run-tests 'auto-ap.ssr.transaction.edit-simple-advanced-mode-test)"
```
Expected: all tests pass.
- [ ] **Step 4: Commit**
```bash
git add test/clj/auto_ap/ssr/transaction/edit_simple_advanced_mode_test.clj
git commit -m "test: add e2e acceptance tests for simple/advanced mode"
```
---
## Self-Review Checklist (completed inline)
- **Spec coverage:** All 20 ACs addressed — Tasks 25 implement the behaviour; Task 8 tests all 20.
- **Placeholder scan:** Task 6 and Task 8 contain some "fill in" guidance — this is intentional because they depend on runtime API discovery. The instructions tell the engineer exactly where to look and what to verify.
- **Type consistency:** `manual-coding-section*` is used consistently by `LinksStep/render-step`, `edit-vendor-changed-handler`, and `edit-wizard-toggle-mode-handler`. `#manual-coding-section` is the swap target throughout. `mode` hidden input uses `(name mode)` for string serialization and `(keyword ...)` for deserialization.

View File

@@ -0,0 +1,132 @@
# Transaction Edit Modal: Simple / Advanced Mode
**Date:** 2026-05-27
**Status:** Approved
---
## Overview
The transaction editing modal gains a two-mode interface. **Simple mode** replaces the account/location split table with two single fields (account typeahead + location dropdown), suitable for the common case of a single-account transaction. **Advanced mode** exposes the existing split table for multi-account allocations. The mode is selected automatically on open based on the transaction's current state, and the user can toggle between modes via a server-rendered swap.
---
## Layout
### Simple mode
```
[ Vendor typeahead ]
[ Account typeahead ] [ Location ▼ ]
Switch to advanced mode →
[ Memo ]
[ Approval status buttons ]
```
### Advanced mode
```
[ Vendor typeahead ]
Switch to simple mode →
[ Account | Location | $ / % | ✕ ]
[ Account | Location | $ / % | ✕ ]
[ + Add row ]
[ Memo ]
[ Approval status buttons ]
```
The toggle link sits directly below the vendor field. It reads "Switch to advanced mode" in simple mode and "Switch to simple mode" in advanced mode. In advanced mode with 2+ rows, the "Switch to simple mode" link is hidden (the user must remove rows manually before they can return to simple mode). The toggle link fires `hx-get` on the `::route/edit-wizard-toggle-mode` endpoint with `hx-include="closest form"` so the current form state (including `mode`) is carried in the request.
---
## Mode Selection on Open
The server determines initial mode when rendering the `LinksStep` body:
- **0 or 1 existing account rows** → render simple mode, pre-populate the account/location fields from the existing row (blank if none).
- **2+ existing account rows** → render advanced mode with all rows populated.
---
## Toggle Mechanism (Option B — HTMX swap)
Clicking the toggle link fires an `hx-get` request to a new endpoint that re-renders the editable body of the modal in the target mode. Mode is passed as a query param (e.g., `?mode=advanced` or `?mode=simple`).
**Simple → Advanced:** The current account and location values from the simple fields are carried into the first row of the advanced table (100% of transaction total, or full dollar amount). Any additional rows previously added to the table are preserved via the multi-form-state snapshot.
**Advanced → Simple:** Only available when there is exactly 0 or 1 row in the table. The toggle link is absent when 2+ rows exist.
The swapped fragment replaces the entire editable body div (`#links-step-body` or equivalent target), keeping the side panel and modal chrome intact.
The current mode is tracked as a hidden `<input name="mode" value="simple|advanced">` inside the form. This ensures all HTMX calls that `hx-include` the form (vendor change, toggle, submit) carry the mode value without requiring it to be a separate query param.
---
## Vendor Selection Behaviour
### In simple mode
When the vendor typeahead fires its `change` event, the existing `edit-vendor-changed` HTMX endpoint is called. The response re-renders the simple-mode body with:
- The account field populated with the vendor's default account (clientized for the transaction's client).
- The location field set to the account's fixed location, or "Shared" if the account has no fixed location.
- Fields are editable; the user may override both.
The vendor default only applies when there are no existing accounts (matching existing server-side logic in `edit-vendor-changed-handler`). If the user has already manually chosen an account, changing vendor does not overwrite it.
### In advanced mode
Vendor change behaviour is unchanged from the current implementation: if no rows exist, a single row is created with the vendor's default account and location at 100% / full amount. If rows already exist, the vendor change has no effect on the table.
---
## Form Submission
Both modes submit to the same `edit-submit` endpoint. Simple mode submits the single account and location as a one-element `transaction/accounts` vector, identical in shape to what the advanced table produces today. No schema or handler changes are needed for submission.
---
## New Routes
| Method | Route key | Purpose |
|--------|-----------|---------|
| `GET` | `::route/edit-wizard-toggle-mode` | Re-renders the editable body in the requested mode. Reads current form state from `hx-include`'d form fields; the `mode` hidden input indicates the target mode (the endpoint flips it). |
The `edit-vendor-changed` endpoint reads the `mode` hidden input from the included form to determine whether to return simple-mode or advanced-mode HTML.
---
## Acceptance Criteria
These are the expected behaviours to be verified by e2e tests:
1. **Verify** that when a transaction has no coded accounts, it opens in simple mode with blank account and location fields.
2. **Verify** that when a transaction has exactly one coded account, it opens in simple mode with that account and location pre-selected.
3. **Verify** that when a transaction has two or more coded accounts, it opens in advanced mode showing the full split table.
4. **Verify** that in simple mode, selecting a vendor replaces the account field with the vendor's default account (clientized for the transaction's client) and sets the location to the account's fixed location or "Shared".
5. **Verify** that in simple mode, selecting a vendor does not overwrite an account the user has already manually chosen (i.e., the account typeahead already has a value when the vendor change fires).
6. **Verify** that after saving in simple mode, re-opening the transaction shows the same vendor, account, and location that were saved.
7. **Verify** that in simple mode, the account field is a typeahead that respects the same allowance rules as the advanced table (`:account/default-allowance`).
8. **Verify** that in simple mode, the location dropdown shows the account's fixed location (sole option) if the account has one, or the full list of client locations plus "Shared" if it does not.
9. **Verify** that clicking "Switch to advanced mode" from simple mode re-renders the form in advanced mode with one table row pre-populated from the simple-mode account and location fields.
10. **Verify** that clicking "Switch to advanced mode" from a blank simple mode (no account selected) re-renders the form in advanced mode with an empty table (no rows, just the "Add row" button).
11. **Verify** that the "Switch to simple mode" link is visible in advanced mode when there is exactly 0 or 1 row.
12. **Verify** that the "Switch to simple mode" link is absent in advanced mode when there are 2 or more rows.
13. **Verify** that clicking "Switch to simple mode" from advanced mode (1 row) re-renders the form in simple mode with that row's account and location pre-populated.
14. **Verify** that in advanced mode, selecting a vendor when there are no rows creates a single row with the vendor's default account, correct location, and 100% (or full dollar amount) allocation.
15. **Verify** that in advanced mode, selecting a vendor when rows already exist does not modify the existing rows.
16. **Verify** that the vendor default account is determined by clientizing the vendor for the client the transaction belongs to (client-specific account override takes precedence over the global vendor default).
17. **Verify** that the approval status, memo, and vendor fields are present and functional in both simple and advanced modes.
18. **Verify** that switching modes mid-edit and then saving produces a valid transaction (no orphaned or duplicated account rows).
19. **Verify** that a transaction saved in advanced mode with splits can be re-opened and remains in advanced mode with all splits intact.
20. **Verify** that a transaction saved in simple mode (single account) can be re-opened in simple mode and the single account/location are correctly pre-populated.
---
## Files Affected
| File | Change |
|------|--------|
| `src/clj/auto_ap/ssr/transaction/edit.clj` | Add simple-mode rendering functions; add `edit-wizard-toggle-mode` handler; update `edit-vendor-changed-handler` to support both modes; update `LinksStep` body render to select initial mode |
| `src/cljc/auto_ap/routes/transactions.cljc` | Add `::edit-wizard-toggle-mode` route |
| E2E test file (to be created) | Acceptance criteria tests for all 20 items above |

View File

@@ -234,7 +234,7 @@ test.describe('Bulk Code Transactions - Happy Path', () => {
await openBulkCodeModal(page); await openBulkCodeModal(page);
// Should show all transactions // Should show all transactions
await expect(page.locator('text=Bulk editing 5 transactions')).toBeVisible(); await expect(page.locator('text=Bulk editing 6 transactions')).toBeVisible();
// Add account at 100% // Add account at 100%
await addNewAccount(page); await addNewAccount(page);
@@ -263,6 +263,61 @@ test.describe('Bulk Code Transactions - Validation', () => {
await expect(page.locator('#modal-holder[x-show="open"]')).toBeVisible(); await expect(page.locator('#modal-holder[x-show="open"]')).toBeVisible();
}); });
test('should preserve vendor and status on validation error', async ({ page }) => {
await navigateToTransactions(page);
await selectTransactionByIndex(page, 0);
await openBulkCodeModal(page);
// Select vendor
const testInfo = await getTestInfo(page);
const vendorId = testInfo.accounts.vendor;
const vendorContainer = page.locator('div[hx-post*="vendor-changed"]').first();
const vendorHidden = vendorContainer.locator('input[type="hidden"]').first();
await vendorHidden.evaluate((el: HTMLInputElement, value: string) => {
const newInput = document.createElement('input');
newInput.type = 'hidden';
newInput.name = el.name;
newInput.value = value;
el.parentNode.replaceChild(newInput, el);
}, vendorId.toString());
await vendorContainer.evaluate((el: HTMLElement) => {
el.dispatchEvent(new Event('change', { bubbles: true }));
});
await page.waitForResponse(response => response.url().includes('/vendor-changed') && response.status() === 200);
await page.waitForTimeout(500);
// Select approval status
const statusSelect = page.locator('select[name="step-params[approval-status]"]').first();
await statusSelect.selectOption('approved');
// Vendor selection pre-populated a default account row at 100%.
// Modify its percentage to 50% (invalid - doesn't total 100%).
await setAccountPercentage(page, 0, '50');
await submitBulkCodeForm(page);
await page.waitForTimeout(1000);
// Modal should still be open
await expect(page.locator('#modal-holder[x-show="open"]')).toBeVisible();
// Vendor should still be selected
const vendorHiddenAfter = page.locator('input[type="hidden"][name="step-params[vendor]"]').first();
const vendorValueAfter = await vendorHiddenAfter.inputValue();
expect(vendorValueAfter).toBe(vendorId.toString());
// Status should still be selected
const statusValueAfter = await statusSelect.inputValue();
expect(statusValueAfter).toBe('approved');
// Should show validation error
const errorText = await getModalErrorText(page);
expect(errorText).toContain('does not equal 100%');
});
test('should reject when account percentages total less than 100%', async ({ page }) => { test('should reject when account percentages total less than 100%', async ({ page }) => {
await navigateToTransactions(page); await navigateToTransactions(page);
await selectTransactionByIndex(page, 0); await selectTransactionByIndex(page, 0);
@@ -447,7 +502,7 @@ test.describe('Bulk Code Transactions - Vendor Pre-population', () => {
await page.waitForSelector('table tbody tr'); await page.waitForSelector('table tbody tr');
}); });
test('should NOT pre-populate default account when user has multiple clients', async ({ page }) => { test('should pre-populate non-clientized default account when user has multiple clients', async ({ page }) => {
// Switch to multi-client mode // Switch to multi-client mode
await page.request.get('/test-set-client-mode?mode=multi-client'); await page.request.get('/test-set-client-mode?mode=multi-client');
@@ -480,13 +535,15 @@ test.describe('Bulk Code Transactions - Vendor Pre-population', () => {
await page.waitForResponse(response => response.url().includes('/vendor-changed') && response.status() === 200); await page.waitForResponse(response => response.url().includes('/vendor-changed') && response.status() === 200);
await page.waitForTimeout(500); await page.waitForTimeout(500);
// Should NOT have pre-populated account rows - only the "New account" button row // Should pre-populate the vendor's default account (non-clientized) plus the "New account" row
const accountRows = page.locator('#account-entries tbody tr'); const accountInputs = page.locator('#account-entries input[type="hidden"][name*="[account]"]');
const rowCount = await accountRows.count(); const accountInputCount = await accountInputs.count();
expect(accountInputCount).toBe(1);
// With multi-client, no pre-population should happen, so only 1 row (the "New account" button)
expect(rowCount).toBe(1); // The pre-populated account should be the vendor's raw default account (test-account)
const accountValue = await accountInputs.first().inputValue();
expect(accountValue).toBe(testInfo.accounts['test-account'].toString());
// Switch back to single-client mode for other tests // Switch back to single-client mode for other tests
await page.request.get('/test-set-client-mode?mode=single-client'); await page.request.get('/test-set-client-mode?mode=single-client');
}); });

View File

@@ -0,0 +1,389 @@
import { test, expect } from '@playwright/test';
// These tests cover the "post the whole form, hx-select what to swap" behaviour
// on the transaction edit page. Each edit hits its own route, the server
// re-renders the entire form, and the client selects what to swap back -- with
// no out-of-band swaps and no morph extension:
// - discrete changes (vendor, account, location, mode, add/remove row) swap
// all of #edit-form (the active action/tab round-trips through the form,
// so it survives the swap);
// - typed fields never swap the input the user is in -- the amount field swaps
// only the #account-totals tbody (a sibling of the input rows), and the memo
// posts with hx-swap=none.
// Because the active input is never part of a swapped region, focus and caret
// survive a plain swap.
// Collect any uncaught page errors or console errors so a swap that throws
// (e.g. a tooltip callback dereferencing a stale $refs) fails the test loudly.
function trackErrors(page: any): string[] {
const errors: string[] = [];
page.on('pageerror', (e: any) => errors.push('pageerror: ' + e.message));
page.on('console', (m: any) => {
if (m.type() === 'error') errors.push('console: ' + m.text());
});
return errors;
}
async function openManualAdvanced(page: any, transactionIndex = 0) {
await page.goto('/transaction2');
await page.waitForSelector('table tbody tr');
await page
.locator('button[hx-get*="/transaction2/"][hx-get*="/edit"]')
.nth(transactionIndex)
.click();
await page.waitForSelector('#modal-holder[x-show="open"]', { state: 'visible' });
await page.waitForSelector('#editmodal');
await page.click('button:has-text("Manual")');
// First transaction has no accounts so it opens in "simple" mode. Switch to
// advanced mode (a whole-form swap) so the account grid is present.
const advancedLink = page.locator('a:has-text("Switch to advanced mode")');
if (await advancedLink.count()) {
await advancedLink.first().click();
await page.waitForSelector('#account-grid-body');
}
}
// Drives the vendor typeahead like a user: open the dropdown, inject a result
// (Solr is unavailable in tests), click it, and wait for the whole-form swap.
async function selectVendor(page: any, vendorId: number, label: string) {
const vendor = page
.locator('div[hx-vals*="vendor-changed"]')
.first()
.locator('div.relative[x-data]')
.first();
await vendor.locator('a[x-ref="input"]').click();
const search = page.locator('[data-tippy-root] input[x-model="search"]').first();
await search.waitFor({ state: 'visible' });
await search.fill('xx');
await vendor.evaluate((el: HTMLElement, opt: { id: number; label: string }) => {
(window as any).Alpine.$data(el).elements = [{ value: opt.id, label: opt.label }];
}, { id: vendorId, label });
const swap = page.waitForResponse(
(r: any) =>
r.url().includes('edit-form-changed') &&
r.request().method() === 'POST' &&
r.status() === 200
);
await page.locator('[data-tippy-root] a', { hasText: label }).first().click();
await swap;
await page.waitForTimeout(400);
}
// Removes every existing account row (each remove is its own whole-form swap), so a
// test starts from a known-empty state regardless of what earlier tests saved
// onto the shared transaction.
async function clearAccounts(page: any) {
// eslint-disable-next-line no-constant-condition
while (true) {
const removeButtons = page.locator('#account-grid-body .account-remove-action');
const count = await removeButtons.count();
if (count === 0) break;
await removeButtons.first().click();
await expect
.poll(async () => page.locator('#account-grid-body .account-remove-action').count())
.toBeLessThan(count);
}
}
test.describe.configure({ mode: 'serial' });
test.describe('Transaction Edit whole-form swap', () => {
test('whole-form swaps (toggle mode, add account) do not throw', async ({ page }) => {
const errors = trackErrors(page);
await openManualAdvanced(page, 0);
// Add an account row -- another whole-form swap.
await page
.locator('#account-grid-body')
.locator('button:has-text("New account"), a:has-text("New account")')
.first()
.click();
await expect
.poll(async () => page.locator('#account-grid-body tbody tr.account-row').count())
.toBeGreaterThan(0);
// The form must survive the swap intact.
await expect(page.locator('#edit-form')).toHaveCount(1);
expect(errors, errors.join('\n')).toEqual([]);
});
test('keeps focus and typed value in the amount field across a swap', async ({ page }) => {
const errors = trackErrors(page);
await openManualAdvanced(page, 0);
// Ensure exactly one account row exists.
const rows = await page.locator('#account-grid-body tbody tr.account-row').count();
if (rows === 0) {
await page
.locator('#account-grid-body')
.locator('button:has-text("New account"), a:has-text("New account")')
.first()
.click();
await expect
.poll(async () => page.locator('#account-grid-body tbody tr.account-row').count())
.toBeGreaterThan(0);
}
const amount = page.locator('.account-amount-field').first();
await amount.waitFor();
// Type a clean value via the keyboard. Typing fires the field's htmx trigger
// (keyup), which posts the whole form but swaps back only the #account-totals
// tbody -- a sibling of this input's row, so the input is never replaced. It's
// type=number (no text caret), so we assert focus + node identity + value.
await amount.click();
await amount.press('Control+a');
const amountSwap = page.waitForResponse(
(r: any) =>
r.url().includes('edit-form-changed') &&
r.request().method() === 'POST' &&
r.status() === 200
);
await amount.pressSequentially('150', { delay: 40 });
// Identify the live focused node (before the debounced swap lands) so we can
// prove the *same* node survives.
await page.evaluate(() => {
(window as any).__focusedAmount = document.activeElement;
});
await amountSwap;
await page.waitForTimeout(300);
const state = await page.evaluate(() => {
const active = document.activeElement as HTMLInputElement;
return {
sameNode: active === (window as any).__focusedAmount,
isAmountField: !!active && active.classList.contains('account-amount-field'),
value: active ? active.value : null,
};
});
// Focus must stay on the amount field after the swap...
expect(state.isAmountField).toBe(true);
// ...on the very same DOM node (the input is never part of the swapped region)...
expect(state.sameNode).toBe(true);
// ...with the value the user typed left intact.
expect(state.value).toBe('150');
// The TOTAL must have recomputed server-side from the posted amount and been
// applied via the #account-totals swap.
await expect(page.locator('.account-total-row #total')).toContainText('150');
expect(errors, errors.join('\n')).toEqual([]);
});
test('memo edits issue no request and keep their value/caret', async ({ page }) => {
const errors = trackErrors(page);
// Memo affects nothing else in the form, so editing it must NOT issue a
// request at all -- its value just rides along in the form until save.
let memoRequests = 0;
page.on('request', (r: any) => {
if (r.url().includes('edit-form-changed') && r.method() === 'POST') memoRequests++;
});
await page.goto('/transaction2');
await page.waitForSelector('table tbody tr');
await page.locator('button[hx-get*="/transaction2/"][hx-get*="/edit"]').nth(0).click();
await page.waitForSelector('#editmodal');
const memo = page.locator('#edit-memo');
await memo.waitFor();
// Clear any seeded memo text and type "hello".
await memo.click();
await memo.press('Control+a');
await memo.pressSequentially('hello', { delay: 40 });
// Drop the caret in the middle and insert a char -> "heXllo", caret -> 3.
await memo.evaluate((el: HTMLInputElement) => {
el.focus();
el.setSelectionRange(2, 2);
});
await page.evaluate(() => {
(window as any).__focusedMemo = document.activeElement;
});
await memo.press('X');
// Give the old debounce window a chance to (not) fire.
await page.waitForTimeout(500);
const state = await page.evaluate(() => {
const active = document.activeElement as HTMLInputElement;
return {
sameNode: active === (window as any).__focusedMemo,
id: active ? active.id : null,
value: active ? active.value : null,
caret: active ? active.selectionStart : null,
};
});
// No request fired, and the value/caret are simply intact (nothing swapped).
expect(memoRequests).toBe(0);
expect(state.id).toBe('edit-memo');
expect(state.sameNode).toBe(true);
expect(state.value).toBe('heXllo');
expect(state.caret).toBe(3);
expect(errors, errors.join('\n')).toEqual([]);
});
test('choosing an account from the typeahead does not throw and persists', async ({ page }) => {
const errors = trackErrors(page);
await openManualAdvanced(page, 0);
// Start from a clean, empty account row so selecting the account actually
// changes accountId (and fires the change-gated whole-form swap).
await clearAccounts(page);
await page
.locator('#account-grid-body')
.locator('button:has-text("New account"), a:has-text("New account")')
.first()
.click();
await expect
.poll(async () => page.locator('#account-grid-body tbody tr.account-row').count())
.toBeGreaterThan(0);
const row = page.locator('#account-grid-body tbody tr.account-row').first();
const typeahead = row.locator('div.relative[x-data]').first();
// Open the dropdown (tippy renders the popper into [data-tippy-root]).
await typeahead.locator('a[x-ref="input"]').click();
const search = page.locator('[data-tippy-root] input[x-model="search"]').first();
await search.waitFor({ state: 'visible' });
// Account search is backed by Solr (unavailable in tests), so type under the
// 3-char threshold and inject a clickable result into the typeahead state --
// the click handler, tippy.hide(), Alpine reactivity and the HTMX swap all run
// exactly as in production.
await search.fill('te');
const testInfo = await (await page.request.get('/test-info')).json();
const accountId: number = testInfo.accounts['test-account'];
await typeahead.evaluate((el: HTMLElement, id: number) => {
(window as any).Alpine.$data(el).elements = [{ value: id, label: 'Test Account' }];
}, accountId);
// Clicking the result runs `value = element; tippy.hide(); ...` and dispatches
// the change that fires the whole-form swap.
const swap = page.waitForResponse(
(r: any) =>
r.url().includes('edit-form-changed') &&
r.request().method() === 'POST' &&
r.status() === 200
);
await page.locator('[data-tippy-root] a', { hasText: 'Test Account' }).first().click();
await swap;
await page.waitForTimeout(300);
// The chosen account must survive the whole-form swap.
const hidden = page
.locator('#account-grid-body tbody tr.account-row')
.first()
.locator('input[type="hidden"][name*="transaction-account/account"]')
.first();
await expect(hidden).toHaveValue(accountId.toString());
expect(errors, errors.join('\n')).toEqual([]);
});
test('selecting a vendor populates its default account across the swap', async ({ page }) => {
const errors = trackErrors(page);
// Open the modal in simple mode (transaction 0 has no accounts).
await page.goto('/transaction2');
await page.waitForSelector('table tbody tr');
await page.locator('button[hx-get*="/transaction2/"][hx-get*="/edit"]').nth(0).click();
await page.waitForSelector('#editmodal');
await page.click('button:has-text("Manual")');
await page.waitForSelector('div[hx-vals*="vendor-changed"]');
const testInfo = await (await page.request.get('/test-info')).json();
const vendorId: number = testInfo.accounts.vendor;
const defaultAccountId: number = testInfo.accounts['test-account'];
// Drive the vendor typeahead like a user: open dropdown, inject a result
// (Solr is unavailable in tests), click it.
const vendor = page.locator('div[hx-vals*="vendor-changed"]').first().locator('div.relative[x-data]').first();
await vendor.locator('a[x-ref="input"]').click();
const search = page.locator('[data-tippy-root] input[x-model="search"]').first();
await search.waitFor({ state: 'visible' });
await search.fill('te');
await vendor.evaluate((el: HTMLElement, id: number) => {
(window as any).Alpine.$data(el).elements = [{ value: id, label: 'Test Vendor' }];
}, vendorId);
const swap = page.waitForResponse(
(r: any) =>
r.url().includes('edit-form-changed') &&
r.request().method() === 'POST' &&
r.status() === 200
);
await page.locator('[data-tippy-root] a', { hasText: 'Test Vendor' }).first().click();
await swap;
await page.waitForTimeout(400);
// The vendor's default account must now be reflected in the account field.
// Because the section is rebuilt fresh from the server (no preserved Alpine
// state), the server-driven account value lands without any keying tricks.
const accountHidden = page
.locator('input[type="hidden"][name*="transaction-account/account"]')
.first();
await expect(accountHidden).toHaveValue(defaultAccountId.toString());
// The displayed account label should resolve too.
await expect(page.locator('span[x-text="value.label"]', { hasText: 'Test Account' })).toBeVisible();
expect(errors, errors.join('\n')).toEqual([]);
});
test('changing the vendor a second time still updates it', async ({ page }) => {
const errors = trackErrors(page);
await page.goto('/transaction2');
await page.waitForSelector('table tbody tr');
await page.locator('button[hx-get*="/transaction2/"][hx-get*="/edit"]').nth(0).click();
await page.waitForSelector('#editmodal');
await page.click('button:has-text("Manual")');
await page.waitForSelector('div[hx-vals*="vendor-changed"]');
const testInfo = await (await page.request.get('/test-info')).json();
const vendor1: number = testInfo.accounts.vendor;
const vendor2: number = testInfo.accounts.vendor2;
const account1: number = testInfo.accounts['test-account'];
const account2: number = testInfo.accounts['second-account'];
const vendorLabel = page
.locator('div[hx-vals*="vendor-changed"] span[x-text="value.label"]')
.first();
const accountHidden = page
.locator('input[type="hidden"][name*="transaction-account/account"]')
.first();
// First vendor.
await selectVendor(page, vendor1, 'Test Vendor');
await expect(vendorLabel).toHaveText('Test Vendor');
await expect(accountHidden).toHaveValue(account1.toString());
// Second vendor -- the regression guard: the section (and its vendor
// typeahead) is rebuilt fresh on every swap, so a second change still fires
// its request and updates the default account.
await selectVendor(page, vendor2, 'Second Vendor');
await expect(vendorLabel).toHaveText('Second Vendor');
await expect(accountHidden).toHaveValue(account2.toString());
// And back again, to be sure it keeps working.
await selectVendor(page, vendor1, 'Test Vendor');
await expect(vendorLabel).toHaveText('Test Vendor');
await expect(accountHidden).toHaveValue(account1.toString());
expect(errors, errors.join('\n')).toEqual([]);
});
});

View File

@@ -13,17 +13,20 @@ async function openEditModal(page: any, transactionIndex: number = 0) {
// Wait for the modal to open // Wait for the modal to open
await page.waitForSelector('#modal-holder[x-show="open"]', { state: 'visible' }); await page.waitForSelector('#modal-holder[x-show="open"]', { state: 'visible' });
await page.waitForSelector('#wizardmodal'); await page.waitForSelector('#editmodal');
// Click Next to go to the links step (button says "Transaction Actions") // The modal is now single-page (Edit Transaction). Click "Manual" tab to ensure
await page.click('button:has-text("Transaction Actions")'); // the manual account coding form is active.
// Wait for the links step to load
await page.waitForSelector('text=Transaction Actions', { state: 'visible' });
// Click on "Manual" tab
await page.click('button:has-text("Manual")'); await page.click('button:has-text("Manual")');
// Transactions with 0-1 accounts open in "simple" mode, which has no account
// grid. Switch to "advanced" mode (a whole-form morph swap) so the grid the
// rest of these helpers manipulate is present.
const advancedLink = page.locator('a:has-text("Switch to advanced mode")');
if (await advancedLink.count()) {
await advancedLink.first().click();
}
// Wait for the manual form to appear // Wait for the manual form to appear
await page.waitForSelector('#account-grid-body'); await page.waitForSelector('#account-grid-body');
} }
@@ -38,68 +41,33 @@ async function getTestInfo(page: any) {
} }
async function selectAccountFromTypeahead(page: any, rowIndex: number, accountName: string) { async function selectAccountFromTypeahead(page: any, rowIndex: number, accountName: string) {
// The account search uses Solr which isn't available in tests. // Account search is backed by Solr (unavailable in tests). Drive the typeahead the
// Instead, we directly set the hidden input value via JavaScript. // way a user does, using the Alpine v3 API: open the tippy dropdown, inject a result
// into the component's `elements`, then click it. This runs the real click handler,
// Get all rows except the new-row, total, balance, and transaction total rows // Alpine reactivity and the HTMX swap exactly as in production -- unlike poking the
const allRows = page.locator('#account-grid-body tbody tr'); // long-removed Alpine v2 `__x` internal, which silently no-ops on Alpine v3 and left
const rowCount = await allRows.count(); // the posted account empty.
// Find the row that has a hidden input for account (actual account rows)
let accountRow = null;
let accountRowIndex = 0;
for (let i = 0; i < rowCount; i++) {
const row = allRows.nth(i);
const hasAccountInput = await row.locator('input[name*="transaction-account/account"]').count() > 0;
if (hasAccountInput) {
if (accountRowIndex === rowIndex) {
accountRow = row;
break;
}
accountRowIndex++;
}
}
if (!accountRow) {
throw new Error(`Could not find account row at index ${rowIndex}`);
}
// Find the hidden input for the account
const hiddenInput = accountRow.locator('input[type="hidden"][name*="transaction-account/account"]').first();
// Get account IDs from test-info endpoint
const testInfo = await getTestInfo(page);
const accountKey = accountName === 'Test' ? 'test-account' : 'second-account'; const accountKey = accountName === 'Test' ? 'test-account' : 'second-account';
const label = `${accountName} Account`;
const testInfo = await getTestInfo(page);
const accountId = testInfo.accounts[accountKey]; const accountId = testInfo.accounts[accountKey];
if (!accountId) { if (!accountId) {
throw new Error(`Could not find account with name ${accountName}`); throw new Error(`Could not find account with name ${accountName}`);
} }
// Set the hidden input value and trigger change const row = page.locator('#account-grid-body tbody tr.account-row').nth(rowIndex);
// Also update Alpine.js data to prevent it from overwriting our value const typeahead = row.locator('div.relative[x-data]').first();
await hiddenInput.evaluate((el: HTMLInputElement, value: string) => { await typeahead.locator('a[x-ref="input"]').click();
// Set the DOM value const search = page.locator('[data-tippy-root] input[x-model="search"]').first();
el.value = value; await search.waitFor({ state: 'visible' });
await search.fill('te');
// Update Alpine.js component data await typeahead.evaluate((el: any, opt: { id: number; label: string }) => {
const alpineEl = el.closest('[x-data]'); (window as any).Alpine.$data(el).elements = [{ value: opt.id, label: opt.label }];
if (alpineEl && (alpineEl as any).__x) { }, { id: accountId, label });
(alpineEl as any).__x.$data.value.value = parseInt(value); await page.locator('[data-tippy-root] a', { hasText: label }).first().click();
(alpineEl as any).__x.$data.value.label = 'Selected Account';
} // Wait for the change-gated whole-form swap to settle.
await page.waitForTimeout(400);
// Also update any parent Alpine model (accountId)
const rowEl = el.closest('tr[x-data]');
if (rowEl && (rowEl as any).__x) {
(rowEl as any).__x.$data.accountId = parseInt(value);
}
el.dispatchEvent(new Event('change', { bubbles: true }));
}, accountId.toString());
// Wait for any HTMX updates
await page.waitForTimeout(300);
} }
async function findAccountRow(page: any, rowIndex: number) { async function findAccountRow(page: any, rowIndex: number) {
@@ -156,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);
} }
} }
@@ -177,23 +144,23 @@ async function saveTransaction(page: any) {
} }
async function toggleToPercentMode(page: any) { async function toggleToPercentMode(page: any) {
const percentRadio = page.locator('input[name="step-params[amount-mode]"][value="%"]'); const percentRadio = page.locator('input[name="amount-mode"][value="%"]');
await percentRadio.click(); await percentRadio.click();
// Wait for HTMX to swap the grid body // Wait for HTMX to swap the grid body
await page.waitForResponse(response => await page.waitForResponse(response =>
response.url().includes('/toggle-amount-mode') && response.status() === 200 response.url().includes('/edit-form-changed') && response.status() === 200
); );
await page.waitForTimeout(200); await page.waitForTimeout(200);
} }
async function toggleToDollarMode(page: any) { async function toggleToDollarMode(page: any) {
const dollarRadio = page.locator('input[name="step-params[amount-mode]"][value="$"]'); const dollarRadio = page.locator('input[name="amount-mode"][value="$"]');
await dollarRadio.click(); await dollarRadio.click();
// Wait for HTMX to swap the grid body // Wait for HTMX to swap the grid body
await page.waitForResponse(response => await page.waitForResponse(response =>
response.url().includes('/toggle-amount-mode') && response.status() === 200 response.url().includes('/edit-form-changed') && response.status() === 200
); );
await page.waitForTimeout(200); await page.waitForTimeout(200);
} }
@@ -242,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'); await selectAccountFromTypeahead(page, 0, 'Test');
const hasExistingAccount = await allRows.locator('input[name*="transaction-account/account"]').count() > 0; await setAccountAmount(page, 0, '50');
if (!hasExistingAccount) {
// Add a new account row if none exist
await addNewAccount(page);
}
// Select the account
await selectAccountFromTypeahead(page, 0, 'Test');
// Set amount to 100%
await setAccountAmount(page, 0, '100');
// Save the transaction
await saveTransaction(page);
// Step 2: Re-open and split 50/50 with two accounts
await openEditModal(page);
// Note: amount-mode is UI-only state, so it resets to $ when re-opening
// Switch back to percentage mode
await toggleToPercentMode(page);
// The existing account from step 1 should already be there
// Change its amount from 100% to 50%
await setAccountAmount(page, 0, '50');
// Add a second account at 50%
await addNewAccount(page); await addNewAccount(page);
await page.waitForTimeout(1000);
await selectAccountFromTypeahead(page, 1, 'Second'); await selectAccountFromTypeahead(page, 1, 'Second');
await setAccountAmount(page, 1, '50'); await setAccountAmount(page, 1, '50');
// Save
await saveTransaction(page); await saveTransaction(page);
// Step 3: Re-open and verify dollar amounts // Reopen: dollar mode is the default, and each account is the converted $50.
await openEditModal(page); await openEditModal(page, 0);
// The accounts should be persisted from the previous save
// Wait for accounts to load
await page.waitForTimeout(500); await page.waitForTimeout(500);
// Verify we're in dollar mode (default) const dollarRadio = page.locator('input[name="amount-mode"][value="$"]');
const dollarRadio = page.locator('input[name="step-params[amount-mode]"][value="$"]');
await expect(dollarRadio).toBeChecked(); await expect(dollarRadio).toBeChecked();
// Verify amounts are in dollars (converted from percentages on save) const val0 = await (await findAccountRow(page, 0)).locator('.account-amount-field').inputValue();
const row0 = await findAccountRow(page, 0); const val1 = await (await findAccountRow(page, 1)).locator('.account-amount-field').inputValue();
const row1 = await findAccountRow(page, 1);
const amount0 = row0.locator('.account-amount-field');
const amount1 = row1.locator('.account-amount-field');
// Each should be $50.00 (or close to it)
const val0 = await amount0.inputValue();
const val1 = await amount1.inputValue();
expect(parseFloat(val0)).toBeCloseTo(50.0, 1); expect(parseFloat(val0)).toBeCloseTo(50.0, 1);
expect(parseFloat(val1)).toBeCloseTo(50.0, 1); expect(parseFloat(val1)).toBeCloseTo(50.0, 1);
// Save
await saveTransaction(page);
}); });
}); });
@@ -344,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
@@ -372,17 +300,185 @@ async function openEditModalForTransaction(page: any, description: string) {
const editButton = row.locator('button[hx-get*="/transaction2/"][hx-get*="/edit"]').first(); const editButton = row.locator('button[hx-get*="/transaction2/"][hx-get*="/edit"]').first();
await editButton.click(); await editButton.click();
// Wait for the modal to open // Wait for the modal to open. The modal is single-page now (no multi-step wizard
// navigation), so the action tabs -- including "Link to payment" -- are available
// immediately; callers click the tab they need.
await page.waitForSelector('#modal-holder[x-show="open"]', { state: 'visible' }); await page.waitForSelector('#modal-holder[x-show="open"]', { state: 'visible' });
await page.waitForSelector('#wizardmodal'); await page.waitForSelector('#editmodal');
// Click Next to go to the links step (button says "Transaction Actions")
await page.click('button:has-text("Transaction Actions")');
// Wait for the links step to load
await page.waitForSelector('text=Transaction Actions', { state: 'visible' });
} }
async function selectVendorFromTypeahead(page: any, vendorName: string) {
const testInfo = await getTestInfo(page);
const vendorId = testInfo.accounts.vendor;
if (!vendorId) {
throw new Error(`Could not find vendor with name ${vendorName}`);
}
const vendorContainer = page.locator('div[hx-vals*="vendor-changed"]').first();
const vendorHidden = vendorContainer.locator('input[type="hidden"]').first();
await vendorHidden.evaluate((el: HTMLInputElement, value: string) => {
const newInput = document.createElement('input');
newInput.type = 'hidden';
newInput.name = el.name;
newInput.value = value;
el.parentNode.replaceChild(newInput, el);
}, vendorId.toString());
await vendorContainer.evaluate((el: HTMLElement) => {
el.dispatchEvent(new Event('change', { bubbles: true }));
});
await page.waitForResponse(response => response.url().includes('/edit-form-changed') && response.status() === 200);
await page.waitForTimeout(500);
}
test.describe('Transaction Edit Vendor Pre-population', () => {
test('should start with no account rows when transaction has no accounts', async ({ page }) => {
await openEditModal(page, 3);
await page.click('button:has-text("Manual")');
await page.waitForSelector('#account-grid-body');
// Remove any existing accounts from previous tests
await removeAllAccounts(page);
await page.waitForTimeout(1000);
const accountRows = page.locator('#account-grid-body tbody tr.account-row');
const rowCount = await accountRows.count();
expect(rowCount).toBe(0);
});
test('should pre-populate default account when vendor is selected', async ({ page }) => {
await openEditModal(page, 3);
await page.click('button:has-text("Manual")');
await page.waitForSelector('#account-grid-body');
// Remove any existing accounts from previous tests
await removeAllAccounts(page);
await page.waitForTimeout(1000);
const accountRows = page.locator('#account-grid-body tbody tr.account-row');
const initialRowCount = await accountRows.count();
expect(initialRowCount).toBe(0);
await selectVendorFromTypeahead(page, 'Test Vendor');
const rowsAfterVendor = page.locator('#account-grid-body tbody tr.account-row');
const rowCountAfter = await rowsAfterVendor.count();
expect(rowCountAfter).toBe(1);
const accountHidden = page.locator('input[type="hidden"][name*="transaction-account/account"]').first();
const accountValue = await accountHidden.inputValue();
const testInfo = await getTestInfo(page);
expect(accountValue).toBe(testInfo.accounts['test-account'].toString());
// The populated account amount should equal this transaction's amount (the vendor
// default fills the single row with the whole amount). Read the actual amount from
// the grid's transaction-total row rather than hard-coding it -- table row order is
// not pinned across same-date seed transactions.
const txTotalText = await page.locator('.account-grand-total-row').innerText();
const txTotal = parseFloat(txTotalText.replace(/[^0-9.]/g, ''));
expect(txTotal).toBeGreaterThan(0);
const amountInput = page.locator('.account-amount-field').first();
const amountValue = await amountInput.inputValue();
expect(parseFloat(amountValue)).toBeCloseTo(txTotal, 1);
});
});
// Drives the *real* vendor typeahead the way a user does: open the dropdown,
// click a rendered result. The vendor search is backed by Solr (unavailable in
// tests), so the result option is injected into the typeahead's Alpine
// `elements` instead of being fetched. Everything else -- the dropdown's own
// search input firing a native `change` on blur, the `value = element` click
// handler, the Alpine reactivity, and the HTMX round-trip to
// `edit-form-changed` (op=vendor-changed) -- runs exactly as in production. This is the flow that
// regressed: a stale native `change` from the search input used to win the race
// and revert the vendor to its previous value.
async function selectVendorViaDropdown(page: any, vendorId: number, vendorName: string) {
const wrapper = page.locator('div[hx-vals*="vendor-changed"]').first();
const typeahead = wrapper.locator('div.relative[x-data]').first();
// Open the dropdown (tippy renders the popper into [data-tippy-root]).
await typeahead.locator('a[x-ref="input"]').click();
const search = page.locator('[data-tippy-root] input[x-model="search"]').first();
await search.waitFor({ state: 'visible' });
// Type under the 3-char search threshold so no Solr request fires and clears
// our injected option, while still dirtying the input so it fires a native
// `change` on blur -- the event that used to clobber the selection.
await search.fill('te');
// Inject a clickable result into the typeahead's Alpine state.
await typeahead.evaluate(
(el: HTMLElement, opt: { id: number; label: string }) => {
(window as any).Alpine.$data(el).elements = [{ value: opt.id, label: opt.label }];
},
{ id: vendorId, label: vendorName }
);
// Click the rendered option: fires the search input's native change (stale
// value) AND the synthetic change carrying the new value, then HTMX swaps.
await page.locator('[data-tippy-root] a', { hasText: vendorName }).first().click();
await page.waitForResponse(
(response: any) =>
response.url().includes('/edit-form-changed') && response.status() === 200
);
await page.waitForTimeout(500);
}
// Opens the edit modal and activates the Manual tab, waiting on the vendor
// typeahead rather than the account grid (which only exists in advanced mode).
async function openManualVendorSection(page: any, transactionIndex: number) {
await page.goto('/transaction2');
await page.waitForSelector('table tbody tr');
const editButton = page
.locator('button[hx-get*="/transaction2/"][hx-get*="/edit"]')
.nth(transactionIndex);
await editButton.click();
await page.waitForSelector('#modal-holder[x-show="open"]', { state: 'visible' });
await page.waitForSelector('#editmodal');
await page.click('button:has-text("Manual")');
await page.waitForSelector('div[hx-vals*="vendor-changed"]');
}
test.describe('Transaction Edit Vendor Selection', () => {
test('selecting a vendor from the dropdown updates the displayed vendor', async ({ page }) => {
await openManualVendorSection(page, 3);
const testInfo = await getTestInfo(page);
const vendorId: number = testInfo.accounts.vendor;
await selectVendorViaDropdown(page, vendorId, 'Test Vendor');
// The displayed vendor label must reflect the selection after the HTMX
// round-trip. Before the fix this reverted to blank because a stale
// `change` event submitted the previous vendor and its response won.
const label = page
.locator('div[hx-vals*="vendor-changed"] span[x-text="value.label"]')
.first();
await expect(label).toHaveText('Test Vendor');
// The server-rendered hidden input must carry the newly selected vendor id.
const hidden = page
.locator(
'div[hx-vals*="vendor-changed"] input[type="hidden"][name="transaction/vendor"]'
)
.first();
await expect(hidden).toHaveValue(vendorId.toString());
});
});
test.describe('Transaction Link Date Display', () => { test.describe('Transaction Link Date Display', () => {
test('should show payment date when linking to payment', async ({ page }) => { test('should show payment date when linking to payment', async ({ page }) => {
await openEditModalForTransaction(page, 'Transaction for payment link'); await openEditModalForTransaction(page, 'Transaction for payment link');

View File

@@ -0,0 +1,125 @@
import { test, expect } from '@playwright/test';
// The SSR manual transaction import accepts the exact Yodlee positional-column
// TSV format from the master branch. Column order (14 columns), per
// auto-ap.import.manual/columns:
// 0:status 1:raw-date 2:description-original 3:high-level-category
// 4,5:(unused) 6:amount 7..11:(unused) 12:bank-account-code 13:client-code
//
// The test server (auto-ap.test-server) seeds client "TEST" with a bank
// account whose code is the deterministic "TEST-CHK" (see seed-test-data).
const IMPORT_PATH = '/transaction2/external-import-new';
function yodleeRow(opts: {
status?: string;
date?: string;
description?: string;
category?: string;
amount?: string;
bankAccountCode?: string;
clientCode?: string;
}): string {
const cols = new Array(14).fill('');
cols[0] = opts.status ?? 'POSTED';
cols[1] = opts.date ?? '';
cols[2] = opts.description ?? '';
cols[3] = opts.category ?? '';
cols[6] = opts.amount ?? '';
cols[12] = opts.bankAccountCode ?? '';
cols[13] = opts.clientCode ?? '';
return cols.join('\t');
}
function yodleeTsv(rows: string[]): string {
// First line is a header that the importer drops.
const header = new Array(14).fill('');
header[0] = 'Status';
header[1] = 'Date';
header[2] = 'Description';
header[6] = 'Amount';
header[12] = 'Bank Account';
header[13] = 'Client';
return [header.join('\t'), ...rows].join('\n');
}
async function gotoImport(page: any) {
await page.setExtraHTTPHeaders({ 'x-clients': '"mine"' });
await page.goto(IMPORT_PATH);
}
async function pasteAndParse(page: any, tsv: string) {
const textarea = page.locator('#parse-form textarea').first();
await textarea.fill(tsv);
// A visible "Parse" button submits the paste form (htmx swaps in the grid).
await page.getByRole('button', { name: /parse/i }).click();
await page.waitForTimeout(800);
}
test.describe('Manual Transaction Import (SSR)', () => {
test('renders the import page with a paste box', async ({ page }) => {
await gotoImport(page);
await expect(page.locator('#parse-form textarea').first()).toBeVisible();
});
test('paste -> parse -> review grid -> import a valid transaction', async ({ page }) => {
await gotoImport(page);
const description = 'E2E Imported Coffee';
const tsv = yodleeTsv([
yodleeRow({
date: '01/15/2024',
description,
category: 'Food',
amount: '12.50',
bankAccountCode: 'TEST-CHK',
clientCode: 'TEST',
}),
]);
await pasteAndParse(page, tsv);
// The review grid renders the parsed row as editable inputs (the
// description lives in an input value, so assert on the input, not text).
await expect(page.locator('input[value="TEST-CHK"]').first()).toBeVisible();
await expect(page.locator(`input[value="${description}"]`).first()).toBeVisible();
// Import the clean batch.
await page.getByRole('button', { name: /^import$/i }).click();
await page.waitForTimeout(1000);
// The imported transaction shows up on the transactions list.
await page.goto('/transaction2?date-range=all');
await page.waitForSelector('table tbody tr');
await expect(page.getByText(description)).toBeVisible();
});
test('blocks the whole batch when a row has an unknown bank-account code', async ({ page }) => {
await gotoImport(page);
const description = 'E2E Blocked Row';
const tsv = yodleeTsv([
yodleeRow({
date: '01/16/2024',
description,
amount: '20.00',
bankAccountCode: 'NOPE-DOES-NOT-EXIST',
clientCode: 'TEST',
}),
]);
await pasteAndParse(page, tsv);
// The grid surfaces a blocking error for the bad row. The importer reuses
// the master-branch message wording ("Cannot find bank account by code …").
await expect(page.getByText(/cannot find bank account/i).first()).toBeVisible();
// Importing does not create the transaction (batch blocked).
await page.getByRole('button', { name: /^import$/i }).click();
await page.waitForTimeout(800);
await page.goto('/transaction2?date-range=all');
await page.waitForSelector('table tbody tr');
await expect(page.getByText(description)).toHaveCount(0);
});
});

View File

@@ -74,17 +74,17 @@ test.describe('Transaction Navigation - Amount Filter Persistence', () => {
test('should persist amount filter when navigating to Client Review', async ({ page }) => { test('should persist amount filter when navigating to Client Review', async ({ page }) => {
// Step 1: Navigate to All page and set amount filter // Step 1: Navigate to All page and set amount filter
await navigateToTransactions(page, '/transaction2'); await navigateToTransactions(page, '/transaction2');
await setAmountFilter(page, '', '250'); await setAmountFilter(page, '', '500');
// Step 2: Wait for URL to update // Step 2: Wait for URL to update
await page.waitForURL(url => url.search.includes('amount-lte=250'), { timeout: 5000 }); await page.waitForURL(url => url.search.includes('amount-lte=500'), { timeout: 5000 });
// Step 3: Click Client Review nav link // Step 3: Click Client Review nav link
await clickTransactionNavLink(page, 'Client Review'); await clickTransactionNavLink(page, 'Client Review');
// Step 4: Verify filter persisted // Step 4: Verify filter persisted
const feedbackUrl = page.url(); const feedbackUrl = page.url();
expect(feedbackUrl).toContain('amount-lte=250'); expect(feedbackUrl).toContain('amount-lte=500');
}); });
}); });

View File

@@ -19,7 +19,6 @@
(update m (:db/ident (:ledger-mapped/ledger-side item)) (fnil + 0.0) (:ledger-mapped/amount item 0.0))) (update m (:db/ident (:ledger-mapped/ledger-side item)) (fnil + 0.0) (:ledger-mapped/amount item 0.0)))
{:account account} {:account account}
acc-items)))) acc-items))))
_ (clojure.pprint/pprint aggregated)
line-items (mapv (fn [{:keys [account] :as m}] line-items (mapv (fn [{:keys [account] :as m}]
(cond-> {:db/id (str (java.util.UUID/randomUUID)) (cond-> {:db/id (str (java.util.UUID/randomUUID))
:journal-entry-line/account account :journal-entry-line/account account
@@ -29,8 +28,7 @@
aggregated) aggregated)
total-debits (reduce + 0.0 (map #(get % :ledger-side/debit 0.0) aggregated)) total-debits (reduce + 0.0 (map #(get % :ledger-side/debit 0.0) aggregated))
total-credits (reduce + 0.0 (map #(get % :ledger-side/credit 0.0) aggregated)) total-credits (reduce + 0.0 (map #(get % :ledger-side/credit 0.0) aggregated))]
_ (clojure.pprint/pprint [total-debits total-credits])]
(when (and (seq line-items) (when (and (seq line-items)
(= (Math/round (* 1000 total-debits)) (= (Math/round (* 1000 total-debits))
(Math/round (* 1000 total-credits)))) (Math/round (* 1000 total-credits))))

View File

@@ -1,5 +1,11 @@
import { defineConfig, devices } from '@playwright/test'; import { defineConfig, devices } from '@playwright/test';
// Allow pointing the suite at an already-running test server (e.g. one booted from a
// specific worktree on a non-default port) via BASE_URL. When BASE_URL is set we skip
// the auto-started webServer entirely, so parallel worktrees don't fight over :3333.
const baseURL = process.env.BASE_URL ?? 'http://localhost:3333';
const useExternalServer = !!process.env.BASE_URL;
export default defineConfig({ export default defineConfig({
testDir: './e2e', testDir: './e2e',
fullyParallel: true, fullyParallel: true,
@@ -8,15 +14,17 @@ export default defineConfig({
workers: process.env.CI ? 1 : undefined, workers: process.env.CI ? 1 : undefined,
reporter: 'html', reporter: 'html',
use: { use: {
baseURL: 'http://localhost:3333', baseURL,
trace: 'on-first-retry', trace: 'on-first-retry',
}, },
webServer: { webServer: useExternalServer
command: 'lein run -m auto-ap.test-server', ? undefined
url: 'http://localhost:3333/test-info', : {
reuseExistingServer: !process.env.CI, command: 'lein run -m auto-ap.test-server',
timeout: 120000, url: 'http://localhost:3333/test-info',
}, reuseExistingServer: !process.env.CI,
timeout: 120000,
},
projects: [ projects: [
{ {
name: 'chromium', name: 'chromium',

View File

@@ -96,6 +96,7 @@
[org.clojure/core.async]] [org.clojure/core.async]]
[hiccup "2.0.0-alpha2"] [hiccup "2.0.0-alpha2"]
[selmer "1.12.61"]
;; needed for java 11 ;; needed for java 11
[javax.xml.bind/jaxb-api "2.4.0-b180830.0359"] [javax.xml.bind/jaxb-api "2.4.0-b180830.0359"]
@@ -119,6 +120,7 @@
:aliases {"build" ["do" ["uberjar"]] :aliases {"build" ["do" ["uberjar"]]
#_#_"fig:dev" ["run" "-m" "figwheel.main" "-b" "dev" "-r"] #_#_"fig:dev" ["run" "-m" "figwheel.main" "-b" "dev" "-r"]
"build-dev" ["trampoline" "run" "-m" "figwheel.main" "-b" "dev" "-r"] "build-dev" ["trampoline" "run" "-m" "figwheel.main" "-b" "dev" "-r"]
"mcp-repl" ["trampoline" "run" "-m" "dev-mcp"]
#_#_"fig:min" ["run" "-m" "figwheel.main" "-O" "whitespace" "-bo" "min"]} #_#_"fig:min" ["run" "-m" "figwheel.main" "-O" "whitespace" "-bo" "min"]}

File diff suppressed because one or more lines are too long

View File

@@ -416,4 +416,64 @@ htmx.onLoad(function(content) {
console.error('Failed to copy text to clipboard:', err); console.error('Failed to copy text to clipboard:', err);
} }
} }
/*
(function() {
var lastFocusedSelector = null;
var lastCursorPosition = null;
document.addEventListener('htmx:beforeSwap', function(evt) {
var active = document.activeElement;
if (active && active !== document.body) {
// Build a selector to find this element after swap
if (active.id) {
lastFocusedSelector = '#' + active.id;
} else if (active.name) {
lastFocusedSelector = '[name="' + active.name + '"]';
} else {
lastFocusedSelector = null;
}
// Save cursor position for text inputs. selectionStart is null on
// inputs that don't support selection (number, date, select, etc.),
// and calling setSelectionRange on those throws, so only capture it
// when it's an actual numeric caret position.
if (typeof active.selectionStart === 'number') {
lastCursorPosition = {
start: active.selectionStart,
end: active.selectionEnd,
direction: active.selectionDirection
};
} else {
lastCursorPosition = null;
}
}
});
document.addEventListener('htmx:afterSwap', function(evt) {
if (lastFocusedSelector) {
setTimeout(function() {
var el = document.querySelector(lastFocusedSelector);
// If morph already kept focus on the right element there's nothing
// to do; only restore when focus was actually lost by the swap.
if (el && el.focus && document.activeElement !== el) {
el.focus();
if (lastCursorPosition && el.setSelectionRange) {
try {
el.setSelectionRange(
lastCursorPosition.start,
lastCursorPosition.end,
lastCursorPosition.direction
);
} catch (e) { }
}
}
lastFocusedSelector = null;
lastCursorPosition = null;
}, 10);
}
});
})();
*/

File diff suppressed because one or more lines are too long

View 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>

View File

@@ -0,0 +1 @@
<a class="{{ classes }}"{{ attrs|safe }}><div class="h-4 w-4">{{ body|safe }}</div></a>

View File

@@ -0,0 +1 @@
<div class="{{ classes }}"{{ attrs|safe }}>{{ body|safe }}</div>

View File

@@ -0,0 +1 @@
<button class="{{ classes }}"{{ attrs|safe }}>{{ body|safe }}</button>

View File

@@ -0,0 +1 @@
<div class="inline-flex rounded-md shadow-sm" role="group" hx-on:click="this.querySelector(&quot;input&quot;).value = event.target.value; this.querySelector(&quot;input&quot;).dispatchEvent(new Event('change', {bubbles: true}));"><input type="hidden" name="{{ name }}">{{ body|safe }}</div>

View 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>

View File

@@ -0,0 +1 @@
<td class="px-4 py-2{% if klass %} {{ klass }}{% endif %}"{{ attrs|safe }}>{{ body|safe }}</td>

View 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>

View File

@@ -0,0 +1 @@
<tr class="{{ classes }}"{{ attrs|safe }}>{{ body|safe }}</tr>

View 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>

View 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 }}>

View File

@@ -0,0 +1 @@
<a class="{{ classes }}"{{ attrs|safe }}>{{ body|safe }}</a>

View 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>

View File

@@ -0,0 +1 @@
<div class="{{ classes }}" @click.outside="open=false"{{ attrs|safe }}>{{ body|safe }}</div>

View File

@@ -0,0 +1,2 @@
{# Money input (number, step=0.01, right-aligned). sc/money-input builds `attrs`. #}
<input{{ attrs|safe }}>

View 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>

View 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

View 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

View 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

View 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

View 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 }}>

View 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>

View 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>

View File

@@ -0,0 +1,7 @@
<div id="interop-smoke" class="p-2">
<h3>{{ title }}</h3>
{# a Hiccup-rendered component, passed in pre-rendered and emitted verbatim #}
{{ hiccup_frag|safe }}
<input x-ref="input" x-model="value.value"
@keydown.down.prevent.stop="tippy?.show()" />
</div>

View 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>

View File

@@ -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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View File

@@ -0,0 +1 @@
<div class="text-center py-4 text-gray-500">{{ message }}</div>

View 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>

View File

@@ -0,0 +1,2 @@
{# Outer wrapper for the payment tab; the unlink-payment route swaps #payment-matches. #}
<div id="payment-matches">{{ inner|safe }}</div>

View 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>

View 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>

View 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>

View File

@@ -6,6 +6,12 @@
"sourceType": "github", "sourceType": "github",
"skillPath": "skills/agent-browser/SKILL.md", "skillPath": "skills/agent-browser/SKILL.md",
"computedHash": "228f87d57035100d9dc6efcfc05aafd4b6e3962adacaa04b8217ab2fadb15dc8" "computedHash": "228f87d57035100d9dc6efcfc05aafd4b6e3962adacaa04b8217ab2fadb15dc8"
},
"frontend-design": {
"source": "anthropics/skills",
"sourceType": "github",
"skillPath": "skills/frontend-design/SKILL.md",
"computedHash": "063a0e6448123cd359ad0044cc46b0e490cc7964d45ef4bb9fd842bd2ffbca67"
} }
} }
} }

View File

@@ -66,9 +66,9 @@
]) ])
(defn not-found [_] (defn not-found [_]
{:status 404 {:status 404
:headers {} :headers {}
:body ""}) :body ""})
(defn home-handler [{:keys [identity]}] (defn home-handler [{:keys [identity]}]
(if identity (if identity
@@ -125,13 +125,13 @@
(defn wrap-logging [handler] (defn wrap-logging [handler]
(fn [request] (fn [request]
(mu/with-context (cond-> {:uri (:uri request) (mu/with-context (cond-> {:uri (:uri request)
:route (:handler (bidi.bidi/match-route all-routes :route (:handler (bidi.bidi/match-route all-routes
(:uri request) (:uri request)
:request-method (:request-method request))) :request-method (:request-method request)))
:client-selection (:client-selection request) :client-selection (:client-selection request)
:source "request" :source "request"
:query (:uri request) :query (:uri request)
:request-method (:request-method request) :request-method (:request-method request)
:user (dissoc (:identity request) :user (dissoc (:identity request)
@@ -157,15 +157,15 @@
(defn wrap-idle-session-timeout (defn wrap-idle-session-timeout
[handler] [handler]
(fn [request] (fn [request]
(let [session (:session request {:version session-version/current-session-version}) (let [session (:session request {:version session-version/current-session-version})
end-time (coerce/to-date-time (::idle-timeout session))] end-time (coerce/to-date-time (::idle-timeout session))]
(if (and end-time (time/before? end-time (time/now))) (if (and end-time (time/before? end-time (time/now)))
(if (get (:headers request) "hx-request") (if (get (:headers request) "hx-request")
{:session nil {:session nil
:status 200 :status 200
:headers {"hx-redirect" "/login"}} :headers {"hx-redirect" "/login"}}
{:session nil {:session nil
:status 302 :status 302
:headers {"Location" "/login"}}) :headers {"Location" "/login"}})
(when-let [response (handler request)] (when-let [response (handler request)]
(let [session (:session response session)] (let [session (:session response session)]
@@ -231,7 +231,7 @@
seq seq
(pull-many (dc/db conn) (pull-many (dc/db conn)
'[:db/id :client/name :client/code :client/locations '[:db/id :client/name :client/code :client/locations
:client/matches :client/feature-flags :client/matches :client/feature-flags
{:client/bank-accounts [:db/id {:client/bank-accounts [:db/id
{:bank-account/type [:db/ident]} {:bank-account/type [:db/ident]}
:bank-account/number :bank-account/number
@@ -298,7 +298,7 @@
{:status 200 {:status 200
:headers {"hx-trigger" (cheshire/generate-string :headers {"hx-trigger" (cheshire/generate-string
{"notification" (str (hiccup/html [:div (.getMessage e)]))}) {"notification" (str (hiccup/html [:div (.getMessage e)]))})
"hx-reswap" "none"}} ;; TODO make a warning box so you don't have to reuse the notifaction box, or make it reuse the same box but theme differently "hx-reswap" "none"}} ;; TODO make a warning box so you don't have to reuse the notifaction box, or make it reuse the same box but theme differently
:else :else
{:status 500 {:status 500
:body (pr-str e)}))))) :body (pr-str e)})))))
@@ -315,32 +315,48 @@
:valid-trimmed-client-ids trimmed-clients :valid-trimmed-client-ids trimmed-clients
:first-client-id (first valid-clients) :first-client-id (first valid-clients)
:clients-trimmed? (not= (count trimmed-clients) (count valid-clients))))))) :clients-trimmed? (not= (count trimmed-clients) (count valid-clients)))))))
(defn wrap-dev-login [handler]
(fn [request]
(if (and (= "/dev-login" (:uri request))
(some-> env :base-url (.contains "localhost")))
(let [identity {:user "Dev User"
:user/name "Dev User"
:user/role "admin"
:db/id 0}]
{:status 200
:headers {"Content-Type" "text/html"}
:body "<p>Logged in as Dev User!</p><a href='/dashboard'>Continue to dashboard</a>"
:session {:identity identity
:version session-version/current-session-version}})
(handler request))))
#_{:clj-kondo/ignore [:clojure-lsp/unused-public-var]} #_{:clj-kondo/ignore [:clojure-lsp/unused-public-var]}
(defonce app (defonce app
(-> route-handler (-> route-handler
(wrap-hx-current-url-params) (wrap-hx-current-url-params)
(wrap-guess-route) (wrap-guess-route)
(wrap-logging) (wrap-logging)
(wrap-trim-clients) (wrap-trim-clients)
(wrap-hydrate-clients) (wrap-hydrate-clients)
(wrap-store-client-in-session) (wrap-store-client-in-session)
(wrap-gunzip-jwt) (wrap-gunzip-jwt)
(wrap-authorization auth-backend) (wrap-dev-login)
(wrap-authentication auth-backend (wrap-authorization auth-backend)
(session-backend {:authfn (fn [auth] (wrap-authentication auth-backend
(dissoc auth :exp))})) (session-backend {:authfn (fn [auth]
(dissoc auth :exp))}))
#_(wrap-pprint-session) #_(wrap-pprint-session)
(session-version/wrap-session-version) (session-version/wrap-session-version)
(wrap-idle-session-timeout) (wrap-idle-session-timeout)
(wrap-session {:store (cookie-store (wrap-session {:store (cookie-store
{:key {:key
(byte-array (byte-array
[42, 52, -31, 101, -126, -33, -118, -69, -82, -59, -15, -69, -38, 103, -102, -1])})}) [42, 52, -31, 101, -126, -33, -118, -69, -82, -59, -15, -69, -38, 103, -102, -1])})})
#_(wrap-reload) #_(wrap-reload)
(wrap-params) (wrap-params)
(mp/wrap-multipart-params) (mp/wrap-multipart-params)
(wrap-edn-params) (wrap-edn-params)
(wrap-error))) (wrap-error)))

View File

@@ -333,7 +333,8 @@
(iol-ion.tx.upsert-sales-summary-ledger/upsert-sales-summary (dc/db conn) {:db/id 17592314241429}) (iol-ion.tx.upsert-sales-summary-ledger/upsert-sales-summary (dc/db conn) {:db/id 17592314241429})
(mark-all-dirty 5) (mark-all-dirty 14)
(delete-all) (delete-all)
(sales-summaries-v2) (sales-summaries-v2)

View File

@@ -114,27 +114,17 @@
{} {}
line-items) line-items)
total-line-amount (reduce + 0.0 (vals expense-accounts)) total-line-amount (reduce + 0.0 (vals expense-accounts))
accounts (if (zero? total-line-amount) leftover (- abs-total total-line-amount)
[] food-cost-account (get-line-account nil)
(vec (for [[account amount] expense-accounts] adjusted-accounts (if (zero? leftover)
(let [ratio (/ amount total-line-amount) expense-accounts
cents (int (Math/round (* ratio abs-total 100)))] (update expense-accounts food-cost-account (fnil + 0.0) leftover))
#:invoice-expense-account {:db/id (random-tempid) accounts (vec (for [[account amount] adjusted-accounts]
:account account #:invoice-expense-account {:db/id (random-tempid)
:location (:invoice/location invoice) :account account
:amount (* 0.01 cents)})))) :location (:invoice/location invoice)
accounts (mapv :amount (with-precision 2
(fn [a] (double (.setScale (bigdec amount) 2 java.math.RoundingMode/HALF_UP)))}))]
(update a :invoice-expense-account/amount
#(with-precision 2
(double (.setScale (bigdec %) 2 java.math.RoundingMode/HALF_UP)))))
accounts)
leftover (with-precision 2 (.round (bigdec (- abs-total
(reduce + 0.0 (map :invoice-expense-account/amount accounts))))
*math-context*))
accounts (if (seq accounts)
(update-in accounts [(dec (count accounts)) :invoice-expense-account/amount] #(+ % (double leftover)))
[])]
(dissoc (assoc invoice :invoice/expense-accounts accounts) :line-items)))) (dissoc (assoc invoice :invoice/expense-accounts accounts) :line-items))))
(defn maybe-code-line-items [invoice] (defn maybe-code-line-items [invoice]

View File

@@ -68,7 +68,7 @@
(defn invoke-glimpse2 [f] (defn invoke-glimpse2 [f]
(let [result (slurp (:payload (lambda/invoke {:client-config {:request-timeout 120000 (let [result (slurp (:payload (lambda/invoke {:client-config {:request-timeout 120000
:socket-timeout 120000}} {:function-name "glimpse2" :payload :socket-timeout 120000}} {:function-name "glimpse2-test" :payload
(json/write-str (json/write-str
(alog/peek ::x {"url" (str "https://" "data.prod.app.integreatconsult.com" "/" f)}))})))] (alog/peek ::x {"url" (str "https://" "data.prod.app.integreatconsult.com" "/" f)}))})))]

View File

@@ -62,7 +62,17 @@
(.setHandler server stats-handler)) (.setHandler server stats-handler))
(.setStopAtShutdown server true)) (.setStopAtShutdown server true))
(mount/defstate port :start (Integer/parseInt (str (or (env :port) "3000")))) (def ^:dynamic *http-port-override*
;; In dev, `lein mcp-repl` records the chosen HTTP port in `.http-port` so it
;; stays stable across reloads. `refresh` re-evaluates this def, so reading the
;; file here (rather than relying solely on an alter-var-root override that gets
;; reset) keeps the port from falling back to (env :port). Absent in prod.
(let [f (java.io.File. ".http-port")]
(when (.exists f)
(let [p (.trim ^String (slurp f))]
(when (seq p) p)))))
(mount/defstate port :start (Integer/parseInt (str (or *http-port-override* (env :port) "3000"))))
(mount/defstate jetty (mount/defstate jetty
:start (run-jetty app {:port port :start (run-jetty app {:port port
@@ -82,7 +92,7 @@
(statsd/gauge "requests.5xx" (double (.getResponses5xx (.getHandler jetty)))) (statsd/gauge "requests.5xx" (double (.getResponses5xx (.getHandler jetty))))
(.statsReset (.getHandler jetty)) (.statsReset (.getHandler jetty))
(catch Exception e (catch Exception e
(alog/warn ::cant-collect-stats :error e)))) (alog/warn ::cant-collect-stats :error e))))
#_{:clj-kondo/ignore [:clojure-lsp/unused-public-var]} #_{:clj-kondo/ignore [:clojure-lsp/unused-public-var]}
(mount/defstate jetty-stats (mount/defstate jetty-stats

View File

@@ -10,8 +10,7 @@
[bidi.bidi :as bidi] [bidi.bidi :as bidi]
[clj-time.coerce :as coerce] [clj-time.coerce :as coerce]
[clj-time.core :as time] [clj-time.core :as time]
[datomic.api :as dc] [datomic.api :as dc]))
[hiccup2.core :as hiccup]))
(defn hourly-changes [] (defn hourly-changes []
(let [tx-instant-attr (:db/id (dc/pull (dc/db conn) '[:db/id] :db/txInstant)) (let [tx-instant-attr (:db/id (dc/pull (dc/db conn) '[:db/id] :db/txInstant))
@@ -56,34 +55,68 @@
[:div [:div
[:h1.text-2xl.mb-3.font-bold "Growth in clients"] [:h1.text-2xl.mb-3.font-bold "Growth in clients"]
[:div [:div
[:div {:class "w-full h-64" [:div.w-full.h-64
:id "client-chart" [:canvas.w-full.h-full {:x-data (hx/json {:chart nil
:data-chart (hx/json {:labels ["2 years ago" "1 year ago" "today"], :labels ["2 years ago" "1 year ago" "today"]
:series [(for [n [2 1 0] :data (for [n [2 1 0]
:let [start (time/plus (time/now) (time/years (- n)))]] :let [start (time/plus (time/now) (time/years (- n)))]]
(->> (dc/q '[:find (count ?c) (->> (dc/q '[:find (count ?c)
:in $ :in $
:where [?c :client/code]] :where [?c :client/code]]
(dc/as-of (dc/db conn) (coerce/to-date start))) (dc/as-of (dc/db conn) (coerce/to-date start)))
first first
first))]})}] first))})
[:script {:lang "javascript"} :x-init "new Chart($el, {
(hiccup/raw type: 'bar',
"new Chartist.Bar('#client-chart', JSON.parse(document.getElementById('client-chart').getAttribute('data-chart')))")]]]]) data: {
labels: labels,
datasets: [{
label: 'Clients',
data: data,
borderWidth: 1
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
scales: {
y: {
beginAtZero: true
}
}
}
});"}]]]]])
(com/content-card {:class "w-1/2"} (com/content-card {:class "w-1/2"}
[:div {:class "flex flex-col px-4 py-3 space-y-3"} [:div {:class "flex flex-col px-4 py-3 space-y-3"}
[:div [:div
[:h1.text-2xl.mb-3.font-bold "Changes by hour"] [:h1.text-2xl.mb-3.font-bold "Changes by hour"]
[:div [:div
[:div {:class "w-full h-64" [:div.w-full.h-64
:id "changes" [:canvas.w-full.h-full {:x-data (hx/json {:chart nil
:data-chart (hx/json {:labels (for [n (range -24 0)] :labels (for [n (range -24 0)]
(format "%d" n)), (format "%d" n))
:series [(hourly-changes)]})}] :data (hourly-changes)})
[:script {:lang "javascript"} :x-init "new Chart($el, {
(hiccup/raw type: 'line',
"new Chartist.Line('#changes', JSON.parse(document.getElementById('changes').getAttribute('data-chart')))")]]]])]) data: {
labels: labels,
datasets: [{
label: 'Changes',
data: data,
borderWidth: 1
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
scales: {
y: {
beginAtZero: true
}
}
}
});"}]]]]])])
"Admin")) "Admin"))
(def key->handler (def key->handler

View File

@@ -35,7 +35,7 @@
default-grid-fields-schema)])) default-grid-fields-schema)]))
(defn filters [request] (defn filters [request]
[:form {"hx-trigger" "change delay:500ms, keyup changed from:.hot-filter delay:1000ms" [:form {"hx-trigger" "datesApplied, change delay:500ms, keyup changed from:.hot-filter delay:1000ms"
"hx-get" (bidi/path-for ssr-routes/only-routes "hx-get" (bidi/path-for ssr-routes/only-routes
::route/table) ::route/table)
"hx-target" "#entity-table" "hx-target" "#entity-table"

View File

@@ -1,12 +1,11 @@
(ns auto-ap.ssr.auth (ns auto-ap.ssr.auth
(:require (:require
[auto-ap.session-version :as session-version] [auto-ap.session-version :as session-version]
[auto-ap.ssr.components :as com]
[auto-ap.ssr.hx :as hx] [auto-ap.ssr.hx :as hx]
[auto-ap.ssr.svg :as svg] [auto-ap.ssr.svg :as svg]
[auto-ap.ssr.ui :refer [base-page]]
[buddy.sign.jwt :as jwt] [buddy.sign.jwt :as jwt]
[config.core :refer [env]] [config.core :refer [env]]
[hiccup2.core :as hiccup]
[hiccup.util :as hu])) [hiccup.util :as hu]))
(defn logout [request] (defn logout [request]
@@ -15,7 +14,7 @@
:session {}}) :session {}})
(defn impersonate [request] (defn impersonate [request]
{:status 200 {:status 200
:session {:identity (dissoc (jwt/unsign (get-in request [:query-params "jwt"]) :session {:identity (dissoc (jwt/unsign (get-in request [:query-params "jwt"])
(:jwt-secret env) (:jwt-secret env)
{:alg :hs512}) {:alg :hs512})
@@ -37,68 +36,73 @@
"scope" "https://www.googleapis.com/auth/userinfo.email https://www.googleapis.com/auth/userinfo.profile"} "scope" "https://www.googleapis.com/auth/userinfo.email https://www.googleapis.com/auth/userinfo.profile"}
next (assoc "state" (hu/url-encode next)))))))) next (assoc "state" (hu/url-encode next))))))))
(defn- login-page [contents]
{:status 200
:headers {"Content-Type" "text/html"}
:body (str "<!DOCTYPE html>"
(hiccup/html
[:html
[:head
[:meta {:charset "utf-8"}]
[:meta {:name "viewport" :content "width=device-width, initial-scale=1"}]
[:title "Integreat · Sign In"]
[:link {:rel "icon" :type "image/png" :href "/favicon.png"}]
[:link {:rel "stylesheet" :href "/output.css"}]
[:script {:defer true :src "https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"}]
[:style
"body{background:linear-gradient(160deg,#79b52e 0%,#009cea 100%);min-height:100vh}"]]
[:body contents]]))})
(defn- page-contents [request] (defn- page-contents [request]
[:div#app {"@notification.document" "notificationDetails=event.detail.value; showNotification=true" [:div
{:x-data (hx/json {:showError false
:errorDetails ""})
"@htmx:response-error.camel" "errorDetails = $event.detail.xhr.response; showError=true;"}
:x-data (hx/json {:showError false [:div.fixed.top-0.left-0.right-0.z-50.mx-auto.max-w-md.w-full.px-4.pt-6
:errorDetails "" {:x-show "showError"
:showNotification false "x-transition:enter" "transition duration-200 ease-out"
:notificationDetails ""}) "x-transition:enter-start" "opacity-0 -translate-y-3"
"@htmx:response-error.camel" "errorDetails = $event.detail.xhr.response; showError=true;"} "x-transition:enter-end" "opacity-100 translate-y-0"}
[:div#app-contents.flex.overflow-hidden [:div.relative.bg-white.rounded-xl.shadow-xl.border.border-red-200.p-4
[:div#main-content {:class "relative w-full h-full overflow-y-auto px-4 bg-gray-100 dark:bg-gray-900 min-h-content "} [:button.absolute.right-3.top-3.p-1.text-red-400.hover:text-red-600
[:div#notification-holder {"@click" "showError=false"}
[:div.fixed.top-0.right-0.left-0.z-30.mx-auto.max-w-screen-lg.w-screen-lg.my-0.pt-8.rounded-lg {:x-show "showNotification"} svg/filled-x]
[:div.relative [:div.flex.items-start.gap-3
[:button.absolute.right-2.top-2.w-6.h-6.z-50.text-blue-400 [:div.flex-shrink-0.w-5.h-5.text-red-500 svg/alert]
{"@click" "showNotification=false"} [:div.flex-1.min-w-0
svg/filled-x]] [:p.text-sm.font-medium.text-gray-900 "Something went wrong"]
[:p.text-xs.text-gray-500.mt-0.5
"Our team has been notified. Please try again."
[:span {:x-data (hx/json {"e" false})}
" "
[:a.text-xs.underline.cursor-pointer.text-gray-500.hover:text-gray-700
{"@click" "e=true"}
"Details"]
[:pre.text-xs.mt-1.font-mono.text-red-600.bg-red-50.p-2.rounded {:x-show "e" :x-text "errorDetails"}]]]]]]]
[:div.m-4.overflow-auto.z-30.flex.center-items.justify-center.text-blue-800.bg-blue-50.dark:bg-gray-800.dark:text-blue-400.border-blue-300.rounded-lg.border.max-h-96 [:div.flex.items-center.justify-center.min-h-screen.px-4
{:x-show "showNotification" [:div.w-full.max-w-lg
"x-transition:enter" "transition duration-300 transform ease-in-out" [:div.flex.flex-col.items-center.mb-10
"x-transition:enter-start" "opacity-0 translate-y-full" [:img {:src "/img/logo-big.png" :alt "Integreat" :class "h-16 brightness-0 invert"}]]
"x-transition:enter-end" "opacity-100 translate-y-0"
"x-transition:leave" "transition duration-300 transform ease-in-out"
"x-transition:leave-start" "opacity-100 translate-y-0"
"x-transition:leave-end" "opacity-0 translate-y-full"}
[:div {:class "p-4 text-lg w-full" :role "alert"} [:div.bg-white.rounded-2xl.shadow-2xl.p-10
[:div.text-sm {:style "animation: slideUp 0.4s ease-out forwards; opacity: 0;"}
[:pre#notification-details.text-xs {:x-html "notificationDetails"}]]]]]] [:div.flex.flex-col.items-center.gap-8
[:div {:x-show "showError" [:div.text-center
:x-init ""} [:h1.text-2xl.font-bold.text-gray-900 "Sign in to Integreat"]
[:div.fixed.top-0.right-0.left-0.z-30.mx-auto.max-w-screen-lg.w-screen-lg.my-0.pt-8.rounded-lg [:p.mt-2.text-base.text-gray-500 "Use your Google account to continue"]]
[:div.relative
[:button.absolute.right-2.top-2.w-6.h-6.z-50.text-red-600
{"@click" "showError=false"}
svg/filled-x]]
[:div.m-4.overflow-auto.z-30.flex.center-items.justify-center.text-red-800.bg-red-50.dark:bg-gray-800.dark:text-red-400.border-red-300.rounded-lg.border.max-h-96 [:a {:href (login-url (get (:query-params request) "redirect-to"))
{:x-show "showError" :class "w-full max-w-xs flex items-center justify-center gap-3 px-6 py-3.5 text-base font-semibold rounded-xl border-2 border-gray-200 text-gray-700 bg-white hover:bg-gray-50 hover:border-gray-300 shadow-md hover:shadow-lg focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-gray-400 transition-all duration-150"}
"x-transition:enter" "transition duration-300" svg/google
"x-transition:enter-start" "opacity-0" "Sign in with Google"]]
"x-transition:enter-end" "opacity-100"}
[:div {:class "p-4 mb-4 text-lg w-full" :role "alert"} [:p.mt-2.text-center.text-xs.text-gray-400
[:div.inline-block.w-8.h-8.mr-2 svg/alert] "By signing in, you agree to our "
[:span.font-medium "Oh, drat! An unexpected error has occurred."] [:a.underline.hover:text-gray-600 {:href "/terms"} "Terms of Service"]
[:div.text-sm {:x-data (hx/json {"expandError" false})} " and "
[:p "Integreat staff have been notified and are looking into it. "] [:a.underline.hover:text-gray-600 {:href "/privacy"} "Privacy Policy"]]]]]])
[:p "To see error details, " [:a.underline.cursor-pointer {"@click" "expandError=true"} "click here"] "."]
[:pre#error-details.text-xs {:x-show "expandError" :x-text "errorDetails"}]]]]]]
[:div.p-4.flex.flex-row.justify-center.items-center.h-screen
(com/card {:class "animate-slideUp"}
[:div.p-4
[:img {:src "/img/logo-big.png"}]
[:div
[:a.button.is-large.is-primary {:href (login-url (get (:query-params request) "redirect-to"))} "Login with Google"]]
"HELLO"])]]]])
(defn login [request] (defn login [request]
(base-page (login-page (page-contents request)))
request
(page-contents request)
"Dashboard"))

View File

@@ -80,9 +80,7 @@
(defn- transaction-nav-url [request route & {:keys [default-params] :or {default-params {:date-range "month"}}}] (defn- transaction-nav-url [request route & {:keys [default-params] :or {default-params {:date-range "month"}}}]
(let [preserved (transaction-nav-params request)] (let [preserved (transaction-nav-params request)]
(hu/url (bidi/path-for ssr-routes/only-routes route) (hu/url (bidi/path-for ssr-routes/only-routes route)
#_(if (or (:start-date preserved) (:end-date preserved)) {:date-range "month"})))
preserved
(merge default-params preserved)))))
(defn left-aside- [{:keys [nav page-specific]} & _] (defn left-aside- [{:keys [nav page-specific]} & _]
[:aside {:id "left-nav", [:aside {:id "left-nav",
@@ -306,6 +304,12 @@
:hx-boost "true" :hx-boost "true"
:hx-include "#transaction-filters"} :hx-include "#transaction-filters"}
"Approved") "Approved")
(when (is-admin? (:identity request))
(menu-button- {:href (bidi/path-for ssr-routes/only-routes
::transaction-routes/external-import-page)
:active? (= ::transaction-routes/external-import-page (:matched-route request))
:hx-boost "true"}
"Import"))
(when (can? (:identity request) (when (can? (:identity request)
{:subject :transaction :activity :insights}) {:subject :transaction :activity :insights})
(menu-button- {:href (bidi/path-for ssr-routes/only-routes (menu-button- {:href (bidi/path-for ssr-routes/only-routes

View File

@@ -14,5 +14,5 @@
[:section (merge params {:class (hh/add-class " py-3 sm:py-5" (:class params))}) [:section (merge params {:class (hh/add-class " py-3 sm:py-5" (:class params))})
[:div {:class (:max-w params "max-w-screen-2xl")} [:div {:class (:max-w params "max-w-screen-2xl")}
(into (into
[:div {:class "relative overflow-scroll shadow-md dark:bg-gray-800 sm:rounded-lg border-2 border-gray-200 dark:border-gray-900 bg-white"}] [:div {:class "relative overflow-auto shadow-md dark:bg-gray-800 sm:rounded-lg border-2 border-gray-200 dark:border-gray-900 bg-white"}]
children)]]) children)]])

View File

@@ -45,10 +45,10 @@
[:label {:for "checkbox-all", :class "sr-only"} "checkbox"]]]) [:label {:for "checkbox-all", :class "sr-only"} "checkbox"]]])
(defn data-grid- (defn data-grid-
[{:keys [headers thead-params id] :as params} & rest] [{:keys [headers thead-params id footer-tbody] :as params} & rest]
[:div.shrink.overflow-y-scroll [:div.shrink.overflow-y-scroll
[:table (merge {:class "w-full text-sm text-left text-gray-500 dark:text-gray-400 shrink"} [:table (merge {:class "w-full text-sm text-left text-gray-500 dark:text-gray-400 shrink"}
(dissoc params :headers :thead-params)) (dissoc params :headers :thead-params :footer-tbody))
[:thead (update thead-params :class #(-> "text-xs text-gray-800 uppercase bg-gray-50 dark:bg-gray-700 dark:text-gray-400 group-[.raw]:sticky group-[.raw]:z-10 group-[.raw]:top-0" [:thead (update thead-params :class #(-> "text-xs text-gray-800 uppercase bg-gray-50 dark:bg-gray-700 dark:text-gray-400 group-[.raw]:sticky group-[.raw]:z-10 group-[.raw]:top-0"
(hh/add-class (or % "")))) (hh/add-class (or % ""))))
(into (into
@@ -56,7 +56,11 @@
headers)] headers)]
(into (into
[:tbody {}] [:tbody {}]
rest)]]) rest)
;; Optional second <tbody> (valid HTML) so callers can keep a stable,
;; separately-swappable region in the same table -- e.g. totals rows that
;; update without touching the input-bearing rows above them.
footer-tbody]])
;; needed for tailwind ;; needed for tailwind
;; lg:table-cell md:table-cell ;; lg:table-cell md:table-cell

View File

@@ -7,7 +7,7 @@
[clj-time.core :as t] [clj-time.core :as t]
[clj-time.periodic :as per])) [clj-time.periodic :as per]))
(defn date-range-field [{:keys [value id apply-button?]}] (defn date-range-field [{:keys [value id]}]
[:div {:id id} [:div {:id id}
(com/field {:label "Date Range"} (com/field {:label "Date Range"}
[:div.space-y-4 [:div.space-y-4
@@ -17,7 +17,7 @@
(com/button-group-button {:size :small :value "week" :hx-trigger "click"} "Week") (com/button-group-button {:size :small :value "week" :hx-trigger "click"} "Week")
(com/button-group-button {:size :small :value "month" :hx-trigger "click"} "Month") (com/button-group-button {:size :small :value "month" :hx-trigger "click"} "Month")
(com/button-group-button {:size :small :value "year" :hx-trigger "click"} "Year"))] (com/button-group-button {:size :small :value "year" :hx-trigger "click"} "Year"))]
[:div.flex.space-x-1.items-baseline.w-full.justify-start [:div.flex.space-x-1.items-baseline.w-full.justify-start {"@change.stop" ""}
(com/date-input {:name "start-date" (com/date-input {:name "start-date"
:value (some-> (:start value) :value (some-> (:start value)
(atime/unparse-local atime/normal-date)) (atime/unparse-local atime/normal-date))
@@ -31,9 +31,8 @@
:placeholder "Date" :placeholder "Date"
:size :small :size :small
:class "shrink date-filter-input"}) :class "shrink date-filter-input"})
(when apply-button? (but/button- {:color :secondary
(but/button- {:color :secondary :size :small
:size :small :type "button"
:type "button" "x-on:click" "$dispatch('datesApplied')"}
"x-on:click" "$dispatch('datesApplied')"} "Apply")]])])
"Apply"))]])])

View File

@@ -51,26 +51,31 @@
{:x-init "$el.indeterminate = true"}))])) {:x-init "$el.indeterminate = true"}))]))
(defn typeahead- [params] (defn typeahead- [params]
[:div.relative {:x-data (hx/json {:baseUrl (str (:url params)) [:div.relative (cond-> {:x-data (hx/json {:baseUrl (str (:url params))
:value {:value ((:value-fn params identity) (:value params)) :label ((:content-fn params identity) (:value params))} :value {:value ((:value-fn params identity) (:value params)) :label ((:content-fn params identity) (:value params))}
:tippy nil :tippy nil
:search "" :search ""
:active -1 :active -1
:elements (if ((:value-fn params identity) (:value params)) :elements (if ((:value-fn params identity) (:value params))
[{:value ((:value-fn params identity) (:value params)) :label ((:content-fn params identity) (:value params))}] [{:value ((:value-fn params identity) (:value params)) :label ((:content-fn params identity) (:value params))}]
[])}) [])})
:x-modelable "value.value" :x-modelable "value.value"
:x-model (:x-model params)} :x-model (:x-model params)}
;; Key the component by its current value so alpine-morph re-initialises
;; it (rather than preserving stale Alpine x-data) whenever the *server*
;; changes the value -- e.g. the default account a vendor selection
;; populates. alpine-morph keys off the `key` attribute, not `id`.
(:id params) (assoc :key (str (:id params) "--" ((:value-fn params identity) (:value params)))))
(if (:disabled params) (if (:disabled params)
[:span {:x-text "value.label"}] [:span {:x-text "value.label"}]
[:a {:class (-> (hh/add-class (or (:class params) "") default-input-classes) [:a {:class (-> (hh/add-class (or (:class params) "") default-input-classes)
(hh/add-class "cursor-pointer")) (hh/add-class "cursor-pointer"))
"x-tooltip.on.click" "{content: ()=>$refs.dropdown.innerHTML, placement: 'bottom', onMount(i) {htmx.process(i.popper); }, popperOptions: {strategy: 'fixed', modifiers: [{name: 'flip', options: {fallbackPlacements: ['top']}}]}, theme: 'dropdown', allowHTML: true, interactive:true}" "x-tooltip.on.click" "{content: ()=>($refs.dropdown?.innerHTML ?? ''), placement: 'bottom', onMount(i) {htmx.process(i.popper); }, popperOptions: {strategy: 'fixed', modifiers: [{name: 'flip', options: {fallbackPlacements: ['top']}}]}, theme: 'dropdown', allowHTML: true, interactive:true}"
"@keydown.down.prevent.stop" "tippy.show();" "@keydown.down.prevent.stop" "tippy?.show();"
"@keydown.backspace" "tippy.hide(); value = {value: '', label: '' }" "@keydown.backspace" "tippy?.hide(); value = {value: '', label: '' }"
:tabindex 0 :tabindex 0
:x-init (str "$nextTick(() => tippy = $el.__x_tippy); " (:x-init params)) :x-init (str "$nextTick(() => tippy = $el.__x_tippy); " (:x-init params))
:x-ref "input"} :x-ref "input"}
[:input (-> params [:input (-> params
(dissoc :class) (dissoc :class)
(dissoc :value-fn) (dissoc :value-fn)
@@ -81,9 +86,9 @@
(assoc (assoc
"x-ref" "hidden" "x-ref" "hidden"
:type "hidden" :type "hidden"
":value" "value.value" ":value" "value.value"
:x-init (hiccup/raw (str "$watch('value', v => $dispatch('change')); "))))] :x-init (hiccup/raw (str "$watch('value', v => { $el.value = (v && v.value != null) ? v.value : ''; $nextTick(() => $dispatch('change')); }); "))))]
[:div.flex.w-full.justify-items-stretch [:div.flex.w-full.justify-items-stretch
[:span.flex-grow.text-left {"x-text" "value.label"}] [:span.flex-grow.text-left {"x-text" "value.label"}]
[:div {:class "w-3 h-3 m-1 inline ml-1 justify-self-end text-gray-500 self-center"} [:div {:class "w-3 h-3 m-1 inline ml-1 justify-self-end text-gray-500 self-center"}
@@ -93,71 +98,72 @@
:x-tooltip "value.warning"} "!")]]]) :x-tooltip "value.warning"} "!")]]])
[:template {:x-ref "dropdown"} [:template {:x-ref "dropdown"}
[:ul.dropdown-contents {:class "bg-gray-100 dark:bg-gray-600 ring-1" [:ul.dropdown-contents {:class "bg-gray-100 dark:bg-gray-600 ring-1"
"@keydown.escape" "tippy.hide(); value = {value: '', label: '' }; " "@keydown.escape" "$refs.input?.__x_tippy?.hide(); value = {value: '', label: '' }; "
:x-destroy "if ($refs.input) {$refs.input.focus();}"} :x-destroy "if ($refs.input) {$refs.input.focus();}"}
[:input {:type "text" [:input {:type "text"
:autofocus true :autofocus true
:class (-> (:class params) :class (-> (:class params)
(or "") (or "")
(hh/add-class default-input-classes) (hh/add-class default-input-classes)
(hh/replace-wildcard ["rounded" "border"] "border-bottom bg-gray-100 rounded-t-lg w-full")) (hh/replace-wildcard ["rounded" "border"] "border-bottom bg-gray-100 rounded-t-lg w-full"))
"x-model" "search" "x-model" "search"
"placeholder" (:placeholder params) "placeholder" (:placeholder params)
"@keydown.down.prevent" "active ++; active = active >= elements.length - 1 ? elements.length - 1 : active" "@change.stop" ""
"@keydown.up.prevent" "active --; active = active < 0 ? 0 : active" "@keydown.down.prevent" "active ++; active = active >= elements.length - 1 ? elements.length - 1 : active"
"@keydown.enter.prevent.stop" "tippy.hide(); value = elements.length > 0 ? $data.elements[active] : {'value': '', label: ''}; $refs.input.focus()" "@keydown.up.prevent" "active --; active = active < 0 ? 0 : active"
"x-init" "$el.focus(); $watch('search', s => { if($el.value.length > 2) {fetch(addQueryParam(baseUrl, 'q', s)).then(data=>data.json()).then(data => {elements = data; active=-1; tippy.popperInstance.update()}) }})"}] "@keydown.enter.prevent.stop" "$refs.input?.__x_tippy?.hide(); value = elements.length > 0 ? $data.elements[active >= 0 ? active : 0] : {'value': '', label: ''}; $refs.input?.focus()"
"x-init" "$el.focus(); $watch('search', s => { if($el.value.length > 2) {fetch(addQueryParam(baseUrl, 'q', s)).then(data=>data.json()).then(data => {elements = data; active=-1; $refs.input?.__x_tippy?.popperInstance?.update()}) }})"}]
[:div.dropdown-options {:class "rounded-b-lg overflow-hidden"} [:div.dropdown-options {:class "rounded-b-lg overflow-hidden"}
[:template {:x-for "(element, index) in elements"} [:template {:x-for "(element, index) in elements"}
[:li [:a {:class "px-4 py-2 flex gap-2 items-center outline-0 focus:bg-neutral-100 hover:bg-neutral-100 whitespace-nowrap [&.active]:bg-primary-500 [&.active]:dark:bg-primary-700 text-gray-800 dark:text-gray-100" [:li [:a {:class "px-4 py-2 flex gap-2 items-center outline-0 focus:bg-neutral-100 hover:bg-neutral-100 whitespace-nowrap [&.active]:bg-primary-500 [&.active]:dark:bg-primary-700 text-gray-800 dark:text-gray-100"
:href "#" :href "#"
":class" "active == index ? 'active' : ''" ":class" "active == index ? 'active' : ''"
"@mouseover" "active = index" "@mouseover" "active = index"
"@mouseout" "active = -1" "@mouseout" "active = -1"
"@click.prevent" "value = element; tippy.hide(); $refs.input.focus()" "@click.prevent" "value = element; $refs.input?.__x_tippy?.hide(); setTimeout(() => $refs.input?.focus(), 10)"
"x-html" "element.label"}]]] "x-html" "element.label"}]]]
[:template {:x-if "elements.length == 0"} [:template {:x-if "elements.length == 0"}
[:li {:class "px-4 py-2 flex gap-2 items-center outline-0 focus:bg-neutral-100 hover:bg-neutral-100 whitespace-nowrap [&.active]:bg-primary-500 text-gray-800 dark:text-gray-100 text-xs "} [:li {:class "px-4 py-2 flex gap-2 items-center outline-0 focus:bg-neutral-100 hover:bg-neutral-100 whitespace-nowrap [&.active]:bg-primary-500 text-gray-800 dark:text-gray-100 text-xs "}
"No results found"]]]]]]) "No results found"]]]]]])
(defn multi-typeahead-dropdown- [params] (defn multi-typeahead-dropdown- [params]
[:template {:x-ref "dropdown"} [:template {:x-ref "dropdown"}
[:ul.dropdown-contents {:class "bg-gray-100 dark:bg-gray-600 rounded-lg shadow-2xl w-max z-50 ring-1 p-4" [:ul.dropdown-contents {:class "bg-gray-100 dark:bg-gray-600 rounded-lg shadow-2xl w-max z-50 ring-1 p-4"
"@keydown.escape.prevent" "tippy.hide();" "@keydown.escape.prevent" "$refs.input?.__x_tippy?.hide();"
:x-destroy "if ($refs.input) {$refs.input.focus();}"} :x-destroy "if ($refs.input) {$refs.input.focus();}"}
[:div {:class (-> "relative" [:div {:class (-> "relative"
#_(hh/replace-wildcard ["rounded" "border"] "border-bottom bg-gray-100 rounded-t-lg w-full"))} #_(hh/replace-wildcard ["rounded" "border"] "border-bottom bg-gray-100 rounded-t-lg w-full"))}
[:div {:class "absolute inset-y-0 start-0 flex items-center ps-3 pointer-events-none"} [:div {:class "absolute inset-y-0 start-0 flex items-center ps-3 pointer-events-none"}
[:svg {:class "w-4 h-4 text-gray-500 dark:text-gray-400", :aria-hidden "true", :xmlns "http://www.w3.org/2000/svg", :fill "none", :viewbox "0 0 20 20"} [:svg {:class "w-4 h-4 text-gray-500 dark:text-gray-400", :aria-hidden "true", :xmlns "http://www.w3.org/2000/svg", :fill "none", :viewbox "0 0 20 20"}
[:path {:stroke "currentColor", :stroke-linecap "round", :stroke-linejoin "round", :stroke-width "2", :d "m19 19-4-4m0-7A7 7 0 1 1 1 8a7 7 0 0 1 14 0Z"}]]] [:path {:stroke "currentColor", :stroke-linecap "round", :stroke-linejoin "round", :stroke-width "2", :d "m19 19-4-4m0-7A7 7 0 1 1 1 8a7 7 0 0 1 14 0Z"}]]]
[:input {:type "text" [:input {:type "text"
:class (-> (:class params) :class (-> (:class params)
(or "") (or "")
(hh/add-class "block w-full p-2 ps-10 text-sm text-gray-900 border border-gray-300 rounded-lg bg-gray-50 focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500") (hh/add-class "block w-full p-2 ps-10 text-sm text-gray-900 border border-gray-300 rounded-lg bg-gray-50 focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500")
(hh/add-class default-input-classes)) (hh/add-class default-input-classes))
"x-model" "search" "x-model" "search"
"placeholder" (:placeholder params) "placeholder" (:placeholder params)
"@keydown.down.prevent" "active ++; active = active >= elements.length - 1 ? elements.length - 1 : active" "@keydown.down.prevent" "active ++; active = active >= elements.length - 1 ? elements.length - 1 : active"
"@keydown.up.prevent" "active --; active = active < 0 ? 0 : active" "@keydown.up.prevent" "active --; active = active < 0 ? 0 : active"
"@keydown.enter.prevent.stop" "if ($data.elements[active]) { if (value.has($data.elements[active].value)) { value.delete($data.elements[active].value) } else {value.add($data.elements[active].value); lookup[$data.elements[active].value] = $data.elements[active].label} } " "@keydown.enter.prevent.stop" "if ($data.elements[active]) { if (value.has($data.elements[active].value)) { value.delete($data.elements[active].value) } else {value.add($data.elements[active].value); lookup[$data.elements[active].value] = $data.elements[active].label} } "
"x-init" " $el.focus(); $watch('search', s => { if($el.value.length > 2) {fetch(addQueryParam(baseUrl, 'q', s)).then(data=>data.json()).then(data => reset_elements(data)) }})"}]] "x-init" " $el.focus(); $watch('search', s => { if($el.value.length > 2) {fetch(addQueryParam(baseUrl, 'q', s)).then(data=>data.json()).then(data => reset_elements(data)) }})"}]]
[:div.dropdown-options {:class "overflow-hidden divide-y divide-gray-200 "} [:div.dropdown-options {:class "overflow-hidden divide-y divide-gray-200 "}
[:template {:x-for "(element, index) in elements"} [:template {:x-for "(element, index) in elements"}
[:li {":style" "index == 0 && 'border: 0 !important;'"} [:li {":style" "index == 0 && 'border: 0 !important;'"}
[:label {:class "p-3 group rounded flex gap-2 items-center outline-0 focus:bg-neutral-100 hover:bg-neutral-100 whitespace-nowrap [&.active]:bg-primary-300 [&.active]:dark:bg-primary-700 [&.implied]:text-gray-500 text-gray-800 dark:text-gray-100 cursor-pointer" [:label {:class "p-3 group rounded flex gap-2 items-center outline-0 focus:bg-neutral-100 hover:bg-neutral-100 whitespace-nowrap [&.active]:bg-primary-300 [&.active]:dark:bg-primary-700 [&.implied]:text-gray-500 text-gray-800 dark:text-gray-100 cursor-pointer"
:href "#" :href "#"
":class" (hx/json {"active" (hx/js-fn "active==index") ":class" (hx/json {"active" (hx/js-fn "active==index")
"implied" (hx/js-fn "all_selected && index != 0")}) "implied" (hx/js-fn "all_selected && index != 0")})
"@mouseover" "active = index" "@mouseover" "active = index"
"@mouseout" "active = -1" "@mouseout" "active = -1"
"@click.prevent" "toggle(element)"} "@click.prevent" "toggle(element)"}
(checkbox- {":checked" "value.has(element.value) || all_selected" (checkbox- {":checked" "value.has(element.value) || all_selected"
:class "group-[&.implied]:bg-green-200"}) :class "group-[&.implied]:bg-green-200"})
#_[:input {:type "checkbox"}] #_[:input {:type "checkbox"}]
[:span {"x-html" "element.label"}]]]] [:span {"x-html" "element.label"}]]]]
[:template {:x-if "elements.length == 0"} [:template {:x-if "elements.length == 0"}
[:li {:class "px-4 pt-4 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 " "style" "border: 0 !important"} [:li {:class "px-4 pt-4 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 " "style" "border: 0 !important"}
"No results found"]]]]]) "No results found"]]]]])
@@ -225,7 +231,7 @@
:x-init (str "$watch('value', v => $dispatch('change')); ") :x-init (str "$watch('value', v => $dispatch('change')); ")
:search "" :search ""
:active -1 :active -1
:elements (cond-> [{:value "all" :label "All"}] :elements (cond-> [{:value "all" :label "All"}]
(sequential? (:value params)) (sequential? (:value params))
(into (map (fn [v] (into (map (fn [v]
{:value ((:value-fn params identity) v) {:value ((:value-fn params identity) v)
@@ -237,24 +243,24 @@
:x-init "value=new Set(value || []); "} :x-init "value=new Set(value || []); "}
(if (:disabled params) (if (:disabled params)
[:span {:x-text "value.label"}] [:span {:x-text "value.label"}]
[:a {:class (-> (hh/add-class (or (:class params) "") default-input-classes) [:a {:class (-> (hh/add-class (or (:class params) "") default-input-classes)
(hh/add-class "cursor-pointer")) (hh/add-class "cursor-pointer"))
"x-tooltip.on.click.prevent" "{content: ()=>$refs.dropdown.innerHTML, placement: 'bottom', onMount(i) {htmx.process(i.popper); }, popperOptions: {strategy: 'fixed', modifiers: [{name: 'flip', options: {fallbackPlacements: ['top']}}]}, theme: 'dropdown', allowHTML: true, interactive:true}" "x-tooltip.on.click.prevent" "{content: ()=>($refs.dropdown?.innerHTML ?? ''), placement: 'bottom', onMount(i) {htmx.process(i.popper); }, popperOptions: {strategy: 'fixed', modifiers: [{name: 'flip', options: {fallbackPlacements: ['top']}}]}, theme: 'dropdown', allowHTML: true, interactive:true}"
"@keydown.down.prevent.stop" "tippy.show();" "@keydown.down.prevent.stop" "$refs.input?.__x_tippy?.show();"
"@keydown.backspace" "tippy.hide(); value=new Set( []);" "@keydown.backspace" "$refs.input?.__x_tippy?.hide(); value=new Set( []);"
:tabindex 0 :tabindex 0
:x-init (str "$nextTick(() => tippy = $el.__x_tippy); " (:x-init params)) :x-init (str "$nextTick(() => tippy = $el.__x_tippy); " (:x-init params))
:x-ref "input"} :x-ref "input"}
[:template {:x-for "v in Array.from(value.values())"} [:template {:x-for "v in Array.from(value.values())"}
[:input (-> params [:input (-> params
(dissoc :class :value-fn :content-fn :placeholder :x-model) (dissoc :class :value-fn :content-fn :placeholder :x-model)
(assoc (assoc
:type "hidden" :type "hidden"
"x-bind:value" "v"))]] "x-bind:value" "v"))]]
[:template {:x-if "value.size == 0"} [:template {:x-if "value.size == 0"}
[:input (-> params [:input (-> params
(dissoc :class :value-fn :content-fn :placeholder :x-model) (dissoc :class :value-fn :content-fn :placeholder :x-model)
(assoc :type "hidden" (assoc :type "hidden"
:value ""))]] :value ""))]]
[:div.flex.w-full.justify-items-stretch [:div.flex.w-full.justify-items-stretch
(multi-typeahead-selected-pill- params) (multi-typeahead-selected-pill- params)
@@ -296,23 +302,23 @@
(defn money-input- [{:keys [size] :as params}] (defn money-input- [{:keys [size] :as params}]
[:input [:input
(-> params (-> params
(update :class (fnil hh/add-class "") default-input-classes) (update :class (fnil hh/add-class "") default-input-classes)
(update :class hh/add-class "appearance-none text-right") (update :class hh/add-class "appearance-none text-right")
(update :class #(str % (use-size size))) (update :class #(str % (use-size size)))
(assoc :type "number" (assoc :type "number"
:step "0.01") :step "0.01")
(dissoc :size))]) (dissoc :size))])
(defn int-input- [{:keys [size] :as params}] (defn int-input- [{:keys [size] :as params}]
[:input [:input
(-> params (-> params
(update :class (fnil hh/add-class "") default-input-classes) (update :class (fnil hh/add-class "") default-input-classes)
(update :class hh/add-class "appearance-none text-right") (update :class hh/add-class "appearance-none text-right")
(update :class #(str % (use-size size))) (update :class #(str % (use-size size)))
(assoc :type "number" (assoc :type "number"
:step "1") :step "1")
(dissoc :size))]) (dissoc :size))])
(defn date-input- [{:keys [size] :as params}] (defn date-input- [{:keys [size] :as params}]
[:div.shrink {:x-data (hx/json {:value (:value params) [:div.shrink {:x-data (hx/json {:value (:value params)
@@ -321,40 +327,40 @@
"x-effect" "console.log('changed to' +value)" "x-effect" "console.log('changed to' +value)"
"@change-date.camel" "$dispatch('change')"} "@change-date.camel" "$dispatch('change')"}
[:input [:input
(-> params (-> params
(update :class (fnil hh/add-class "") default-input-classes) (update :class (fnil hh/add-class "") default-input-classes)
(assoc :x-model "value") (assoc :x-model "value")
(assoc "x-tooltip.on.focus" "{content: ()=>$refs.tooltip.innerHTML, theme: 'light', onMount(i) { htmx.process(i.popper); }, allowHTML: true, interactive:true}") (assoc "x-tooltip.on.focus" "{content: ()=>($refs.tooltip?.innerHTML ?? ''), theme: 'light', onMount(i) { htmx.process(i.popper); }, allowHTML: true, interactive:true}")
(assoc :x-init "$nextTick(() => tippy = $el.__x_tippy); ") (assoc :x-init "$nextTick(() => tippy = $el.__x_tippy); ")
(assoc :type "text") (assoc :type "text")
(assoc "autocomplete" "off") (assoc "autocomplete" "off")
(assoc "@change" "value = $event.target.value;") (assoc "@change" "value = $event.target.value;")
(assoc "@keydown.escape" "tippy.hide(); ") (assoc "@keydown.escape" "$el?.__x_tippy?.hide(); ")
#_(assoc "hx-on" (hiccup/raw "changeDate: htmx.trigger(this, \"change\") ")) #_(assoc "hx-on" (hiccup/raw "changeDate: htmx.trigger(this, \"change\") "))
(update :class #(str % (use-size size) " w-full")) (update :class #(str % (use-size size) " w-full"))
(dissoc :size))] (dissoc :size))]
[:template {:x-ref "tooltip"} [:template {:x-ref "tooltip"}
[:div.shrink [:div.shrink
[:div [:div
(-> params (-> params
(update :class (fnil hh/add-class "") default-input-classes) (update :class (fnil hh/add-class "") default-input-classes)
(assoc :type "text") (assoc :type "text")
(assoc :value (:value params)) (assoc :value (:value params))
;; the data-date field has to be bound before the datepicker can be initialized ;; the data-date field has to be bound before the datepicker can be initialized
(assoc :x-init "$nextTick(() => { dp = initCalendar($el); ;}); ") (assoc :x-init "$nextTick(() => { dp = initCalendar($el); ;}); ")
(assoc "x-effect" "if(dp) { dp.setDate(value); } ") (assoc "x-effect" "if(dp) { dp.setDate(value); } ")
(assoc ":data-date" "value") (assoc ":data-date" "value")
(assoc "@htmx:before-history-save" "destroyDatepicker(dp)") (assoc "@htmx:before-history-save" "destroyDatepicker(dp)")
(assoc "@htmx:before-cleanup-element" "destroyDatepicker(dp)") (assoc "@htmx:before-cleanup-element" "destroyDatepicker(dp)")
(assoc "x-destroy" "destroyDatepicker(dp)") (assoc "x-destroy" "destroyDatepicker(dp)")
(assoc "@change-date.camel" "value = dp.getDate(\"mm/dd/yyyy\");") (assoc "@change-date.camel" "value = dp.getDate(\"mm/dd/yyyy\");")
(update :class #(str % (use-size size) " w-full")) (update :class #(str % (use-size size) " w-full"))
(dissoc :size :name :x-model :x-modelable))]]]]) (dissoc :size :name :x-model :x-modelable))]]]])
(defn multi-calendar-input- [{:keys [size] :as params}] (defn multi-calendar-input- [{:keys [size] :as params}]
(let [value (str/join ", " (let [value (str/join ", "
@@ -368,21 +374,21 @@
[:template {:x-for "v in value"} [:template {:x-for "v in value"}
[:input {:type "hidden" :name (:name params) :x-model "v"}]] [:input {:type "hidden" :name (:name params) :x-model "v"}]]
[:div [:div
(-> params (-> params
(update :class (fnil hh/add-class "") default-input-classes) (update :class (fnil hh/add-class "") default-input-classes)
(assoc :type "text") (assoc :type "text")
(assoc :value value) (assoc :value value)
;; the data-date field has to be bound before the datepicker can be initialized ;; the data-date field has to be bound before the datepicker can be initialized
(assoc :x-init "$nextTick(() => { dp = initMultiDatepicker($el, value); ;}); ") (assoc :x-init "$nextTick(() => { dp = initMultiDatepicker($el, value); ;}); ")
(assoc "x-effect" "if(dp) { dp.setDate(Array.from(value), {clear: true}); } ") (assoc "x-effect" "if(dp) { dp.setDate(Array.from(value), {clear: true}); } ")
(assoc ":data-date" "Array.prototype.join.call(value, ', ')") (assoc ":data-date" "Array.prototype.join.call(value, ', ')")
(assoc "@htmx:before-history-save" "destroyDatepicker(dp)") (assoc "@htmx:before-history-save" "destroyDatepicker(dp)")
(assoc "@htmx:before-cleanup-element" "destroyDatepicker(dp)") (assoc "@htmx:before-cleanup-element" "destroyDatepicker(dp)")
(assoc "x-destroy" "destroyDatepicker(dp)") (assoc "x-destroy" "destroyDatepicker(dp)")
(assoc "@change-date.camel" "value = dp.getDate(\"mm/dd/yyyy\");") (assoc "@change-date.camel" "value = dp.getDate(\"mm/dd/yyyy\");")
(update :class #(str % (use-size size) " w-full")) (update :class #(str % (use-size size) " w-full"))
(dissoc :size :name :x-model :x-modelable))]])) (dissoc :size :name :x-model :x-modelable))]]))
(defn calendar-input- [{:keys [size] :as params}] (defn calendar-input- [{:keys [size] :as params}]
(let [value (:value params)] (let [value (:value params)]
@@ -392,21 +398,21 @@
:x-model (:x-model params)} :x-model (:x-model params)}
[:input {:type "hidden" :name (:name params) :x-model "value"}] [:input {:type "hidden" :name (:name params) :x-model "value"}]
[:div [:div
(-> params (-> params
(update :class (fnil hh/add-class "") default-input-classes) (update :class (fnil hh/add-class "") default-input-classes)
(assoc :type "text") (assoc :type "text")
(assoc :value value) (assoc :value value)
;; the data-date field has to be bound before the datepicker can be initialized ;; the data-date field has to be bound before the datepicker can be initialized
(assoc :x-init "$nextTick(() => { dp = initCalendar($el); ;}); ") (assoc :x-init "$nextTick(() => { dp = initCalendar($el); ;}); ")
(assoc "x-effect" "if(dp) { dp.setDate(value); } ") (assoc "x-effect" "if(dp) { dp.setDate(value); } ")
(assoc ":data-date" "value") (assoc ":data-date" "value")
(assoc "@htmx:before-history-save" "destroyDatepicker(dp)") (assoc "@htmx:before-history-save" "destroyDatepicker(dp)")
(assoc "@htmx:before-cleanup-element" "destroyDatepicker(dp)") (assoc "@htmx:before-cleanup-element" "destroyDatepicker(dp)")
(assoc "x-destroy" "destroyDatepicker(dp)") (assoc "x-destroy" "destroyDatepicker(dp)")
(assoc "@change-date.camel" "value = dp.getDate(\"mm/dd/yyyy\");") (assoc "@change-date.camel" "value = dp.getDate(\"mm/dd/yyyy\");")
(update :class #(str % (use-size size) " w-full")) (update :class #(str % (use-size size) " w-full"))
(dissoc :size :name :x-model :x-modelable))]])) (dissoc :size :name :x-model :x-modelable))]]))
(defn field-errors- [{:keys [source key]} & rest] (defn field-errors- [{:keys [source key]} & rest]
(let [errors (:errors (cond-> (meta source) (let [errors (:errors (cond-> (meta source)

View 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)})))

View File

@@ -56,7 +56,6 @@
(defn field-errors (defn field-errors
([] ([]
(println "CURRENT IS" *current*)
(field-errors *current*)) (field-errors *current*))
([cursor] ([cursor]
(get-in *form-errors* (cursor/path cursor)))) (get-in *form-errors* (cursor/path cursor))))

View File

@@ -56,7 +56,7 @@
[:div {:id "exact-match-id-tag"}])) [:div {:id "exact-match-id-tag"}]))
(defn filters [request] (defn filters [request]
[:form#invoice-filters {"hx-trigger" "change delay:500ms, keyup changed from:.hot-filter delay:1000ms" [:form#invoice-filters {"hx-trigger" "datesApplied, change delay:500ms, keyup changed from:.hot-filter delay:1000ms"
"hx-get" (bidi/path-for ssr-routes/only-routes "hx-get" (bidi/path-for ssr-routes/only-routes
::route/import-table) ::route/import-table)
"hx-target" "#entity-table" "hx-target" "#entity-table"

View File

@@ -11,10 +11,10 @@
[auto-ap.routes.transactions :as transaction-routes] [auto-ap.routes.transactions :as transaction-routes]
[auto-ap.ssr-routes :as ssr-routes] [auto-ap.ssr-routes :as ssr-routes]
[auto-ap.ssr.components :as com] [auto-ap.ssr.components :as com]
[auto-ap.ssr.components.date-range :as dr]
[auto-ap.ssr.components.link-dropdown :refer [link-dropdown]] [auto-ap.ssr.components.link-dropdown :refer [link-dropdown]]
[auto-ap.ssr.grid-page-helper :as helper] [auto-ap.ssr.grid-page-helper :as helper]
[auto-ap.ssr.hx :as hx] [auto-ap.ssr.hx :as hx]
[auto-ap.ssr.pos.common :refer [date-range-field*]]
[auto-ap.ssr.svg :as svg] [auto-ap.ssr.svg :as svg]
[auto-ap.ssr.utils [auto-ap.ssr.utils
:refer [clj-date-schema entity-id html-response ref->enum-schema :refer [clj-date-schema entity-id html-response ref->enum-schema
@@ -31,7 +31,7 @@
(defn exact-match-id* [request] (defn exact-match-id* [request]
(if (nat-int? (:exact-match-id (:query-params request))) (if (nat-int? (:exact-match-id (:query-params request)))
[:div {:x-data (hx/json {:exact_match (:exact-match-id (:query-params request))}) :id "exact-match-id-tag"} [:div {:x-data (hx/json {:exact_match (:exact-match-id (:query-params request))}) :id "exact-match-id-tag" :class "filter-trigger"}
(com/hidden {:name "exact-match-id" (com/hidden {:name "exact-match-id"
"x-model" "exact_match"}) "x-model" "exact_match"})
(com/pill {:color :primary} (com/pill {:color :primary}
@@ -46,13 +46,14 @@
[:div {:hx-trigger "clientSelected from:body" [:div {:hx-trigger "clientSelected from:body"
:hx-get (bidi.bidi/path-for ssr-routes/only-routes ::route/bank-account-filter) :hx-get (bidi.bidi/path-for ssr-routes/only-routes ::route/bank-account-filter)
:hx-target "this" :hx-target "this"
:hx-swap "outerHTML"} :hx-swap "outerHTML"
:class "filter-trigger"}
(when (:client request) (when (:client request)
(let [bank-account-belongs-to-client? (get (set (map :db/id (:client/bank-accounts (:client request)))) (let [bank-account-belongs-to-client? (get (set (map :db/id (:client/bank-accounts (:client request))))
(:db/id (:bank-account (:query-params request))))] (:db/id (:bank-account (:query-params request))))]
(com/field {:label "Bank Account"} (com/field {:label "Bank Account"}
(com/radio-card {:size :small (com/radio-card {:size :small
:name "bank-account" :name "bank-account"
:value (or (when bank-account-belongs-to-client? :value (or (when bank-account-belongs-to-client?
(:db/id (:bank-account (:query-params request)))) (:db/id (:bank-account (:query-params request))))
"") "")
@@ -60,90 +61,96 @@
(into [{:value "" (into [{:value ""
:content "All"}] :content "All"}]
(for [ba (:client/bank-accounts (:client request))] (for [ba (:client/bank-accounts (:client request))]
{:value (:db/id ba) {:value (:db/id ba)
:content (:bank-account/name ba)}))}))))]) :content (:bank-account/name ba)}))}))))])
(defn bank-account-filter [request] (defn bank-account-filter [request]
(html-response (bank-account-filter* request))) (html-response (bank-account-filter* request)))
(defn filters [request] (defn filters [request]
[:form#ledger-filters {"hx-trigger" "change delay:500ms, keyup changed from:.hot-filter delay:1000ms" [:form#ledger-filters {"hx-trigger" "datesApplied, change delay:500ms from:.filter-trigger, keyup changed from:.hot-filter delay:1000ms"
"hx-get" (bidi/path-for ssr-routes/only-routes "hx-get" (bidi/path-for ssr-routes/only-routes
::route/table) ::route/table)
"hx-target" "#entity-table" "hx-target" "#entity-table"
"hx-indicator" "#entity-table"} "hx-indicator" "#entity-table"}
(com/hidden {:name "status" (com/hidden {:name "status"
:value (some-> (:status (:query-params request)) name)}) :value (some-> (:status (:query-params request)) name)})
[:fieldset.space-y-6 [:fieldset.space-y-6
(com/field {:label "Vendor"} (com/field {:label "Vendor"}
(com/typeahead {:name "vendor" (com/typeahead {:name "vendor"
:id "vendor" :id "vendor"
:url (bidi/path-for ssr-routes/only-routes :vendor-search) :url (bidi/path-for ssr-routes/only-routes :vendor-search)
:value (:vendor (:query-params request)) :value (:vendor (:query-params request))
:value-fn :db/id :value-fn :db/id
:content-fn :vendor/name})) :content-fn :vendor/name
:class "filter-trigger"}))
(com/field {:label "Account"} (com/field {:label "Account"}
(com/typeahead {:name "account" (com/typeahead {:name "account"
:id "account" :id "account"
:url (bidi/path-for ssr-routes/only-routes :account-search) :url (bidi/path-for ssr-routes/only-routes :account-search)
:value (:account (:query-params request)) :value (:account (:query-params request))
:value-fn :db/id :value-fn :db/id
:content-fn #(:account/name (d-accounts/clientize (dc/pull (dc/db conn) d-accounts/default-read (:db/id %)) :content-fn #(:account/name (d-accounts/clientize (dc/pull (dc/db conn) d-accounts/default-read (:db/id %))
(:db/id (:client request))))})) (:db/id (:client request))))
:class "filter-trigger"}))
(bank-account-filter* request) (bank-account-filter* request)
(date-range-field* request) (dr/date-range-field {:value {:start (:start-date (:query-params request))
:end (:end-date (:query-params request))}
:id "date-range"
:apply-button? true})
(com/field {:label "Invoice #"} (com/field {:label "Invoice #"}
(com/text-input {:name "invoice-number" (com/text-input {:name "invoice-number"
:id "invoice-number" :id "invoice-number"
:class "hot-filter" :class "hot-filter"
:value (:invoice-number (:query-params request)) :value (:invoice-number (:query-params request))
:placeholder "e.g., ABC-456" :placeholder "e.g., ABC-456"
:size :small})) :size :small}))
(com/field {:label "Account Code"} (com/field {:label "Account Code"}
[:div.flex.space-x-4.items-baseline [:div.flex.space-x-4.items-baseline
(com/int-input {:name "numeric-code-gte" (com/int-input {:name "numeric-code-gte"
:id "numeric-code-gte" :id "numeric-code-gte"
:hx-preserve "true" :hx-preserve "true"
:class "hot-filter w-20" :class "hot-filter w-20"
:value (:numeric-code-gte (:query-params request)) :value (:numeric-code-gte (:query-params request))
:placeholder "40000" :placeholder "40000"
:size :small}) :size :small})
[:div.align-baseline [:div.align-baseline
"to"] "to"]
(com/int-input {:name "numeric-code-lte" (com/int-input {:name "numeric-code-lte"
:hx-preserve "true" :hx-preserve "true"
:id "numeric-code-lte" :id "numeric-code-lte"
:class "hot-filter w-20" :class "hot-filter w-20"
:value (:numeric-code-lte (:query-params request)) :value (:numeric-code-lte (:query-params request))
:placeholder "50000" :placeholder "50000"
:size :small})]) :size :small})])
(com/field {:label "Amount"} (com/field {:label "Amount"}
[:div.flex.space-x-4.items-baseline [:div.flex.space-x-4.items-baseline
(com/money-input {:name "amount-gte" (com/money-input {:name "amount-gte"
:id "amount-gte" :id "amount-gte"
:hx-preserve "true" :hx-preserve "true"
:class "hot-filter w-20" :class "hot-filter w-20"
:value (:amount-gte (:query-params request)) :value (:amount-gte (:query-params request))
:placeholder "0.01" :placeholder "0.01"
:size :small}) :size :small})
[:div.align-baseline [:div.align-baseline
"to"] "to"]
(com/money-input {:name "amount-lte" (com/money-input {:name "amount-lte"
:hx-preserve "true" :hx-preserve "true"
:id "amount-lte" :id "amount-lte"
:class "hot-filter w-20" :class "hot-filter w-20"
:value (:amount-lte (:query-params request)) :value (:amount-lte (:query-params request))
:placeholder "9999.34" :placeholder "9999.34"
:size :small})]) :size :small})])
[:div.mt-4 {:x-data (hx/json {:onlyUnbalanced (:only-unbalanced (:query-params request))})} [:div.mt-4 {:x-data (hx/json {:onlyUnbalanced (:only-unbalanced (:query-params request))})}
(com/hidden {:name "only-unbalanced" (com/hidden {:name "only-unbalanced"
":value" "onlyUnbalanced ? 'on' : ''"}) ":value" "onlyUnbalanced ? 'on' : ''"})
(com/checkbox {:value (:only-unbalanced (:query-params request)) (com/checkbox {:value (:only-unbalanced (:query-params request))
:class "filter-trigger"
:x-model "onlyUnbalanced"} :x-model "onlyUnbalanced"}
"Show unbalanced")] "Show unbalanced")]
(exact-match-id* request)]]) (exact-match-id* request)]])
@@ -184,12 +191,12 @@
args query-params args query-params
query query
(if (:exact-match-id args) (if (:exact-match-id args)
{:query {:find '[?e] {:query {:find '[?e]
:in '[$ ?e [?c ...]] :in '[$ ?e [?c ...]]
:where '[[?e :journal-entry/client ?c]]} :where '[[?e :journal-entry/client ?c]]}
:args [db :args [db
(:exact-match-id args) (:exact-match-id args)
valid-clients]} valid-clients]}
(cond-> {:query {:find [] (cond-> {:query {:find []
:in ['$ '[?clients ?start ?end]] :in ['$ '[?clients ?start ?end]]
:where '[[(iol-ion.query/scan-ledger $ ?clients ?start ?end) [[?e _ ?sort-default] ...]]]} :where '[[(iol-ion.query/scan-ledger $ ?clients ?start ?end) [[?e _ ?sort-default] ...]]]}
@@ -202,28 +209,28 @@
(merge-query {:query {:where ['(not [?e :journal-entry/original-entity])]}}) (merge-query {:query {:where ['(not [?e :journal-entry/original-entity])]}})
(seq (:external-id-like args)) (seq (:external-id-like args))
(merge-query {:query {:in ['?external-id-like] (merge-query {:query {:in ['?external-id-like]
:where ['[?e :journal-entry/external-id ?external-id] :where ['[?e :journal-entry/external-id ?external-id]
'[(.contains ^String ?external-id ?external-id-like)]]} '[(.contains ^String ?external-id ?external-id-like)]]}
:args [(:external-id-like args)]}) :args [(:external-id-like args)]})
(seq (:source args)) (seq (:source args))
(merge-query {:query {:in ['?source] (merge-query {:query {:in ['?source]
:where ['[?e :journal-entry/source ?source]]} :where ['[?e :journal-entry/source ?source]]}
:args [(:source args)]}) :args [(:source args)]})
(:external? route-params) (:external? route-params)
(merge-query {:query {:where ['[?e :journal-entry/external-id]]}}) (merge-query {:query {:where ['[?e :journal-entry/external-id]]}})
(:vendor args) (:vendor args)
(merge-query {:query {:in ['?vendor-id] (merge-query {:query {:in ['?vendor-id]
:where ['[?e :journal-entry/vendor ?vendor-id]]} :where ['[?e :journal-entry/vendor ?vendor-id]]}
:args [(:db/id (:vendor args))]}) :args [(:db/id (:vendor args))]})
(:invoice-number args) (:invoice-number args)
(merge-query {:query {:in ['?invoice-number] (merge-query {:query {:in ['?invoice-number]
:where ['[?e :journal-entry/original-entity ?oe] :where ['[?e :journal-entry/original-entity ?oe]
'[?oe :invoice/invoice-number ?invoice-number]]} '[?oe :invoice/invoice-number ?invoice-number]]}
:args [(:invoice-number args)]}) :args [(:invoice-number args)]})
(or (:numeric-code-lte args) (or (:numeric-code-lte args)
(:numeric-code-gte args) (:numeric-code-gte args)
@@ -235,77 +242,77 @@
(or (:numeric-code-gte args) (or (:numeric-code-gte args)
(:numeric-code-lte args)) (:numeric-code-lte args))
(merge-query {:query {:in '[?from-numeric-code ?to-numeric-code] (merge-query {:query {:in '[?from-numeric-code ?to-numeric-code]
:where ['[?li :journal-entry-line/account ?a] :where ['[?li :journal-entry-line/account ?a]
'(or-join [?a ?c] '(or-join [?a ?c]
[?a :account/numeric-code ?c] [?a :account/numeric-code ?c]
[?a :bank-account/numeric-code ?c]) [?a :bank-account/numeric-code ?c])
'[(>= ?c ?from-numeric-code)] '[(>= ?c ?from-numeric-code)]
'[(<= ?c ?to-numeric-code)]]} '[(<= ?c ?to-numeric-code)]]}
:args [(or (:numeric-code-gte args) 0) (or (:numeric-code-lte args) 99999)]}) :args [(or (:numeric-code-gte args) 0) (or (:numeric-code-lte args) 99999)]})
(seq (:numeric-code args)) (seq (:numeric-code args))
(merge-query {:query {:in '[[[?from-numeric-code ?to-numeric-code] ...]] (merge-query {:query {:in '[[[?from-numeric-code ?to-numeric-code] ...]]
:where ['[?li :journal-entry-line/account ?a] :where ['[?li :journal-entry-line/account ?a]
'(or-join [?a ?c] '(or-join [?a ?c]
[?a :account/numeric-code ?c] [?a :account/numeric-code ?c]
[?a :bank-account/numeric-code ?c]) [?a :bank-account/numeric-code ?c])
'[(>= ?c ?from-numeric-code)] '[(>= ?c ?from-numeric-code)]
'[(<= ?c ?to-numeric-code)]]} '[(<= ?c ?to-numeric-code)]]}
:args [(map (juxt :from :to) (:numeric-code args))]}) :args [(map (juxt :from :to) (:numeric-code args))]})
(seq (:account args)) (seq (:account args))
(merge-query {:query {:in ['?a3] (merge-query {:query {:in ['?a3]
:where ['[?li :journal-entry-line/account ?a3]]} :where ['[?li :journal-entry-line/account ?a3]]}
:args [(:db/id (:account args))]}) :args [(:db/id (:account args))]})
(:amount-gte args) (:amount-gte args)
(merge-query {:query {:in ['?amount-gte] (merge-query {:query {:in ['?amount-gte]
:where ['[?e :journal-entry/amount ?a] :where ['[?e :journal-entry/amount ?a]
'[(>= ?a ?amount-gte)]]} '[(>= ?a ?amount-gte)]]}
:args [(:amount-gte args)]}) :args [(:amount-gte args)]})
(:amount-lte args) (:amount-lte args)
(merge-query {:query {:in ['?amount-lte] (merge-query {:query {:in ['?amount-lte]
:where ['[?e :journal-entry/amount ?a] :where ['[?e :journal-entry/amount ?a]
'[(<= ?a ?amount-lte)]]} '[(<= ?a ?amount-lte)]]}
:args [(:amount-lte args)]}) :args [(:amount-lte args)]})
(:db/id (:bank-account args)) (:db/id (:bank-account args))
(merge-query {:query {:in ['?a] (merge-query {:query {:in ['?a]
:where ['[?li :journal-entry-line/account ?a]]} :where ['[?li :journal-entry-line/account ?a]]}
:args [(:db/id (:bank-account args))]}) :args [(:db/id (:bank-account args))]})
(:account-id args) (:account-id args)
(merge-query {:query {:in ['?a2] (merge-query {:query {:in ['?a2]
:where ['[?e :journal-entry/line-items ?li2] :where ['[?e :journal-entry/line-items ?li2]
'[?li2 :journal-entry-line/account ?a2]]} '[?li2 :journal-entry-line/account ?a2]]}
:args [(:account-id args)]}) :args [(:account-id args)]})
(not-empty (:location args)) (not-empty (:location args))
(merge-query {:query {:in ['?location] (merge-query {:query {:in ['?location]
:where ['[?li :journal-entry-line/location ?location]]} :where ['[?li :journal-entry-line/location ?location]]}
:args [(:location args)]}) :args [(:location args)]})
(not-empty (:locations args)) (not-empty (:locations args))
(merge-query {:query {:in ['[?location ...]] (merge-query {:query {:in ['[?location ...]]
:where ['[?li :journal-entry-line/location ?location]]} :where ['[?li :journal-entry-line/location ?location]]}
:args [(:locations args)]}) :args [(:locations args)]})
(:sort args) (add-sorter-fields {"client" ['[?e :journal-entry/client ?c] (:sort args) (add-sorter-fields {"client" ['[?e :journal-entry/client ?c]
'[?c :client/name ?sort-client]] '[?c :client/name ?sort-client]]
"date" ['[?e :journal-entry/date ?sort-date]] "date" ['[?e :journal-entry/date ?sort-date]]
"vendor" '[(or-join [?e ?sort-vendor] "vendor" '[(or-join [?e ?sort-vendor]
(and (and
[?e :journal-entry/vendor ?v] [?e :journal-entry/vendor ?v]
[?v :vendor/name ?sort-vendor]) [?v :vendor/name ?sort-vendor])
(and [(missing? $ ?e :journal-entry/vendor)] (and [(missing? $ ?e :journal-entry/vendor)]
[(ground "") ?sort-vendor]))] [(ground "") ?sort-vendor]))]
"amount" ['[?e :journal-entry/amount ?sort-amount]] "amount" ['[?e :journal-entry/amount ?sort-amount]]
"external-id" ['[?e :journal-entry/external-id ?sort-external-id]] "external-id" ['[?e :journal-entry/external-id ?sort-external-id]]
"source" '[(or-join [?e ?sort-source] "source" '[(or-join [?e ?sort-source]
[?e :journal-entry/source ?sort-source] [?e :journal-entry/source ?sort-source]
(and [(missing? $ ?e :journal-entry/source)] (and [(missing? $ ?e :journal-entry/source)]
[(ground "") ?sort-source]))]} [(ground "") ?sort-source]))]}
args) args)
true true
@@ -334,11 +341,11 @@
:journal-entry/external-id :journal-entry/external-id
:db/id :db/id
[:journal-entry/date :xform clj-time.coerce/from-date] [:journal-entry/date :xform clj-time.coerce/from-date]
{:journal-entry/vendor [:vendor/name :db/id] {:journal-entry/vendor [:vendor/name :db/id]
:journal-entry/original-entity [:invoice/invoice-number :journal-entry/original-entity [:invoice/invoice-number
:invoice/source-url :invoice/source-url
:transaction/description-original :db/id] :transaction/description-original :db/id]
:journal-entry/client [:client/name :client/code :db/id] :journal-entry/client [:client/name :client/code :db/id]
:journal-entry/line-items [:journal-entry-line/debit :journal-entry/line-items [:journal-entry-line/debit
:journal-entry-line/location :journal-entry-line/location
:journal-entry-line/running-balance :journal-entry-line/running-balance
@@ -362,8 +369,8 @@
(defn sum-outstanding [ids] (defn sum-outstanding [ids]
(->> (->>
(dc/q {:find ['?id '?o] (dc/q {:find ['?id '?o]
:in ['$ '[?id ...]] :in ['$ '[?id ...]]
:where ['[?id :invoice/outstanding-balance ?o]]} :where ['[?id :invoice/outstanding-balance ?o]]}
(dc/db conn) (dc/db conn)
ids) ids)
@@ -375,8 +382,8 @@
(defn sum-total-amount [ids] (defn sum-total-amount [ids]
(->> (->>
(dc/q {:find ['?id '?o] (dc/q {:find ['?id '?o]
:in ['$ '[?id ...]] :in ['$ '[?id ...]]
:where ['[?id :invoice/total ?o]]} :where ['[?id :invoice/total ?o]]}
(dc/db conn) (dc/db conn)
ids) ids)
@@ -386,7 +393,7 @@
0.0))) 0.0)))
(defn fetch-page [request] (defn fetch-page [request]
(let [db (dc/db conn) (let [db (dc/db conn)
{ids-to-retrieve :ids matching-count :count {ids-to-retrieve :ids matching-count :count
all-ids :all-ids} (fetch-ids db request)] all-ids :all-ids} (fetch-ids db request)]
@@ -410,12 +417,12 @@
(if account-name (if account-name
[:div {:x-tooltip (hx/json (str "Running Balance: " (some->> (:journal-entry-line/running-balance jel) [:div {:x-tooltip (hx/json (str "Running Balance: " (some->> (:journal-entry-line/running-balance jel)
(format "$%,.2f"))))} (format "$%,.2f"))))}
[:div.text-left.underline.cursor-pointer {:x-ref "source"} [:div.text-left.underline.cursor-pointer {:x-ref "source"}
(:journal-entry-line/location jel) ": " (:journal-entry-line/location jel) ": "
(or (:account/numeric-code account) (:bank-account/numeric-code account)) (or (:account/numeric-code account) (:bank-account/numeric-code account))
" - " account-name]] " - " account-name]]
[:div.text-left (com/pill {:color :yellow} "Unassigned")]) [:div.text-left (com/pill {:color :yellow} "Unassigned")])
[:div.text-right.text-underline (format "$%,.2f" (key jel))])) [:div.text-right.text-underline (format "$%,.2f" (key jel))]))
(when-not (= 1 (count lines)) (when-not (= 1 (count lines))
[:div.col-span-2 (com/pill {:color :primary} "Total: " (->> lines [:div.col-span-2 (com/pill {:color :primary} "Total: " (->> lines
@@ -443,9 +450,9 @@
[:to nat-int?]]]]] [:to nat-int?]]]]]
[:numeric-code-gte {:optional true} [:maybe nat-int?]] [:numeric-code-gte {:optional true} [:maybe nat-int?]]
[:numeric-code-lte {:optional true} [:maybe nat-int?]] [:numeric-code-lte {:optional true} [:maybe nat-int?]]
[:vendor {:optional true :default nil} [:maybe [:entity-map {:pull [:db/id :vendor/name]}]]] [:vendor {:optional true :default nil} [:maybe [:entity-map {:pull [:db/id :vendor/name]}]]]
[:bank-account {:optional true :default nil} [:maybe [:entity-map {:pull [:db/id :bank-account/name]}]]] [:bank-account {:optional true :default nil} [:maybe [:entity-map {:pull [:db/id :bank-account/name]}]]]
[:account {:optional true :default nil} [:maybe [:entity-map {:pull [:db/id :account/name]}]]] [:account {:optional true :default nil} [:maybe [:entity-map {:pull [:db/id :account/name]}]]]
[:check-number {:optional true} [:maybe [:string {:decode/string strip}]]] [:check-number {:optional true} [:maybe [:string {:decode/string strip}]]]
[:invoice-number {:optional true} [:maybe [:string {:decode/string strip}]]] [:invoice-number {:optional true} [:maybe [:string {:decode/string strip}]]]
[:status {:optional true} [:maybe (ref->enum-schema "invoice-status")]] [:status {:optional true} [:maybe (ref->enum-schema "invoice-status")]]
@@ -459,17 +466,20 @@
[:maybe clj-date-schema]]]])) [:maybe clj-date-schema]]]]))
(def grid-page (def grid-page
(helper/build {:id "entity-table" (helper/build {:id "entity-table"
:nav com/main-aside-nav :nav com/main-aside-nav
:check-boxes? true :check-boxes? true
:check-box-warning? (fn [e] :check-box-warning? (fn [e]
(some? (:invoice/scheduled-payment e))) (some? (:invoice/scheduled-payment e)))
:page-specific-nav filters :page-specific-nav filters
:fetch-page fetch-page :fetch-page fetch-page
:oob-render :oob-render
(fn [request] (fn [request]
[(assoc-in (date-range-field* request) [1 :hx-swap-oob] true) [(assoc-in (dr/date-range-field {:value {:start (:start-date (:query-params request))
(assoc-in (exact-match-id* request) [1 :hx-swap-oob] true)]) :end (:end-date (:query-params request))}
:id "date-range"
:apply-button? true}) [1 :hx-swap-oob] true)
(assoc-in (exact-match-id* request) [1 :hx-swap-oob] true)])
:query-schema query-schema :query-schema query-schema
:action-buttons (fn [request] :action-buttons (fn [request]
[(when-not (:external? (:route-params request)) (com/button {:color :primary [(when-not (:external? (:route-params request)) (com/button {:color :primary
@@ -485,7 +495,7 @@
:hx-confirm "Are you sure you want to void this invoice?"} :hx-confirm "Are you sure you want to void this invoice?"}
svg/trash)) svg/trash))
(when (and (can? (:identity request) {:subject :invoice :activity :edit}) (when (and (can? (:identity request) {:subject :invoice :activity :edit})
(#{:invoice-status/unpaid :invoice-status/paid} (:invoice/status entity))) (#{:invoice-status/unpaid :invoice-status/paid} (:invoice/status entity)))
(com/icon-button {:hx-put (bidi/path-for ssr-routes/only-routes (com/icon-button {:hx-put (bidi/path-for ssr-routes/only-routes
::route/edit-wizard ::route/edit-wizard
:db/id (:db/id entity))} :db/id (:db/id entity))}
@@ -497,14 +507,14 @@
:db/id (:db/id entity))} :db/id (:db/id entity))}
svg/undo))]) svg/undo))])
:breadcrumbs [[:a {:href (bidi/path-for ssr-routes/only-routes ::route/page)} :breadcrumbs [[:a {:href (bidi/path-for ssr-routes/only-routes ::route/page)}
"Ledger"]] "Ledger"]]
:title (fn [r] :title (fn [r]
(str (str
(some-> r :route-params :status name str/capitalize (str " ")) (some-> r :route-params :status name str/capitalize (str " "))
"Register")) "Register"))
:entity-name "register" :entity-name "register"
:route ::route/table :route ::route/table
:csv-route ::route/csv :csv-route ::route/csv
:break-table (fn [request entity] :break-table (fn [request entity]
(cond (cond
@@ -521,102 +531,102 @@
(for [je journal-entries (for [je journal-entries
jel (:journal-entry/line-items je)] jel (:journal-entry/line-items je)]
(merge jel je))) (merge jel je)))
:headers [{:key "id" :headers [{:key "id"
:name "Id" :name "Id"
:render-csv :db/id :render-csv :db/id
:render-for #{:csv}} :render-for #{:csv}}
{:key "client" {:key "client"
:name "Client" :name "Client"
:sort-key "client" :sort-key "client"
:hide? (fn [args] :hide? (fn [args]
(and (= (count (:clients args)) 1) (and (= (count (:clients args)) 1)
(= 1 (count (:client/locations (:client args)))))) (= 1 (count (:client/locations (:client args))))))
:render (fn [x] [:div.flex.items-center.gap-2 (-> x :journal-entry/client :client/name)]) :render (fn [x] [:div.flex.items-center.gap-2 (-> x :journal-entry/client :client/name)])
:render-csv (fn [x] (-> x :journal-entry/client :client/name))} :render-csv (fn [x] (-> x :journal-entry/client :client/name))}
{:key "vendor" {:key "vendor"
:name "Vendor" :name "Vendor"
:sort-key "vendor" :sort-key "vendor"
:render (fn [e] (or (-> e :journal-entry/vendor :vendor/name) :render (fn [e] (or (-> e :journal-entry/vendor :vendor/name)
[:span.italic.text-gray-400 (-> e :journal-entry/alternate-description)])) [:span.italic.text-gray-400 (-> e :journal-entry/alternate-description)]))
:render-csv (fn [e] (or (-> e :journal-entry/vendor :vendor/name) :render-csv (fn [e] (or (-> e :journal-entry/vendor :vendor/name)
(-> e :journal-entry/alternate-description)))} (-> e :journal-entry/alternate-description)))}
{:key "source" {:key "source"
:name "Source" :name "Source"
:sort-key "source" :sort-key "source"
:hide? (fn [args] :hide? (fn [args]
(not (:external? (:route-params args)))) (not (:external? (:route-params args))))
:render :journal-entry/source :render :journal-entry/source
:render-csv :journal-entry/source} :render-csv :journal-entry/source}
{:key "external-id" {:key "external-id"
:name "External Id" :name "External Id"
:sort-key "external-id" :sort-key "external-id"
:class "max-w-[12rem]" :class "max-w-[12rem]"
:hide? (fn [args] :hide? (fn [args]
(not (:external? (:route-params args)))) (not (:external? (:route-params args))))
:render (fn [x] [:p.truncate (:journal-entry/external-id x)]) :render (fn [x] [:p.truncate (:journal-entry/external-id x)])
:render-csv :journal-entry/external-id} :render-csv :journal-entry/external-id}
{:key "date" {:key "date"
:sort-key "date" :sort-key "date"
:name "Date" :name "Date"
:show-starting "lg" :show-starting "lg"
:render (fn [{:journal-entry/keys [date]}] :render (fn [{:journal-entry/keys [date]}]
(some-> date (atime/unparse-local atime/normal-date)))} (some-> date (atime/unparse-local atime/normal-date)))}
{:key "amount" {:key "amount"
:sort-key "amount" :sort-key "amount"
:name "Amount" :name "Amount"
:show-starting "lg" :show-starting "lg"
:render (fn [{:journal-entry/keys [amount]}] :render (fn [{:journal-entry/keys [amount]}]
(some->> amount (some->> amount
(format "$%,.2f")))} (format "$%,.2f")))}
{:key "account" {:key "account"
:name "Account" :name "Account"
:sort-key "account" :sort-key "account"
:class "text-right" :class "text-right"
:render-csv #(or (-> % :journal-entry-line/account :account/name) :render-csv #(or (-> % :journal-entry-line/account :account/name)
(-> % :journal-entry-line/account :bank-account/name)) (-> % :journal-entry-line/account :bank-account/name))
:render-for #{:csv}} :render-for #{:csv}}
{:key "debit" {:key "debit"
:name "Debit" :name "Debit"
:class "text-right" :class "text-right"
:render (partial render-lines :journal-entry-line/debit) :render (partial render-lines :journal-entry-line/debit)
:render-csv :journal-entry-line/debit} :render-csv :journal-entry-line/debit}
{:key "credit" {:key "credit"
:name "Credit" :name "Credit"
:class "text-right" :class "text-right"
:render (partial render-lines :journal-entry-line/credit) :render (partial render-lines :journal-entry-line/credit)
:render-csv :journal-entry-line/credit} :render-csv :journal-entry-line/credit}
{:key "links" {:key "links"
:name "Links" :name "Links"
:show-starting "lg" :show-starting "lg"
:class "w-8" :class "w-8"
:render (fn [i] :render (fn [i]
(link-dropdown (link-dropdown
(cond-> [] (cond-> []
(-> i :journal-entry/original-entity :invoice/invoice-number) (-> i :journal-entry/original-entity :invoice/invoice-number)
(conj (conj
{:link (hu/url (bidi/path-for ssr-routes/only-routes {:link (hu/url (bidi/path-for ssr-routes/only-routes
::invoice-route/all-page) ::invoice-route/all-page)
{:exact-match-id (:db/id (:journal-entry/original-entity i))}) {:exact-match-id (:db/id (:journal-entry/original-entity i))})
:color :primary :color :primary
:content (format "Invoice '%s'" (-> i :journal-entry/original-entity :invoice/invoice-number))}) :content (format "Invoice '%s'" (-> i :journal-entry/original-entity :invoice/invoice-number))})
(-> i :journal-entry/original-entity :invoice/source-url) (-> i :journal-entry/original-entity :invoice/source-url)
{:link (-> i :journal-entry/original-entity :invoice/source-url) {:link (-> i :journal-entry/original-entity :invoice/source-url)
:color :secondary :color :secondary
:content (str "File")} :content (str "File")}
(-> i :journal-entry/original-entity :transaction/description-original) (-> i :journal-entry/original-entity :transaction/description-original)
(conj (conj
{:link (hu/url (bidi/path-for ssr-routes/only-routes {:link (hu/url (bidi/path-for ssr-routes/only-routes
::transaction-routes/all-page) ::transaction-routes/all-page)
{:exact-match-id (:db/id (:journal-entry/original-entity i))}) {:exact-match-id (:db/id (:journal-entry/original-entity i))})
:color :primary :color :primary
:content (format "Transaction '%s'" (-> i :journal-entry/original-entity :transaction/description-original))}) :content (format "Transaction '%s'" (-> i :journal-entry/original-entity :transaction/description-original))})
(-> i :journal-entry/memo) (-> i :journal-entry/memo)
(conj {:color :secondary (conj {:color :secondary
:content (str "Memo: " (:journal-entry/memo i))})))) :content (str "Memo: " (:journal-entry/memo i))}))))
:render-for #{:html}}]})) :render-for #{:html}}]}))
(def row* (partial helper/row* grid-page)) (def row* (partial helper/row* grid-page))

View File

@@ -53,7 +53,7 @@
[:div {:id "exact-match-id-tag"}])) [:div {:id "exact-match-id-tag"}]))
(defn filters [request] (defn filters [request]
[:form#payment-filters {"hx-trigger" "change delay:500ms, keyup changed from:.hot-filter delay:1000ms" [:form#payment-filters {"hx-trigger" "datesApplied, change delay:500ms, keyup changed from:.hot-filter delay:1000ms"
"hx-get" (bidi/path-for ssr-routes/only-routes "hx-get" (bidi/path-for ssr-routes/only-routes
::route/table) ::route/table)
"hx-target" "#entity-table" "hx-target" "#entity-table"

View File

@@ -29,7 +29,7 @@
default-grid-fields-schema)])) default-grid-fields-schema)]))
(defn filters [params] (defn filters [params]
[:form {"hx-trigger" "change delay:500ms, keyup changed from:.hot-filter delay:1000ms" [:form {"hx-trigger" "datesApplied, change delay:500ms, keyup changed from:.hot-filter delay:1000ms"
"hx-get" (bidi/path-for ssr-routes/only-routes "hx-get" (bidi/path-for ssr-routes/only-routes
:pos-cash-drawer-shift-table) :pos-cash-drawer-shift-table)
"hx-target" "#cash-drawer-shift-table" "hx-target" "#cash-drawer-shift-table"

View File

@@ -34,7 +34,7 @@
default-grid-fields-schema)])) default-grid-fields-schema)]))
(defn filters [request] (defn filters [request]
[:form {"hx-trigger" "change delay:500ms, keyup changed from:.hot-filter delay:1000ms" [:form {"hx-trigger" "datesApplied, change delay:500ms, keyup changed from:.hot-filter delay:1000ms"
"hx-get" (bidi/path-for ssr-routes/only-routes "hx-get" (bidi/path-for ssr-routes/only-routes
:pos-expected-deposit-table) :pos-expected-deposit-table)
"hx-target" "#expected-deposit-table" "hx-target" "#expected-deposit-table"

View File

@@ -29,7 +29,7 @@
[:client {:optional true :default nil} [:maybe [:entity-map {:pull [:db/id :client/name]}]]]] [:client {:optional true :default nil} [:maybe [:entity-map {:pull [:db/id :client/name]}]]]]
default-grid-fields-schema)])) default-grid-fields-schema)]))
(defn filters [request] (defn filters [request]
[:form {"hx-trigger" "change delay:500ms, keyup changed from:.hot-filter delay:1000ms" [:form {"hx-trigger" "datesApplied, change delay:500ms, keyup changed from:.hot-filter delay:1000ms"
"hx-get" (bidi/path-for ssr-routes/only-routes "hx-get" (bidi/path-for ssr-routes/only-routes
:pos-refund-table) :pos-refund-table)
"hx-target" "#refund-table" "hx-target" "#refund-table"

View File

@@ -34,7 +34,7 @@
default-grid-fields-schema)])) default-grid-fields-schema)]))
(defn filters [request] (defn filters [request]
[:form {"hx-trigger" "change delay:500ms, keyup changed from:.hot-filter delay:1000ms" [:form {"hx-trigger" "datesApplied, change delay:500ms, keyup changed from:.hot-filter delay:1000ms"
"hx-get" (bidi/path-for ssr-routes/only-routes "hx-get" (bidi/path-for ssr-routes/only-routes
:pos-sales-table) :pos-sales-table)
"hx-target" "#sales-table" "hx-target" "#sales-table"

View File

@@ -44,7 +44,7 @@
default-grid-fields-schema)])) default-grid-fields-schema)]))
(defn filters [request] (defn filters [request]
[:form {"hx-trigger" "change delay:500ms, keyup changed from:.hot-filter delay:1000ms" [:form {"hx-trigger" "datesApplied, change delay:500ms, keyup changed from:.hot-filter delay:1000ms"
"hx-get" (bidi/path-for ssr-routes/only-routes "hx-get" (bidi/path-for ssr-routes/only-routes
::route/table) ::route/table)
"hx-target" "#entity-table" "hx-target" "#entity-table"

View File

@@ -22,7 +22,7 @@
;; always should be fast ;; always should be fast
(defn filters [request] (defn filters [request]
[:form {"hx-trigger" "change delay:500ms, keyup changed from:.hot-filter delay:1000ms" [:form {"hx-trigger" "datesApplied, change delay:500ms, keyup changed from:.hot-filter delay:1000ms"
"hx-get" (bidi/path-for ssr-routes/only-routes "hx-get" (bidi/path-for ssr-routes/only-routes
:pos-tender-table) :pos-tender-table)
"hx-target" "#tender-table" "hx-target" "#tender-table"

View File

@@ -0,0 +1,43 @@
(ns auto-ap.ssr.selmer
"Selmer rendering + the Hiccup<->Selmer interop bridge for the SSR form/wizard
migration (see .claude/skills/ssr-form-migration). Interactive, attribute-heavy
components render from Selmer templates with plain-HTML Alpine/HTMX attributes;
the bridge lets a Selmer template embed Hiccup output and lets a Selmer fragment
sit inside a Hiccup tree during the strangler transition.
Templates live under resources/templates/ and are referenced by classpath-relative
path, e.g. (render \"templates/components/typeahead.html\" ctx)."
(:require
[hiccup.util :as hu]
[hiccup2.core :as h2]
[selmer.parser :as selmer]))
(defn hiccup->html
"Render a Hiccup form to an HTML string so it can be embedded in a Selmer
context value and emitted with the |safe filter: {{ frag|safe }}."
[hiccup]
(str (h2/html {} hiccup)))
(defn raw
"Wrap an already-rendered HTML string (e.g. from `render`) so hiccup2 emits it
verbatim instead of escaping it. Use to drop a Selmer fragment into a Hiccup tree:
[:div (sel/raw (sel/render \"...\" ctx))]."
[^String html]
(hu/raw-string html))
(defn render
"Render a Selmer template file (classpath-relative path) with `ctx`, returning an
HTML string. Hiccup values in `ctx` should be pre-rendered via `hiccup->html` and
referenced with |safe in the template."
[template ctx]
(selmer/render-file template ctx))
(defn render-str
"Render a Selmer template given as a string (handy for tests/REPL)."
[template ctx]
(selmer/render template ctx))
(defn render->hiccup
"Render a Selmer template file and wrap the result for safe embedding in Hiccup."
[template ctx]
(raw (render template ctx)))

View File

@@ -83,11 +83,11 @@
[:line {:stroke "currentColor", :fill "none", :stroke-linejoin "round", :y1 "16.22", :stroke-linecap "round", :stroke-width "1.5px", :x1 "16.221", :y2 "23.25", :x2 "23.25"}]]) [:line {:stroke "currentColor", :fill "none", :stroke-linejoin "round", :y1 "16.22", :stroke-linecap "round", :stroke-width "1.5px", :x1 "16.221", :y2 "23.25", :x2 "23.25"}]])
(def moon (def moon
[:svg {:id "theme-toggle-dark-icon", :fill "currentColor", :viewbox "0 0 20 20", :xmlns "http://www.w3.org/2000/svg"} [:svg {:id "theme-toggle-dark-icon", :fill "currentColor", :viewbox "0 0 20 20", :xmlns "http://www.w3.org/2000/svg"}
[:path {:d "M17.293 13.293A8 8 0 016.707 2.707a8.001 8.001 0 1010.586 10.586z"}]]) [:path {:d "M17.293 13.293A8 8 0 016.707 2.707a8.001 8.001 0 1010.586 10.586z"}]])
(def sun (def sun
[:svg {:id "theme-toggle-light-icon", :fill "currentColor", :viewbox "0 0 20 20", :xmlns "http://www.w3.org/2000/svg"} [:svg {:id "theme-toggle-light-icon", :fill "currentColor", :viewbox "0 0 20 20", :xmlns "http://www.w3.org/2000/svg"}
[:path {:d "M10 2a1 1 0 011 1v1a1 1 0 11-2 0V3a1 1 0 011-1zm4 8a4 4 0 11-8 0 4 4 0 018 0zm-.464 4.95l.707.707a1 1 0 001.414-1.414l-.707-.707a1 1 0 00-1.414 1.414zm2.12-10.607a1 1 0 010 1.414l-.706.707a1 1 0 11-1.414-1.414l.707-.707a1 1 0 011.414 0zM17 11a1 1 0 100-2h-1a1 1 0 100 2h1zm-7 4a1 1 0 011 1v1a1 1 0 11-2 0v-1a1 1 0 011-1zM5.05 6.464A1 1 0 106.465 5.05l-.708-.707a1 1 0 00-1.414 1.414l.707.707zm1.414 8.486l-.707.707a1 1 0 01-1.414-1.414l.707-.707a1 1 0 011.414 1.414zM4 11a1 1 0 100-2H3a1 1 0 000 2h1z", :fill-rule "evenodd", :clip-rule "evenodd"}]]) [:path {:d "M10 2a1 1 0 011 1v1a1 1 0 11-2 0V3a1 1 0 011-1zm4 8a4 4 0 11-8 0 4 4 0 018 0zm-.464 4.95l.707.707a1 1 0 001.414-1.414l-.707-.707a1 1 0 00-1.414 1.414zm2.12-10.607a1 1 0 010 1.414l-.706.707a1 1 0 11-1.414-1.414l.707-.707a1 1 0 011.414 0zM17 11a1 1 0 100-2h-1a1 1 0 100 2h1zm-7 4a1 1 0 011 1v1a1 1 0 11-2 0v-1a1 1 0 011-1zM5.05 6.464A1 1 0 106.465 5.05l-.708-.707a1 1 0 00-1.414 1.414l.707.707zm1.414 8.486l-.707.707a1 1 0 01-1.414-1.414l.707-.707a1 1 0 011.414 1.414zM4 11a1 1 0 100-2H3a1 1 0 000 2h1z", :fill-rule "evenodd", :clip-rule "evenodd"}]])
(def home (def home
@@ -157,23 +157,23 @@
[:defs] [:defs]
[:title "navigation-next"] [:title "navigation-next"]
[:path [:path
{:d "M23,9.5H12.387a4,4,0,0,0-4,4v2", {:d "M23,9.5H12.387a4,4,0,0,0-4,4v2",
:fill "none", :fill "none",
:stroke "currentColor", :stroke "currentColor",
:stroke-linecap "round", :stroke-linecap "round",
:stroke-linejoin "round"}] :stroke-linejoin "round"}]
[:polyline [:polyline
{:points "19 13.498 23 9.498 19 5.498", {:points "19 13.498 23 9.498 19 5.498",
:fill "none", :fill "none",
:stroke "currentColor", :stroke "currentColor",
:stroke-linecap "round", :stroke-linecap "round",
:stroke-linejoin "round"}] :stroke-linejoin "round"}]
[:path [:path
{:d {: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", "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", :fill "none",
:stroke "currentColor", :stroke "currentColor",
:stroke-linecap "round", :stroke-linecap "round",
:stroke-linejoin "round"}]]) :stroke-linejoin "round"}]])
(def play (def play
[:svg {:xmlns "http://www.w3.org/2000/svg", :viewbox "-0.5 -0.5 24 24"} [:svg {:xmlns "http://www.w3.org/2000/svg", :viewbox "-0.5 -0.5 24 24"}
@@ -187,26 +187,26 @@
[:defs] [:defs]
[:title "pencil"] [:title "pencil"]
[:rect [:rect
{:y "1.09", {:y "1.09",
:stroke "currentColor", :stroke "currentColor",
:transform "translate(11.889 -5.238) rotate(45)", :transform "translate(11.889 -5.238) rotate(45)",
:fill "none", :fill "none",
:stroke-linejoin "round", :stroke-linejoin "round",
:width "6", :width "6",
:stroke-linecap "round", :stroke-linecap "round",
:x "9.268", :x "9.268",
:height "21.284"}] :height "21.284"}]
[:polygon [:polygon
{:points "2.621 17.136 0.5 23.5 6.864 21.379 2.621 17.136", {:points "2.621 17.136 0.5 23.5 6.864 21.379 2.621 17.136",
:fill "none", :fill "none",
:stroke "currentColor", :stroke "currentColor",
:stroke-linecap "round", :stroke-linecap "round",
:stroke-linejoin "round"}] :stroke-linejoin "round"}]
[:path [:path
{:d "M21.914,6.328,17.672,2.086l.707-.707a3,3,0,0,1,4.242,4.242Z", {:d "M21.914,6.328,17.672,2.086l.707-.707a3,3,0,0,1,4.242,4.242Z",
:fill "none", :fill "none",
:stroke "currentColor", :stroke "currentColor",
:stroke-linecap "round", :stroke-linecap "round",
:stroke-linejoin "round"}]]) :stroke-linejoin "round"}]])
(def dollar-tag (def dollar-tag
@@ -231,15 +231,15 @@
[:path [:path
{:d {:d
"M5.5,11.5c-.275,0-.341.159-.146.354l6.292,6.293a.5.5,0,0,0,.709,0l6.311-6.275c.2-.193.13-.353-.145-.355L15.5,11.5V1.5a1,1,0,0,0-1-1h-5a1,1,0,0,0-1,1V11a.5.5,0,0,1-.5.5Z", "M5.5,11.5c-.275,0-.341.159-.146.354l6.292,6.293a.5.5,0,0,0,.709,0l6.311-6.275c.2-.193.13-.353-.145-.355L15.5,11.5V1.5a1,1,0,0,0-1-1h-5a1,1,0,0,0-1,1V11a.5.5,0,0,1-.5.5Z",
:fill "none", :fill "none",
:stroke "currentColor", :stroke "currentColor",
:stroke-linecap "round", :stroke-linecap "round",
:stroke-linejoin "round"}] :stroke-linejoin "round"}]
[:path [:path
{:d "M23.5,18.5v4a1,1,0,0,1-1,1H1.5a1,1,0,0,1-1-1v-4", {:d "M23.5,18.5v4a1,1,0,0,1-1,1H1.5a1,1,0,0,1-1-1v-4",
:fill "none", :fill "none",
:stroke "currentColor", :stroke "currentColor",
:stroke-linecap "round", :stroke-linecap "round",
:stroke-linejoin "round"}]]) :stroke-linejoin "round"}]])
(def trash (def trash
@@ -522,3 +522,10 @@
[:path {:d "m12 16 0 3", :fill "none", :stroke "currentColor", :stroke-linecap "round", :stroke-linejoin "round", :stroke-width "1"}] [:path {:d "m12 16 0 3", :fill "none", :stroke "currentColor", :stroke-linecap "round", :stroke-linejoin "round", :stroke-width "1"}]
[:path {:d "M4.5 9.5h15s1 0 1 1v12s0 1 -1 1h-15s-1 0 -1 -1v-12s0 -1 1 -1", :fill "none", :stroke "currentColor", :stroke-linecap "round", :stroke-linejoin "round", :stroke-width "1"}] [:path {:d "M4.5 9.5h15s1 0 1 1v12s0 1 -1 1h-15s-1 0 -1 -1v-12s0 -1 1 -1", :fill "none", :stroke "currentColor", :stroke-linecap "round", :stroke-linejoin "round", :stroke-width "1"}]
[:path {:d "M6.5 6a5.5 5.5 0 0 1 11 0v3.5h-11Z", :fill "none", :stroke "currentColor", :stroke-linecap "round", :stroke-linejoin "round", :stroke-width "1"}]]) [:path {:d "M6.5 6a5.5 5.5 0 0 1 11 0v3.5h-11Z", :fill "none", :stroke "currentColor", :stroke-linecap "round", :stroke-linejoin "round", :stroke-width "1"}]])
(def google
[:svg {:viewbox "0 0 24 24", :width "20", :height "20", :xmlns "http://www.w3.org/2000/svg"}
[:path {:fill "#4285F4" :d "M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"}]
[:path {:fill "#34A853" :d "M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"}]
[:path {:fill "#FBBC05" :d "M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"}]
[:path {:fill "#EA4335" :d "M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"}]])

Some files were not shown because too many files have changed in this diff Show More