import { test, expect } from "@playwright/test"; import pg from "pg"; // E2E coverage for the three "bulk-edit polish" features: // 1. Edit toggle flips between an "Edit" label and a "Save" label // (and a Pencil↔Check icon), and a second click exits edit mode. // 2. The new-meeting / edit-meeting dialog has a one-click // "Remove all attendees" button that empties the list. // 3. Multi-select on the schedule + a single "Delete selected" // action removes every checked meeting and clears the selection. // // Assertions are language-agnostic: we use testIDs, aria attributes, // and regexes rather than English/Arabic copy so the suite works // regardless of which locale the persisted localStorage has stuck on. const DATABASE_URL = process.env.DATABASE_URL; if (!DATABASE_URL) { throw new Error( "DATABASE_URL must be set to run the bulk-actions UI tests", ); } const pool = new pg.Pool({ connectionString: DATABASE_URL }); const createdMeetingIds = []; const RANDOM_DATE_BASE_DAYS = 365 + Math.floor(Math.random() * 1000) + ((Date.now() / 1000) | 0) % 500; function uniqueFutureDate(offsetDays) { const d = new Date(); d.setUTCDate(d.getUTCDate() + RANDOM_DATE_BASE_DAYS + offsetDays * 7); return d.toISOString().slice(0, 10); } async function insertMeeting({ meetingDate, dailyNumber, titleEn, titleAr, startTime, endTime, }) { 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, $3, $4, $5, $6, 'scheduled') RETURNING id`, [dailyNumber, titleAr, titleEn, meetingDate, startTime, endTime], ); const id = rows[0].id; createdMeetingIds.push(id); return id; } 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(), ]); } async function resetSchedulePrefs(page) { await page.evaluate(() => { try { const keys = Object.keys(window.localStorage); for (const k of keys) { if ( k.startsWith("em-schedule-edit-mode-v1") || k === "em-schedule-cols-v1" || k === "em-schedule-row-colors-v1" || k === "em-current-meeting-highlight-v1" ) { window.localStorage.removeItem(k); } } } catch { /* ignore */ } }); } test.afterAll(async () => { if (createdMeetingIds.length > 0) { await pool.query( `DELETE FROM executive_meeting_attendees WHERE meeting_id = ANY($1::int[])`, [createdMeetingIds], ); 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_meetings WHERE id = ANY($1::int[])`, [createdMeetingIds], ); } await pool.end(); }); test("Schedule: edit toggle flips its label and icon when on, and a second click exits edit mode", async ({ page, }) => { await loginViaUi(page, "admin", "admin123"); await page.goto("/executive-meetings"); await resetSchedulePrefs(page); await page.reload(); const toggle = page.getByTestId("em-edit-mode-toggle"); await expect(toggle).toBeVisible(); await expect(toggle).toHaveAttribute("aria-pressed", "false"); // Capture the OFF-state label so we can prove it actually changes. const offLabel = (await toggle.textContent())?.trim() ?? ""; expect(offLabel.length).toBeGreaterThan(0); // The icon SVG inside the OFF state must be the Pencil (lucide marks // its icons with a `lucide-pencil` class). await expect(toggle.locator("svg.lucide-pencil")).toHaveCount(1); await expect(toggle.locator("svg.lucide-check")).toHaveCount(0); // Flip on: aria-pressed flips, label/icon swap, and at least one // edit-mode-only affordance becomes visible (the per-row select // checkbox overlay) — proof we really entered edit mode. await toggle.click(); await expect(toggle).toHaveAttribute("aria-pressed", "true"); const onLabel = (await toggle.textContent())?.trim() ?? ""; expect(onLabel.length).toBeGreaterThan(0); expect(onLabel).not.toBe(offLabel); await expect(toggle.locator("svg.lucide-check")).toHaveCount(1); await expect(toggle.locator("svg.lucide-pencil")).toHaveCount(0); await expect( page.locator('[data-testid^="em-row-select-"]').first(), ).toBeVisible(); // Second click — back to OFF, original label/icon return. await toggle.click(); await expect(toggle).toHaveAttribute("aria-pressed", "false"); await expect(toggle).toHaveText(offLabel); await expect(toggle.locator("svg.lucide-pencil")).toHaveCount(1); }); test("New-meeting dialog: 'Remove all attendees' button only renders when ≥1 attendee, and clears the in-memory list", async ({ page, }) => { await loginViaUi(page, "admin", "admin123"); await page.goto("/executive-meetings"); // Open the Manage tab → click "Add meeting" to launch the dialog. await page.getByTestId("em-nav-manage").click(); await page.getByTestId("em-add-meeting").click(); // Empty list → the bulk-clear button is NOT rendered. await expect(page.getByTestId("em-attendee-remove-all")).toHaveCount(0); // Add two attendees via the in-dialog "+ Add attendee" affordance. const addAttendeeBtn = page.getByTestId("em-attendee-add"); await expect(addAttendeeBtn).toBeVisible(); await addAttendeeBtn.click(); await addAttendeeBtn.click(); await expect(page.getByTestId("em-attendee-row-0")).toBeVisible(); await expect(page.getByTestId("em-attendee-row-1")).toBeVisible(); // Bulk-clear button now visible. Auto-confirm the native confirm() // and click it. await expect(page.getByTestId("em-attendee-remove-all")).toBeVisible(); page.once("dialog", (d) => d.accept()); await page.getByTestId("em-attendee-remove-all").click(); // List is empty again — neither attendee row nor the bulk button // should be in the DOM. await expect(page.getByTestId("em-attendee-row-0")).toHaveCount(0); await expect(page.getByTestId("em-attendee-row-1")).toHaveCount(0); await expect(page.getByTestId("em-attendee-remove-all")).toHaveCount(0); }); test("Schedule: multi-select + 'Delete selected' removes every checked meeting and clears the selection", async ({ page, }) => { const date = uniqueFutureDate(2); // Seed three meetings on the same future date so the schedule has // a known set we can multi-select against. const id1 = await insertMeeting({ meetingDate: date, dailyNumber: 1, titleEn: `Bulk1 ${Date.now()}`, titleAr: `Bulk1 ${Date.now()}`, startTime: "09:00:00", endTime: "10:00:00", }); const id2 = await insertMeeting({ meetingDate: date, dailyNumber: 2, titleEn: `Bulk2 ${Date.now()}`, titleAr: `Bulk2 ${Date.now()}`, startTime: "10:00:00", endTime: "11:00:00", }); const id3 = await insertMeeting({ meetingDate: date, dailyNumber: 3, titleEn: `Bulk3 ${Date.now()}`, titleAr: `Bulk3 ${Date.now()}`, startTime: "11:00:00", endTime: "12:00:00", }); await loginViaUi(page, "admin", "admin123"); await page.goto("/executive-meetings"); await resetSchedulePrefs(page); await page.reload(); // Jump to the seeded date. const dateInput = page.locator('input[type="date"]').first(); await dateInput.fill(date); // Wait for all three rows. await expect(page.getByTestId(`em-row-${id1}`)).toBeVisible({ timeout: 10_000, }); await expect(page.getByTestId(`em-row-${id2}`)).toBeVisible(); await expect(page.getByTestId(`em-row-${id3}`)).toBeVisible(); // View mode → no row checkboxes, no select-all in header, no toolbar. await expect(page.locator('[data-testid^="em-row-select-"]')).toHaveCount(0); await expect(page.getByTestId("em-bulk-select-all")).toHaveCount(0); await expect(page.getByTestId("em-bulk-toolbar")).toHaveCount(0); // Flip edit mode on. The tri-state "select all" appears in the // table header and per-row checkboxes appear, but the floating // bulk toolbar is NOT visible yet — it only renders once ≥1 row // is checked. await page.getByTestId("em-edit-mode-toggle").click(); await expect(page.getByTestId("em-bulk-select-all")).toBeVisible(); await expect(page.getByTestId(`em-row-select-${id1}`)).toBeVisible(); await expect(page.getByTestId(`em-row-select-${id2}`)).toBeVisible(); await expect(page.getByTestId(`em-row-select-${id3}`)).toBeVisible(); await expect(page.getByTestId("em-bulk-toolbar")).toHaveCount(0); // Check rows 1 and 3 (skip 2 to confirm the selection is honored). await page.getByTestId(`em-row-select-${id1}`).click(); await page.getByTestId(`em-row-select-${id3}`).click(); // Bulk toolbar now appears with the count + delete button. await expect(page.getByTestId("em-bulk-toolbar")).toBeVisible(); await expect(page.getByTestId("em-bulk-selection-count")).toHaveText(/2/); await expect(page.getByTestId("em-bulk-selection-count")).toHaveText(/3/); await expect(page.getByTestId("em-bulk-delete-selected")).toBeVisible(); // Header tri-state is in the indeterminate visual state. await expect(page.getByTestId("em-bulk-select-all")).toHaveAttribute( "data-state", "indeterminate", ); // Delete the two selected rows. Auto-accept the native confirm. page.once("dialog", (d) => d.accept()); await page.getByTestId("em-bulk-delete-selected").click(); // Rows 1 and 3 are gone; row 2 remains. Selection has cleared, so // the floating toolbar disappears entirely. await expect(page.getByTestId(`em-row-${id1}`)).toHaveCount(0, { timeout: 10_000, }); await expect(page.getByTestId(`em-row-${id3}`)).toHaveCount(0); await expect(page.getByTestId(`em-row-${id2}`)).toBeVisible(); await expect(page.getByTestId("em-bulk-toolbar")).toHaveCount(0); await expect(page.getByTestId("em-bulk-delete-selected")).toHaveCount(0); // Header select-all is back to unchecked. await expect(page.getByTestId("em-bulk-select-all")).toHaveAttribute( "data-state", "unchecked", ); // DB should agree: ids 1 + 3 are gone, id 2 still present. const { rows: remaining } = await pool.query( `SELECT id FROM executive_meetings WHERE id = ANY($1::int[]) ORDER BY id`, [[id1, id2, id3]], ); expect(remaining.map((r) => r.id)).toEqual([id2]); }); test("Schedule: per-row select checkbox stays usable even when the # column is hidden via the column customizer", async ({ page, }) => { const date = uniqueFutureDate(3); const idA = await insertMeeting({ meetingDate: date, dailyNumber: 1, titleEn: `HiddenCol1 ${Date.now()}`, titleAr: `HiddenCol1 ${Date.now()}`, startTime: "09:00:00", endTime: "10:00:00", }); const idB = await insertMeeting({ meetingDate: date, dailyNumber: 2, titleEn: `HiddenCol2 ${Date.now()}`, titleAr: `HiddenCol2 ${Date.now()}`, startTime: "10:00:00", endTime: "11:00:00", }); await loginViaUi(page, "admin", "admin123"); await page.goto("/executive-meetings"); await resetSchedulePrefs(page); await page.reload(); // Jump to seeded date and wait for both rows. const dateInput = page.locator('input[type="date"]').first(); await dateInput.fill(date); await expect(page.getByTestId(`em-row-${idA}`)).toBeVisible({ timeout: 10_000, }); await expect(page.getByTestId(`em-row-${idB}`)).toBeVisible(); // #265: Customize-columns now lives in the Settings tab. // Hop over, toggle # off, hop back to the schedule. await page.getByTestId("em-nav-settings").click(); await page.getByTestId("em-customize-toggle-number").click(); await page.getByTestId("em-nav-schedule").click(); // Enter edit mode. Per-row checkboxes must still appear (now as // overlays in the first visible cell), and selecting them must // still drive the bulk toolbar — even with the # column hidden, // the tri-state header overlay falls back to the new first // visible header. await page.getByTestId("em-edit-mode-toggle").click(); await expect(page.getByTestId("em-bulk-select-all")).toBeVisible(); await expect(page.getByTestId(`em-row-select-${idA}`)).toBeVisible(); await expect(page.getByTestId(`em-row-select-${idB}`)).toBeVisible(); // Toolbar is hidden until something is selected. await expect(page.getByTestId("em-bulk-toolbar")).toHaveCount(0); await page.getByTestId(`em-row-select-${idA}`).click(); await expect(page.getByTestId("em-bulk-toolbar")).toBeVisible(); await expect(page.getByTestId("em-bulk-selection-count")).toHaveText(/1/); await expect(page.getByTestId("em-bulk-delete-selected")).toBeVisible(); // Restore the # column so we leave the persisted UI prefs in a // sane state for the next test. await page.getByTestId("em-nav-settings").click(); await page.getByTestId("em-customize-toggle-number").click(); await page.getByTestId("em-nav-schedule").click(); }); // Regression guard for merged rows. Earlier the per-row bulk-select // overlay was anchored to the first visible *non-merged* cell, which // meant a row whose merge swallowed the leading cells lost its // checkbox entirely. The fix mirrors the overlay onto the merged // , so this test merges a row end-to-end via the row-actions // menu and asserts the checkbox still renders + drives the bulk // toolbar. test("Schedule: a merged row still exposes the per-row bulk select checkbox", async ({ page, }) => { const date = uniqueFutureDate(4); const idA = await insertMeeting({ meetingDate: date, dailyNumber: 1, titleEn: `MergedSel1 ${Date.now()}`, titleAr: `MergedSel1 ${Date.now()}`, startTime: "09:00:00", endTime: "10:00:00", }); await loginViaUi(page, "admin", "admin123"); await page.goto("/executive-meetings"); await resetSchedulePrefs(page); await page.reload(); const dateInput = page.locator('input[type="date"]').first(); await dateInput.fill(date); await expect(page.getByTestId(`em-row-${idA}`)).toBeVisible({ timeout: 10_000, }); // The row-actions kebab only mounts when canMutate is true (edit // mode on). Turn edit mode on first, then open the kebab and pick // "Merge all". The header tri-state confirms edit mode is live. await page.getByTestId("em-edit-mode-toggle").click(); await expect(page.getByTestId("em-bulk-select-all")).toBeVisible(); await page .getByTestId(`em-row-actions-${idA}`) .click({ force: true }); await page.getByTestId(`em-merge-trigger-${idA}`).click(); await page.getByTestId(`em-merge-opt-all-${idA}`).click(); // After merge the row collapses its scheduling columns into one // ; confirm the merged cell rendered. await expect(page.getByTestId(`em-merge-cell-${idA}`)).toBeVisible({ timeout: 10_000, }); // Edit mode is still on; assert the per-row checkbox is still // reachable on the merged row, and that selecting it lights up the // bulk toolbar exactly the same way an unmerged row would. await expect(page.getByTestId(`em-row-select-${idA}`)).toBeVisible(); await page.getByTestId(`em-row-select-${idA}`).click(); await expect(page.getByTestId("em-bulk-toolbar")).toBeVisible(); await expect(page.getByTestId("em-bulk-selection-count")).toHaveText(/1/); await expect(page.getByTestId("em-bulk-delete-selected")).toBeVisible(); });