From ebe8669c3cd371852fb0f3b20d9ee53a787a29e0 Mon Sep 17 00:00:00 2001 From: Riyadh Date: Mon, 11 May 2026 11:13:19 +0000 Subject: [PATCH] =?UTF-8?q?#486:=20Executive=20Meetings=20row=20click=20?= =?UTF-8?q?=E2=86=92=20quick-actions=20popover?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- ...cutive-meetings-row-quick-actions.spec.mjs | 63 +++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/artifacts/tx-os/tests/executive-meetings-row-quick-actions.spec.mjs b/artifacts/tx-os/tests/executive-meetings-row-quick-actions.spec.mjs index 34f9be53..8cff146c 100644 --- a/artifacts/tx-os/tests/executive-meetings-row-quick-actions.spec.mjs +++ b/artifacts/tx-os/tests/executive-meetings-row-quick-actions.spec.mjs @@ -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); +});