From c0007740e7b44d046f1d63cf5be64dac0363685a Mon Sep 17 00:00:00 2001 From: giles Date: Mon, 29 Jun 2026 15:49:03 +0000 Subject: [PATCH] host: relate removes just the picked candidate row in place (no reload) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Picking a candidate to relate it no longer does a full POST -> 303 -> reload. The candidate
  • now carries an id and its relate form is an AJAX sx-post (sx-target="#cand--", sx-swap="delete"): on success the engine deletes just that one row — the item is now related, so it leaves the candidate pool with no reload and no candidate-list refetch. host/blog-relate-submit returns an empty 200 for an SX request (so the delete swap fires) and still 303s for a plain POST (no-JS fallback via the form's method+action). relate-picker.spec.js test 4 updated to assert the in-place row delete + no reload + the relation still persists (shows on the post page). 6/6 + conformance 272/272. (Symmetric unrelate-in-place was prototyped but backed out: the current-links form, bound via boot's process-elements rather than post-swap, didn't fire the AJAX delete despite identical markup — a binding quirk to chase separately. Unrelate keeps its plain POST -> reload for now, no regression.) Co-Authored-By: Claude Opus 4.8 --- lib/host/blog.sx | 24 ++++++++++++++++------- lib/host/playwright/relate-picker.spec.js | 15 +++++++------- 2 files changed, 25 insertions(+), 14 deletions(-) diff --git a/lib/host/blog.sx b/lib/host/blog.sx index 24908416..57d68bf8 100644 --- a/lib/host/blog.sx +++ b/lib/host/blog.sx @@ -362,13 +362,18 @@ (define host/blog--picker-item (fn (slug p kind) (quasiquote - (li :style "border-bottom:1px solid #eee" - ;; sx-disable: this relate form is a plain POST -> 303 -> full reload (the - ;; engine swaps the picker rows in, which would otherwise boost this form; - ;; a boosted POST+redirect into #content swaps unreliably). A relate is a - ;; deliberate action, so a clean reload that re-renders the editor is right. - (form :method "post" :style "margin:0" :sx-disable "true" + (li :id (unquote (str "cand-" kind "-" (get p :slug))) + :style "border-bottom:1px solid #eee" + ;; AJAX relate: sx-post the relation, then sx-swap="delete" removes THIS + ;; candidate row (its sx-target) — it's now related, so it leaves the pool + ;; without a reload or a list refetch. method+action stay for the no-JS + ;; fallback (plain POST -> 303 -> reload); the engine prevents the double + ;; submit when it handles sx-post. + (form :method "post" :style "margin:0" :action (unquote (str "/" slug "/relate")) + :sx-post (unquote (str "/" slug "/relate")) + :sx-target (unquote (str "#cand-" kind "-" (get p :slug))) + :sx-swap "delete" (input :type "hidden" :name "other" :value (unquote (get p :slug))) (input :type "hidden" :name "kind" :value (unquote kind)) (button :type "submit" @@ -866,7 +871,12 @@ (when (and other (not (= other "")) (not (= other slug)) (host/blog--kind-spec kind) (host/blog-exists? other)) (host/blog-relate! slug other kind)) - (dream-redirect (str "/" slug "/edit"))))))) + ;; AJAX (the picker's sx-post): return an empty 200 so the candidate + ;; form's sx-swap="delete" removes just that one row — no full reload, + ;; no candidate-list refetch. Plain POST (no-JS) still redirects. + (if (host/blog--spa-req? req) + (dream-html "") + (dream-redirect (str "/" slug "/edit")))))))) ;; POST //unrelate — remove the relation to `other` under `kind` (default ;; "related"). Idempotent; redirects back to the edit page. diff --git a/lib/host/playwright/relate-picker.spec.js b/lib/host/playwright/relate-picker.spec.js index 0611c01a..a53799ea 100644 --- a/lib/host/playwright/relate-picker.spec.js +++ b/lib/host/playwright/relate-picker.spec.js @@ -78,19 +78,20 @@ test.describe('relate picker', () => { await expect(page.locator(RELR)).toContainText('Picker Item 13'); }); - test('clicking a candidate relates it (and it shows on the post page)', async ({ page }) => { - // heavier than the others: a full-reload relate (sx-disable) then a goto to the - // post page — two WASM kernel boots — so it needs more than the 30s default. + test('clicking a candidate relates it and removes just that row (no reload)', async ({ page }) => { test.setTimeout(75000); await loginTo(page, `/${HOST}/edit`); await waitReady(page); await page.fill(RELF, 'Item 07'); await expect.poll(() => page.locator(RELROWS).count(), { timeout: 10000 }).toBe(1); + // sentinel survives ONLY if there is no full-page reload + await page.evaluate(() => { window.__noReload = true; }); await page.locator(`${RELROWS} button`).first().click(); - // form POST -> 303 back to the edit page; the related list now links the slug - await expect(page).toHaveURL(new RegExp(`/${HOST}/edit`)); - await expect(page.locator('a[href="/picker-item-07/"]')).toHaveCount(1); - // and the public post page shows the Related posts block with the title + // the AJAX relate deletes just that candidate row in place — no reload, no + // candidate-list refetch + await expect.poll(() => page.locator(RELROWS).count(), { timeout: 10000 }).toBe(0); + expect(await page.evaluate(() => window.__noReload)).toBe(true); + // and the relation actually persisted (shows on the public post page) await page.goto(`/${HOST}/`); await expect(page.getByRole('heading', { name: 'Related posts' })).toBeVisible(); await expect(page.locator('body')).toContainText('Picker Item 07');