Compare commits
3 Commits
a4fde446fc
...
docs/ssr-r
| Author | SHA1 | Date | |
|---|---|---|---|
| d360316590 | |||
| 0e02c489e0 | |||
| 917b7f3857 |
@@ -15,14 +15,22 @@ skill* that makes the next migration cheaper.
|
|||||||
## 1. Goals
|
## 1. Goals
|
||||||
|
|
||||||
1. **Render forms by re-rendering the whole form** (or a precise, isolated
|
1. **Render forms by re-rendering the whole form** (or a precise, isolated
|
||||||
fragment) over HTMX, instead of mutating the DOM in place. This removes the
|
fragment) over HTMX, using hx-select to choose elements, instead of mutating
|
||||||
class of bugs around stale state, lost focus/caret, and out-of-band patching.
|
the DOM in place. This removes the class of bugs around stale state, lost
|
||||||
2. **Make render functions pure.** A render function takes an explicit data map
|
focus/caret, and out-of-band patching.
|
||||||
and returns markup. No dynamic bindings, no "cursor" context, no duplicate
|
2. **Root cursors at the top; never fake their position.** Cursors are fine and
|
||||||
`*-no-cursor*` variants.
|
stay — a render function may take an explicit data map *or* a cursor. What we
|
||||||
|
remove is the practice of **faking a cursor to start deeper** in the tree to
|
||||||
|
satisfy a partial render, and the duplicate `*-no-cursor*` variants that
|
||||||
|
fakery forces. The target: a cursor always begins at the top level of what the
|
||||||
|
form consumes and walks down naturally from there. (Because the whole form is
|
||||||
|
re-rendered each time, there is no longer any reason to fake a deep starting
|
||||||
|
position.)
|
||||||
3. **Stop forcing single-step forms through wizard machinery.** Most "wizards"
|
3. **Stop forcing single-step forms through wizard machinery.** Most "wizards"
|
||||||
are single-step; they become plain forms. Genuine multi-step flows use a
|
are single-step; they become plain forms. Genuine multi-step flows use a
|
||||||
small data-driven engine instead of protocols + middleware stacking.
|
small data-driven engine instead of protocols + middleware stacking, and
|
||||||
|
**store each step's data in the session** (combined only at the end) instead
|
||||||
|
of round-tripping and merging an EDN snapshot — the Django `formtools` model.
|
||||||
4. **Render HTML with Selmer templates** (Jinja-style) instead of Hiccup for the
|
4. **Render HTML with Selmer templates** (Jinja-style) instead of Hiccup for the
|
||||||
interactive, attribute-heavy components, so Alpine/HTMX attributes are
|
interactive, attribute-heavy components, so Alpine/HTMX attributes are
|
||||||
first-class HTML rather than a mix of Clojure keywords and strings.
|
first-class HTML rather than a mix of Clojure keywords and strings.
|
||||||
@@ -44,13 +52,17 @@ form** and letting the typed value ride along in the form is simpler and
|
|||||||
correct, *provided the input the user is typing in is never inside the region
|
correct, *provided the input the user is typing in is never inside the region
|
||||||
being swapped*.
|
being swapped*.
|
||||||
|
|
||||||
### 2.2 Cursor-based rendering forces duplicate functions
|
### 2.2 Faking cursor positions forces duplicate functions
|
||||||
Render code that reads from dynamic bindings (a "form cursor") is
|
A "form cursor" itself is fine. The pain comes from **faking the cursor's
|
||||||
context-dependent and hard to test, which has spawned duplicate render functions
|
starting position** — rebinding the dynamic root deeper in the tree so a deeply
|
||||||
— one that reads the cursor and one that takes plain params:
|
nested render function can run against a fragment. That fakery is fragile and
|
||||||
|
hard to follow, and it has spawned duplicate render functions: one that reads the
|
||||||
|
faked cursor and one that takes plain params for the cases where the fake can't
|
||||||
|
be set up.
|
||||||
|
|
||||||
```clojure
|
```clojure
|
||||||
;; SMELL: needs cursor context (dynamic bindings *form-data* / *current* / *prefix*)
|
;; SMELL: this render fn assumes the cursor was faked to start deep at an account,
|
||||||
|
;; so it only works when *current*/*prefix* were rebound to point there first.
|
||||||
(defn account-row* [{:keys [value client-id]}]
|
(defn account-row* [{:keys [value client-id]}]
|
||||||
(com/data-grid-row
|
(com/data-grid-row
|
||||||
(fc/with-field :transaction-account/account
|
(fc/with-field :transaction-account/account
|
||||||
@@ -58,18 +70,30 @@ context-dependent and hard to test, which has spawned duplicate render functions
|
|||||||
(account-typeahead* {:value (fc/field-value) :name (fc/field-name)})))
|
(account-typeahead* {:value (fc/field-value) :name (fc/field-name)})))
|
||||||
...))
|
...))
|
||||||
|
|
||||||
;; SMELL: a second copy of the same markup, just to avoid the cursor
|
;; SMELL: a second copy of the same markup, just to avoid the faked-deep cursor
|
||||||
(defn account-row-no-cursor* [{:keys [account index client-id]}]
|
(defn account-row-no-cursor* [{:keys [account index client-id]}]
|
||||||
...)
|
...)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Target:** the cursor starts at the top of the form's data and walks down
|
||||||
|
naturally; a row render either takes explicit row data or receives a cursor the
|
||||||
|
caller advanced step-by-step from the root — never one teleported to a deep node.
|
||||||
|
|
||||||
### 2.3 Single-step forms wear wizard costumes
|
### 2.3 Single-step forms wear wizard costumes
|
||||||
Several forms implement a multi-step wizard protocol (5 protocols, 15+ methods),
|
Several forms implement a multi-step wizard protocol (5 protocols, 15+ methods),
|
||||||
serialize an EDN snapshot with custom readers into hidden fields, and register
|
serialize an EDN snapshot with custom readers into hidden fields, and register
|
||||||
10–20 routes with stacked middleware — all for a single-step form. That is pure
|
10–20 routes with stacked middleware — all for a single-step form. That is pure
|
||||||
overhead.
|
overhead.
|
||||||
|
|
||||||
### 2.4 Hiccup makes Alpine/HTMX attributes ambiguous
|
### 2.4 Multi-step wizards round-trip and merge a snapshot
|
||||||
|
The genuine multi-step wizards carry the whole accumulating form state as an EDN
|
||||||
|
snapshot in hidden fields, then rebuild it each request by merging the posted
|
||||||
|
pieces back into the snapshot. The serialization needs custom readers, the merge
|
||||||
|
logic is error-prone, and the page payload grows with every step. The fix is to
|
||||||
|
**store each step's data in the session under its own key and combine only at the
|
||||||
|
end** — the Django `formtools` model (§3.3) — so no snapshot is built or merged.
|
||||||
|
|
||||||
|
### 2.5 Hiccup makes Alpine/HTMX attributes ambiguous
|
||||||
The same attribute is sometimes a keyword and sometimes a string in the same
|
The same attribute is sometimes a keyword and sometimes a string in the same
|
||||||
file, and event handlers must be strings while structural Alpine attrs are
|
file, and event handlers must be strings while structural Alpine attrs are
|
||||||
keywords. There is no rule a reader (or an LLM) can rely on:
|
keywords. There is no rule a reader (or an LLM) can rely on:
|
||||||
@@ -163,9 +187,43 @@ state:
|
|||||||
(assoc attrs :key (str id "--" current-value))
|
(assoc attrs :key (str id "--" current-value))
|
||||||
```
|
```
|
||||||
|
|
||||||
### 3.2 Pure render functions
|
**Selector strategy for targeted swaps (a consideration, not a mandate).**
|
||||||
|
Rules 2 and 4 above need a stable `hx-target`/`hx-select`. The obvious approach
|
||||||
|
— a unique `id` on every swappable element — gets noisy in repeated structures
|
||||||
|
(e.g. a table of financial accounts where choosing an account must swap *that
|
||||||
|
row's* dropdown). When you reach those advanced cases, consider a more
|
||||||
|
consistent scheme instead of hand-minting ids everywhere:
|
||||||
|
|
||||||
One function, explicit data in, markup out:
|
- **Semantic markup + data-attributes** to craft a fine-grained selector without
|
||||||
|
per-element ids. For example, mark rows/cells with their identity and target
|
||||||
|
by attribute:
|
||||||
|
```html
|
||||||
|
<tr data-row="account" data-index="0">
|
||||||
|
<td data-cell="account">
|
||||||
|
<select hx-post="/transaction/edit-form-changed"
|
||||||
|
hx-target="[data-row='account'][data-index='0'] [data-cell='location']"
|
||||||
|
hx-select="[data-row='account'][data-index='0'] [data-cell='location']"
|
||||||
|
hx-swap="outerHTML" hx-trigger="changed">…</select>
|
||||||
|
</td>
|
||||||
|
<td data-cell="location">…</td>
|
||||||
|
</tr>
|
||||||
|
```
|
||||||
|
- **A `form-path -> id` (or `-> selector`) function**, derived the same way a
|
||||||
|
cursor path is, so the server and the markup agree on the target by
|
||||||
|
construction rather than by convention. A render fn at form-path
|
||||||
|
`[:accounts 0 :location]` would compute its own stable selector (id or
|
||||||
|
data-attribute query) from that path, mirroring §3.2's top-rooted cursor.
|
||||||
|
|
||||||
|
The aim is *consistency and predictability* of swap targets in repeated/nested
|
||||||
|
structures — pick whichever keeps targets unambiguous and easy to generate. Note
|
||||||
|
this in `reference/swap-doctrine.md` and let the first modal that hits nested
|
||||||
|
repeated swaps (Phase 5 / the wizards) settle on a convention for the cookbook.
|
||||||
|
|
||||||
|
### 3.2 Render functions: explicit data, or a top-rooted cursor
|
||||||
|
|
||||||
|
One function, data in, markup out. The data can arrive as a plain map or via a
|
||||||
|
cursor — **as long as the cursor was rooted at the top of the form and walked
|
||||||
|
down to here**, never faked to start at this depth.
|
||||||
|
|
||||||
```clojure
|
```clojure
|
||||||
;; GOOD: pure, works everywhere, testable without setup
|
;; GOOD: pure, works everywhere, testable without setup
|
||||||
@@ -180,8 +238,19 @@ One function, explicit data in, markup out:
|
|||||||
...))
|
...))
|
||||||
```
|
```
|
||||||
|
|
||||||
If a caller still has a cursor, give it a *thin* wrapper that adapts cursor →
|
```clojure
|
||||||
data and calls the pure function. Never duplicate the markup.
|
;; ALSO FINE: a cursor that started at the form root and was advanced naturally.
|
||||||
|
;; The top-level render walks the cursor; the row fn receives the dereferenced
|
||||||
|
;; row (or the advanced cursor) — no rebinding of *current*/*prefix* to fake depth.
|
||||||
|
(defn account-rows [accounts-cursor]
|
||||||
|
(for [row-cursor (fc/each accounts-cursor)] ; advanced from the root, not faked
|
||||||
|
(account-row {:account @row-cursor :index (fc/index row-cursor) ...})))
|
||||||
|
```
|
||||||
|
|
||||||
|
The rule is about *where the cursor starts*, not whether you use one. If a caller
|
||||||
|
already holds a top-rooted cursor, advance it and hand the row data (or the
|
||||||
|
advanced cursor) to one render function. Never rebind the cursor to teleport to a
|
||||||
|
deep node, and never keep a second `*-no-cursor*` copy of the markup.
|
||||||
|
|
||||||
### 3.3 Forms vs. wizards (and the data-driven wizard engine)
|
### 3.3 Forms vs. wizards (and the data-driven wizard engine)
|
||||||
|
|
||||||
@@ -193,7 +262,21 @@ data and calls the pure function. Never duplicate the markup.
|
|||||||
::route/edit-submit (fn [req] (validate-and-save req))}
|
::route/edit-submit (fn [req] (validate-and-save req))}
|
||||||
```
|
```
|
||||||
|
|
||||||
- **Genuinely multi-step → data-driven engine.** A wizard is *data*:
|
- **Genuinely multi-step → data-driven engine with session-stored step state.**
|
||||||
|
|
||||||
|
> **Inspiration — Django `formtools` `WizardView`.** Django's wizard does *not*
|
||||||
|
> round-trip a serialized blob of the whole form through the page. Each step's
|
||||||
|
> validated (cleaned) data is written to a **storage backend (the user session
|
||||||
|
> by default)** under that step's key, and the steps are combined only at the
|
||||||
|
> very end via `get_all_cleaned_data()`. We adopt the same model: **replace the
|
||||||
|
> EDN snapshot + piecewise merging with per-step form state stored in the
|
||||||
|
> session.** A step writes its own data under its own key; nothing is merged
|
||||||
|
> into a snapshot and nothing about other steps rides through the form.
|
||||||
|
> Refs: `formtools.wizard.views.WizardView`, its `storage` backends
|
||||||
|
> (`SessionStorage`), and `get_all_cleaned_data()`
|
||||||
|
> (https://django-formtools.readthedocs.io/en/latest/wizard.html).
|
||||||
|
|
||||||
|
A wizard is *data*:
|
||||||
```clojure
|
```clojure
|
||||||
(def vendor-wizard-config
|
(def vendor-wizard-config
|
||||||
{:steps [{:key :info :schema info-schema :fields [...] :render render-info-step
|
{:steps [{:key :info :schema info-schema :fields [...] :render render-info-step
|
||||||
@@ -204,41 +287,57 @@ data and calls the pure function. Never duplicate the markup.
|
|||||||
:submit-route "/admin/vendor/wizard/submit"
|
:submit-route "/admin/vendor/wizard/submit"
|
||||||
:done-fn (fn [all-data req] (save! all-data) (html-response "Saved"))})
|
:done-fn (fn [all-data req] (save! all-data) (html-response "Saved"))})
|
||||||
```
|
```
|
||||||
with a tiny engine (no protocols) and server-side state keyed by a UUID token:
|
with a tiny engine (no protocols) whose state lives **in the session**, keyed
|
||||||
|
by a wizard instance id, with each step's data stored under its own step key —
|
||||||
|
the formtools `SessionStorage` model. No snapshot, no custom EDN readers, no
|
||||||
|
merge-into-snapshot:
|
||||||
```clojure
|
```clojure
|
||||||
;; state: an atom keyed by wizard-id (add a timestamp + TTL sweep)
|
;; Storage backed by the Ring session (replaces the hidden EDN snapshot).
|
||||||
(defonce ^:private store (atom {}))
|
;; Path in session: [:wizards <wizard-id> :step-data <step-key>]
|
||||||
(defn create-wizard! [init] (let [id (str (java.util.UUID/randomUUID))]
|
(defn create-wizard! [session config]
|
||||||
(swap! store assoc id {:current-step (-> init :steps first :key)
|
(let [id (str (java.util.UUID/randomUUID))]
|
||||||
:step-data {} :created-at (System/currentTimeMillis)})
|
[id (assoc-in session [:wizards id]
|
||||||
id))
|
{:current-step (-> config :steps first :key) :step-data {}})]))
|
||||||
(defn update-step! [id k data] (swap! store update-in [id :step-data k] merge data))
|
|
||||||
(defn get-all [id] (apply merge (vals (:step-data (@store id)))))
|
|
||||||
|
|
||||||
(defn render-wizard [{:keys [wizard-id config request]}]
|
(defn put-step [session id k data] (assoc-in session [:wizards id :step-data k] data)) ; replace, not merge
|
||||||
(let [{:keys [current-step step-data]} (@store wizard-id)
|
(defn set-step [session id k] (assoc-in session [:wizards id :current-step] k))
|
||||||
|
(defn get-all [session id] (->> (get-in session [:wizards id :step-data]) vals (apply merge)))
|
||||||
|
(defn forget [session id] (update session :wizards dissoc id))
|
||||||
|
|
||||||
|
(defn render-wizard [{:keys [wizard-id config session request]}]
|
||||||
|
(let [{:keys [current-step step-data]} (get-in session [:wizards wizard-id])
|
||||||
step (first (filter #(= (:key %) current-step) (:steps config)))]
|
step (first (filter #(= (:key %) current-step) (:steps config)))]
|
||||||
[:form#wizard-form {:hx-post (:submit-route config)
|
[:form#wizard-form {:hx-post (:submit-route config)
|
||||||
:hx-target "#wizard-form" :hx-select "#wizard-form" :hx-swap "outerHTML"}
|
:hx-target "#wizard-form" :hx-select "#wizard-form" :hx-swap "outerHTML"}
|
||||||
|
;; only a reference token rides in the form -- not the form's state
|
||||||
(com/hidden {:name "wizard-id" :value wizard-id})
|
(com/hidden {:name "wizard-id" :value wizard-id})
|
||||||
(com/hidden {:name "current-step" :value (name current-step)})
|
(com/hidden {:name "current-step" :value (name current-step)})
|
||||||
((:render step) (assoc request :step-data (get step-data current-step {})))]))
|
((:render step) (assoc request :step-data (get step-data current-step {})))]))
|
||||||
|
|
||||||
(defn handle-step-submit [config request]
|
;; Handlers thread the (possibly updated) session back into the Ring response.
|
||||||
|
(defn handle-step-submit [config {:keys [session] :as request}]
|
||||||
(let [{:strs [wizard-id current-step]} (:form-params request)
|
(let [{:strs [wizard-id current-step]} (:form-params request)
|
||||||
step (first (filter #(= (:key %) (keyword current-step)) (:steps config)))
|
step (first (filter #(= (:key %) (keyword current-step)) (:steps config)))
|
||||||
data (select-keys (:form-params request) (map name (:fields step)))]
|
data (select-keys (:form-params request) (map name (:fields step)))]
|
||||||
(if-let [errors (mc/explain (:schema step) data)]
|
(if-let [errors (mc/explain (:schema step) data)]
|
||||||
(render-wizard {:wizard-id wizard-id :config config :request (assoc request :errors errors)})
|
(-> (render-wizard {:wizard-id wizard-id :config config :session session
|
||||||
(do (update-step! wizard-id (keyword current-step) data)
|
:request (assoc request :errors errors)})
|
||||||
(let [nxt ((:next step) data)]
|
html-response)
|
||||||
(if (= nxt :done)
|
(let [session' (put-step session wizard-id (keyword current-step) data)
|
||||||
(let [all (get-all wizard-id)] (swap! store dissoc wizard-id) ((:done-fn config) all request))
|
nxt ((:next step) data)]
|
||||||
(do (swap! store assoc-in [wizard-id :current-step] nxt)
|
(if (= nxt :done)
|
||||||
(render-wizard {:wizard-id wizard-id :config config :request request}))))))))
|
(-> ((:done-fn config) (get-all session' wizard-id) request) ; combine only at the end
|
||||||
|
(assoc :session (forget session' wizard-id)))
|
||||||
|
(let [session'' (set-step session' wizard-id nxt)]
|
||||||
|
(-> (html-response (render-wizard {:wizard-id wizard-id :config config
|
||||||
|
:session session'' :request request}))
|
||||||
|
(assoc :session session''))))))))
|
||||||
```
|
```
|
||||||
Two routes per wizard: open (`partial open-wizard config`) and submit
|
Two routes per wizard: open (`partial open-wizard config`) and submit
|
||||||
(`partial handle-step-submit config`).
|
(`partial handle-step-submit config`). State is namespaced by `wizard-id` inside
|
||||||
|
the session, so multiple in-flight wizards (and tabs) don't collide, and it is
|
||||||
|
discarded on completion (`forget`). See Open decision 1 for the storage-backend
|
||||||
|
choice (Ring session store vs. a durable store for long-lived wizards).
|
||||||
|
|
||||||
### 3.4 Selmer templates
|
### 3.4 Selmer templates
|
||||||
|
|
||||||
@@ -300,8 +399,9 @@ convention, e.g. `.claude/skills/testing-conventions/SKILL.md`).
|
|||||||
.claude/skills/ssr-form-migration/
|
.claude/skills/ssr-form-migration/
|
||||||
SKILL.md # the playbook (§8): classify → migrate → verify → record
|
SKILL.md # the playbook (§8): classify → migrate → verify → record
|
||||||
reference/
|
reference/
|
||||||
swap-doctrine.md # §3.1 rules, focus invariant, OOB-vs-hoist, Alpine hardening
|
swap-doctrine.md # §3.1 rules, focus invariant, OOB-vs-hoist, Alpine hardening,
|
||||||
pure-render.md # §3.2 pure functions + thin cursor adapters
|
# target-selector strategy (semantic/data-attr/form-path->id)
|
||||||
|
render-functions.md # §3.2 explicit-data or top-rooted cursor; no faked positions
|
||||||
form-vs-wizard.md # §3.3 classification + the data-driven engine
|
form-vs-wizard.md # §3.3 classification + the data-driven engine
|
||||||
selmer-conventions.md # §3.4 attr style, interop bridge, include/block patterns
|
selmer-conventions.md # §3.4 attr style, interop bridge, include/block patterns
|
||||||
component-cookbook.md # GROWS: typeahead, account-row, totals, money-input, mode-toggle…
|
component-cookbook.md # GROWS: typeahead, account-row, totals, money-input, mode-toggle…
|
||||||
@@ -331,7 +431,7 @@ the touched modal.**
|
|||||||
|
|
||||||
| # | Heuristic | Measure | Target |
|
| # | Heuristic | Measure | Target |
|
||||||
|---|-----------|---------|--------|
|
|---|-----------|---------|--------|
|
||||||
| 1 | Form-cursor / dynamic-binding usage | `grep -cE 'fc/with-field|\*form-data\*|\*current\*|\*prefix\*|-no-cursor'` | → 0 |
|
| 1 | Faked cursor positions (not cursors themselves) | count cursor-root rebinds (`binding` of `*current*`/`*prefix*`/`*form-data*`, or `with-field`/`with-*` used to *re-root* deeper) + `grep -c '\-no-cursor'` | → 0 (top-rooted cursors are fine) |
|
||||||
| 2 | Implicit state merges (snapshot/cursor) | count merge sites | → 0 (forms); explicit `update-step!` only (wizards) |
|
| 2 | Implicit state merges (snapshot/cursor) | count merge sites | → 0 (forms); explicit `update-step!` only (wizards) |
|
||||||
| 3 | Branching complexity | `clj-kondo`, or count `cond`/`condp`/`case`/nested `if` + max depth | net ↓ |
|
| 3 | Branching complexity | `clj-kondo`, or count `cond`/`condp`/`case`/nested `if` + max depth | net ↓ |
|
||||||
| 4 | Lines of code | `wc -l` on the modal's file(s) | net ↓ |
|
| 4 | Lines of code | `wc -l` on the modal's file(s) | net ↓ |
|
||||||
@@ -377,8 +477,9 @@ Modal phases below list only what is *specific* to that modal plus this loop.
|
|||||||
wizard (engine + server state). When in doubt, it's a form.
|
wizard (engine + server state). When in doubt, it's a form.
|
||||||
3. [ ] **Baseline the scorecard (§6).** Record before-numbers.
|
3. [ ] **Baseline the scorecard (§6).** Record before-numbers.
|
||||||
4. [ ] **Characterize behavior (test-first).** Write/confirm the e2e spec.
|
4. [ ] **Characterize behavior (test-first).** Write/confirm the e2e spec.
|
||||||
5. [ ] **Extract pure render functions** (kills cursor faking and `*-no-cursor*`
|
5. [ ] **Consolidate render functions** so they take explicit data or a
|
||||||
duplicates — heuristics 1, 2).
|
top-rooted cursor — remove faked cursor positions and `*-no-cursor*`
|
||||||
|
duplicates (heuristics 1, 2). Using a cursor is fine; faking its start is not.
|
||||||
6. [ ] **Templatize in Selmer**; reuse cookbook bits, add new ones back
|
6. [ ] **Templatize in Selmer**; reuse cookbook bits, add new ones back
|
||||||
(heuristics 5, 8).
|
(heuristics 5, 8).
|
||||||
7. [ ] **Wire HTMX per the swap doctrine** (§3.1); focus invariant intact; OOB
|
7. [ ] **Wire HTMX per the swap doctrine** (§3.1); focus invariant intact; OOB
|
||||||
@@ -423,7 +524,8 @@ acceptable prerequisite.)
|
|||||||
- [ ] Write `reference/swap-doctrine.md` from §3.1 (the four rules, focus
|
- [ ] Write `reference/swap-doctrine.md` from §3.1 (the four rules, focus
|
||||||
invariant, OOB-vs-hoist, Alpine hardening), using the real transaction-edit
|
invariant, OOB-vs-hoist, Alpine hardening), using the real transaction-edit
|
||||||
swaps as worked examples.
|
swaps as worked examples.
|
||||||
- [ ] Write `reference/pure-render.md` from §3.2.
|
- [ ] Write `reference/render-functions.md` from §3.2 (explicit data or a
|
||||||
|
top-rooted cursor; remove faked positions and `*-no-cursor*` duplicates).
|
||||||
- [ ] Write `reference/form-vs-wizard.md` from §3.3 (classification + engine).
|
- [ ] Write `reference/form-vs-wizard.md` from §3.3 (classification + engine).
|
||||||
- [ ] Stub `reference/selmer-conventions.md` from §3.4, marked "validated in
|
- [ ] Stub `reference/selmer-conventions.md` from §3.4, marked "validated in
|
||||||
Phase 2."
|
Phase 2."
|
||||||
@@ -487,8 +589,8 @@ apply it cold." Single-step form currently wearing a wizard costume.
|
|||||||
- [ ] Collapse 4 wizard routes → 2 (`GET` open, `POST` submit).
|
- [ ] Collapse 4 wizard routes → 2 (`GET` open, `POST` submit).
|
||||||
- [ ] Verify bulk-code applies correctly (assert DB) + full suite green.
|
- [ ] Verify bulk-code applies correctly (assert DB) + full suite green.
|
||||||
- [ ] Feed the skill; append scorecard row.
|
- [ ] Feed the skill; append scorecard row.
|
||||||
- [ ] **Exit criteria:** ≥2 cookbook entries reused; LOC, route-count, cursor-use
|
- [ ] **Exit criteria:** ≥2 cookbook entries reused; LOC, route-count, and
|
||||||
all down vs. baseline.
|
faked-cursor count all down vs. baseline.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -516,6 +618,10 @@ apply it cold." Single-step form currently wearing a wizard costume.
|
|||||||
cookbook entries from Phase 2.
|
cookbook entries from Phase 2.
|
||||||
- [ ] Add-row: a `POST` that appends a fresh row; totals re-render via the
|
- [ ] Add-row: a `POST` that appends a fresh row; totals re-render via the
|
||||||
sibling-`<tbody>` swap, **not** OOB.
|
sibling-`<tbody>` swap, **not** OOB.
|
||||||
|
- [ ] **Settle a target-selector convention** for repeated/nested rows (§3.1
|
||||||
|
"Selector strategy"): semantic data-attributes and/or a `form-path -> selector`
|
||||||
|
helper, rather than hand-minted ids per element. Record the chosen convention
|
||||||
|
in `reference/swap-doctrine.md` + `component-cookbook.md` so later wizards reuse it.
|
||||||
- [ ] Collapse 4 wizard routes → 3 (open, submit, add-row).
|
- [ ] Collapse 4 wizard routes → 3 (open, submit, add-row).
|
||||||
- [ ] Verify add/remove rows + totals + apply (assert DB) + full suite green.
|
- [ ] Verify add/remove rows + totals + apply (assert DB) + full suite green.
|
||||||
- [ ] Feed the skill; append scorecard row.
|
- [ ] Feed the skill; append scorecard row.
|
||||||
@@ -527,15 +633,22 @@ apply it cold." Single-step form currently wearing a wizard costume.
|
|||||||
### Phase 6 — Build the wizard engine + migrate Transaction Rule (2-step wizard)
|
### Phase 6 — Build the wizard engine + migrate Transaction Rule (2-step wizard)
|
||||||
|
|
||||||
**Rationale:** the first genuinely multi-step modal, and the simplest one — the
|
**Rationale:** the first genuinely multi-step modal, and the simplest one — the
|
||||||
right place to introduce the data-driven engine (§3.3) and server-side state.
|
right place to introduce the data-driven engine (§3.3) and **session-stored
|
||||||
|
per-step state** (the Django `formtools` model), replacing the EDN snapshot +
|
||||||
|
merge.
|
||||||
|
|
||||||
**Engine (do once, here):**
|
**Engine (do once, here):**
|
||||||
- [ ] Create `components/wizard_state.clj` (atom store, `create-wizard!`,
|
- [ ] Create `components/wizard_state.clj` backed by the **Ring session**:
|
||||||
`update-step!`, `get-all`, `destroy!`, **TTL sweep** for abandoned wizards).
|
`create-wizard!`, `put-step` (replace step data, do **not** merge into a
|
||||||
Test the lifecycle via REPL.
|
snapshot), `set-step`, `get-all` (combine only at the end), `forget`. State is
|
||||||
|
namespaced by `wizard-id` inside the session (`[:wizards <id> ...]`) so tabs
|
||||||
|
and concurrent wizards don't collide. Each fn returns the updated session for
|
||||||
|
the handler to thread into the Ring response. Test the lifecycle via REPL.
|
||||||
- [ ] Create `components/wizard2.clj` (`render-wizard`, `handle-step-submit`,
|
- [ ] Create `components/wizard2.clj` (`render-wizard`, `handle-step-submit`,
|
||||||
`open-wizard`). Test render + step navigation.
|
`open-wizard`) — engine threads session through and only `wizard-id` rides in
|
||||||
- [ ] Document the engine usage in `reference/form-vs-wizard.md`.
|
the form. Test render + step navigation + that no snapshot is emitted.
|
||||||
|
- [ ] Document the engine usage and the formtools inspiration in
|
||||||
|
`reference/form-vs-wizard.md`.
|
||||||
|
|
||||||
**Modal migration (run the §8 loop), specifics:**
|
**Modal migration (run the §8 loop), specifics:**
|
||||||
- [ ] Extract `render-edit-step` and `render-test-step` (the test step shows a
|
- [ ] Extract `render-edit-step` and `render-test-step` (the test step shows a
|
||||||
@@ -634,7 +747,7 @@ matches, emails, contact methods). Deliberately last, when the skill is richest.
|
|||||||
|
|
||||||
| Risk | Mitigation |
|
| Risk | Mitigation |
|
||||||
|------|------------|
|
|------|------------|
|
||||||
| Server restart loses in-flight wizard state | Server state only for true multi-step wizards; forms hold none. TTL + sweep; consider a durable store if a wizard is long-lived. |
|
| In-flight wizard state lost (restart / session expiry) | State lives in the session (formtools model), scoped to true multi-step wizards; plain forms hold none. Lifetime follows the session; for long-lived wizards choose a durable session backend or store (Open decision 1). `forget` on completion prevents session bloat. |
|
||||||
| Mixed Hiccup/Selmer interop gets messy | Build + prove the interop bridge in Phase 2 before broad use; strangler keeps both valid. |
|
| Mixed Hiccup/Selmer interop gets messy | Build + prove the interop bridge in Phase 2 before broad use; strangler keeps both valid. |
|
||||||
| Selmer loses Hiccup's structural testability | Lean on e2e + DB assertions; unit-test the data-prep functions, not markup. |
|
| Selmer loses Hiccup's structural testability | Lean on e2e + DB assertions; unit-test the data-prep functions, not markup. |
|
||||||
| Large files hide behavior (`clients.clj`, `edit.clj`) | They go last, after the skill is rich; characterization e2e first; split per step. |
|
| Large files hide behavior (`clients.clj`, `edit.clj`) | They go last, after the skill is rich; characterization e2e first; split per step. |
|
||||||
@@ -647,8 +760,12 @@ matches, emails, contact methods). Deliberately last, when the skill is richest.
|
|||||||
|
|
||||||
## 11. Open decisions
|
## 11. Open decisions
|
||||||
|
|
||||||
1. **Server state scope** — server-side state only for multi-step wizards, none
|
1. **Wizard state storage** — store multi-step state in the **Ring session**
|
||||||
for plain forms? *(recommended: yes)*
|
(Django `formtools` `SessionStorage` model), keyed by `wizard-id`, none for
|
||||||
|
plain forms? Confirm the session backend in use (in-memory vs. durable) is
|
||||||
|
acceptable for in-flight wizard lifetime, or pick a durable store for
|
||||||
|
long-lived flows. *(recommended: session storage, scoped to multi-step
|
||||||
|
wizards only)*
|
||||||
2. **Selmer scope** — convert only interactive/attribute-heavy components first
|
2. **Selmer scope** — convert only interactive/attribute-heavy components first
|
||||||
(hybrid), or all SSR files (full sweep)? *(recommended: hybrid, revisit in
|
(hybrid), or all SSR files (full sweep)? *(recommended: hybrid, revisit in
|
||||||
Phase 11)*
|
Phase 11)*
|
||||||
|
|||||||
Reference in New Issue
Block a user