From a02e26d0badf4dc397d656a59a3413dcbeca9d28 Mon Sep 17 00:00:00 2001 From: xarmian Date: Wed, 22 Jul 2026 20:46:54 +0000 Subject: [PATCH] fix(web): keep the inline "New quick action" form open on click (BUG-2281) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Clicking the QuickActionsMenu footer "+ New quick action" flipped showCreateForm=true, unmounting the footer's {:else} branch (the very button clicked). Svelte 5 flushSyncs after a delegated event handler, so by the time that click bubbled on to the click-outside handler the button was detached — target.closest('.quick-actions-menu') returned null, and handleWindowClick treated it as an outside click and closed the whole menu, wiping the create form the instant it opened. The create-form Cancel button had the same detach-then-close quirk (closed the menu instead of returning to the action list). handleTriggerClick already guards this with e.stopPropagation(); handleOpenCreateForm and the Cancel handler did not. Add the same guard to both. Adds a Playwright regression test (mutation-tested: fails on the pre-fix code, passes after) — the inline form is exercised in a real Chromium event pipeline for the first time (jsdom doesn't reproduce the mid-bubble detach, and the capstone spec only asserted the button was visible, never clicked it). Also documents BUG-2280 in ItemDetail.svelte: the QuickActionsMenu oncollectionupdated callback's `{@const keyedSlug = itemSlug}` fence was a Svelte-5 no-op, but the callback is already switch-safe by two independent layers (the child-side collection-id guard reads the LIVE parent collection and drops a cross-collection callback; loadData's identity clause forces the correct collection regardless). Replaces the dead no-op fence with a comment explaining why it's safe and warning against re-adding a no-op snapshot fence (the literal BUG-2129 trap). No behavior change in ItemDetail. BUG-2281: real, fixed. BUG-2280: investigated, not a live bug (wontfix). Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra --- web/e2e/quickactions-inline-form.spec.ts | 126 ++++++++++++++++++ .../components/common/QuickActionsMenu.svelte | 24 +++- .../lib/components/items/ItemDetail.svelte | 42 ++++-- 3 files changed, 178 insertions(+), 14 deletions(-) create mode 100644 web/e2e/quickactions-inline-form.spec.ts diff --git a/web/e2e/quickactions-inline-form.spec.ts b/web/e2e/quickactions-inline-form.spec.ts new file mode 100644 index 00000000..9d7dd752 --- /dev/null +++ b/web/e2e/quickactions-inline-form.spec.ts @@ -0,0 +1,126 @@ +import { test, expect } from './fixtures'; +import { browserLogin } from './lib/collab-helpers'; +import type { APIRequestContext } from '@playwright/test'; +import type { SuiteFixture } from './fixtures'; + +/** + * BUG-2281 regression e2e — the inline "New quick action" form must open + * and stay open. + * + * Found during the BUG-2129 switch-safety reconciliation. Clicking the + * footer "+ New quick action" flips `showCreateForm=true`, which unmounts + * the footer's {:else} branch (the very button clicked). Svelte 5 + * flushSyncs after a delegated event handler, so by the time that click + * bubbles on to the click-outside handler the button is + * DETACHED — `target.closest('.quick-actions-menu')` returns null and + * handleWindowClick treats it as an outside click, closing the whole menu + * and wiping the create form the instant it opens. `handleTriggerClick` + * already guards this with `e.stopPropagation()`; `handleOpenCreateForm` + * and the create-form Cancel button did not. + * + * jsdom (the unit test) doesn't reproduce the mid-bubble detach, and no + * e2e ever CLICKED "New quick action" (the capstone only asserts it's + * visible) — so this closes that gap in a real Chromium event pipeline. + * Pre-fix: the create form never appears. Post-fix (stopPropagation): it + * opens and stays, and Cancel returns to the action list instead of + * closing the whole menu. + */ + +function authHeaders(fixture: SuiteFixture) { + return { Authorization: `Bearer ${fixture.apiToken}`, 'Content-Type': 'application/json' }; +} + +// A plain collection with a minimal valid schema. Letters-only prefix and a +// timestamped name so the server-derived slug stays in sync with the name +// (see pane-collection-migration-race.spec.ts::seedMigratableCollection for +// why numeric-derived prefixes 404 by-ref lookups and out-of-sync name/slug +// reassign the slug on the next save). +async function seedCollection( + fixture: SuiteFixture, + request: APIRequestContext, + namePrefix: string, + itemPrefix: string, +) { + const name = `${namePrefix} ${Date.now()}`; + const schema = JSON.stringify({ + fields: [ + { + key: 'status', + label: 'Status', + type: 'select', + options: ['draft', 'published'], + default: 'draft', + required: true, + }, + ], + }); + const resp = await request.post(`/api/v1/workspaces/${fixture.workspaceSlug}/collections`, { + headers: authHeaders(fixture), + data: { name, prefix: itemPrefix, schema }, + }); + if (!resp.ok()) throw new Error(`collection create failed (${resp.status()}): ${await resp.text()}`); + return (await resp.json()) as { id: string; slug: string; name: string }; +} + +async function seedItem( + fixture: SuiteFixture, + request: APIRequestContext, + collSlug: string, + title: string, +) { + const resp = await request.post( + `/api/v1/workspaces/${fixture.workspaceSlug}/collections/${collSlug}/items`, + { + headers: authHeaders(fixture), + data: { title, fields: JSON.stringify({ status: 'draft' }), content: '' }, + }, + ); + if (!resp.ok()) throw new Error(`item create failed (${resp.status()}): ${await resp.text()}`); + return (await resp.json()) as { id: string; slug: string }; +} + +test("the inline 'New quick action' form opens and stays open (BUG-2281)", async ({ + page, + fixture, + request, +}, testInfo) => { + test.skip( + testInfo.project.name !== 'desktop-chromium', + 'event-propagation behavior is viewport-agnostic; one project is enough', + ); + test.setTimeout(30_000); + + // A brand-new collection has NO quick actions yet, so the dropdown opens + // straight to the footer (New quick action / Manage actions) — the exact + // state a user hits when adding their first action. + const coll = await seedCollection(fixture, request, 'bug2281-form', 'BFRM'); + const it = await seedItem(fixture, request, coll.slug, 'BUG-2281 form item'); + + await browserLogin(page); + await page.goto(`/${fixture.adminUsername}/${fixture.workspaceSlug}/${coll.slug}/${it.slug}`); + await expect(page.locator('.title', { hasText: 'BUG-2281 form item' })).toBeVisible(); + + await page.locator('button.trigger-btn[title="Quick actions"]').click(); + const openFormBtn = page.locator('button.action-item.footer-row', { hasText: 'New quick action' }); + await expect(openFormBtn).toBeVisible(); + await openFormBtn.click(); + + // Pre-fix, the click reached the click-outside handler on a + // footer button that Svelte's flushSync had already detached, so the menu + // closed instantly and the create form never appeared. Post-fix + // (stopPropagation on handleOpenCreateForm), the form opens and stays. + await expect(page.locator('input.qa-label-input')).toBeVisible(); + await expect(page.locator('textarea.qa-prompt-input')).toBeVisible(); + // A short settle to prove it didn't merely flash open then close. + await page.waitForTimeout(200); + await expect(page.locator('input.qa-label-input')).toBeVisible(); + + // Cancel returns to the action list (not: closes the whole menu) — same + // stopPropagation fix on the create-form Cancel button. + await page.locator('button.qa-btn.qa-btn-cancel', { hasText: 'Cancel' }).click(); + await expect(page.locator('input.qa-label-input')).toHaveCount(0); + await expect( + page.locator('button.action-item.footer-row', { hasText: 'New quick action' }), + 'Cancel returns to the action list rather than closing the whole menu', + ).toBeVisible(); +}); diff --git a/web/src/lib/components/common/QuickActionsMenu.svelte b/web/src/lib/components/common/QuickActionsMenu.svelte index 2ad8514f..6a7e8167 100644 --- a/web/src/lib/components/common/QuickActionsMenu.svelte +++ b/web/src/lib/components/common/QuickActionsMenu.svelte @@ -112,10 +112,30 @@ newIcon = ''; } - function handleOpenCreateForm() { + function handleOpenCreateForm(e: MouseEvent) { + // stopPropagation, mirroring handleTriggerClick (BUG-2281): flipping + // showCreateForm unmounts the footer's {:else} branch (this very + // button). Svelte 5 flushSyncs after a delegated event handler, so by + // the time this click bubbles on to the click-outside + // handler the button is DETACHED — `target.closest('.quick-actions-menu')` + // then returns null, and handleWindowClick treats it as an outside + // click and closes the whole menu (open=false + resetCreateForm), + // wiping the create form the instant it opens. Stopping propagation + // keeps the click from reaching the window handler at all. + e.stopPropagation(); showCreateForm = true; } + function handleCancelCreateForm(e: MouseEvent) { + // Same detach-then-window-close race as handleOpenCreateForm (BUG-2281): + // resetCreateForm unmounts the create form (this Cancel button), so + // without stopPropagation the bubbling click would hit the window + // handler on a detached target and close the whole menu instead of + // returning to the action list. + e.stopPropagation(); + resetCreateForm(); + } + function handleManage() { // Recheck `canEdit` at dispatch time (mirrors handleSaveNewAction): if it // flips false while the menu is open — e.g. the master-freeze passing @@ -279,7 +299,7 @@ Template variables: {'{ref}'} {'{title}'} {'{status}'} {'{priority}'} {'{collection}'} {'{content}'} {'{fields}'}
-