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');