import { test, expect } from "@playwright/test"; import pg from "pg"; // E2E coverage for the four "interactive schedule" features that // previously only had backend integration tests: // 1. Tiptap rich-text title editing (bold + color, persisted as HTML) // 2. Drag-to-reorder rows (dnd-kit) — daily numbers + start times swap // 3. Custom highlight color from the customize popover — applied as // an inset box-shadow ring on the current meeting row // 4. Non-mutate user (executive_viewer role) sees no grip handle // // Each scenario seeds its own meetings via direct DB inserts on a // far-future date (or today, for the highlight scenario), so we never // collide with real schedule data and afterAll cleanup leaves the DB // untouched. const DATABASE_URL = process.env.DATABASE_URL; if (!DATABASE_URL) { throw new Error( "DATABASE_URL must be set to run the executive-meetings schedule UI tests", ); } const pool = new pg.Pool({ connectionString: DATABASE_URL }); // IDs we created so the afterAll cleanup can DELETE them. const createdMeetingIds = []; // Track temporary executive_viewer role assignments so we can revoke // them on cleanup (the "ahmed" demo user must end the run with the // same role set he started with). const grantedRoleAssignments = []; // [{ userId, roleId }] 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(), ]); } // Wipe the schedule's persisted UI prefs so each test starts from a // known baseline (view mode off, default highlight color, default // columns, no per-row tints). Must be called AFTER login because the // edit-mode storage key is namespaced by userId. 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 */ } }); } async function setLangEn(page) { await page.addInitScript(() => { try { window.localStorage.setItem("tx-lang", "en"); } catch { /* localStorage may not be ready before navigation */ } }); } // Insert a single attendee row directly. Used by the attendee-name // editing scenario so we don't have to drive the manage-dialog flow // just to seed initial state. async function insertAttendee({ meetingId, name, attendanceType, sortOrder, }) { await pool.query( `INSERT INTO executive_meeting_attendees (meeting_id, name, attendance_type, sort_order) VALUES ($1, $2, $3, $4)`, [meetingId, name, attendanceType, sortOrder], ); } // Insert a meeting row directly. Returns the new id. 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; } // Pick a meeting date that won't collide with any real schedule data // OR with stale rows leftover from a previously-failed run. We jitter // the year-out offset by a per-process random base so reruns within // the same minute land on different days. 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); } function todayIso() { return new Date().toISOString().slice(0, 10); } // HH:MM:SS string for a wall-clock time `minutesOffset` minutes from // the local "now". Used to seed a meeting whose [startTime, endTime) // window contains the moment the highlight test runs. function localTimeWithOffset(minutesOffset) { const d = new Date(); d.setMinutes(d.getMinutes() + minutesOffset); const hh = String(d.getHours()).padStart(2, "0"); const mm = String(d.getMinutes()).padStart(2, "0"); return `${hh}:${mm}:00`; } 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], ); } for (const { userId, roleId } of grantedRoleAssignments) { await pool.query( `DELETE FROM user_roles WHERE user_id = $1 AND role_id = $2`, [userId, roleId], ); } await pool.end(); }); test("Schedule: editing a title with bold + color via the Tiptap toolbar persists after reload", async ({ page, }) => { await setLangEn(page); const date = uniqueFutureDate(1); const uniq = `${Date.now().toString(36)}_${Math.random() .toString(36) .slice(2, 6)}`; const originalTitle = `RichEdit Original ${uniq}`; const meetingId = await insertMeeting({ meetingDate: date, dailyNumber: 1, titleEn: originalTitle, titleAr: originalTitle, startTime: "09:00:00", endTime: "10: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 the row we just inserted. const row = page.getByTestId(`em-row-${meetingId}`); await expect(row).toBeVisible({ timeout: 10_000 }); // Flip on edit mode so the EditableCell becomes interactive. const toggle = page.getByTestId("em-edit-mode-toggle"); await toggle.click(); await expect(toggle).toHaveAttribute("aria-pressed", "true"); // Open the title cell editor and apply bold + a red color to ALL of // the existing text, then save via the toolbar's check button. We // deliberately keep the original text — the assertion is that the // toolbar persists *formatting*, not that typing also works (typing // is covered by the existing "edit toggle" tests). const titleCell = page.getByTestId(`em-edit-title-${meetingId}`); await titleCell.click(); const toolbar = page.getByTestId("em-edit-toolbar"); await expect(toolbar).toBeVisible(); // Wait for the contenteditable inside the editor to be ready, then // select the entire title so the bold + color marks apply to every // character. The editor itself opens with the cursor at the end of // the existing text, so a simple ControlOrMeta+a is enough. const editorContent = titleCell.locator('[contenteditable="true"]'); await expect(editorContent).toBeVisible(); await page.keyboard.press("ControlOrMeta+a"); await page.getByTestId("em-edit-bold").click(); await page.getByTestId("em-edit-color-red").click(); // Watch for the PATCH that saves the title before clicking save. const savePromise = page.waitForResponse( (resp) => { const u = new URL(resp.url()); return ( u.pathname === `/api/executive-meetings/${meetingId}` && resp.request().method() === "PATCH" && resp.ok() ); }, { timeout: 15_000 }, ); await page.getByTestId("em-edit-save").click(); await savePromise; await expect(toolbar).toBeHidden(); // Reload the page to confirm the formatting wasn't just sitting in // a Tiptap document — it must round-trip via the server. await page.reload(); await dateInput.fill(date); const rowAfterReload = page.getByTestId(`em-row-${meetingId}`); await expect(rowAfterReload).toBeVisible({ timeout: 10_000 }); const titleAfterReload = page.getByTestId(`em-edit-title-${meetingId}`); await expect(titleAfterReload).toContainText(originalTitle); // Inspect the rendered HTML in the cell. The server-sanitized output // must keep the bold tag and the inline color span. We accept either // (StarterKit's default tag) or (some sanitizers // collapse to ) so the assertion stays robust against the // server's exact allowlist serialization. const html = await titleAfterReload.innerHTML(); expect(html.toLowerCase()).toMatch(/<(strong|b)[\s>]/); // The Tiptap "red" swatch is #dc2626. Browsers serialize inline // styles in either hex or rgb form; accept both. expect(html.toLowerCase()).toMatch(/(#dc2626|rgb\(\s*220\s*,\s*38\s*,\s*38\s*\))/); // And confirm the DB itself stored the formatted HTML, not the plain // text — this catches any frontend-only "looks formatted" regression. // The page may render in either Arabic or English depending on the // signed-in user's preferredLanguage, so we check whichever column // the editor wrote to (the EditableCell saves to title_ar when RTL, // title_en otherwise). const { rows: dbRows } = await pool.query( `SELECT title_ar, title_en FROM executive_meetings WHERE id = $1`, [meetingId], ); expect(dbRows.length).toBe(1); const editedColumn = String( /<(strong|b)[\s>]/i.test(String(dbRows[0].title_ar)) ? dbRows[0].title_ar : dbRows[0].title_en, ).toLowerCase(); expect(editedColumn).toMatch(/<(strong|b)[\s>]/); expect(editedColumn).toMatch(/(#dc2626|rgb\(\s*220\s*,\s*38\s*,\s*38\s*\))/); }); test("Schedule: dragging row 2 above row 1 swaps daily numbers and start times", async ({ page, }) => { await setLangEn(page); const date = uniqueFutureDate(2); const uniq = `${Date.now().toString(36)}_${Math.random() .toString(36) .slice(2, 6)}`; const idA = await insertMeeting({ meetingDate: date, dailyNumber: 1, titleEn: `Drag A ${uniq}`, titleAr: `سحب أ ${uniq}`, startTime: "09:00:00", endTime: "10:00:00", }); const idB = await insertMeeting({ meetingDate: date, dailyNumber: 2, titleEn: `Drag B ${uniq}`, titleAr: `سحب ب ${uniq}`, startTime: "11:00:00", endTime: "12:00:00", }); await loginViaUi(page, "admin", "admin123"); await page.goto("/executive-meetings"); await resetSchedulePrefs(page); await page.reload(); await page.locator('input[type="date"]').first().fill(date); await expect(page.getByTestId(`em-row-${idA}`)).toBeVisible({ timeout: 10_000, }); await expect(page.getByTestId(`em-row-${idB}`)).toBeVisible(); // Edit mode must be on for the grip handle to render. const toggle = page.getByTestId("em-edit-mode-toggle"); await toggle.click(); await expect(toggle).toHaveAttribute("aria-pressed", "true"); // #489: the dedicated GripVertical handle was retired. The whole // is now the drag handle, gated by `effectiveCanMutate = // canMutate && editMode`. We drag from the # cell (the first ) // because it contains only the daily-number — no skip-listed // descendants (buttons, inputs, em-edit-* / em-time-*) that // safeRowDragListeners filters out. const numberCellB = page .locator(`[data-testid="em-row-${idB}"] td`) .first(); const fromBox = await numberCellB.boundingBox(); const toBox = await page .locator(`[data-testid="em-row-${idA}"]`) .boundingBox(); expect(fromBox).not.toBeNull(); expect(toBox).not.toBeNull(); // dnd-kit's PointerSensor activates only after a 6px move from the // pointerdown position, so we issue a small "warm-up" move before // the long move to the target. Using `steps` keeps the synthetic // pointer events spread across enough frames for dnd-kit to pick // them up reliably. const startX = fromBox.x + fromBox.width / 2; const startY = fromBox.y + fromBox.height / 2; const endX = startX; // Aim slightly ABOVE the target row's vertical center so dnd-kit // inserts the dragged row before the target instead of after. const endY = toBox.y + 4; // Drag-row reorder now goes through /rotate-content (#489). const reorderPromise = page.waitForResponse( (resp) => { const u = new URL(resp.url()); return ( u.pathname === "/api/executive-meetings/rotate-content" && resp.request().method() === "POST" && resp.ok() ); }, { timeout: 15_000 }, ); await page.mouse.move(startX, startY); await page.mouse.down(); await page.mouse.move(startX, startY + 12, { steps: 5 }); await page.mouse.move(endX, endY, { steps: 15 }); await page.mouse.up(); await reorderPromise; // rotate-content keeps each chronological slot's (start_time, // end_time, daily_number) anchored and rotates only the meeting // CONTENT into the new positions. After moving B above A, B // inherits slot 1 (09:00–10:00, dn=1) and A inherits slot 2 // (11:00–12:00, dn=2) — same observable end-state as the legacy // /reorder API for this two-row case. const { rows } = await pool.query( `SELECT id, daily_number, start_time, end_time FROM executive_meetings WHERE id = ANY($1::int[]) ORDER BY id`, [[idA, idB]], ); const byId = Object.fromEntries(rows.map((r) => [r.id, r])); expect(byId[idB].daily_number).toBe(1); expect(String(byId[idB].start_time)).toBe("09:00:00"); expect(String(byId[idB].end_time)).toBe("10:00:00"); expect(byId[idA].daily_number).toBe(2); expect(String(byId[idA].start_time)).toBe("11:00:00"); expect(String(byId[idA].end_time)).toBe("12:00:00"); // A reload should reflect the new visual order in the table. await page.reload(); await page.locator('input[type="date"]').first().fill(date); await expect(page.getByTestId(`em-row-${idA}`)).toBeVisible({ timeout: 10_000, }); const rowsInOrder = await page .locator('tr[data-testid^="em-row-"]') .evaluateAll((els) => els.map((el) => el.getAttribute("data-testid"))); // Drop any rows from other dates (defensive — the schedule is // date-scoped so there shouldn't be any, but just in case). const orderedIds = rowsInOrder .map((tid) => Number(tid?.replace("em-row-", ""))) .filter((n) => n === idA || n === idB); expect(orderedIds).toEqual([idB, idA]); }); test("Schedule: choosing a custom highlight color paints the current meeting's box-shadow ring", async ({ page, }) => { await setLangEn(page); // The "current meeting" detection requires the row to be on TODAY // and for now() to fall inside [startTime, endTime). Build a 60-min // window centered on now so the test is robust to a few seconds of // drift between page load and the highlight tick. const date = todayIso(); const startTime = localTimeWithOffset(-30); const endTime = localTimeWithOffset(+30); // Use a dailyNumber high enough not to collide with anything seeded // elsewhere in the test suite for today. The DB has a UNIQUE // (meeting_date, daily_number) index so we pick something out of // the way and recover with a single retry on collision. let meetingId = null; for (const dailyNumber of [991, 992, 993, 994, 995]) { try { meetingId = await insertMeeting({ meetingDate: date, dailyNumber, titleEn: `Highlight Now ${dailyNumber}`, titleAr: `إبراز الآن ${dailyNumber}`, startTime, endTime, }); break; } catch (err) { if (!/duplicate/i.test(String(err.message))) throw err; } } expect(meetingId).not.toBeNull(); await loginViaUi(page, "admin", "admin123"); await page.goto("/executive-meetings"); await resetSchedulePrefs(page); await page.reload(); // Today is the default date, but we set it explicitly to make the // test robust to any future change of the page's initial state. await page.locator('input[type="date"]').first().fill(date); const row = page.getByTestId(`em-row-${meetingId}`); await expect(row).toBeVisible({ timeout: 10_000 }); // The seeded row should be flagged as the current meeting via the // data attribute. Wait for it — the highlight scheduling tick can // need a frame to land after the data arrives. await expect(row).toHaveAttribute("data-current-meeting", "true", { timeout: 5_000, }); // Default highlight color is green (#16a34a). The ring is rendered // as `inset 0 0 0 2px ` via inline box-shadow on the . const initialShadow = await row.evaluate( (el) => getComputedStyle(el).boxShadow, ); // Computed style normalizes hex to rgb; #16a34a → rgb(22, 163, 74). expect(initialShadow).toMatch(/22,\s*163,\s*74/); // #265: Highlight controls now live in the unified Settings tab. // Hop over, flip the swatch, hop back to the schedule. await page.getByTestId("em-nav-settings").click(); const highlightToggle = page.getByTestId("em-highlight-toggle"); await expect(highlightToggle).toBeVisible(); // Make sure the highlight is enabled (the default is on, but assert // it explicitly so the test isn't sensitive to future default flips). const isOn = await highlightToggle.getAttribute("aria-checked"); if (isOn !== "true") { await highlightToggle.click(); await expect(highlightToggle).toHaveAttribute("aria-checked", "true"); } await page.getByTestId("em-highlight-color-#dc2626").click(); await page.getByTestId("em-nav-schedule").click(); // The ring should now reflect the new color: #dc2626 → rgb(220, 38, 38). await expect .poll( async () => row.evaluate((el) => getComputedStyle(el).boxShadow), { timeout: 5_000 }, ) .toMatch(/220,\s*38,\s*38/); }); test("Schedule: a non-mutate user (executive_viewer) sees no grip handle and no edit toggle", async ({ page, }) => { await setLangEn(page); // Seed a meeting so there's something to (try to) drag. const date = uniqueFutureDate(3); const uniq = `${Date.now().toString(36)}_${Math.random() .toString(36) .slice(2, 6)}`; const meetingId = await insertMeeting({ meetingDate: date, dailyNumber: 1, titleEn: `Viewer Only ${uniq}`, titleAr: `مشاهد فقط ${uniq}`, startTime: "09:00:00", endTime: "10:00:00", }); // Grant the demo "ahmed" user the executive_viewer role (read-only). // We track the assignment so afterAll can revoke it. The role is // already seeded by scripts/src/seed.ts. const { rows: ahmedRows } = await pool.query( `SELECT id FROM users WHERE username = 'ahmed'`, ); expect(ahmedRows.length).toBe(1); const ahmedId = ahmedRows[0].id; const { rows: roleRows } = await pool.query( `SELECT id FROM roles WHERE name = 'executive_viewer'`, ); expect(roleRows.length).toBe(1); const viewerRoleId = roleRows[0].id; const { rowCount } = await pool.query( `INSERT INTO user_roles (user_id, role_id) VALUES ($1, $2) ON CONFLICT DO NOTHING`, [ahmedId, viewerRoleId], ); // Only schedule cleanup if WE were the ones who added the row — // otherwise we'd revoke a role ahmed had legitimately. if (rowCount === 1) { grantedRoleAssignments.push({ userId: ahmedId, roleId: viewerRoleId }); } await loginViaUi(page, "ahmed", "user123"); await page.goto("/executive-meetings"); await resetSchedulePrefs(page); await page.reload(); await page.locator('input[type="date"]').first().fill(date); // The seeded row must be visible (read access works). const row = page.getByTestId(`em-row-${meetingId}`); await expect(row).toBeVisible({ timeout: 10_000 }); // No grip handles anywhere — they only render when the row prop // canMutate is true, which equals me.canMutate && editMode. await expect( page.locator('[data-testid^="em-row-grip-"]'), ).toHaveCount(0); // The view/edit toggle is rendered ONLY for users who have the // mutate capability, so a viewer should never see it. await expect(page.getByTestId("em-edit-mode-toggle")).toHaveCount(0); // Sanity: the customize popover is still available (it's purely a // presentation control and does not require mutate access), but the // row-color trigger and merge trigger — both edit-mode-only — must // not be present. await expect(page.getByTestId("em-row-color-trigger")).toHaveCount(0); await expect( page.locator('[data-testid^="em-merge-trigger-"]'), ).toHaveCount(0); // Reorder API must reject a viewer's POST as well — defense in depth. const reorderResp = await page.request.post( "/api/executive-meetings/reorder", { data: { meetingDate: date, orderedIds: [meetingId] }, }, ); expect(reorderResp.status()).toBeGreaterThanOrEqual(400); }); test("Schedule: editing an attendee name with bold + color via the Tiptap toolbar persists after reload", async ({ page, }) => { await setLangEn(page); // Seed a meeting with one attendee whose name is plain text. The // attendee row uses the SAME EditableCell + Tiptap toolbar as titles // (parent task #122), so applying bold + a color swatch and saving // via the toolbar's check button must round-trip through the PUT // attendees endpoint and survive a full reload, just like titles do. const date = uniqueFutureDate(4); const uniq = `${Date.now().toString(36)}_${Math.random() .toString(36) .slice(2, 6)}`; const originalName = `RichAttendee Original ${uniq}`; const meetingId = await insertMeeting({ meetingDate: date, dailyNumber: 1, titleEn: `Attendee Host ${uniq}`, titleAr: `مضيف ${uniq}`, startTime: "09:00:00", endTime: "10:00:00", }); await insertAttendee({ meetingId, name: originalName, attendanceType: "internal", sortOrder: 0, }); await loginViaUi(page, "admin", "admin123"); await page.goto("/executive-meetings"); await resetSchedulePrefs(page); await page.reload(); // Jump to the seeded date and wait for the row. const dateInput = page.locator('input[type="date"]').first(); await dateInput.fill(date); const row = page.getByTestId(`em-row-${meetingId}`); await expect(row).toBeVisible({ timeout: 10_000 }); // Flip on edit mode so the attendee EditableCell becomes interactive. const toggle = page.getByTestId("em-edit-mode-toggle"); await toggle.click(); await expect(toggle).toHaveAttribute("aria-pressed", "true"); // The testid uses the original index in meeting.attendees (0). Scope // under the seeded row so the selector stays unambiguous. const attendeeCell = row.getByTestId(`em-edit-attendee-0`); await expect(attendeeCell).toBeVisible(); await expect(attendeeCell).toContainText(originalName); // Click to enter edit mode, then select the entire name and apply // bold + the red color swatch. Same flow as the title test above — // we keep the original text so the assertion is purely about the // toolbar persisting *formatting*, not typing. await attendeeCell.click(); const toolbar = page.getByTestId("em-edit-toolbar"); await expect(toolbar).toBeVisible(); const editorContent = attendeeCell.locator('[contenteditable="true"]'); await expect(editorContent).toBeVisible(); await page.keyboard.press("ControlOrMeta+a"); await page.getByTestId("em-edit-bold").click(); await page.getByTestId("em-edit-color-red").click(); // Watch for the PUT that saves the attendees array before clicking // save. Inline attendee edits go through PUT /attendees, NOT the // meeting-level PATCH used by titles. const savePromise = page.waitForResponse( (resp) => { const u = new URL(resp.url()); return ( u.pathname === `/api/executive-meetings/${meetingId}/attendees` && resp.request().method() === "PUT" && resp.ok() ); }, { timeout: 15_000 }, ); await page.getByTestId("em-edit-save").click(); await savePromise; await expect(toolbar).toBeHidden(); // Reload to confirm the formatting wasn't just sitting in the local // Tiptap document — it must round-trip via the server. await page.reload(); await dateInput.fill(date); const rowAfterReload = page.getByTestId(`em-row-${meetingId}`); await expect(rowAfterReload).toBeVisible({ timeout: 10_000 }); const attendeeAfterReload = rowAfterReload.getByTestId(`em-edit-attendee-0`); await expect(attendeeAfterReload).toContainText(originalName); // Inspect the rendered HTML in the cell. The server-sanitized output // must keep the bold tag and the inline color span. Accept either // (StarterKit's default tag) or (some sanitizers // collapse to ) so the assertion stays robust against the // server's exact allowlist serialization. const html = await attendeeAfterReload.innerHTML(); expect(html.toLowerCase()).toMatch(/<(strong|b)[\s>]/); // The Tiptap "red" swatch is #dc2626. Browsers serialize inline // styles in either hex or rgb form; accept both. expect(html.toLowerCase()).toMatch(/(#dc2626|rgb\(\s*220\s*,\s*38\s*,\s*38\s*\))/); // And confirm the DB itself stored the formatted HTML, not the plain // text — this catches any frontend-only "looks formatted" regression. // Attendees have a single `name` column (no AR/EN split), so the // assertion is simpler than the title case. const { rows: dbRows } = await pool.query( `SELECT name FROM executive_meeting_attendees WHERE meeting_id = $1 ORDER BY sort_order`, [meetingId], ); expect(dbRows.length).toBe(1); const storedName = String(dbRows[0].name).toLowerCase(); expect(storedName).toMatch(/<(strong|b)[\s>]/); expect(storedName).toMatch(/(#dc2626|rgb\(\s*220\s*,\s*38\s*,\s*38\s*\))/); // The original plain text is still readable inside the formatted markup. expect(storedName).toContain(originalName.toLowerCase()); }); test("Schedule: dragging the attendees column header above the meeting column reorders headers and persists across reload", async ({ page, }) => { await setLangEn(page); // Seed a single meeting so the schedule actually renders rows. The // floating sticky thead (which carries the interactive // SortableHeader cells) is rendered regardless, but dragging into // an empty grid is brittle — having a row keeps the layout stable. const date = uniqueFutureDate(5); const uniq = `${Date.now().toString(36)}_${Math.random() .toString(36) .slice(2, 6)}`; const meetingId = await insertMeeting({ meetingDate: date, dailyNumber: 1, titleEn: `ColReorder ${uniq}`, titleAr: `إعادة ترتيب ${uniq}`, startTime: "09:00:00", endTime: "10:00:00", }); await loginViaUi(page, "admin", "admin123"); await page.goto("/executive-meetings"); await resetSchedulePrefs(page); await page.reload(); await page.locator('input[type="date"]').first().fill(date); await expect(page.getByTestId(`em-row-${meetingId}`)).toBeVisible({ timeout: 10_000, }); // Sanity: the column customizer panel is still surfaced from the // Settings tab (#265 moved it out of a popover into Settings). We // hop over to confirm it exists, then bounce back to the schedule // where the actual header drag happens — column reordering is wired // to the SortableHeader cells inside the schedule's floating thead, // not to chips inside the customizer panel. await page.getByTestId("em-nav-settings").click(); await expect(page.getByTestId("em-customize-columns-panel")).toBeVisible(); await page.getByTestId("em-nav-schedule").click(); // Edit mode must be on for SortableHeader to register dnd-kit // attributes/listeners (dragEnabled === effectiveCanMutate). const toggle = page.getByTestId("em-edit-mode-toggle"); await toggle.click(); await expect(toggle).toHaveAttribute("aria-pressed", "true"); // Read the initial header order. DEFAULT_COLUMNS is // [number, meeting, attendees, time]. const readHeaderOrder = () => page .locator('th[data-testid^="em-col-header-"]') .evaluateAll((els) => els.map((el) => el.getAttribute("data-testid"))); expect(await readHeaderOrder()).toEqual([ "em-col-header-number", "em-col-header-meeting", "em-col-header-attendees", "em-col-header-time", ]); const attendeesHeader = page.getByTestId("em-col-header-attendees"); const meetingHeader = page.getByTestId("em-col-header-meeting"); await expect(attendeesHeader).toBeVisible(); await expect(meetingHeader).toBeVisible(); const fromBox = await attendeesHeader.boundingBox(); const toBox = await meetingHeader.boundingBox(); expect(fromBox).not.toBeNull(); expect(toBox).not.toBeNull(); // dnd-kit's PointerSensor activates only after a 6px move from the // pointerdown position, so we issue a small "warm-up" move before // the long move to the target. SortableContext uses // horizontalListSortingStrategy here, so we drop the pointer // slightly LEFT of the target's horizontal center to insert the // dragged header BEFORE the target instead of after. const startX = fromBox.x + fromBox.width / 2; const startY = fromBox.y + fromBox.height / 2; const endX = toBox.x + 4; const endY = toBox.y + toBox.height / 2; await page.mouse.move(startX, startY); await page.mouse.down(); await page.mouse.move(startX - 12, startY, { steps: 5 }); await page.mouse.move(endX, endY, { steps: 15 }); await page.mouse.up(); // After the drag, the headers should reflect the new order: // attendees moves above meeting. const expectedOrder = [ "em-col-header-number", "em-col-header-attendees", "em-col-header-meeting", "em-col-header-time", ]; await expect.poll(readHeaderOrder, { timeout: 5_000 }).toEqual(expectedOrder); // The new order must be persisted to localStorage under the key // documented in the task (em-schedule-cols-v1). const stored = await page.evaluate(() => window.localStorage.getItem("em-schedule-cols-v1"), ); expect(stored).not.toBeNull(); const storedIds = JSON.parse(stored).map((c) => c.id); expect(storedIds).toEqual(["number", "attendees", "meeting", "time"]); // Reload to confirm persistence — the order must restore from // localStorage rather than reverting to DEFAULT_COLUMNS. await page.reload(); await page.locator('input[type="date"]').first().fill(date); await expect(page.getByTestId(`em-row-${meetingId}`)).toBeVisible({ timeout: 10_000, }); expect(await readHeaderOrder()).toEqual(expectedOrder); }); test("Schedule: when the reorder API rejects a row drag, the UI rolls back to the original order and surfaces an error toast", async ({ page, }) => { await setLangEn(page); // Seed two meetings on a unique future date so we own the order // entirely. The optimistic UI flow we exercise here: // 1. Drag row B above row A → setOptimisticOrder([B, A]) // 2. POST /api/executive-meetings/reorder // 3. Server responds 500 → setOptimisticOrder(null) + error toast // 4. DOM order returns to [A, B] // We assert both the rollback and the user-facing error toast. // Use offset 6 — offsets 1..5 are already claimed by other tests in // this file. The afterAll cleanup is file-level, so two tests sharing // a date + daily_number would hit the UNIQUE (meeting_date, // daily_number) index. Picking a free offset keeps the seed // collision-proof without needing duplicate-retry logic. const date = uniqueFutureDate(6); const uniq = `${Date.now().toString(36)}_${Math.random() .toString(36) .slice(2, 6)}`; const idA = await insertMeeting({ meetingDate: date, dailyNumber: 1, titleEn: `Rollback A ${uniq}`, titleAr: `تراجع أ ${uniq}`, startTime: "09:00:00", endTime: "10:00:00", }); const idB = await insertMeeting({ meetingDate: date, dailyNumber: 2, titleEn: `Rollback B ${uniq}`, titleAr: `تراجع ب ${uniq}`, startTime: "11:00:00", endTime: "12:00:00", }); await loginViaUi(page, "admin", "admin123"); await page.goto("/executive-meetings"); await resetSchedulePrefs(page); await page.reload(); await page.locator('input[type="date"]').first().fill(date); await expect(page.getByTestId(`em-row-${idA}`)).toBeVisible({ timeout: 10_000, }); await expect(page.getByTestId(`em-row-${idB}`)).toBeVisible(); // Capture the DOM order BEFORE we install the route override. This // is the order we expect to see again after the rollback. const readDomOrder = async () => { const rowsInOrder = await page .locator('tr[data-testid^="em-row-"]') .evaluateAll((els) => els.map((el) => el.getAttribute("data-testid"))); return rowsInOrder .map((tid) => Number(tid?.replace("em-row-", ""))) .filter((n) => n === idA || n === idB); }; expect(await readDomOrder()).toEqual([idA, idB]); // Edit mode must be on for the grip handle to render. const toggle = page.getByTestId("em-edit-mode-toggle"); await toggle.click(); await expect(toggle).toHaveAttribute("aria-pressed", "true"); // Intercept the rotate-content POST (#489 replaced /reorder for // drag-driven reorders) and respond with a 500 so the mutation // throws and triggers the onError rollback branch (re-set query // cache + destructive toast). We track the hits so the assertion // below can prove the request really fired (otherwise a missed // drag would silently pass the rollback check). let reorderHits = 0; await page.route( "**/api/executive-meetings/rotate-content", async (route) => { reorderHits += 1; await route.fulfill({ status: 500, contentType: "application/json", // apiJson() reads `data.error` for the thrown message, so use // `error` (not `message`) to make the description deterministic. body: JSON.stringify({ error: "Simulated reorder failure" }), }); }, ); // #489: drag from the # cell of row B (the dedicated grip is gone). const numberCellB = page .locator(`[data-testid="em-row-${idB}"] td`) .first(); const fromBox = await numberCellB.boundingBox(); const toBox = await page .locator(`[data-testid="em-row-${idA}"]`) .boundingBox(); expect(fromBox).not.toBeNull(); expect(toBox).not.toBeNull(); // Same drag mechanics as the success-path reorder test above: // dnd-kit's PointerSensor needs a small warm-up move past its 6px // activation distance, then a stepped move to the target. Aim // slightly above the target row's vertical center so dnd-kit // inserts B before A. const startX = fromBox.x + fromBox.width / 2; const startY = fromBox.y + fromBox.height / 2; const endX = startX; const endY = toBox.y + 4; const failedReorderPromise = page.waitForResponse( (resp) => { const u = new URL(resp.url()); return ( u.pathname === "/api/executive-meetings/rotate-content" && resp.status() === 500 ); }, { timeout: 15_000 }, ); await page.mouse.move(startX, startY); await page.mouse.down(); await page.mouse.move(startX, startY + 12, { steps: 5 }); await page.mouse.move(endX, endY, { steps: 15 }); await page.mouse.up(); await failedReorderPromise; expect(reorderHits).toBeGreaterThanOrEqual(1); // The page renders a destructive toast with title = t("common.error") // ("An error occurred" / "حدث خطأ" depending on the signed-in user's // preferred language) and description = the thrown error message. // We assert against the description because it comes straight from // our route override's response body and is therefore independent of // the UI language. Wait for it before asserting the rollback, since // the toast and the optimistic-order reset both happen synchronously // in the onError branch — once the toast is visible we know the // rollback has already run. const errorToastDesc = page.getByText("Simulated reorder failure", { exact: true, }); await expect(errorToastDesc).toBeVisible({ timeout: 5_000 }); // Also verify the title is one of the localized "error" strings so // we catch a regression that drops the title entirely. const errorToastTitle = page.getByText(/^(An error occurred|حدث خطأ)$/); await expect(errorToastTitle.first()).toBeVisible(); // The DOM order must have returned to the original [A, B]. Use // expect.poll so we tolerate a render frame between the // setOptimisticOrder(null) call and React flushing the new order. await expect .poll(readDomOrder, { timeout: 5_000 }) .toEqual([idA, idB]); // Defense in depth: the database must be untouched — neither row's // daily_number, start_time, nor end_time should have moved, since // the server "rejected" the request and the optimistic UI was rolled // back. (The route override prevented any real PATCH from running, // but we assert against the DB anyway so a future regression that // somehow fires the request through a different code path can't // sneak past.) const { rows } = await pool.query( `SELECT id, daily_number, start_time, end_time FROM executive_meetings WHERE id = ANY($1::int[]) ORDER BY id`, [[idA, idB]], ); const byId = Object.fromEntries(rows.map((r) => [r.id, r])); expect(byId[idA].daily_number).toBe(1); expect(String(byId[idA].start_time)).toBe("09:00:00"); expect(String(byId[idA].end_time)).toBe("10:00:00"); expect(byId[idB].daily_number).toBe(2); expect(String(byId[idB].start_time)).toBe("11:00:00"); expect(String(byId[idB].end_time)).toBe("12:00:00"); await page.unroute("**/api/executive-meetings/rotate-content"); });