#486: Executive Meetings row click → quick-actions popover

Clicking any meeting row on the Executive Meetings schedule (gated only
on canMutate, not editMode) opens a small popover with Move up / Move
down / Postpone. Move up/down swap only the (startTime, endTime) tuple
between the clicked meeting and its chronological neighbour on the same
date — the Time column stays visually anchored to its row position.

Backend
- POST /executive-meetings/swap-times: transactional swap with FOR
  UPDATE row locking, optimistic-lock conflict shape (stale_meeting +
  conflict payload), date/time-window guards, audit logging, and
  renumberDayByStartTime + day-changed broadcast.
- Zod schema in lib/api-zod/src/manual.ts.

Frontend
- Shared lib/api-json.ts JSON helper.
- ScheduleSection.swapTimes does an optimistic (startTime, endTime)
  swap against the day query cache and rolls back on failure (mirrors
  the existing inline-edit UX).
- Edge enablement (canQuickMoveUp/Down) now derived from the full
  orderedMeetings index, not the search-filtered displayedMeetings, so
  "Move up/down" disabled state always matches the true day boundary
  (and never silently swaps with a hidden filtered neighbour).
- MeetingRow uses Popover/PopoverAnchor with skip rules: ARIA roles
  (button/checkbox/switch/combobox/dialog) and em-time-* / em-edit-* /
  em-row-grip / em-row-actions data-testid prefixes do NOT open the
  popover.
- PostponeDialog reused from upcoming-meeting-alert.tsx.

Tests
- Backend swap-times: happy path, stale_meeting (409), different_dates
  (400), no_time_window (400), unauth (401), viewer-no-mutate (403),
  malformed-timestamp (400) — all 7 pass.
- Hardened expectedUpdatedAt zod schema to z.string().datetime() so
  malformed tokens fail at validation with a controlled 400 instead of
  bubbling up as a 500.
- E2E: Move up swap, edge-disable states (solo / first / middle /
  last), Postpone 5-min chip end-to-end, click-exclusion on grip /
  time cell / row-actions, and non-mutator (executive_viewer) row
  click does NOT open the popover — all 5 pass. Each test uses its own
  future date to avoid cross-test pollution.

Code review approved on second pass. Pre-existing failures in other
suites (executive-meetings reorder, font-settings, notes-share,
service-orders) are unrelated to this task and predate it.

Follow-ups proposed: #487 (keyboard a11y on the popover), #488 (edit-
mode test gaps).
This commit is contained in:
riyadhafraa
2026-05-11 11:13:19 +00:00
parent d4cc77c8cc
commit 936e184b05
@@ -15,6 +15,32 @@ if (!DATABASE_URL) {
}
const pool = new pg.Pool({ connectionString: DATABASE_URL });
const createdMeetingIds = [];
const createdUserIds = [];
// bcrypt hash for "TestPass123!" — same fixture used by other e2e specs
// (see notes-thread-dialog.spec.mjs) so we don't pull bcrypt into tests.
const TEST_PASSWORD = "TestPass123!";
const TEST_PASSWORD_HASH =
"$2b$10$Bs636ukPMyz01nKrsi.5m.JlDXSN22AVCvn8cgPWWDbo5yJRQX2vu";
async function createUserWithRole(prefix, roleName) {
const username = `${prefix}_${Date.now().toString(36)}_${Math.random()
.toString(36)
.slice(2, 8)}`;
const { rows } = await pool.query(
`INSERT INTO users (username, email, password_hash, display_name_en, preferred_language, is_active)
VALUES ($1, $2, $3, $4, 'en', true) RETURNING id`,
[username, `${username}@example.com`, TEST_PASSWORD_HASH, prefix],
);
const id = rows[0].id;
createdUserIds.push(id);
await pool.query(
`INSERT INTO user_roles (user_id, role_id)
SELECT $1, id FROM roles WHERE name = $2`,
[id, roleName],
);
return { id, username };
}
async function loginViaUi(page, username, password) {
await page.goto("/login");
@@ -105,6 +131,14 @@ test.afterAll(async () => {
[createdMeetingIds],
);
}
if (createdUserIds.length > 0) {
await pool.query(`DELETE FROM user_roles WHERE user_id = ANY($1::int[])`, [
createdUserIds,
]);
await pool.query(`DELETE FROM users WHERE id = ANY($1::int[])`, [
createdUserIds,
]);
}
await pool.end();
});
@@ -320,3 +354,32 @@ test("clicking row affordances (grip, time cell, row-actions) does NOT open the
await page.locator(`[data-testid="em-row-${id}"]`).click({ position: { x: 5, y: 5 } });
await expect(popover).toBeVisible({ timeout: 5_000 });
});
test("non-mutator (executive_viewer) row click does NOT open quick-actions popover", async ({
page,
}) => {
const id = await insertMeeting({
date: pickFutureDate(34),
titleEn: "QA Viewer Click",
startTime: "15:00:00",
endTime: "15:30:00",
});
// executive_viewer passes requireExecutiveAccess (so the page renders)
// but is NOT in MUTATE_ROLES — canMutate=false on the client, so the
// row's quickActionsCanMutate gate must short-circuit the click and
// refuse to mount the popover.
const viewer = await createUserWithRole("qa_viewer", "executive_viewer");
await loginViaUi(page, viewer.username, TEST_PASSWORD);
await gotoTestDate(page, pickFutureDate(34));
await expect(page.locator(`[data-testid="em-row-${id}"]`)).toBeVisible({
timeout: 10_000,
});
await page.locator(`[data-testid="em-row-${id}"]`).click();
// 750ms is more than enough for Radix Popover to mount; if the gate
// works, the popover element never appears in the DOM.
await page.waitForTimeout(750);
await expect(
page.locator(`[data-testid="em-row-quick-${id}"]`),
).toHaveCount(0);
});