refactor(ssr): render the bulk-code modal fully through Selmer

Move all markup in the Transaction Bulk Code modal out of Clojure and into
Selmer templates so bulk_code.clj only assembles data.

- Replace the inline sel/raw HTML strings and one Hiccup [:p] with templates:
  head, form-errors, footer, account-entries, success-body.
- Render the expense-account grid from a {% for %} template (account-grid.html
  + account-row.html) driven by a per-row view-model (account-row-vm); the row
  reuses the shared components/typeahead.html via a {% with %} include (no fork).
- Extract behaviour-preserving data-prep helpers reused by the view-model:
  sc/typeahead-ctx, sc/money-input-attrs, sc/validated-field-classes,
  sc/errors-str, edit/account-typeahead-ctx, edit/location-select-ctx.

Verified: REPL render parity + browser QA (add/remove row, typeahead select,
per-row location swap, percentage validation, submit, vendor auto-populate);
no JS errors.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-29 19:22:37 -07:00
parent 314c47b7a6
commit a760d15509
10 changed files with 242 additions and 143 deletions

View File

@@ -0,0 +1,2 @@
{# Wrapper around the expense-account grid (the body of the accounts validated-field). #}
<div id="account-entries" class="space-y-3">{{ grid|safe }}</div>

View File

@@ -0,0 +1,22 @@
{# Expense-account grid -- fully template-driven. A single for-loop over the per-row
view-models (bulk-code/account-row-vm), each delegating to account-row.html. Replaces
the Clojure data-grid / data-grid-row / data-grid-cell composition. The trailing
"New account" button posts the whole #bulk-code-form (op=new-account). #}
<div class="shrink overflow-y-scroll">
<table class="w-full text-sm text-left text-gray-500 dark:text-gray-400 shrink">
<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">
<tr>
<th class="px-4 py-3" scope="col">Account</th>
<th class="px-4 py-3 w-32" scope="col">Location</th>
<th class="px-4 py-3 w-16" scope="col">%</th>
<th class="px-4 py-3 w-16" scope="col"></th>
</tr>
</thead>
<tbody>
{% for row in rows %}{% include "templates/transaction-bulk-code/account-row.html" %}{% endfor %}
<tr class="new-row border-b dark:border-gray-600 group hover:bg-gray-100 dark:hover:bg-gray-700">
<td class="px-4 py-2" colspan="4">{{ new_account_button|safe }}</td>
</tr>
</tbody>
</table>
</div>

View File

@@ -0,0 +1,35 @@
{# One expense-account row, read entirely from a loop-bound `row` view-model
(bulk-code/account-row-vm). The account typeahead reuses the shared
components/typeahead.html partial (its context mapped in from row.account via a with
block); the location select, percentage input, and remove button are inlined plain
HTML. The location cell (#account-location-N) swaps just itself on account change; the
remove button swaps the whole #bulk-code-form. Every dynamic attribute arrives
pre-serialized as a string. #}
<tr class="{{ row.tr_classes }}"{{ row.tr_attrs|safe }}>
<input type="hidden" name="{{ row.db_id_name }}"{% if row.db_id_value %} value="{{ row.db_id_value }}"{% endif %}>
<td class="px-4 py-2">
<div class="{{ row.account_field_classes }}">
<div class="flex flex-col">{% with x_data=row.account.x_data x_model=row.account.x_model key=row.account.key disabled=row.account.disabled a_class=row.account.a_class a_xinit=row.account.a_xinit search_class=row.account.search_class placeholder=row.account.placeholder hidden_attrs=row.account.hidden_attrs %}{% include "templates/components/typeahead.html" %}{% endwith %}</div>
<p class="mt-2 text-xs text-red-600 dark:text-red-500 h-4">{{ row.account_error }}</p>
</div>
</td>
<td class="px-4 py-2" id="{{ row.location_cell_id }}">
<div class="{{ row.location_field_classes }}"{{ row.location_field_attrs|safe }}>
<select name="{{ row.location.name }}" class="{{ row.location.classes }}">
{% for opt in row.location.options %}<option value="{{ opt.value }}"{% if opt.selected %} selected{% endif %}>{{ opt.label }}</option>{% endfor %}
</select>
<p class="mt-2 text-xs text-red-600 dark:text-red-500 h-4">{{ row.location_error }}</p>
</div>
</td>
<td class="px-4 py-2">
<div class="{{ row.pct_field_classes }}">
<input {{ row.pct_attrs|safe }}>
<p class="mt-2 text-xs text-red-600 dark:text-red-500 h-4">{{ row.pct_error }}</p>
</div>
</td>
<td class="px-4 py-2 align-top">
<a class="account-remove-action 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"{{ row.remove_attrs|safe }}>
<div class="h-4 w-4">{% include "templates/components/svg-x.html" %}</div>
</a>
</td>
</tr>

View File

@@ -0,0 +1,5 @@
{# Modal footer: the form-errors sink on the left, the Save button on the right.
Both are pre-rendered fragments (errors sink = form-errors.html, save = sc/button). #}
<div class="flex justify-end">
<div class="flex items-baseline gap-x-4">{{ form_errors|safe }}{{ save_button|safe }}</div>
</div>

View File

@@ -0,0 +1,4 @@
{# Submit-error sink. A 4xx submit swaps the inner `.error-content` (hx-target-400);
the span is present only when there are form-level errors, matching the prior
hand-rolled markup byte-for-byte. #}
<div id="form-errors">{% if errors_str %}<span class="error-content"><p class="mt-2 text-xs text-red-600 dark:text-red-500 h-4">{{ errors_str }}</p></span>{% endif %}</div>

View File

@@ -0,0 +1,2 @@
{# Modal header label: how many transactions this bulk-code operation will touch. #}
<div class="p-2">Bulk editing {{ count }} transactions</div>

View File

@@ -0,0 +1,2 @@
{# Post-submit confirmation message embedded in the shared success modal. #}
<p>Successfully coded {{ count }} transactions.</p>

View File

@@ -72,14 +72,19 @@
(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
(defn money-input-attrs
"The serialized attribute string for a money input. Split out so a template-driven
grid can inline `<input {{ attrs|safe }}>` without re-deriving the class logic."
[{:keys [size] :as params}]
(attrs->str (-> 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)})))
(assoc :type "number" :step "0.01"))))
(defn money-input [params]
(render "templates/components/money-input.html" {:attrs (money-input-attrs params)}))
(defn select
"Generic <select> rendered from a Selmer partial (the location-select.html shape,
@@ -101,23 +106,34 @@
;; --- field wrapper ---------------------------------------------------------------
(defn validated-field-classes
"The wrapping-div class string for a validated field (group + optional has-error +
caller class). Split out so a template-driven row can stamp the same classes."
[{:keys [errors] :as params}]
(cond-> (or (:class params) "")
(sequential? errors) (hh/add-class "has-error")
:always (hh/add-class "group")))
(defn errors-str
"Comma-join the string errors at a field (nil/empty -> empty string), matching the
validated-field error <p>."
[errors]
(or (when (sequential? errors)
(str/join ", " (filter string? errors)))
""))
(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)))]
(let [attrs (dissoc params :label :errors :error-source :error-key :class)]
(render "templates/components/validated-field.html"
{:label label
:classes classes
:classes (validated-field-classes params)
:attrs (attrs->str attrs)
:body (body->html body)
:errors_str (or errors-str "")})))
:errors_str (errors-str errors)})))
;; --- buttons / badges / links ----------------------------------------------------
@@ -274,10 +290,12 @@
: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."
(defn typeahead-ctx
"Build the plain-data context map for templates/components/typeahead.html. 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. Split out from
`typeahead` so a fully template-driven grid can feed the same partial per row (via
{% with %}) without re-deriving any of this logic. Every value is a string/boolean."
[{:keys [value value-fn content-fn x-model x-init id class placeholder disabled url]
:as params}]
(let [vf (or value-fn identity)
@@ -298,13 +316,17 @@
(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)})))
{: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)}))
(defn typeahead
"Selmer port of com/typeahead. Preserves every tippy?. null-guard. See typeahead-ctx."
[params]
(render "templates/components/typeahead.html" (typeahead-ctx params)))

View File

@@ -20,8 +20,8 @@
[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.transaction.edit :as edit :refer [account-typeahead-ctx
location-select-ctx]]
[auto-ap.ssr.utils
:refer [->db-id apply-middleware-to-all-handlers assert-schema entity-id
form-validation-error html-response main-transformer modal-response
@@ -123,13 +123,22 @@
;; 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)."
(defn- account-row-vm
"Build the plain-data view-model for one expense-account row (rendered by
account-row.html). Every dynamic attribute is pre-serialized to an HTML attribute
string here; the structure (cells, controls, the loop) lives in the templates. The
account typeahead and location <select> contexts come from the shared edit/* ctx
builders so the URL / content-fn / options logic is not duplicated."
[{:keys [value client-id index]}]
(let [account-val (let [av (:account value)]
(if (map? av) (:db/id av) av))
tr-map (hx/alpine-mount-then-appear
{: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"})
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)))
@@ -140,79 +149,61 @@
: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" {}))))))
{:tr_classes (str (:class tr-map) " border-b dark:border-gray-600 group hover:bg-gray-100 dark:hover:bg-gray-700")
:tr_attrs (sc/attrs->str (dissoc tr-map :class))
:db_id_name (account-field-name index :db/id)
:db_id_value (:db/id value)
:account_field_classes (sc/validated-field-classes {:errors (account-field-errors index :account)})
:account_error (sc/errors-str (account-field-errors index :account))
:account (account-typeahead-ctx {:value account-val
:client-id client-id
:name (account-field-name index :account)
:x-model "accountId"})
:location_cell_id (str "account-location-" index)
:location_field_classes (sc/validated-field-classes {:errors (account-field-errors index :location)})
:location_field_attrs (sc/attrs->str location-attrs)
:location_error (sc/errors-str (account-field-errors index :location))
:location (location-select-ctx {: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)})
:pct_attrs (sc/money-input-attrs {:name (account-field-name index :percentage)
:class "w-16"
:value (some-> (:percentage value) (* 100) long)})
:pct_field_classes (sc/validated-field-classes {:errors (account-field-errors index :percentage)})
:pct_error (sc/errors-str (account-field-errors index :percentage))
:remove_attrs (sc/attrs->str {: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"
:href ""})}))
(defn- account-grid* [request]
(defn- account-grid*
"Render the whole expense-account grid from account-grid.html: a {% for %} over the
per-row view-models, plus the pre-rendered New-account button. All grid structure is
in the template -- the Clojure here only assembles data."
[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")))]))))
(sel/render->hiccup
"templates/transaction-bulk-code/account-grid.html"
{:rows (map-indexed
(fn [index account]
(account-row-vm {:value account
:client-id client-id
:index index}))
accounts)
:new_account_button (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)
@@ -249,24 +240,18 @@
["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>"))))})))
(sc/render "templates/transaction-bulk-code/account-entries.html"
{:grid (str (account-grid* request))})))})))
(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>"))
(sc/render "templates/transaction-bulk-code/form-errors.html"
{:errors_str (when (seq errors)
(str/join ", " (filter string? errors)))}))
(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>")))
(sc/render "templates/transaction-bulk-code/footer.html"
{:form_errors (form-errors-html (:errors (:form-errors request)))
:save_button (sc/button {:color :primary :x-ref "next" :class "w-32 wizard-save-action"} "Save")}))
(defn render-form
"Renders the whole plain bulk-code form (no wizard). Binds *errors* from the request's
@@ -280,7 +265,7 @@
(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>")
{:head (sc/render "templates/transaction-bulk-code/head.html" {:count (count ids)})
:side_panel nil
:body (str (bulk-code-body* request))
:footer (str (footer* request))})]
@@ -463,7 +448,8 @@
;; Return success modal
(html-response
(com/success-modal {:title "Transactions Coded"}
[:p (str "Successfully coded " (count ids) " transactions.")])
(sel/render->hiccup "templates/transaction-bulk-code/success-body.html"
{:count (count ids)}))
:headers {"hx-trigger" "refreshTable, reset-selection"}))))
;; ---------------------------------------------------------------------------

View File

@@ -159,11 +159,10 @@
clientized (clientize-vendor vendor client-id)]
(:vendor/default-account clientized))))
(defn location-select*
"The location <select> for an account row, rendered from a Selmer template
(templates/components/location-select.html) -- the first interactive modal component
migrated off Hiccup. Same options/selection/styling as the old com/select, emitted as
plain HTML and embedded back into the Hiccup row via the interop bridge."
(defn location-select-ctx
"Plain-data context for templates/components/location-select.html: {:name :classes
:options [{:value :label :selected}]}. Split out from location-select* so a fully
template-driven account grid can stamp the same <select> from its own row loop."
[{:keys [name account-location client-locations value]}]
(let [options (cond account-location
[[account-location account-location]]
@@ -177,28 +176,48 @@
[["Shared" "Shared"]])
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 label] options]
{:value v :label label :selected (= v selected)})})))
{:name name
:classes classes
:options (for [[v label] options]
{:value v :label label :selected (= v selected)})}))
(defn location-select*
"The location <select> for an account row, rendered from a Selmer template
(templates/components/location-select.html) -- the first interactive modal component
migrated off Hiccup. Same options/selection/styling as the old com/select, emitted as
plain HTML and embedded back into the Hiccup row via the interop bridge."
[params]
(sel/render->hiccup
"templates/components/location-select.html"
(location-select-ctx params)))
(defn- account-typeahead-params
"Shared param map for the account typeahead (account-search url + clientized label
content-fn). Used by both account-typeahead* (renders) and account-typeahead-ctx
(returns the typeahead context for a template-driven grid)."
[{:keys [name value client-id x-model]}]
{: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
: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-typeahead-ctx
"Plain-data typeahead context (sc/typeahead-ctx) for the account cell -- no flex-col
wrapper. Lets a template-driven row feed templates/components/typeahead.html via
{% with %} without re-deriving the url/content-fn."
[params]
(sc/typeahead-ctx (account-typeahead-params params)))
(defn account-typeahead*
[{:keys [name value client-id x-model]}]
(wrap-div
"flex flex-col"
(sc/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
:value value
:content-fn (fn [value]
(:account/name (d-accounts/clientize (dc/pull (dc/db conn) d-accounts/default-read value)
client-id)))})))
[params]
(wrap-div "flex flex-col" (sc/typeahead (account-typeahead-params params))))
(def ^:dynamic *errors*
"Humanized form errors for the current render, keyed by edit-form-schema paths (e.g.