#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:
@@ -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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user