// #486: e2e for the Executive Meetings schedule row quick-actions // popover. Clicking any row (gated only on canMutate, NOT editMode) // opens a small menu with Move up / Move down / Postpone. Move up/ // down swap 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. import { test, expect } from "@playwright/test"; import pg from "pg"; const DATABASE_URL = process.env.DATABASE_URL; if (!DATABASE_URL) { throw new Error( "DATABASE_URL must be set to run the row-quick-actions test", ); } const pool = new pg.Pool({ connectionString: DATABASE_URL }); const createdMeetingIds = []; async function loginViaUi(page, username, password) { await page.goto("/login"); await page.locator("#username").fill(username); await page.locator("#password").fill(password); await Promise.all([ page.waitForURL((url) => !url.pathname.endsWith("/login"), { timeout: 15_000, }), page.locator('form button[type="submit"]').click(), ]); } function pickFutureDate() { // Pick a date well in the future so it can't collide with the // upcoming-meeting alert, recurring seeded data, or other suite fixtures. const d = new Date(Date.now() + 30 * 86_400_000); const y = d.getFullYear(); const m = String(d.getMonth() + 1).padStart(2, "0"); const day = String(d.getDate()).padStart(2, "0"); return `${y}-${m}-${day}`; } const TEST_DATE = pickFutureDate(); async function gotoTestDate(page) { await page.goto("/executive-meetings"); // The schedule's date is internal React state, not a URL param, so // drive it through the actual `` in the toolbar // (same control the user clicks). It's the only date input on the // page until the user opens a side panel. const dateInput = page.locator('input[type="date"]').first(); await dateInput.waitFor({ state: "visible", timeout: 10_000 }); await dateInput.fill(TEST_DATE); await dateInput.dispatchEvent("change"); } async function nextDailyNumberForDate(date) { const { rows } = await pool.query( `SELECT COALESCE(MAX(daily_number), 0) + 1 AS n FROM executive_meetings WHERE meeting_date = $1`, [date], ); return rows[0].n; } async function insertMeeting({ titleEn, startTime, endTime }) { const dn = await nextDailyNumberForDate(TEST_DATE); const { rows } = await pool.query( `INSERT INTO executive_meetings (daily_number, title_ar, title_en, meeting_date, start_time, end_time, status) VALUES ($1, $2, $2, $3, $4, $5, 'scheduled') RETURNING id`, [dn, titleEn, TEST_DATE, startTime, endTime], ); const id = rows[0].id; createdMeetingIds.push(id); return id; } async function readMeeting(id) { const { rows } = await pool.query( `SELECT start_time, end_time, daily_number FROM executive_meetings WHERE id = $1`, [id], ); return rows[0]; } test.afterAll(async () => { if (createdMeetingIds.length > 0) { await pool.query( `DELETE FROM executive_meeting_audit_logs WHERE entity_type = 'meeting' AND entity_id = ANY($1::int[])`, [createdMeetingIds], ); await pool.query( `DELETE FROM executive_meeting_attendees WHERE meeting_id = ANY($1::int[])`, [createdMeetingIds], ); await pool.query( `DELETE FROM executive_meetings WHERE id = ANY($1::int[])`, [createdMeetingIds], ); } await pool.end(); }); test("row click opens quick-actions popover, Move up swaps times with neighbour", async ({ page, }) => { const aId = await insertMeeting({ titleEn: "QA Row Alpha", startTime: "09:00:00", endTime: "09:30:00", }); const bId = await insertMeeting({ titleEn: "QA Row Bravo", startTime: "10:00:00", endTime: "10:30:00", }); // admin/admin123 is the canonical seeded credential the rest of the // executive-meetings e2e suite uses, and it has the mutate role // (canMutate=true) without needing to flip on edit mode. await loginViaUi(page, "admin", "admin123"); await gotoTestDate(page); // Wait for both rows to render. await expect(page.locator(`[data-testid="em-row-${aId}"]`)).toBeVisible({ timeout: 10_000, }); await expect(page.locator(`[data-testid="em-row-${bId}"]`)).toBeVisible(); // Click the SECOND row (Bravo, 10:00) — it should expose Move up. await page.locator(`[data-testid="em-row-${bId}"]`).click(); const popover = page.locator(`[data-testid="em-row-quick-${bId}"]`); await expect(popover).toBeVisible(); await expect( popover.locator(`[data-testid="em-row-quick-up-${bId}"]`), ).toBeEnabled(); await expect( popover.locator(`[data-testid="em-row-quick-postpone-${bId}"]`), ).toBeVisible(); await popover.locator(`[data-testid="em-row-quick-up-${bId}"]`).click(); // Wait for the swap to land in the DB (refetch picks it up via the // day-changed socket but we assert the DB directly for determinism). await expect.poll(async () => (await readMeeting(bId)).start_time).toBe( "09:00:00", ); const aAfter = await readMeeting(aId); const bAfter = await readMeeting(bId); // Times swapped, daily numbers re-sorted by start time. expect(aAfter.start_time).toBe("10:00:00"); expect(aAfter.end_time).toBe("10:30:00"); expect(bAfter.start_time).toBe("09:00:00"); expect(bAfter.end_time).toBe("09:30:00"); }); test("Postpone item from quick-actions popover opens the postpone dialog", async ({ page, }) => { const id = await insertMeeting({ titleEn: "QA Postpone Target", startTime: "13:00:00", endTime: "13:30:00", }); await loginViaUi(page, "admin", "admin123"); await gotoTestDate(page); await expect(page.locator(`[data-testid="em-row-${id}"]`)).toBeVisible({ timeout: 10_000, }); await page.locator(`[data-testid="em-row-${id}"]`).click(); const popover = page.locator(`[data-testid="em-row-quick-${id}"]`); await expect(popover).toBeVisible(); await popover.locator(`[data-testid="em-row-quick-postpone-${id}"]`).click(); // PostponeDialog is the same one upcoming-meeting-alert uses, so we // assert by role=dialog presence rather than coupling to the // dialog's internal markup. await expect(page.getByRole("dialog")).toBeVisible({ timeout: 5_000 }); });