host: relate/unrelate keep both lists in sync (add to current list, never blank the picker)
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 30s
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 30s
Two reported bugs on the edit page's relation editor:
1. relating a candidate didn't add it to the current-relations list (the AJAX
relate just deleted the candidate row; the relation only showed after a reload);
2. removing a relation could blank the relate picker.
Fix (lib/host/blog.sx): both the candidate's relate form and a current relation's
remove form now target #rel-editor-<kind> with sx-swap=outerHTML, and the
relate/unrelate handlers return the re-rendered editor for that kind (current list +
a fresh picker). So one swap keeps BOTH lists in sync: the related post moves into
the current list and out of the (re-loaded) candidate pool; removing moves it back.
Gated on the SX-Target header, so a plain boosted form / no-JS POST (the is-a-tag
toggle) still redirects + re-renders #content.
Engine fix (web/orchestration.sx): handle-html-response's non-select branch called
post-swap on the OLD target, which an outerHTML swap has already REPLACED — so the
swapped-in content's triggers (here the re-rendered picker's "load") never bound and
the picker stayed empty. post-swap the swap result (the new node), mirroring the
sx-select branch. Recompiled orchestration.sxbc for the content-addressed client.
Tests:
- web/tests/test-relate-picker.sx: relating re-syncs the editor (post in current
list + picker re-loads); removing does likewise — both fail without the engine fix.
- lib/host/tests/blog.sx: relate/unrelate return the re-rendered editor fragment
(200, #rel-editor + picker), forms wire to #rel-editor-KIND/outerHTML, plain
boosted POST still 303.
- relate-picker.spec.js: the full in-page flow (relate adds to list, remove keeps
the picker, no reload) + persistence.
Verified: host conformance 277/277, web engine suite 8/8, run-picker-check 3/3,
run-spa-check 3/3.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -24,7 +24,8 @@
|
||||
(define _mock-fetch-ok true)
|
||||
(define _mock-fetch-status 200)
|
||||
(define _mock-fetch-ct "text/html; charset=utf-8")
|
||||
(define _mock-fetch-body "")
|
||||
(define _mock-fetch-body "") ;; the GET /relate-options (picker) response
|
||||
(define _mock-editor-body "") ;; the POST /relate|/unrelate (editor) response
|
||||
(define _mock-fetch-fail false)
|
||||
|
||||
(define reset-fetch-mock!
|
||||
@@ -34,16 +35,20 @@
|
||||
(set! _mock-fetch-status 200)
|
||||
(set! _mock-fetch-ct "text/html; charset=utf-8")
|
||||
(set! _mock-fetch-body "")
|
||||
(set! _mock-editor-body "")
|
||||
(set! _mock-fetch-fail false)))
|
||||
|
||||
;; URL-aware mock: the picker GETs relate-options (returns candidate rows); a relate
|
||||
;; or unrelate POST returns the re-rendered editor fragment.
|
||||
(define fetch-request
|
||||
(fn (config success-fn error-fn)
|
||||
(set! _mock-fetch-calls (+ _mock-fetch-calls 1))
|
||||
(if _mock-fetch-fail
|
||||
(error-fn "network error")
|
||||
(success-fn _mock-fetch-ok _mock-fetch-status
|
||||
(fn (name) (if (= name "content-type") _mock-fetch-ct nil))
|
||||
_mock-fetch-body))))
|
||||
(let ((url (or (get config "url") "")))
|
||||
(success-fn _mock-fetch-ok _mock-fetch-status
|
||||
(fn (name) (if (= name "content-type") _mock-fetch-ct nil))
|
||||
(if (contains? url "relate-options") _mock-fetch-body _mock-editor-body))))))
|
||||
|
||||
;; ── harness platform shims ──────────────────────────────────────────
|
||||
;; Reactive hydration + island disposal live in web/boot.sx (the browser boot
|
||||
@@ -121,38 +126,57 @@
|
||||
"<ul id=\"rp-" kind "-results\" class=\"rp-results\"></ul>"
|
||||
"</form>")))
|
||||
|
||||
;; One candidate row, as host/blog--picker-item renders it.
|
||||
;; One candidate row, as host/blog--picker-item renders it: relating re-renders the
|
||||
;; whole editor (sx-target=#rel-editor-KIND, sx-swap=outerHTML).
|
||||
(define row-html
|
||||
(fn (slug kind cand title)
|
||||
(str
|
||||
"<li id=\"cand-" kind "-" cand "\">"
|
||||
"<form method=\"post\" action=\"/" slug "/relate\""
|
||||
" sx-post=\"/" slug "/relate\""
|
||||
" sx-target=\"#cand-" kind "-" cand "\""
|
||||
" sx-swap=\"delete\">"
|
||||
" sx-target=\"#rel-editor-" kind "\""
|
||||
" sx-swap=\"outerHTML\">"
|
||||
"<input type=\"hidden\" name=\"other\" value=\"" cand "\">"
|
||||
"<input type=\"hidden\" name=\"kind\" value=\"" kind "\">"
|
||||
"<button type=\"submit\">" title "</button>"
|
||||
"</form>"
|
||||
"</li>")))
|
||||
|
||||
;; One CURRENT-relation row, as host/blog--relation-editor renders it: an AJAX
|
||||
;; in-place remove (sx-post + sx-target=#cur-… + sx-swap=delete).
|
||||
;; One CURRENT-relation row, as host/blog--relation-editor renders it: removing
|
||||
;; re-renders the whole editor (sx-target=#rel-editor-KIND, sx-swap=outerHTML).
|
||||
(define cur-row-html
|
||||
(fn (slug kind other label)
|
||||
(str
|
||||
"<li id=\"cur-" kind "-" other "\">"
|
||||
"<li>"
|
||||
"<a href=\"/" other "/\">" label "</a> "
|
||||
"<form method=\"post\" action=\"/" slug "/unrelate\""
|
||||
" sx-post=\"/" slug "/unrelate\""
|
||||
" sx-target=\"#cur-" kind "-" other "\""
|
||||
" sx-swap=\"delete\">"
|
||||
" sx-target=\"#rel-editor-" kind "\""
|
||||
" sx-swap=\"outerHTML\">"
|
||||
"<input type=\"hidden\" name=\"other\" value=\"" other "\">"
|
||||
"<input type=\"hidden\" name=\"kind\" value=\"" kind "\">"
|
||||
"<button type=\"submit\">remove</button>"
|
||||
"</form>"
|
||||
"</li>")))
|
||||
|
||||
;; The kind's relation editor: #rel-editor-KIND wrapping the current-relations list
|
||||
;; and the picker (with `candidates` already in its results <ul>). This is what the
|
||||
;; relate/unrelate handlers return and what the initial edit page renders.
|
||||
(define editor-html
|
||||
(fn (kind current candidates)
|
||||
(str
|
||||
"<div id=\"rel-editor-" kind "\">"
|
||||
"<h3>" kind "</h3>"
|
||||
"<ul class=\"rp-current\">" current "</ul>"
|
||||
"<form class=\"relate-picker\" data-slug=\"host\" data-kind=\"" kind "\""
|
||||
" sx-get=\"/host/relate-options\" sx-trigger=\"input delay:200ms, load\""
|
||||
" sx-target=\"#rp-" kind "-results\" sx-swap=\"innerHTML\">"
|
||||
"<input type=\"hidden\" name=\"kind\" value=\"" kind "\">"
|
||||
"<input type=\"text\" name=\"q\" class=\"rp-filter\">"
|
||||
"<ul id=\"rp-" kind "-results\" class=\"rp-results\">" candidates "</ul>"
|
||||
"</form>"
|
||||
"</div>")))
|
||||
|
||||
;; The "load more" sentinel, as host/blog--picker-more renders it.
|
||||
(define sentinel-html
|
||||
(fn (slug kind offset)
|
||||
@@ -174,28 +198,38 @@
|
||||
(loop (+ i 1)))
|
||||
acc)))))
|
||||
|
||||
;; ── Phase 0: relate -> delete row ───────────────────────────────────
|
||||
;; ── relating a candidate re-syncs the editor (adds to the current list) ─
|
||||
;; Regression for "adding a related post doesn't add it to the list": the candidate
|
||||
;; relate form re-renders the whole #rel-editor-KIND (outerHTML), so the related
|
||||
;; post moves into the current-relations list and the fresh picker re-loads — both
|
||||
;; lists stay in sync, in-page, no reload.
|
||||
(defsuite
|
||||
"relate-picker:relate-delete"
|
||||
"relate-picker:relate-resyncs-editor"
|
||||
(deftest
|
||||
"submitting a candidate's relate form deletes just that row"
|
||||
"relating a candidate adds it to the current list and re-loads the picker"
|
||||
(reset-fetch-mock!)
|
||||
;; the AJAX relate returns an empty 200 (text/html); sx-swap=delete then
|
||||
;; removes the candidate's own <li> — this is the host's real response.
|
||||
(reset-reveal!)
|
||||
;; the picker's load shows one candidate (item-07) to click
|
||||
(set! _mock-fetch-body (row-html "host" "related" "item-07" "Picker Item 07"))
|
||||
(let
|
||||
((root (mk-root (str "<ul class=\"rp-results\">"
|
||||
(row-html "host" "related" "item-07" "Picker Item 07")
|
||||
"</ul>")))
|
||||
(results (dom-query ".rp-results"))
|
||||
(form (dom-query "form")))
|
||||
((root (mk-root (str "<div id=\"content\">"
|
||||
(editor-html "related" "" "")
|
||||
"</div>")))
|
||||
(results (dom-query "#rp-related-results")))
|
||||
(process-elements root)
|
||||
;; one candidate row before
|
||||
;; load populated the candidate; current list empty
|
||||
(assert-equal 1 (count-candidates results))
|
||||
;; submit the relate form -> execute-request -> mock fetch -> delete swap
|
||||
(fire-event! form "submit")
|
||||
;; the fetch actually ran, and the row is gone
|
||||
(assert-true (> _mock-fetch-calls 0))
|
||||
(assert-equal 0 (count-candidates results))
|
||||
(assert-nil (dom-query "a[href=\"/item-07/\"]"))
|
||||
;; clicking it: the relate POST returns the re-rendered editor (item-07 now in
|
||||
;; the current list), and the fresh picker re-loads OTHER candidates.
|
||||
(set! _mock-editor-body
|
||||
(editor-html "related" (cur-row-html "host" "related" "item-07" "Picker Item 07") ""))
|
||||
(set! _mock-fetch-body (rows-html "host" "related" 8 11)) ;; refreshed pool
|
||||
(fire-event! (dom-query "#cand-related-item-07 form") "submit")
|
||||
;; the related post is now in the current list (added, not just removed)...
|
||||
(assert-true (not (nil? (dom-query "a[href=\"/item-07/\"]"))))
|
||||
;; ...and the picker is NOT cleared — it re-loaded a fresh candidate page
|
||||
(assert-equal 3 (count-candidates (dom-query "#rp-related-results")))
|
||||
(clear-root! root))))
|
||||
|
||||
;; ── Phase 1: load / filter / paging / error-retry ───────────────────
|
||||
@@ -308,41 +342,39 @@
|
||||
(clear-root! root))))
|
||||
|
||||
;; ── regression: removing a relation must not clear the relate picker ─
|
||||
;; The remove button is an AJAX in-place delete (sx-post + sx-swap=delete on its own
|
||||
;; current-relation row). Submitting it deletes ONLY that row — the sibling picker's
|
||||
;; candidate list is left intact, because #content is never re-rendered. (Bug: the
|
||||
;; old plain-boosted remove redirected and the re-rendered picker came back empty.)
|
||||
;; The remove button re-renders the kind's editor (outerHTML #rel-editor-KIND): the
|
||||
;; row leaves the current list and the FRESH picker re-loads its candidates. (Bug:
|
||||
;; the old plain-boosted remove redirected and the re-rendered picker came back
|
||||
;; empty — here the picker repopulates instead of clearing to 0.)
|
||||
(defsuite
|
||||
"relate-picker:unrelate-keeps-picker"
|
||||
(deftest
|
||||
"removing a current relation deletes just that row, leaving the picker intact"
|
||||
"removing a current relation re-syncs the editor and re-loads the picker"
|
||||
(reset-fetch-mock!)
|
||||
;; the AJAX unrelate returns an empty 200 (like the host); sx-swap=delete then
|
||||
;; removes the current-relation row in place.
|
||||
(reset-reveal!)
|
||||
;; start: Beta + Gamma are related; the picker (after load) shows nothing yet
|
||||
(set! _mock-fetch-body "")
|
||||
(let
|
||||
((root (mk-root (str
|
||||
"<div id=\"cur-box\"><ul>"
|
||||
(cur-row-html "host" "related" "beta" "Beta")
|
||||
(cur-row-html "host" "related" "gamma" "Gamma")
|
||||
"</ul></div>"
|
||||
"<div id=\"res-box\"><ul class=\"rp-results\">"
|
||||
(row-html "host" "related" "delta" "Picker Delta")
|
||||
(row-html "host" "related" "epsilon" "Picker Epsilon")
|
||||
"</ul></div>")))
|
||||
(cur-box (dom-query "#cur-box"))
|
||||
(res-box (dom-query "#res-box")))
|
||||
((root (mk-root (str "<div id=\"content\">"
|
||||
(editor-html "related"
|
||||
(str (cur-row-html "host" "related" "beta" "Beta")
|
||||
(cur-row-html "host" "related" "gamma" "Gamma"))
|
||||
"")
|
||||
"</div>"))))
|
||||
(process-elements root)
|
||||
;; two current relations, two picker candidates to start
|
||||
(assert-equal 2 (len (dom-query-all cur-box "li")))
|
||||
(assert-equal 2 (len (dom-query-all res-box "li")))
|
||||
;; remove Beta — submit its in-place remove form
|
||||
(fire-event! (dom-query (dom-query "#cur-related-beta") "form") "submit")
|
||||
;; just Beta's row is gone; Gamma remains
|
||||
(assert-nil (dom-query "#cur-related-beta"))
|
||||
(assert-true (not (nil? (dom-query "#cur-related-gamma"))))
|
||||
(assert-equal 1 (len (dom-query-all cur-box "li")))
|
||||
;; and the picker's candidate list is UNTOUCHED — the bug was it cleared to 0
|
||||
(assert-equal 2 (len (dom-query-all res-box "li")))
|
||||
(assert-equal 2 (len (dom-query-all (dom-query "#rel-editor-related") "ul.rp-current li")))
|
||||
;; remove Beta: the unrelate POST returns the editor with only Gamma current,
|
||||
;; and the fresh picker re-loads candidates (Beta is available again).
|
||||
(set! _mock-editor-body
|
||||
(editor-html "related" (cur-row-html "host" "related" "gamma" "Gamma") ""))
|
||||
(set! _mock-fetch-body (rows-html "host" "related" 0 4))
|
||||
(fire-event! (dom-query "form") "submit") ;; Beta's remove form (first form)
|
||||
;; Beta is gone from the current list; Gamma stays
|
||||
(assert-nil (dom-query "a[href=\"/beta/\"]"))
|
||||
(assert-true (not (nil? (dom-query "a[href=\"/gamma/\"]"))))
|
||||
(assert-equal 1 (len (dom-query-all (dom-query "#rel-editor-related") "ul.rp-current li")))
|
||||
;; and the picker is NOT cleared — it re-loaded a fresh candidate page
|
||||
(assert-equal 4 (count-candidates (dom-query "#rp-related-results")))
|
||||
(clear-root! root))))
|
||||
|
||||
;; ── Phase 3: the engine drives a non-browser target (the console) ───
|
||||
|
||||
Reference in New Issue
Block a user