Files
integreat/.claude/skills/ssr-form-migration/reference/selmer-conventions.md
Bryce a01dfc197e refactor(ssr): full Selmer migration of Transaction Edit; remove the wizard
Migrate every part of the Transaction Edit modal's HTML to Selmer templates
(zero Hiccup in the render path) and delete the mm multi-modal "wizard"
abstraction entirely -- there was only ever one step.

- New auto-ap.ssr.components.selmer (sc) + ~22 shared component partials under
  resources/templates/components/ (typeahead, button-group, radio-card,
  data-grid, validated-field, modal, buttons, inputs, SVGs). Each wrapper renders
  its own partial; dynamic HTMX/Alpine attrs bridge via attrs->str -> {{attrs|safe}}.
- 15 modal templates under resources/templates/transaction-edit/.
- Delete EditWizard/LinksStep records + all mm/* usage. Plain handlers: flat
  wrap-decode-edit (fields renamed off step-params[...], stray keys stripped),
  flat wrap-derive-state, *errors*-based field errors, generic wrap-form-4xx-2.
- Drop the edit-wizard-navigate route (routes ~12 -> 5).
- Fix: stray `method` (tab button-group hidden) leaked into the upsert -> 500;
  strip decoded map to schema keys.
- e2e selectors updated (#wizard-form->#edit-form, #wizardmodal->#editmodal,
  step-params[...] field names). Parity: swap 6/6, edit 8/8, suite 38/1
  (1 pre-existing unrelated nav test).
- ssr-form-migration skill updated with the learnings (composition mechanics,
  sc/* library, drop-the-wizard recipe, scorecard row, 3 new gotchas).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 07:47:47 -07:00

7.8 KiB

Selmer template conventions

Validated in the Transaction Edit migration: location-select* now renders from resources/templates/components/location-select.html via the interop bridge, embedded back into the Hiccup account row. Verified: swap spec 6/6, transaction-edit 8/8 (the Shared Location test selects through the Selmer <select>, saves, and spreads to DT).

Why Selmer for interactive components

In Hiccup the same Alpine/HTMX attribute is sometimes a keyword and sometimes a string in the same file — there's no rule a reader (or an LLM) can rely on. The real com/typeahead- mixes them in one map:

:x-modelable "value.value"     ; keyword key
"x-ref" "hidden"               ; string key
"@keydown.down.prevent.stop" "tippy?.show();"   ; handlers MUST be strings
:x-init "..."                  ; structural attrs are keywords

In a Selmer template the same markup is unambiguous plain HTML:

{# templates/components/typeahead.html #}
<div class="relative" x-data="{{ x_data|safe }}" x-model="{{ x_model }}">
  <a class="{{ classes }}" x-ref="input" tabindex="0"
     @keydown.down.prevent.stop="tippy?.show()"
     @keydown.backspace="tippy?.hide(); value = {value:'', label:''}">
    <span x-text="value.label"></span>
  </a>
</div>

Note the tippy?. null-guard carried over from the swap doctrine — Selmer doesn't change the Alpine-survives-swap requirement.

The render helper + interop bridge (auto-ap.ssr.selmer)

(sel/render template ctx)        ; selmer/render-file -> HTML string (classpath-relative path)
(sel/render-str template ctx)    ; render from a string (tests/REPL)
(sel/hiccup->html h)             ; Hiccup -> string, for {{ frag|safe }} inside a template
(sel/raw html-string)            ; wrap a rendered string so hiccup2 emits it verbatim
(sel/render->hiccup template ctx); render + raw, ready to drop into a Hiccup tree

The bridge works both ways (proven in selmer_test): a Hiccup component renders inside a Selmer template (hiccup->html + |safe), and a Selmer fragment renders inside a Hiccup tree (render->hiccup, which raw-wraps so hiccup2 doesn't double-escape).

The worked example — location-select*

Template (resources/templates/components/location-select.html): plain HTML, an {% for %} over option maps, {% if opt.selected %}.

;; Clojure side: build the data, compute classes (reuse inputs/default-input-classes so
;; styling can't drift), render, and return a Hiccup-embeddable fragment.
(defn location-select* [{:keys [name client-locations value ...]}]
  (let [options  (cond ...)                       ; [[value label] ...]
        selected (or value (ffirst options))
        classes  (str/join " " (conj (vec inputs/default-input-classes) "w-full"))]
    (sel/render->hiccup "templates/components/location-select.html"
                        {:name name :classes classes
                         :options (for [[v l] options] {:value v :label l :selected (= v selected)})})))

Lessons:

  • Pass computed values in, don't hard-code. Reuse the Clojure source of truth (inputs/default-input-classes) as a context value rather than copying class strings into the template — otherwise styling drifts from the shared components.
  • Verify by string-match + e2e, not byte-parity. hh/add-class is set-based, so class order differs from the old com/select output; CSS is order-independent and the e2e proves behavior. (testing-conventions: don't assert on exact markup.)
  • Embed via the bridge. render->hiccup lets the Selmer fragment sit inside the still-Hiccup row (com/validated-field wraps it) — strangler, one component at a time.

Composition — verified mechanics (selmer 1.12.61)

Proven by REPL before the full migration (do the same before relying on any of these):

  • {% include %} works in FILE renders only. sel/render = selmer/render-file, and include/extends/block are parse-stage tags. Rendering a template string that contains {% include %} throws unrecognized tag: :include (the runtime expr-tag has a nil handler). So includes only work from a .html file, never from render-str.
  • {% include %} sees {% for %} loop bindings. Inside {% for row in rows %}…{% include "components/x.html" %}…{% endfor %} the partial can read row.*. Good for repeated rows — though Clojure-composing the rows (below) is usually simpler.
  • {% include … with k=v %} is IGNORED in 1.12.61 (the with clause is dropped). To parametrize, either wrap with the block tag {% with k=v %}{% include … %}{% endwith %} (works), or — preferred — let a Clojure wrapper render the partial with an explicit ctx.

The Clojure-wrapper composition pattern (auto-ap.ssr.components.selmer / sc)

Because {% include with %} can't pass args and the server computes most values anyway, each shared component is a thin Clojure wrapper that renders its own partial (the proven location-select* shape, generalised). The element structure lives 100% in the .html; the only Clojure is data assembly. The modal's render fns compose these wrappers and assemble a view-model, interpolating the pre-rendered fragments as {{ frag|safe }}.

(sc/hidden {:name  :value })            ; -> render "components/hidden.html"
(sc/validated-field {:label  :errors } body)
(sc/typeahead {:name  :url  :value  :content-fn })   ; resolves label server-side
(sc/data-grid {:headers [] :footer-tbody } rows)

attrs->str — the dynamic-attribute bridge

HTMX/Alpine attributes vary per call site, so a fixed template can't enumerate them. sc/attrs->str serialises an attribute map to an HTML attribute string injected with {{ attrs|safe }}: <input type="hidden"{{ attrs|safe }}>. Rules mirror hiccup2 — nil/false dropped, true → bare boolean attr, else name="escaped" (via hu/escape-html, so JSON x-data and x-init quotes become &quot;/&apos; and the browser decodes them back). Keyword keys keep their full name incl. namespaced/colon forms (:x-hx-val:account-idx-hx-val:account-id). This keeps templates free of per-attribute {% if %} ladders while still emitting 100% Selmer markup — attrs->str serialises data, it does not build Hiccup.

Reuse the real class helpers

Wrappers reuse inputs/default-input-classes, inputs/use-size, hh/add-class, btn/bg-colors so output matches the Hiccup component modulo Tailwind class order. Verify by class-set equality + e2e, never byte-parity (see the cookbook entries).

Trivial wrapper divs

A bare <div class="w-72">…</div> around a fragment is composed with a wrap-div string helper (or put the class in the parent template), not a Hiccup vector — string composition of a structural wrapper is not Hiccup and avoids a micro-template per div.

Keep |safe to values the server fully controls (rendered fragments, JSON for x-data), never raw user input.

Scope (Open decision 2)

Hybrid, and the boundary is real: the modal's attribute-heavy components delegate to the shared com/typeahead / com/select / com/button-group-button. Converting those is a cross-cutting change (every modal uses them), so it belongs to the Phase 11 Selmer sweep, not a single modal. location-select* is the first, self-contained proof; the shared components follow when the sweep promotes them to Selmer partials.

Attribute-consistency scorecard (heuristic 8)

grep -cE '"x-[a-z]|"hx-[a-z]|"@' <migrated-template>   # → 0 mixed encodings in Selmer

A migrated Selmer template has no mixed :x-/"x-" encodings because everything is plain HTML. (The Hiccup "@click"/":class" offenders that remain in edit.clj live in the shared-component call sites — they clear when those components move to Selmer.)