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>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -51,23 +51,28 @@
|
||||
{:x-init "$el.indeterminate = true"}))]))
|
||||
|
||||
(defn typeahead- [params]
|
||||
[:div.relative {: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 ""
|
||||
:active -1
|
||||
:elements (if ((:value-fn params identity) (:value params))
|
||||
[{:value ((:value-fn params identity) (:value params)) :label ((:content-fn params identity) (:value params))}]
|
||||
[])})
|
||||
:x-modelable "value.value"
|
||||
:x-model (:x-model 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 ""
|
||||
:active -1
|
||||
:elements (if ((:value-fn params identity) (:value params))
|
||||
[{:value ((:value-fn params identity) (:value params)) :label ((:content-fn params identity) (:value params))}]
|
||||
[])})
|
||||
: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))]
|
||||
|
||||
292
src/clj/auto_ap/ssr/components/selmer.clj
Normal file
292
src/clj/auto_ap/ssr/components/selmer.clj
Normal file
@@ -0,0 +1,292 @@
|
||||
(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)})))
|
||||
|
||||
;; --- 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)})))
|
||||
43
src/clj/auto_ap/ssr/selmer.clj
Normal file
43
src/clj/auto_ap/ssr/selmer.clj
Normal 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)))
|
||||
File diff suppressed because it is too large
Load Diff
@@ -5,13 +5,13 @@
|
||||
[hiccup2.core :as hiccup]
|
||||
[auto-ap.ssr.components :as com]))
|
||||
(defn html-page [hiccup]
|
||||
{:status 200
|
||||
{:status 200
|
||||
:headers {"Content-Type" "text/html"}
|
||||
:body (str
|
||||
"<!DOCTYPE html>"
|
||||
(hiccup/html
|
||||
{}
|
||||
hiccup))})
|
||||
:body (str
|
||||
"<!DOCTYPE html>"
|
||||
(hiccup/html
|
||||
{}
|
||||
hiccup))})
|
||||
|
||||
(defn base-page [request contents page-name]
|
||||
(html-page
|
||||
@@ -28,7 +28,7 @@
|
||||
[:script {:src "https://cdn.jsdelivr.net/npm/@ryangjchandler/alpine-tooltip@1.x.x/dist/cdn.min.js" :defer true}]
|
||||
[:link {:rel "stylesheet" :href "/css/tippy/tippy.css"}]
|
||||
[:link {:rel "stylesheet" :href "/css/tippy/light.css"}]
|
||||
[:script {:src "/js/htmx.js"
|
||||
[:script {:src "/js/htmx.js"
|
||||
:crossorigin= "anonymous"}]
|
||||
|
||||
[:script {:src "https://cdn.jsdelivr.net/npm/sortablejs@1.15.2/Sortable.min.js"}]
|
||||
@@ -41,7 +41,7 @@
|
||||
[:script {:src "https://cdnjs.cloudflare.com/ajax/libs/Chart.js/4.4.1/chart.umd.min.js" :integrity "sha512-CQBWl4fJHWbryGE+Pc7UAxWMUMNMWzWxF4SQo9CgkJIN1kx6djDQZjh3Y8SZ1d+6I+1zze6Z7kHXO7q3UyZAWw==" :crossorigin "anonymous" :referrerpolicy "no-referrer"}]
|
||||
|
||||
[:script {:src "https://unpkg.com/dropzone@5.9.3/dist/min/dropzone.min.js" :defer true}]
|
||||
[:link {:rel "stylesheet" :href "https://unpkg.com/dropzone@5/dist/min/dropzone.min.css" :type "text/css" :defer true}]
|
||||
[:link {:rel "stylesheet" :href "https://unpkg.com/dropzone@5/dist/min/dropzone.min.css" :type "text/css" :defer true}]
|
||||
[:script {:defer true :src "/js/alpine-vals.js"}]
|
||||
[:script {:defer true :src "https://cdn.jsdelivr.net/npm/@ryangjchandler/alpine-clipboard@2.x.x/dist/alpine-clipboard.js"}]
|
||||
[:script {:defer true :src "https://cdn.jsdelivr.net/npm/@alpinejs/focus@3.x.x/dist/cdn.min.js"}]
|
||||
@@ -92,14 +92,14 @@ input[type=number] {
|
||||
"x-transition:leave-end" "!bg-opacity-0"}
|
||||
|
||||
[:div {:class "flex h-full w-full justify-stretch md:justify-center items-stretch md:items-center "
|
||||
"x-trap.inert.noscroll" "open"
|
||||
"x-trap.inert" "open"
|
||||
"x-show" "open"
|
||||
"x-transition:enter" "ease-out duration-300"
|
||||
"x-trap.inert.noscroll" "open"
|
||||
"x-trap.inert" "open"
|
||||
"x-show" "open"
|
||||
"x-transition:enter" "ease-out duration-300"
|
||||
"x-transition:enter-start" "!bg-opacity-0 !translate-y-32"
|
||||
"x-transition:enter-end" "!bg-opacity-100 !translate-y-0"
|
||||
"x-transition:leave" "duration-300"
|
||||
"x-transition:enter-end" "!bg-opacity-100 !translate-y-0"
|
||||
"x-transition:leave" "duration-300"
|
||||
"x-transition:leave-start" "!opacity-100 !translate-y-0"
|
||||
"x-transition:leave-end" "!opacity-0 !translate-y-32"}
|
||||
"x-transition:leave-end" "!opacity-0 !translate-y-32"}
|
||||
|
||||
[:div#modal-content.flex.items-center.justify-center {:class "md:p-12"}]]]]]]))
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
(ns auto-ap.routes.transactions)
|
||||
|
||||
(def routes {"" {:get ::page
|
||||
:put ::edit-wizard-navigate
|
||||
"/unapproved" ::unapproved-page
|
||||
"/requires-feedback" ::requires-feedback-page
|
||||
"/approved" ::approved-page
|
||||
@@ -28,13 +27,8 @@
|
||||
|
||||
["/" [#"\d+" :db/id]] {"/edit" {:get ::edit-wizard}}
|
||||
"/edit-submit" ::edit-submit
|
||||
"/edit-vendor-changed" ::edit-vendor-changed
|
||||
"/location-select" ::location-select
|
||||
"/account-total" ::account-total
|
||||
"/account-balance" ::account-balance
|
||||
"/toggle-amount-mode" ::toggle-amount-mode
|
||||
"/edit-wizard-new-account" ::edit-wizard-new-account
|
||||
"/edit-wizard-toggle-mode" ::edit-wizard-toggle-mode
|
||||
"/edit-form-changed" ::edit-form-changed
|
||||
"/match-payment" ::link-payment
|
||||
"/match-autopay-invoices" ::link-autopay-invoices
|
||||
"/match-unpaid-invoices" ::link-unpaid-invoices
|
||||
|
||||
Reference in New Issue
Block a user