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:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>."
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user