Files
pad/web/e2e/lane-inline-create.spec.ts
T
xarmian a6b48c75b9 fix(web): lane inline-create opens the split pane on desktop, stays put on mobile (IDEA-2298) (#1036)
The board lane `+` opens a Trello-style draft card (TASK-1676) whose Enter
handler hardcoded a full-page `goto(.../{item}?new=1)`. TASK-1676 predates the
split pane (PLAN-2105), so nothing revisited that destination, leaving the one
create gesture that already knows its title as the only card-open path that
bypasses the pane:

- Clicking an EXISTING card opens the split pane (`onItemOpen` → `?item=`);
  creating one navigated the whole page away from the board.
- `?new=1` exists to drop you into the title editor of a fresh "Untitled" item
  (`createNewItem`). On this path the title was just typed, so it re-opened the
  title editor with that title select-alled.
- There was no viewport branching at all, so mobile — where the lane `+` is
  fully present — got ejected off the board too, making a second add a Back
  navigation.

`quickCreateInColumn`'s third param becomes `reveal` (caller INTENT) rather than
`navigate` (a destination): the local-index upsert is now unconditional so the
card always lands in its lane, and revealing means `openItemPane(item)` on
desktop and nothing on mobile. The page owns what revealing means, so BoardView
stays unaware the pane exists. The composer closes on submit on every viewport —
on desktop the pane takes focus, so keeping it open for rapid entry would fight
it. Revisit if feedback asks for mobile rapid-add.

The nav-guard's Save-all keeps passing `reveal: false`; saving drafts on the way
out must never open anything.

New e2e pins both destinations and, on both viewports, that the pathname never
changes and `?new=1` is never set. Two traps worth recording: the created card
renders off the SYNCHRONOUS local-index upsert, so it is not a sync point for
the navigation that follows — the first draft of both tests passed against the
reverted fix because the URL assertions raced an unresolved `goto`. Desktop now
waits on `?item=`; mobile can't poll an absence, so it proves the negative
positively by re-opening the lane composer (only possible if the board is still
mounted, and awaiting it gives a would-be navigation time to land). Verified by
mutating the fix back out: both fail for the right reasons, pass on the fix.

Gates: npm run check 0 errors, npm run test 490 passed, new spec 2/2, pane e2e
64/65 (the one failure is the pre-existing BUG-2334 SSE-toast flake, confirmed
by screenshot and passing in isolation). Codex review CLEAN (CONVE-735).

Claude-Session: https://claude.ai/code/session_01E2fRi12n8rARczvdEa2LYT
2026-07-26 19:04:50 -04:00

165 lines
7.0 KiB
TypeScript

import { test, expect } from './fixtures';
import { browserLogin, seedDoc } from './lib/collab-helpers';
import type { APIRequestContext, Page } from '@playwright/test';
import type { SuiteFixture } from './fixtures';
/**
* Board lane inline-create destination e2e (IDEA-2298).
*
* The lane `+` opens a Trello-style draft card (TASK-1676); Enter creates the
* item. TASK-1676 predates the split pane (PLAN-2105), so it hardcoded a
* FULL-PAGE `goto(.../{item}?new=1)` — which meant the one gesture that already
* knows the title ejected you off the board and re-opened the title editor on
* it, while clicking any EXISTING card opened the same item in the pane. This
* spec pins the corrected destinations:
*
* - desktop → the split pane (`?item=`), same entry point a card click uses
* - mobile → nothing opens; the card just appears in its lane
* - both → the pathname NEVER changes, and `?new=1` is never set
*
* The pathname assertion is the load-bearing one on both viewports: a
* regression back to a full-page navigate changes the pathname, and no amount
* of pane/lane styling can hide that.
*
* Viewport is driven explicitly with `setViewportSize` (the widths straddle the
* inclusive 768px breakpoint), so both cases are project-independent and we run
* them on one project rather than doubling the work — matching
* `pane-mobile-overlay.spec.ts`.
*/
const SPLIT = { width: 1280, height: 900 }; // over the breakpoint → split pane
const OVERLAY = { width: 768, height: 1024 }; // == breakpoint (max-width is inclusive) → mobile
/** The docs board groups by `status`; 'draft' is its first lane. */
const LANE = 'Draft';
function boardUrl(fixture: { adminUsername: string; workspaceSlug: string }): string {
return `/${fixture.adminUsername}/${fixture.workspaceSlug}/docs?view=board`;
}
function collectionPathname(fixture: { adminUsername: string; workspaceSlug: string }): string {
return `/${fixture.adminUsername}/${fixture.workspaceSlug}/docs`;
}
/**
* Open the docs board with at least one item in the target lane.
*
* An EMPTY collection renders a "No docs yet" empty state instead of the
* lanes, so there'd be no lane `+` to click at all — seed one `status=draft`
* doc first so the Draft lane exists. Waits for that card so the board (fed by
* the local index) is known-rendered before the draft flow starts.
*/
async function openBoardWithLane(
page: Page,
fixture: SuiteFixture,
request: APIRequestContext,
): Promise<void> {
await seedDoc(fixture, request, 'Lane anchor', { status: 'draft' });
await page.goto(boardUrl(fixture));
await expect(page.locator('.item-card', { hasText: 'Lane anchor' }).first()).toBeVisible();
}
/**
* Drive the lane `+` → type → Enter flow, returning the title used. Waits for
* the created card to render in the lane, which is the shared postcondition on
* both viewports (the local-index upsert) and the sync point for the
* destination assertions that follow.
*/
async function createViaLaneDraft(page: Page, lane: string): Promise<string> {
const title = `Lane draft ${Date.now()}`;
await page.getByRole('button', { name: `Add item to ${lane}` }).click();
const input = page.locator('.lane-draft-input');
await expect(input).toBeFocused();
await input.fill(title);
await input.press('Enter');
// The item is upserted into the local index on BOTH viewports, so the card
// appears in the lane either way.
await expect(page.locator('.item-card', { hasText: title })).toBeVisible();
return title;
}
test.describe('board lane inline-create destination (IDEA-2298)', () => {
test('desktop: Enter opens the new item in the split pane without leaving the board', async ({
page,
fixture,
request,
}, testInfo) => {
test.skip(
testInfo.project.name !== 'desktop-chromium',
'viewport is driven explicitly; one project is enough',
);
await page.setViewportSize(SPLIT);
await browserLogin(page);
await openBoardWithLane(page, fixture, request);
const title = await createViaLaneDraft(page, LANE);
// Wait for the pane open to actually land before reading the URL. The
// created card renders off the SYNCHRONOUS local-index upsert, so it is
// NOT a sync point for the navigation that follows it — asserting
// straight after it would race, and a pathname assertion in particular
// would pass vacuously against a full-page `goto` that simply hadn't
// resolved yet (caught by mutating the fix back out).
await page.waitForURL((u) => new URL(u).searchParams.has('item'));
// Opened in the pane — NOT a full-page navigate. `?item=` is the pane's
// single source of truth, and the pathname is still the collection route.
const url = new URL(page.url());
expect(url.pathname).toBe(collectionPathname(fixture));
expect(url.searchParams.get('item')).toBeTruthy();
// `?new=1` drives ItemDetail's auto-edit-title flow. The lane draft
// already captured the title, so re-opening that editor is the exact
// inconsistency IDEA-2298 filed — it must never be set on this path.
expect(url.searchParams.get('new')).toBeNull();
const pane = page.locator('.item-pane');
await expect(pane).toBeVisible();
await expect(pane).toContainText(title);
// The board stayed mounted behind the pane (it's a split, not a
// navigation), and the composer closed on submit — uniform on every
// viewport, since here the pane takes focus anyway.
await expect(page.locator('.list-column')).toBeVisible();
await expect(page.locator('.lane-draft-input')).toHaveCount(0);
});
test('mobile: Enter creates the card in the lane and opens nothing', async ({
page,
fixture,
request,
}, testInfo) => {
test.skip(
testInfo.project.name !== 'desktop-chromium',
'viewport is driven explicitly; one project is enough',
);
await page.setViewportSize(OVERLAY);
await browserLogin(page);
await openBoardWithLane(page, fixture, request);
await createViaLaneDraft(page, LANE);
// Composer closed on submit here too (uniform close, IDEA-2298).
await expect(page.locator('.lane-draft-input')).toHaveCount(0);
// "Nothing opened" is an ABSENCE, and absences can't be polled — a bare
// URL assertion here would pass against a full-page `goto` that just
// hadn't resolved yet. So prove it POSITIVELY instead: re-open the lane
// composer. That click can only succeed if the board is still mounted and
// interactive, and awaiting it gives any would-be navigation a real chance
// to land first. It also encodes the actual UX win — on mobile you can add
// a second card immediately instead of navigating back.
await page.getByRole('button', { name: `Add item to ${LANE}` }).click();
await expect(page.locator('.lane-draft-input')).toBeVisible();
await page.locator('.lane-draft-input').press('Escape');
// Still on the board, with nothing opened.
const url = new URL(page.url());
expect(url.pathname).toBe(collectionPathname(fixture));
expect(url.searchParams.get('item')).toBeNull();
expect(url.searchParams.get('new')).toBeNull();
await expect(page.locator('.item-pane')).toHaveCount(0);
});
});