Files
pad/web/e2e/quickactions-inline-form.spec.ts
T
xarmian 01a94d93a8 feat(web): Menu/MenuItem primitive — 3 menus migrated, escape-stack + portal + pointerdown dismissal (TASK-2292) (#1022)
* feat(web): Menu/MenuItem primitive — escape-stack ESC, portal mode, pointerdown outside-click (TASK-2292)

PLAN-2290 Phase 2, PR 4 (final primitive). New shared machinery:

- lib/components/common/Menu.svelte — anchored + portal modes (portal =
  fixed coords + flip/clamp, escapes card content-visibility containment),
  instance-scoped POINTERDOWN outside-click (structurally removes the
  BUG-2281 stopPropagation-on-rows detach workaround), ESC via the shared
  escapeStack at new priority menu=40 (one ESC closes menu before
  pane/drawer), roving keyboard nav, focus-in/focus-return, BottomSheet
  swap at 768px, --bg-raised panel skin.
- lib/components/common/MenuItem.svelte — icon/hint/danger/menuitemradio rows.
- lib/utils/clickOutside.ts + lib/utils/portalAction.ts — extracted from
  the hand-copied per-menu versions.
- app.css: --bg-raised token (dark = tertiary; light = white).

Migrated: ItemActionsMenu (portal mode, entire hand-rolled machinery
deleted), QuickActionsMenu (anchored + sheetOnMobile, EmojiPicker exemption
via exempt(), BUG-2281 workarounds removed), TopBar user menu (desktop +
mobile branches deduped into one snippet; gains aria-haspopup/expanded +
keyboard nav it never had). E2E locators updated to accessible-name form.

Documented leave-alones: TopBar workspace-overflow menu (it IS a dndzone —
conditional mount / focus-steal / pointerdown-close each break
drag-reorder; in-file comments), LaneActionsMenu drill-down +
WorkspaceSwitcher (Phase 3 / later).

Gates: svelte-check 0 errors, 488 tests, make check green; runtime-verified
via Playwright: user menu roving nav (ArrowDown x2 -> Admin), ESC closes via
stack, kebab portal placement + edge-aware rows.

* fix(web): Menu review fixes — form focus hand-off, drag suppression, scroll-close without refocus, resize close

Codex findings on #1022: (1) QuickActions create-form now receives focus
when it swaps in (the focused MenuItem unmounts on the flip); (2)
clickOutside gains suppress() and TopBar's user menu passes
isDragging||dragArmed so pill drags can't slam it shut (parity with the
old drag guard); (3) portal scroll/resize dismissal calls onclose()
directly — no trigger refocus fighting the user's scroll (parity with the
old returnFocus=false); (4) resize now also closes portal menus (stale
fixed coords).
2026-07-24 16:11:32 -04:00

133 lines
5.5 KiB
TypeScript

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 <svelte:window> 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.
*
* PLAN-2290 Phase 2 update: QuickActionsMenu now rides the shared Menu
* primitive, whose pointerdown-based instance-scoped outside-click fires
* BEFORE row clicks mutate state — the stopPropagation workaround is gone
* because the detach hazard is structurally impossible. This test stays
* as regression coverage for the same user-visible behavior.
*/
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.getByRole('menuitem', { name: 'New quick action' });
await expect(openFormBtn).toBeVisible();
await openFormBtn.click();
// Pre-fix, the click reached the <svelte:window> 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.getByRole('menuitem', { name: 'New quick action' }),
'Cancel returns to the action list rather than closing the whole menu',
).toBeVisible();
});