38 Commits

Author SHA1 Message Date
ddffbf58f9 docs(skill): feed ssr-form-migration with Phase 10 (Client) learnings
- form-vs-wizard.md: the sub-editor pattern — modeling a parameterized sub-step
  (list ⇄ per-item editor with accept/discard/sort) on the linear engine as
  whole-form swaps driven by routes that mutate session step-data, with a
  pass-through step :decode that re-reads the list via a non-stripped `wiz` hidden.
- scorecard.md: Phase 10 row (defrecord 9→0, multimethods→case, grid+schemas+
  power-query preserved verbatim, blank-address recurrence, 71/71 green).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 00:50:56 -07:00
7b0e8bfd65 refactor(ssr): Phase 10 — migrate Client wizard onto the engine (7 steps + bank-account sub-editor)
The largest SSR modal, moved off the mm/* multi-step wizard protocol machinery
(ClientWizard/*Modal records, MultiStepFormState, fc/* form-cursors, EDN-snapshot
round-trip) onto the session-backed engine (wizard2 + wizard-state): flat de-cursored
field names, whole-form HTMX swaps, per-step session state combined by the done-fn.

Seven linear steps (info → matches → contact → bank-accounts → integrations → cash-flow
→ other-settings), each a data-driven {:decode :validate :render :next}. The grid, form
schemas, and the sales power-query export are preserved unchanged.

The parameterized [:bank-account which] mm sub-step (which the linear engine can't model)
becomes a sub-editor of the bank-accounts step: the list view and per-account editor are
whole-form swaps of #wizard-form, driven by dedicated routes (new/edit/accept/discard/
sort) that mutate the :bank-accounts step-data in the session directly and re-render via
the engine's render-wizard. The bank-accounts step's :decode is a pass-through that
re-affirms the session-managed list (read via a `wiz` hidden the engine doesn't strip),
so Next never wipes it.

Notable fixes carried over from prior phases:
- New vs edit is keyed off :db/id presence (the engine always POSTs, so the old PUT/POST
  split no longer distinguishes them).
- Client + bank-account dates are coerced to #inst for EDN-safe session storage
  (clj-time DateTime has no cookie-session reader).
- An empty Contact-step address posts blank fields → decodes to an all-nil, db/id-less
  map; blank-address? drops it before upsert (else datomic: "tempid used only as value").

Routes: drop ::navigate/::discard; add the four bank-account sub-editor routes.
Full e2e suite green (71/71); client-wizard acceptance spec rewritten for the engine
(flat field names, data-primary nav, bank-account open/accept/discard sub-flows).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 00:49:56 -07:00
b0fe7cc70d test(ssr): Phase 10 parity gate — characterization spec for the Client wizard
Pin the current (pre-migration) behavior of the Client wizard — the largest SSR
modal: seven linear steps (info → matches → contact → bank-accounts → integrations
→ cash-flow → other-settings) plus the parameterized bank-account sub-editor — so
the upcoming engine migration preserves it.

- e2e/client-wizard.spec.ts: new dialog renders info + the 7-step timeline; edit
  opens prefilled with a disabled code; the bank-accounts step shows the seeded
  account card and the add-account affordance (the crux sub-step); and an
  edit-through-to-save round-trip keeps the client in the grid.
- test_server.clj: give the seeded TEST client a :client/name so the row is
  selectable in the admin grid (its base query requires :client/name); also honor
  a TEST_SERVER_PORT env var so a from-disk e2e JVM can run on a free port
  alongside a REPL-held one (the same parallel-run need the playwright config notes).

Full e2e suite green (69/69) against a fresh from-disk server.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 00:13:03 -07:00
c5dc305854 refactor(ssr): Phase 9 — migrate New/Edit Vendor onto the engine (5-step wizard)
A five-step linear wizard (info → terms → account → address → legal) plus a
separate Merge dialog, migrated off mm/* + form-cursor + the EDN snapshot onto the
session-backed engine (wizard2), following the Phase 8 template.

Latent bug found + fixed: the old "Next" PUT /admin/vendor/navigat carried a
[:map [:db/id entity-id]] route-schema on a route with no :db/id path param, so empty
route-params {} → main-transformer's parse-empty-as-nil → nil → 500 on every advance
(the same quirk as Phase 8's query-params, now via route-params). The engine's submit
is a POST with no such schema; the dead navigate route is deleted.

What changed:
- defrecord 5 → 0 (InfoModal/TermsModal/AccountModal/AddressModal/LegalEntityModal +
  VendorWizard), mm/ 0, fc/ cursor refs 0 (wizard AND the de-cursored Merge dialog),
  step-params[…] 0.
- 5 de-cursored step renders (plain data + path->name2 + a *errors* binding); the 3
  repeated grids became add-row-handler + a blank-row row render; the timeline is
  preserved as a per-step side panel.
- :init-fn branches new (empty) vs edit (entity split across the 5 steps' :init-data,
  seeded as per-step step-data so edit opens populated); per-step :validate via
  mc/validate + me/humanize replaces wrap-ensure-step; vendor-step wraps
  handle-step-submit in try+ to surface create-time validation as a 4xx.

Two new gotchas found + fixed + documented:
- empty-step decode: an all-blank step collapses to nil (parse-empty-as-nil), which a
  schema :validate rejects as "invalid type"; decode-with coerces nil → {} so optional-
  only steps advance while required-field steps still fail on the missing key.
- blank nested entity: an untouched Address (all-nil, no :db/id) makes :upsert-entity
  mint a tempid used only as value (datomic error); blank-address? drops it.

Verification: full e2e suite 65/65 (61 prior + 4 new: info renders + timeline; create
across all 5 steps persists; edit opens prefilled and a rename persists; a too-short
name blocks advancing). Create + edit confirmed at the REPL incl. the cookie-session
EDN round-trip. Skill fed (scorecard Phase 9; gotchas for both new traps).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 22:39:10 -07:00
5502f4c4a2 refactor(ssr): Phase 8 — migrate New/Edit Invoice onto the engine (conditional :next)
The hardest modal in the app: one wizard that both creates and edits invoices,
with a conditional middle step (basic-details → [accounts] → next-steps, where
the expense-accounts step is skipped on the default-accounts path). Migrated off
mm/* + form-cursor + the EDN snapshot onto the session-backed engine (wizard2).

Finding: the OLD basic-details "Save" was broken. It hx-puts /invoice/new/navigate,
whose `[:to {:optional true} …]` query-schema 500s on empty query-params — Ring's
wrap-params yields {} for a no-query PUT, and main-transformer's parse-empty-as-nil
decodes {} → nil, which the bare [:map] rejects. Production uses the identical
wrap-params, so it was broken there too. So e2e/invoice-new.spec.ts is an ACCEPTANCE
gate (red on the old code, green on the engine, whose submit is a POST with no
query-schema): the migration fixes a latent bug. Create semantics (default → vendor
default account, location-spread; customize → posted grid; edit → prefill + updated
row) were pinned at the REPL.

What changed:
- defrecord 4 → 0 (NewWizard2 / BasicDetailsStep / AccountsStep / NextSteps), mm/ 0,
  fc/ cursor refs 0, step-params[…] field names 0.
- Conditional `:next` `(if (= :customize …) :accounts :done)` replaces mm/CustomNext +
  the broken 308-to-submit. Dual-purpose new+edit = one :init-fn branching on a route
  :db/id; create-wizard! seeds :init-data as per-step step-data so edit opens populated.
- The broken new-wizard-navigate route is deleted; the genuine async helpers
  (account-prediction, due/scheduled-payment-date, location-select, expense total/balance,
  add-row) remain but read the posted flat form (+ ws/get-all for the cross-step total).
- next-steps becomes the done-fn's returned modal (Pay now / Add another / Close).
- Dates ride as java.util.Date (#inst) in step-data so it's EDN-safe across the
  non-terminal step (clj-time DateTimes break the cookie store).

Verification: full e2e suite 61/61 (58 prior + 3 new); maybe-spread-locations unit
test 6/6; create semantics + edit prefill confirmed at the REPL. Skill fed
(scorecard Phase 8, gotchas {}→nil 500 + #inst dates, form-vs-wizard conditional
:next + dual-purpose).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 22:05:01 -07:00
7a53441ec7 test(ssr): expose seeded client ids in /test-info (Phase 8 gate scaffolding)
The New Invoice wizard's basic-details step needs a client + vendor in its Solr
typeaheads; tests inject the seeded ids. accounts.vendor was already exposed; add
:clientIds (TEST / TEST2) so the upcoming Phase 8 parity gate can drive the create flow.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 21:09:38 -07:00
4b2a3e53dd refactor(ssr): Phase 7 — migrate Invoice Pay onto the engine; prove the cross-step merge
Invoice Pay is the first GENUINE multi-data-step wizard, and migrating it exercises
the engine's central abstraction for the first time: choose-method collects
{:bank-account :method}, payment-details collects {:invoices :check-number
:handwritten-date :mode}, and the engine's get-all MERGES the two independent step
payloads for the per-method pay (handwrite-check transacts a pending check; the
others go through print-checks-internal). This is exactly the mechanism the Phase-6
adversarial review flagged as unproven.

What changed
- Deleted the 3 wizard records (PayWizard / ChoosePaymentMethodModal /
  PaymentDetailsStep), MultiStepFormState, the EDN snapshot, and the step-params[...]
  prefix. Replaced with pay-wizard-config (init-fn builds read-only :context;
  two steps; done-fn = pay!) driven by wizard2.
- De-cursored the payment-details amounts grid (fc/cursor-map -> explicit
  (map-indexed) over :context :invoices with path->name2 names).
- The bank-account cards' method controls now post {bank-account, method,
  direction:next} straight to the engine submit-route (was a bespoke navigate route).
- Routes 3 -> 2: open-pay-wizard (GET), pay-step (every transition); the
  pay-wizard-navigate route is deleted.
- Used the post-review engine primitives: :open-response (modal wrap), nav-footer
  (with new :save-label "Pay"), auto nav-field stripping (flat decode, no allowlist),
  Enter guard.

invoices.clj falls fully off the framework: Invoice Pay was the last mm/fc user
(bulk-edit went in Phase 5), so fc/ 0, mm/ 0, defrecord 0, step-params 0 — and the
multi-modal / form-cursor / malli.util requires are removed.

Gotcha discovered + documented: wizard session data must be EDN-safe (the cookie
session store has no clj-time readers), so the date default is computed in render,
not stored in context.

Verification: invoice-pay spec 3/3 (the merge end-to-end); full suite 58/58; load-file
clean; cljfmt clean. Skill fed: scorecard row (merge proven; whole-file zeroing) +
the EDN-session-safety gotcha.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 19:59:24 -07:00
01aca9d362 test(ssr): Phase 7 parity gate — characterization spec for the Invoice Pay wizard
The Invoice Pay wizard is the first GENUINE multi-data-step wizard: choose-method
(collects bank-account + method) -> payment-details (collects check-number /
handwritten-date / amounts), merged only at submit. This gate characterizes that
flow before migrating it onto the session-backed engine, so the merge can be proven
behavior-preserving.

- Seed: make the TEST client's check bank account visible (+ name "Test Checking")
  so the choose-method step renders a usable method card. The pay flow had no e2e
  coverage, so the bank account was never visible in tests before.
- Spec drives the real 2-step flow against the unmodified wizard: choose-method
  renders the bank account + its methods (print-check/debit/handwrite-check, in the
  card tooltip); picking handwrite-check advances to payment-details (check-number +
  date + Pay); filling the check number and submitting shows the completion modal.
  The handwrite-check path is used because it transacts a pending check payment
  directly (no PDF/S3), making the success assertion stable.

Notes for the migration: the method controls live in a <template x-ref="tooltip">
revealed by the card button; the footer Pay submit is x-ref="next"; both the grid
filters and the modal carry a check-number input, so the modal selectors are scoped
to #wizard-form.

Verification: invoice-pay spec 3/3; full suite 58/58 (no regressions from the seed
change).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 17:17:57 -07:00
a2d8517668 refactor(ssr): wizard2 engine absorbs the per-consumer boilerplate (review follow-up)
Adversarial review of Phase 6 found the engine's coupling had relocated rather than
dissolved: every wizard consumer had to hand-build a decode allowlist, re-implement the
open-handler modal wrap, mint temp ids for added rows, and hand-roll the nav buttons +
Enter guard. The engine had the information to prevent all four. Now it does:

- handle-step-submit strips its own nav fields (wizard-id/current-step/direction) from
  form-params before calling a step's :decode -- no per-consumer allowlist, and they can
  no longer leak into the saved entity (the Phase-6 "500 on save" class of bug is
  structurally impossible).
- open-wizard takes an :open-response config fn and owns the create!/render/wrap/thread
  flow, so modal wizards route through (partial wizard2/open-wizard config) directly.
- wizard2/blank-row supplies a temp :db/id (+ :new?) so an added row passes schema
  validation and the step actually advances.
- wizard2/nav-footer emits the direction buttons (Back/advance/Save), marks the primary,
  and wizard-form guards Enter to trigger the primary button.

Consumer (transaction_rules.clj) gets correspondingly leaner: deleted rule-form-keys +
the decode allowlist, rule-nav, and the hand-rolled open-rule-wizard; new/edit routes are
now (partial wizard2/open-wizard config). A new wizard is now just a config map + the step
:render fns. LOC 964 -> 932, and the deleted code was exactly the cross-consumer
boilerplate, not modal-specific logic.

Verification: rule spec 4/4; full suite 55/55; cljfmt clean. Skill gotchas updated from
"three traps" to "use the engine's primitives" (the engine now absorbs them).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 14:38:07 -07:00
107a02f4f1 refactor(ssr): Phase 6b — migrate Transaction Rule wizard onto the session engine; de-cursor
Proves the Phase-6a wizard engine against a real 2-step modal: the Transaction
Rule wizard (edit step + read-only test/preview step) now runs on wizard2 /
wizard-state, fully de-cursored.

What changed
- Wizard machinery removed: deleted the EditModal / TestModal /
  TransactionRuleWizard defrecords (mm/ModalWizardStep + LinearModalWizard),
  MultiStepFormState, the EDN snapshot, and the step-params[...] prefix. Replaced
  with a data-driven `transaction-rule-wizard-config` (two steps + init-fn +
  done-fn) driven by the engine.
- De-cursored the whole edit form (82 fc/ refs -> 0): every field reads explicit
  data + path->name2; errors via a bound *errors* / ferr. The account row's Alpine
  cross-field dispatch wiring (clientId -> accountId -> location) is preserved
  verbatim — only the data plumbing moved off the cursor.
- The test step's :render reads :all-data (the engine's get-all), so the
  formtools "combine at the end" mechanism feeds the preview table.
- Routes 4 -> 2: open-rule-wizard (new + edit), save-step (every transition via the
  engine's `direction` field). The dedicated `navigate` route is deleted.
- decode-rule-form select-keys to the schema's known keys so the engine's nav
  fields (wizard-id/current-step/direction) don't leak into the upserted entity.

Scorecard (admin/transaction_rules.clj): fc/ 82->0, mm/ 20->0, defrecords 3->0,
LOC 1000->964, routes 4->2.

Scope note: the de-cursored edit step keeps com/* Hiccup leaf components (not yet
sc/* Selmer); the value here was removing fc/ + mm/ and proving the engine, not
re-templating the conditional/Alpine-cross-field layout. Hiccup-in-render is a
documented partial; the com/ -> sc/ swap is a mechanical follow-up.

Verification: rule spec 4/4 (new + edit dialogs, advance-to-test preview, save);
full Playwright suite 55/55; cljfmt clean. Skill fed: scorecard row + narrative
(engine's first real modal; generalizes for a one-data-step wizard); gotchas
(strip engine nav fields in decode, new-row temp-id, direction-button nav).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 11:12:33 -07:00
15ff9855c1 test(ssr): Phase 6b parity gate — characterization spec for Transaction Rule wizard
Behavior-parity safety net before migrating the Transaction Rule modal onto the
session-backed wizard engine. The modal had no e2e coverage; the test server seeded
no rules.

- test_server.clj: seed a transaction rule (under client TEST2, in a SEPARATE
  transaction so the first transaction's tempid->entity-id allocation — and thus the
  TEST transaction grid order the other specs depend on — is byte-identical); surface
  its id via /test-info (ruleId).
- e2e/transaction-rule.spec.ts (4 tests): the new-rule edit step renders (description,
  account grid, approval radios, Test control), the edit dialog pre-populates the
  seeded rule, advancing to the test step renders the matching-transactions preview,
  and saving from the test step creates the rule + closes the modal. Covers both entry
  points (new/edit), both steps (edit + test), and save.

Note: deliberately NOT seeding a recent matching transaction — a date-NOW txn perturbs
an unrelated transaction-edit save spec (pre-existing fragility), and the test-table
query/render is reused unchanged by the migration, so characterizing that the preview
renders is sufficient parity.

Full Playwright suite 55/55 (51 prior + 4 new).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 07:07:18 -07:00
d56056d66c feat(ssr): Phase 6a — session-backed wizard engine (the formtools model)
Builds the reusable multi-step wizard engine the plan front-loads in Phase 6, as
two protocol-free namespaces. This replaces the EDN-snapshot-in-a-hidden-field
round-trip for genuine multi-step flows: per-step validated data lives in the Ring
session and is combined only at the end — only an opaque wizard-id rides in the form.

- components/wizard_state.clj — pure session storage (Django formtools SessionStorage
  model): create-wizard!, instance, exists?, current-step, context, step-data,
  put-step (REPLACE not merge), set-step, get-all (combine at end), forget. State
  namespaced by wizard-id at [:wizards <id> ...]; :context holds read-only step inputs
  outside :step-data so it never merges into the result. Each fn is session -> session'.
- components/wizard2.clj — the engine: open-wizard, render-wizard, handle-step-submit,
  wizard-form. A wizard is a config map (steps with :decode/:validate/:render/:next,
  plus :init-fn/:done-fn/:submit-route). Steps' :render get {wizard-id, current-step,
  context, all-data, step-data, errors, request}; nav posts a `direction` field
  (next/back/submit). Two routes per wizard (open + submit); the engine threads the
  session into the response itself — no wrap-wizard / wrap-decode-multi-form-state stack.

REPL-proven lifecycle (before wiring any modal):
  1. OPEN     -> seeds session state, renders step 1, form leaks NO accumulated data
  2. NEXT     -> stores {:info {:name "Acme"}}, advances to :terms
  3. INVALID  -> re-renders the same step with errors, no advance
  4. DONE     -> done-fn gets combined {:name "Acme" :days 30} (get-all), instance forgotten
  5. BACK     -> :terms -> :info, no validation
  6. EXPIRED  -> unknown wizard-id re-opens fresh instead of 500-ing

Inert infrastructure — nothing imports it yet (Transaction Rule migrates onto it next),
so the e2e suite is unaffected. cljfmt clean. Skill: form-vs-wizard.md updated from
aspirational to the realized engine API + the Phase-6 fit note (Transaction Rule
exercises render/nav/preview; the cross-step merge gets its workout in Phase 7+).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 00:29:32 -07:00
2bf87056d7 refactor(ssr): Phase 5 — full Selmer migration of Invoice Bulk Edit; remove the wizard; implement live totals
Migrates the Invoice Bulk Edit modal off the wizard to a plain Selmer form,
building on the parity gate. Structurally Phase 3's bulk-code applied to invoices
(selected entities -> expense-account rows), so near-pure reuse of bulk-code's
flat-state plumbing + edit's account-totals-tbody.

What changed
- Wizard removed: deleted BulkEditWizard/AccountsStep records, MultiStepFormState,
  the step-params[...] prefix, the EDN snapshot, and all mm/* for this modal.
  Replaced with a plain handler + flat wrap-bulk-state (decode straight into
  bulk-edit-schema, no snapshot).
- Selection-as-ids round-trip: the non-editable invoice selection is resolved to a
  concrete not-locked id vector at open and ridden back in hidden ids[] fields (the
  bulk analog of edit's single db/id) -- no filter re-query.
- De-cursored bulk-edit-account-row* to Selmer (sc/*), explicit-id location swap
  (#account-location-<index>, replacing the old find * swap), reusing
  tx-edit/location-select*.
- 100% Selmer modal render path; the surgical edit was done with the text-based
  Edit tool (the clojure-mcp structural tools reformat the whole 1812-line file),
  so the diff is contained to the requires + the bulk-edit region.
- Routes 5 -> 3: GET bulk-edit, PUT bulk-edit-submit, POST bulk-edit-form-changed
  (one whole-form op dispatcher folding the old new-account route).

Implemented the dead totals
- The wizard's TOTAL/BALANCE percentage rows were commented out (#_(...)) with a
  duplicate id="total". Implemented as a #expense-totals sibling-<tbody> refreshed by
  a Rule-4 percentage-keyup swap (the new-account + total + balance routes all fold
  into form-changed / the sibling-tbody).

Scorecard delta (bulk-edit modal): wizard records 2->0, bulk-edit routes 5->3,
step-params/fc-cursor (modal) ->0, location swap find *-> explicit-id, totals
dead->implemented.

Verification: invoice-bulk-edit spec 5/5 (incl. add-row, save, validation, the
implemented totals); full Playwright suite 50/50; cljfmt clean; diff confined to
the modal region. Skill fed: scorecard row + settled repeated-row target-selector
convention; gotcha (structural tools reformat large files -> use text Edit).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 23:09:37 -07:00
4139919036 test(ssr): Phase 5 parity gate — characterization spec for Invoice Bulk Edit
Behavior-parity safety net before migrating the Invoice Bulk Edit modal off the
wizard. The modal had no e2e coverage; the existing seeded invoice is bulk-editable
as-is, so no seed change was needed (avoids interfering with the transaction-link
spec).

e2e/invoice-bulk-edit.spec.ts (4 tests): open the modal (expense-account grid with
Account/Location/%/TOTAL/BALANCE + a default row + New account), add an account row,
save a 100% coding (modalclose), and the percentage-validation rejection. Models the
bulk-code-transactions spec.

Full Playwright suite 50/50 (46 prior + 4 new).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 22:50:44 -07:00
599b849e6f refactor(ssr): Phase 4 — full Selmer migration of POS Sales Summary; remove the wizard; fix add-item + totals
Migrates the POS Sales Summary edit modal off the wizard to a plain Selmer form,
building on the parity gate committed earlier. Largest migration so far and the
first with no prior test coverage.

What changed
- Wizard removed: deleted MainStep/EditWizard records, MultiStepFormState, the
  step-params[...] prefix, the EDN snapshot round-trip, and all mm/* middleware.
  Replaced with a plain handler + flat wrap-decode/wrap-derive-state. The 51 fc/
  cursor refs are de-cursored into explicit data + Selmer templates.
- db/id-keyed item merge: wrap-derive-state overlays posted items onto the
  persisted items by :db/id, so read-only fields the form doesn't post
  (ledger-side, amount) survive a re-render and the debit/credit split + totals
  stay correct. New manual rows (temp db/id) ride through as-is.
- Inline click-to-edit account cell preserved as three small targeted
  .account-cell-swap routes (edit/save/cancel-item-account), ported to Selmer
  with the new field-name scheme.
- 100% Selmer modal render path (the remaining Hiccup / hx-swap-oob / "hx-"
  strings are all grid-page code — grid render lambdas, the filters form, and the
  submit response-header map — not the modal).
- Routes: dropped edit-wizard-navigate + new-summary-item; added form-changed.

Fixes (two pre-existing bugs, per request)
- "New Summary Item" add button (was throwing `newRowIndex is not defined` and
  targeting a non-existent `.new-row`) is now a whole-form-swap op=new-item that
  adds an editable manual row (category + account typeahead + debit/credit money
  inputs + remove).
- The dead totals/balance display (malformed Hiccup that discarded its labels) is
  replaced by a proper #summary-totals block showing running Total +
  Balanced/Unbalanced, refreshed via a Rule-4 targeted swap on manual amount edits.

Scorecard delta (pos/sales_summaries.clj): LOC 790->732, mm coupling 20->0,
wizard records 4->0, fc/ cursor 51->0, step-params 27->0 (2 comments), modal
routes 8->6. (hx-swap-oob 1 and mixed-hx live in the grid page, not the modal.)

Verification: sales-summary spec 7/7 (incl. the two fixes); full Playwright suite
46/46; cljfmt clean. Skill fed: scorecard row + narrative; gotchas (parity-gate-
first, characterize-then-fix, keyup-trigger tests); cookbook (inline click-to-edit
cell, db/id-keyed item merge).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 22:13:19 -07:00
a289ff2557 test(ssr): Phase 4 parity gate — seed + characterization spec for Sales Summary edit
Establishes the behavior-parity safety net required before migrating the POS
Sales Summary edit modal off the wizard (the modal had zero test coverage and the
test server seeded no POS data).

- test_server.clj: seed a balanced sales summary ($500 credit = $500 debit) with
  two auto items referencing the existing test client + accounts; surface its id
  via /test-info (`salesSummaryId`).
- e2e/sales-summary-edit.spec.ts: characterization spec (6 tests) capturing current
  behavior — open modal (debit/credit columns, categories, resolved account names,
  amounts), balanced state, inline account editor (pencil -> typeahead editor ->
  cancel restores / save re-renders the cell), and Save (PUT round-trip closes the
  modal + keeps the grid row). Exercises the edit-wizard, edit/save/cancel-item-account,
  and edit-wizard-submit routes.

Notable finding: the "New Summary Item" button is currently BROKEN (its Alpine
handler throws "newRowIndex is not defined" and hx-target="closest .new-row"
matches no ancestor, so the new-summary-item route never fires). The spec documents
this as inert rather than asserting it works; the migration will decide fix-vs-preserve.

Full Playwright suite 45/45 (39 prior + 6 new).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 21:15:28 -07:00
03620e9d42 refactor(ssr): Phase 3 — full Selmer migration of Transaction Bulk Code; remove the wizard
Migrates the Transaction Bulk Code modal (a single-step form wearing a full
wizard costume) to a plain Selmer form, cold-applying the ssr-form-migration
skill. Almost entirely reuse of the Phase-2 work: the whole `sc/*` Selmer
component library, `account-typeahead*` / `location-select*`, and the
`edit-modal` / `transitioner` chrome are imported wholesale.

What changed
- Wizard removed: deleted `BulkCodeWizard` / `AccountsStep` records,
  `MultiStepFormState`, the `step-params[...]` prefix, and all `mm/*`
  middleware. Replaced with a plain handler + flat `wrap-bulk-state` (decode
  straight into `bulk-code-schema`, no snapshot round-trip).
- Selection round-trip: the non-editable transaction selection is resolved to
  a concrete not-locked id vector at open and ridden back in hidden `ids[]`
  fields (the bulk analog of edit's single `db/id`) — no EDN snapshot, no
  filter re-query, and more correct (codes exactly the rows the user saw).
- 100% Selmer render path (only the shared terminal `com/success-modal` keeps
  Hiccup — heuristic-9 exception). New shared component `sc/select`
  (`location-select.html` generalized) for the status dropdown.
- Routes 4 -> 3: GET `bulk-code` (open), POST `bulk-code-submit`, POST
  `bulk-code-form-changed` (one whole-form op dispatcher folding the old
  `new-account` + `vendor-changed` routes). Location swap moved off `find *`
  onto explicit `#account-location-<index>` + `hx-select`.
- Fixed a latent correctness bug surfaced by the migration: the vendor
  typeahead needs `:id` (value-keyed `:key`) or its value-bound hidden goes
  stale across a whole-form swap and posts blank.

Scorecard delta (transaction/bulk_code.clj): mm coupling 19->0, snapshot
merges 4->0, wizard records 3->0, step-params 10->0, routes 4->3, OOB 0,
Hiccup-in-render ->0 (bar success-modal). LOC 420->506 (documented exception:
the wizard was a thin shell over mm/* defaults, so explicitness moves shared
plumbing into the file). Cookbook: reused the entire Phase-2 sc/* lib + chrome,
added sc/select.

Verification: bulk-code-transactions.spec.ts 13/13; full Playwright suite
39/39; cljfmt clean.

Skill fed: scorecard row + narrative + LOC exception; gotchas (value-bound
typeahead keying, selection-as-ids round-trip); cookbook (sc/select).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 19:38:09 -07:00
70c178de83 refactor(ssr): full Selmer migration of Transaction Edit; remove the wizard
Squashed Phase-2 SSR work: migrate the Transaction Edit modal's render path
entirely to Selmer templates (zero Hiccup in the render path), rip out the
multi-step wizard abstraction (EditWizard/LinksStep records, MultiStepFormState,
step-params[...] field names, mm/* middleware) in favor of a plain form with
flat derived state, and promote shared UI components to reusable Selmer partials
under resources/templates/components/. Adds the Selmer interop bridge, the
auto-ap.ssr.components.selmer (sc) wrapper library, and the ssr-form-migration
skill capturing the learnings.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 08:36:29 -07:00
e2ccfc8d2c Merge branch 'ledger-bulk' into staging 2026-06-23 22:03:29 -07:00
e8cbd2760c fixes 2026-06-23 22:03:26 -07:00
e0da8e1866 feat(ssr): add delete selected to external ledger
Replicate the master CLJS "delete external ledger" feature on the SSR
external ledger page: an admin-only bulk delete that retracts the
selected journal entries, skipping any in a client's locked period and
capping at 1000 per request.

Return the result via modal-response (retargets the persistent
#modal-content shell) and target #modal-content from the button so the
request never relies on the outerHTML swap inherited from the data-grid
card, which previously replaced #modal-holder and broke the next click.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 21:48:39 -07:00
2e3c1e3646 feat(ssr): add clear filters button to transactions
Shows a "Clear filters" button in the transactions action bar whenever a
non-date filter is active. It's a boosted link back to the transactions
page that preserves the date range (and any implied status), so the
sidebar filters and table both reset.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 22:43:43 -07:00
a7e9fbaf6b feat(ssr): disable bulk action buttons until a transaction is selected
Disable Code, Delete, and Suppress until at least one row is checked or
"select all" is active, matching the existing selection-aware UI.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 22:19:39 -07:00
8a676718a7 feat(ssr): reset transaction selection after bulk code
Bulk coding left the checked items selected after the table refreshed.
Add a dedicated reset-selection event that the grid's Alpine state
listens for, and fire it alongside refreshTable on bulk-code submit.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 22:06:37 -07:00
3ffb661da3 fix(ssr): stop unresolved filter flipping to true on transactions navigation
The unresolved/potential-duplicates query-param decoders fell through to
(boolean %) for unrecognized strings. A round-tripped "false" (pushed into the
URL, re-read via HX-Current-URL) decoded to true since any non-nil string is
truthy, so navigating pages silently turned on the "Unresolved only" filter.

Handle "false" and already-boolean values symmetrically.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 21:40:31 -07:00
f9438ba983 fix(ssr): only require account coding for manual transaction edits
Account coding lived in the always-applied base map of edit-form-schema, so
every action (including the link/apply-rule/unlink actions) required a valid
transaction-account/account. The edit modal always submits the Manual tab's
(usually blank) account row, so link submits failed validation before reaching
their save-handler and silently no-op'd. Move account validation into the
:manual branch of the action :multi so link actions validate without it.

Also surface whole-form validation errors in the wizard footer error bar:
default-step-footer only handled top-level/sequential error shapes, so nested
field-error maps (e.g. a hidden tab's account error) produced an empty bar and
a silent failure. Add flatten-form-errors to flatten the humanized error tree.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 21:30:29 -07:00
7d34b8a5f6 money 2026-06-18 20:26:07 -07:00
c09d85ede6 fix(ssr): fix Client Review (requires-feedback) status in bulk-code dialog
The bulk-code "Requires Feedback" option submitted "requires_feedback"
(underscore), which decoded to an enum keyword not present in the
schema (idents use a hyphen), so selecting it failed validation. Use
the hyphenated value and relabel the option, the reconciliation report
header to "Client Review" to unify with the sidebar terminology.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 10:38:00 -07:00
ec4f88b7fc fix(ssr): hide P&L warning box when there is no warning
The profit-and-loss report always passed :warning as a [:div ...] hiccup
vector, which is truthy even when empty. The shared report table renders
its red warning box with (when warning ...), so a clean report with no
warning and no unresolved entries still showed an empty red error box.

Only build the warning div when there is actual warning text or sample
links, matching how the balance-sheet and cash-flows reports pass nil.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 15:56:43 -07:00
8ca5e75c4d fix(ssr): hide client column in edited transaction row for single client
The save-handler re-rendered the edited row via row* without passing
:request, so the Client column's :hide? predicate received a nil request
and never hid the column. Pass :request request like table* does.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 13:40:50 -07:00
4aed27b204 feat(ssr): add bank account column to transactions table
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 13:36:08 -07:00
d0028f403c fix(ssr): allow picking bank account when editing a transaction rule
The bank-account filter rendered "Please select a client" even when a
client was set on the rule. Two causes:

- Inside (fc/with-field :transaction-rule/bank-account ...) the cursor is
  rebound to the bank-account field, so (:transaction-rule/client
  (fc/field-value)) read the nil bank-account value and the server
  rendered the placeholder. The clientId watcher only fires on change, so
  when editing (client preset, unchanged) the htmx swap never corrected
  it. Read the client from the form root before entering the field.
- The clientId-change swap used innerHTML, nesting a fresh typeahead
  inside the stale one and breaking its Alpine refs. Use outerHTML so the
  typeahead is replaced in place.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 13:32:30 -07:00
6b4392b74b fix(ssr): keep top bar to a fixed-height single row
The top bar grew vertically on narrower viewports when the environment
badge and company-selector labels wrapped, pushing content under the
fixed navbar (which the layout offsets with a fixed pt-16).

Rework the navbar into a fixed h-16 row with a priority-based responsive
layout:
- search fills the middle (flex-1) and shrinks first when space is tight
- company selector holds its size and truncates long names
- environment badge degrades full pill -> compact letter badge -> hidden
- harmonize control heights (40px controls, 32px badge/avatar accents) so
  the search no longer renders as a cramped thin strip

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 09:01:32 -07:00
cdc87d3710 fix 2026-06-16 20:36:00 -07:00
1e3952a7fb fix(auth): login error-details pre escaping Alpine scope
The error-details <pre> lived inside a <span x-data="{e:false}"> that was
itself inside a <p>. Since <pre> is block content, the HTML parser closed
the <p> and reparented the <pre> out of the span, so Alpine evaluated
x-show="e" with e no longer in scope ("e is not defined"). Use a <div>
wrapper instead of <p> so the pre stays within the e scope.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 20:05:43 -07:00
e099714af1 fix(ssr): transaction edit dropdown duplication and advanced->simple toggle
- Location field hx-target "find *" resolved to the <label> (first child),
  so changing an account swapped the reloaded <select> over the label and
  left a duplicate dropdown. Target "find select" instead (simple + advanced).
- edit-wizard-toggle-mode-handler read mode only from step-params, but the
  hidden "mode" field is a top-level form param, so current-mode always
  defaulted to "simple" and the toggle could never return from advanced.
  Read it from form-params too, matching edit-vendor-changed-handler.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 20:02:41 -07:00
11024b7b89 fix(ssr): transaction rule wizard drops fields on Next
The EditModal step body wrapped all rule fields in a nested
<form id="my-form"> inside the wizard's own #wizard-form. By HTML
form-ownership rules those fields belonged to the inner form, so when
htmx serialized #wizard-form on Next, none of the step-params fields
were sent. The server saw an empty rule, reported "required" for
description/accounts, and re-rendered a blank wizard (losing input).

Replace the nested <form> with a plain <div>; the wizard form already
owns submission, so the inner form and its htmx attributes were
redundant.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 14:58:36 -07:00
de2a1ab850 fixes pnl file name 2026-06-16 14:37:13 -07:00
107 changed files with 8820 additions and 5353 deletions

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,205 @@
# 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/select` (Phase 3) | `select.html` | generic `<select>`; `options [[value label] …]`, `:value` (string/keyword) marks selected, extra hx-/x- attrs ride through. `location-select.html` generalized — reach for this before `com/select`. Added for the bulk-code status field. |
## inline click-to-edit cell (Phase 4) — targeted `.account-cell` swap, not a whole-form op
A "display value + pencil → edit-in-place → check/cancel" cell. Three tiny **stateless** routes,
each swapping just the cell (`hx-target="closest .account-cell"`, `outerHTML`): a `display` cell
(value + pencil `hx-get edit`), an `edit` cell (typeahead + check `hx-put save` / cancel
`hx-get cancel`). State rides in the request (item index + current value via `hx-vals`), so no
server-side "which cell is editing" flag is needed. Keep it as its own routes — it is a distinct
feature, *not* folded into the whole-form `form-changed` dispatcher (that would lose the targeted
swap and re-render the whole modal on every pencil click). The cells are assembled with `sc/*` +
`sel/raw` strings (like `edit.clj`'s `footer*`); SVGs ride in as `svg/*` Hiccup via the
`sc/a-icon-button` body (no `[:svg]` literal lands in the modal file).
## db/id-keyed item merge (Phase 4) — for rows the form posts only partially
When a row renders some fields read-only (so they aren't posted) but the entity holds them
(sales-summary auto items post only db/id/category/account — not ledger-side/amount), the flat
`wrap-derive-state` must **overlay posted items onto the persisted items by `:db/id`** so the
unposted fields survive a re-render: `(merge (by-id (:db/id posted)) posted)`. New rows (temp
`:db/id` not in the entity) ride through as-is. This is the row-level analog of edit's
"entity-only fields always from the entity"; without it, a re-render drops ledger-side/amount and
the debit/credit split + totals break.
| `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,264 @@
# 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.
## The engine — REALIZED (Phase 6)
Built and REPL-proven in Phase 6 as two namespaces (no protocols, no defrecords):
- **`auto-ap.ssr.components.wizard-state`** — the pure session-storage layer (the skeleton
above, fleshed out): `create-wizard!` / `instance` / `exists?` / `current-step` /
`context` / `step-data` / `put-step` (replace) / `set-step` / `get-all` / `forget`. Each
is `session -> session'` (or a read); nothing mutates global state. `:context` holds
read-only data the steps need (e.g. an entity id) **outside** `:step-data`, so it never
gets merged into the combined result.
- **`auto-ap.ssr.components.wizard2`** — the engine: `open-wizard`, `render-wizard`,
`handle-step-submit`, and the `wizard-form` shell. A wizard is a **config map**:
```clojure
{:name :vendor :form-id "wizard-form" :submit-route "<resolved url>"
:init-fn (fn [request] {:context {...} :init-data {step-key data}})
:done-fn (fn [all-data request] ring-response)
:steps [{:key :info
:decode (fn [request] -> data-map) ; parse this step's posted fields
:validate (fn [data request] -> errors|nil) ; optional
:render (fn [ctx] -> hiccup) ; step body; engine wraps the <form>
:next (fn [data] -> next-step-key | :done)}
...]}
```
The step's `:render` gets `{:wizard-id :current-step :context :all-data :step-data
:errors :request :config}`. `:all-data` (every step combined so far) is exactly what a
**read-only summary/preview step** consumes. Nav buttons post a `direction` field:
`"next"` (validate+advance via `:next`), `"back"` (no validate), `"submit"` (== next, for
the last step). Only `wizard-id` + `current-step` ride in the form — **no snapshot**.
**Two routes per wizard:** `(partial open-wizard config)` (GET) and
`(partial handle-step-submit config)` (POST). No `wrap-wizard` / `wrap-decode-multi-form-state`
stack — the engine threads the session itself and `(assoc resp :session session')`.
**Proven via REPL** (lifecycle, before any modal used it): open seeds session state and
renders step 1 with no accumulated data in the form; next stores `{step-key data}` and
advances; an invalid step re-renders itself with errors (no advance); the final step's
`:done` calls `done-fn` with the combined `get-all` data and `forget`s the instance; back
navigates without validating; an unknown/expired `wizard-id` re-opens fresh instead of
500-ing. See the lifecycle eval in the Phase 6 commit message.
**Note (Phase 6 fit).** Transaction Rule itself is *edit + read-only preview of one
entity*, not a true multi-data-step flow — so it exercises the engine's render/navigation/
preview path (`:all-data` feeds the test table) but not the cross-step *merge*. The merge
(`get-all` combining independent steps) gets its real workout in Phase 7+ (Invoice Pay,
New Invoice, Vendor, Client), where steps collect genuinely different fields.
## Conditional `:next` + dual-purpose (new+edit) — New Invoice (Phase 8)
A step's `:next` is just `(fn [data] -> next-step-key | :done)`, so **branching the flow is a
one-liner** — no `CustomNext` protocol, no 308-redirect-to-submit hack:
```clojure
{:key :basic-details
:next (fn [data] (if (= :customize (:customize-accounts data)) :accounts :done))}
```
`:default` skips the expense-accounts step entirely (the done-fn uses the vendor's default
account); `:customize` routes through the grid. The old wizard expressed this with
`mm/CustomNext` returning either `navigate-handler{:to :accounts}` or a 308 to the submit
route — and the 308 path was broken (see `gotchas.md`, the `{}`→nil 500). The engine's
conditional `:next` is both simpler and correct.
**Dual-purpose (create *and* edit) = one config, one `:init-fn` that branches on a route id:**
```clojure
(defn new-init-fn [request]
(if-let [id (->db-id (get-in request [:route-params :db/id]))]
{:init-data {:basic-details (… entity prefilled, :customize-accounts :customize)
:accounts {:invoice/expense-accounts (… existing rows)}}} ; edit
{:init-data {:basic-details {:invoice/date (coerce/to-date (time/now)) ; new
:customize-accounts :default}}}))
```
`create-wizard!` stores `:init-data` **as the per-step `:step-data` map directly**, so seeding
`{:basic-details … :accounts …}` opens both steps populated — the edit case repopulates the
grid without a separate hydrate. Two open routes (`new-wizard`, `edit-wizard`) both reduce to
`(partial wizard2/open-wizard config)`; the done-fn branches on `(:db/id all-data)` to return
the next-steps modal (create) vs the swapped table row (edit).
**Async step fragments read the posted form, not multi-form-state.** The basic-details
fragments (account-prediction radio, due-date / scheduled-payment suggestions) and the
accounts totals all post the whole `#wizard-form`; in the engine that form carries the flat
`invoice/*` fields + the opaque `wizard-id`, so a fragment decodes what it needs straight from
`form-params` (and, for a cross-step value like the invoice total on the accounts step, reads
`ws/get-all` via the posted `wizard-id`). No `mm/wrap-decode-multi-form-state` stack survives.
## Sub-editor: a parameterized sub-step on the linear engine (Phase 10, bank accounts)
The engine's steps are a flat list — it has no nested/parameterized step like the old
mm `[:bank-account which]`. When a step owns a *collection you edit one item at a time*
(a list view ⇄ a per-item editor, with accept/discard/sort), don't try to bend the step
list. Model it as a **sub-editor of that step**, entirely in whole-form swaps:
- **The step renders the list view** (cards/rows + an "add" affordance). Each item's
edit/new control is an `hx-get` that targets `#wizard-form` with `hx-swap outerHTML` and
carries `?wizard-id=<id>&index=N` (the wizard-id is in the render ctx).
- **The editor is its own `<form id="wizard-form">`** (so it swaps cleanly and the next
swap replaces it) with the item's fields + hidden `wizard-id` + a hidden item index. Its
Accept `hx-post`s an accept route; Discard `hx-get`s a discard route. It is NOT a wizard
step and does NOT go through `handle-step-submit`.
- **Dedicated routes mutate the step's data in the session directly** and re-render the
list via the engine: read `(ws/step-data session wid <step-key>)`, splice the decoded
item into the vector (`assoc` at index, or `conj` to append for new), `ws/put-step`, then
`(wizard2/render-wizard {:config … :wizard-id wid :session session' :request request})`
and `(assoc :session session')`. Discard just re-renders from the unchanged session.
- **The step's own `:decode` is a pass-through.** Because the list lives in the session
(managed by the sub-editor, not by in-form inputs), the step's Next must re-affirm it,
not decode it from a near-empty form. Read it back with the wizard-id — but the engine
strips `wizard-id`/`current-step`/`direction` from form-params before `:decode`, so smuggle
it through an extra hidden the engine leaves alone (we used `wiz`):
`(or (ws/step-data (:session request) (get-in request [:form-params "wiz"]) <step-key>) {…})`.
- Give the step a no-op `:validate` (`(fn [_ _] nil)`) — items are validated on Accept.
- Clean control keys out of the decoded item before storage (`select-keys` to `:db/id` +
the entity's own namespace) so `wizard-id`/index/`:new?` never reach datomic.
This keeps the doctrine intact (every byte is a whole-form swap of `#wizard-form`; no EDN
snapshot rides the page) while giving the linear engine an add/edit/sort sub-flow it has
no native concept for.

View File

@@ -0,0 +1,399 @@
# 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.
**Proper fix (landed on `staging`, adopted at the rebase):** a `/test-reset` endpoint
(`test_server.clj` → `reset-test-data!` recreates + re-seeds the in-memory db) called from a
`test.beforeEach` in each spec, plus `fullyParallel: false` + `workers: 1` in
`playwright.config.ts`. Every test starts from the same deterministic dataset regardless of
run order. This **supersedes** the earlier `--workers=1`-only workaround (which kept order
dependence; it merely serialized the races instead of eliminating cross-test state).
Post-adoption baseline is **39 pass / 0 fail** — the previously-flaky
`transaction-navigation.spec.ts` date-range test is now green, because `/test-reset` removes
the residual mutation it was tripping over.
## A value-bound typeahead hidden goes stale across a whole-form swap unless keyed
A typeahead (`sc/typeahead`) posts its value through a hidden `<input :value="value.value">`
whose DOM `.value` is set by Alpine, not by the server-rendered static `value` attr. After a
**whole-form `outerHTML` swap** that re-renders the typeahead, Alpine may preserve the *previous*
component's empty `.value` instead of binding the new server value — so the field posts blank
on the next submit. Fix: pass **`:id`** to `sc/typeahead` (the account typeahead already does).
`:id` makes the wrapper emit `:key (str id "--" value)`, and the value-keyed `:key` forces a
clean Alpine re-init that lands the server value. The bulk-code *vendor* typeahead hit this
(account rows didn't, because they pass `:id`) — symptom: "vendor not preserved on a validation
re-render." Note the testing trap: reading the hidden's `.value` in isolation
(`inputValue()` / `toHaveValue`) is an unreliable probe — it lags Alpine. Assert what the form
**actually posts** instead: `new FormData(form).get('vendor')` (wrap in `expect.poll`).
## Round-trip a multi-row selection as `ids[]`, not as an EDN/filter snapshot
A bulk modal acts on a *selection* of N entities (bulk-code: the checked transactions), the
analog of a single modal's one `db/id`. The wizard stashed the whole search-params blob (filters
+ `selected` + `all-selected`) in the EDN snapshot and re-ran the filter query on every post.
Don't carry that forward. Instead **resolve the selection to a concrete id vector once at open**
(`selected->ids` → the not-locked set) and ride it back in hidden `ids[0..n]` fields; re-read it
on each post (`[:vector {:coerce? true} entity-id]` + the `coerce-vector` transformer turns the
`{"0" "123"}` index-map into `[123]`). No snapshot, no filter round-trip, and it's *more* correct
— you code exactly the rows the user saw, immune to data changing between open and submit. This
is heuristic 2 → 0 for a multi-select modal.
## No parity gate? Build one first — seed + characterization spec, before touching code
A modal with **no e2e coverage** (and no test-server seed for its domain) cannot be migrated
safely — "behavior parity is proven by tests, not by reading" is the skill's #1 non-negotiable.
Phase 4 (POS Sales Summary) had zero coverage. The fix: (1) seed a representative entity in
`test_server.clj`'s `seed-test-data` and surface its id via `/test-info`; (2) write a
characterization spec against the **unmodified** modal and confirm it green; (3) commit the gate
*separately, ahead of the rewrite*. Reach the modal the real way (grid → row's edit button), not
a direct fragment URL. To discover the actual rendered structure (field names, ids, swap targets)
— especially when the code has dead/buggy render fns — dump the live modal HTML with a throwaway
spec first; assert against what *renders*, not what the code looks like.
## Characterize before you fix; never assert a bug as working
Writing the gate often surfaces pre-existing bugs (Phase 4: a "New Summary Item" button that
threw `newRowIndex is not defined`, and a totals display whose malformed Hiccup discarded its
own labels). Do **not** assert the broken behavior as if it works, and do **not** silently "fix"
it mid-refactor — surface it and let the user decide fix-vs-preserve. If they choose *fix*: the
spec first documents the break (a passing test of the *current* inert behavior or an explicit
note), then is rewritten to assert the *fixed* behavior as part of the migration commit.
## htmx `keyup`-triggered inputs need real keystrokes in tests
A money/text input wired `hx-trigger="keyup changed delay:300ms"` does **not** fire on Playwright
`.fill()` + `dispatchEvent('change')` — `fill` sets the value without keyup events. Use
`.click()` then `.pressSequentially('500')` (types char-by-char, firing keyup) so the targeted
swap actually triggers. (A `change`-triggered control is the opposite — `dispatchEvent('change')`
is fine there.)
## clojure-mcp structural edits reformat the whole file — use text Edit in big shared files
`clojure_edit` / `clojure_edit_replace_sexp` re-emit the **entire file** through the formatter.
In a small single-modal file that's fine (cljfmt-clean output). In a **large multi-modal file**
(Phase 5: `invoices.clj`, 1812 lines) a one-line require addition produced a **650-line spurious
whitespace diff** that buries the real change and makes review impossible. For a surgical
migration inside a big shared file, use the **text-based Edit tool** (exact-string match — no
reformat); this is the AGENTS.md "edit Clojure with file tools only when absolutely necessary"
carve-out. Verify with `load-file` (compile) + `lein cljfmt check`, not by eyeballing. Confirm the
diff is contained with `git diff -U0 <file> | grep '^@@'` — the hunks should cluster only where you
edited (requires + the modal region), nothing else.
## Wiring a modal onto the wizard2 engine — use the engine's primitives, don't re-roll them
Phase 6's first migration (Transaction Rule) hit three traps; an adversarial review pointed
out the engine had the information to prevent all three, so **the engine now absorbs them**.
A consumer is just a config map + the step `:render` fns — reach for these instead of
re-implementing them (and re-hitting the bug):
- **Nav fields are stripped for you.** `handle-step-submit` `dissoc`s its own
`wizard-id`/`current-step`/`direction` from `:form-params` before calling a step's
`:decode` (`wizard2.clj`), so your decode sees only real fields and they can't ride into
the saved entity. (The old failure was a **500 on save** — `:db.error/not-an-entity
:current-step` — because an open `:map` decode kept them. No allowlist needed anymore.)
- **`wizard2/open-wizard` owns the modal wrap.** Give the config an `:open-response` fn
(e.g. `(fn [form] (modal-response [:div#transitioner.flex-1 form]))`); then the
new/edit routes are literally `(partial wizard2/open-wizard config)`. Don't hand-roll
`create!/render/wrap/thread` — that boilerplate was duplicating engine internals.
- **Add rows with `wizard2/blank-row`.** It supplies a temp `:db/id` (so a row schema
requiring `[:db/id [:or entity-id temp-id]]` validates and the step actually advances —
the old symptom was "the Next/Test button does nothing") plus `:new?` for the appear
animation: `(wizard2/blank-row :foo/location "Shared")`.
- **Footer with `wizard2/nav-footer`.** It emits the `direction` submit buttons (Back /
primary advance / Save), marks the advance/save button `data-primary`, and the form's
Enter guard (`wizard2/wizard-form`) triggers `data-primary` — so Enter and Back/Save
aren't left to per-consumer convention. (Testing note that survives: Back and Save are
*both* `type=submit`, so target a save button by its text, not `button[type=submit]`.)
## Scorecard exceptions (ratchet violations with a reason)
**Heuristic 4 (LOC net ↓) — exception (Phase 3, Transaction Bulk Code: 420→506).** When the
modal's wizard was a *thin* shell that delegated almost everything to `mm/*` defaults
(`default-render-step`, `default-render-wizard`, `submit-handler`, `open-wizard-handler`),
ripping the wizard out moves that previously-shared plumbing **into the file** as explicit
render/decode/submit/handler code, so the single-file LOC rises even though total system
complexity drops. This is the opposite of a fat wizard (edit went 1608→1548). The trade is
intended and every other heuristic improved sharply (mm coupling 19→0, snapshot merges 4→0,
wizard records 3→0, routes 4→3, `find *`→explicit-id swap). Watch for it on the small
"single-step wearing a wizard costume" modals — LOC is the wrong headline metric there;
the mm-coupling / snapshot / route counts are.
**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.
## Keep wizard session data EDN-safe (the cookie store has no custom readers)
The session-backed engine stores per-step data + context in the Ring session, and this app's
session store is a **cookie-store** (`ring.middleware.session.cookie`) that serializes with
`pr-str` and reads back with plain `clojure.edn/read-string` — **no custom tag readers**. So
anything you put in a wizard's `:context` or that a step `:decode` returns (which `put-step`
persists) must round-trip through bare EDN. A `clj-time` `DateTime` does not: it `pr-str`s as
`#clj-time/date-time "…"` and the read side 500s with **"No reader function for tag
clj-time/date-time"** on the *next* request that reads the cookie.
This first bit Invoice Pay (Phase 7), whose context defaulted `:handwritten-date (time/now)`.
Rules of thumb:
- **Context**: store only EDN-safe primitives (numbers, strings, keywords, vectors, maps,
`#inst`/`java.util.Date`). Compute clj-time defaults in the *render* fn, not in context.
- **Step data**: a `clj-time` value decoded by a step is fine *in memory* on the terminal
(`:done`) path — `get-all` reads it before `forget` clears the wizard, so it never reaches
the cookie. It only bites if a clj-time value survives in a step that gets re-persisted
(a non-terminal `put-step`). When in doubt, decode dates to `#inst` or keep them as strings
until the done-fn.
- The old `mm` wizard dodged this because it read its EDN snapshot with
`clojure.edn/read-string {:readers clj-time.coerce/data-readers}` (see `multi_modal.clj`) —
the cookie store has no such readers. (A durable/typed session backend would remove this
constraint; until then, EDN-safe is the rule. See `form-vs-wizard.md` open question.)
## A bare `[:map …]` query-schema 500s on empty query-params (the `{}`→nil trap)
`auto-ap.ssr.utils/main-transformer` includes `parse-empty-as-nil`, whose **`:map` decoder
turns any map with no truthy values into `nil`** (`(if (seq (filter identity (vals m))) m nil)`).
So `(mc/coerce [:map [:k {:optional true} …]] {} main-transformer)` decodes `{}` → `nil`,
then validates `nil` against `[:map …]` → `:malli.core/invalid-type` → **500**.
Ring's `wrap-params` sets `:query-params` to `{}` (not nil) for a request with no query
string. So **any handler wrapped with `wrap-schema-enforce :query-schema [:map …]` 500s on a
PUT/POST that carries no `?query`** — `(and query-schema query-params)` is truthy for `{}`,
so the coercion runs and blows up. This is exactly why the pre-migration New Invoice
basic-details "Save" was broken: its button `hx-put`s `/invoice/new/navigate` (no `?to`), and
`mm/next-handler`'s `[:to {:optional true} …]` query-schema 500d every time (the
`CustomNext`/308-to-submit logic never even ran).
- A `[:maybe [:map …]]` query-schema survives (`nil` is valid) — that's why the *grid*
query-schema, hit by the same empty POST, doesn't throw.
- **The engine sidesteps this entirely**: `handle-step-submit` is a POST with **no**
query-schema, so empty query-params never reach a `[:map]` coercion. Migrating a wizard
off the `mm` navigate route *removes* the bug; you don't need to fix the old route.
## Keep wizard dates as `#inst`, not clj-time, in step-data
Reinforcing the EDN-safety rule above: a new+edit wizard that stores dates across a
non-terminal step (New Invoice: `basic-details` holds `:invoice/date` while you visit
`accounts`) must keep them **EDN-safe**. Decode them to `java.util.Date` (`coerce/to-date`)
before they land in step-data, and coerce back to clj-time only for display
(`coerce/from-date` → `atime/unparse-local`). A helper that maps over the date keys
(`->edn-safe-dates`) right after `mc/decode` is the clean seam — both the step `:decode` and
the edit `:init-fn` run the posted/persisted map through it. Datomic's upsert wants
`java.util.Date` anyway, so the done-fn needs no extra conversion.
## The `{}`→nil trap has a THIRD face: empty-step decode → validation "invalid type"
Beyond query-params (Phase 8) and route-params (Phase 9's `/navigat`), the same
`parse-empty-as-nil` `:map` decoder bites a wizard step whose fields are all blank: an
all-empty step posts only blank inputs → the decoded all-nil map collapses to `nil`. If that
`nil` then flows into a `:validate` that does `(mc/validate step-schema data)`, validation
fails with `[invalid type]` (nil isn't a map) and the step can never advance — even though
every field is optional. The legal/address steps (all-optional) hit this.
Fix at the seam: have the step `:decode` coerce nil back to `{}`:
```clojure
(defn- decode-with [schema request]
(or (mc/decode schema (... nested form-params ...) main-transformer) {}))
```
Now an optional-only step validates `{}` (passes, advances) while a required-field step
(e.g. account needs `:vendor/default-account`) still fails on the *missing key*, not on a
spurious nil. Don't "fix" it by skipping validation when data is nil — that lets a genuinely
empty required step through.
## A new (db/id-less) nested entity with all-nil fields → datomic "tempid used only as value"
The empty Address step decodes to `{:vendor/address {:address/street1 nil, …}}` — a map of
nils with no `:db/id`. `:upsert-entity` mints a tempid for that nested map but, since every
attribute is nil, the address entity has nothing transacted, so the tempid is referenced as
a ref value but never defined → `:db.error/tempid-not-an-entity … used only as value`. Drop
such blank nested maps before the upsert:
```clojure
(defn- blank-address? [a] (and (map? a) (not (:db/id a)) (every? nil? (vals a))))
```
This is the nested-entity analogue of "don't create empty rows"; the engine's `blank-row`
gives *added* rows a tempid, but a never-touched optional nested entity must be elided.

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,330 @@
# 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** |
| 3 | Transaction Bulk Code `transaction/bulk_code.clj` | 506 (was 420 — see exception) | **3** | 0 | 0 | **0** | 0 | 0† | reused **all** of Phase-2's `sc/*` lib + `account-typeahead*`/`location-select*` + `edit-modal`/`transitioner` chrome / added **`sc/select`** |
† The one `"hx-..."` string hit is a response-header map (`{"hx-trigger" "refreshTable, reset-selection"}`), not a mixed attribute encoding. mm coupling 19→**0**, wizard records 3→**0**, step-params 10→**0** (the 2 hits are comments), Hiccup-in-render → **0** except the shared `com/success-modal` (heuristic-9 exception, as in Phase 2).
| 4 | POS Sales Summary `pos/sales_summaries.clj` | **732** (was 790) | **6** modal | 0 | 0 | **0** | 1✦ | 0✦ | reused `sc/*` lib + `edit-modal`/`transitioner` chrome / added the inline click-to-edit **account-cell** + **manual-items** patterns |
✦ The residual 1 `hx-swap-oob` and the `"hx-..."` string hits all live in the **grid page** code (the `grid-page` render lambdas + the `filters` form + the submit response-header map) — none are in the migrated **modal** render path, which is 100% Selmer. `defrecord` count **0** (all 4 wizard records gone), `fc/` cursor refs 51→**0**, mm coupling 20→**0**, step-params 27→**0** (2 comments). LOC dropped (this wizard held real custom code, unlike bulk-code's thin shell). **Two pre-existing bugs fixed** (per the user's call): the "New Summary Item" add button (was throwing `newRowIndex is not defined`) and the dead totals/balance display.
### 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).
> **Phase 3 — Transaction Bulk Code (first cold apply of the mature skill).** Single-step
> form wearing a full wizard costume (`BulkCodeWizard`/`AccountsStep`, `MultiStepFormState`,
> the `step-params[...]` prefix, the old `find *` location swap). Migrated to a plain form by
> mirroring Phase 2 — and it was mostly **reuse**: the entire `sc/*` Selmer component library,
> `account-typeahead*`/`location-select*`, and the `edit-modal`/`transitioner` chrome were
> imported wholesale; the only new shared component was **`sc/select`** (the status dropdown —
> `location-select.html` generalized). Parity held: bulk-code spec **13/13**, full suite
> **39/39** (up from the Phase-2 baseline of 3839). mm coupling 19→0, snapshot merges 4→0,
> wizard records 3→0, routes 4→3 (open / submit / `form-changed` — the per-op `new-account` +
> `vendor-changed` routes folded into one `form-changed` op dispatcher), the location swap moved
> off `find *` onto explicit `#account-location-<index>` + `hx-select`.
>
> **The one regression — LOC 420→506 (documented exception, see `gotchas.md`).** Unlike edit
> (whose wizard held real custom code), bulk-code's wizard was a *thin* shell that delegated
> almost everything to `mm/*` defaults (`default-render-step`, `default-render-wizard`,
> `submit-handler`, `open-wizard-handler`). Ripping the wizard out moves that
> previously-shared plumbing **into the file** as explicit render/decode/submit/handler code.
> The trade is intended: every other heuristic improved and the modal is now self-contained
> and wizard-free. New patterns added to the cookbook: the **selection-as-`ids[]` round-trip**
> (resolve the non-editable selection to a concrete id vector at open, ride it in hidden
> fields — the bulk analog of edit's single `db/id`), and the **`:id`-keyed vendor typeahead**
> (a value-bound hidden must be keyed or its posted value goes stale across a whole-form swap).
> **Phase 4 — POS Sales Summary (first modal with no prior test coverage).** The largest
> migration so far and the first that required **building the parity gate first**: the modal
> had zero e2e/clj tests and the test server seeded no POS data, so the work began by seeding a
> balanced sales summary + writing a 7-test characterization spec (committed separately, ahead
> of the rewrite). Then the standard wizard→plain-Selmer migration: `MainStep`/`EditWizard` +
> `MultiStepFormState` deleted, the 51 `fc/` cursor refs de-cursored into explicit data +
> Selmer, `step-params` dropped, the EDN snapshot replaced by flat `wrap-decode`/`wrap-derive-state`
> (with a **db/id-keyed item merge** so the read-only fields the form doesn't post —
> ledger-side, amount — survive a re-render). The **inline click-to-edit account cell** (pencil →
> typeahead editor → check/cancel) was preserved as three small targeted `.account-cell`-swap
> routes (a distinct feature, not folded into the form-changed dispatcher). LOC 790→**732** (net
> ↓ — a fat wizard, opposite of bulk-code).
>
> **Characterize-then-fix.** Writing the gate surfaced two pre-existing bugs: the "New Summary
> Item" button threw `newRowIndex is not defined` (dead since forever) and the totals/balance
> display was dead code (malformed Hiccup that discarded its labels). The spec first *documented*
> them as broken (never assert a bug as working); then, on the user's call, the migration **fixed
> both** — add-item is now a whole-form-swap `op=new-item` adding an editable manual row, and a
> proper `#summary-totals` block shows running Total + Balanced/Unbalanced (a Rule-4 targeted swap
> on manual amount edits). The spec was updated to assert the fixed behavior. New cookbook entries:
> the **inline click-to-edit cell** and the **db/id-keyed item merge** for partially-posted rows.
> **Phase 5 — Invoice Bulk Edit (cold apply in a 1812-line shared file).** Structurally
> Phase 3's bulk-code applied to invoices (selected entities → expense-account rows:
> account/location/percentage), so it was near-pure reuse: bulk-code's flat-state plumbing
> (ids round-trip, `wrap-bulk-state`, schema/decode) + edit's `account-totals-tbody` for the
> live totals. `BulkEditWizard`/`AccountsStep` + `MultiStepFormState` deleted, `step-params`
> dropped, the rows de-cursored to Selmer with the explicit-id location swap, bulk-edit
> routes 5→**3** (the `new-account` + `total` + `balance` routes folded into one
> `form-changed` op dispatcher + the sibling-`<tbody>` totals swap). **Implemented the dead
> TOTAL/BALANCE display** (the wizard had them commented out with a duplicate `id="total"`)
> as a `#expense-totals` sibling-`<tbody>` refreshed by a Rule-4 percentage-keyup swap.
> Parity held: invoice-bulk-edit spec 5/5, full suite 50/50.
>
> **Editing a wizard buried in a large shared file:** the clojure-mcp structural tools
> (`clojure_edit` / `replace_sexp`) **reformat the whole file** — here that was a spurious
> 650-line whitespace diff that would bury the real change. For a surgical migration inside a
> big multi-modal file, use the **text-based Edit tool** instead (the AGENTS.md "absolutely
> necessary" carve-out), then `load-file` + `cljfmt` to verify. The resulting diff was fully
> contained to the requires + the bulk-edit region.
>
> **Repeated-row target-selector convention — settled (the Phase 5 exit criterion).** Across
> edit / bulk-code / sales-summary / invoice-bulk-edit the convention converged on: **explicit
> per-row ids** (`#account-location-<index>`, `#account-row-<index>`) for a cell-local swap
> (Rule 2), and a **single stable-id sibling-`<tbody>`** (`#account-totals` / `#expense-totals`)
> for running totals (Rule 4) — *not* data-attribute selectors or a `form-path→selector`
> helper. Per-row ids are generated from the row index the form already uses for field names
> (`path->name2`), so server and markup agree by construction. Whole-form swap (Rule 3) covers
> structural changes (add/remove row). This is now the cookbook default; see `swap-doctrine.md`.
> **Phase 6 — the wizard engine, and its first real modal (Transaction Rule).** The inflection
> phase. (a) **Engine** (`6a`, committed separately): `wizard-state` + `wizard2`, the Django
> `formtools` SessionStorage model, REPL-proven before any modal touched it. (b) **First real
> modal** (`6b`): the Transaction Rule wizard (edit step + read-only test/preview step) migrated
> onto the engine and **fully de-cursored** like Phases 2-5. Scorecard (`admin/transaction_rules.clj`):
> `fc/` cursor refs **82 -> 0**, `mm/` coupling **20 -> 0**, defrecords **3 -> 0** (EditModal /
> TestModal / TransactionRuleWizard all gone), LOC 1000 -> 964, the 4 wizard routes
> (open/navigate/save + per-dialog) collapse to **2** (`open-rule-wizard` for new+edit,
> `save-step` for every transition). Parity held: rule spec **4/4**, full suite **55/55**.
>
> **The engine generalizes even for a one-data-step "wizard".** Transaction Rule is *edit + a
> read-only preview of the same entity*, not two independent data steps — so it exercises the
> engine's render / navigation / `:all-data`-preview path but not the cross-step *merge* (that
> waits for Phase 7's Invoice Pay). The test step's `:render` reads `:all-data` (the engine's
> `get-all`), which here is just the edit step's rule — so the formtools "combine at the end"
> mechanism is exactly what feeds the preview table. Nav is the engine's `direction` field
> (plain submit buttons `name="direction" value="next|back|submit"`), so the per-step
> `navigate` route is deleted.
>
> **Note (scope):** the de-cursored edit step keeps `com/*` Hiccup leaf components rather than
> porting to `sc/*` Selmer partials — the modal's value was removing `fc/` + `mm/` and proving
> the engine, not re-templating its (conditional, Alpine-cross-field) layout. Hiccup-in-render
> (heuristic 9) is therefore a documented partial here; the leaf-component `com/ -> sc/` swap is
> a mechanical follow-up. The Alpine cross-field dispatch wiring (clientId -> accountId ->
> location) was preserved verbatim — de-cursoring touched only the data plumbing.
> **Phase 7 — Invoice Pay: the engine's cross-step merge, finally proven.** The first
> *genuine* multi-data-step wizard (every prior one was single-data-step wearing wizard
> costume, or edit+preview of one entity). Step 1 `choose-method` collects
> `{:bank-account :method}`; step 2 `payment-details` collects
> `{:invoices :check-number :handwritten-date :mode}`; the engine's `get-all` **merges the two
> independent payloads** for the per-method `pay!` (handwrite-check transacts a pending check;
> the others go through `print-checks-internal`). This is the exact mechanism the Phase-6
> reviewer flagged as unproven — now exercised end-to-end (gate 3/3: choose-method renders the
> bank-account + methods → handwrite-check advances to details → check number + submit shows
> the completion modal). Conditional rendering by method (handwrite shows check-number, print
> shows date) lives in step 2's render, reading `:method` from `:all-data`.
>
> **The whole file falls off the framework.** Invoice Pay was the *last* `mm`/`fc` user in
> `invoices.clj` (bulk-edit went in Phase 5), so the migration zeroed the file: `fc/` cursor
> refs **0**, `mm/` **0**, `defrecord` **0** (PayWizard + ChoosePaymentMethodModal +
> PaymentDetailsStep all gone), `step-params` **0** — and the `multi-modal` / `form-cursor` /
> `malli.util` requires were deleted outright. The pay wizard's 3 routes (open / navigate /
> submit) collapse to **2** (open = `open-pay-wizard`, every transition = `pay-step`); the
> cards post `{bank-account, method, direction:next}` straight to the engine submit-route
> instead of a bespoke navigate route.
>
> **Engine dividends from the review follow-up paid off here.** This migration *used* the
> primitives the engine absorbed after Phase 6: `:open-response` (modal wrap, so open is one
> handler), `nav-footer` (with the new `:save-label "Pay"`), the auto nav-field stripping (the
> flat `{bank-account, method}` decode needs no allowlist), and the Enter guard — so the
> consumer is config + render + the per-method `pay!`, not framework plumbing.
>
> **New constraint discovered:** wizard session data must be EDN-safe (the cookie store has no
> clj-time readers) — see `gotchas.md`. The de-cursored amounts grid stores the enriched
> invoice list in `:context`, which is fine at gate scale (1 invoice) but is the session-bloat
> risk the reviewer named; a leaner context (ids + amounts, re-query in render) is the
> follow-up if real payments carry many invoices.
---
## Phase 8 — New / Edit Invoice (the conditional-`:next`, dual-purpose wizard)
`auto-ap.ssr.invoice.new-invoice-wizard` — the hardest modal in the app: one wizard that
both **creates and edits** invoices, with a **conditional middle step** (basic-details →
*[accounts]* → next-steps, where accounts is skipped on the default-accounts path), Solr
typeaheads for client+vendor, an async account-prediction fragment, and live expense-account
totals.
**Finding: the OLD basic-details "Save" was broken.** It `hx-put`s `/invoice/new/navigate`,
whose `[:to {:optional true} …]` query-schema 500s on empty query-params (the `{}`→nil
`main-transformer` quirk — see `gotchas.md`). Production uses the identical `wrap-params`, so
it was broken there too; the underlying create only worked when POSTed straight to
`new-invoice-submit`. So the Phase 8 gate (`e2e/invoice-new.spec.ts`) is an **acceptance**
gate, not a green→green characterization: red on the old code, green on the engine (whose
submit is a POST with no query-schema). The migration *fixes* a latent bug. The create
*semantics* (default → vendor default account, location-spread; customize → the posted grid;
edit → prefilled + updated row) were pinned via REPL before/around the migration.
**Conditional `:next` is a one-liner** (`(if (= :customize …) :accounts :done)`) replacing the
`mm/CustomNext` protocol + the broken 308-to-submit. **Dual-purpose = one `:init-fn` branching
on a route `:db/id`**; `create-wizard!` seeds `:init-data` as per-step step-data so edit opens
both steps populated. See `form-vs-wizard.md`.
**Coupling outcome (the review's lens).** The whole wizard collapses to *config + render +
fragments*: `defrecord` **4 → 0** (NewWizard2 / BasicDetailsStep / AccountsStep / NextSteps all
gone), `mm/` **0**, `fc/` cursor refs **0**, `step-params[…]` field names **0**. The broken
`new-wizard-navigate` route is **deleted** (3 wizard-nav routes → the engine's open + submit);
the genuine async helpers (account-prediction, due-date, scheduled-payment-date,
location-select, expense-account total/balance, add-row) remain but were **de-coupled from
multi-form-state** — each now reads the posted flat form (+ `ws/get-all` for the one cross-step
value). `next-steps` stops being a wizard step and becomes the done-fn's returned modal (Pay
now / Add another / Close), matching the Phase 7 pay-success shape.
**Verification:** full e2e suite **61/61** (58 prior + 3 new: basic-details renders;
default-path create → next-steps; customize-path → accounts grid → create → next-steps); the
`maybe-spread-locations` unit test still 6/6; create semantics + edit prefill confirmed at the
REPL; dates ride as `#inst` so step-data is EDN-safe across the non-terminal step.
---
## Phase 9 — New / Edit Vendor (5-step linear wizard)
`auto-ap.ssr.admin.vendors` — a five-step linear wizard (info → terms → account → address →
legal) plus a separate Merge dialog. Migrated onto the engine following the Phase 8 template.
**Latent bug found + fixed (again):** the old "Next" PUT `/admin/vendor/navigat` (note the
typo) carried a `[:map [:db/id entity-id]]` route-schema on a route with **no** `:db/id` path
param, so empty route-params `{}`→nil 500d every advance (same `main-transformer` quirk as
Phase 8's query-params). The engine's submit is a POST with no such schema → gone.
**Coupling outcome:** `defrecord` **5 → 0** (InfoModal / TermsModal / AccountModal /
AddressModal / LegalEntityModal + VendorWizard all gone), `mm/` **0**, `fc/` cursor refs
**0** (the wizard *and* the de-cursored Merge dialog), `step-params[…]` **0**. Routes: the
broken `navigate` is deleted (open + submit + 3 add-rows + account-typeahead remain). The 5
step renders are plain data + `path->name2` + a `*errors*` binding; the 3 repeated grids
(terms-overrides / automatic-payment / account-overrides) became `add-row-handler` + a
`blank-row` row render. The wizard timeline is preserved as a per-step side panel.
**Engine refinements exercised:** conditionless linear `:next`; `:init-fn` branches new
(empty) vs edit (entity split across the 5 steps' `:init-data`, which `create-wizard!` seeds
as per-step step-data so edit opens fully populated); per-step `:validate` via
`mc/validate` + `me/humanize` replaces the old `wrap-ensure-step` schema assertion;
`vendor-step` wraps `handle-step-submit` in `try+` to surface create-time validation as a
4xx. Two new gotchas surfaced and are documented: the empty-step `{}`→nil decode trap and the
blank-nested-entity upsert error (see `gotchas.md`).
**Verification:** full e2e suite **65/65** (61 prior + 4 new: info renders + timeline;
create across all 5 steps persists; edit opens prefilled and a rename persists; a too-short
name blocks advancing). Create + edit semantics also confirmed at the REPL (incl. the
cookie-session EDN round-trip). `maybe-spread-locations`-style domain helpers untouched.
---
## Phase 10 — New/Edit Client (the largest modal: 7 steps + a bank-account sub-editor)
**Coupling outcome:** `defrecord` **9 → 0** (InfoModal / MatchesModal / ContactModal /
BankAccountsModal / IntegrationsModal / BankAccountModal / CashFlowModal /
OtherSettingsModal + ClientWizard all gone), `mm/` **0**, `fc/` cursor refs **0**,
`step-params[…]` **0**, `bank-account-card`/`bank-account-form` multimethods (dispatched on
`(comp deref :bank-account/type)`) collapsed to plain `case` on a data map. Routes: the
broken `navigate` + `discard` are deleted; four bank-account sub-editor routes added
(new/edit/accept/discard) + sort kept. The grid, both form schemas, and ~200 lines of
sales power-query export are preserved **verbatim** (stitched around the rewritten wizard
region rather than retyped).
**The new pattern — a parameterized sub-step on a linear engine.** The old
`[:bank-account which]` mm sub-step (open one account, Accept/discard/sort, back to the
list) doesn't map onto wizard2's flat step list. Modeled instead as a *sub-editor of the
bank-accounts step*: see `form-vs-wizard.md` ("Sub-editor"). Key moves: list + editor are
both whole-form swaps of `#wizard-form`; dedicated routes mutate `:bank-accounts`
step-data in the session via `ws/put-step` and re-render through `wizard2/render-wizard`;
the step's own `:decode` is a **pass-through** that re-reads the session list (via a `wiz`
hidden the engine doesn't strip) so Next never wipes the out-of-band list.
**Fixes carried/!surfaced:** new-vs-edit keyed off `:db/id` presence (engine always POSTs,
so the old PUT/POST split is gone); client + bank-account dates → `#inst` for EDN-safe
session; the **blank-address** trap recurs (empty Contact address posts blank fields →
all-nil db/id-less map → "tempid used only as value") — same `blank-address?` drop as
Phase 9. A long detour confirmed the REPL is direct-link-poisoned: validate migrations
against a fresh `TEST_SERVER_PORT=… lein run -m auto-ap.test-server` JVM, not the REPL.
**Verification:** full e2e suite **71/71** (65 prior + 6 client-wizard: new dialog +
timeline; edit prefill w/ disabled code; bank-accounts card + add affordance; editor
open/discard; accept-merge; edit→save round-trip). Engine flow + accept + pass-through +
edit init also confirmed at the REPL.

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.

View File

@@ -1,5 +1,11 @@
import { test, expect } from '@playwright/test';
// Reset the shared test-server dataset before each test so tests are isolated
// from one another (and from other spec files) regardless of run order.
test.beforeEach(async ({ request }) => {
await request.post('/test-reset');
});
let testInfoCache: any = null;
async function getTestInfo(page: any) {
@@ -34,7 +40,7 @@ async function openBulkCodeModal(page: any) {
const codeButton = page.locator('button:has-text("Code")').first();
await codeButton.click();
await page.waitForSelector('#modal-holder[x-show="open"]', { state: 'visible' });
await page.waitForSelector('#wizardmodal');
await page.waitForSelector('#bulkcodemodal');
}
async function closeBulkCodeModal(page: any) {
@@ -150,7 +156,7 @@ async function addNewAccount(page: any) {
}
async function submitBulkCodeForm(page: any) {
const form = page.locator('#wizard-form');
const form = page.locator('#bulk-code-form');
await form.evaluate((el: HTMLFormElement) => {
el.dispatchEvent(new Event('submit', { bubbles: true }));
});
@@ -178,7 +184,7 @@ test.describe('Bulk Code Transactions - Happy Path', () => {
await expect(page.locator('text=Bulk editing 1 transactions')).toBeVisible();
// Select vendor
const vendorHidden = page.locator('input[type="hidden"][name="step-params[vendor]"]').first();
const vendorHidden = page.locator('input[type="hidden"][name="vendor"]').first();
const testInfo = await getTestInfo(page);
await vendorHidden.evaluate((el: HTMLInputElement, value: string) => {
const newInput = document.createElement('input');
@@ -190,7 +196,7 @@ test.describe('Bulk Code Transactions - Happy Path', () => {
await page.waitForTimeout(300);
// Select approval status
const statusSelect = page.locator('select[name="step-params[approval-status]"]').first();
const statusSelect = page.locator('select[name="approval-status"]').first();
await statusSelect.selectOption('approved');
// Add account
@@ -272,7 +278,7 @@ test.describe('Bulk Code Transactions - Validation', () => {
const testInfo = await getTestInfo(page);
const vendorId = testInfo.accounts.vendor;
const vendorContainer = page.locator('div[hx-post*="vendor-changed"]').first();
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) => {
@@ -287,11 +293,11 @@ test.describe('Bulk Code Transactions - Validation', () => {
el.dispatchEvent(new Event('change', { bubbles: true }));
});
await page.waitForResponse(response => response.url().includes('/vendor-changed') && response.status() === 200);
await page.waitForResponse(response => response.url().includes('/form-changed') && response.status() === 200);
await page.waitForTimeout(500);
// Select approval status
const statusSelect = page.locator('select[name="step-params[approval-status]"]').first();
const statusSelect = page.locator('select[name="approval-status"]').first();
await statusSelect.selectOption('approved');
// Vendor selection pre-populated a default account row at 100%.
@@ -304,10 +310,14 @@ test.describe('Bulk Code Transactions - Validation', () => {
// 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());
// Vendor should still be selected. The vendor typeahead is Alpine-managed and posts
// its value via an x-bound hidden input, so the right correctness check is what the
// form actually submits (the value that gets saved), not the lagging DOM .value of the
// hidden read in isolation.
await expect.poll(async () =>
page.locator('#bulk-code-form').evaluate((f: HTMLFormElement) =>
new FormData(f).get('vendor'))
).toBe(vendorId.toString());
// Status should still be selected
const statusValueAfter = await statusSelect.inputValue();
@@ -458,7 +468,7 @@ test.describe('Bulk Code Transactions - Vendor Pre-population', () => {
// The vendor typeahead dispatches change from its parent div
// We need to set the hidden input and dispatch change on the container
const vendorContainer = page.locator('div[hx-post*="vendor-changed"]').first();
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) => {
@@ -475,7 +485,7 @@ test.describe('Bulk Code Transactions - Vendor Pre-population', () => {
});
// Wait for HTMX response
await page.waitForResponse(response => response.url().includes('/vendor-changed') && response.status() === 200);
await page.waitForResponse(response => response.url().includes('/form-changed') && response.status() === 200);
await page.waitForTimeout(500);
// Account should be pre-populated - check for account row
@@ -515,7 +525,7 @@ test.describe('Bulk Code Transactions - Vendor Pre-population', () => {
const testInfo = await getTestInfo(page);
const vendorId = testInfo.accounts.vendor;
const vendorContainer = page.locator('div[hx-post*="vendor-changed"]').first();
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) => {
@@ -532,7 +542,7 @@ test.describe('Bulk Code Transactions - Vendor Pre-population', () => {
});
// Wait for HTMX response
await page.waitForResponse(response => response.url().includes('/vendor-changed') && response.status() === 200);
await page.waitForResponse(response => response.url().includes('/form-changed') && response.status() === 200);
await page.waitForTimeout(500);
// Should pre-populate the vendor's default account (non-clientized) plus the "New account" row

127
e2e/client-wizard.spec.ts Normal file
View File

@@ -0,0 +1,127 @@
import { test, expect } from '@playwright/test';
// Acceptance spec for the New/Edit Client wizard — the largest SSR modal in the app:
// seven linear steps (info → matches → contact → bank-accounts → integrations → cash-flow →
// other-settings) PLUS a parameterized bank-account sub-editor reached from the
// bank-accounts step. Migrated onto the session-backed engine (wizard2): flat de-cursored
// field names (client/name, not step-params[client/name]), whole-form HTMX swaps, and the
// bank-account add/edit/sort modeled as whole-form swaps of #wizard-form.
//
// The seed (test_server.clj) exposes client "Test Client" (code TEST, location DT) which
// owns one "Test Checking" (TEST-CHK, a checking account) bank account.
test.beforeEach(async ({ request }) => { await request.post('/test-reset'); });
// The grid lazy-loads its rows into #entity-table after the page renders.
async function openClientList(page: any) {
await page.goto('/admin/client');
await page.waitForSelector('#entity-table tbody tr[data-id]');
}
async function openNewClient(page: any) {
await openClientList(page);
await page.locator('button:has-text("New Client")').first().click();
await page.waitForSelector('#wizard-form');
await page.waitForTimeout(400);
}
async function openEditTestClient(page: any) {
await openClientList(page);
await page.locator('#entity-table tbody tr', { hasText: 'Test Client' }).first()
.locator('[hx-get*="/edit"]').first().click();
await page.waitForSelector('#wizard-form');
await page.waitForTimeout(400);
}
// Advance one step: click the data-primary Next button and wait until the whole-form swap
// has actually changed the current-step hidden (the timeline lists every step name, so a
// text check can't confirm progress — the hidden value can).
async function advance(page: any) {
const before = await page.locator('#wizard-form input[name="current-step"]').first().inputValue();
await page.locator('#wizard-form button[data-primary]').first().click();
await page.waitForFunction(
(prev: string) => {
const el = document.querySelector('#wizard-form input[name="current-step"]') as HTMLInputElement | null;
return !!el && el.value !== prev;
}, before, { timeout: 6000 });
}
test.describe.configure({ mode: 'serial' });
test.describe('Client wizard (acceptance)', () => {
test('new dialog renders the info step with the 7-step timeline', async ({ page }) => {
await openNewClient(page);
const form = page.locator('#wizard-form');
await expect(form.locator('input[name="client/name"]')).toBeVisible();
await expect(form.locator('input[name="client/code"]').first()).toBeVisible();
await expect(form).toContainText('Locations');
for (const label of ['Info', 'Matches', 'Contact', 'Bank Accounts',
'Integrations', 'Cash Flow', 'Other Settings']) {
await expect(form).toContainText(label);
}
});
test('edit opens prefilled with the name and a disabled code', async ({ page }) => {
await openEditTestClient(page);
const form = page.locator('#wizard-form');
await expect(form.locator('input[name="client/name"]')).toHaveValue('Test Client');
// the visible code input is disabled on edit (a hidden twin carries the value on submit)
const code = form.locator('input[name="client/code"]').first();
await expect(code).toHaveValue('TEST');
await expect(code).toBeDisabled();
});
test('bank-accounts step shows the seeded account card and a new-account affordance', async ({ page }) => {
await openEditTestClient(page);
await advance(page); // info -> matches
await advance(page); // matches -> contact
await advance(page); // contact -> bank-accounts
const form = page.locator('#wizard-form');
await expect(form).toContainText('Bank Accounts');
await expect(form).toContainText('Test Checking');
await expect(form).toContainText('Add a new');
});
test('opening the bank-account editor swaps in the per-account form', async ({ page }) => {
await openEditTestClient(page);
await advance(page); await advance(page); await advance(page); // -> bank-accounts
// click the pencil on the seeded account card to open its editor
await page.locator('#wizard-form [hx-get*="/bank-account/edit"]').first().click();
await page.waitForTimeout(450);
const form = page.locator('#wizard-form');
await expect(form.locator('input[name="bank-account/name"]')).toHaveValue('Test Checking');
await expect(form).toContainText('Accept');
// discard returns to the list
await page.locator('#wizard-form [hx-get*="/bank-account/discard"]').first().click();
await page.waitForTimeout(450);
await expect(form).toContainText('Test Checking');
});
test('accepting a bank-account edit merges the change back into the card', async ({ page }) => {
await openEditTestClient(page);
await advance(page); await advance(page); await advance(page); // -> bank-accounts
await page.locator('#wizard-form [hx-get*="/bank-account/edit"]').first().click();
await page.waitForTimeout(450);
const form = page.locator('#wizard-form');
await form.locator('input[name="bank-account/name"]').fill('Renamed Checking');
await form.locator('button[data-primary]:has-text("Accept")').first().click();
await page.waitForTimeout(450);
// back on the list, the card now shows the new nickname
await expect(form).toContainText('Renamed Checking');
});
test('editing through to the last step and saving keeps the client in the grid', async ({ page }) => {
await openEditTestClient(page);
const nameInput = page.locator('#wizard-form input[name="client/name"]');
await nameInput.fill('Test Client RENAMED');
await expect(nameInput).toHaveValue('Test Client RENAMED');
// info -> matches -> contact -> bank-accounts -> integrations -> cash-flow -> other-settings
for (let i = 0; i < 6; i++) await advance(page);
// the last step is the only one with a Feature Flags grid — confirm we really got here
await expect(page.locator('#wizard-form')).toContainText('Feature Flags');
// Save persists the edit; reload the grid and the rename is there
await page.locator('#wizard-form button[data-primary]').first().click();
await page.waitForTimeout(1200);
await openClientList(page);
await expect(page.locator('#entity-table')).toContainText('Test Client RENAMED');
});
});

View File

@@ -0,0 +1,145 @@
import { test, expect } from '@playwright/test';
// Characterization spec for the Invoice Bulk Edit modal. Captures CURRENT
// (pre-migration) behavior so the wizard -> plain-Selmer migration can be proven
// behavior-preserving. Reset the shared dataset before each test for isolation.
test.beforeEach(async ({ request }) => {
await request.post('/test-reset');
});
async function getTestInfo(page: any) {
return (await page.request.get('/test-info')).json();
}
async function navigateToInvoices(page: any) {
await page.setExtraHTTPHeaders({ 'x-clients': '"mine"' });
await page.goto('/invoice');
await page.waitForSelector('#entity-table tbody tr');
}
async function selectFirstInvoice(page: any) {
await page.locator('#entity-table tbody input[type="checkbox"]').first().click();
await page.waitForTimeout(200);
}
async function openBulkEditModal(page: any) {
await page.locator('button:has-text("Bulk Edit")').first().click();
await page.waitForSelector('#wizardmodal');
}
async function addNewAccount(page: any) {
await page.locator('a:has-text("New account")').first().click();
await page.waitForTimeout(500);
}
// Set an account on a row by replacing its Alpine-managed hidden input with a plain
// one (Solr-backed typeahead is unavailable in tests), then dispatching the location
// reload -- the same approach the bulk-code spec uses.
async function setRowAccount(page: any, rowIndex: number, accountId: string) {
const rows = page.locator('#bulk-edit-form tbody tr');
const row = rows.nth(rowIndex);
const hidden = row.locator('input[type="hidden"][name*="[account]"]').first();
await hidden.evaluate((el: HTMLInputElement, value: string) => {
const n = document.createElement('input');
n.type = 'hidden'; n.name = el.name; n.value = value;
el.parentNode!.replaceChild(n, el);
}, accountId);
await page.waitForTimeout(200);
const loc = row.locator('[x-dispatch\\:changed]').first();
if (await loc.count() > 0) {
await loc.evaluate((el: HTMLElement) => el.dispatchEvent(new CustomEvent('changed', { bubbles: true })));
await page.waitForTimeout(400);
}
}
async function setRowPercentage(page: any, rowIndex: number, pct: string) {
const row = page.locator('#bulk-edit-form tbody tr').nth(rowIndex);
const input = row.locator('input.amount-field, input[name*="percentage"]').first();
await input.fill(pct);
await input.dispatchEvent('change');
await page.waitForTimeout(200);
}
async function submitForm(page: any) {
await page.locator('#bulk-edit-form').evaluate((f: HTMLFormElement) =>
f.dispatchEvent(new Event('submit', { bubbles: true })));
}
test.describe.configure({ mode: 'serial' });
test.describe('Invoice Bulk Edit (characterization)', () => {
test('opens the modal with the expense-account grid', async ({ page }) => {
await navigateToInvoices(page);
await selectFirstInvoice(page);
await openBulkEditModal(page);
const modal = page.locator('#wizardmodal');
await expect(modal).toContainText('Bulk editing 1 invoices');
await expect(modal).toContainText('Account');
await expect(modal).toContainText('Location');
await expect(modal).toContainText('TOTAL');
await expect(modal).toContainText('BALANCE');
// a default expense-account row is present, plus the New account button
expect(await modal.locator('input[name*="expense-accounts"][name*="[account]"]').count()).toBeGreaterThanOrEqual(1);
await expect(modal.locator('a:has-text("New account")')).toBeVisible();
});
test('New account adds an expense-account row', async ({ page }) => {
await navigateToInvoices(page);
await selectFirstInvoice(page);
await openBulkEditModal(page);
const accountRows = () => page.locator('#bulk-edit-form input[name*="expense-accounts"][name*="[account]"]');
const before = await accountRows().count();
await addNewAccount(page);
expect(await accountRows().count()).toBe(before + 1);
});
test('saving a 100% account coding closes the modal', async ({ page }) => {
const info = await getTestInfo(page);
await navigateToInvoices(page);
await selectFirstInvoice(page);
await openBulkEditModal(page);
// the default row is at 100% already; set its account and save
await setRowAccount(page, 0, info.accounts['test-account'].toString());
await setRowPercentage(page, 0, '100');
await submitForm(page);
// a successful save fires modalclose -> the modal closes
await expect(page.locator('#wizardmodal')).toBeHidden({ timeout: 8000 });
});
// The TOTAL/BALANCE percentage rows were dead code in the wizard (commented out, with a
// duplicate id="total"); the migration implements them as a sibling-<tbody> Rule-4 swap.
test('TOTAL/BALANCE percentages render and recompute on edit (implemented)', async ({ page }) => {
await navigateToInvoices(page);
await selectFirstInvoice(page);
await openBulkEditModal(page);
// default row is 100% -> TOTAL 100.0%
await expect(page.locator('#expense-totals')).toContainText('100.0%');
// edit to 50% -> the totals tbody refreshes via the targeted swap
const pct = page.locator('#bulk-edit-form input.amount-field').first();
await pct.click();
await pct.fill('');
await pct.pressSequentially('50'); // keyup -> Rule-4 swap of #expense-totals
await expect(page.locator('#expense-totals')).toContainText('50.0%', { timeout: 5000 });
});
test('rejects when account percentages do not total 100%', async ({ page }) => {
const info = await getTestInfo(page);
await navigateToInvoices(page);
await selectFirstInvoice(page);
await openBulkEditModal(page);
await setRowAccount(page, 0, info.accounts['test-account'].toString());
await setRowPercentage(page, 0, '50');
await submitForm(page);
await page.waitForTimeout(1000);
// modal stays open on validation failure
await expect(page.locator('#modal-holder[x-show="open"]')).toBeVisible();
await expect(page.locator('#wizardmodal')).toContainText('does not equal 100%');
});
});

101
e2e/invoice-new.spec.ts Normal file
View File

@@ -0,0 +1,101 @@
import { test, expect } from '@playwright/test';
// Acceptance spec for the New Invoice wizard (basic-details -> [accounts] -> next-steps),
// the dual-purpose new+edit wizard with a CONDITIONAL middle step: basic-details creates
// straight away when customize-accounts = :default (the vendor's default expense account),
// and routes through the expense-accounts grid when :customize.
//
// NOTE: the pre-migration `mm` flow's basic-details "Save" was broken in this harness (and
// prod): the button PUT /invoice/new/navigate, whose `:to` query-schema 500s on empty
// query-params (the {}->nil main-transformer quirk). So this is an ACCEPTANCE gate -- red on
// the old code, green on the engine (whose submit is a POST with no query-schema). The seed
// exposes client TEST + vendor "Test Vendor" (default account "Test Account") via /test-info.
test.beforeEach(async ({ request }) => { await request.post('/test-reset'); });
async function seedIds(page: any): Promise<{ client: number; vendor: number }> {
const info = await (await page.request.get('/test-info')).json();
return { client: info.clientIds.test, vendor: info.accounts.vendor };
}
// Open the wizard from the invoice list (so htmx/alpine are present -- opening the modal
// fragment directly would submit natively).
async function openNewWizard(page: any) {
await page.setExtraHTTPHeaders({ 'x-clients': '"mine"' });
await page.goto('/invoice');
await page.waitForSelector('#entity-table tbody tr');
await page.locator('button:has-text("New invoice")').first().click();
await page.waitForSelector('#wizard-form');
await page.waitForTimeout(400);
}
// The vendor field is a typeahead whose hidden input posts invoice/vendor; set it the way a
// dropdown pick would land (the value the form submits).
async function setVendor(page: any, vendorId: number) {
await page.evaluate((id: number) => {
const hidden = document.querySelector('input[name="invoice/vendor"]') as HTMLInputElement;
hidden.value = String(id);
hidden.dispatchEvent(new Event('change', { bubbles: true }));
}, vendorId);
}
// The customize-accounts radio lives in an async fragment loaded on the "bryce" event (fired
// when the Alpine vendorId changes). Trigger that htmx load explicitly after setting vendor.
async function loadPrediction(page: any) {
await page.evaluate(() => {
const el = document.querySelector('#expense-account-prediction [hx-put], #expense-account-prediction[hx-put]');
// @ts-ignore
if (el && window.htmx) window.htmx.trigger(el, 'bryce');
});
await page.waitForTimeout(600);
}
const save = (page: any) => page.locator('#wizard-form button:has-text("Save")').first().click();
test.describe.configure({ mode: 'serial' });
test.describe('New Invoice wizard (acceptance)', () => {
test('basic-details renders the invoice fields', async ({ page }) => {
await openNewWizard(page);
const form = page.locator('#wizard-form');
await expect(form).toContainText('New invoice');
await expect(form).toContainText('Vendor');
await expect(form).toContainText('Date');
await expect(form).toContainText('Invoice Number');
await expect(form).toContainText('Total');
await expect(form.locator('input[name="invoice/total"]')).toBeVisible();
});
test('default-accounts path creates the invoice and offers to pay it now', async ({ page }) => {
const { vendor } = await seedIds(page);
await openNewWizard(page);
await setVendor(page, vendor);
await page.locator('input[name="invoice/invoice-number"]').fill('NEW-1001');
await page.locator('input[name="invoice/total"]').fill('212.44');
await page.waitForTimeout(200);
await save(page);
await page.waitForTimeout(1200);
// the next-steps modal (done-fn output) -- no accounts step on the default path
await expect(page.locator('body')).toContainText('Would you like to pay this invoice now?');
});
test('customize-accounts path routes through the expense-accounts grid then creates', async ({ page }) => {
const { vendor } = await seedIds(page);
await openNewWizard(page);
await setVendor(page, vendor);
await page.locator('input[name="invoice/invoice-number"]').fill('NEW-1002');
await page.locator('input[name="invoice/total"]').fill('300.00');
await loadPrediction(page);
// pick "Customize accounts" (the radio in the async fragment)
await page.locator('input[name="customize-accounts"][value="customize"]').first().check();
await page.waitForTimeout(150);
await save(page);
await page.waitForTimeout(1000);
// the expense-accounts step: a grid prefilled with the vendor's default account + total
const form = page.locator('#wizard-form');
await expect(form).toContainText('Invoice accounts');
await expect(form).toContainText('INVOICE TOTAL');
await save(page); // accounts -> done
await page.waitForTimeout(1200);
await expect(page.locator('body')).toContainText('Would you like to pay this invoice now?');
});
});

73
e2e/invoice-pay.spec.ts Normal file
View File

@@ -0,0 +1,73 @@
import { test, expect } from '@playwright/test';
// Characterization spec for the Invoice Pay wizard (the first genuine multi-data-step
// wizard: choose-method -> payment-details, merged at submit). Captures CURRENT
// (pre-migration) behavior so the migration onto the session-backed engine can be proven
// behavior-preserving. The seed's lone unpaid invoice (UNPAID-001, Test Vendor, $150,
// client TEST) is payable; its client has one visible check bank account (Test Checking).
test.beforeEach(async ({ request }) => { await request.post('/test-reset'); });
// Select the unpaid invoice on the grid and open the pay wizard (choose-method step).
async function openPayWizard(page: any) {
await page.setExtraHTTPHeaders({ 'x-clients': '"mine"' });
await page.goto('/invoice');
await page.waitForSelector('#entity-table tbody tr');
await page.locator('#entity-table tbody input[type="checkbox"]').first().click();
await page.waitForTimeout(300);
// #pay-button's container hx-gets /invoice/pay on click; wait for the wizard to land.
await page.locator('#pay-button').first().click();
await page.waitForTimeout(900);
}
// The bank-account card's method options (print-check / debit / handwrite-check) live in a
// <template x-ref="tooltip"> revealed by clicking the card's tooltip button; open it.
async function openMethodTooltip(page: any) {
await page.locator('button[x-ref="button"]').first().click();
await page.waitForTimeout(400);
}
// Advance choose-method -> payment-details by picking a method (each is an hx-put to
// .../pay/navigate?to=:payment-details carrying step-params[method]).
async function pickMethod(page: any, method: string) {
await openMethodTooltip(page);
await page.locator(`[hx-vals*="${method}"]`).first().click();
await page.waitForTimeout(900);
}
test.describe.configure({ mode: 'serial' });
test.describe('Invoice Pay wizard (characterization)', () => {
test('choose-method step renders the bank account and its payment methods', async ({ page }) => {
await openPayWizard(page);
const body = page.locator('body');
await expect(body).toContainText('Payment method');
await expect(body).toContainText('Test Checking');
// a check account offers print-check / debit / handwrite-check (in the card's tooltip)
await openMethodTooltip(page);
expect(await page.locator('[hx-vals*="handwrite-check"]').count()).toBeGreaterThan(0);
expect(await page.locator('[hx-vals*="print-check"]').count()).toBeGreaterThan(0);
});
test('picking handwrite-check advances to the payment-details step', async ({ page }) => {
await openPayWizard(page);
await pickMethod(page, 'handwrite-check');
const body = page.locator('body');
await expect(body).toContainText('Check number'); // handwrite-check-only field
await expect(body).toContainText('Date'); // check date
await expect(body.locator('button:has-text("Pay"), a:has-text("Pay")').first()).toBeVisible();
});
test('completing a handwritten-check payment shows the success modal', async ({ page }) => {
await openPayWizard(page);
await pickMethod(page, 'handwrite-check');
// step 2 collects the check number; method (step 1) + check-number (step 2) combine at submit
// scope to the wizard form (the background grid filters also have a check-number input)
await page.locator('#wizard-form input[name*="check-number"]').first().fill('10001');
await page.waitForTimeout(150);
// the footer Pay submit button, scoped to the form (not the background #pay-button)
await page.locator('#wizard-form button:has-text("Pay")').first().click();
await page.waitForTimeout(1500);
// the submit transacts a pending check payment and swaps in the completion modal
await expect(page.locator('body')).toContainText('payment is complete');
});
});

View File

@@ -0,0 +1,141 @@
import { test, expect } from '@playwright/test';
// Characterization spec for the POS Sales Summary edit modal. Captures CURRENT
// (pre-migration) behavior so the wizard -> plain-Selmer migration can be proven
// behavior-preserving. Reset the shared dataset before each test for isolation.
test.beforeEach(async ({ request }) => {
await request.post('/test-reset');
});
async function getTestInfo(page: any) {
return (await page.request.get('/test-info')).json();
}
async function openEditModal(page: any) {
await page.setExtraHTTPHeaders({ 'x-clients': '"mine"' });
await page.goto('/pos/summaries');
await page.waitForSelector('#entity-table tbody tr');
// The row's edit button is an hx-get to /pos/summaries/<id> (the edit-wizard route).
await page.locator('#entity-table tbody tr').first()
.locator('a[hx-get], button[hx-get]').first().click();
await page.waitForSelector('#wizardmodal');
}
test.describe.configure({ mode: 'serial' });
test.describe('Sales Summary Edit (characterization)', () => {
test('opens the edit modal with debit/credit columns, categories, accounts and amounts', async ({ page }) => {
await openEditModal(page);
const modal = page.locator('#wizardmodal');
await expect(modal).toContainText('Edit Summary');
await expect(modal).toContainText('Debits');
await expect(modal).toContainText('Credits');
// seeded items
await expect(modal).toContainText('Cash Deposit'); // debit item category
await expect(modal).toContainText('Food Sales'); // credit item category
// resolved account names (account-display-cell pulls the account name)
await expect(modal).toContainText('Second Account'); // debit item account
await expect(modal).toContainText('Test Account'); // credit item account
// amounts render
await expect(modal).toContainText('$500.00');
// two account cells, each with an inline-edit pencil
expect(await modal.locator('.account-cell').count()).toBe(2);
expect(await modal.locator('[hx-get*="edit/item-account"]').count()).toBe(2);
});
test('seeded summary is balanced (shows Balanced totals, no out-of-balance)', async ({ page }) => {
await openEditModal(page);
const modal = page.locator('#wizardmodal');
// balanced: $500 debit == $500 credit -> the (now-fixed) totals block shows Balanced
await expect(modal.locator('#summary-totals')).toContainText('Balanced');
await expect(modal.locator('#summary-totals')).toContainText('$500.00');
await expect(modal).not.toContainText('Unbalanced');
});
test('inline account edit: pencil opens the typeahead editor; cancel restores the display', async ({ page }) => {
await openEditModal(page);
const modal = page.locator('#wizardmodal');
// The debit row shows "Second Account" with a pencil. Click it -> account-edit-cell.
const debitCell = modal.locator('.account-cell', { hasText: 'Second Account' }).first();
await debitCell.locator('[hx-get*="edit/item-account"]').click();
// edit cell: a typeahead plus check (save) + cancel buttons
const editCell = modal.locator('.account-cell').filter({ has: page.locator('[hx-put*="save-item-account"]') }).first();
await expect(editCell.locator('[hx-put*="save-item-account"]')).toBeVisible();
await expect(editCell.locator('[hx-get*="cancel-item-account"]')).toBeVisible();
// Cancel -> back to display mode showing the original account
await editCell.locator('[hx-get*="cancel-item-account"]').click();
await page.waitForTimeout(300);
await expect(modal.locator('.account-cell', { hasText: 'Second Account' }).first()).toBeVisible();
// back in display mode: the pencil (edit) is shown again
await expect(modal.locator('.account-cell', { hasText: 'Second Account' })
.first().locator('[hx-get*="edit/item-account"]')).toBeVisible();
});
test('inline account edit: save (check) re-renders the account display cell', async ({ page }) => {
await openEditModal(page);
const modal = page.locator('#wizardmodal');
const creditCell = modal.locator('.account-cell', { hasText: 'Test Account' }).first();
await creditCell.locator('[hx-get*="edit/item-account"]').click();
const editCell = modal.locator('.account-cell').filter({ has: page.locator('[hx-put*="save-item-account"]') }).first();
await expect(editCell.locator('[hx-put*="save-item-account"]')).toBeVisible();
// Save without changing -> display cell re-renders, account preserved, pencil back.
await editCell.locator('[hx-put*="save-item-account"]').click();
await page.waitForTimeout(300);
const display = modal.locator('.account-cell', { hasText: 'Test Account' }).first();
await expect(display).toBeVisible();
await expect(display.locator('[hx-get*="edit/item-account"]')).toBeVisible();
});
// The "New Summary Item" button was broken in the pre-migration wizard (its Alpine
// handler threw "newRowIndex is not defined"); the migration fixes it as a whole-form
// swap (op=new-item). This asserts the FIXED behavior: clicking adds an editable manual
// row (category + account typeahead + debit/credit money inputs).
test('New Summary Item adds an editable manual row (fixed)', async ({ page }) => {
await openEditModal(page);
const modal = page.locator('#wizardmodal');
expect(await modal.locator('.manual-item-row').count()).toBe(0);
await modal.locator('a[hx-vals*="new-item"]').click();
await expect(modal.locator('.manual-item-row').first()).toBeVisible();
expect(await modal.locator('.manual-item-row').count()).toBe(1);
const row = modal.locator('.manual-item-row').first();
await expect(row.locator('input[placeholder="Category/Explanation"]')).toBeVisible();
expect(await row.locator('input[name*="[debit]"]').count()).toBe(1);
expect(await row.locator('input[name*="[credit]"]').count()).toBe(1);
});
test('a manual debit amount recomputes the totals to Unbalanced (fixed)', async ({ page }) => {
await openEditModal(page);
const modal = page.locator('#wizardmodal');
await expect(modal.locator('#summary-totals')).toContainText('Balanced');
await modal.locator('a[hx-vals*="new-item"]').click();
await expect(modal.locator('.manual-item-row').first()).toBeVisible();
// adding a $500 debit -> $1000 debit vs $500 credit -> the totals block recomputes
const debit = modal.locator('.manual-item-row input[name*="[debit]"]').first();
await debit.click();
await debit.pressSequentially('500'); // fires keyup -> hx-trigger "keyup changed delay:300ms"
await expect(modal.locator('#summary-totals')).toContainText('Unbalanced', { timeout: 5000 });
});
test('Save closes the modal and the summary stays in the grid', async ({ page }) => {
await openEditModal(page);
const modal = page.locator('#wizardmodal');
// submit the form via the Save button; the PUT swaps the grid row + fires modalclose
const putResp = page.waitForResponse(r =>
r.url().includes('/pos/summaries') && r.request().method() === 'PUT');
await modal.locator('button[type="submit"]').click();
expect((await putResp).status()).toBe(200);
// modalclose hides the modal (it is hidden, not removed from the DOM)
await expect(modal).toBeHidden({ timeout: 5000 });
// the grid still shows the summary row
await expect(page.locator('#entity-table tbody tr')).toHaveCount(1);
});
});

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

@@ -1,5 +1,11 @@
import { test, expect } from '@playwright/test';
// Reset the shared test-server dataset before each test so tests are isolated
// from one another (and from other spec files) regardless of run order.
test.beforeEach(async ({ request }) => {
await request.post('/test-reset');
});
async function openEditModal(page: any, transactionIndex: number = 0) {
// Navigate to transactions page
await page.goto('/transaction2');
@@ -13,13 +19,21 @@ async function openEditModal(page: any, transactionIndex: number = 0) {
// Wait for the modal to open
await page.waitForSelector('#modal-holder[x-show="open"]', { state: 'visible' });
await page.waitForSelector('#wizardmodal');
await page.waitForSelector('#editmodal');
// The modal is now single-page (Edit Transaction). Click "Manual" tab to ensure
// the manual account coding form is active.
await page.click('button:has-text("Manual")');
// Wait for the manual form to appear
// Transactions with 0-1 accounts open in "simple" mode, which has no account
// grid. Switch to "advanced" mode (a whole-form 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 (account grid) to appear
await page.waitForSelector('#account-grid-body');
}
@@ -33,68 +47,33 @@ async function getTestInfo(page: any) {
}
async function selectAccountFromTypeahead(page: any, rowIndex: number, accountName: string) {
// The account search uses Solr which isn't available in tests.
// Instead, we directly set the hidden input value via JavaScript.
// Get all rows except the new-row, total, balance, and transaction total rows
const allRows = page.locator('#account-grid-body tbody tr');
const rowCount = await allRows.count();
// Find the row that has a hidden input for account (actual account rows)
let accountRow = null;
let accountRowIndex = 0;
for (let i = 0; i < rowCount; i++) {
const row = allRows.nth(i);
const hasAccountInput = await row.locator('input[name*="transaction-account/account"]').count() > 0;
if (hasAccountInput) {
if (accountRowIndex === rowIndex) {
accountRow = row;
break;
}
accountRowIndex++;
}
}
if (!accountRow) {
throw new Error(`Could not find account row at index ${rowIndex}`);
}
// Find the hidden input for the account
const hiddenInput = accountRow.locator('input[type="hidden"][name*="transaction-account/account"]').first();
// Get account IDs from test-info endpoint
const testInfo = await getTestInfo(page);
// Account search is backed by Solr (unavailable in tests). Drive the typeahead the
// way a user does, using the Alpine v3 API: open the tippy dropdown, inject a result
// into the component's `elements`, then click it. This runs the real click handler,
// Alpine reactivity and the HTMX swap exactly as in production -- unlike poking the
// long-removed Alpine v2 `__x` internal, which silently no-ops on Alpine v3 and left
// the posted account empty.
const accountKey = accountName === 'Test' ? 'test-account' : 'second-account';
const label = `${accountName} Account`;
const testInfo = await getTestInfo(page);
const accountId = testInfo.accounts[accountKey];
if (!accountId) {
throw new Error(`Could not find account with name ${accountName}`);
}
// Set the hidden input value and trigger change
// Also update Alpine.js data to prevent it from overwriting our value
await hiddenInput.evaluate((el: HTMLInputElement, value: string) => {
// Set the DOM value
el.value = value;
const row = page.locator('#account-grid-body tbody tr.account-row').nth(rowIndex);
const typeahead = row.locator('div.relative[x-data]').first();
await typeahead.locator('a[x-ref="input"]').click();
const search = page.locator('[data-tippy-root] input[x-model="search"]').first();
await search.waitFor({ state: 'visible' });
await search.fill('te');
await typeahead.evaluate((el: any, opt: { id: number; label: string }) => {
(window as any).Alpine.$data(el).elements = [{ value: opt.id, label: opt.label }];
}, { id: accountId, label });
await page.locator('[data-tippy-root] a', { hasText: label }).first().click();
// Update Alpine.js component data
const alpineEl = el.closest('[x-data]');
if (alpineEl && (alpineEl as any).__x) {
(alpineEl as any).__x.$data.value.value = parseInt(value);
(alpineEl as any).__x.$data.value.label = 'Selected Account';
}
// Also update any parent Alpine model (accountId)
const rowEl = el.closest('tr[x-data]');
if (rowEl && (rowEl as any).__x) {
(rowEl as any).__x.$data.accountId = parseInt(value);
}
el.dispatchEvent(new Event('change', { bubbles: true }));
}, accountId.toString());
// Wait for any HTMX updates
await page.waitForTimeout(300);
// Wait for the change-gated whole-form swap to settle.
await page.waitForTimeout(400);
}
async function findAccountRow(page: any, rowIndex: number) {
@@ -151,14 +130,13 @@ async function getAccountLocation(page: any, rowIndex: number): Promise<string>
}
async function removeAllAccounts(page: any) {
const accountRows = page.locator('#account-grid-body tbody tr.account-row');
const rowCount = await accountRows.count();
for (let i = rowCount - 1; i >= 0; i--) {
const row = accountRows.nth(i);
const removeButton = row.locator('.account-remove-action');
await removeButton.click();
// Wait for the Alpine.js removal animation (500ms + buffer)
// Re-query each iteration: every remove is a whole-form swap that re-renders the rows,
// so a row index captured up front goes stale. Click the last remove button until none
// remain.
for (let guard = 0; guard < 20; guard++) {
const removeButtons = page.locator('#account-grid-body .account-remove-action');
if (await removeButtons.count() === 0) break;
await removeButtons.last().click();
await page.waitForTimeout(700);
}
}
@@ -172,23 +150,23 @@ async function saveTransaction(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();
// Wait for HTMX to swap the grid body
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);
}
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();
// Wait for HTMX to swap the grid body
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);
}
@@ -237,78 +215,39 @@ test.describe('Transaction Edit Shared Location', () => {
});
test.describe('Transaction Edit Full Workflow', () => {
test('should code transaction with vendor using percentage, then split 50/50, then switch to dollars', async ({ page }) => {
// Step 1: Open edit modal and code with 100% to one account
await openEditModal(page);
// Switch to percentage mode first (this re-renders the grid from server state)
test('splits a transaction 50/50 in percentage mode and stores it as dollars', async ({ page }) => {
// Transaction 0 is $100. Code it 50% / 50% across two accounts in percentage mode and
// verify the save-time %->$ conversion stores/displays $50 + $50 on reopen.
//
// 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);
// Check if there's already an account from previous tests
const allRows = page.locator('#account-grid-body tbody tr');
const hasExistingAccount = await allRows.locator('input[name*="transaction-account/account"]').count() > 0;
if (!hasExistingAccount) {
// Add a new account row if none exist
await addNewAccount(page);
}
// 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 page.waitForTimeout(1000);
await selectAccountFromTypeahead(page, 1, 'Second');
await setAccountAmount(page, 1, '50');
// Save
await saveTransaction(page);
// Step 3: Re-open and verify dollar amounts
await openEditModal(page);
// The accounts should be persisted from the previous save
// Wait for accounts to load
// Reopen: dollar mode is the default, and each account is the converted $50.
await openEditModal(page, 0);
await page.waitForTimeout(500);
// Verify we're in dollar mode (default)
const dollarRadio = page.locator('input[name="step-params[amount-mode]"][value="$"]');
const dollarRadio = page.locator('input[name="amount-mode"][value="$"]');
await expect(dollarRadio).toBeChecked();
// Verify amounts are in dollars (converted from percentages on save)
const row0 = await findAccountRow(page, 0);
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();
const val0 = await (await findAccountRow(page, 0)).locator('.account-amount-field').inputValue();
const val1 = await (await findAccountRow(page, 1)).locator('.account-amount-field').inputValue();
expect(parseFloat(val0)).toBeCloseTo(50.0, 1);
expect(parseFloat(val1)).toBeCloseTo(50.0, 1);
// Save
await saveTransaction(page);
});
});
@@ -339,13 +278,13 @@ test.describe('Transaction Edit Validation', () => {
await expect(page.locator('#modal-holder[x-show="open"]')).toBeVisible();
// The form should still be present
const form = page.locator('#wizard-form');
const form = page.locator('#edit-form');
await expect(form).toBeVisible();
// Verify the account row is still there with our $50 value
const amountInput = page.locator('.account-amount-field').first();
const value = await amountInput.inputValue();
expect(parseFloat(value)).toBeCloseTo(50.0, 1);
// Note: the validation-error response re-renders the manual section, and with
// a single account that renders in "simple" mode (no advanced grid), so we
// don't assert on the advanced-grid amount field here. The error message
// below confirms the $50 value was received and validated.
// Verify the user-friendly error message is displayed
const errorElement = page.locator('#form-errors .error-content');
@@ -367,15 +306,16 @@ async function openEditModalForTransaction(page: any, description: string) {
const editButton = row.locator('button[hx-get*="/transaction2/"][hx-get*="/edit"]').first();
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('#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' });
// The modal is single-page: the link tabs ("Link to payment", "Link to unpaid
// invoices", ...) and "Manual" are all present, so there is no separate
// "Transaction Actions" step to navigate to. Just wait for the tabs to render.
await page.waitForSelector('button:has-text("Link to payment")');
}
async function selectVendorFromTypeahead(page: any, vendorName: string) {
@@ -386,7 +326,7 @@ async function selectVendorFromTypeahead(page: any, vendorName: string) {
throw new Error(`Could not find vendor with name ${vendorName}`);
}
const vendorContainer = page.locator('div[hx-post*="edit-vendor-changed"]').first();
const vendorContainer = page.locator('div[hx-vals*="vendor-changed"]').first();
const vendorHidden = vendorContainer.locator('input[type="hidden"]').first();
await vendorHidden.evaluate((el: HTMLInputElement, value: string) => {
@@ -401,7 +341,7 @@ async function selectVendorFromTypeahead(page: any, vendorName: string) {
el.dispatchEvent(new Event('change', { bubbles: true }));
});
await page.waitForResponse(response => response.url().includes('/edit-vendor-changed') && response.status() === 200);
await page.waitForResponse(response => response.url().includes('/edit-form-changed') && response.status() === 200);
await page.waitForTimeout(500);
}
@@ -449,9 +389,17 @@ test.describe('Transaction Edit Vendor Pre-population', () => {
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(400.0, 1);
expect(parseFloat(amountValue)).toBeCloseTo(txTotal, 1);
});
});
@@ -461,11 +409,11 @@ test.describe('Transaction Edit Vendor Pre-population', () => {
// `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-vendor-changed` -- runs exactly as in production. This is the flow that
// `edit-form-changed` (op=vendor-changed) -- runs exactly as in production. This is the flow that
// regressed: a stale native `change` from the search input used to win the race
// and revert the vendor to its previous value.
async function selectVendorViaDropdown(page: any, vendorId: number, vendorName: string) {
const wrapper = page.locator('div[hx-post*="edit-vendor-changed"]').first();
const wrapper = page.locator('div[hx-vals*="vendor-changed"]').first();
const typeahead = wrapper.locator('div.relative[x-data]').first();
// Open the dropdown (tippy renders the popper into [data-tippy-root]).
@@ -493,7 +441,7 @@ async function selectVendorViaDropdown(page: any, vendorId: number, vendorName:
await page.waitForResponse(
(response: any) =>
response.url().includes('/edit-vendor-changed') && response.status() === 200
response.url().includes('/edit-form-changed') && response.status() === 200
);
await page.waitForTimeout(500);
}
@@ -510,9 +458,9 @@ async function openManualVendorSection(page: any, transactionIndex: number) {
await editButton.click();
await page.waitForSelector('#modal-holder[x-show="open"]', { state: 'visible' });
await page.waitForSelector('#wizardmodal');
await page.waitForSelector('#editmodal');
await page.click('button:has-text("Manual")');
await page.waitForSelector('div[hx-post*="edit-vendor-changed"]');
await page.waitForSelector('div[hx-vals*="vendor-changed"]');
}
test.describe('Transaction Edit Vendor Selection', () => {
@@ -528,14 +476,14 @@ test.describe('Transaction Edit Vendor Selection', () => {
// 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-post*="edit-vendor-changed"] span[x-text="value.label"]')
.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-post*="edit-vendor-changed"] input[type="hidden"][name="step-params[transaction/vendor]"]'
'div[hx-vals*="vendor-changed"] input[type="hidden"][name="transaction/vendor"]'
)
.first();
await expect(hidden).toHaveValue(vendorId.toString());

View File

@@ -1,5 +1,11 @@
import { test, expect } from '@playwright/test';
// Reset the shared test-server dataset before each test so tests are isolated
// from one another (and from other spec files) regardless of run order.
test.beforeEach(async ({ request }) => {
await request.post('/test-reset');
});
// 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:

View File

@@ -1,5 +1,11 @@
import { test, expect } from '@playwright/test';
// Reset the shared test-server dataset before each test so tests are isolated
// from one another (and from other spec files) regardless of run order.
test.beforeEach(async ({ request }) => {
await request.post('/test-reset');
});
async function navigateToTransactions(page: any, path: string = '/transaction2') {
await page.setExtraHTTPHeaders({
'x-clients': '"mine"'
@@ -90,15 +96,24 @@ test.describe('Transaction Navigation - Amount Filter Persistence', () => {
test.describe('Transaction Navigation - Date Filter Persistence', () => {
test('should persist date-range preset when navigating between pages', async ({ page }) => {
// Step 1: Navigate with date-range=all (includes 2022 test data)
// Step 1: Navigate with date-range=all (includes 2022 test data).
// The server expands the "all" preset into a concrete start-date (~6 years
// back) and drops the date-range key, so persistence happens via start-date.
await navigateToTransactions(page, '/transaction2?date-range=all');
// Step 2: Click Unapproved nav link
await clickTransactionNavLink(page, 'Unapproved');
// Step 3: Verify date-range persisted
const unapprovedUrl = page.url();
expect(unapprovedUrl).toContain('date-range=all');
// Step 3: Verify the expanded date range persisted as a start-date.
// "all" resolves to roughly 6 years before today (MM/DD/YYYY).
const sixYearsAgo = new Date();
sixYearsAgo.setFullYear(sixYearsAgo.getFullYear() - 6);
const mm = String(sixYearsAgo.getMonth() + 1).padStart(2, '0');
const dd = String(sixYearsAgo.getDate()).padStart(2, '0');
const expectedStart = `${mm}/${dd}/${sixYearsAgo.getFullYear()}`;
const startDate = new URL(page.url()).searchParams.get('start-date');
expect(startDate).toBe(expectedStart);
});
});

View File

@@ -0,0 +1,126 @@
import { test, expect } from '@playwright/test';
// Characterization spec for the Transaction Rule wizard (edit step + test/preview step).
// Captures CURRENT (pre-migration) behavior so the migration onto the session-backed
// wizard engine can be proven behavior-preserving. Reset the dataset before each test.
test.beforeEach(async ({ request }) => {
await request.post('/test-reset');
});
async function getTestInfo(page: any) {
return (await page.request.get('/test-info')).json();
}
async function navigateToRules(page: any) {
// The rule fixtures live under client TEST2 (to stay out of the single-client TEST
// transaction grid the other specs use), so view as an admin who sees all clients.
await page.request.get('/test-set-client-mode?mode=multi-client');
await page.setExtraHTTPHeaders({ 'x-clients': '"all"' });
await page.goto('/admin/transaction-rule');
await page.waitForSelector('#entity-table');
}
async function openNewDialog(page: any) {
await page.locator('button:has-text("New Transaction Rule")').first().click();
await page.waitForSelector('#wizard-form');
}
async function openEditDialog(page: any) {
// the edit pencil on the seeded rule's row (hx-get .../<id>/edit)
await page.locator('#entity-table tbody tr').first()
.locator('[hx-get*="/edit"]').first().click();
await page.waitForSelector('#wizard-form');
}
// Add a valid account-coding row (Solr typeahead unavailable in tests, so inject the
// account id into the row's hidden input), location Shared, percentage 100.
async function addAccount(page: any, accountId: string) {
await page.locator('#wizard-form a:has-text("New account")').first().click();
await page.waitForTimeout(400);
const hidden = page.locator('#wizard-form input[type="hidden"][name*="[transaction-rule-account/account]"]').first();
await hidden.evaluate((el: HTMLInputElement, v: string) => {
const n = document.createElement('input'); n.type = 'hidden'; n.name = el.name; n.value = v;
el.parentNode!.replaceChild(n, el);
}, accountId);
await page.waitForTimeout(200);
const loc = page.locator('#wizard-form select[name*="[transaction-rule-account/location]"]').first();
if (await loc.count() > 0) await loc.selectOption('Shared').catch(() => {});
const pct = page.locator('#wizard-form input[name*="[transaction-rule-account/percentage]"]').first();
await pct.fill('100');
await pct.dispatchEvent('change');
await page.waitForTimeout(200);
}
async function fillDescription(page: any, desc: string) {
await page.locator('#wizard-form input[name="transaction-rule/description"]').first().fill(desc);
}
// Approval status is required to advance/save; the radio-card's first option is "Approved".
async function selectApproved(page: any) {
const radio = page.locator('#wizard-form input[type="radio"][name*="transaction-approval-status"]').first();
await radio.check({ force: true }).catch(async () => {
await page.locator('#wizard-form label:has-text("Approved")').first().click();
});
await page.waitForTimeout(100);
}
async function clickTest(page: any) {
// the footer "Test" button navigates edit -> test
await page.locator('#wizard-form button:has-text("Test"), #wizard-form a:has-text("Test")').first().click();
await page.waitForTimeout(800);
}
test.describe.configure({ mode: 'serial' });
test.describe('Transaction Rule wizard (characterization)', () => {
test('New dialog opens the edit step with rule form + account grid', async ({ page }) => {
await navigateToRules(page);
await openNewDialog(page);
const modal = page.locator('#wizard-form');
await expect(modal).toContainText('Description');
await expect(modal).toContainText('Outcomes');
await expect(modal).toContainText('New account');
await expect(modal).toContainText('Approval status');
// the step indicator + the Test (advance) control
await expect(modal.locator('button:has-text("Test"), a:has-text("Test")').first()).toBeVisible();
});
test('Edit dialog pre-populates the seeded rule', async ({ page }) => {
await navigateToRules(page);
await openEditDialog(page);
const desc = page.locator('#wizard-form input[name="transaction-rule/description"]').first();
await expect(desc).toHaveValue('ZZRULEMATCH');
});
test('advancing to the test step renders the matching-transactions preview', async ({ page }) => {
const info = await getTestInfo(page);
await navigateToRules(page);
await openNewDialog(page);
await fillDescription(page, 'ZZRULEMATCH');
await addAccount(page, info.accounts['test-account'].toString());
await selectApproved(page);
await clickTest(page);
// the wizard advances to the test/preview step (the test-table query + render is
// reused unchanged by the migration; the seed has no recent match, so the count is 0)
const modal = page.locator('#wizard-form');
await expect(modal).toContainText('Matching transactions');
});
test('Saving from the test step creates the rule and closes the modal', async ({ page }) => {
const info = await getTestInfo(page);
await navigateToRules(page);
const before = await page.locator('#entity-table tbody tr').count();
await openNewDialog(page);
await fillDescription(page, 'ZZRULEMATCH');
await addAccount(page, info.accounts['test-account'].toString());
await selectApproved(page);
await clickTest(page);
// Save from the test step (the precise Save button, not Back which is also submit)
await page.locator('#wizard-form button:has-text("Save")').first().click();
await page.waitForTimeout(1000);
// modal closed + a new rule row added
await expect(page.locator('#wizard-form')).toBeHidden();
expect(await page.locator('#entity-table tbody tr').count()).toBe(before + 1);
});
});

103
e2e/vendor-wizard.spec.ts Normal file
View File

@@ -0,0 +1,103 @@
import { test, expect } from '@playwright/test';
// Acceptance spec for the New/Edit Vendor wizard (info → terms → account → address → legal),
// migrated onto the session-backed engine (wizard2). Like New Invoice, the pre-migration
// "Next" PUT /admin/vendor/navigat 500d on the empty route-params {}→nil quirk (a
// [:map [:db/id …]] route-schema on a route with no path param), so this is an ACCEPTANCE
// gate: green on the engine. Required fields: vendor/name (min 3) on info, vendor/default-account
// on account.
test.beforeEach(async ({ request }) => { await request.post('/test-reset'); });
async function seedAccount(page: any): Promise<number> {
const info = await (await page.request.get('/test-info')).json();
return info.accounts['test-account'];
}
async function openNewVendor(page: any) {
await page.goto('/admin/vendor');
await page.waitForSelector('#entity-table');
await page.locator('button:has-text("New Vendor")').first().click();
await page.waitForSelector('#wizard-form');
await page.waitForTimeout(400);
}
// The advance/save button is the engine's data-primary nav button.
const primary = (page: any) => page.locator('#wizard-form button[data-primary]').first().click();
async function setHidden(page: any, name: string, value: number) {
await page.evaluate(({ name, value }: { name: string; value: number }) => {
const h = document.querySelector(`input[name="${name}"]`) as HTMLInputElement;
h.value = String(value);
h.dispatchEvent(new Event('change', { bubbles: true }));
}, { name, value });
}
test.describe.configure({ mode: 'serial' });
test.describe('Vendor wizard (acceptance)', () => {
test('info step renders with the name field and a timeline', async ({ page }) => {
await openNewVendor(page);
const form = page.locator('#wizard-form');
await expect(form).toContainText('Basic Info');
await expect(form).toContainText('Terms'); // timeline step
await expect(form.locator('input[name="vendor/name"]')).toBeVisible();
});
test('create a vendor across all 5 steps adds it to the grid', async ({ page }) => {
const account = await seedAccount(page);
await openNewVendor(page);
// info
await page.locator('input[name="vendor/name"]').fill('Acme Supplies');
await primary(page); // -> terms
await page.waitForTimeout(500);
await expect(page.locator('#wizard-form')).toContainText('Terms Overrides');
await primary(page); // -> account
await page.waitForTimeout(500);
await expect(page.locator('#wizard-form')).toContainText('Default Account');
await setHidden(page, 'vendor/default-account', account);
await primary(page); // -> address
await page.waitForTimeout(500);
await expect(page.locator('#wizard-form')).toContainText('Street');
await primary(page); // -> legal
await page.waitForTimeout(500);
await expect(page.locator('#wizard-form')).toContainText('Legal Entity');
await primary(page); // Save
await page.waitForTimeout(1200);
// the vendor persists: reload the grid and it's there
await page.goto('/admin/vendor');
await page.waitForSelector('#entity-table tbody tr');
await expect(page.locator('#entity-table')).toContainText('Acme Supplies');
});
test('edit opens prefilled and a rename persists', async ({ page }) => {
const account = await seedAccount(page);
await page.goto('/admin/vendor');
await page.waitForSelector('#entity-table tbody tr');
// open the edit wizard for the seeded "Test Vendor" (its row pencil)
await page.locator('#entity-table tbody tr', { hasText: 'Test Vendor' }).first()
.locator('[hx-get*="/edit"]').first().click();
await page.waitForSelector('#wizard-form');
await page.waitForTimeout(400);
// info step is prefilled with the existing name
await expect(page.locator('input[name="vendor/name"]')).toHaveValue('Test Vendor');
await page.locator('input[name="vendor/name"]').fill('Test Vendor RENAMED');
await primary(page); await page.waitForTimeout(400); // terms
await primary(page); await page.waitForTimeout(400); // account (default-account already set)
await setHidden(page, 'vendor/default-account', account);
await primary(page); await page.waitForTimeout(400); // address
await primary(page); await page.waitForTimeout(400); // legal
await primary(page); await page.waitForTimeout(1000); // save
await page.goto('/admin/vendor');
await page.waitForSelector('#entity-table tbody tr');
await expect(page.locator('#entity-table')).toContainText('Test Vendor RENAMED');
});
test('info step blocks advancing when the name is too short', async ({ page }) => {
await openNewVendor(page);
await page.locator('input[name="vendor/name"]').fill('ab'); // < 3 chars
await primary(page);
await page.waitForTimeout(500);
// still on the info step (validation re-renders it, no advance)
await expect(page.locator('#wizard-form')).toContainText('Basic Info');
});
});

View File

@@ -1,17 +1,28 @@
import { defineConfig, devices } from '@playwright/test';
// Allow pointing the suite at an already-running test server (e.g. one booted from a
// specific worktree on a non-default port) via BASE_URL. When BASE_URL is set we skip
// the auto-started webServer entirely, so parallel worktrees don't fight over :3333.
const baseURL = process.env.BASE_URL ?? 'http://localhost:3333';
const useExternalServer = !!process.env.BASE_URL;
export default defineConfig({
testDir: './e2e',
fullyParallel: true,
// These tests share a single stateful test server with one fixed dataset and
// mutate the same transactions (coding, bulk coding, etc.), so they must run
// serially. Running them in parallel causes cross-test races and flakes.
fullyParallel: false,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
workers: 1,
reporter: 'html',
use: {
baseURL: 'http://localhost:3333',
baseURL,
trace: 'on-first-retry',
},
webServer: {
webServer: useExternalServer
? undefined
: {
command: 'lein run -m auto-ap.test-server',
url: 'http://localhost:3333/test-info',
reuseExistingServer: !process.env.CI,

View File

@@ -96,6 +96,7 @@
[org.clojure/core.async]]
[hiccup "2.0.0-alpha2"]
[selmer "1.12.61"]
;; needed for java 11
[javax.xml.bind/jaxb-api "2.4.0-b180830.0359"]

View File

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

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,4 @@
{# Generic <select>. options = [{value, label, selected}]. Plain-HTML attributes -- the
Selmer migration target (no Hiccup keyword/string attribute ambiguity). Extra attrs
(hx-*, x-*) ride through {{ attrs|safe }}. #}
<select name="{{ name }}" class="{{ classes }}"{{ attrs|safe }}>{% for opt in options %}<option value="{{ opt.value }}"{% if opt.selected %} selected{% endif %}>{{ opt.label }}</option>{% endfor %}</select>

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,4 @@
{# Top-level plain bulk-edit form (no wizard). The resolved (not-locked) invoice id set
rides in hidden ids[] fields so the selection survives form-changed / submit posts
without an EDN snapshot or a filter round-trip. #}
<form id="bulk-edit-form"{{ form_attrs|safe }}>{{ ids_hidden|safe }}{{ modal|safe }}</form>

View File

@@ -0,0 +1,5 @@
{# Running TOTAL / BALANCE percentage rows in their own swappable <tbody>, a sibling of
the input rows, so a percentage edit refreshes them with a targeted swap (Rule 4) and
never replaces the input-bearing rows above. Replaces the old per-cell bulk-edit-total
/ bulk-edit-balance routes. #}
<tbody id="expense-totals">{{ rows|safe }}</tbody>

View File

@@ -0,0 +1,4 @@
{# Plain sales-summary edit form (no wizard). db/id rides in a hidden field; all other
state is the live form, re-derived against the entity each request (no EDN snapshot,
no step-params). #}
<form id="summary-edit-form"{{ form_attrs|safe }}><input type="hidden" name="db/id" value="{{ db_id }}">{{ modal|safe }}</form>

View File

@@ -0,0 +1,4 @@
{# Sales-summary modal body: a read-only Debits | Credits two-column view of the auto
items (each account is inline-editable), a swappable totals/balance block, and an
editable Manual Items section with a working "New Summary Item" add. #}
<div class="space-y-4 p-2"><div class="grid grid-cols-2 gap-6"><div><div class="font-semibold text-sm mb-2">Debits</div><div class="space-y-1">{{ debit_rows|safe }}</div></div><div><div class="font-semibold text-sm mb-2">Credits</div><div class="space-y-1">{{ credit_rows|safe }}</div></div></div><div id="summary-totals">{{ totals|safe }}</div><div class="mt-4 border-t pt-3"><div class="font-semibold text-sm mb-2">Manual Items</div><div class="space-y-2" id="manual-items">{{ manual_rows|safe }}</div><div class="mt-2 flex justify-center">{{ new_item_button|safe }}</div></div></div>

View File

@@ -0,0 +1,3 @@
{# Bulk-code modal body: vendor field (a change repopulates the default account via a
whole-form swap), status select, and the expense-account grid. #}
<div class="space-y-4 p-4"><div class="grid grid-cols-2 gap-4"><div{{ vendor_changed_attrs|safe }}>{{ vendor_field|safe }}</div><div>{{ status_field|safe }}</div><div class="col-span-2 pt-4"><h3 class="text-lg font-medium mb-3">Expense Accounts</h3>{{ accounts_field|safe }}</div></div></div>

View File

@@ -0,0 +1,5 @@
{# Top-level plain form for bulk-code (no wizard). The resolved (not-locked) transaction
id set rides in hidden ids[] fields -- the analog of the edit modal's single db/id
hidden -- so the selection survives form-changed / submit posts without an EDN snapshot
or a filter round-trip. #}
<form id="bulk-code-form"{{ form_attrs|safe }}>{{ ids_hidden|safe }}{{ modal|safe }}</form>

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>

File diff suppressed because it is too large Load Diff

View File

@@ -14,21 +14,21 @@
[auto-ap.rule-matching :as rm]
[auto-ap.solr :as solr]
[auto-ap.ssr-routes :as ssr-routes]
[auto-ap.ssr.common-handlers :refer [add-new-entity-handler]]
[auto-ap.ssr.company :refer [bank-account-typeahead*]]
[auto-ap.ssr.components :as com]
[auto-ap.ssr.components.multi-modal :as mm]
[auto-ap.ssr.form-cursor :as fc]
[auto-ap.ssr.components.wizard2 :as wizard2]
[auto-ap.ssr.grid-page-helper :as helper :refer [wrap-apply-sort]]
[auto-ap.ssr.hx :as hx]
[auto-ap.ssr.nested-form-params :as nfp]
[auto-ap.ssr.svg :as svg]
[auto-ap.ssr.utils
:refer [apply-middleware-to-all-handlers
:refer [->db-id apply-middleware-to-all-handlers
default-grid-fields-schema entity-id
field-validation-error form-validation-error
html-response many-entity modal-response money percentage
ref->enum-schema ref->radio-options regex temp-id
wrap-entity wrap-merge-prior-hx wrap-schema-enforce]]
html-response main-transformer many-entity modal-response money
path->name2 percentage ref->enum-schema ref->radio-options regex
temp-id wrap-entity wrap-form-4xx-2 wrap-merge-prior-hx
wrap-schema-enforce]]
[auto-ap.time :as atime]
[auto-ap.utils :refer [dollars=]]
[bidi.bidi :as bidi]
@@ -37,7 +37,23 @@
[clojure.string :as str]
[datomic.api :as dc]
[malli.core :as mc]
[malli.util :as mut]))
[malli.error :as me]))
;; ---------------------------------------------------------------------------
;; Field-name / error helpers for the (de-cursored) rule form. No step-params
;; prefix -- posted fields decode straight into form-schema. Mirrors edit.clj.
;; ---------------------------------------------------------------------------
(def ^:dynamic *errors*
"Humanized form errors for the current rule render, keyed by form-schema paths.
Bound by render-edit-step from the engine ctx :errors."
{})
(defn- fname [& path] (apply path->name2 path))
(defn- ferr [& path] (get-in *errors* (vec path)))
(defn- err? [& path] (boolean (seq (apply ferr path))))
(defn- account-field-name [index field] (path->name2 :transaction-rule/accounts index field))
(defn- account-field-errors [index field] (ferr :transaction-rule/accounts index field))
(def query-schema (mc/schema
[:maybe
@@ -437,67 +453,63 @@
client-id))))))})])
(defn- transaction-rule-account-row*
[account client-id client-locations]
"One account-coding row, from a plain account map + its index (no cursor). The Alpine
cross-field dispatch wiring (clientId -> accountId -> location) is preserved verbatim;
only the field names/values move from the form cursor to explicit data + path->name2."
[account index client-id client-locations]
(let [acct (:transaction-rule-account/account account)
acct-id (if (map? acct) (:db/id acct) acct)
aname (account-field-name index :transaction-rule-account/account)
lname (account-field-name index :transaction-rule-account/location)]
(com/data-grid-row
(-> {:x-data (hx/json {:accountId (or (:db/id (fc/field-value (:transaction-rule-account/account account)))
(fc/field-value (:transaction-rule-account/account account)))
:location (fc/field-value (:transaction-rule-account/location account))
:show (boolean (not (fc/field-value (:new? account))))})
(-> {:x-data (hx/json {:accountId acct-id
:location (:transaction-rule-account/location account)
:show (boolean (not (:new? account)))})
:data-key "show"
:x-ref "p"}
hx/alpine-mount-then-appear)
(let [account-name (fc/field-name (:transaction-rule-account/account account))]
(list
(fc/with-field :db/id
(com/hidden {:name (fc/field-name)
:value (fc/field-value)}))
(fc/with-field :transaction-rule-account/account
(com/hidden {:name (account-field-name index :db/id)
:value (:db/id account)})
(com/data-grid-cell
{}
(com/validated-field
{:errors (fc/field-errors)}
{:errors (account-field-errors index :transaction-rule-account/account)}
[:div {:hx-trigger "changed"
:hx-target "next div"
:hx-vals (format "js:{name: '%s', 'client-id': event.detail.clientId || '', value: event.detail.accountId || ''}" account-name)
:hx-vals (format "js:{name: '%s', 'client-id': event.detail.clientId || '', value: event.detail.accountId || ''}" aname)
:hx-get (str (bidi/path-for ssr-routes/only-routes ::route/account-typeahead))
:x-init "$watch('clientId', cid => $dispatch('changed', $data));"}]
(account-typeahead* {:value (fc/field-value)
(account-typeahead* {:value acct-id
:client-id client-id
:name (fc/field-name)
:x-model "accountId"}))))
(fc/with-field :transaction-rule-account/location
:name aname
:x-model "accountId"})))
(com/data-grid-cell
{}
(com/validated-field
{:errors (fc/field-errors)
:x-data (hx/json {:location (fc/field-value)})}
;; TODO make this thing into a component
{:errors (account-field-errors index :transaction-rule-account/location)
:x-data (hx/json {:location (:transaction-rule-account/location account)})}
[:div {:hx-trigger "changed"
:hx-target "next *"
:hx-swap "outerHTML"
:hx-vals (format "js:{name: '%s', 'client-id': event.detail.clientId || '', 'account-id': event.detail.accountId || '', value: event.detail.location || ''}" (fc/field-name))
:hx-vals (format "js:{name: '%s', 'client-id': event.detail.clientId || '', 'account-id': event.detail.accountId || '', value: event.detail.location || ''}" lname)
:hx-get (bidi/path-for ssr-routes/only-routes ::route/location-select)
:x-init "$watch('clientId', cid => $dispatch('changed', $data)); $watch('accountId', cid => $dispatch('changed', $data) )"}]
(location-select* {:name (fc/field-name)
:account-location (:account/location (cond->> (:transaction-rule-account/account @account)
(nat-int? (:transaction-rule-account/account @account)) (dc/pull (dc/db conn)
'[:account/location])))
(location-select* {:name lname
:account-location (:account/location (when (nat-int? acct-id)
(dc/pull (dc/db conn) '[:account/location] acct-id)))
:client-locations client-locations
:x-model "location"
:value (fc/field-value)}))))
(fc/with-field :transaction-rule-account/percentage
:value (:transaction-rule-account/location account)})))
(com/data-grid-cell
{}
(com/validated-field
{:errors (fc/field-errors)}
(com/money-input {:name (fc/field-name)
{:errors (account-field-errors index :transaction-rule-account/percentage)}
(com/money-input {:name (account-field-name index :transaction-rule-account/percentage)
:class "w-16"
:value (some-> (fc/field-value)
:value (some-> (:transaction-rule-account/percentage account)
(* 100)
(long))}))))))
(long))})))
(com/data-grid-cell {:class "align-top"}
(com/a-icon-button {"@click.prevent.stop" "show=false; setTimeout(() => $refs.p.remove(), 500)"} svg/x))))
(com/a-icon-button {"@click.prevent.stop" "show=false; setTimeout(() => $refs.p.remove(), 500)"} svg/x)))))
(defn all-ids-not-locked [all-ids]
(->> all-ids
@@ -638,274 +650,212 @@
(html-response (row* (:identity request) entity {:delete-after-settle? true :class "live-removed"})
:headers {"hx-retarget" (format "#entity-table tr[data-id=\"%d\"]" (:db/id entity))}))
(defrecord EditModal [linear-wizard]
mm/ModalWizardStep
(step-name [_]
"Edit")
;; ---------------------------------------------------------------------------
;; The rule wizard on the data-driven session engine (wizard2 / wizard-state),
;; replacing the EditModal/TestModal/TransactionRuleWizard records +
;; MultiStepFormState + the EDN-snapshot round-trip.
;; ---------------------------------------------------------------------------
(step-key [_]
:edit)
(defn- rule-modal-card [& {:keys [head body footer]}]
(com/modal-card-advanced
{}
(com/modal-header {} head)
(com/modal-body {} body)
(com/modal-footer {} footer)))
(edit-path [_ _] [])
(step-schema [_]
(mm/form-schema linear-wizard))
(render-step [this request]
(mm/default-render-step
linear-wizard this
(defn render-edit-step
"Edit step: the rule form, de-cursored (explicit data + path->name2 + *errors*)."
[{:keys [step-data errors]}]
(binding [*errors* (or errors {})]
(let [rule (or step-data {})
rule-client (:transaction-rule/client rule)
client-id (if (map? rule-client) (:db/id rule-client) rule-client)
client-locations (some->> client-id (pull-attr (dc/db conn) :client/locations))
accounts (vec (:transaction-rule/accounts rule))]
(rule-modal-card
:head "Transaction rule"
:body (mm/default-step-body {}
[:form#my-form {:hx-ext "response-targets"
:hx-target-400 "#form-errors .error-content"
:hx-indicator "#submit"
:x-trap "true"
(if (:db/id (fc/field-value))
:hx-put
:hx-post) (str (bidi/path-for ssr-routes/only-routes ::route/save))}
:body [:div#my-form {:x-trap "true"}
[:fieldset {:class "hx-disable"
:x-data (hx/json {:clientId (or (:db/id (:transaction-rule/client (fc/field-value)))
(:transaction-rule/client (fc/field-value)))})}
:x-data (hx/json {:clientId client-id})}
[:div.space-y-1
(when-let [id (:db/id (fc/field-value))]
(com/hidden {:name "db/id"
:value id}))
(fc/with-field :transaction-rule/description
(com/validated-field {:label "Description"
:errors (fc/field-errors)}
(com/text-input {:name (fc/field-name)
:error? (fc/error?)
(when-let [id (:db/id rule)]
(com/hidden {:name "db/id" :value id}))
(com/validated-field {:label "Description" :errors (ferr :transaction-rule/description)}
(com/text-input {:name (fname :transaction-rule/description)
:error? (err? :transaction-rule/description)
:x-init "$el.focus()"
:placeholder "HOME DEPOT"
:class "w-96"
:value (fc/field-value)})))
[:div.filters {:x-data (hx/json {:clientFilter (boolean (fc/field-value (:transaction-rule/client fc/*current*)))
:clientGroupFilter (boolean (fc/field-value (:transaction-rule/client-group fc/*current*)))
:bankAccountFilter (boolean (fc/field-value (:transaction-rule/bank-account fc/*current*)))
:amountFilter (boolean (or (fc/field-value (:transaction-rule/amount-gte fc/*current*))
(fc/field-value (:transaction-rule/amount-lte fc/*current*))))
:domFilter (boolean (or (fc/field-value (:transaction-rule/dom-gte fc/*current*))
(fc/field-value (:transaction-rule/dom-lte fc/*current*))))})}
:value (:transaction-rule/description rule)}))
[:div.filters {:x-data (hx/json {:clientFilter (boolean (:transaction-rule/client rule))
:clientGroupFilter (boolean (:transaction-rule/client-group rule))
:bankAccountFilter (boolean (:transaction-rule/bank-account rule))
:amountFilter (boolean (or (:transaction-rule/amount-gte rule) (:transaction-rule/amount-lte rule)))
:domFilter (boolean (or (:transaction-rule/dom-gte rule) (:transaction-rule/dom-lte rule)))})}
[:div.flex.gap-2.mb-2
(com/a-button {"@click" "clientFilter=true"
"x-show" "!clientFilter"} "Filter client")
(com/a-button {"@click" "clientGroupFilter=true"
"x-show" "!clientGroupFilter"} "Filter client group")
(com/a-button {"@click" "bankAccountFilter=true"
"x-show" "clientFilter && !bankAccountFilter"} "Filter bank account")
(com/a-button {"@click" "amountFilter=true"
"x-show" "!amountFilter"} "Filter amount")
(com/a-button {"@click" "domFilter=true"
"x-show" "!domFilter"} "Filter day of month")]
(fc/with-field :transaction-rule/client
(com/a-button {"@click" "clientFilter=true" "x-show" "!clientFilter"} "Filter client")
(com/a-button {"@click" "clientGroupFilter=true" "x-show" "!clientGroupFilter"} "Filter client group")
(com/a-button {"@click" "bankAccountFilter=true" "x-show" "clientFilter && !bankAccountFilter"} "Filter bank account")
(com/a-button {"@click" "amountFilter=true" "x-show" "!amountFilter"} "Filter amount")
(com/a-button {"@click" "domFilter=true" "x-show" "!domFilter"} "Filter day of month")]
(com/validated-field
(-> {:label "Client"
:errors (fc/field-errors)
:x-show "clientFilter"}
(hx/alpine-appear))
(-> {:label "Client" :errors (ferr :transaction-rule/client) :x-show "clientFilter"} (hx/alpine-appear))
[:div.w-96
(com/typeahead {:name (fc/field-name)
:error? (fc/error?)
:class "w-96"
:placeholder "Search..."
(com/typeahead {:name (fname :transaction-rule/client)
:error? (err? :transaction-rule/client)
:class "w-96" :placeholder "Search..."
:url (bidi/path-for ssr-routes/only-routes :company-search)
:x-model "clientId"
:value (fc/field-value)
:content-fn (fn [c] (pull-attr (dc/db conn) :client/name c))})]))
(fc/with-field :transaction-rule/client-group
:value rule-client
:content-fn (fn [c] (pull-attr (dc/db conn) :client/name c))})])
(com/validated-field
(-> {:label "Client Group"
:errors (fc/field-errors)
:x-show "clientGroupFilter"}
(hx/alpine-appear))
(-> {:label "Client Group" :errors (ferr :transaction-rule/client-group) :x-show "clientGroupFilter"} (hx/alpine-appear))
[:div.w-96
(com/text-input {:name (fc/field-name)
:error? (fc/error?)
:class "w-24"
:placeholder "NTG"
:value (fc/field-value)})]))
(fc/with-field :transaction-rule/bank-account
(com/text-input {:name (fname :transaction-rule/client-group)
:error? (err? :transaction-rule/client-group)
:class "w-24" :placeholder "NTG"
:value (:transaction-rule/client-group rule)})])
(com/validated-field
(-> {:label "Bank Account"
:errors (fc/field-errors)
:x-show "bankAccountFilter"}
hx/alpine-appear)
(-> {:label "Bank Account" :errors (ferr :transaction-rule/bank-account) :x-show "bankAccountFilter"} hx/alpine-appear)
[:div.w-96
[:div#bank-account-changer {:hx-get (bidi/path-for ssr-routes/only-routes :bank-account-typeahead)
:hx-trigger "changed"
:hx-target "next *"
:hx-include "#bank-account-changer"
:hx-swap "innerHTML"
:hx-vals (format "js:{name: '%s', 'client-id': event.detail.clientId}" (fc/field-name))
:hx-swap "outerHTML"
:hx-vals (format "js:{name: '%s', 'client-id': event.detail.clientId}" (fname :transaction-rule/bank-account))
:x-init "$watch('clientId', cid => $dispatch('changed', $data))"}]
(bank-account-typeahead* {:client-id (:transaction-rule/client (fc/field-value))
:name (fc/field-name)
:value (fc/field-value)})]))
(com/field (-> {:label "Amount"
:x-show "amountFilter"}
hx/alpine-appear)
(bank-account-typeahead* {:client-id client-id
:name (fname :transaction-rule/bank-account)
:value (:transaction-rule/bank-account rule)})])
(com/field (-> {:label "Amount" :x-show "amountFilter"} hx/alpine-appear)
[:div.flex.gap-2
(fc/with-field :transaction-rule/amount-gte
[:div.flex.flex-col
(com/money-input {:name (fc/field-name)
:placeholder ">="
:class "w-24"
:value (fc/field-value)})
(com/errors {:errors (fc/field-errors)})])
(fc/with-field :transaction-rule/amount-lte
(com/money-input {:name (fname :transaction-rule/amount-gte) :placeholder ">=" :class "w-24" :value (:transaction-rule/amount-gte rule)})
(com/errors {:errors (ferr :transaction-rule/amount-gte)})]
[:div.flex.flex-col
(com/money-input {:name (fc/field-name)
:placeholder "<="
:class "w-24"
:value (fc/field-value)})
(com/errors {:errors (fc/field-errors)})])])
(com/field (-> {:label "Day of month"
:x-show "domFilter"}
hx/alpine-appear)
(com/money-input {:name (fname :transaction-rule/amount-lte) :placeholder "<=" :class "w-24" :value (:transaction-rule/amount-lte rule)})
(com/errors {:errors (ferr :transaction-rule/amount-lte)})]])
(com/field (-> {:label "Day of month" :x-show "domFilter"} hx/alpine-appear)
[:div.flex.gap-2
(fc/with-field :transaction-rule/dom-gte
(com/validated-field
{:errors (fc/field-errors)}
(com/int-input {:name (fc/field-name)
:placeholder ">="
:class "w-24"
:value (fc/field-value)})))
(fc/with-field :transaction-rule/dom-lte
(com/validated-field
{:errors (fc/field-errors)}
(com/int-input {:name (fc/field-name)
:placeholder ">="
:class "w-24"
:value (fc/field-value)})))])]
(com/validated-field {:errors (ferr :transaction-rule/dom-gte)}
(com/int-input {:name (fname :transaction-rule/dom-gte) :placeholder ">=" :class "w-24" :value (:transaction-rule/dom-gte rule)}))
(com/validated-field {:errors (ferr :transaction-rule/dom-lte)}
(com/int-input {:name (fname :transaction-rule/dom-lte) :placeholder ">=" :class "w-24" :value (:transaction-rule/dom-lte rule)}))])]
[:h2.text-lg "Outcomes"]
(fc/with-field :transaction-rule/vendor
(com/validated-field {:label "Assign Vendor"
:errors (fc/field-errors)}
(com/validated-field {:label "Assign Vendor" :errors (ferr :transaction-rule/vendor)}
[:div.w-96
(com/typeahead {:name (fc/field-name)
(com/typeahead {:name (fname :transaction-rule/vendor)
:placeholder "Search..."
:url (bidi/path-for ssr-routes/only-routes :vendor-search)
:class "w-96"
:value (fc/field-value)
:content-fn #(pull-attr (dc/db conn) :vendor/name %)})]))
(fc/with-field :transaction-rule/accounts
:value (:transaction-rule/vendor rule)
:content-fn #(pull-attr (dc/db conn) :vendor/name %)})])
(com/validated-field
{:errors (fc/field-errors)}
(let [client-locations (some->> (fc/field-value) :transaction-rule/client (pull-attr (dc/db conn) :client/locations))]
{:errors (ferr :transaction-rule/accounts)}
(com/data-grid {:headers [(com/data-grid-header {} "Account")
(com/data-grid-header {:class "w-32"} "Location")
(com/data-grid-header {:class "w-16"} "%")
(com/data-grid-header {:class "w-16"})]}
(fc/cursor-map #(transaction-rule-account-row* % (:transaction-rule/client (fc/field-value)) client-locations))
(map-indexed (fn [i a] (transaction-rule-account-row* a i client-id client-locations)) accounts)
(com/data-grid-new-row {:colspan 4
:hx-get (bidi/path-for ssr-routes/only-routes
::route/new-account)
:index (count (fc/field-value))
:hx-get (bidi/path-for ssr-routes/only-routes ::route/new-account)
:index (count accounts)
:tr-params (hx/bind-alpine-vals {} {"client-id" "clientId"})}
"New account")))))
(fc/with-field :transaction-rule/transaction-approval-status
(com/validated-field {:label "Approval status"
:errors (fc/field-errors)}
"New account")))
(com/validated-field {:label "Approval status" :errors (ferr :transaction-rule/transaction-approval-status)}
(com/radio-card {:options (ref->radio-options "transaction-approval-status")
:value (fc/field-value)
:name (fc/field-name)
:value (:transaction-rule/transaction-approval-status rule)
:name (fname :transaction-rule/transaction-approval-status)
:size :small
:orientation :horizontal})))]]])
:footer
(mm/default-step-footer linear-wizard this :validation-route ::route/navigate)
:validation-route ::route/navigate)))
:orientation :horizontal}))]]]
:footer (wizard2/nav-footer {:next "Test"})))))
(defrecord TestModal [linear-wizard]
mm/ModalWizardStep
(step-name [_]
"Test")
(step-key [_]
:test)
(edit-path [_ _] [])
(step-schema [_]
(mut/select-keys (mm/form-schema linear-wizard) #{}))
(render-step [this request]
(mm/default-render-step
linear-wizard this
(defn render-test-step
"Test step: a read-only preview of the transactions the rule (the combined session
data) matches. The query/render is reused unchanged."
[{:keys [all-data request]}]
(rule-modal-card
:head [:div.p-2.flex.space-x-4 [:div "Transaction Rule"] [:div ">"] [:div "Results"]]
:body [:div.space-y-1 {:class "w-[850px] h-[600px]"}
(transaction-rule-test-table* {:entity (:snapshot (:multi-form-state request))
:clients (:clients request)})]
:footer
(mm/default-step-footer linear-wizard this :validation-route ::route/navigate)
:validation-route ::route/navigate)))
(transaction-rule-test-table* {:entity all-data :clients (:clients request)})]
:footer (wizard2/nav-footer {:back? true :save? true})))
(defrecord TransactionRuleWizard [transaction-rule current-step entity]
mm/LinearModalWizard
(hydrate-from-request
[this request]
this
#_(assoc this :entity (:entity request)))
(navigate [this step-key]
(assoc this :current-step step-key))
(get-current-step [this]
(if current-step
(mm/get-step this current-step)
(mm/get-step this :edit)))
(render-wizard [this {:keys [multi-form-state] :as request}]
(mm/default-render-wizard
this request
:form-params
(-> mm/default-form-props
(assoc (if (get-in multi-form-state [:snapshot :db/id])
:hx-put
:hx-post)
(str (bidi/path-for ssr-routes/only-routes ::route/save))))))
(steps [_]
[:edit
:test])
(defn- decode-rule-form
"Parse the posted edit-step fields straight into the rule map (no step-params prefix).
The engine has already stripped its own nav fields (wizard-id / current-step /
direction), so they can't leak into the decoded rule."
[request]
(let [nested (:form-params (nfp/nested-params-request request {}))]
(mc/decode form-schema nested main-transformer)))
(get-step [this step-key]
(let [step-key-result (mc/parse mm/step-key-schema step-key)
[step-key-type step-key] step-key-result]
(if (= :step step-key-type)
(get {:edit (->EditModal this)
:test (->TestModal this)}
step-key)
(defn- rule-form-errors
"Per-step validation: schema-validate so an invalid form can't advance to the test step
(matches the old navigate-validates behavior). Returns a humanized errors map or nil.
The full custom checks (percentage sum, location, bank-account) run at save."
[rule _request]
(when-not (mc/validate form-schema rule)
(me/humanize (mc/explain form-schema rule))))
nil)))
(form-schema [_] form-schema)
(submit [_ {:keys [multi-form-state request-method identity] :as request}]
(let [transaction-rule (:snapshot multi-form-state)
_ (validate-transaction-rule transaction-rule)
entity (cond-> transaction-rule
(:transaction-rule/client-group transaction-rule) (update :transaction-rule/client-group str/upper-case)
(= :post request-method) (assoc :db/id "new")
true (assoc :transaction-rule/note (entity->note transaction-rule)))
{:keys [tempids]} (audit-transact [[:upsert-entity entity]]
(:identity request))
updated-rule (dc/pull (dc/db conn)
default-read
(or (get tempids (:db/id entity)) (:db/id entity)))]
(defn save-rule!
"Engine done-fn: validate + upsert the rule, then return the grid row + modalclose."
[all-data request]
(validate-transaction-rule all-data)
(let [editing? (some? (:db/id all-data))
entity (cond-> all-data
(:transaction-rule/client-group all-data) (update :transaction-rule/client-group str/upper-case)
(not editing?) (assoc :db/id "new")
true (assoc :transaction-rule/note (entity->note all-data)))
{:keys [tempids]} (audit-transact [[:upsert-entity entity]] (:identity request))
saved (dc/pull (dc/db conn) default-read (or (get tempids (:db/id entity)) (:db/id entity)))]
(html-response
(row* identity updated-rule {:flash? true})
(row* (:identity request) saved {:flash? true})
:headers (cond-> {"hx-trigger" "modalclose"}
(= :post request-method) (assoc "hx-retarget" "#entity-table tbody"
"hx-reswap" "afterbegin")
(= :put request-method) (assoc "hx-retarget" (format "#entity-table tr[data-id=\"%d\"]" (:db/id updated-rule))
"hx-reswap" "outerHTML"))))))
(def rule-wizard (->TransactionRuleWizard nil nil nil))
(not editing?) (assoc "hx-retarget" "#entity-table tbody" "hx-reswap" "afterbegin")
editing? (assoc "hx-retarget" (format "#entity-table tr[data-id=\"%d\"]" (:db/id saved)) "hx-reswap" "outerHTML")))))
(def transaction-rule-wizard-config
{:name :transaction-rule
:form-id "wizard-form"
:submit-route (bidi/path-for ssr-routes/only-routes ::route/save)
:form-attrs {:hx-ext "response-targets"
:hx-target-400 "#form-errors"}
:init-fn (fn [request]
{:context {}
:init-data (when-let [e (:entity request)] {:edit e})})
;; The engine owns the modal wrap: open-wizard applies this to the rendered form, so the
;; new/edit routes are just (partial wizard2/open-wizard config) -- no hand-rolled
;; create!/render/wrap/thread boilerplate.
:open-response (fn [form]
(modal-response [:div#transitioner.flex-1 form]))
:steps [{:key :edit
:decode decode-rule-form
:validate rule-form-errors
:render render-edit-step
:next (fn [_] :test)}
{:key :test
:decode (fn [_] {})
:render render-test-step
:next (fn [_] :done)}]
:done-fn save-rule!})
(defn save-step
"POST handler for every step transition (next / back / save) -- the engine reads the
`direction` field and either advances, goes back, or finishes via done-fn."
[request]
(wizard2/handle-step-submit transaction-rule-wizard-config request))
(defn- new-account
"Render one fresh (de-cursored) account row at the posted index (the data grid's
newRowIndex Alpine counter increments it for repeated adds)."
[request]
(let [idx (-> request :query-params :index)
idx (if (string? idx) (Integer/parseInt idx) idx)
client-id (-> request :query-params :client-id)
client-locations (some->> client-id (pull-attr (dc/db conn) :client/locations))]
(html-response
(transaction-rule-account-row* (wizard2/blank-row :transaction-rule-account/location "Shared")
idx client-id client-locations))))
(def key->handler
(apply-middleware-to-all-handlers
@@ -916,16 +866,9 @@
(wrap-entity [:route-params :db/id] default-read)
(wrap-schema-enforce :route-params [:map [:db/id entity-id]]))
::route/new-account
(->
(add-new-entity-handler [:step-params :transaction-rule/accounts]
(fn render [cursor request]
(transaction-rule-account-row*
cursor
(:client-id (:query-params request))
(some->> (:client-id (:query-params request)) (pull-attr (dc/db conn) :client/locations))))
(fn build-new-row [base _]
(assoc base :transaction-rule-account/location "Shared")))
(-> new-account
(wrap-schema-enforce :query-schema [:map
[:index {:optional true} [:maybe nat-int?]]
[:client-id {:optional true}
[:maybe entity-id]]]))
@@ -943,10 +886,7 @@
[:maybe entity-id]]
[:value {:optional true}
[:maybe entity-id]]]))
::route/save (-> mm/submit-handler
(mm/wrap-wizard rule-wizard)
(mm/wrap-decode-multi-form-state)
(wrap-entity [:form-params :db/id] default-read))
::route/save save-step
::route/execute (-> execute
(wrap-entity [:route-params :db/id] default-read)
@@ -976,24 +916,11 @@
(wrap-entity [:route-params :db/id] default-read)
(wrap-schema-enforce :route-schema [:map [:db/id entity-id]]))
::route/navigate (-> mm/next-handler
(mm/wrap-wizard rule-wizard)
(mm/wrap-decode-multi-form-state))
::route/edit-dialog (-> mm/open-wizard-handler
(mm/wrap-wizard rule-wizard)
(mm/wrap-init-multi-form-state (fn [request]
(mm/->MultiStepFormState (:entity request)
[]
(:entity request))))
::route/edit-dialog (-> (partial wizard2/open-wizard transaction-rule-wizard-config)
(wrap-entity [:route-params :db/id] default-read)
(wrap-schema-enforce :route-schema [:map [:db/id entity-id]]))
::route/new-dialog (-> mm/open-wizard-handler
(mm/wrap-wizard rule-wizard)
(mm/wrap-init-multi-form-state (fn [_]
(mm/->MultiStepFormState {}
[]
{}))))})
::route/new-dialog (partial wizard2/open-wizard transaction-rule-wizard-config)})
(fn [h]
(-> h
(wrap-copy-qp-pqp)

File diff suppressed because it is too large Load Diff

View File

@@ -50,7 +50,7 @@
[: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{background:linear-gradient(160deg,#79b52e 0%,#009cea 100%);min-height:100vh}@keyframes slideUp{from{opacity:0;transform:translateY(20px)}to{opacity:1;transform:translateY(0)}}"]]
[:body contents]]))})
(defn- page-contents [request]
@@ -72,7 +72,7 @@
[:div.flex-shrink-0.w-5.h-5.text-red-500 svg/alert]
[:div.flex-1.min-w-0
[:p.text-sm.font-medium.text-gray-900 "Something went wrong"]
[:p.text-xs.text-gray-500.mt-0.5
[:div.text-xs.text-gray-500.mt-0.5
"Our team has been notified. Please try again."
[:span {:x-data (hx/json {"e" false})}
" "

View File

@@ -28,7 +28,7 @@
(com/data-grid-header {} "Synced count")
(com/data-grid-header {} "Approved transactions")
(com/data-grid-header {} "Unapproved transactions")
(com/data-grid-header {} "Requires feedback transactions")
(com/data-grid-header {} "Client Review transactions")
(com/data-grid-header {} "Missing transactions")])
#_#_:thead-params {:class "sticky top-0 z-50"}}
(for [row report]

View File

@@ -81,7 +81,7 @@
(dropdown-search-results* {:options (get-clients identity (get (:query-params request) "search-text"))})))
(defn dropdown [{:keys [client-selection client identity clients]}]
[:div#company-dropdown {:x-data (hx/json {})}
[:div#company-dropdown {:x-data (hx/json {}) :class "shrink-0"}
[:script
(hiccup/raw
"localStorage.setItem(\"last-client-id\", \"" (:db/id client) "\")" "\n"
@@ -93,9 +93,10 @@
:else
client-selection) ")")]
[:div
[:button#company-dropdown-button {:class "text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:outline-none focus:ring-blue-300 font-medium rounded-lg text-sm px-4 py-2.5 text-center inline-flex items-center dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800"
[:button#company-dropdown-button {:class "text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:outline-none focus:ring-blue-300 font-medium rounded-lg text-sm px-4 py-2.5 text-center inline-flex items-center whitespace-nowrap dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800"
"x-tooltip.on.click" "{content: ()=>$refs.tooltip.innerHTML, theme: 'light', onMount(i) { htmx.process(i.popper); }, allowHTML: true, interactive:true}"
:type "button"}
[:span {:class "truncate max-w-[10rem] sm:max-w-[14rem]"}
(cond
(= :mine client-selection)
"My Companies"
@@ -107,8 +108,8 @@
(:client/name client)
:else
(str (count clients) " Companies"))
[:div.w-4.h-4.ml-2
(str (count clients) " Companies"))]
[:div.w-4.h-4.ml-2.shrink-0
svg/drop-down]]
[:template#company-dropdown-list {:x-ref "tooltip"}
[:div {:class "w-[300px]"

View File

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

View File

@@ -51,7 +51,7 @@
{:x-init "$el.indeterminate = true"}))]))
(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))}
:tippy nil
:search ""
@@ -61,13 +61,18 @@
[])})
:x-modelable "value.value"
:x-model (:x-model params)}
;; Key the component by its current value so alpine-morph re-initialises
;; it (rather than preserving stale Alpine x-data) whenever the *server*
;; changes the value -- e.g. the default account a vendor selection
;; populates. alpine-morph keys off the `key` attribute, not `id`.
(:id params) (assoc :key (str (:id params) "--" ((:value-fn params identity) (:value params)))))
(if (:disabled params)
[:span {:x-text "value.label"}]
[:a {:class (-> (hh/add-class (or (:class params) "") default-input-classes)
(hh/add-class "cursor-pointer"))
"x-tooltip.on.click" "{content: ()=>$refs.dropdown.innerHTML, placement: 'bottom', onMount(i) {htmx.process(i.popper); }, popperOptions: {strategy: 'fixed', modifiers: [{name: 'flip', options: {fallbackPlacements: ['top']}}]}, theme: 'dropdown', allowHTML: true, interactive:true}"
"@keydown.down.prevent.stop" "tippy.show();"
"@keydown.backspace" "tippy.hide(); value = {value: '', label: '' }"
"x-tooltip.on.click" "{content: ()=>($refs.dropdown?.innerHTML ?? ''), placement: 'bottom', onMount(i) {htmx.process(i.popper); }, popperOptions: {strategy: 'fixed', modifiers: [{name: 'flip', options: {fallbackPlacements: ['top']}}]}, theme: 'dropdown', allowHTML: true, interactive:true}"
"@keydown.down.prevent.stop" "tippy?.show();"
"@keydown.backspace" "tippy?.hide(); value = {value: '', label: '' }"
:tabindex 0
:x-init (str "$nextTick(() => tippy = $el.__x_tippy); " (:x-init params))
:x-ref "input"}
@@ -94,7 +99,7 @@
[:template {:x-ref "dropdown"}
[:ul.dropdown-contents {:class "bg-gray-100 dark:bg-gray-600 ring-1"
"@keydown.escape" "tippy.hide(); value = {value: '', label: '' }; "
"@keydown.escape" "$refs.input?.__x_tippy?.hide(); value = {value: '', label: '' }; "
:x-destroy "if ($refs.input) {$refs.input.focus();}"}
[:input {:type "text"
:autofocus true
@@ -107,8 +112,8 @@
"@change.stop" ""
"@keydown.down.prevent" "active ++; active = active >= elements.length - 1 ? elements.length - 1 : active"
"@keydown.up.prevent" "active --; active = active < 0 ? 0 : active"
"@keydown.enter.prevent.stop" "tippy.hide(); value = elements.length > 0 ? $data.elements[active >= 0 ? active : 0] : {'value': '', label: ''}; $refs.input.focus()"
"x-init" "$el.focus(); $watch('search', s => { if($el.value.length > 2) {fetch(addQueryParam(baseUrl, 'q', s)).then(data=>data.json()).then(data => {elements = data; active=-1; tippy.popperInstance.update()}) }})"}]
"@keydown.enter.prevent.stop" "$refs.input?.__x_tippy?.hide(); value = elements.length > 0 ? $data.elements[active >= 0 ? active : 0] : {'value': '', label: ''}; $refs.input?.focus()"
"x-init" "$el.focus(); $watch('search', s => { if($el.value.length > 2) {fetch(addQueryParam(baseUrl, 'q', s)).then(data=>data.json()).then(data => {elements = data; active=-1; $refs.input?.__x_tippy?.popperInstance?.update()}) }})"}]
[:div.dropdown-options {:class "rounded-b-lg overflow-hidden"}
[:template {:x-for "(element, index) in elements"}
[:li [:a {:class "px-4 py-2 flex gap-2 items-center outline-0 focus:bg-neutral-100 hover:bg-neutral-100 whitespace-nowrap [&.active]:bg-primary-500 [&.active]:dark:bg-primary-700 text-gray-800 dark:text-gray-100"
@@ -117,7 +122,7 @@
"@mouseover" "active = index"
"@mouseout" "active = -1"
"@click.prevent" "value = element; tippy.hide(); setTimeout(() => $refs.input.focus(), 10)"
"@click.prevent" "value = element; $refs.input?.__x_tippy?.hide(); setTimeout(() => $refs.input?.focus(), 10)"
"x-html" "element.label"}]]]
[:template {:x-if "elements.length == 0"}
[:li {:class "px-4 py-2 flex gap-2 items-center outline-0 focus:bg-neutral-100 hover:bg-neutral-100 whitespace-nowrap [&.active]:bg-primary-500 text-gray-800 dark:text-gray-100 text-xs "}
@@ -126,7 +131,7 @@
(defn multi-typeahead-dropdown- [params]
[:template {:x-ref "dropdown"}
[:ul.dropdown-contents {:class "bg-gray-100 dark:bg-gray-600 rounded-lg shadow-2xl w-max z-50 ring-1 p-4"
"@keydown.escape.prevent" "tippy.hide();"
"@keydown.escape.prevent" "$refs.input?.__x_tippy?.hide();"
:x-destroy "if ($refs.input) {$refs.input.focus();}"}
[:div {:class (-> "relative"
#_(hh/replace-wildcard ["rounded" "border"] "border-bottom bg-gray-100 rounded-t-lg w-full"))}
@@ -240,9 +245,9 @@
[:span {:x-text "value.label"}]
[:a {:class (-> (hh/add-class (or (:class params) "") default-input-classes)
(hh/add-class "cursor-pointer"))
"x-tooltip.on.click.prevent" "{content: ()=>$refs.dropdown.innerHTML, placement: 'bottom', onMount(i) {htmx.process(i.popper); }, popperOptions: {strategy: 'fixed', modifiers: [{name: 'flip', options: {fallbackPlacements: ['top']}}]}, theme: 'dropdown', allowHTML: true, interactive:true}"
"@keydown.down.prevent.stop" "tippy.show();"
"@keydown.backspace" "tippy.hide(); value=new Set( []);"
"x-tooltip.on.click.prevent" "{content: ()=>($refs.dropdown?.innerHTML ?? ''), placement: 'bottom', onMount(i) {htmx.process(i.popper); }, popperOptions: {strategy: 'fixed', modifiers: [{name: 'flip', options: {fallbackPlacements: ['top']}}]}, theme: 'dropdown', allowHTML: true, interactive:true}"
"@keydown.down.prevent.stop" "$refs.input?.__x_tippy?.show();"
"@keydown.backspace" "$refs.input?.__x_tippy?.hide(); value=new Set( []);"
:tabindex 0
:x-init (str "$nextTick(() => tippy = $el.__x_tippy); " (:x-init params))
:x-ref "input"}
@@ -325,7 +330,7 @@
(-> params
(update :class (fnil hh/add-class "") default-input-classes)
(assoc :x-model "value")
(assoc "x-tooltip.on.focus" "{content: ()=>$refs.tooltip.innerHTML, theme: 'light', onMount(i) { htmx.process(i.popper); }, allowHTML: true, interactive:true}")
(assoc "x-tooltip.on.focus" "{content: ()=>($refs.tooltip?.innerHTML ?? ''), theme: 'light', onMount(i) { htmx.process(i.popper); }, allowHTML: true, interactive:true}")
(assoc :x-init "$nextTick(() => tippy = $el.__x_tippy); ")
(assoc :type "text")
@@ -333,7 +338,7 @@
(assoc "autocomplete" "off")
(assoc "@change" "value = $event.target.value;")
(assoc "@keydown.escape" "tippy.hide(); ")
(assoc "@keydown.escape" "$el?.__x_tippy?.hide(); ")
#_(assoc "hx-on" (hiccup/raw "changeDate: htmx.trigger(this, \"change\") "))
(update :class #(str % (use-size size) " w-full"))
(dissoc :size))]

View File

@@ -138,6 +138,33 @@
[:div.space-y-1 {}
children])
(defn flatten-form-errors
"Walks a malli-humanized error structure and returns a flat sequence of
human-readable strings, prefixing each leaf message with the nearest
field name for context. Lets the footer's error bar surface every
validation error for the whole form, even ones whose field lives on a
hidden step/tab and so would otherwise be invisible."
([errors] (flatten-form-errors nil errors))
([field errors]
(let [label (cond (keyword? field) (name field)
(string? field) field
:else nil)
decorate (fn [msg] (if label (str label ": " msg) msg))]
(cond
(map? errors)
(mapcat (fn [[k v]] (flatten-form-errors k v)) errors)
(and (sequential? errors) (every? string? errors))
(map decorate errors)
(sequential? errors)
(mapcat #(flatten-form-errors field %) errors)
(string? errors)
[(decorate errors)]
:else nil))))
(defn default-step-footer [linear-wizard step & {:keys [validation-route
discard-button
next-button
@@ -146,7 +173,8 @@
[:div.flex.items-baseline.gap-x-4
(let [step-errors (:step-params fc/*form-errors*)]
(com/form-errors {:errors (or (:errors step-errors)
(when (sequential? step-errors) step-errors))}))
(when (sequential? step-errors) step-errors)
(seq (distinct (flatten-form-errors step-errors))))}))
(when (not= (first (steps linear-wizard))
(step-key step))
(when validation-route

View File

@@ -7,32 +7,42 @@
[auto-ap.ssr.components.buttons :refer [icon-button-]]
[auto-ap.ssr.components.user-dropdown :as user-dropdown]
[auto-ap.ssr.svg :as svg]
[bidi.bidi :as bidi]))
[bidi.bidi :as bidi]
[clojure.string :as str]))
(defn navbar- [{:keys [client-selection client identity clients dd-env]}]
[:nav {:class "fixed z-30 w-full bg-white border-b border-gray-200 dark:bg-gray-800 dark:border-gray-700"}
[:div {:class "px-3 py-3 lg:px-5 lg:pl-3"}
[:div {:class "flex items-center justify-between"}
[:div {:class "flex items-center justify-start"}
[:button {:aria-controls "left-nav", :id "left-nav-toggle" :type "button", :class "inline-flex items-center p-2 mt-2 ml-2 mr-2 text-sm text-gray-500 rounded-lg hover:bg-gray-100 focus:outline-none focus:ring-2 focus:ring-gray-200 dark:text-gray-400 dark:hover:bg-gray-700 dark:focus:ring-gray-600"
[:div {:class "px-3 lg:px-5 lg:pl-3 h-16 flex items-center"}
[:div {:class "flex items-center w-full"}
;; Left cluster: sidebar toggle, logo, environment badge. Holds its size.
[:div {:class "flex items-center shrink-0"}
[:button {:aria-controls "left-nav", :id "left-nav-toggle" :type "button", :class "inline-flex items-center p-2 mr-2 text-sm text-gray-500 rounded-lg hover:bg-gray-100 focus:outline-none focus:ring-2 focus:ring-gray-200 dark:text-gray-400 dark:hover:bg-gray-700 dark:focus:ring-gray-600"
"@click" "leftNavShow = !leftNavShow"}
[:span {:class "sr-only"} "Open sidebar"]
[:svg {:class "w-6 h-6", :aria-hidden "true", :fill "currentColor", :viewbox "0 0 20 20", :xmlns "http://www.w3.org/2000/svg"}
[:path {:clip-rule "evenodd", :fill-rule "evenodd", :d "M2 4.75A.75.75 0 012.75 4h14.5a.75.75 0 010 1.5H2.75A.75.75 0 012 4.75zm0 10.5a.75.75 0 01.75-.75h7.5a.75.75 0 010 1.5h-7.5a.75.75 0 01-.75-.75zM2 10a.75.75 0 01.75-.75h14.5a.75.75 0 010 1.5H2.75A.75.75 0 012 10z"}]]]
[:a {:href (bidi/path-for ssr-routes/only-routes ::dashboard/page) :class "flex ml-2 hidden md:mr-24 sm:inline"}
[:img {:src "/img/logo-big2.png", :class "h-10", :alt "Integreat logo"}]]
(when-not (= "prod" dd-env) [:div.rounded-full.bg-yellow-200.text-lg.text-yellow-800.px-4.hidden.md:block.mr-8 "environment: " dd-env])]
[:div {:class "flex items-center gap-4"}
[:a {:href (bidi/path-for ssr-routes/only-routes ::dashboard/page) :class "hidden sm:flex items-center shrink-0"}
[:img {:src "/img/logo-big2.png", :class "h-10 max-w-none", :alt "Integreat logo"}]]
(when (and dd-env (not= "prod" dd-env))
(let [env-label (str "environment: " dd-env)]
[:div {:class "shrink-0"}
;; Full pill when there is room (md-lg and xl+); compact letter badge in the tight lg range.
[:span {:class "hidden md:inline-flex lg:hidden xl:inline-flex items-center ml-4 h-8 px-3 rounded-full bg-yellow-200 text-yellow-800 text-sm font-medium whitespace-nowrap"}
env-label]
[:span {:class "hidden lg:flex xl:hidden items-center justify-center ml-3 w-8 h-8 rounded-full bg-yellow-200 text-yellow-800 text-sm font-bold"
:title env-label}
(str/upper-case (subs dd-env 0 1))]]))]
;; Search: fills the middle, grows to a comfortable max and shrinks first when space is tight.
(when (is-admin? identity)
[:button.mt-1.lg:w-96.relative.hidden.lg:block {:class "bg-gray-50 hover:bg-gray-200 dark:hover:bg-gray-700 border border-gray-300 text-gray-900 sm:text-sm rounded-lg focus:ring-primary-500 focus:border-primary-500 w-full pl-10 py-4 pr-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-primary-500 dark:focus:border-primary-500 gap-4 "
[:button.relative.hidden.lg:block.flex-1.min-w-0.max-w-md.mx-4 {:class "bg-gray-50 hover:bg-gray-200 dark:hover:bg-gray-700 border border-gray-300 text-gray-900 sm:text-sm rounded-lg focus:ring-primary-500 focus:border-primary-500 pl-10 h-10 pr-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-primary-500 dark:focus:border-primary-500"
:hx-get (bidi/path-for ssr-routes/only-routes :search)}
[:div {:class "absolute inset-y-0 left-0 flex items-center pl-3 pointer-events-none text-gray-500"}
[:div.w-4.h-4 svg/search]
[:span.ml-2 "Search"]]])
[:div {:class "hidden mr-3 -mb-1 sm:block"}
[:span]]
;; Right cluster: mobile search, company selector, user menu. Stays pinned right and keeps its size.
[:div {:class "flex items-center gap-2 sm:gap-4 ml-auto shrink-0"}
(icon-button-
{:id "toggleSidebarMobileSearch", :type "button", :class "p-2 text-gray-500 rounded-lg lg:hidden hover:text-gray-900 hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-white"
:hx-get (bidi/path-for ssr-routes/only-routes

View File

@@ -0,0 +1,310 @@
(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)})))
(defn select
"Generic <select> rendered from a Selmer partial (the location-select.html shape,
generalized). options = [[value label] ...]; `value` (string or keyword) marks the
selected option. Class defaults to the standard input classes, like com/select. Extra
attrs (hx-*, x-*) ride through onto the element."
[{:keys [name value options class] :as params}]
(let [classes (-> ""
(hh/add-class inputs/default-input-classes)
(hh/add-class (or class "")))
sel (cond-> value (keyword? value) clojure.core/name)
attrs (dissoc params :name :value :options :class)]
(render "templates/components/select.html"
{:name name
:classes classes
:attrs (attrs->str attrs)
:options (for [[v label] options]
{:value v :label label :selected (= (str v) (str sel))})})))
;; --- 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

@@ -0,0 +1,183 @@
(ns auto-ap.ssr.components.wizard2
"Data-driven multi-step wizard engine — no protocols, no defrecords, no middleware
stacking. A wizard is a plain *config map*; per-step validated state lives in the Ring
session (see `wizard-state`), combined only at the end. Two routes per wizard: open
(GET) and submit (POST). Only an opaque `wizard-id` + the `current-step` ride in the
form — never the accumulated data, so there is no EDN snapshot to serialize or merge.
## Config shape
{:name :vendor ; instance label (for debugging)
:form-id \"wizard-form\" ; the <form> id (swap target)
:submit-route \"/admin/vendor/wizard\" ; resolved URL the form posts to
:form-attrs {...} ; extra <form> attrs (hx-ext, etc.)
:init-fn (fn [request] {:context {...} :init-data {step-key data}})
:done-fn (fn [all-data request] ring-response) ; called when a step's :next = :done
:steps [{:key :info
:decode (fn [request] -> data-map) ; parse this step's posted fields
:validate (fn [data request] -> errors|nil) ; optional
:render (fn [ctx] -> hiccup) ; renders the step body
:next (fn [data] -> next-step-key | :done)}
...]}
The engine wraps each step's body in the wizard <form> (adding the wizard-id /
current-step hiddens + hx-post). A step's `:render` receives a ctx map:
{:wizard-id :current-step :context :all-data :step-data :errors :request :config}
`:step-data` is the previously-stored data for this step (so editing repopulates), or
the just-posted data on a validation re-render. `:all-data` is every step combined so
far (handy for a read-only preview/summary step). Navigation buttons post a `direction`
field: \"next\" (validate+advance), \"back\" (no validate), \"submit\" (== next, for the
last step). See `reference/form-vs-wizard.md`."
(:require
[auto-ap.ssr.components :as com]
[auto-ap.ssr.components.wizard-state :as ws]
[auto-ap.ssr.utils :refer [html-response]]))
(defn- step-by-key [config k]
(first (filter #(= (:key %) k) (:steps config))))
(defn- prev-step
"The step key before `k` in the linear step order (or `k` itself if first)."
[config k]
(let [keys (mapv :key (:steps config))
i (.indexOf keys k)]
(if (pos? i) (nth keys (dec i)) k)))
(defn wizard-form
"Wrap a step body in the wizard <form>: the form posts to the submit route, and only the
wizard-id + current-step ride along (no accumulated data — that lives in the session).
Enter is guarded so it triggers the step's primary nav button (the one marked
`data-primary`) rather than whichever submit button the browser picks first."
[config wizard-id current-step body]
[:form (merge {:id (:form-id config "wizard-form")
:hx-post (:submit-route config)
:hx-target "this"
:hx-swap "outerHTML"
"@keydown.enter.prevent.stop" "$el.querySelector('[data-primary]')?.click()"}
(:form-attrs config))
(com/hidden {:name "wizard-id" :value wizard-id})
(com/hidden {:name "current-step" :value (name current-step)})
body])
(defn nav-footer
"Standard wizard footer controls — so consumers don't hand-roll the `direction` buttons
(and mis-target Back vs Save, or forget the Enter guard). Buttons post a `direction`
field the engine branches on; the advance/save button is marked `data-primary` so the
form's Enter guard triggers it. Also renders the `#form-errors` slot.
(nav-footer {:next \"Test\"}) ; intermediate step: Next
(nav-footer {:back? true :save? true}) ; last step: Back + Save
(nav-footer {:back? true :save? true :save-label \"Pay\"}) ; last step, custom label"
[{:keys [next back? save? save-label]}]
[:div.flex.justify-end.items-baseline.gap-x-4
[:div#form-errors]
(when back?
(com/button {:type "submit" :name "direction" :value "back" :class "w-24"} "Back"))
(when next
(com/button {:type "submit" :name "direction" :value "next" :data-primary "" :color :primary :class "w-24"} next))
(when save?
(com/button {:type "submit" :name "direction" :value "submit" :data-primary "" :color :primary :class "w-24"} (or save-label "Save")))])
(defn blank-row
"A fresh repeated-row map for an 'add row' interaction, with a temp `:db/id` (so a row
schema requiring `[:db/id [:or entity-id temp-id]]` validates and the step can advance,
instead of the add button silently doing nothing) plus `:new?` for the appear
animation. Merge in any field defaults: `(blank-row :foo/location \"Shared\")`."
[& {:as defaults}]
(merge {:db/id (str (java.util.UUID/randomUUID)) :new? true} defaults))
(defn render-wizard
"Render the current step's body inside the wizard form. `step-data`/`errors` let a
validation re-render show the just-posted values + messages."
[{:keys [config wizard-id session request step-errors step-posted]}]
(let [cur (ws/current-step session wizard-id)
step (step-by-key config cur)
ctx {:wizard-id wizard-id
:current-step cur
:context (ws/context session wizard-id)
:all-data (ws/get-all session wizard-id)
:step-data (or step-posted (ws/step-data session wizard-id cur))
:errors step-errors
:request request
:config config}]
(wizard-form config wizard-id cur ((:render step) ctx))))
(defn- render-response
"html-response of the rendered wizard, with the (possibly updated) session threaded into
the Ring response so the session store persists the new wizard state."
[config wizard-id session request & [extra]]
(-> (html-response (render-wizard (merge {:config config
:wizard-id wizard-id
:session session
:request request}
extra)))
(assoc :session session)))
(defn open-wizard
"Create a wizard instance in the session, render its first step, and return a Ring
response with the updated session threaded. `:init-fn` returns {:context ..., :init-data
...} (both optional). If the config supplies an `:open-response` fn it is applied to the
rendered form hiccup to build the response (e.g. wrap it in a modal shell via
modal-response); otherwise a bare html-response is returned. This makes open-wizard
directly usable as a route handler — `(partial open-wizard config)` — for modal
wizards, instead of every consumer re-implementing create!/render/wrap/thread."
[config request]
(let [{:keys [context init-data]} ((:init-fn config) request)
first-step (-> config :steps first :key)
[id session'] (ws/create-wizard! (:session request) (:name config)
{:first-step first-step
:context context
:init-data init-data})
form (render-wizard {:config config :wizard-id id :session session' :request request})
resp ((or (:open-response config) html-response) form)]
(assoc resp :session session')))
(defn- expired-response
"The wizard instance is gone from the session (server restart / session expiry / a stale
tab). Re-open a fresh wizard rather than 500-ing."
[config request]
(open-wizard config request))
(defn handle-step-submit
"Submit handler. Reads wizard-id / current-step / direction from the posted form, then:
- \"back\": move to the previous step (no validation).
- else: decode + validate the current step; on error re-render it with messages;
otherwise store the step's data and either advance to `:next` or, when
`:next` is :done, call `done-fn` with all combined data and `forget` the
instance."
[config request]
(let [fp (:form-params request)
wizard-id (get fp "wizard-id")
current-step (keyword (get fp "current-step"))
direction (or (get fp "direction") "next")
session (:session request)]
(cond
(not (ws/exists? session wizard-id))
(expired-response config request)
(= direction "back")
(render-response config wizard-id
(ws/set-step session wizard-id (prev-step config current-step))
request)
:else
(let [step (step-by-key config current-step)
;; The engine owns wizard-id / current-step / direction. Strip them so the
;; step's :decode never sees them and can decode straight into its schema --
;; no per-consumer allowlist, and they can't leak into the saved entity.
clean (update request :form-params dissoc "wizard-id" "current-step" "direction")
posted ((:decode step) clean)
errors (when-let [v (:validate step)] (v posted request))]
(if (seq errors)
(render-response config wizard-id session request
{:step-errors errors :step-posted posted})
(let [session' (ws/put-step session wizard-id current-step posted)
nxt ((:next step) posted)]
(if (= nxt :done)
(-> ((:done-fn config) (ws/get-all session' wizard-id) request)
(assoc :session (ws/forget session' wizard-id)))
(render-response config wizard-id
(ws/set-step session' wizard-id nxt)
request))))))))

View File

@@ -0,0 +1,66 @@
(ns auto-ap.ssr.components.wizard-state
"Session-backed storage for multi-step wizards — the Django formtools `SessionStorage`
model. Each wizard instance's per-step *validated* data lives in the Ring session under
[:wizards <wizard-id> :step-data <step-key>]
and the steps are combined only at the very end via `get-all`. This replaces the
EDN-snapshot-in-a-hidden-field round-trip (and its custom readers + merge logic): no
data about other steps ever rides through the page — only an opaque `wizard-id` token.
State is namespaced by `wizard-id` (a random uuid), so concurrent wizards and browser
tabs don't collide, and a completed/abandoned wizard is discarded with `forget`.
These functions are pure: each takes a session map and returns a new session map (or a
read). The engine (`wizard2`) threads the returned session into the Ring response; the
session store (cookie / durable) then persists it. Nothing here touches global state."
(:require
[clojure.string :as str]))
(defn create-wizard!
"Seed a fresh wizard instance. Returns `[wizard-id session']`. `opts`:
:first-step the step key the wizard opens on (required)
:context read-only data the steps need but don't edit (e.g. an entity id) — kept
out of :step-data so it never gets merged into the combined result
:init-data optional pre-filled per-step data ({step-key data}), e.g. when editing an
existing entity so step 1 opens populated.
Despite the bang, this only *computes* the next session — it doesn't mutate anything;
the caller threads `session'` into its response."
[session config-name {:keys [first-step context init-data]}]
(let [id (str (java.util.UUID/randomUUID))]
[id (assoc-in session [:wizards id]
{:config-name config-name
:current-step first-step
:context (or context {})
:step-data (or init-data {})})]))
(defn instance [session id] (get-in session [:wizards id]))
(defn exists? [session id] (boolean (and id (get-in session [:wizards id]))))
(defn current-step [session id] (get-in session [:wizards id :current-step]))
(defn context [session id] (get-in session [:wizards id :context]))
(defn step-data [session id step-key] (get-in session [:wizards id :step-data step-key]))
(defn put-step
"Store (REPLACE, never merge) a step's validated data. Replacing is the whole point —
re-submitting a step overwrites that step only; other steps are untouched."
[session id step-key data]
(assoc-in session [:wizards id :step-data step-key] data))
(defn set-step
"Move the wizard's current step (navigation)."
[session id step-key]
(assoc-in session [:wizards id :current-step] step-key))
(defn get-all
"Combine every stored step's data into one map (the formtools `get_all_cleaned_data`).
Combined only here, at the end — later steps win on key collisions (steps order)."
[session id]
(->> (get-in session [:wizards id :step-data])
vals
(apply merge {})))
(defn forget
"Discard the wizard instance (on completion or abandonment) so the session doesn't grow
unbounded. Call from the done-fn's response."
[session id]
(update session :wizards dissoc id))

View File

@@ -282,6 +282,7 @@
[:div {:x-data (hx/json {:selected [] :all_selected false :type (:entity-name grid-spec)})
"x-on:copy" "if (selected.length > 0) {$clipboard(JSON.stringify({'type': type, 'selected': selected}))}"
"x-on:client-selected.document" "selected=[]; all_selected=false"
"x-on:reset-selection.document" "selected=[]; all_selected=false"
"x-bind:hx-vals" "JSON.stringify({selected: $data.selected, 'all-selected': $data.all_selected})"
:x-init "$watch('selected', s=> $dispatch('selectedChanged', {selected: s, all_selected: all_selected}) );
$watch('all_selected', a=>$dispatch('selectedChanged', {selected: selected, all_selected: a}))"}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -31,7 +31,7 @@
[auto-ap.ssr.ui :refer [base-page]]
[auto-ap.ssr.utils
:refer [apply-middleware-to-all-handlers clj-date-schema
html-response main-transformer money strip
html-response modal-response main-transformer money strip
wrap-form-4xx-2 wrap-implied-route-param
wrap-merge-prior-hx wrap-schema-decode
wrap-schema-enforce]]
@@ -69,6 +69,40 @@
selected)]
ids))
(defn all-ids-not-locked
"Filters journal-entry ids to only those whose date is on/after the client's
locked-until date (i.e. not in a reconciled/locked period)."
[all-ids]
(->> all-ids
(dc/q '[:find ?t
:in $ [?t ...]
:where
[?t :journal-entry/client ?c]
[(get-else $ ?c :client/locked-until #inst "2000-01-01") ?lu]
[?t :journal-entry/date ?d]
[(>= ?d ?lu)]]
(dc/db conn))
(map first)))
(defn bulk-delete [request]
(assert-admin (:identity request))
(let [params (:form-params request)
ids (selected->ids (assoc-in request [:route-params :external?] true) params)
all-ids (all-ids-not-locked ids)]
(if (> (count all-ids) 1000)
(modal-response
(com/success-modal {:title "Too many ledger entries"}
[:p "You can only delete 1000 ledger entries at a time."]))
(do
(alog/info ::bulk-delete-ledger :count (count all-ids) :sample (take 3 all-ids))
(audit-transact-batch
(map (fn [i] [:db/retractEntity i]) all-ids)
(:identity request))
(modal-response
(com/success-modal {:title "Ledger Entries Deleted"}
[:p (str "Successfully deleted " (count all-ids) " ledger entries.")])
:headers {"hx-trigger" "invalidated, reset-selection"})))))
(defn delete [{invoice :entity :as request identity :identity}]
(exception->notification
#(when-not (= :invoice-status/unpaid (:invoice/status invoice))
@@ -696,6 +730,8 @@
::route/csv (helper/csv-route grid-page)
::route/external-import-page external-import-page
::route/bank-account-filter bank-account-filter
::route/bulk-delete (-> bulk-delete
(wrap-schema-enforce :form-schema query-schema))
::route/external-import-parse (-> external-import-parse
(wrap-schema-enforce :form-schema parse-form-schema)
(wrap-form-4xx-2 external-import-parse)

View File

@@ -482,10 +482,26 @@
(assoc-in (exact-match-id* request) [1 :hx-swap-oob] true)])
:query-schema query-schema
:action-buttons (fn [request]
[(when-not (:external? (:route-params request)) (com/button {:color :primary
[(when-not (:external? (:route-params request))
(com/button {:color :primary
:hx-get (bidi/path-for ssr-routes/only-routes
::route/new)}
"Add journal entry"))])
"Add journal entry"))
(when (and (:external? (:route-params request))
(= "admin" (:user/role (:identity request))))
(com/button {:color :red
:hx-post (bidi/path-for ssr-routes/only-routes ::route/bulk-delete)
;; target the persistent modal shell content slot directly so the
;; request never relies on the outerHTML swap inherited from the
;; data-grid card (which would replace #modal-holder and break the
;; next click). modal-response also retargets here.
:hx-target "#modal-content"
:hx-swap "innerHTML"
"x-bind:hx-vals" "JSON.stringify({selected: $data.selected, 'all-selected': $data.all_selected})"
"x-bind:disabled" "selected.length === 0 && !all_selected"
"hx-include" "#ledger-filters"
:hx-confirm "Are you sure you want to delete these ledger entries?"}
"Delete selected"))])
:row-buttons (fn [request entity]
[(when (and (= :invoice-status/unpaid (:invoice/status entity))
(can? (:identity request) {:subject :invoice :activity :delete}))

View File

@@ -129,7 +129,17 @@
(let [{:keys [client warning]} (maybe-trim-clients request client)
{:keys [data report]} (get-report (assoc-in request [:form-params :client] client))
client-count (count (set (map :client-id (:data data))))
table-contents (concat-tables (concat (:summaries report) (:details report)))]
table-contents (concat-tables (concat (:summaries report) (:details report)))
warning-text (not-empty (str/join "\n " (filter not-empty [warning (:warning report)])))
sample-links (when (can? (:identity request)
{:subject :history
:activity :view})
(seq (for [n (:invalid-ids report)]
[:div
(com/link {:href (str (bidi/path-for ssr-routes/only-routes
:admin-history)
"/" n)}
"Sample")])))]
(list
[:div.text-2xl.font-bold.text-gray-600 (str "Profit and loss - " (str/join ", " (map :client/name client)))]
(table {:widths (into [20] (take (dec (cell-count table-contents))
@@ -140,18 +150,8 @@
[13 6])))))
:investigate-url (bidi.bidi/path-for ssr-routes/only-routes ::route/investigate)
:table table-contents
:warning [:div
(not-empty (str (str/join "\n " (filter not-empty [warning (:warning report)]))))
(when (can? (:identity request)
{:subject :history
:activity :view})
(for [n (:invalid-ids report)]
[:div
(com/link {:href (str (bidi/path-for ssr-routes/only-routes
:admin-history)
"/" n)}
"Sample")]))]}))))])
:warning (when (or warning-text sample-links)
[:div warning-text sample-links])}))))])
(defn form* [request & children]
(let [params (or (:query-params request) {})]
@@ -254,10 +254,10 @@
(str/replace (->> client-ids (pull-many (dc/db conn) [:client/name]) (map :client/name) (str/join "-")) #"[^\w]" "_"))
(defn profit-and-loss-args->name [request]
(let [date (atime/unparse-local
(:date (:query-params request))
atime/iso-date)
name (->> request :query-params :client (map :db/id) join-names)]
(let [{:keys [client periods]} (:form-params request)
client (if (= :all client) (:clients request) client)
date (some-> periods last :end (atime/unparse-local atime/iso-date))
name (->> client (map :db/id) join-names)]
(format "Profit-and-loss-%s-for-%s" date name)))
(defn print-profit-and-loss [request]

View File

@@ -9,29 +9,29 @@
[auto-ap.client-routes :as client-routes]
[auto-ap.routes.pos.sales-summaries :as route]
[auto-ap.ssr-routes :as ssr-routes]
[auto-ap.ssr.common-handlers :refer [add-new-entity-handler]]
[auto-ap.ssr.components :as com]
[auto-ap.ssr.components.link-dropdown :refer [link-dropdown]]
[auto-ap.ssr.components.multi-modal :as mm]
[auto-ap.ssr.form-cursor :as fc]
[auto-ap.ssr.components.selmer :as sc]
[auto-ap.ssr.grid-page-helper :as helper :refer [wrap-apply-sort]]
[auto-ap.ssr.hx :as hx]
[auto-ap.ssr.nested-form-params :refer [wrap-nested-form-params]]
[auto-ap.ssr.pos.common
:refer [date-range-field*]]
[auto-ap.ssr.selmer :as sel]
[auto-ap.ssr.svg :as svg]
[auto-ap.ssr.utils
:refer [apply-middleware-to-all-handlers clj-date-schema
default-grid-fields-schema entity-id html-response money
strip temp-id wrap-merge-prior-hx wrap-schema-enforce]]
:refer [->db-id apply-middleware-to-all-handlers assert-schema
clj-date-schema default-grid-fields-schema entity-id html-response
main-transformer modal-response money path->name2 strip temp-id
wrap-form-4xx-2 wrap-merge-prior-hx wrap-schema-enforce]]
[auto-ap.time :as atime]
[bidi.bidi :as bidi]
[clj-time.coerce :as c]
[clojure.string :as str]
[datomic.api :as dc]
[hiccup.util :as hu]
[iol-ion.query :refer [dollars= dollars-0?]]
[malli.core :as mc]
[malli.util :as mut]))
[iol-ion.query :refer [dollars=]]
[malli.core :as mc]))
(def query-schema (mc/schema
[:maybe
@@ -133,63 +133,6 @@
(str (subs s 0 (- max-len 3)) "...")
s))
(defn account-typeahead*
[{:keys [name value client-id]}]
[:div.flex.flex-col
(com/typeahead {:name name
:placeholder "Search..."
:url (hu/url (bidi/path-for ssr-routes/only-routes :account-search)
{:client-id client-id
:purpose "invoice"})
:value value
:content-fn (fn [value]
(:account/name (d-accounts/clientize (dc/pull (dc/db conn) d-accounts/default-read value)
client-id)))})])
(defn account-display-cell [{:keys [item field-name-prefix client-id]}]
(let [account-id (:ledger-mapped/account item)
account-name (when account-id
(:account/name (d-accounts/clientize (dc/pull (dc/db conn) d-accounts/default-read account-id)
client-id)))]
[:div.account-cell.flex.items-center.gap-2
(com/hidden {:name (str field-name-prefix "[ledger-mapped/account]")
:value (or account-id "")})
(if account-id
[:span.text-sm account-name]
(com/pill {:color :red} "Missing acct"))
(com/a-icon-button {:class "p-1"
:hx-get (bidi/path-for ssr-routes/only-routes ::route/edit-item-account)
:hx-target "closest .account-cell"
:hx-swap "outerHTML"
:hx-vals (hx/json {:item-index (or (:item-index item) 0)
:client-id client-id
:current-account-id (or account-id "")})}
svg/pencil)]))
(defn account-edit-cell [{:keys [field-name-prefix client-id current-account-id]}]
(let [account-input-name (str field-name-prefix "[ledger-mapped/account]")]
[:div.account-cell.flex.flex-col.gap-2
(account-typeahead* {:name account-input-name
:value current-account-id
:client-id client-id})
[:div.flex.gap-1
(com/a-icon-button {:class "p-1"
:hx-put (bidi/path-for ssr-routes/only-routes ::route/save-item-account)
:hx-target "closest .account-cell"
:hx-swap "outerHTML"
:hx-include "closest .account-cell"
:hx-vals (hx/json {:field-name-prefix field-name-prefix
:client-id client-id})}
svg/check)
(com/a-icon-button {:class "p-1"
:hx-get (bidi/path-for ssr-routes/only-routes ::route/cancel-item-account)
:hx-target "closest .account-cell"
:hx-swap "outerHTML"
:hx-vals (hx/json {:field-name-prefix field-name-prefix
:client-id client-id
:current-account-id (or current-account-id "")})}
svg/x)]]))
(def grid-page
(helper/build {:id "entity-table"
:id-fn :db/id
@@ -247,7 +190,7 @@
[:span.shrink-0.font-mono.tabular-nums.text-right.text-gray-900.whitespace-nowrap
(format "$%,.2f" (:ledger-mapped/amount si))]])
(for [_ (range (max 0 (- credit-count (count debit-items))))]
[:li.py-0.5.text-sm " "])]
[:li.py-0.5.text-sm " "])]
[:div.border-t-2.border-gray-300.mt-1.pt-1.flex.justify-between.items-baseline
[:span.text-xs.uppercase.tracking-wider.font-semibold.text-gray-500 "Total"]
[:span.font-mono.tabular-nums.font-bold.text-gray-900
@@ -273,7 +216,7 @@
[:span.shrink-0.font-mono.tabular-nums.text-right.text-gray-900.whitespace-nowrap
(format "$%,.2f" (:ledger-mapped/amount si))]])
(for [_ (range (max 0 (- debit-count (count credit-items))))]
[:li.py-0.5.text-sm " "])]
[:li.py-0.5.text-sm " "])]
[:div.border-t-2.border-gray-300.mt-1.pt-1.flex.justify-between.items-baseline
[:span.text-xs.uppercase.tracking-wider.font-semibold.text-gray-500 "Total"]
[:span.font-mono.tabular-nums.font-bold.text-gray-900
@@ -348,296 +291,308 @@
(not (and (:credit x)
(:debit x))))]]]]])
(defn summary-total-row* [request]
(let [total-credits (-> request
:multi-form-state
:step-params
:sales-summary/items
(total-credits))
total-debits (-> request
:multi-form-state
:step-params
:sales-summary/items
(total-debits))]
;; ---------------------------------------------------------------------------
;; Field-name / error helpers (no step-params[...] prefix) + item helpers.
;; Mirrors transaction/edit.clj and transaction/bulk_code.clj.
;; ---------------------------------------------------------------------------
(com/data-grid-row {:id "total-row"
:class "bg-slate-50 border-t-2 border-slate-300"}
(com/data-grid-cell {})
(com/data-grid-cell {:class "text-right"}
[:span.text-xs.uppercase.tracking-wider.font-semibold.text-slate-600
"Total"])
(com/data-grid-cell {:class "text-right"}
[:span.font-mono.tabular-nums.font-bold.text-slate-900
(format "$%,.2f" total-debits)])
(com/data-grid-cell {:class "text-right"}
[:span.font-mono.tabular-nums.font-bold.text-slate-900
(format "$%,.2f" total-credits)])
(com/data-grid-cell {}))))
(def ^:dynamic *errors*
"Humanized form errors for the current render, keyed by edit-schema paths. Bound by
render-form from the request's :form-errors. Plain map -- no wizard, no cursor."
{})
(defn unbalanced-row* [request]
(let [total-credits (-> request
:multi-form-state
:step-params
:sales-summary/items
(total-credits))
total-debits (-> request
:multi-form-state
:step-params
:sales-summary/items
(total-debits))
unbalanced? (not (dollars= total-credits total-debits))
debit-over? (and unbalanced? (> total-debits total-credits))
credit-over? (and unbalanced? (> total-credits total-debits))]
(defn- ferr [& path]
(get-in *errors* (vec path)))
(com/data-grid-row {:id "unbalanced-row"
:class (when unbalanced? "bg-red-50 border-t border-red-200")}
(com/data-grid-cell {})
(com/data-grid-cell {:class "text-right"}
(when unbalanced?
[:span.text-xs.uppercase.tracking-wider.font-semibold.text-red-700
"Out of balance"]))
(com/data-grid-cell {:class "text-right"}
(when debit-over?
[:span.font-mono.tabular-nums.font-bold.text-red-700
(format "$%,.2f" (- total-debits total-credits))]))
(com/data-grid-cell {:class "text-right"}
(when credit-over?
[:span.font-mono.tabular-nums.font-bold.text-red-700
(format "$%,.2f" (- total-credits total-debits))]))
(com/data-grid-cell {}))))
(defn- item-field-name [index field]
(path->name2 :sales-summary/items index field))
(defn summary-total-display [request]
(let [total-credits (-> request
:multi-form-state
:step-params
:sales-summary/items
(total-credits))
total-debits (-> request
:multi-form-state
:step-params
:sales-summary/items
(total-debits))]
[:div.flex.justify-between.text-sm.py-1.border-t.mt-1
{:id "total-display"}]
[:span.font-semibold "Total"]
[:div.flex.gap-8
[:span.font-mono (format "$%,.2f" total-debits)]
[:span.font-mono (format "$%,.2f" total-credits)]]))
(defn- item-field-errors [index field]
(ferr :sales-summary/items index field))
(defn unbalanced-display [request]
(let [total-credits (-> request
:multi-form-state
:step-params
:sales-summary/items
(total-credits))
total-debits (-> request
:multi-form-state
:step-params
:sales-summary/items
(total-debits))
delta (- total-debits total-credits)]
(when-not (dollars-0? delta)
[:div.flex.justify-between.text-sm.py-1
{:id "unbalanced-display"}
[:span.font-semibold.text-red-600 "Unbalanced"]
[:div.flex.gap-8
[:span.font-mono (when (pos? delta) (format "$%,.2f" delta))
[:span.font-mono (when (neg? delta) (format "$%,.2f" (Math/abs delta)))]]]])))
(defn- item-side
"Which column an item belongs to: its persisted ledger-side for auto items, else the
filled-in credit/debit for a manual row (nil for a brand-new blank manual row)."
[item]
(cond
(= :ledger-side/debit (:ledger-mapped/ledger-side item)) :debit
(= :ledger-side/credit (:ledger-mapped/ledger-side item)) :credit
(:debit item) :debit
(:credit item) :credit
:else nil))
(defn sales-summary-item-row* [{:keys [value client-id]}]
(let [manual? (fc/field-value (:sales-summary-item/manual? value))]
(com/data-grid-row (cond-> {:x-ref "p"
:x-data (hx/json {})
:class (when manual?
"bg-indigo-50/40 border-l-2 border-indigo-300")}
(fc/field-value (:new? value)) (hx/htmx-transition-appear))
(fc/with-field :db/id
(com/hidden {:name (fc/field-name)
:value (fc/field-value)}))
(when manual?
(fc/with-field :sales-summary-item/manual?
(com/hidden {:name (fc/field-name)
:value true})))
(com/data-grid-cell {:class "align-top"}
(fc/with-field :sales-summary-item/category
(if manual?
(com/validated-field {:errors (fc/field-errors)}
(com/text-input {:placeholder "Category/Explanation"
:name (fc/field-name)
:value (fc/field-value)}))
(defn- sum-debits [items]
(->> items (keep :debit) (filter number?) (reduce + 0.0)))
(list
(com/hidden {:name (fc/field-name)
:value (fc/field-value)})
[:span.text-sm.text-gray-700
(fc/field-value (:sales-summary-item/category value))]))))
(com/data-grid-cell {:class "align-top"}
(fc/with-field :ledger-mapped/account
(com/validated-field {:errors (fc/field-errors)}
(account-typeahead* {:value (fc/field-value)
(defn- sum-credits [items]
(->> items (keep :credit) (filter number?) (reduce + 0.0)))
;; ---------------------------------------------------------------------------
;; Render (Selmer): account typeahead, inline account cell (display/edit),
;; the read-only auto rows, the editable manual rows, totals/balance.
;; ---------------------------------------------------------------------------
(defn account-typeahead* [{:keys [name value client-id]}]
(sc/typeahead {:name name
:id name
:placeholder "Search..."
:url (hu/url (bidi/path-for ssr-routes/only-routes :account-search)
{:client-id client-id
:purpose "invoice"})
:value value
:content-fn (fn [value]
(:account/name (d-accounts/clientize (dc/pull (dc/db conn) d-accounts/default-read value)
client-id)))}))
(defn account-display-cell*
"Account name + inline-edit pencil. The pencil swaps just this `.account-cell`
(#account-cell, Rule 2) into the edit cell."
[{:keys [index account-id client-id]}]
(let [account-id (when (and account-id (not= account-id "")) (->db-id account-id))
account-name (when account-id
(:account/name (d-accounts/clientize (dc/pull (dc/db conn) d-accounts/default-read account-id)
client-id)))]
(str "<div class=\"account-cell flex items-center gap-2\">"
(str (sc/hidden {:name (item-field-name index :ledger-mapped/account)
:value (or account-id "")}))
(if account-name
(str "<span class=\"text-sm\">" (hu/escape-html account-name) "</span>")
(str (sel/hiccup->html (com/pill {:color :red} "Missing acct"))))
(str (sc/a-icon-button {:class "p-1"
:hx-get (bidi/path-for ssr-routes/only-routes ::route/edit-item-account)
:hx-target "closest .account-cell"
:hx-swap "outerHTML"
:hx-vals (hx/json {:item-index index
:client-id client-id
:name (fc/field-name)}))))
(com/data-grid-cell {:class "text-right align-top"}
:current-account-id (or account-id "")})}
svg/pencil))
"</div>")))
(if manual?
(fc/with-field :debit
(com/validated-field {:errors (fc/field-errors)}
(com/money-input {:class "w-24 text-right font-mono tabular-nums"
:name (fc/field-name)
:value (fc/field-value)})))
(when (= (fc/field-value (:ledger-mapped/ledger-side value))
:ledger-side/debit)
[:span.font-mono.tabular-nums.text-gray-900.text-sm.whitespace-nowrap
(format "$%,.2f" (fc/field-value (:ledger-mapped/amount value)))])))
(com/data-grid-cell {:class "text-right align-top"}
(defn account-edit-cell*
"The account typeahead + check (save) / cancel buttons. Each swaps just the
`.account-cell` back to the display cell."
[{:keys [index account-id client-id]}]
(str "<div class=\"account-cell flex flex-col gap-2\">"
(str (account-typeahead* {:name (item-field-name index :ledger-mapped/account)
:value account-id
:client-id client-id}))
"<div class=\"flex gap-1\">"
(str (sc/a-icon-button {:class "p-1"
:hx-put (bidi/path-for ssr-routes/only-routes ::route/save-item-account)
:hx-target "closest .account-cell"
:hx-swap "outerHTML"
:hx-include "closest .account-cell"
:hx-vals (hx/json {:item-index index :client-id client-id})}
svg/check))
(str (sc/a-icon-button {:class "p-1"
:hx-get (bidi/path-for ssr-routes/only-routes ::route/cancel-item-account)
:hx-target "closest .account-cell"
:hx-swap "outerHTML"
:hx-vals (hx/json {:item-index index
:client-id client-id
:current-account-id (or account-id "")})}
svg/x))
"</div></div>"))
(if manual?
(fc/with-field :credit
(com/validated-field {:errors (fc/field-errors)}
(com/money-input {:class "w-24 text-right font-mono tabular-nums"
:name (fc/field-name)
:value (fc/field-value)})))
(when (= (fc/field-value (:ledger-mapped/ledger-side value))
:ledger-side/credit)
[:span.font-mono.tabular-nums.text-gray-900.text-sm.whitespace-nowrap
(format "$%,.2f" (fc/field-value (:ledger-mapped/amount value)))])))
(com/data-grid-cell {:class "align-top"}
(when manual?
(com/a-icon-button {"@click.prevent.stop" "$refs.p.remove()"} svg/x))))))
(defn- auto-item-row*
"A read-only auto item in its Debits/Credits column: category + inline-editable account
cell + the (read-only) amount. Posts db/id, category, and account."
[index item client-id]
(let [side (item-side item)
amount (if (= side :debit) (:debit item) (:credit item))]
(str "<div class=\"flex items-center gap-2 text-sm\">"
(str (sc/hidden {:name (item-field-name index :db/id) :value (:db/id item)}))
(str (sc/hidden {:name (item-field-name index :sales-summary-item/category)
:value (:sales-summary-item/category item)}))
"<span class=\"text-gray-500 flex-1\">" (hu/escape-html (str (:sales-summary-item/category item))) "</span>"
(str (account-display-cell* {:index index
:account-id (:ledger-mapped/account item)
:client-id client-id}))
"<span class=\"ml-auto font-mono tabular-nums text-gray-900\">" (format "$%,.2f" (or amount 0.0)) "</span>"
"</div>")))
(defrecord MainStep [linear-wizard]
mm/ModalWizardStep
(step-name [_]
"Main")
(step-key [_]
:main)
(defn- manual-amount-input* [index field item]
(sc/money-input {:name (item-field-name index field)
:value (get item field)
:class "w-24 text-right font-mono tabular-nums"
:placeholder (str/capitalize (clojure.core/name field))
:hx-post (bidi/path-for ssr-routes/only-routes ::route/form-changed)
:hx-target "#summary-totals"
:hx-select "#summary-totals"
:hx-swap "outerHTML"
:hx-trigger "keyup changed delay:300ms"
:hx-include "closest form"}))
(edit-path [_ _]
[])
(step-schema [_]
(mut/select-keys (mm/form-schema linear-wizard) #{:db/id :sales-summary/items}))
(render-step
[this {:keys [multi-form-state] :as request}]
(let [client-id (:db/id (:sales-summary/client (:snapshot multi-form-state)))
items (:sales-summary/items (:step-params multi-form-state))
sorted-items (sort-items items)
indexed-items (map-indexed vector sorted-items)
debit-items (filter #(= :ledger-side/debit (:ledger-mapped/ledger-side (second %))) indexed-items)
credit-items (filter #(= :ledger-side/credit (:ledger-mapped/ledger-side (second %))) indexed-items)
max-rows (max (count debit-items) (count credit-items))
padded-debits (concat debit-items (repeat (- max-rows (count debit-items)) nil))
padded-credits (concat credit-items (repeat (- max-rows (count credit-items)) nil))]
(mm/default-render-step
linear-wizard this
:head [:div.p-2 "Edit Summary"]
:body (mm/default-step-body
{}
[:div
(fc/with-field :db/id
(com/hidden {:name (fc/field-name)
:value (fc/field-value)}))
[:div.grid.grid-cols-2.gap-6
[:div
[:div.font-semibold.text-sm.mb-2 "Debits"]
[:div.space-y-1
(for [[actual-idx item] padded-debits]
(if item
(let [manual? (:sales-summary-item/manual? item)]
(if manual?
[:div.flex.items-center.gap-2.text-sm {:x-ref "p" :x-data (hx/json {})}
(com/hidden {:name (str "step-params[sales-summary/items][" actual-idx "][db/id]")
:value (:db/id item)})
(com/hidden {:name (str "step-params[sales-summary/items][" actual-idx "][sales-summary-item/manual?]")
:value "true"})
(fc/start-form-with-prefix [(str "step-params[sales-summary/items][" actual-idx "]")]
item []
(fc/with-field :sales-summary-item/category
(com/text-input {:placeholder "Category"
:name (fc/field-name)
:value (fc/field-value)
:class "w-32 text-sm"})))
(account-typeahead* {:name (str "step-params[sales-summary/items][" actual-idx "][ledger-mapped/account]")
(defn- manual-item-row*
"An editable manual item: category + account typeahead + debit + credit money inputs +
remove. The amount inputs swap the totals block (Rule 4); remove swaps the whole form."
[index item client-id]
(str "<div class=\"manual-item-row flex items-center gap-2\">"
(str (sc/hidden {:name (item-field-name index :db/id) :value (:db/id item)}))
(str (sc/hidden {:name (item-field-name index :sales-summary-item/manual?) :value "true"}))
(str (sc/validated-field
{:errors (item-field-errors index :sales-summary-item/category) :class "flex-1"}
(sc/text-input {:name (item-field-name index :sales-summary-item/category)
:value (:sales-summary-item/category item)
:placeholder "Category/Explanation"})))
(str (sc/validated-field
{:errors (item-field-errors index :ledger-mapped/account)}
(account-typeahead* {:name (item-field-name index :ledger-mapped/account)
:value (:ledger-mapped/account item)
:client-id client-id})
(fc/start-form-with-prefix [(str "step-params[sales-summary/items][" actual-idx "]")]
item []
(fc/with-field :debit
(com/money-input {:class "w-24 text-right font-mono tabular-nums"
:name (fc/field-name)
:value (fc/field-value)})))
(com/a-icon-button {"@click.prevent.stop" "$refs.p.remove()"} svg/x)]
[:div.flex.items-center.gap-2.text-sm
(com/hidden {:name (str "step-params[sales-summary/items][" actual-idx "][db/id]")
:value (:db/id item)})
(com/hidden {:name (str "step-params[sales-summary/items][" actual-idx "][sales-summary-item/category]")
:value (:sales-summary-item/category item)})
[:span.text-gray-500 (truncate (:sales-summary-item/category item) 30)]
(account-display-cell {:item (assoc item :item-index actual-idx)
:field-name-prefix (str "step-params[sales-summary/items][" actual-idx "]")
:client-id client-id})
[:span.ml-auto.font-mono.tabular-nums.text-gray-900 (format "$%,.2f" (:ledger-mapped/amount item))]]))
[:div.h-6]))]
[:div.mt-2.border-t.pt-1
(summary-total-display request)
(unbalanced-display request)]]
[:div
[:div.font-semibold.text-sm.mb-2 "Credits"]
[:div.space-y-1
(for [[actual-idx item] padded-credits]
(if item
(let [manual? (:sales-summary-item/manual? item)]
(if manual?
[:div.flex.items-center.gap-2.text-sm {:x-ref "p" :x-data (hx/json {})}
(com/hidden {:name (str "step-params[sales-summary/items][" actual-idx "][db/id]")
:value (:db/id item)})
(com/hidden {:name (str "step-params[sales-summary/items][" actual-idx "][sales-summary-item/manual?]")
:value "true"})
(fc/start-form-with-prefix [(str "step-params[sales-summary/items][" actual-idx "]")]
item []
(fc/with-field :sales-summary-item/category
(com/text-input {:placeholder "Category"
:name (fc/field-name)
:value (fc/field-value)
:class "w-32 text-sm"})))
(account-typeahead* {:name (str "step-params[sales-summary/items][" actual-idx "][ledger-mapped/account]")
:value (:ledger-mapped/account item)
:client-id client-id})
(fc/start-form-with-prefix [(str "step-params[sales-summary/items][" actual-idx "]")]
item []
(fc/with-field :credit
(com/money-input {:class "w-24 text-right font-mono tabular-nums"
:name (fc/field-name)
:value (fc/field-value)})))
(com/a-icon-button {"@click.prevent.stop" "$refs.p.remove()"} svg/x)]
[:div.flex.items-center.gap-2.text-sm
(com/hidden {:name (str "step-params[sales-summary/items][" actual-idx "][db/id]")
:value (:db/id item)})
(com/hidden {:name (str "step-params[sales-summary/items][" actual-idx "][sales-summary-item/category]")
:value (:sales-summary-item/category item)})
[:span.text-gray-500 (truncate (:sales-summary-item/category item) 30)]
(account-display-cell {:item (assoc item :item-index actual-idx)
:field-name-prefix (str "step-params[sales-summary/items][" actual-idx "]")
:client-id client-id})
[:span.ml-auto.font-mono.tabular-nums.text-gray-900 (format "$%,.2f" (:ledger-mapped/amount item))]]))
[:div.h-6]))]
[:div.mt-2.border-t.pt-1
(summary-total-display request)
(unbalanced-display request)]]]
[:div.mt-4.border-t.pt-2
(fc/with-field :sales-summary/items
(com/data-grid-new-row {:colspan 2
:hx-get (bidi/path-for ssr-routes/only-routes ::route/new-summary-item)
:row-offset 0
:index (count (fc/field-value))
:tr-params {:hx-vals (hx/json {:client-id client-id})}}
"New Summary Item"))]])
:client-id client-id})))
(str (manual-amount-input* index :debit item))
(str (manual-amount-input* index :credit item))
(str (sc/a-icon-button {:class "p-1 account-remove-action"
:hx-post (bidi/path-for ssr-routes/only-routes ::route/form-changed)
:hx-vals (hx/json {:op "remove-item" :row-index index})
:hx-target "#summary-edit-form"
:hx-select "#summary-edit-form"
:hx-swap "outerHTML"
:hx-include "closest form"}
svg/x))
"</div>"))
:footer
(mm/default-step-footer linear-wizard this :validation-route ::route/edit-wizard-navigate)
:validation-route ::route/edit-wizard-navigate
:width-height-class "lg:w-[900px] lg:h-[600px]"))))
(defn- totals*
"Swappable totals + balance block (#summary-totals). Fixes the previously-dead total /
balance display: shows the running debit/credit totals and a Balanced / Unbalanced
indicator."
[items]
(let [td (sum-debits items)
tc (sum-credits items)
balanced? (dollars= td tc)
delta (- td tc)]
(str "<div class=\"border-t pt-2 mt-2 space-y-1\">"
"<div class=\"flex justify-between text-sm font-semibold\"><span>Total</span>"
"<div class=\"flex gap-8\"><span class=\"font-mono\">" (format "$%,.2f" td) "</span>"
"<span class=\"font-mono\">" (format "$%,.2f" tc) "</span></div></div>"
(if balanced?
"<div class=\"text-sm text-emerald-700 font-semibold\">Balanced</div>"
(str "<div class=\"text-sm text-red-600 font-semibold flex justify-between\"><span>Unbalanced</span>"
"<span class=\"font-mono\">" (format "$%,.2f" (Math/abs delta)) " "
(if (pos? delta) "Debit over" "Credit over") "</span></div>"))
"</div>")))
(defn- new-item-button* []
(sc/a-button {:hx-post (bidi/path-for ssr-routes/only-routes ::route/form-changed)
:hx-vals (hx/json {:op "new-item"})
:hx-target "#summary-edit-form"
:hx-select "#summary-edit-form"
:hx-swap "outerHTML"
:hx-include "closest form"
:color :secondary}
"New Summary Item"))
(defn- form-errors-html [errors]
(str "<div id=\"form-errors\">"
(when (seq errors)
(str "<span class=\"error-content\"><p class=\"mt-2 text-xs text-red-600 dark:text-red-500 h-4\">"
(str/join ", " (filter string? errors))
"</p></span>"))
"</div>"))
(defn- footer* [request]
(sel/raw
(str "<div class=\"flex justify-end\"><div class=\"flex items-baseline gap-x-4\">"
(form-errors-html (:errors (:form-errors request)))
(str (sc/button {:color :primary :x-ref "next" :class "w-32" :type "submit"} "Save"))
"</div></div>")))
(defn render-form
"Renders the whole plain sales-summary edit form (no wizard). Binds *errors* so the
field-level lookups (item-field-errors) resolve. Reuses the edit modal chrome."
[request]
(binding [*errors* (or (:form-errors request) {})]
(let [{tx-id :db/id client :sales-summary/client items :sales-summary/items} (:edit-state request)
client-id (:db/id client)
indexed (map-indexed vector items)
auto (remove (fn [[_ it]] (:sales-summary-item/manual? it)) indexed)
manual (filter (fn [[_ it]] (:sales-summary-item/manual? it)) indexed)
debit-rows (->> auto
(filter (fn [[_ it]] (= :debit (item-side it))))
(map (fn [[i it]] (auto-item-row* i it client-id)))
(apply str))
credit-rows (->> auto
(filter (fn [[_ it]] (= :credit (item-side it))))
(map (fn [[i it]] (auto-item-row* i it client-id)))
(apply str))
manual-rows (->> manual
(map (fn [[i it]] (manual-item-row* i it client-id)))
(apply str))
body (sel/render "templates/sales-summary/summary-body.html"
{:debit_rows debit-rows
:credit_rows credit-rows
:totals (totals* items)
:manual_rows manual-rows
:new_item_button (str (new-item-button*))})
modal-card (sel/render "templates/transaction-edit/edit-modal.html"
{:head "<div class=\"p-2\">Edit Summary</div>"
:side_panel nil
:body body
:footer (str (footer* request))})]
(sel/render->hiccup
"templates/sales-summary/edit-form.html"
{:db_id tx-id
:form_attrs (sc/attrs->str {:hx-ext "response-targets"
:hx-swap "outerHTML"
:hx-target-400 "#form-errors .error-content"
:hx-trigger "submit"
:hx-target "this"
:hx-put (bidi/path-for ssr-routes/only-routes ::route/edit-wizard-submit)})
:modal (str (sc/modal {:id "wizardmodal"} (sel/raw modal-card)))}))))
;; ---------------------------------------------------------------------------
;; State: derive the flat edit-state from the entity overlaid with the posted
;; form (replaces MultiStepFormState + the EDN snapshot round-trip).
;; ---------------------------------------------------------------------------
(defn entity->edit-state
"The persisted sales summary, shaped like the form's state: each item gets a :credit or
:debit field derived from its ledger-side/amount (what initial-edit-wizard-state did)."
[tx-id]
(let [e (dc/pull (dc/db conn) default-read tx-id)
items (->> (:sales-summary/items e)
sort-items
(mapv (fn [x]
(if (= :ledger-side/debit (:ledger-mapped/ledger-side x))
(assoc x :debit (:ledger-mapped/amount x))
(assoc x :credit (:ledger-mapped/amount x))))))]
{:db/id (:db/id e)
:sales-summary/client (:sales-summary/client e)
:sales-summary/items items}))
(defn- merge-items
"Overlay the posted items onto the persisted items by :db/id, so read-only fields the
form doesn't post (ledger-side, amount, the credit/debit shaping for auto items)
survive while edited fields (category, account, manual credit/debit) win. New manual
rows (temp db/id) have no persisted match and ride through as-is."
[entity-items posted-items]
(let [by-id (into {} (map (juxt :db/id identity)) entity-items)]
(mapv (fn [pi] (merge (get by-id (:db/id pi)) pi)) posted-items)))
(defn wrap-decode
"Parses the posted (nested) form params and decodes them straight into edit-schema --
no step-params[...] prefix. Strips to the editable top-level keys."
[handler]
(-> (fn [request]
(let [decoded (mc/decode edit-schema (:form-params request) main-transformer)
decoded (if (map? decoded) (select-keys decoded [:db/id :sales-summary/items]) {})]
(handler (assoc request :posted decoded))))
(wrap-nested-form-params)))
(defn wrap-derive-state
"Builds :edit-state from the entity (db/id hidden, or the route on initial open) overlaid
with the live posted items -- no serialized snapshot. db/id + client always come from
the entity; items are the merged posted items when present, else the entity's."
[handler]
(fn [request]
(let [tx-id (->db-id (or (some-> request :form-params (get "db/id"))
(-> request :route-params :db/id)))
base (entity->edit-state tx-id)
posted (:posted request)
items (if (contains? posted :sales-summary/items)
(merge-items (:sales-summary/items base) (:sales-summary/items posted))
(:sales-summary/items base))]
(handler (assoc request :edit-state (assoc base :sales-summary/items items))))))
(defn attach-ledger [i]
(cond-> i
@@ -645,132 +600,118 @@
:ledger-mapped/amount (:credit i))
(:debit i) (assoc :ledger-mapped/ledger-side :ledger-side/debit
:ledger-mapped/amount (:debit i))
true (dissoc :credit :debit)
true (dissoc :credit :debit :new? :item-index)
true (assoc :sales-summary-item/manual? true)))
(defrecord EditWizard [_ current-step]
mm/LinearModalWizard
(hydrate-from-request
[this request]
this)
(navigate [this step-key]
(assoc this :current-step step-key))
(get-current-step
[this]
(mm/get-step this :main))
(render-wizard [this {:keys [multi-form-state] :as request}]
(mm/default-render-wizard
this request
:form-params
(-> mm/default-form-props
(assoc :hx-put
(str (bidi/path-for ssr-routes/only-routes ::route/edit-wizard-submit))))
:render-timeline? false))
(steps [_]
[:main])
(get-step [this step-key]
(let [step-key-result (mc/parse mm/step-key-schema step-key)
[step-key-type step-key] step-key-result]
(->MainStep this)))
(form-schema [_]
edit-schema)
(submit [this {:keys [multi-form-state request-method identity] :as request}]
(let [result (:snapshot multi-form-state)
transaction [:upsert-sales-summary {:db/id (:db/id result)
:sales-summary/items (map
(fn [i]
;; ---------------------------------------------------------------------------
;; form-changed ops (whole-form re-render): add / remove a manual item. A bare
;; re-render (no op) refreshes the totals block (manual amount edits).
;; ---------------------------------------------------------------------------
(defn apply-new-item [request]
(let [items (vec (:sales-summary/items (:edit-state request)))
new-item {:db/id (str (java.util.UUID/randomUUID))
:new? true
:sales-summary-item/manual? true
:sales-summary-item/category ""}]
(assoc-in request [:edit-state :sales-summary/items] (conj items new-item))))
(defn apply-remove-item [request]
(let [row-index (some-> request :form-params (get "row-index") Integer/parseInt)
items (vec (:sales-summary/items (:edit-state request)))
updated (if (and row-index (< row-index (count items)))
(vec (concat (subvec items 0 row-index)
(subvec items (inc row-index))))
items)]
(assoc-in request [:edit-state :sales-summary/items] updated)))
(defn form-changed-handler
"Single whole-form re-render endpoint. Dispatches on `op` (add/remove a manual item);
a missing op (a manual amount keyup) just re-renders (hx-select picks #summary-totals)."
[request]
(let [op (get-in request [:form-params "op"])
request' (case op
"new-item" (apply-new-item request)
"remove-item" (apply-remove-item request)
request)]
(html-response (render-form request'))))
;; ---------------------------------------------------------------------------
;; Inline account editor (targeted .account-cell swaps -- a distinct click-to-edit
;; feature, kept as its own three small stateless routes).
;; ---------------------------------------------------------------------------
(defn edit-item-account [request]
(let [{:keys [item-index client-id current-account-id]} (:query-params request)
idx (if (string? item-index) (Integer/parseInt item-index) item-index)
account-id (when (and current-account-id (not= current-account-id "")) (->db-id current-account-id))]
(html-response
(sel/raw (account-edit-cell* {:index idx :account-id account-id :client-id (->db-id client-id)})))))
(defn save-item-account [request]
(let [item-index (get-in request [:params "item-index"])
idx (if (string? item-index) (Integer/parseInt item-index) item-index)
client-id (->db-id (get-in request [:params "client-id"]))
account-id-str (get-in request [:form-params (item-field-name idx :ledger-mapped/account)])
account-id (when (and account-id-str (not= account-id-str "")) (->db-id account-id-str))]
(html-response
(sel/raw (account-display-cell* {:index idx :account-id account-id :client-id client-id})))))
(defn cancel-item-account [request]
(let [{:keys [item-index client-id current-account-id]} (:query-params request)
idx (if (string? item-index) (Integer/parseInt item-index) item-index)
account-id (when (and current-account-id (not= current-account-id "")) (->db-id current-account-id))]
(html-response
(sel/raw (account-display-cell* {:index idx :account-id account-id :client-id (->db-id client-id)})))))
;; ---------------------------------------------------------------------------
;; Open + submit
;; ---------------------------------------------------------------------------
(defn open-handler [request]
(modal-response
(sel/render->hiccup "templates/transaction-edit/transitioner.html"
{:body (str (render-form request))})))
(defn- render-form-response [request]
(html-response (render-form request)
:headers {"HX-reswap" "outerHTML"}))
(defn submit
"Validates the posted edit-state against edit-schema (field errors via wrap-form-4xx-2),
then upserts the sales summary: manual items attach-ledger (credit/debit -> ledger
side+amount), auto items update only their account."
[request]
(let [{tx-id :db/id items :sales-summary/items :as edit-state} (:edit-state request)]
(assert-schema edit-schema edit-state)
(let [transaction [:upsert-sales-summary
{:db/id tx-id
:sales-summary/items (map (fn [i]
(if (:sales-summary-item/manual? i)
(attach-ledger i)
{:db/id (:db/id i)
:ledger-mapped/account (:ledger-mapped/account i)}))
(:sales-summary/items result))}]]
items)}]]
@(dc/transact conn [transaction])
(html-response
(row* identity (dc/pull (dc/db conn) default-read (:db/id result))
(row* (:identity request) (dc/pull (dc/db conn) default-read tx-id)
{:flash? true
:request request})
:headers (cond-> {"hx-trigger" "modalclose"
"hx-retarget" (format "#entity-table tr[data-id=\"%d\"]" (:db/id result))
"hx-reswap" "outerHTML"})))))
(def edit-wizard (->EditWizard nil nil))
(defn initial-edit-wizard-state [request]
(let [entity (dc/pull (dc/db conn) default-read (:db/id (:route-params request)))
entity (select-keys entity (mut/keys edit-schema))
entity (update entity :sales-summary/items (comp #(map (fn [x]
(if (= :ledger-side/debit (:ledger-mapped/ledger-side x))
(assoc x :debit (:ledger-mapped/amount x))
(assoc x :credit (:ledger-mapped/amount x))))
%) sort-items))]
(mm/->MultiStepFormState entity [] entity)))
(defn edit-item-account [request]
(let [{:keys [item-index client-id current-account-id]} (:query-params request)
item-index (if (string? item-index) (Integer/parseInt item-index) item-index)
field-name-prefix (str "step-params[sales-summary/items][" item-index "]")
current-account-id (when (and current-account-id (not= current-account-id ""))
(if (string? current-account-id)
(Long/parseLong current-account-id)
current-account-id))
client-id (if (string? client-id) (Long/parseLong client-id) client-id)]
(html-response
(account-edit-cell {:field-name-prefix field-name-prefix
:client-id client-id
:current-account-id current-account-id}))))
(defn save-item-account [request]
(let [field-name-prefix (get-in request [:params "field-name-prefix"])
client-id (get-in request [:params "client-id"])
account-input-name (str field-name-prefix "[ledger-mapped/account]")
account-id-str (get-in request [:form-params account-input-name])
account-id (when (and account-id-str (not= account-id-str ""))
(Long/parseLong account-id-str))
item {:ledger-mapped/account account-id
:item-index (second (re-find #"\[(\d+)\]" (or field-name-prefix "")))}
client-id (if (string? client-id) (Long/parseLong client-id) client-id)]
(html-response
(account-display-cell {:item item
:field-name-prefix field-name-prefix
:client-id client-id}))))
(defn cancel-item-account [request]
(let [{:keys [field-name-prefix client-id current-account-id]} (:query-params request)
account-id (when (and current-account-id (not= current-account-id ""))
(if (string? current-account-id)
(Long/parseLong current-account-id)
current-account-id))
item {:ledger-mapped/account account-id
:item-index (second (re-find #"\[(\d+)\]" (or field-name-prefix "")))}
client-id (if (string? client-id) (Long/parseLong client-id) client-id)]
(html-response
(account-display-cell {:item item
:field-name-prefix field-name-prefix
:client-id client-id}))))
:headers {"hx-trigger" "modalclose"
"hx-retarget" (format "#entity-table tr[data-id=\"%d\"]" tx-id)
"hx-reswap" "outerHTML"}))))
(def key->handler
(apply-middleware-to-all-handlers
(->>
{::route/page (helper/page-route grid-page)
::route/table (helper/table-route grid-page)
::route/edit-wizard (-> mm/open-wizard-handler
(mm/wrap-wizard edit-wizard)
(mm/wrap-init-multi-form-state initial-edit-wizard-state)
::route/edit-wizard (-> open-handler
(wrap-derive-state)
(wrap-decode)
(wrap-schema-enforce :route-schema [:map [:db/id entity-id]]))
::route/edit-wizard-navigate (-> mm/next-handler
(mm/wrap-wizard edit-wizard)
(mm/wrap-decode-multi-form-state))
::route/new-summary-item (-> (add-new-entity-handler [:step-params :sales-summary/items]
(fn render [cursor request]
(sales-summary-item-row*
{:value cursor
:client-id (:client-id (:query-params request))}))
(fn build-new-row [base _]
(assoc base :sales-summary-item/manual? true)))
(wrap-schema-enforce :query-schema [:map
[:client-id {:optional true}
[:maybe entity-id]]]))
::route/form-changed (-> form-changed-handler
(wrap-derive-state)
(wrap-decode))
::route/edit-item-account (-> edit-item-account
(wrap-schema-enforce :query-schema [:map
[:item-index nat-int?]
@@ -778,9 +719,10 @@
[:current-account-id {:optional true} [:maybe :string]]]))
::route/save-item-account save-item-account
::route/cancel-item-account cancel-item-account
::route/edit-wizard-submit (-> mm/submit-handler
(mm/wrap-wizard edit-wizard)
(mm/wrap-decode-multi-form-state))})
::route/edit-wizard-submit (-> submit
(wrap-form-4xx-2 render-form-response)
(wrap-derive-state)
(wrap-decode))}
(fn [h]
(-> h
(wrap-copy-qp-pqp)

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

@@ -10,86 +10,74 @@
[auto-ap.routes.utils :refer [wrap-client-redirect-unauthenticated]]
[auto-ap.rule-matching :as rm]
[auto-ap.ssr-routes :as ssr-routes]
[auto-ap.ssr.common-handlers :refer [add-new-entity-handler]]
[auto-ap.ssr.components :as com]
[auto-ap.ssr.components.multi-modal :as mm :refer [wrap-wizard]]
[auto-ap.ssr.form-cursor :as fc]
[auto-ap.ssr.components.selmer :as sc]
[auto-ap.ssr.grid-page-helper :refer [wrap-apply-sort]]
[auto-ap.ssr.hx :as hx]
[auto-ap.ssr.ledger :refer [wrap-ensure-bank-account-belongs]]
[auto-ap.ssr.svg :as svg]
[auto-ap.ssr.nested-form-params :refer [wrap-nested-form-params]]
[auto-ap.ssr.selmer :as sel]
[auto-ap.ssr.transaction.common :refer [grid-page query-schema
selected->ids
wrap-status-from-source]]
[auto-ap.ssr.transaction.edit :as edit :refer [account-typeahead*
location-select*]]
[auto-ap.ssr.utils
:refer [apply-middleware-to-all-handlers entity-id
form-validation-error html-response percentage
ref->enum-schema wrap-merge-prior-hx wrap-schema-enforce]]
:refer [->db-id apply-middleware-to-all-handlers assert-schema entity-id
form-validation-error html-response main-transformer modal-response
path->name2 percentage ref->enum-schema wrap-form-4xx-2
wrap-merge-prior-hx wrap-schema-enforce]]
[bidi.bidi :as bidi]
[clojure.string :as str]
[datomic.api :as dc]
[iol-ion.query :refer [dollars=]]
[iol-ion.tx :refer [random-tempid]]
[malli.core :as mc]))
(defn transaction-account-row* [{:keys [value client-id]}]
(com/data-grid-row
(-> {:x-data (hx/json {:show (boolean (not (fc/field-value (:new? value))))
:accountId (fc/field-value (: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 :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 :location
(com/data-grid-cell
{}
(com/validated-field
{:errors (fc/field-errors)
:x-hx-val:account-id "accountId"
:hx-vals (hx/json (cond-> {:name (fc/field-name)}
client-id (assoc :client-id client-id)))
:x-dispatch:changed "accountId"
:hx-trigger "changed"
:hx-get (bidi/path-for ssr-routes/only-routes ::route/location-select)
:hx-target "find *"
:hx-swap "outerHTML"}
(location-select* {:name (fc/field-name)
:account-location (let [account-id (:account @value)]
(when (nat-int? account-id)
(:account/location (dc/pull (dc/db conn) '[:account/location] account-id))))
:client-locations (pull-attr (dc/db conn) :client/locations client-id)
:value (fc/field-value)}))))
(fc/with-field :percentage
(com/data-grid-cell
{}
(com/validated-field
{:errors (fc/field-errors)}
(com/money-input {:name (fc/field-name)
:class "w-16"
:value (some-> (fc/field-value)
(* 100)
(long))}))))
(com/data-grid-cell {:class "align-top"}
(com/a-icon-button {"@click.prevent.stop" "show=false; setTimeout(() => $refs.p.remove(), 500)"} svg/x))))
;; ---------------------------------------------------------------------------
;; Field-name / error helpers (no step-params[...] prefix -- posted fields
;; decode straight into bulk-code-schema, mirroring transaction/edit.clj).
;; ---------------------------------------------------------------------------
(defn initial-bulk-edit-state [request]
(mm/->MultiStepFormState {:search-params (:query-params request)
:accounts []}
[]
{:search-params (:query-params request)
:accounts []}))
(def ^:dynamic *errors*
"Humanized form errors for the current render, keyed by bulk-code-schema paths
(e.g. {:accounts {0 {:location [\"required\"]}}}). Bound by render-form from the
request's :form-errors. Plain map -- no wizard, no cursor."
{})
(defn- ferr
"Field errors at a schema path, read from *errors* (no step-params prefix)."
[& path]
(get-in *errors* (vec path)))
(defn- account-field-name [index field]
(path->name2 :accounts index field))
(defn- account-field-errors [index field]
(ferr :accounts index field))
;; ---------------------------------------------------------------------------
;; Schema + decode
;; ---------------------------------------------------------------------------
(def bulk-code-schema
(mc/schema [:map
[:vendor {:optional true} [:maybe entity-id]]
[:approval-status {:optional true} [:maybe (ref->enum-schema "transaction-approval-status")]]
[:ids {:optional true} [:maybe [:vector {:coerce? true} entity-id]]]
[:accounts {:optional true}
[:maybe
[:vector {:coerce? true}
[:map
[:db/id {:optional true} [:maybe :string]]
[:account entity-id]
[:location [:string {:min 1 :error/message "required"}]]
[:percentage percentage]]]]]]))
(def ^:private bulk-code-form-keys
"Editable top-level keys (vendor/status/accounts). The transaction selection (:ids)
is non-editable -- it is threaded separately by wrap-bulk-state."
[:vendor :approval-status :accounts])
(defn all-ids-not-locked
"Filters transaction IDs to only those that aren't locked (client locked date earlier than transaction date)"
@@ -105,16 +93,281 @@
(dc/db conn))
(map first)))
(def bulk-code-schema
(mc/schema [:map
[:vendor {:optional true} [:maybe entity-id]]
[:approval-status {:optional true} [:maybe (ref->enum-schema "transaction-approval-status")]]
[:accounts {:optional true}
[:maybe
[:vector {:coerce? true}
[:map [:account entity-id]
[:location [:string {:min 1 :error/message "required"}]]
[:percentage percentage]]]]]]))
(defn wrap-bulk-state
"Replaces the wizard's MultiStepFormState/snapshot round-trip. Parses the posted
(nested) form params, decodes them straight into bulk-code-schema, and resolves the
target transaction id set. On open (GET) the selection comes from the grid's
query-params (selected / all-selected + filters); on every post the concrete
(not-locked) id list rides back in hidden ids[] fields, so no EDN snapshot / filter
round-trip is needed -- and we code exactly the transactions the user saw."
[handler]
(-> (fn [request]
(let [parsed (:form-params request)
decoded (mc/decode bulk-code-schema parsed main-transformer)
decoded (if (map? decoded) decoded {})
posted-ids (some->> (:ids decoded) (keep ->db-id) vec)
ids (if (seq posted-ids)
posted-ids
(vec (all-ids-not-locked (selected->ids request (:query-params request)))))]
(handler (assoc request :bulk-state (assoc (select-keys decoded bulk-code-form-keys) :ids ids)))))
(wrap-nested-form-params)))
(defn- single-client-id
"Returns the client ID if the user has access to exactly one client, nil otherwise."
[request]
(when (= 1 (count (:clients request)))
(-> request :clients first :db/id)))
;; ---------------------------------------------------------------------------
;; Render (100% Selmer -- reuses the transaction/edit.clj sc/* component library
;; and the shared edit-modal / transitioner chrome).
;; ---------------------------------------------------------------------------
(defn transaction-account-row*
"One row of the bulk-code account grid, from a plain account map (no cursor). The
location cell swaps just itself (#account-location-<index>, Rule 2); remove swaps the
whole #bulk-code-form (Rule 3). Percentage rides along to submit (Rule 1, no request)."
[{:keys [value client-id index]}]
(let [account-val (let [av (:account value)]
(if (map? av) (:db/id av) av))
location-attrs {:x-hx-val:account-id "accountId"
:hx-vals (hx/json (cond-> {:name (account-field-name index :location)}
client-id (assoc :client-id client-id)))
:x-dispatch:changed "accountId"
:hx-trigger "changed"
:hx-post (bidi/path-for ssr-routes/only-routes ::route/bulk-code-form-changed)
:hx-target (str "#account-location-" index)
:hx-select (str "#account-location-" index)
:hx-swap "outerHTML"
:hx-include "closest form"}]
(sc/data-grid-row
(-> {:class "account-row"
:id (str "account-row-" index)
:x-data (hx/json {:show (boolean (not (:new? value)))
:accountId account-val})
:data-key "show"
:x-ref "p"}
hx/alpine-mount-then-appear)
(sc/hidden {:name (account-field-name index :db/id)
:value (:db/id value)})
(sc/data-grid-cell
{}
(sc/validated-field
{:errors (account-field-errors index :account)}
(account-typeahead* {:value account-val
:client-id client-id
:name (account-field-name index :account)
:x-model "accountId"})))
(sc/data-grid-cell
{:id (str "account-location-" index)}
(sc/validated-field
(merge {:errors (account-field-errors index :location)}
location-attrs)
(location-select* {:name (account-field-name index :location)
:account-location (:account/location (when (nat-int? account-val)
(dc/pull (dc/db conn) '[:account/location] account-val)))
:client-locations (pull-attr (dc/db conn) :client/locations client-id)
:value (:location value)})))
(sc/data-grid-cell
{}
(sc/validated-field
{:errors (account-field-errors index :percentage)}
(sc/money-input {:name (account-field-name index :percentage)
:class "w-16"
:value (some-> (:percentage value) (* 100) long)})))
(sc/data-grid-cell
{:class "align-top"}
(sc/a-icon-button {:hx-post (bidi/path-for ssr-routes/only-routes ::route/bulk-code-form-changed)
:hx-vals (hx/json {:op "remove-account" :row-index (or index 0)})
:hx-target "#bulk-code-form"
:hx-select "#bulk-code-form"
:hx-swap "outerHTML"
:hx-include "closest form"
:class "account-remove-action"}
(sc/render "templates/components/svg-x.html" {}))))))
(defn- account-grid* [request]
(let [client-id (single-client-id request)
accounts (vec (:accounts (:bulk-state request)))]
(apply
sc/data-grid
{:headers [(sc/data-grid-header {} "Account")
(sc/data-grid-header {:class "w-32"} "Location")
(sc/data-grid-header {:class "w-16"} "%")
(sc/data-grid-header {:class "w-16"})]}
(concat
(map-indexed
(fn [index account]
(transaction-account-row* {:value account
:client-id client-id
:index index}))
accounts)
[(sc/data-grid-row
{:class "new-row"}
(sc/data-grid-cell {:colspan 4}
(sc/a-button {:hx-post (bidi/path-for ssr-routes/only-routes ::route/bulk-code-form-changed)
:hx-vals (hx/json {:op "new-account"})
:hx-target "#bulk-code-form"
:hx-select "#bulk-code-form"
:hx-swap "outerHTML"
:hx-include "closest form"
:color :secondary}
"New account")))]))))
(defn- bulk-code-body* [request]
(let [bulk-state (:bulk-state request)
vendor-val (:vendor bulk-state)
status-val (some-> (:approval-status bulk-state) name)]
(sel/render->hiccup
"templates/transaction-bulk-code/bulk-code-body.html"
{:vendor_changed_attrs (sc/attrs->str {:hx-trigger "change"
:hx-post (bidi/path-for ssr-routes/only-routes ::route/bulk-code-form-changed)
:hx-vals (hx/json {:op "vendor-changed"})
:hx-target "#bulk-code-form"
:hx-select "#bulk-code-form"
:hx-swap "outerHTML"
:hx-sync "this:replace"
:hx-include "closest form"})
:vendor_field (str (sc/validated-field
{:label "Vendor" :errors (ferr :vendor)}
(sc/typeahead {:name (path->name2 :vendor)
:id (path->name2 :vendor)
:error? (boolean (seq (ferr :vendor)))
:class "w-96"
:placeholder "Search for vendor..."
:url (bidi/path-for ssr-routes/only-routes :vendor-search)
:value vendor-val
:content-fn (fn [c] (pull-attr (dc/db conn) :vendor/name c))})))
:status_field (str (sc/validated-field
{:label "Status" :errors (ferr :approval-status)}
(sc/select {:name (path->name2 :approval-status)
:value status-val
:options [["" "No Change"]
["approved" "Approved"]
["unapproved" "Unapproved"]
["suppressed" "Suppressed"]
["requires-feedback" "Client Review"]]})))
:accounts_field (str (sc/validated-field
{:errors (ferr :accounts)}
(sel/raw (str "<div id=\"account-entries\" class=\"space-y-3\">"
(str (account-grid* request))
"</div>"))))})))
(defn- form-errors-html [errors]
(str "<div id=\"form-errors\">"
(when (seq errors)
(str "<span class=\"error-content\"><p class=\"mt-2 text-xs text-red-600 dark:text-red-500 h-4\">"
(str/join ", " (filter string? errors))
"</p></span>"))
"</div>"))
(defn- footer* [request]
(sel/raw
(str "<div class=\"flex justify-end\"><div class=\"flex items-baseline gap-x-4\">"
(form-errors-html (:errors (:form-errors request)))
(str (sc/button {:color :primary :x-ref "next" :class "w-32 wizard-save-action"} "Save"))
"</div></div>")))
(defn render-form
"Renders the whole plain bulk-code form (no wizard). Binds *errors* from the request's
:form-errors so the field-level error lookups (ferr) resolve. Reuses the edit modal's
chrome (edit-modal.html), with no side panel."
[request]
(binding [*errors* (or (:form-errors request) {})]
(let [ids (:ids (:bulk-state request))
ids-hidden (apply str
(map-indexed (fn [i id]
(str (sc/hidden {:name (path->name2 :ids i) :value id})))
ids))
modal-card (sel/render "templates/transaction-edit/edit-modal.html"
{:head (str "<div class=\"p-2\">Bulk editing " (count ids) " transactions</div>")
:side_panel nil
:body (str (bulk-code-body* request))
:footer (str (footer* request))})]
(sel/render->hiccup
"templates/transaction-bulk-code/bulk-code-form.html"
{:ids_hidden ids-hidden
:form_attrs (sc/attrs->str {:hx-ext "response-targets"
:hx-swap "outerHTML"
:hx-target-400 "#form-errors .error-content"
:hx-trigger "submit"
:hx-target "this"
:hx-post (bidi/path-for ssr-routes/only-routes ::route/bulk-code-submit)})
:modal (str (sc/modal {:id "bulkcodemodal"} (sel/raw modal-card)))}))))
;; ---------------------------------------------------------------------------
;; edit-form-changed ops (whole-form re-render). Replaces the per-operation
;; bulk-code-new-account / bulk-code-vendor-changed routes.
;; ---------------------------------------------------------------------------
(defn- vendor-default-account
"Returns the vendor's standard default account. For single-client contexts,
the account name is clientized (tailored to the customer). For multi-client
contexts, the raw account name is used."
[vendor-id client-id]
(when vendor-id
(let [vendor (edit/get-vendor vendor-id)
account (:vendor/default-account vendor)]
(if client-id
(d-accounts/clientize account client-id)
account))))
(defn- build-default-account-row [account]
{:db/id (str (java.util.UUID/randomUUID))
:account (:db/id account)
:location (or (:account/location account) "Shared")
:percentage 1.0})
(defn apply-vendor-changed
"bulk-code-form-changed op: when the accounts are empty and a vendor with a default
account is chosen, pre-populate a single 100% default-account row."
[request]
(let [bulk-state (:bulk-state request)
client-id (single-client-id request)
vendor-id (->db-id (:vendor bulk-state))
accounts (:accounts bulk-state)]
(if (and (empty? accounts) vendor-id)
(if-let [default-account (vendor-default-account vendor-id client-id)]
(assoc-in request [:bulk-state :accounts] [(build-default-account-row default-account)])
request)
request)))
(defn apply-new-account
"bulk-code-form-changed op: append a fresh (blank, Shared) account row."
[request]
(let [accounts (vec (:accounts (:bulk-state request)))
new-account {:db/id (str (java.util.UUID/randomUUID))
:new? true
:location "Shared"}]
(assoc-in request [:bulk-state :accounts] (conj accounts new-account))))
(defn apply-remove-account
"bulk-code-form-changed op: remove the account row at form-param row-index."
[request]
(let [row-index (some-> request :form-params (get "row-index") Integer/parseInt)
accounts (vec (:accounts (:bulk-state request)))
updated-accounts (if (and row-index (< row-index (count accounts)))
(vec (concat (subvec accounts 0 row-index)
(subvec accounts (inc row-index))))
accounts)]
(assoc-in request [:bulk-state :accounts] updated-accounts)))
(defn bulk-code-form-changed-handler
"Single whole-form re-render endpoint. Dispatches on the `op` form-param (vendor
change, add/remove row), then re-renders the whole form. A missing/unknown op (e.g.
an account selection driving the location swap) just re-renders."
[request]
(let [op (get-in request [:form-params "op"])
request' (case op
"vendor-changed" (apply-vendor-changed request)
"new-account" (apply-new-account request)
"remove-account" (apply-remove-account request)
request)]
(html-response (render-form request'))))
;; ---------------------------------------------------------------------------
;; Submit
;; ---------------------------------------------------------------------------
(defn maybe-code-accounts [transaction account-rules valid-locations]
(with-precision 2
@@ -151,137 +404,22 @@
[])]
accounts)))
(defrecord AccountsStep [linear-wizard]
mm/ModalWizardStep
(step-name [_]
"Bulk Code")
(step-key [_]
:accounts)
(edit-path [_ _]
[])
(step-schema [_]
(mm/form-schema linear-wizard))
(render-step [this {{:keys [snapshot] :as multi-form-state} :multi-form-state :as request}]
(let [_ (alog/peek ::SEARCH_PARAMS (:search-params snapshot))
selected-ids (selected->ids (assoc request :query-params (:search-params snapshot)) (:search-params snapshot))
all-ids (all-ids-not-locked selected-ids)]
(mm/default-render-step
linear-wizard this
:head [:div.p-2 "Bulk editing " (count all-ids) " transactions"]
:body (mm/default-step-body
{}
[:div
#_(com/hidden {:name "ids" :value (pr-str ids)})
[:div.space-y-4.p-4
[:div.grid.grid-cols-2.gap-4
;; Vendor field
[:div {:hx-trigger "change"
:hx-post (bidi/path-for ssr-routes/only-routes ::route/bulk-code-vendor-changed)
:hx-target "#account-entries"
:hx-swap "innerHTML"
:hx-include "closest form"}
(fc/with-field :vendor
(com/validated-field {:label "Vendor"
:errors (fc/field-errors)}
(com/typeahead {:name (fc/field-name)
:placeholder "Search for vendor..."
: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))})))]
;; Status field
[:div
(fc/with-field :approval-status
(com/validated-field {:label "Status"
:errors (fc/field-errors)}
(com/select {:name (fc/field-name)
:value (some-> (fc/field-value)
name)
:options [["" "No Change"]
["approved" "Approved"]
["unapproved" "Unapproved"]
["suppressed" "Suppressed"]
["requires_feedback" "Requires Feedback"]]})))]
;; Accounts section
[:div.col-span-2.pt-4
[:h3.text-lg.font-medium.mb-3 "Expense Accounts"]
[:div#account-entries.space-y-3
(fc/with-field :accounts
(com/validated-field
{:errors (fc/field-errors)}
(com/data-grid {:headers [(com/data-grid-header {} "Account")
(com/data-grid-header {:class "w-32"} "Location")
(com/data-grid-header {:class "w-16"} "%")
(com/data-grid-header {:class "w-16"})]}
(fc/cursor-map #(transaction-account-row* {:value %}))
(com/data-grid-new-row {:colspan 4
:hx-get (bidi/path-for ssr-routes/only-routes
::route/bulk-code-new-account)
:row-offset 0
:index (count (fc/field-value))}
"New account"))))]]]]])
;; Button to add more accounts
:footer
(mm/default-step-footer linear-wizard this :validation-route ::route/new-wizard-navigate
:next-button (com/button {:color :primary :x-ref "next" :class "w-32"} "Save"))
:validation-route ::route/new-wizard-navigate))))
(defn assert-percentages-add-up [{:keys [accounts]}]
(let [account-total (reduce + 0 (map (fn [x] (:percentage x)) accounts))]
(when-not (dollars= 1.0 account-total)
(form-validation-error (str "Expense account total (" account-total ") does not equal 100%")))))
(defrecord BulkCodeWizard [_ current-step]
mm/LinearModalWizard
(hydrate-from-request
[this request]
this)
(navigate [this step-key]
(assoc this :current-step step-key))
(get-current-step [this]
(if current-step
(mm/get-step this current-step)
(mm/get-step this :accounts)))
(render-wizard [this {:keys [multi-form-state] :as request}]
(mm/default-render-wizard
this request
:form-params
(-> mm/default-form-props
(assoc :hx-put
(str (bidi/path-for ssr-routes/only-routes ::route/bulk-code-submit))))
:render-timeline? false))
(steps [_]
[:accounts])
(get-step [this step-key]
(let [step-key-result (mc/parse mm/step-key-schema step-key)
[step-key-type step-key] step-key-result]
(get {:accounts (->AccountsStep this)}
step-key)))
(form-schema [_]
bulk-code-schema)
(submit [this {:keys [multi-form-state request-method identity] :as request}]
(let [ids (selected->ids (assoc request :query-params (:search-params (:snapshot multi-form-state))) (:search-params (:snapshot multi-form-state)))
all-ids (all-ids-not-locked ids)
vendor (-> request :multi-form-state :snapshot :vendor)
approval-status (-> request :multi-form-state :snapshot :approval-status)
accounts (-> request :multi-form-state :snapshot :accounts)]
(defn submit
"Validates the posted bulk-code form (schema field errors via wrap-form-4xx-2, then the
percentage-sum and per-account location checks as form errors), then applies the chosen
vendor / status / account-coding across every selected (not-locked) transaction."
[request]
(let [{:keys [ids vendor approval-status accounts]} (:bulk-state request)]
(assert-schema bulk-code-schema (select-keys (:bulk-state request) bulk-code-form-keys))
(when (seq accounts)
(assert-percentages-add-up (:snapshot multi-form-state)))
(alog/peek ::ACCOUNTS (-> request :multi-form-state :snapshot))
;; Get transactions and filter for locked ones
(assert-percentages-add-up {:accounts accounts}))
(let [db (dc/db conn)
transactions (pull-many db [:db/id :transaction/amount {:transaction/client [:db/id]}] (vec all-ids))
transactions (pull-many db [:db/id :transaction/amount {:transaction/client [:db/id]}] (vec ids))
;; Get client locations
client->locations (->> (map (comp :db/id :transaction/client) transactions)
@@ -325,89 +463,36 @@
;; Return success modal
(html-response
(com/success-modal {:title "Transactions Coded"}
[:p (str "Successfully coded " (count all-ids) " transactions.")])
:headers {"hx-trigger" "refreshTable"})))))
[:p (str "Successfully coded " (count ids) " transactions.")])
:headers {"hx-trigger" "refreshTable, reset-selection"}))))
(defn- vendor-default-account [vendor-id client-id]
"Returns the vendor's standard default account. For single-client contexts,
the account name is clientized (tailored to the customer). For multi-client
contexts, the raw account name is used."
(when vendor-id
(let [vendor (edit/get-vendor vendor-id)
account (:vendor/default-account vendor)]
(if client-id
(d-accounts/clientize account client-id)
account))))
;; ---------------------------------------------------------------------------
;; Handlers + routes
;; ---------------------------------------------------------------------------
(defn- build-default-account-row [account]
{:db/id (str (java.util.UUID/randomUUID))
:account (:db/id account)
:location (or (:account/location account) "Shared")
:percentage 1.0})
(defn open-handler
"Initial modal open (GET). Wraps the rendered form in the #transitioner shell expected
by the modal stack (reuses the edit modal's transitioner)."
[request]
(modal-response
(sel/render->hiccup "templates/transaction-edit/transitioner.html"
{:body (str (render-form request))})))
(defn- render-accounts-section [request]
(let [multi-form-state (:multi-form-state request)]
(html-response
[:div
(fc/start-form multi-form-state
(when (:form-errors request) {:step-params (:form-errors request)})
(fc/with-field :step-params
(fc/with-field :accounts
(com/validated-field
{:errors (fc/field-errors)}
(com/data-grid {:headers [(com/data-grid-header {} "Account")
(com/data-grid-header {:class "w-32"} "Location")
(com/data-grid-header {:class "w-16"} "%")
(com/data-grid-header {:class "w-16"})]}
(fc/cursor-map #(transaction-account-row* {:value %}))
(com/data-grid-new-row {:colspan 4
:hx-get (bidi/path-for ssr-routes/only-routes
::route/bulk-code-new-account)
:row-offset 0
:index (count (fc/field-value))}
"New account"))))))])))
(defn- single-client-id [request]
"Returns the client ID if the user has access to exactly one client, nil otherwise."
(when (= 1 (count (:clients request)))
(-> request :clients first :db/id)))
(defn vendor-changed-handler [request]
(let [snapshot (:snapshot (:multi-form-state request))
step-params (:step-params (:multi-form-state request))
client-id (single-client-id request)
vendor-id (or (:vendor step-params) (:vendor snapshot))
updated-step-params (if (and (empty? (:accounts step-params))
vendor-id)
(if-let [default-account (vendor-default-account vendor-id client-id)]
(assoc step-params :accounts [(build-default-account-row default-account)])
step-params)
step-params)]
(render-accounts-section (assoc-in request [:multi-form-state :step-params] updated-step-params))))
(def bulk-code-wizard (->BulkCodeWizard nil nil))
(defn- render-form-response
"wrap-form-4xx-2 form-handler: re-render the whole form with field/form errors."
[request]
(html-response (render-form request)
:headers {"HX-reswap" "outerHTML"}))
(def key->handler
(apply-middleware-to-all-handlers
{::route/bulk-code (-> mm/open-wizard-handler
(mm/wrap-wizard bulk-code-wizard)
(mm/wrap-init-multi-form-state initial-bulk-edit-state))
::route/bulk-code-new-account (->
(add-new-entity-handler [:step-params :accounts]
(fn render [cursor request]
(transaction-account-row*
{:value cursor}))
(fn build-new-row [base _]
(assoc base :location "Shared")))
(wrap-schema-enforce :query-schema [:map
[:client-id {:optional true}
[:maybe entity-id]]]))
::route/bulk-code-vendor-changed (-> vendor-changed-handler
(mm/wrap-wizard bulk-code-wizard)
(mm/wrap-decode-multi-form-state))
::route/bulk-code-submit (-> mm/submit-handler
(wrap-wizard bulk-code-wizard)
(mm/wrap-decode-multi-form-state))}
{::route/bulk-code (-> open-handler
(wrap-bulk-state))
::route/bulk-code-form-changed (-> bulk-code-form-changed-handler
(wrap-bulk-state))
::route/bulk-code-submit (-> submit
(wrap-form-4xx-2 render-form-response)
(wrap-bulk-state))}
(fn [h]
(-> h
(wrap-copy-qp-pqp)

View File

@@ -36,9 +36,9 @@
[:import-batch-id {:optional true} [:maybe entity-id]]
[:unresolved {:optional true}
[:maybe [:boolean {:decode/string {:enter #(cond (= % "on") true
(= % "") false
:else
(boolean %))}}]]]
(= % "true") true
(boolean? %) %
:else false)}}]]]
[:description {:optional true} [:maybe [:string {:decode/string strip}]]]
[:memo {:optional true} [:maybe [:string {:decode/string strip}]]]
[:vendor {:optional true :default nil} [:maybe [:entity-map {:pull [:db/id :vendor/name]}]]]
@@ -50,9 +50,9 @@
[:location {:optional true} [:maybe [:string {:decode/string strip}]]]
[:potential-duplicates {:optional true}
[:maybe [:boolean {:decode/string {:enter #(cond (= % "on") true
(= % "") false
:else
(boolean %))}}]]]
(= % "true") true
(boolean? %) %
:else false)}}]]]
#_[:status {:optional true} [:maybe (ref->enum-schema "transaction-status")]]
[:exact-match-id {:optional true} [:maybe entity-id]]
[:all-selected {:optional true :default nil} [:maybe :boolean]]
@@ -421,6 +421,35 @@
(import-batch-id* request)
(exact-match-id* request)]])
(def non-date-filter-params
"Query-param keys that represent transaction filters other than the date range."
[:vendor :account :bank-account :description :memo :location
:amount-gte :amount-lte :linked-to :unresolved :potential-duplicates
:import-batch-id :exact-match-id])
(defn- filter-value-active? [v]
(cond
(nil? v) false
(false? v) false
(string? v) (not (str/blank? v))
:else true))
(defn non-date-filters-active? [request]
(boolean (some (comp filter-value-active? #(get (:query-params request) %))
non-date-filter-params)))
(defn clear-filters-href
"URL for the transactions page with every non-date filter cleared, preserving
the active date range (and an implied status, if any)."
[request]
(let [qp (:query-params request)
status (:status qp)]
(str (hu/url (bidi/path-for ssr-routes/only-routes ::route/page)
(cond-> {}
(:start-date qp) (assoc "start-date" (atime/unparse (:start-date qp) atime/normal-date))
(:end-date qp) (assoc "end-date" (atime/unparse (:end-date qp) atime/normal-date))
(keyword? status) (assoc "status" (name status)))))))
(def grid-page
(helper/build {:id "entity-table"
:nav com/main-aside-nav
@@ -434,16 +463,18 @@
(assoc-in (exact-match-id* request) [1 :hx-swap-oob] true)
(some-> (import-batch-id* request) (assoc-in [1 :hx-swap-oob] true))])
:action-buttons (fn [request]
[(com/button {:color :primary
(cond-> [(com/button {:color :primary
:hx-get (bidi/path-for ssr-routes/only-routes ::route/bulk-code)
:hx-target "#modal-holder"
"x-bind:hx-vals" "JSON.stringify({selected: $data.selected, 'all-selected': $data.all_selected})"
"x-bind:disabled" "selected.length === 0 && !all_selected"
"hx-include" "#transaction-filters"}
"Code")
(com/button {:color :primary
:hx-post (bidi/path-for ssr-routes/only-routes ::route/bulk-delete)
:hx-target "#modal-holder"
"x-bind:hx-vals" "JSON.stringify({selected: $data.selected, 'all-selected': $data.all_selected})"
"x-bind:disabled" "selected.length === 0 && !all_selected"
"hx-include" "#transaction-filters"
:hx-confirm "Are you sure you want to delete these transactions?"}
"Delete")
@@ -451,9 +482,15 @@
:hx-post (bidi/path-for ssr-routes/only-routes ::route/bulk-delete)
:hx-target "#modal-holder"
"x-bind:hx-vals" "JSON.stringify({selected: $data.selected, 'all-selected': $data.all_selected})"
"x-bind:disabled" "selected.length === 0 && !all_selected"
"hx-include" "#transaction-filters"
:hx-confirm "Are you sure you want to suppress these transactions?"}
"Suppress")])
"Suppress")]
(non-date-filters-active? request)
(conj (com/a-button {:color :secondary
:hx-boost "true"
:href (clear-filters-href request)}
"Clear filters"))))
:row-buttons (fn [request entity]
(let [client (:transaction/client entity)
locked-until (:client/locked-until client)
@@ -499,6 +536,17 @@
(= 1 (count (:client/locations (:client args))))))
:render (fn [x] [:div.flex.items-center.gap-2 (-> x :transaction/client :client/name)])
:render-csv (fn [x] (-> x :transaction/client :client/name))}
{:key "bank-account"
:name "Bank Account"
:show-starting "lg"
:render (fn [x]
(let [ba (:transaction/bank-account x)]
(or (:bank-account/name ba)
(:bank-account/numeric-code ba))))
:render-csv (fn [x]
(let [ba (:transaction/bank-account x)]
(or (:bank-account/name ba)
(:bank-account/numeric-code ba))))}
{:key "vendor"
:name "Vendor"
:sort-key "vendor"

File diff suppressed because it is too large Load Diff

View File

@@ -211,7 +211,7 @@
(com/data-grid-cell {} (fc/with-field :description-original
(com/text-input {:value (fc/field-value) :name (fc/field-name)})))
(com/data-grid-cell {} (fc/with-field :amount
(com/money-input {:value (fc/field-value) :name (fc/field-name) :class "w-28"})))
(com/text-input {:value (fc/field-value) :name (fc/field-name) :class "w-28 text-right" :inputmode "decimal"})))
(com/data-grid-cell {} (fc/with-field :bank-account-code
(com/text-input {:value (fc/field-value) :name (fc/field-name) :class "w-28"})))
(com/data-grid-cell {} (fc/with-field :client-code

View File

@@ -4,9 +4,6 @@
:post ::save}
"/table" ::table
"/navigate" ::navigate
"/bank-accounts/sort" ::sort-bank-accounts
"/discard" ::discard
"/square-locations" ::refresh-square-locations
"/location/new" ::new-location
@@ -15,6 +12,13 @@
"/email-contact/new" ::new-email-contact
"/group/new" ::new-group
"/feature-flag/new" ::new-feature-flag
"/bank-account/new" ::new-bank-account
"/bank-account/edit" ::edit-bank-account
"/bank-account/accept" {:post ::accept-bank-account}
"/bank-account/discard" ::discard-bank-account
"/bank-accounts/sort" ::sort-bank-accounts
"/new" {:get ::new-dialog}
["/" [#"\d+" :db/id] "/sales-powerquery"] ::biweekly-sales-powerquery
["/" [#"\d+" :db/id] "/edit"] ::edit-dialog})

View File

@@ -10,7 +10,6 @@
"/account/typeahead" ::account-typeahead
"/test" ::test
"/new" {:get ::new-dialog}
"/navigate" ::navigate
["/" [#"\d+" :db/id] "/edit"] ::edit-dialog
["/" [#"\d+" :db/id] "/delete"] ::delete
["/" [#"\d+" :db/id] "/run"] {:get ::execute-dialog

View File

@@ -9,7 +9,6 @@
"/account-override" ::new-account-override
"/account-typeahead" ::account-typeahead
"/validate" ::validate
"/navigat" ::navigate
"/new" {:get ::new}
"/merge" {:get ::merge
:put ::merge-submit}

View File

@@ -15,7 +15,6 @@
:put ::new-invoice-submit
"/due-date" ::due-date
"/scheduled-payment-date" ::scheduled-payment-date
"/navigate" ::new-wizard-navigate
"/account/new" ::new-wizard-new-account
"/account/location-select" ::location-select
"/account/prediction" ::account-prediction
@@ -26,16 +25,12 @@
"/pay" {:get ::pay-wizard
"/using-credit" ::pay-using-credit
"/navigate" ::pay-wizard-navigate
:post ::pay-submit}
"/bulk-delete" {:get ::bulk-delete
:delete ::bulk-delete-confirm}
"/bulk-edit" {:get ::bulk-edit
:put ::bulk-edit-submit
"/account" ::bulk-edit-new-account
"/total" ::bulk-edit-total
"/balance" ::bulk-edit-balance}
"/form-changed" ::bulk-edit-form-changed}
["/" [#"\d+" :db/id]] {:delete ::delete
"/undo-autopay" ::undo-autopay
"/unvoid" ::unvoid

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