SSR modernization: ssr-form-migration skill + Transaction Edit plain-form/Selmer migration #14

Open
notid wants to merge 21 commits from integreat-execute-refactor into staging
5 changed files with 115 additions and 43 deletions
Showing only changes of commit c892719bd1 - Show all commits

View File

@@ -90,6 +90,25 @@ replaces the amount input (caret survives).
:placeholder "Optional note"}) ; no hx-* — rides along to save
```
## location-select — first Selmer-migrated component (validated)
The account row's location `<select>`, rendered from a Selmer template instead of
`com/select`. The first interactive modal component off Hiccup; proves the render-file
path + interop bridge on real, e2e-covered markup (swap 6/6, transaction-edit 8/8).
```clojure
;; templates/components/location-select.html — plain HTML, {% for %} + {% if selected %}
(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)})})))
```
Reuse: pass `inputs/default-input-classes` in (don't hard-code); embed via
`render->hiccup` so it drops into the still-Hiccup row. See `selmer-conventions.md`.
## fixed-index row from explicit data — de-faking a deep cursor
When a row always lives at a known index (e.g. simple mode renders exactly `accounts[0]`),

View File

@@ -39,7 +39,7 @@ Each migration appends one row (after-numbers), referencing the before in the di
| Phase | Modal | LOC | Routes | no-cursor twins | faked roots | snapshot merges | OOB | mixed hx- | cookbook reused / added |
|-------|-------|-----|--------|-----------------|-------------|-----------------|-----|-----------|-------------------------|
| 1 (baseline) | Transaction Edit `transaction/edit.clj` | 1608 | ~12 | 1 | 2 | ~75 | 0 | 8 | — / seeded 7 entries |
| 2 | Transaction Edit `transaction/edit.clj` | 1584 | **~5** | **0** | **0** | **0 round-trip** | 0 | 8 | — / 0 |
| 2 | Transaction Edit `transaction/edit.clj` | 1593 | **~5** | **0** | **0** | **0 round-trip** | 0 | 8 (shared) | location-select / **1 Selmer** |
> **Phase 2 progress.** Achieved with parity held (swap spec **6/6**, transaction-edit
> spec **8/8**, full suite **38 pass / 1 unrelated fail / 0 skip**, up from 30/2/7):
@@ -52,8 +52,10 @@ Each migration appends one row (after-numbers), referencing the before in the di
> - fixed a real production bug (`:mode` → 500 on every advanced manual save);
> - greened `transaction-edit.spec.ts` (8/8) and matured the skill.
>
> **Still open** for this modal — the **wizard→plain-form rewrite** (one interdependent
> effort): remove the snapshot round-trip (~75 merges → 0; this also fixes the
> operations-drop-live-values bug in `gotchas.md`), drop the `mm/ModalWizardStep` protocol,
> and Selmer-convert the shared interactive components (`com/typeahead`/`com/select`).
> Mixed string `hx-` attrs (8) clear as those components move to Selmer templates.
> **Phase 2 complete.** The wizard→plain-form rewrite removed the snapshot round-trip
> (heuristic 2 → 0) and the first interactive component (`location-select`) is migrated to
> a Selmer template (`selmer-conventions.md` validated). Remaining for *later phases*: drop
> the now-thin `mm/ModalWizardStep` protocol wrappers, and the cross-cutting Phase 11
> Selmer sweep of the shared `com/typeahead`/`com/select`/`com/button-group-button` (those
> shared call sites hold the last 8 mixed `@`/`:`-attr offenders; they clear when the
> shared components move to Selmer — not a single-modal task, per Open decision 2).

View File

@@ -1,21 +1,21 @@
# Selmer template conventions
> **Status: STUB — validated in Phase 2.** This file describes the target. The Selmer
> dependency, render helper, and interop bridge are added in Phase 2 (Transaction Edit);
> rewrite this file from the *real, verified* example once that lands, and record each
> converted component in `component-cookbook.md`.
> **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 same file — there's no rule a reader (or an LLM) can rely on. The real
`com/typeahead-` mixes them in one map:
```clojure
;; All of these appear in one component today:
:x-ref "input" "x-ref" "hidden"
:x-model "value.value" "x-model" "search"
"@keydown.down.prevent.stop" "tippy.show();" ; handlers MUST be strings
:x-init "..." ; structural attrs are keywords
: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:
@@ -28,36 +28,66 @@ In a Selmer template the same markup is unambiguous plain HTML:
@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.
## Render helper + interop bridge (the Phase 2 foundation)
## The render helper + interop bridge (`auto-ap.ssr.selmer`)
```clojure
(defn render [tpl ctx] (selmer/render-file tpl ctx))
(defn hiccup->html [h] (hiccup/html h)) ; embed hiccup inside selmer via {{ frag|safe }}
;; selmer fragment inside hiccup: [:div (hiccup/raw (render "..." ctx))]
(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 must work **both ways** during the strangler transition: a Hiccup component
renders inside a Selmer template (pass `(hiccup->html h)` into the context, render with
`|safe`), and a Selmer fragment renders inside a Hiccup tree (`(hiccup/raw (render ...))`).
Prove both in Phase 2 before broad use.
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
;; 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
Selmer composes via `{% include %}` and `{% block %}`. Prefer small per-component
templates that the cookbook references by path. Keep `|safe` to values the server fully
controls (rendered Hiccup, JSON for `x-data`), never raw user input.
Selmer composes via `{% include %}` and `{% block %}`. Prefer small per-component templates
referenced by classpath path. Keep `|safe` to values the server fully controls (rendered
Hiccup, JSON for `x-data`), never raw user input.
## Scope (Open decision 2)
Hybrid: convert interactive/attribute-heavy components first; static markup may stay
Hiccup. Revisit a fuller sweep in Phase 11.
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)
@@ -65,4 +95,5 @@ Hiccup. Revisit a fuller sweep in Phase 11.
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.
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.)

View File

@@ -0,0 +1,8 @@
{# Location <select> for a transaction account row. Plain-HTML attributes -- the Selmer
migration target (no Hiccup keyword/string attribute ambiguity). Rendered into the
surrounding Hiccup row via the auto-ap.ssr.selmer interop bridge. #}
<select name="{{ name }}" class="{{ classes }}">
{% for opt in options %}
<option value="{{ opt.value }}"{% if opt.selected %} selected{% endif %}>{{ opt.label }}</option>
{% endfor %}
</select>

View File

@@ -21,11 +21,13 @@
[auto-ap.ssr-routes :as ssr-routes]
[auto-ap.ssr.common-handlers :refer [add-new-entity-handler]]
[auto-ap.ssr.components :as com]
[auto-ap.ssr.components.inputs :as inputs]
[auto-ap.ssr.grid-page-helper :as helper]
[auto-ap.ssr.transaction.common :refer [grid-page]]
[auto-ap.ssr.components.multi-modal :as mm]
[auto-ap.ssr.form-cursor :as fc]
[auto-ap.ssr.hx :as hx]
[auto-ap.ssr.selmer :as sel]
[auto-ap.ssr.svg :as svg]
[auto-ap.ssr.utils
:refer [->db-id apply-middleware-to-all-handlers check-allowance
@@ -36,6 +38,7 @@
[bidi.bidi :as bidi]
[clj-time.coerce :as coerce]
[clojure.edn :as edn]
[clojure.string :as str]
[datomic.api :as dc]
[hiccup.util :as hu]
[iol-ion.query :refer [dollars=]]
@@ -153,20 +156,29 @@
(:vendor/default-account clientized))))
(defn location-select*
"The location <select> for an account row, rendered from a Selmer template
(templates/components/location-select.html) -- the first interactive modal component
migrated off Hiccup. Same options/selection/styling as the old com/select, emitted as
plain HTML and embedded back into the Hiccup row via the interop bridge."
[{:keys [name account-location client-locations value]}]
(let [options (into (cond account-location
[[account-location account-location]]
(let [options (cond account-location
[[account-location account-location]]
(seq client-locations)
(into [["Shared" "Shared"]]
(for [cl client-locations]
[cl cl]))
:else
[["Shared" "Shared"]]))]
(com/select {:options options
:name name
:value (or value (ffirst options))
:class "w-full"})))
(seq client-locations)
(into [["Shared" "Shared"]]
(for [cl client-locations]
[cl cl]))
:else
[["Shared" "Shared"]])
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 label] options]
{:value v :label label :selected (= v selected)})})))
(defn account-typeahead*
[{:keys [name value client-id x-model]}]