refactor(ssr): compose the bulk-code form entirely in Selmer

Each bulk-code route now ends in a single sel/render call; all composition
(modal chrome, body, account grid, rows, footer, errors) happens in the
templates via {% extends %}/{% block %}/{% include %}/{% with %}, reading one
nested view-model (form-ctx). No HTML is stitched together in Clojure.

- Add components/modal-card.html: a base chrome with head/body/footer blocks;
  bulk-code/card.html extends it. (Transaction Edit keeps its string-slot
  edit-modal.html for now.)
- New top-level templates: open.html, form.html, card.html, body.html; rework
  account-grid/account-row/footer/head to pull the shared component partials in
  via {% include %}+{% with %} instead of hardcoding class strings or receiving
  pre-rendered HTML strings.
- render-form / open-handler collapse to one sel/render of form.html / open.html.
  bulk-code-body*, footer*, form-errors-html, account-grid*, the *errors* dynamic
  var and ferr are gone; field errors are read straight from :form-errors.
- Extract sc/{select,button,a-button,a-icon-button}-ctx so templates can include
  those partials with computed context (the render wrappers now call the -ctx fns).

Verified: rendered output is DOM-identical to the prior version across empty /
populated / error scenarios (whitespace-normalized token compare), plus browser
QA (open, vendor auto-populate, add/remove row, typeahead, per-row location swap,
percentage validation, submit); no JS errors.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-29 19:44:52 -07:00
parent a760d15509
commit ee558a34e9
14 changed files with 208 additions and 204 deletions

View File

@@ -0,0 +1,15 @@
{# Base modal-card chrome (single-step: header / optional side panel / body / footer).
A child template extends this and fills the head / side_panel / body / footer blocks,
so the whole card renders from one shared context in a single render call. Enter
triggers the footer save button via $refs.next. Mirrors transaction-edit/edit-modal
(the string-slot version still used by Transaction Edit). #}
<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">{% block head %}{% endblock %}</div>
<div class="flex shrink overflow-auto grow">
{% block side_panel %}{% endblock %}
<div class="px-6 py-2 space-y-6 overflow-y-scroll w-full shrink grow">{% block body %}{% endblock %}</div>
</div>
<div class="p-4 border-t">{% block footer %}{% endblock %}</div>
</div>

View File

@@ -1,2 +0,0 @@
{# 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

@@ -1,7 +1,7 @@
{# 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). #}
view-models (bulk-code/account-row-vm), each delegating to account-row.html. The
trailing "New account" button (a-button partial) 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">
@@ -13,9 +13,9 @@
</tr>
</thead>
<tbody>
{% for row in rows %}{% include "templates/transaction-bulk-code/account-row.html" %}{% endfor %}
{% for row in accounts.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>
<td class="px-4 py-2" colspan="4">{% with classes=accounts.new_account.classes attrs=accounts.new_account.attrs indicator=accounts.new_account.indicator body=accounts.new_account.body %}{% include "templates/components/a-button.html" %}{% endwith %}</td>
</tr>
</tbody>
</table>

View File

@@ -1,10 +1,8 @@
{# 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. #}
{# 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
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 %}>
<td class="px-4 py-2">
@@ -15,9 +13,7 @@
</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>
{% with name=row.location.name classes=row.location.classes 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>
@@ -27,9 +23,5 @@
<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>
<td class="px-4 py-2 align-top"><a class="{{ row.remove.classes }}"{{ 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,29 @@
{# 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. #}
<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 }}">
<label class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Vendor</label>
{% with x_data=vendor.ta.x_data x_model=vendor.ta.x_model key=vendor.ta.key disabled=vendor.ta.disabled a_class=vendor.ta.a_class a_xinit=vendor.ta.a_xinit search_class=vendor.ta.search_class 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 }}">
<label class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Status</label>
{% with name=status.name classes=status.classes attrs=status.attrs options=status.options %}{% include "templates/components/select.html" %}{% endwith %}
<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 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>
</div>
</div>
</div>

View File

@@ -1,13 +0,0 @@
{# 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

@@ -1,6 +0,0 @@
{# 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,6 @@
{# Bulk-code modal card: extends the shared modal-card base and fills its blocks with the
bulk-code head / body / footer partials. No side panel. #}
{% extends "templates/components/modal-card.html" %}
{% block head %}{% include "templates/transaction-bulk-code/head.html" %}{% endblock %}
{% block body %}{% include "templates/transaction-bulk-code/body.html" %}{% endblock %}
{% block footer %}{% include "templates/transaction-bulk-code/footer.html" %}{% endblock %}

View File

@@ -1,5 +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). #}
{# Modal footer: the form-errors sink on the left, the Save button on the right. Both
pull from the shared view-model; the button reuses components/button.html. #}
<div class="flex justify-end">
<div class="flex items-baseline gap-x-4">{{ form_errors|safe }}{{ save_button|safe }}</div>
<div class="flex items-baseline gap-x-4">{% include "templates/transaction-bulk-code/form-errors.html" %}{% with classes=save.classes attrs=save.attrs loading_label=save.loading_label body=save.body %}{% include "templates/components/button.html" %}{% endwith %}</div>
</div>

View File

@@ -0,0 +1,5 @@
{# Single render entrypoint for the bulk-code form. The route passes one view-model and
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>

View File

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

View File

@@ -0,0 +1,3 @@
{# Modal-open response: the transitioner shell the modal stack expects, wrapping the whole
form. Composes in one render from the shared view-model. #}
<div id="transitioner" class="flex-1">{% include "templates/transaction-bulk-code/form.html" %}</div>

View File

@@ -86,23 +86,27 @@
(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,
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."
(defn select-ctx
"Plain-data context for templates/components/select.html. options = [[value label] ...];
`value` (string or keyword) marks the selected option. Split out so a template can
{% include %} the partial via {% with %} without re-deriving classes/selection."
[{: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))})})))
{:value v :label label :selected (= (str v) (str sel))})}))
(defn select
"Generic <select> rendered from a Selmer partial (the location-select.html shape,
generalized). See select-ctx."
[params]
(render "templates/components/select.html" (select-ctx params)))
;; --- field wrapper ---------------------------------------------------------------
@@ -153,7 +157,9 @@
:attrs (attrs->str (dissoc params :class))
:body (body->html children)}))
(defn button [{:keys [color disabled minimal-loading?] :as params} & children]
(defn button-ctx
"Plain-data context for templates/components/button.html (classes/attrs/loading_label/body)."
[{: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))
@@ -161,13 +167,17 @@
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)})))
:body (body->html children)}))
(defn a-button [{:keys [color disabled] :as params} & children]
(defn button [params & children]
(render "templates/components/button.html" (apply button-ctx params children)))
(defn a-button-ctx
"Plain-data context for templates/components/a-button.html (classes/attrs/indicator/body)."
[{: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")
@@ -176,23 +186,29 @@
(= :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)})))
:body (body->html children)}))
(defn a-icon-button [{:keys [class] :as params} & children]
(defn a-button [params & children]
(render "templates/components/a-button.html" (apply a-button-ctx params children)))
(defn a-icon-button-ctx
"Plain-data context for templates/components/a-icon-button.html (classes/attrs/body)."
[{: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)})))
:body (body->html children)}))
(defn a-icon-button [params & children]
(render "templates/components/a-icon-button.html" (apply a-icon-button-ctx params children)))
(defn button-group-button [{:keys [size] :or {size :normal} :as params} & children]
(let [classes (cond-> (:class params)

View File

@@ -39,23 +39,9 @@
;; decode straight into bulk-code-schema, mirroring transaction/edit.clj).
;; ---------------------------------------------------------------------------
(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
;; ---------------------------------------------------------------------------
@@ -119,17 +105,20 @@
(-> request :clients first :db/id)))
;; ---------------------------------------------------------------------------
;; Render (100% Selmer -- reuses the transaction/edit.clj sc/* component library
;; and the shared edit-modal / transitioner chrome).
;; Render. Each route ends in a single sel/render call (render-form / open-handler)
;; from one nested view-model (form-ctx); all composition lives in the Selmer
;; templates under transaction-bulk-code/ (form -> card extends components/modal-card
;; -> head/body/footer -> account-grid -> account-row), pulling the shared sc/*
;; component partials in via {% include %} + {% with %}. No HTML is built in Clojure.
;; ---------------------------------------------------------------------------
(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]}]
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]."
[{: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
@@ -153,16 +142,16 @@
: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_field_classes (sc/validated-field-classes {:errors (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 (account-field-errors index :location)})
:location_field_classes (sc/validated-field-classes {:errors (get-in errors [:accounts index :location])})
:location_field_attrs (sc/attrs->str location-attrs)
:location_error (sc/errors-str (account-field-errors 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)))
@@ -171,47 +160,37 @@
: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)
: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"
:href ""})}))
:class "account-remove-action"})}))
(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."
(defn- form-ctx
"The whole bulk-code form as one nested view-model. render-form / open-handler each make
a single sel/render call with this; all composition (modal chrome, body, account grid,
rows, footer, errors) happens in the Selmer templates -- no markup is built in Clojure."
[request]
(let [client-id (single-client-id request)
accounts (vec (:accounts (:bulk-state request)))]
(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)
errors (or (:form-errors request) {})
client-id (single-client-id request)
ids (:ids bulk-state)
accounts (vec (:accounts bulk-state))
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"
{: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)})
: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"
@@ -219,66 +198,47 @@
: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)
:field_classes (sc/validated-field-classes {:errors (:vendor errors)})
:error (sc/errors-str (:vendor errors))
:ta (sc/typeahead-ctx {:name (path->name2 :vendor)
:id (path->name2 :vendor)
:error? (boolean (seq (ferr :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_field (str (sc/validated-field
{:label "Status" :errors (ferr :approval-status)}
(sc/select {:name (path->name2 :approval-status)
: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"]]})))
:accounts_field (str (sc/validated-field
{:errors (ferr :accounts)}
(sc/render "templates/transaction-bulk-code/account-entries.html"
{:grid (str (account-grid* request))})))})))
(defn- form-errors-html [errors]
(sc/render "templates/transaction-bulk-code/form-errors.html"
{:errors_str (when (seq errors)
(str/join ", " (filter string? errors)))}))
(defn- footer* [request]
(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")}))
["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))
: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"
:hx-select "#bulk-code-form"
:hx-swap "outerHTML"
:hx-include "closest form"
:color :secondary}
"New account")
:rows (map-indexed (fn [i a]
(account-row-vm {:value a :client-id client-id :index i :errors errors}))
accounts)}
:errors_str (when-let [e (seq (:errors errors))]
(str/join ", " (filter string? e)))
:save (sc/button-ctx {: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
:form-errors so the field-level error lookups (ferr) resolve. Reuses the edit modal's
chrome (edit-modal.html), with no side panel."
"Render the whole bulk-code form as a single Selmer render of form.html from the form
view-model -- no Clojure-side string stitching, no wizard, no cursor."
[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 (sc/render "templates/transaction-bulk-code/head.html" {:count (count ids)})
: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)))}))))
(sel/render->hiccup "templates/transaction-bulk-code/form.html" (form-ctx request)))
;; ---------------------------------------------------------------------------
;; edit-form-changed ops (whole-form re-render). Replaces the per-operation
@@ -457,12 +417,11 @@
;; ---------------------------------------------------------------------------
(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)."
"Initial modal open (GET). A single Selmer render of open.html (the #transitioner shell
that includes the whole form) from the form view-model."
[request]
(modal-response
(sel/render->hiccup "templates/transaction-edit/transitioner.html"
{:body (str (render-form request))})))
(sel/render->hiccup "templates/transaction-bulk-code/open.html" (form-ctx request))))
(defn- render-form-response
"wrap-form-4xx-2 form-handler: re-render the whole form with field/form errors."