refactor(ssr): make the bulk-code form-ctx data-only

Move the remaining static markup out of the bulk-code form view-model and into
the templates, leaving form-ctx as plain data (plus a urls map and two button
contexts). The form/vendor hx-wiring, the status <option> list, the per-row
transition / location-swap / remove wiring, and the field names are now literal
in the templates, built from the row index and the shared urls.

- form.html: form attrs literal; ids render name="ids[N]" via forloop.counter0.
- body.html: vendor-changed wiring literal; status is an inline <select> with
  literal options (selected via {% if status.value = ... %}); field wrappers use
  {% if has_error %}has-error.
- account-row.html: the <tr> transitions, db/id hidden, location-cell swap and
  remove <a> are literal with {{ row.index }} / {{ urls.changed }}; only the
  Alpine x-data, errors, and the typeahead/location/money control contexts are
  passed as data.
- form-ctx / account-row-vm reduced to data; drop the now-unused
  sc/validated-field-classes.

Tradeoff: the status <select> and the remove <a> inline the shared base classes
(those partials can't take literal option labels / per-row wiring), so those two
class strings are duplicated in the bulk-code templates.

Verified: moved wiring correct by targeted checks (ids[N], form/vendor hx-*,
account-row-N, location swap + remove with index, status selected, no unrendered
tags); full browser flow green -- open (3 ids), vendor auto-populate, status
set+persist, add/remove row, submit "Transactions Coded", no JS errors. Shared
component class-sets unchanged (this commit only touches bulk-code).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-29 22:48:54 -07:00
parent f16c52d70b
commit e1a2f7b638
5 changed files with 58 additions and 100 deletions

View File

@@ -1,27 +1,34 @@
{# One expense-account row, read from a loop-bound `row` view-model (account-row-vm). The
account typeahead, location select, and remove button all reuse the shared component
partials (typeahead.html / location-select.html / a-icon-button via its ctx); only the
table layout is inline. The location cell (#account-location-N) swaps just itself on
{# One expense-account row from a loop-bound `row` view-model. All structure, wiring, and
field names are literal here, built from `row.index` + the shared `urls`; only data (the
Alpine x-data, db/id, errors, and the typeahead / location / money control contexts)
comes from the view-model. The location cell (#account-location-N) swaps just itself on
account change; the remove button swaps the whole #bulk-code-form. #}
<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 %}>
<tr class="account-row border-b dark:border-gray-600 group hover:bg-gray-100 dark:hover:bg-gray-700"
id="account-row-{{ row.index }}" x-data="{{ row.x_data }}" x-ref="p" x-show="show"
x-init="$nextTick(() => show=true)"
x-transition:enter="transition-opacity duration-500" x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100"
x-transition:leave="transition duration-500" x-transition:leave-start="opacity-100" x-transition:leave-end="opacity-0">
<input type="hidden" name="accounts[{{ row.index }}][db/id]"{% 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 width=row.account.width x_data=row.account.x_data x_model=row.account.x_model key=row.account.key disabled=row.account.disabled a_xinit=row.account.a_xinit placeholder=row.account.placeholder hidden_attrs=row.account.hidden_attrs %}{% include "templates/components/typeahead.html" %}{% endwith %}</div>
<div class="group {% if row.account_has_error %}has-error {% endif %}">
<div class="flex flex-col">{% with width="" x_data=row.account.x_data x_model=row.account.x_model key=row.account.key disabled=row.account.disabled a_xinit=row.account.a_xinit 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 }}>
{% with name=row.location.name variant=row.location.variant options=row.location.options %}{% include "templates/components/location-select.html" %}{% endwith %}
<td class="px-4 py-2" id="account-location-{{ row.index }}">
<div class="group {% if row.location_has_error %}has-error {% endif %}"
hx-post="{{ urls.changed }}" hx-target="#account-location-{{ row.index }}" hx-select="#account-location-{{ row.index }}"
hx-vals='{"name":"accounts[{{ row.index }}][location]"{% if client_id %},"client-id":{{ client_id }}{% endif %}}'
x-hx-val:account-id="accountId" x-dispatch:changed="accountId" hx-trigger="changed" hx-swap="outerHTML" hx-include="closest form">
{% with name=row.location.name variant="w-full" options=row.location.options %}{% include "templates/components/location-select.html" %}{% endwith %}
<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 }}">
<div class="group {% if row.pct_has_error %}has-error {% endif %}">
{% with variant=row.pct.variant attrs=row.pct.attrs %}{% include "templates/components/money-input.html" %}{% endwith %}
<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">{% with extra=row.remove.extra attrs=row.remove.attrs body=row.remove.body %}{% include "templates/components/a-icon-button.html" %}{% endwith %}</td>
<td class="px-4 py-2 align-top"><a href="" 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" hx-post="{{ urls.changed }}" hx-vals='{"op":"remove-account","row-index":{{ row.index }}}' hx-target="#bulk-code-form" hx-select="#bulk-code-form" hx-swap="outerHTML" hx-include="closest form"><div class="h-4 w-4">{% include "templates/components/svg-x.html" %}</div></a></td>
</tr>

View File

@@ -1,26 +1,32 @@
{# Bulk-code modal body: vendor typeahead (a change repopulates the default account via a
whole-form swap), status select, and the expense-account grid. Each field inlines the
validated-field wrapper (label + error <p>) and pulls its control in from the shared
component partials; all data comes from the form view-model. #}
whole-form swap), status select, and the expense-account grid. All wiring, the status
options, and the field-wrapper classes are literal here; only data (selected values,
resolved labels, errors) comes from the view-model. #}
<div class="space-y-4 p-4">
<div class="grid grid-cols-2 gap-4">
<div {{ vendor.changed_attrs|safe }}>
<div class="{{ vendor.field_classes }}">
<div hx-trigger="change" hx-post="{{ urls.changed }}" hx-vals='{"op":"vendor-changed"}' hx-target="#bulk-code-form" hx-select="#bulk-code-form" hx-swap="outerHTML" hx-sync="this:replace" hx-include="closest form">
<div class="group {% if vendor.has_error %}has-error {% endif %}">
<label class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Vendor</label>
{% with width=vendor.ta.width x_data=vendor.ta.x_data x_model=vendor.ta.x_model key=vendor.ta.key disabled=vendor.ta.disabled a_xinit=vendor.ta.a_xinit placeholder=vendor.ta.placeholder hidden_attrs=vendor.ta.hidden_attrs %}{% include "templates/components/typeahead.html" %}{% endwith %}
{% with width="w-96" x_data=vendor.ta.x_data x_model=vendor.ta.x_model key=vendor.ta.key disabled=vendor.ta.disabled a_xinit=vendor.ta.a_xinit placeholder=vendor.ta.placeholder hidden_attrs=vendor.ta.hidden_attrs %}{% include "templates/components/typeahead.html" %}{% endwith %}
<p class="mt-2 text-xs text-red-600 dark:text-red-500 h-4">{{ vendor.error }}</p>
</div>
</div>
<div>
<div class="{{ status.field_classes }}">
<div class="group {% if status.has_error %}has-error {% endif %}">
<label class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Status</label>
{% with name=status.name variant=status.variant attrs=status.attrs options=status.options %}{% include "templates/components/select.html" %}{% endwith %}
<select name="approval-status" class="bg-gray-50 border text-sm rounded-lg block p-2.5 border-gray-300 text-gray-900 focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500 group-[.has-error]:bg-red-50 group-[.has-error]:border-red-500 group-[.has-error]:text-red-900 group-[.has-error]:placeholder-red-700 group-[.has-error]:focus:ring-red-500 group-[.has-error]:dark:bg-gray-700 group-[.has-error]:focus:border-red-500 group-[.has-error]:dark:text-red-500 group-[.has-error]:dark:placeholder-red-500 group-[.has-error]:dark:border-red-500">
<option value=""{% if not status.value %} selected{% endif %}>No Change</option>
<option value="approved"{% if status.value = "approved" %} selected{% endif %}>Approved</option>
<option value="unapproved"{% if status.value = "unapproved" %} selected{% endif %}>Unapproved</option>
<option value="suppressed"{% if status.value = "suppressed" %} selected{% endif %}>Suppressed</option>
<option value="requires-feedback"{% if status.value = "requires-feedback" %} selected{% endif %}>Client Review</option>
</select>
<p class="mt-2 text-xs text-red-600 dark:text-red-500 h-4">{{ status.error }}</p>
</div>
</div>
<div class="col-span-2 pt-4">
<h3 class="text-lg font-medium mb-3">Expense Accounts</h3>
<div class="{{ accounts.field_classes }}">
<div class="group">
<div id="account-entries" class="space-y-3">{% include "templates/transaction-bulk-code/account-grid.html" %}</div>
<p class="mt-2 text-xs text-red-600 dark:text-red-500 h-4">{{ accounts.error }}</p>
</div>

View File

@@ -2,4 +2,4 @@
every sub-template composes from it via includes/blocks. The resolved (not-locked)
transaction 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-code-form"{{ form_attrs|safe }}>{% for id in ids %}<input type="hidden" name="{{ id.name }}" value="{{ id.value }}">{% endfor %}<div class="" @click.outside="open=false" id="bulkcodemodal">{% include "templates/transaction-bulk-code/card.html" %}</div></form>
<form id="bulk-code-form" hx-ext="response-targets" hx-swap="outerHTML" hx-target-400="#form-errors .error-content" hx-trigger="submit" hx-target="this" hx-post="{{ urls.submit }}">{% for id in ids %}<input type="hidden" name="ids[{{ forloop.counter0 }}]" value="{{ id }}">{% endfor %}<div class="" @click.outside="open=false" id="bulkcodemodal">{% include "templates/transaction-bulk-code/card.html" %}</div></form>

View File

@@ -103,14 +103,6 @@
;; --- 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>."

View File

@@ -113,63 +113,35 @@
;; ---------------------------------------------------------------------------
(defn- account-row-vm
"Build the plain-data view-model for one expense-account row (rendered by
account-row.html). Attribute maps are pre-serialized to HTML attribute strings; the
account typeahead, location <select>, and remove button contexts come from the shared
sc/* and edit/* ctx builders, so the row template just {% include %}s those partials.
`errors` is the request's :form-errors map; field errors live under [:accounts i field]."
"Plain-data view-model for one expense-account row (rendered by account-row.html). Pure
data only: the row's index, the Alpine x-data, the db/id value, per-field error flags +
text, and the typeahead / location / money-input control contexts. All wiring (the row
transitions, the location-cell swap, the remove button) and field names are built in
the template from `index` + the shared `urls`."
[{:keys [value client-id index errors]}]
(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)))
: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"}]
{: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)
(if (map? av) (:db/id av) av))]
{:index index
:x_data (hx/json {:show (boolean (not (:new? value))) :accountId account-val})
:db_id_value (:db/id value)
:account_field_classes (sc/validated-field-classes {:errors (get-in errors [:accounts index :account])})
:account_has_error (boolean (seq (get-in errors [:accounts index :account])))
:account_error (sc/errors-str (get-in errors [:accounts 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 (get-in errors [:accounts index :location])})
:location_field_attrs (sc/attrs->str location-attrs)
:location_has_error (boolean (seq (get-in errors [:accounts index :location])))
:location_error (sc/errors-str (get-in errors [:accounts 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_has_error (boolean (seq (get-in errors [:accounts index :percentage])))
:pct_error (sc/errors-str (get-in errors [:accounts index :percentage]))
:pct (sc/money-input-ctx {:name (account-field-name index :percentage)
:class "w-16"
:value (some-> (:percentage value) (* 100) long)})
:pct_field_classes (sc/validated-field-classes {:errors (get-in errors [:accounts index :percentage])})
:pct_error (sc/errors-str (get-in errors [:accounts index :percentage]))
:remove (sc/a-icon-button-ctx {: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" {}))}))
:value (some-> (:percentage value) (* 100) long)})}))
(defn- form-ctx
"The whole bulk-code form as one nested view-model. render-form / open-handler each make
@@ -183,43 +155,24 @@
accounts (vec (:accounts bulk-state))
vendor-val (:vendor bulk-state)
status-val (some-> (:approval-status bulk-state) name)]
{: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)})
{:urls {:submit (bidi/path-for ssr-routes/only-routes ::route/bulk-code-submit)
:changed (bidi/path-for ssr-routes/only-routes ::route/bulk-code-form-changed)}
:client_id client-id
:head_count (count ids)
:ids (map-indexed (fn [i id] {:name (path->name2 :ids i) :value id}) ids)
: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"})
:field_classes (sc/validated-field-classes {:errors (:vendor errors)})
:ids ids
:vendor {:has_error (boolean (seq (:vendor errors)))
:error (sc/errors-str (:vendor errors))
:ta (sc/typeahead-ctx {:name (path->name2 :vendor)
:id (path->name2 :vendor)
:error? (boolean (seq (:vendor errors)))
: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 (merge (sc/select-ctx {:name (path->name2 :approval-status)
:value status-val
:options [["" "No Change"]
["approved" "Approved"]
["unapproved" "Unapproved"]
["suppressed" "Suppressed"]
["requires-feedback" "Client Review"]]})
{:field_classes (sc/validated-field-classes {:errors (:approval-status errors)})
:error (sc/errors-str (:approval-status errors))})
:accounts {:field_classes (sc/validated-field-classes {:errors (:accounts errors)})
:error (sc/errors-str (:accounts errors))
:status {:value status-val
:has_error (boolean (seq (:approval-status errors)))
:error (sc/errors-str (:approval-status errors))}
:accounts {:error (sc/errors-str (:accounts errors))
:new_account (sc/a-button-ctx {: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"