From 09465f44835aaeaf106eeabb25782e881d88f618 Mon Sep 17 00:00:00 2001 From: giles Date: Mon, 29 Jun 2026 19:15:11 +0000 Subject: [PATCH] host: removing a related post no longer clears the relate picker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bug: the edit page's remove button (on a current relation) was a plain boosted form — POST /unrelate -> 303 redirect -> the engine re-rendered #content, and the freshly-swapped relate picker came back EMPTY ("the list of posts to relate" was cleared). Fix: make the remove button an AJAX in-place delete, exactly like the relate candidate rows — each current-relation
  • gets an id and its form carries sx-post + sx-target=#cur-- + sx-swap=delete. unrelate-submit returns an empty 200 for that request so the engine deletes just that one row; #content is never re-rendered, so the picker is untouched. method+action stay for no-JS. The empty-200 is gated on the SX-Target header (sent only by the sx-post form), so a plain boosted form / no-JS POST still redirects + re-renders — the is-a-tag toggle and graceful degradation are unaffected. Tests (all red before the fix): - lib/host/playwright/relate-picker.spec.js: the remove-button test now asserts the picker still has candidates after a removal (the reproduction). - web/tests/test-relate-picker.sx: an SX engine test — removing a current relation deletes just that row and leaves the sibling picker's list intact. - lib/host/tests/blog.sx: the relation-editor renders the AJAX delete attrs; unrelate returns empty-200 with SX-Target and 303 without. Verified: host conformance 275/275, web engine suite 8/8, run-picker-check 2/2. Co-Authored-By: Claude Opus 4.8 --- lib/host/blog.sx | 30 +++++++++++-- lib/host/playwright/relate-picker.spec.js | 9 +++- lib/host/tests/blog.sx | 31 +++++++++++++ web/tests/test-relate-picker.sx | 55 +++++++++++++++++++++++ 4 files changed, 120 insertions(+), 5 deletions(-) diff --git a/lib/host/blog.sx b/lib/host/blog.sx index 57d68bf8..5343eb53 100644 --- a/lib/host/blog.sx +++ b/lib/host/blog.sx @@ -434,6 +434,15 @@ ;; so the blog degrades gracefully to plain server-rendered pages. (define host/blog--spa-req? (fn (req) (= (dream-header req "sx-request") "true"))) +;; An AJAX in-place row delete vs. a plain boosted form. The engine sends an +;; SX-Target header for an sx-post form (the relation-editor's remove button, +;; sx-target=#cur-…), but NOT for a plain boosted form (the is-a-tag toggle). So +;; this tells "delete just this row, empty 200" apart from "redirect + re-render". +(define host/blog--row-swap-req? + (fn (req) + (and (host/blog--spa-req? req) + (let ((t (dream-header req "sx-target"))) (and t (not (= t ""))))))) + (define host/blog--page (fn (req title body) (if (host/blog--spa-req? req) @@ -589,9 +598,18 @@ (cons (quote ul) (map (fn (s) (quasiquote - (li (a :href (unquote (str "/" s "/")) (unquote s)) " " + ;; AJAX unrelate: sx-post the removal, then sx-swap="delete" + ;; removes just THIS current-relation row (its sx-target) in + ;; place — NO #content re-render, so the relate picker (the + ;; "posts to relate" list) is left untouched. method+action + ;; stay for the no-JS fallback (plain POST -> 303 -> reload). + (li :id (unquote (str "cur-" kind "-" s)) + (a :href (unquote (str "/" s "/")) (unquote s)) " " (form :method "post" :style "display:inline" :action (unquote (str "/" slug "/unrelate")) + :sx-post (unquote (str "/" slug "/unrelate")) + :sx-target (unquote (str "#cur-" kind "-" s)) + :sx-swap "delete" (input :type "hidden" :name "other" :value (unquote s)) (input :type "hidden" :name "kind" :value (unquote kind)) (button :type "submit" "remove"))))) @@ -879,7 +897,7 @@ (dream-redirect (str "/" slug "/edit")))))))) ;; POST //unrelate — remove the relation to `other` under `kind` (default -;; "related"). Idempotent; redirects back to the edit page. +;; "related"). Idempotent. (define host/blog-unrelate-submit (fn (req) (let ((slug (dream-param req "slug")) @@ -888,7 +906,13 @@ (begin (when (and other (not (= other "")) (host/blog--kind-spec kind)) (host/blog-unrelate! slug other kind)) - (dream-redirect (str "/" slug "/edit")))))) + ;; AJAX in-place remove (the editor's sx-post, carries SX-Target): return an + ;; empty 200 so the current-relation row's sx-swap="delete" removes just that + ;; one row — no #content re-render, so the relate picker is untouched. A plain + ;; boosted form (the tag toggle) or a no-JS POST still redirects + re-renders. + (if (host/blog--row-swap-req? req) + (dream-html "") + (dream-redirect (str "/" slug "/edit"))))))) ;; GET //edit — edit form pre-filled with the post's current title, raw ;; sx_content (in a textarea — render-to-html escapes the text child, so the diff --git a/lib/host/playwright/relate-picker.spec.js b/lib/host/playwright/relate-picker.spec.js index eefe7baa..b7304168 100644 --- a/lib/host/playwright/relate-picker.spec.js +++ b/lib/host/playwright/relate-picker.spec.js @@ -63,10 +63,15 @@ test.describe('relate picker (browser-only)', () => { await waitReady(page); const relLink = page.locator('a[href="/picker-item-13/"]'); await expect(relLink).toHaveCount(1); // current relation present - // click its remove button — a plain boosted form (regression: this did nothing - // because bind-boost-form discarded the form's method/action) + // the picker is populated (empty filter -> first page of candidates) + await expect.poll(() => page.locator(RELROWS).count(), { timeout: 12000 }).toBeGreaterThan(0); + // click its remove button await page.locator('li:has(a[href="/picker-item-13/"]) button').click(); await expect(relLink).toHaveCount(0, { timeout: 12000 }); // relation removed + // REGRESSION: removing a relation must NOT clear "the list of posts to relate". + // (The old plain-boosted remove form redirected -> re-rendered #content and the + // picker came back empty; the AJAX in-place remove leaves the picker untouched.) + await expect.poll(() => page.locator(RELROWS).count(), { timeout: 12000 }).toBeGreaterThan(0); }); test('picker populates after a boosted SPA nav to the edit page', async ({ page }) => { diff --git a/lib/host/tests/blog.sx b/lib/host/tests/blog.sx index c28dcca8..a3dee264 100644 --- a/lib/host/tests/blog.sx +++ b/lib/host/tests/blog.sx @@ -295,6 +295,37 @@ (host-bl-test "relate-options omits the load-more sentinel on a short last page" (contains? (dream-resp-body (host-bl-app (host-bl-req "/alpha-post/relate-options"))) "rp-more") false) + +;; -- unrelate: AJAX in-place row delete (regression: removing a related post must +;; NOT clear the relate picker). The remove button is now an sx-post form that +;; deletes just its own current-relation row (sx-target=#cur-…, sx-swap=delete), +;; so #content is never re-rendered and the picker is left intact. -- +(host/blog-relate! "alpha-post" "beta-post" "related") +(host-bl-test "relation-editor remove button is an AJAX in-place delete" + (let ((html (render-page (host/blog--relation-editor "alpha-post" "related")))) + (list (contains? html "id=\"cur-related-beta-post\"") ;; row has a target id + (contains? html "sx-post=\"/alpha-post/unrelate\"") ;; AJAX, not plain post + (contains? html "sx-target=\"#cur-related-beta-post\"") + (contains? html "sx-swap=\"delete\""))) + (list true true true true)) +;; the AJAX remove (carries SX-Target) returns an empty 200 so only the row is +;; swapped out — no redirect, no #content re-render that would blank the picker. +(host-bl-test "unrelate (AJAX, SX-Target) returns an empty 200" + (let ((resp (host/blog-unrelate-submit + (dream-request "POST" "/alpha-post/unrelate" + {:sx-request "true" :sx-target "#cur-related-beta-post"} + "other=beta-post&kind=related")))) + (list (dream-status resp) (dream-resp-body resp))) + (list 200 "")) +;; a plain boosted form / no-JS POST (no SX-Target) still redirects + re-renders, +;; so the is-a-tag toggle and graceful degradation are unaffected. +(host/blog-relate! "alpha-post" "beta-post" "related") +(host-bl-test "unrelate (plain boosted / no-JS, no SX-Target) still redirects" + (dream-status (host/blog-unrelate-submit + (dream-request "POST" "/alpha-post/unrelate" + {:sx-request "true"} "other=beta-post&kind=related"))) + 303) +(host/blog-unrelate! "alpha-post" "beta-post" "related") (host/blog-put! "hint-post" "Hint Post" "(p \"h\")" "published") (host-bl-test "relations section: hint when logged-in + no relations" (contains? (str (host/blog--relations-or-hint "hint-post" true)) "add some") true) diff --git a/web/tests/test-relate-picker.sx b/web/tests/test-relate-picker.sx index e7927d50..2070bcfb 100644 --- a/web/tests/test-relate-picker.sx +++ b/web/tests/test-relate-picker.sx @@ -136,6 +136,23 @@ "" "
  • "))) +;; One CURRENT-relation row, as host/blog--relation-editor renders it: an AJAX +;; in-place remove (sx-post + sx-target=#cur-… + sx-swap=delete). +(define cur-row-html + (fn (slug kind other label) + (str + "
  • " + "" label " " + "
    " + "" + "" + "" + "
    " + "
  • "))) + ;; The "load more" sentinel, as host/blog--picker-more renders it. (define sentinel-html (fn (slug kind offset) @@ -290,6 +307,44 @@ (assert-equal 3 (count-candidates results)) (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.) +(defsuite + "relate-picker:unrelate-keeps-picker" + (deftest + "removing a current relation deletes just that row, leaving the picker intact" + (reset-fetch-mock!) + ;; the AJAX unrelate returns an empty 200 (like the host); sx-swap=delete then + ;; removes the current-relation row in place. + (let + ((root (mk-root (str + "
    " + "
    "))) + (cur-box (dom-query "#cur-box")) + (res-box (dom-query "#res-box"))) + (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"))) + (clear-root! root)))) + ;; ── Phase 3: the engine drives a non-browser target (the console) ─── ;; render-to-console (web/console-render.sx) prints the live engine tree as text. ;; These assert the picker's terminal rendering directly on a built tree — the