Files
TX/artifacts/tx-os/tests/executive-meetings-schedule-features.spec.mjs
T
riyadhafraa f53f7307da Task #489: row-wide drag rotates meeting content; time + daily numbers anchored
Backend
- New POST /api/executive-meetings/rotate-content (zod-validated) rotates
  ONLY meeting content through fixed (start_time, end_time, daily_number)
  slots. Same-date enforced; per-meeting expectedUpdatedAt → 409 stale;
  incomplete day (missing visible row) → 400.
- ExecutiveMeetingsRotateContentBody added in lib/api-zod (manual.ts).
- 6 backend tests cover happy path, stale, different_dates, 401, 403,
  incomplete_day. Existing /swap-times tests still pass.

Frontend (artifacts/tx-os)
- Whole <tr> is now the drag handle (the dedicated GripVertical button is
  retired). useSortable is gated on canMutate; safeRowDragListeners
  filters drags whose target is an interactive descendant (button, input,
  edit/time cells, row-actions, bulk-select). useSortable `attributes`
  are spread only when canMutate so view-mode rows stay clickable
  (otherwise aria-disabled blocked the popover trigger).
- onRowDragEnd → rotateContent(fromId, toId): optimistic patch reassigns
  each chronological slot's tuple to the new occupant; rolls back + toast
  on failure.
- Quick-actions popover now contains only Postpone (#486 Move up/down
  buttons removed).

Tests (artifacts/tx-os)
- New tests/executive-meetings-row-drag.spec.mjs: drags Alpha → Charlie
  position by # cell, asserts rotate-content fires and slots stay
  anchored.
- tests/executive-meetings-row-quick-actions.spec.mjs: drops up/down
  cases, keeps Postpone + skip-surfaces + viewer.
- tests/executive-meetings-schedule-features.spec.mjs: two legacy grip
  drag tests rewritten to drag the row body and target /rotate-content
  (the legacy /reorder route + tests are intentionally untouched).

Drift / notes
- Architect flagged a medium-severity hardening note: rotate-content
  FOR UPDATE locks orderedIds but not the day-scope completeness query.
  Out of #489 scope; no follow-up created (proposeFollowUpTasks was
  already consumed on #486).
2026-05-11 12:14:08 +00:00

1011 lines
37 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
// <strong> (StarterKit's default tag) or <b> (some sanitizers
// collapse to <b>) 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
// <tr> is now the drag handle, gated by `effectiveCanMutate =
// canMutate && editMode`. We drag from the # cell (the first <td>)
// because it contains only the daily-number <span> — 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:0010:00, dn=1) and A inherits slot 2
// (11:0012: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 <color>` via inline box-shadow on the <tr>.
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
// <strong> (StarterKit's default tag) or <b> (some sanitizers
// collapse to <b>) 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");
});