Files
TX/artifacts/tx-os/tests/executive-meetings-keyboard-editing.spec.mjs
T
riyadhafraa 7a2ae8434d Update project documentation and code comments to remove platform-specific references
Refactor documentation files and code comments to remove references to Replit, specific task numbers, and other platform-specific identifiers.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: 77cfe984-2c65-4152-bb7a-0df28274fe66
Replit-Commit-Checkpoint-Type: full_checkpoint
Replit-Commit-Event-Id: fa18e5d4-a810-4bd5-8cde-2a60d64d9e3f
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/c3c252e4-c83d-40ca-9fff-99a3ea60701e/77cfe984-2c65-4152-bb7a-0df28274fe66/kI0sxlu
Replit-Helium-Checkpoint-Created: true
2026-05-14 06:23:49 +00:00

2202 lines
81 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 keyboard editing in the executive-meetings schedule.
// These tests verify the keyboard contract for the three inline-edit
// cells (time range, title, attendee name):
//
// - Tab navigation reaches the cell (it is keyboard-reachable via
// tabIndex=0).
// - Pressing Enter on the focused display opens edit mode.
// - Pressing Enter inside the editor saves (PATCH/PUT succeeds,
// editor collapses to display mode).
// - Pressing Escape cancels — original value is restored, NO PATCH/PUT
// is sent, editor collapses.
// - Focus does not get stranded on an unmounted node after save/cancel
// (it returns to body or the row, not into the void).
//
// Each scenario seeds its own meeting row on a far-future date so we
// never collide with real schedule data, and afterAll cleanup deletes
// the rows we created.
const DATABASE_URL = process.env.DATABASE_URL;
if (!DATABASE_URL) {
throw new Error(
"DATABASE_URL must be set to run the executive-meetings keyboard-editing UI tests",
);
}
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(),
]);
}
// Wipe the schedule's persisted UI prefs so each test starts from a
// known baseline. 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 */
}
});
}
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 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],
);
}
const RANDOM_DATE_BASE_DAYS =
730 + 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);
}
// Switch the schedule into edit mode so the inline editors are
// interactive. Idempotent — clicks the toggle only when off.
async function ensureEditMode(page) {
const toggle = page.getByTestId("em-edit-mode-toggle");
await expect(toggle).toBeVisible();
const pressed = await toggle.getAttribute("aria-pressed");
if (pressed !== "true") {
await toggle.click();
await expect(toggle).toHaveAttribute("aria-pressed", "true");
}
}
// Common setup: log in, navigate to /executive-meetings, reset prefs,
// reload, jump to the seeded date, wait for the row, flip on edit mode.
async function gotoMeetingRow(page, meetingId, date) {
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);
const row = page.getByTestId(`em-row-${meetingId}`);
await expect(row).toBeVisible({ timeout: 10_000 });
await ensureEditMode(page);
return row;
}
// Returns the data-testid attribute of the currently-focused element,
// or null when focus is on body / has no testid. Useful for asserting
// keyboard navigation lands on the right cell without depending on a
// fragile Tab count.
async function activeTestId(page) {
return await page.evaluate(() => {
const el = document.activeElement;
if (!el || el === document.body) return null;
return el.getAttribute("data-testid");
});
}
// Tab forward up to `maxSteps` times until the focused element has the
// given test id. Throws via expect() if we never reach it. We use this
// instead of a hard-coded number of Tab presses so the test stays
// stable against small DOM changes (e.g. extra row-overlay buttons).
async function tabUntilFocused(page, targetTestId, maxSteps = 25) {
for (let i = 0; i < maxSteps; i++) {
const cur = await activeTestId(page);
if (cur === targetTestId) return;
await page.keyboard.press("Tab");
}
const cur = await activeTestId(page);
expect(
cur,
`expected focus to reach ${targetTestId} within ${maxSteps} Tab presses`,
).toBe(targetTestId);
}
// Wait for the schedule's day-level GET refetch to land. After every
// inline save the page calls qc.invalidateQueries({ queryKey: [
// "/api/executive-meetings", date ] }), which fires a fresh GET. The
// cell re-renders only AFTER that GET resolves, so polling for the new
// text without first awaiting the refetch can race.
function waitForDayRefetch(page, date) {
return page.waitForResponse(
(resp) => {
const u = new URL(resp.url());
return (
u.pathname === "/api/executive-meetings" &&
u.searchParams.get("date") === date &&
resp.request().method() === "GET" &&
resp.ok()
);
},
{ timeout: 15_000 },
);
}
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 keyboard: Tab into time cell, Enter opens edit, valid times save via Enter and PATCH succeeds", async ({
page,
}) => {
await setLangEn(page);
const date = uniqueFutureDate(1);
const meetingId = await insertMeeting({
meetingDate: date,
dailyNumber: 1,
titleEn: `Keyboard Time ${Date.now().toString(36)}`,
titleAr: `وقت لوحة المفاتيح ${Date.now().toString(36)}`,
startTime: "09:00:00",
endTime: "10:00:00",
});
await gotoMeetingRow(page, meetingId, date);
const timeCell = page.getByTestId(`em-time-${meetingId}`);
await expect(timeCell).toBeVisible();
// Display variant is rendered as <div role="button" tabindex="0">.
// Confirm the cell is keyboard-reachable: focus it via the DOM (a
// raw Tab walk is also covered by a separate tab-order test below;
// the exact step count from the start of the page would depend on
// how many overlay buttons render in this row, which is not the
// target of this assertion).
await timeCell.evaluate((el) => el.focus());
expect(await activeTestId(page)).toBe(`em-time-${meetingId}`);
// Enter on the focused display opens edit mode.
await page.keyboard.press("Enter");
const startInput = page.getByTestId(`em-time-start-${meetingId}`);
const endInput = page.getByTestId(`em-time-end-${meetingId}`);
await expect(startInput).toBeVisible();
await expect(endInput).toBeVisible();
// The start input auto-focuses on enter-edit so the user can begin
// typing immediately.
await expect(startInput).toBeFocused();
// Type valid new times.
await startInput.fill("13:30");
await endInput.focus();
await endInput.fill("14:45");
// Watch for the PATCH that the Enter keypress should trigger AND
// for the day-level GET refetch the page kicks off afterwards. The
// displayed cell only updates once the refetch resolves.
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 },
);
const refetchPromise = waitForDayRefetch(page, date);
await page.keyboard.press("Enter");
await Promise.all([savePromise, refetchPromise]);
// Editor must collapse back to display mode and show the new values.
// The display cell renders compact 12h with NO AM/PM marker per
// formatTime() — "13:30" (24h wire) renders as "1:30" in the cell.
// The DB row asserted below is the source of truth for the wire
// value; the regex here just confirms the display refreshed.
await expect(startInput).toHaveCount(0);
await expect(endInput).toHaveCount(0);
await expect(timeCell).toHaveText(/1:30\s*[\u2013\-]\s*2:45/, {
timeout: 10_000,
});
// The DB must reflect the PATCH (server is the source of truth).
const { rows } = await pool.query(
`SELECT start_time, end_time FROM executive_meetings WHERE id = $1`,
[meetingId],
);
expect(String(rows[0].start_time)).toBe("13:30:00");
expect(String(rows[0].end_time)).toBe("14:45:00");
// Focus must return to a sensible element — either the (re-rendered)
// display cell or the document body. What it MUST NOT do is land on
// an unmounted input or escape the page entirely.
const focused = await activeTestId(page);
expect(focused === null || focused === `em-time-${meetingId}`).toBe(true);
});
test("Schedule keyboard: time cell Esc restores original times and sends no PATCH", async ({
page,
}) => {
await setLangEn(page);
const date = uniqueFutureDate(2);
const meetingId = await insertMeeting({
meetingDate: date,
dailyNumber: 1,
titleEn: `Keyboard Time Esc ${Date.now().toString(36)}`,
titleAr: `إلغاء الوقت ${Date.now().toString(36)}`,
startTime: "08:15:00",
endTime: "09:45:00",
});
await gotoMeetingRow(page, meetingId, date);
const timeCell = page.getByTestId(`em-time-${meetingId}`);
// formatTime() renders compact 12h with no AM/PM marker, dropping
// the leading zero — "08:15" (24h wire) shows as "8:15" in the cell.
await expect(timeCell).toHaveText(/8:15\s*[\u2013\-]\s*9:45/);
// Spy on any PATCH to this meeting — Escape must not trigger one.
let patchSeen = false;
page.on("response", (resp) => {
try {
const u = new URL(resp.url());
if (
u.pathname === `/api/executive-meetings/${meetingId}` &&
resp.request().method() === "PATCH"
) {
patchSeen = true;
}
} catch {
/* ignore */
}
});
await timeCell.evaluate((el) => el.focus());
await page.keyboard.press("Enter");
const startInput = page.getByTestId(`em-time-start-${meetingId}`);
const endInput = page.getByTestId(`em-time-end-${meetingId}`);
await expect(startInput).toBeFocused();
// Type something different from the saved value so that, if Escape
// failed to cancel, we'd see a real change in the cell.
await startInput.fill("17:00");
await endInput.focus();
await endInput.fill("18:30");
// Esc cancels — editor closes, original values restored. Display
// cell uses the same compact 12h, no AM/PM, no-leading-zero shape
// formatTime() produces.
await page.keyboard.press("Escape");
await expect(startInput).toHaveCount(0);
await expect(endInput).toHaveCount(0);
await expect(timeCell).toHaveText(/8:15\s*[\u2013\-]\s*9:45/);
// Give the page a beat to make sure no late PATCH fires from a
// delayed blur path before we assert.
await page.waitForTimeout(400);
expect(patchSeen).toBe(false);
// DB must still hold the originals.
const { rows } = await pool.query(
`SELECT start_time, end_time FROM executive_meetings WHERE id = $1`,
[meetingId],
);
expect(String(rows[0].start_time)).toBe("08:15:00");
expect(String(rows[0].end_time)).toBe("09:45:00");
// Focus is sensible after cancel (cell or body, not on a removed input).
const focused = await activeTestId(page);
expect(focused === null || focused === `em-time-${meetingId}`).toBe(true);
});
test("Schedule keyboard: title cell Enter opens edit and Enter saves the new value via PATCH", async ({
page,
}) => {
await setLangEn(page);
const date = uniqueFutureDate(3);
const uniq = Date.now().toString(36);
const originalTitle = `KbdTitle Original ${uniq}`;
// We append (instead of select-all + replace) because typing-as-append
// exercises the same Enter-saves contract without depending on the
// ProseMirror selectAll keymap (which can be flaky to drive from
// outside the editor in headless Chromium).
const appended = ` SAVED-${uniq}`;
const meetingId = await insertMeeting({
meetingDate: date,
dailyNumber: 1,
titleEn: originalTitle,
titleAr: originalTitle,
startTime: "09:00:00",
endTime: "10:00:00",
});
await gotoMeetingRow(page, meetingId, date);
const titleCell = page.getByTestId(`em-edit-title-${meetingId}`);
await expect(titleCell).toContainText(originalTitle);
// Confirm the title cell is keyboard-reachable (tabIndex=0) by
// focusing it directly, then press Enter to enter edit mode.
await titleCell.evaluate((el) => el.focus());
expect(await activeTestId(page)).toBe(`em-edit-title-${meetingId}`);
await page.keyboard.press("Enter");
const toolbar = page.getByTestId("em-edit-toolbar");
await expect(toolbar).toBeVisible();
const editorContent = titleCell.locator('[contenteditable="true"]');
await expect(editorContent).toBeVisible();
await expect(editorContent).toBeFocused();
// The editor opens with the cursor at the end of the existing text
// (commands.focus("end")), so plain typing appends.
await page.keyboard.type(appended);
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 },
);
const refetchPromise = waitForDayRefetch(page, date);
// Single-line title (multiline=false): Enter (no Shift) saves via
// the EditableCell keydown handler.
await page.keyboard.press("Enter");
await Promise.all([savePromise, refetchPromise]);
const expectedTitle = originalTitle + appended;
await expect(toolbar).toBeHidden();
await expect(titleCell).toContainText(expectedTitle, { timeout: 10_000 });
// DB confirms the save round-tripped. The EditableCell saves to
// title_ar when the page is RTL (admin's preferredLanguage drives
// this) and title_en when LTR — so check whichever column actually
// received the saved HTML, exactly like the schedule-features
// formatting test does.
const { rows } = await pool.query(
`SELECT title_ar, title_en FROM executive_meetings WHERE id = $1`,
[meetingId],
);
const savedTitle = String(rows[0].title_ar).includes(appended)
? String(rows[0].title_ar)
: String(rows[0].title_en);
expect(savedTitle).toContain(originalTitle);
expect(savedTitle).toContain(appended);
const focused = await activeTestId(page);
expect(focused === null || focused === `em-edit-title-${meetingId}`).toBe(
true,
);
});
test("Schedule keyboard: title cell Esc closes editor, restores the original, and sends no PATCH", async ({
page,
}) => {
await setLangEn(page);
const date = uniqueFutureDate(4);
const uniq = Date.now().toString(36);
const originalTitle = `KbdTitleEsc Original ${uniq}`;
const meetingId = await insertMeeting({
meetingDate: date,
dailyNumber: 1,
titleEn: originalTitle,
titleAr: originalTitle,
startTime: "09:00:00",
endTime: "10:00:00",
});
await gotoMeetingRow(page, meetingId, date);
const titleCell = page.getByTestId(`em-edit-title-${meetingId}`);
await expect(titleCell).toContainText(originalTitle);
let patchSeen = false;
page.on("response", (resp) => {
try {
const u = new URL(resp.url());
if (
u.pathname === `/api/executive-meetings/${meetingId}` &&
resp.request().method() === "PATCH"
) {
patchSeen = true;
}
} catch {
/* ignore */
}
});
await titleCell.evaluate((el) => el.focus());
await page.keyboard.press("Enter");
const toolbar = page.getByTestId("em-edit-toolbar");
await expect(toolbar).toBeVisible();
const editorContent = titleCell.locator('[contenteditable="true"]');
await expect(editorContent).toBeFocused();
// Type a draft change that we expect Esc to throw away.
await page.keyboard.type(" DRAFT-NEVER-SAVED");
await page.keyboard.press("Escape");
await expect(toolbar).toBeHidden();
await expect(titleCell).toContainText(originalTitle);
await expect(titleCell).not.toContainText("DRAFT-NEVER-SAVED");
await page.waitForTimeout(400);
expect(patchSeen).toBe(false);
// DB still holds the original.
const { rows } = await pool.query(
`SELECT title_en FROM executive_meetings WHERE id = $1`,
[meetingId],
);
expect(String(rows[0].title_en)).toBe(originalTitle);
const focused = await activeTestId(page);
expect(focused === null || focused === `em-edit-title-${meetingId}`).toBe(
true,
);
});
test("Schedule keyboard: attendee cell Enter opens edit and Enter saves the new name via PUT", async ({
page,
}) => {
await setLangEn(page);
const date = uniqueFutureDate(5);
const uniq = Date.now().toString(36);
const originalName = `KbdAtt Original ${uniq}`;
// Append a suffix rather than replace, for the same reason as the
// title test above: it exercises the same Enter-saves contract
// without depending on an external select-all keymap.
const appended = ` SAVED-${uniq}`;
const meetingId = await insertMeeting({
meetingDate: date,
dailyNumber: 1,
titleEn: `Att Host ${uniq}`,
titleAr: `مضيف ${uniq}`,
startTime: "09:00:00",
endTime: "10:00:00",
});
await insertAttendee({
meetingId,
name: originalName,
attendanceType: "internal",
sortOrder: 0,
});
const row = await gotoMeetingRow(page, meetingId, date);
// The testid uses the original index in meeting.attendees (0).
// Scope under the seeded row so the selector stays unambiguous even
// if a future test ever shares the same date.
const attendeeCell = row.getByTestId(`em-edit-attendee-0`);
await expect(attendeeCell).toBeVisible();
await expect(attendeeCell).toContainText(originalName);
await attendeeCell.evaluate((el) => el.focus());
expect(await activeTestId(page)).toBe(`em-edit-attendee-0`);
await page.keyboard.press("Enter");
const toolbar = page.getByTestId("em-edit-toolbar");
await expect(toolbar).toBeVisible();
const editorContent = attendeeCell.locator('[contenteditable="true"]');
await expect(editorContent).toBeVisible();
await expect(editorContent).toBeFocused();
// Cursor opens at end (focus("end")), so typing appends.
await page.keyboard.type(appended);
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 },
);
const refetchPromise = waitForDayRefetch(page, date);
await page.keyboard.press("Enter");
await Promise.all([savePromise, refetchPromise]);
const expectedName = originalName + appended;
await expect(toolbar).toBeHidden();
await expect(attendeeCell).toContainText(expectedName, { timeout: 10_000 });
const { rows } = await pool.query(
`SELECT name FROM executive_meeting_attendees
WHERE meeting_id = $1 ORDER BY sort_order`,
[meetingId],
);
expect(rows.length).toBe(1);
expect(String(rows[0].name)).toContain(originalName);
expect(String(rows[0].name)).toContain(appended);
const focused = await activeTestId(page);
expect(focused === null || focused === `em-edit-attendee-0`).toBe(true);
});
test("Schedule keyboard: attendee cell Esc closes editor, restores the original, and sends no PUT", async ({
page,
}) => {
await setLangEn(page);
const date = uniqueFutureDate(6);
const uniq = Date.now().toString(36);
const originalName = `KbdAttEsc Original ${uniq}`;
const meetingId = await insertMeeting({
meetingDate: date,
dailyNumber: 1,
titleEn: `Att Host Esc ${uniq}`,
titleAr: `مضيف إلغاء ${uniq}`,
startTime: "09:00:00",
endTime: "10:00:00",
});
await insertAttendee({
meetingId,
name: originalName,
attendanceType: "internal",
sortOrder: 0,
});
const row = await gotoMeetingRow(page, meetingId, date);
const attendeeCell = row.getByTestId(`em-edit-attendee-0`);
await expect(attendeeCell).toContainText(originalName);
let putSeen = false;
page.on("response", (resp) => {
try {
const u = new URL(resp.url());
if (
u.pathname === `/api/executive-meetings/${meetingId}/attendees` &&
resp.request().method() === "PUT"
) {
putSeen = true;
}
} catch {
/* ignore */
}
});
await attendeeCell.evaluate((el) => el.focus());
await page.keyboard.press("Enter");
const toolbar = page.getByTestId("em-edit-toolbar");
await expect(toolbar).toBeVisible();
const editorContent = attendeeCell.locator('[contenteditable="true"]');
await expect(editorContent).toBeFocused();
await page.keyboard.type(" DRAFT-NEVER-SAVED");
await page.keyboard.press("Escape");
await expect(toolbar).toBeHidden();
await expect(attendeeCell).toContainText(originalName);
await expect(attendeeCell).not.toContainText("DRAFT-NEVER-SAVED");
await page.waitForTimeout(400);
expect(putSeen).toBe(false);
// DB unchanged.
const { rows } = await pool.query(
`SELECT name FROM executive_meeting_attendees
WHERE meeting_id = $1 ORDER BY sort_order`,
[meetingId],
);
expect(rows.length).toBe(1);
expect(String(rows[0].name)).toBe(originalName);
const focused = await activeTestId(page);
expect(focused === null || focused === `em-edit-attendee-0`).toBe(true);
});
test("Schedule keyboard: Tab from the title cell reaches the time cell in the same row", async ({
page,
}) => {
// Covers the keyboard-reachability requirement ("Tab into a time
// cell"). We seed a row with NO attendees so the tab path between
// the title and the time cell is short and stable.
await setLangEn(page);
const date = uniqueFutureDate(7);
const meetingId = await insertMeeting({
meetingDate: date,
dailyNumber: 1,
titleEn: `Tab Order ${Date.now().toString(36)}`,
titleAr: `ترتيب التبويب ${Date.now().toString(36)}`,
startTime: "09:00:00",
endTime: "10:00:00",
});
await gotoMeetingRow(page, meetingId, date);
const titleCell = page.getByTestId(`em-edit-title-${meetingId}`);
await titleCell.evaluate((el) => el.focus());
expect(await activeTestId(page)).toBe(`em-edit-title-${meetingId}`);
// Walk forward with Tab until we land on the time cell. Bounded so a
// future regression that breaks tab order fails fast instead of
// tabbing forever.
await tabUntilFocused(page, `em-time-${meetingId}`, 30);
// Pressing Enter on the focused time display opens edit mode.
await page.keyboard.press("Enter");
await expect(page.getByTestId(`em-time-start-${meetingId}`)).toBeFocused();
});
// -------------------------------------------------------------------
// typing on the keyboard + Enter must save.
//
// The historical bug: the time cell used <input type="time">, which
// only fires onChange when the value is a complete, canonical "HH:MM"
// string. Typing "9:30" without a leading zero, "0930" compact, "9:30
// PM", or Arabic-Indic digits left the controlled React state empty,
// so pressing Enter ran save() with the stale value, equality with
// the saved value short-circuited, no PATCH fired, and the cell
// snapped back to "—" (or the prior value) — looking to the user
// like Enter "didn't save".
//
// These cases exercise each typed shape through the full UI stack
// (keystrokes → React state → save() → PATCH → DB → refetched cell
// text) so a regression at any layer trips a test.
// -------------------------------------------------------------------
// Reusable helper: open a meeting's time cell, focus the start input,
// clear it, type a new value, optionally pick the AM/PM toggle, do
// the same for end, press Enter, and wait for the PATCH + day GET
// refetch.
//
// the time cell is now a 12-hour picker — typing a
// 112 hour without first picking AM or PM is intentionally an
// ambiguous parse error (the fix for the "1:15 silently saved as
// AM" bug). For typed shapes that the picker can resolve on its
// own (an explicit AM/PM marker like "9:30 PM", or a 24h-only hour
// like "13:30") `period` should be `null`; for ambiguous shapes
// pass "am" or "pm" so the helper clicks the matching toggle
// before pressing Enter.
async function typeTimesAndSave(
page,
meetingId,
date,
startText,
endText,
startPeriod = null,
endPeriod = null,
) {
const timeCell = page.getByTestId(`em-time-${meetingId}`);
await timeCell.evaluate((el) => el.focus());
await page.keyboard.press("Enter");
const startInput = page.getByTestId(`em-time-start-${meetingId}`);
const endInput = page.getByTestId(`em-time-end-${meetingId}`);
await expect(startInput).toBeVisible();
await expect(startInput).toBeFocused();
// Clear and type into start. Use page.keyboard.type for the AR-digit
// case so the OS IME doesn't intercept; for ASCII we use fill() which
// is faster and equivalent.
await startInput.click();
await page.keyboard.press("ControlOrMeta+a");
await page.keyboard.press("Delete");
await page.keyboard.type(startText, { delay: 10 });
if (startPeriod) {
await page
.getByTestId(`em-time-start-period-${startPeriod}-${meetingId}`)
.click();
}
await endInput.click();
await page.keyboard.press("ControlOrMeta+a");
await page.keyboard.press("Delete");
await page.keyboard.type(endText, { delay: 10 });
if (endPeriod) {
await page
.getByTestId(`em-time-end-period-${endPeriod}-${meetingId}`)
.click();
}
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 },
);
const refetchPromise = waitForDayRefetch(page, date);
// Re-focus the end input so Enter triggers save() rather than
// re-activating the AM/PM toggle that was last clicked.
await endInput.focus();
await page.keyboard.press("Enter");
await Promise.all([savePromise, refetchPromise]);
}
// Each row in this matrix is one accepted typing shape from the task's
// "Done looks like" list. Both fields use the same shape so the
// assertions stay simple, and the tested shapes intentionally vary in
// hour/minute so a test that accidentally passes by reading stale
// state (rather than the new value) would fail.
//
// `startPeriod` / `endPeriod` mirror's "the picker has no
// implicit default" rule: ambiguous typed shapes (112 hour, no
// marker) require the helper to click the AM/PM toggle, while
// shapes with an explicit marker or a 24h-only hour are resolved by
// the picker without a toggle pick.
const TYPING_SHAPES = [
{ offset: 8, label: "canonical 24h", startText: "13:30", endText: "14:45", startPeriod: null, endPeriod: null, expectStart: "13:30:00", expectEnd: "14:45:00" },
{ offset: 9, label: "no leading zero + AM", startText: "9:30", endText: "10:45", startPeriod: "am", endPeriod: "am", expectStart: "09:30:00", expectEnd: "10:45:00" },
{ offset: 10, label: "compact HHMM + AM", startText: "0930", endText: "1045", startPeriod: "am", endPeriod: "am", expectStart: "09:30:00", expectEnd: "10:45:00" },
{ offset: 11, label: "12h with PM", startText: "9:30 AM", endText: "9:30 PM", startPeriod: null, endPeriod: null, expectStart: "09:30:00", expectEnd: "21:30:00" },
// The "0115 + PM = 13:15" shape from the task spec — the toggle
// promotes the typed compact hour to PM.
{ offset: 12, label: "compact + PM toggle", startText: "0115", endText: "0230", startPeriod: "pm", endPeriod: "pm", expectStart: "13:15:00", expectEnd: "14:30:00" },
];
for (const shape of TYPING_SHAPES) {
test(`Schedule keyboard: typing "${shape.label}" + Enter saves and PATCH succeeds`, async ({
page,
}) => {
await setLangEn(page);
// Each shape gets its own future date offset so the loop never
// collides on the (date, daily_number) unique constraint when
// these tests run in the same worker.
const date = uniqueFutureDate(shape.offset);
const meetingId = await insertMeeting({
meetingDate: date,
dailyNumber: 1,
titleEn: `KbdType ${shape.label} ${Date.now().toString(36)}`,
titleAr: `إدخال ${shape.label} ${Date.now().toString(36)}`,
// Seed with an empty time cell so we exercise the
// "type from scratch" path the user reported broken.
startTime: null,
endTime: null,
});
await gotoMeetingRow(page, meetingId, date);
await typeTimesAndSave(
page,
meetingId,
date,
shape.startText,
shape.endText,
shape.startPeriod,
shape.endPeriod,
);
const timeCell = page.getByTestId(`em-time-${meetingId}`);
await expect(page.getByTestId(`em-time-start-${meetingId}`)).toHaveCount(0);
await expect(page.getByTestId(`em-time-end-${meetingId}`)).toHaveCount(0);
// Don't pin the displayed text format — formatTime() picks 12h/24h
// per locale and the dash glyph varies. The DB row is the source
// of truth and is asserted below.
await expect(timeCell).not.toHaveText("—", { timeout: 10_000 });
const { rows } = await pool.query(
`SELECT start_time, end_time FROM executive_meetings WHERE id = $1`,
[meetingId],
);
expect(String(rows[0].start_time)).toBe(shape.expectStart);
expect(String(rows[0].end_time)).toBe(shape.expectEnd);
});
}
test("Schedule keyboard (AR): typing Arabic-Indic digits + Enter saves canonical HH:MM", async ({
page,
}) => {
// Default lang is Arabic — don't set EN. This mirrors how an Arabic
// user types times from their keyboard layout.
const date = uniqueFutureDate(13);
const meetingId = await insertMeeting({
meetingDate: date,
dailyNumber: 1,
titleEn: `KbdAr ${Date.now().toString(36)}`,
titleAr: `أرقام عربية ${Date.now().toString(36)}`,
startTime: null,
endTime: null,
});
await gotoMeetingRow(page, meetingId, date);
// ٠٩:٣٠ -> 09:30, ١٠:٤٥ -> 10:45. Both are 112 hours so the
// picker requires an explicit AM/PM choice — pass the
// Arabic morning marker via the toggle so the helper clicks ص.
await typeTimesAndSave(page, meetingId, date, "٠٩:٣٠", "١٠:٤٥", "am", "am");
const { rows } = await pool.query(
`SELECT start_time, end_time FROM executive_meetings WHERE id = $1`,
[meetingId],
);
expect(String(rows[0].start_time)).toBe("09:30:00");
expect(String(rows[0].end_time)).toBe("10:45:00");
});
// -------------------------------------------------------------------
// dragging a visible meeting must produce a chronologically-
// sorted visible list, even when the same day contains hidden cancelled
// meetings. Before the fix, cancelled meetings (filtered out of
// `orderedMeetings` for display) were still included in the reorder
// POST under their raw-list positions, so the server's slot-swap stole
// chronologically-sorted slots for them and the visible rows ended up
// out of order on screen.
// -------------------------------------------------------------------
test("Schedule drag-reorder: cancelled rows on the same day do not disturb the visible chronological order", async ({
page,
}) => {
await setLangEn(page);
const date = uniqueFutureDate(20);
// Three visible meetings (A, C, D) bracketing a cancelled meeting B
// sandwiched in the middle of the day's daily_number sequence. The
// cancelled meeting MUST keep its time slot and daily number; the
// visible meetings MUST end up in chronological start-time order
// top-to-bottom after the drag.
const a = await insertMeeting({
meetingDate: date,
dailyNumber: 1,
titleEn: `DragA ${Date.now().toString(36)}`,
titleAr: `سحبأ ${Date.now().toString(36)}`,
startTime: "09:00:00",
endTime: "09:30:00",
});
const b = await insertMeeting({
meetingDate: date,
dailyNumber: 2,
titleEn: `DragB-cancelled ${Date.now().toString(36)}`,
titleAr: `سحبب-ملغى ${Date.now().toString(36)}`,
startTime: "10:00:00",
endTime: "10:30:00",
});
const c = await insertMeeting({
meetingDate: date,
dailyNumber: 3,
titleEn: `DragC ${Date.now().toString(36)}`,
titleAr: `سحبج ${Date.now().toString(36)}`,
startTime: "11:00:00",
endTime: "11:30:00",
});
const d = await insertMeeting({
meetingDate: date,
dailyNumber: 4,
titleEn: `DragD ${Date.now().toString(36)}`,
titleAr: `سحبد ${Date.now().toString(36)}`,
startTime: "12:00:00",
endTime: "12:30:00",
});
// Mark B cancelled directly via SQL so the visible list seen by
// dnd-kit is [A, C, D]; the underlying day still holds 4 rows.
await pool.query(
`UPDATE executive_meetings SET status = 'cancelled' WHERE id = $1`,
[b],
);
// Capture B's pre-reorder slot so we can assert it is untouched
// after the drag.
const { rows: bBeforeRows } = await pool.query(
`SELECT daily_number, start_time, end_time
FROM executive_meetings WHERE id = $1`,
[b],
);
const bBefore = bBeforeRows[0];
// Log in, navigate, switch to edit mode (drag handles only show
// when edit mode is on).
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);
const rowA = page.getByTestId(`em-row-${a}`);
const rowC = page.getByTestId(`em-row-${c}`);
const rowD = page.getByTestId(`em-row-${d}`);
await expect(rowA).toBeVisible({ timeout: 10_000 });
await expect(rowC).toBeVisible();
await expect(rowD).toBeVisible();
// Cancelled rows are hidden from the schedule (#273).
await expect(page.getByTestId(`em-row-${b}`)).toHaveCount(0);
await ensureEditMode(page);
// Drive the reorder through dnd-kit's keyboard sensor — focus the
// grip on the bottom visible row (D) and use Space + ArrowUp twice
// + Space to move it to the top. This exercises the REAL onDragEnd
// callback path (including the client's `orderedMeetings`-based
// index math) end-to-end. Headless pixel drag against
// SortableContext is unreliable, so keyboard drag is the high-
// fidelity equivalent — dnd-kit treats both the same downstream.
const gripD = page.getByTestId(`em-row-grip-${d}`);
await expect(gripD).toBeVisible();
await gripD.focus();
const savePromise = page.waitForResponse(
(resp) => {
const u = new URL(resp.url());
return (
u.pathname === "/api/executive-meetings/reorder" &&
resp.request().method() === "POST" &&
resp.ok()
);
},
{ timeout: 15_000 },
);
const refetchPromise = waitForDayRefetch(page, date);
await page.keyboard.press("Space"); // pick up D
// Brief settle pause — dnd-kit re-measures the SortableContext after
// pick-up before the keyboard sensor accepts movement keys, so a
// back-to-back ArrowUp can otherwise be dropped on the floor.
await page.waitForTimeout(150);
await page.keyboard.press("ArrowUp"); // D over C
await page.waitForTimeout(150);
await page.keyboard.press("ArrowUp"); // D over A
await page.waitForTimeout(150);
await page.keyboard.press("Space"); // drop -> visible order [D, A, C]
await Promise.all([savePromise, refetchPromise]);
// After the reorder the visible list must read D, A, C top-to-bottom
// and the times must be chronologically sorted.
const { rows: visibleAfter } = await pool.query(
`SELECT id, daily_number, start_time, end_time, status
FROM executive_meetings
WHERE meeting_date = $1 AND status <> 'cancelled'
ORDER BY daily_number ASC`,
[date],
);
expect(visibleAfter.map((r) => r.id)).toEqual([d, a, c]);
// Chronologically increasing.
for (let i = 1; i < visibleAfter.length; i++) {
expect(
String(visibleAfter[i - 1].start_time) <= String(visibleAfter[i].start_time),
).toBe(true);
}
// Concretely the slot-swap must produce 09:00, 11:00, 12:00 for D, A, C
// (B's 10:00 stays with B).
expect(String(visibleAfter[0].start_time)).toBe("09:00:00");
expect(String(visibleAfter[1].start_time)).toBe("11:00:00");
expect(String(visibleAfter[2].start_time)).toBe("12:00:00");
// Cancelled B: time slot AND daily_number must be unchanged.
const { rows: bAfterRows } = await pool.query(
`SELECT daily_number, start_time, end_time, status
FROM executive_meetings WHERE id = $1`,
[b],
);
const bAfter = bAfterRows[0];
expect(bAfter.status).toBe("cancelled");
expect(String(bAfter.start_time)).toBe(String(bBefore.start_time));
expect(String(bAfter.end_time)).toBe(String(bBefore.end_time));
expect(bAfter.daily_number).toBe(bBefore.daily_number);
});
test("Schedule drag-reorder: a null-startTime row drags deterministically and inherits its new slot", async ({
page,
}) => {
// The slot-swap sort puts non-null startTimes first (chronological)
// and null-startTime rows last. After dragging the null row to the
// top of a 3-row visible day, it should inherit the first
// chronological slot (09:00) and the originally-null slot should
// shift to the bottom row.
await setLangEn(page);
const date = uniqueFutureDate(25);
const a = await insertMeeting({
meetingDate: date,
dailyNumber: 1,
titleEn: `NullA ${Date.now().toString(36)}`,
titleAr: `بلاأ ${Date.now().toString(36)}`,
startTime: "09:00:00",
endTime: "09:30:00",
});
const b = await insertMeeting({
meetingDate: date,
dailyNumber: 2,
titleEn: `NullB ${Date.now().toString(36)}`,
titleAr: `بلاب ${Date.now().toString(36)}`,
startTime: "10:00:00",
endTime: "10:30:00",
});
const n = await insertMeeting({
meetingDate: date,
dailyNumber: 3,
titleEn: `NullN ${Date.now().toString(36)}`,
titleAr: `بلان ${Date.now().toString(36)}`,
startTime: null,
endTime: null,
});
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);
const rowA = page.getByTestId(`em-row-${a}`);
const rowB = page.getByTestId(`em-row-${b}`);
const rowN = page.getByTestId(`em-row-${n}`);
await expect(rowA).toBeVisible({ timeout: 10_000 });
await expect(rowB).toBeVisible();
await expect(rowN).toBeVisible();
await ensureEditMode(page);
const gripN = page.getByTestId(`em-row-grip-${n}`);
await expect(gripN).toBeVisible();
await gripN.focus();
const savePromise = page.waitForResponse(
(resp) => {
const u = new URL(resp.url());
return (
u.pathname === "/api/executive-meetings/reorder" &&
resp.request().method() === "POST" &&
resp.ok()
);
},
{ timeout: 15_000 },
);
const refetchPromise = waitForDayRefetch(page, date);
await page.keyboard.press("Space"); // pick up N (last row)
await page.waitForTimeout(150);
await page.keyboard.press("ArrowUp");
await page.waitForTimeout(150);
await page.keyboard.press("ArrowUp");
await page.waitForTimeout(150);
await page.keyboard.press("Space"); // drop -> visible order [N, A, B]
await Promise.all([savePromise, refetchPromise]);
// Slots sorted chronologically (nulls last) were
// slot[0] = (09:00, dn=A's), slot[1] = (10:00, dn=B's), slot[2] = (null, dn=N's).
// Assignment N->slot[0], A->slot[1], B->slot[2] yields the row times below.
const { rows: visibleAfter } = await pool.query(
`SELECT id, daily_number, start_time, end_time
FROM executive_meetings
WHERE meeting_date = $1
ORDER BY daily_number ASC`,
[date],
);
const byId = Object.fromEntries(visibleAfter.map((r) => [r.id, r]));
expect(String(byId[n].start_time)).toBe("09:00:00");
expect(String(byId[a].start_time)).toBe("10:00:00");
expect(byId[b].start_time).toBe(null);
// dailyNumbers are a unique permutation 1..3 across the day.
const dns = visibleAfter.map((r) => r.daily_number).slice().sort();
expect(dns).toEqual([1, 2, 3]);
});
test("Schedule keyboard: typing an unparseable time + Enter shows a toast and does NOT save", async ({
page,
}) => {
await setLangEn(page);
const date = uniqueFutureDate(14);
const meetingId = await insertMeeting({
meetingDate: date,
dailyNumber: 1,
titleEn: `KbdBad ${Date.now().toString(36)}`,
titleAr: `غير صالح ${Date.now().toString(36)}`,
startTime: "08:00:00",
endTime: "09:00:00",
});
await gotoMeetingRow(page, meetingId, date);
// Spy on PATCH — none should fire for an unparseable typed value.
let patchSeen = false;
page.on("response", (resp) => {
try {
const u = new URL(resp.url());
if (
u.pathname === `/api/executive-meetings/${meetingId}` &&
resp.request().method() === "PATCH"
) {
patchSeen = true;
}
} catch {
/* ignore */
}
});
const timeCell = page.getByTestId(`em-time-${meetingId}`);
await timeCell.evaluate((el) => el.focus());
await page.keyboard.press("Enter");
const startInput = page.getByTestId(`em-time-start-${meetingId}`);
await expect(startInput).toBeFocused();
await startInput.click();
await page.keyboard.press("ControlOrMeta+a");
await page.keyboard.press("Delete");
await page.keyboard.type("25:99", { delay: 10 });
await page.keyboard.press("Enter");
// Editor must STAY open so the user can fix the typo. Toast surfaces
// the parse error.
await expect(startInput).toBeVisible();
await expect(startInput).toBeFocused();
// Match the toast title specifically. The toast also surfaces an
// ARIA live-region copy for screen readers, so without a scope the
// pattern resolves to two elements and trips strict mode.
await expect(
page
.locator('[data-component-name="ToastTitle"]')
.filter({ hasText: /Time format isn't valid|اكتب الوقت مثل/ }),
).toBeVisible({ timeout: 5_000 });
// No PATCH should have been sent for the bad value.
await page.waitForTimeout(300);
expect(patchSeen).toBe(false);
// DB stayed at the seeded values.
const { rows } = await pool.query(
`SELECT start_time, end_time FROM executive_meetings WHERE id = $1`,
[meetingId],
);
expect(String(rows[0].start_time)).toBe("08:00:00");
expect(String(rows[0].end_time)).toBe("09:00:00");
});
test("Schedule keyboard: Tab walks start hour → start minute → start AM → start PM → end hour → end minute → end AM → end PM, preserves the typed start values, Enter on end saves both", async ({
page,
}) => {
await setLangEn(page);
const date = uniqueFutureDate(15);
const meetingId = await insertMeeting({
meetingDate: date,
dailyNumber: 1,
titleEn: `KbdTab ${Date.now().toString(36)}`,
titleAr: `تابات ${Date.now().toString(36)}`,
startTime: null,
endTime: null,
});
await gotoMeetingRow(page, meetingId, date);
const timeCell = page.getByTestId(`em-time-${meetingId}`);
await timeCell.evaluate((el) => el.focus());
await page.keyboard.press("Enter");
const startInput = page.getByTestId(`em-time-start-${meetingId}`);
const endInput = page.getByTestId(`em-time-end-${meetingId}`);
const startAm = page.getByTestId(`em-time-start-period-am-${meetingId}`);
const startPm = page.getByTestId(`em-time-start-period-pm-${meetingId}`);
const endAm = page.getByTestId(`em-time-end-period-am-${meetingId}`);
const endPm = page.getByTestId(`em-time-end-period-pm-${meetingId}`);
await expect(startInput).toBeFocused();
// each side has THREE controls: hour input, minute
// input, AM/PM toggle. Tab walks through start hour → start minute
// → start AM → start PM → end hour → end minute → end AM → end PM.
// "7" + "15" is a 112 hour with no marker, so the picker requires
// an explicit AM/PM toggle pick before the value resolves. This
// test exercises the contract that the typed values survive every
// focus change along the way.
const startMinute = page.getByTestId(`em-time-start-minute-${meetingId}`);
const endMinute = page.getByTestId(`em-time-end-minute-${meetingId}`);
await page.keyboard.type("7", { delay: 10 });
await page.keyboard.press("Tab");
await expect(startMinute).toBeFocused();
await page.keyboard.type("15", { delay: 10 });
await page.keyboard.press("Tab");
await expect(startAm).toBeFocused();
// Activate AM via Space — the canonical button-keyboard contract.
await page.keyboard.press("Space");
await page.keyboard.press("Tab");
await expect(startPm).toBeFocused();
await page.keyboard.press("Tab");
await expect(endInput).toBeFocused();
// The typed start values must still be intact when focus reaches
// the end input — that's the regression this test guards.
await expect(startInput).toHaveValue("7");
await expect(startMinute).toHaveValue("15");
await page.keyboard.type("8", { delay: 10 });
await page.keyboard.press("Tab");
await expect(endMinute).toBeFocused();
await page.keyboard.type("45", { delay: 10 });
await page.keyboard.press("Tab");
await expect(endAm).toBeFocused();
await page.keyboard.press("Space");
// Move focus back to the end minute input so Enter triggers
// save() rather than re-activating the AM button under focus.
await page.keyboard.press("Tab");
await expect(endPm).toBeFocused();
await endMinute.focus();
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 },
);
const refetchPromise = waitForDayRefetch(page, date);
await page.keyboard.press("Enter");
await Promise.all([savePromise, refetchPromise]);
const { rows } = await pool.query(
`SELECT start_time, end_time FROM executive_meetings WHERE id = $1`,
[meetingId],
);
expect(String(rows[0].start_time)).toBe("07:15:00");
expect(String(rows[0].end_time)).toBe("08:45:00");
});
// -------------------------------------------------------------------
// Mobile viewport (iPhone 375x667): the inline 12h picker has 6
// sub-controls per range (start hour + minute + AM + PM, end hour +
// minute + AM + PM, plus save/cancel buttons). On a narrow phone
// viewport the row used to push the end picker out of view, hiding
// the second time field from the user. The TimeRangeCell wrapper
// now uses `flex-wrap` so the end picker drops onto a second line
// when the cell can't fit both side-by-side. This test guards
// against a regression that breaks that wrap or shrinks the touch
// targets below the usable minimum on a phone-sized viewport.
// -------------------------------------------------------------------
test.describe("Schedule mobile viewport (iPhone 375)", () => {
test.use({ viewport: { width: 375, height: 667 } });
test("inline 12h picker: all 6 sub-controls are visible on iPhone, finger-tap sized, and a full hour+minute+AM/PM entry persists", async ({
page,
}) => {
await setLangEn(page);
const date = uniqueFutureDate(20);
const meetingId = await insertMeeting({
meetingDate: date,
dailyNumber: 1,
titleEn: `MobilePicker ${Date.now().toString(36)}`,
titleAr: `محمول ${Date.now().toString(36)}`,
startTime: null,
endTime: null,
});
await gotoMeetingRow(page, meetingId, date);
const timeCell = page.getByTestId(`em-time-${meetingId}`);
// Tap the cell to enter edit mode (mimics a finger tap on
// mobile, exercising the click handler the read-only display
// exposes alongside the keyboard handler).
await timeCell.click();
const startHour = page.getByTestId(`em-time-start-${meetingId}`);
const startMinute = page.getByTestId(`em-time-start-minute-${meetingId}`);
const startAm = page.getByTestId(`em-time-start-period-am-${meetingId}`);
const startPm = page.getByTestId(`em-time-start-period-pm-${meetingId}`);
const endHour = page.getByTestId(`em-time-end-${meetingId}`);
const endMinute = page.getByTestId(`em-time-end-minute-${meetingId}`);
const endAm = page.getByTestId(`em-time-end-period-am-${meetingId}`);
const endPm = page.getByTestId(`em-time-end-period-pm-${meetingId}`);
const saveBtn = page.getByTestId(`em-time-save-${meetingId}`);
// All 8 picker sub-controls + save button must be reachable on
// a 375px viewport. `toBeVisible` checks both the DOM presence
// AND that the element isn't clipped behind 0-size / display:
// none / off-screen — the regression we're guarding against.
for (const control of [
startHour,
startMinute,
startAm,
startPm,
endHour,
endMinute,
endAm,
endPm,
saveBtn,
]) {
await expect(control).toBeVisible();
}
// Touch-target check: each interactive control must be at
// least 28px tall so a finger tap lands cleanly. We use 28px
// (not Apple HIG's 44px) because the inline editor lives in
// a dense schedule table and h-8 (32px) is already a real
// jump from the prior 22px implementation; h<28px would be
// a regression worth catching.
for (const control of [startHour, startMinute, startAm, endPm, saveBtn]) {
const box = await control.boundingBox();
expect(box).not.toBeNull();
expect(box.height).toBeGreaterThanOrEqual(28);
}
// Drive a complete hour + minute + AM/PM entry through tap
// interactions on both sides — this is the canonical mobile
// flow the previous single-row layout broke. "9" + "30" PM
// resolves to 21:30; "10" + "45" PM resolves to 22:45.
await startHour.click();
await page.keyboard.press("ControlOrMeta+a");
await page.keyboard.press("Delete");
await page.keyboard.type("9", { delay: 10 });
await startMinute.click();
await page.keyboard.type("30", { delay: 10 });
await startPm.click();
await expect(startPm).toHaveAttribute("aria-pressed", "true");
await endHour.click();
await page.keyboard.press("ControlOrMeta+a");
await page.keyboard.press("Delete");
await page.keyboard.type("10", { delay: 10 });
await endMinute.click();
await page.keyboard.type("45", { delay: 10 });
await endPm.click();
await expect(endPm).toHaveAttribute("aria-pressed", "true");
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 },
);
const refetchPromise = waitForDayRefetch(page, date);
// Tap the save button — proves the save button is reachable
// on a 375px viewport AND that the picker's commit() path
// fires from a touch interaction.
await saveBtn.click();
await Promise.all([savePromise, refetchPromise]);
const { rows } = await pool.query(
`SELECT start_time, end_time FROM executive_meetings WHERE id = $1`,
[meetingId],
);
expect(String(rows[0].start_time)).toBe("21:30:00");
expect(String(rows[0].end_time)).toBe("22:45:00");
});
});
// -------------------------------------------------------------------
// stop the snap-back race + collapse the editor instantly.
// Three guards:
// 1. Save → immediate re-open of the editor must show the SAVED
// values pre-filled, not blank fields. (The bug the user
// reported as "the times disappeared, I have to re-fill them".)
// 2. Tapping save must close the editor BEFORE the PATCH round-trip
// resolves — otherwise the perceived save latency tracks
// network latency instead of React render time.
// 3. A failed PATCH must roll back the optimistic cache and
// re-open the editor with the user's draft intact, so a transient
// failure doesn't silently leave the row showing the optimistic
// times that never made it to the server.
// -------------------------------------------------------------------
test("Schedule time editor: save then immediately re-open shows the saved values pre-filled (no snap-back to blank)", async ({
page,
}) => {
await setLangEn(page);
const date = uniqueFutureDate(316);
const meetingId = await insertMeeting({
meetingDate: date,
dailyNumber: 1,
titleEn: `SnapBack ${Date.now().toString(36)}`,
titleAr: `لا للارتداد ${Date.now().toString(36)}`,
startTime: null,
endTime: null,
});
await gotoMeetingRow(page, meetingId, date);
const timeCell = page.getByTestId(`em-time-${meetingId}`);
await timeCell.click();
const startHour = page.getByTestId(`em-time-start-${meetingId}`);
const startMinute = page.getByTestId(`em-time-start-minute-${meetingId}`);
const startPm = page.getByTestId(`em-time-start-period-pm-${meetingId}`);
const endHour = page.getByTestId(`em-time-end-${meetingId}`);
const endMinute = page.getByTestId(`em-time-end-minute-${meetingId}`);
const endPm = page.getByTestId(`em-time-end-period-pm-${meetingId}`);
// Use the keyboard.type + AM/PM-toggle pattern from the iPhone
// entry test above — proven to reliably reach commit() across
// viewports, and produces SPLIT picker state (hour/minute/period
// tracked separately) so the rollback / re-open assertions can
// check each sub-control's value independently.
await startHour.click();
await page.keyboard.press("ControlOrMeta+a");
await page.keyboard.press("Delete");
await page.keyboard.type("9", { delay: 10 });
await startMinute.click();
await page.keyboard.type("30", { delay: 10 });
await startPm.click();
await expect(startPm).toHaveAttribute("aria-pressed", "true");
await endHour.click();
await page.keyboard.press("ControlOrMeta+a");
await page.keyboard.press("Delete");
await page.keyboard.type("10", { delay: 10 });
await endMinute.click();
await page.keyboard.type("45", { delay: 10 });
await endPm.click();
await expect(endPm).toHaveAttribute("aria-pressed", "true");
// Wait for the PATCH so we know the server-side change landed;
// the test then immediately re-opens the cell to exercise the
// snap-back path. Click the save button (the user's path that
// triggered the original bug report).
const patchPromise = 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-time-save-${meetingId}`).click();
await patchPromise;
// The editor must have collapsed.
await expect(page.locator(`[data-testid="em-time-${meetingId}"][data-editing="true"]`)).toHaveCount(0);
// Re-open the editor BEFORE the GET refetch necessarily lands.
await timeCell.click();
// The picker must be seeded with the values we just saved — not
// blank, not the prior empty state, not a flash of either. The
// user typed 9:30 PM / 10:45 PM via the toggle.
await expect(page.getByTestId(`em-time-start-${meetingId}`)).toHaveValue("9");
await expect(page.getByTestId(`em-time-start-minute-${meetingId}`)).toHaveValue("30");
await expect(page.getByTestId(`em-time-start-period-pm-${meetingId}`)).toHaveAttribute("aria-pressed", "true");
await expect(page.getByTestId(`em-time-end-${meetingId}`)).toHaveValue("10");
await expect(page.getByTestId(`em-time-end-minute-${meetingId}`)).toHaveValue("45");
await expect(page.getByTestId(`em-time-end-period-pm-${meetingId}`)).toHaveAttribute("aria-pressed", "true");
});
test("Schedule time editor: tapping save closes the editor before the PATCH round-trip resolves (perceived-latency)", async ({
page,
}) => {
await setLangEn(page);
const date = uniqueFutureDate(317);
const meetingId = await insertMeeting({
meetingDate: date,
dailyNumber: 1,
titleEn: `FastClose ${Date.now().toString(36)}`,
titleAr: `إغلاق سريع ${Date.now().toString(36)}`,
startTime: null,
endTime: null,
});
await gotoMeetingRow(page, meetingId, date);
// Delay the PATCH response by 1.5 s so the test can observe the
// editor collapsing well before the network round-trip resolves.
let releasePatch;
const patchHeld = new Promise((resolve) => {
releasePatch = resolve;
});
await page.route(
`**/api/executive-meetings/${meetingId}`,
async (route) => {
if (route.request().method() !== "PATCH") {
await route.continue();
return;
}
await patchHeld; // pause until the test releases us
await route.continue();
},
);
const timeCell = page.getByTestId(`em-time-${meetingId}`);
await timeCell.click();
const startHour = page.getByTestId(`em-time-start-${meetingId}`);
const startMinute = page.getByTestId(`em-time-start-minute-${meetingId}`);
const startPm = page.getByTestId(`em-time-start-period-pm-${meetingId}`);
const endHour = page.getByTestId(`em-time-end-${meetingId}`);
const endMinute = page.getByTestId(`em-time-end-minute-${meetingId}`);
const endPm = page.getByTestId(`em-time-end-period-pm-${meetingId}`);
// 6:00 PM 7:15 PM via keyboard.type + PM toggle (matches the
// iPhone-entry pattern). 18:00 / 19:15 in 24h.
await startHour.click();
await page.keyboard.press("ControlOrMeta+a");
await page.keyboard.press("Delete");
await page.keyboard.type("6", { delay: 10 });
await startMinute.click();
await page.keyboard.type("00", { delay: 10 });
await startPm.click();
await expect(startPm).toHaveAttribute("aria-pressed", "true");
await endHour.click();
await page.keyboard.press("ControlOrMeta+a");
await page.keyboard.press("Delete");
await page.keyboard.type("7", { delay: 10 });
await endMinute.click();
await page.keyboard.type("15", { delay: 10 });
await endPm.click();
await expect(endPm).toHaveAttribute("aria-pressed", "true");
const tStart = Date.now();
await page.getByTestId(`em-time-save-${meetingId}`).click();
// The editor must collapse within React render time — well under
// the 1.5 s held PATCH. We use 800 ms as a generous ceiling that
// still proves the close is NOT gated on the network.
await expect(
page.locator(`[data-testid="em-time-${meetingId}"][data-editing="true"]`),
).toHaveCount(0, { timeout: 800 });
const elapsed = Date.now() - tStart;
expect(elapsed).toBeLessThan(1000);
// The read-only display should show the optimistic times even
// though the PATCH hasn't returned yet. The schedule's compact
// 12-h display drops the leading zero on the hour and emits no
// AM/PM marker, so 18:00 → "6:00", 19:15 → "7:15".
const displayText = await timeCell.innerText();
expect(displayText).toMatch(/6:00/);
expect(displayText).toMatch(/7:15/);
// Now release the PATCH and let the refetch settle so cleanup runs
// against a stable state.
releasePatch();
await 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.unroute(`**/api/executive-meetings/${meetingId}`);
});
test("Schedule time editor: a failed PATCH rolls back the optimistic times and re-opens the editor with the draft intact", async ({
page,
}) => {
await setLangEn(page);
const date = uniqueFutureDate(318);
const meetingId = await insertMeeting({
meetingDate: date,
dailyNumber: 1,
titleEn: `Rollback ${Date.now().toString(36)}`,
titleAr: `استعادة ${Date.now().toString(36)}`,
startTime: "07:00:00",
endTime: "08:00:00",
});
await gotoMeetingRow(page, meetingId, date);
// Stub PATCH to 500 so the optimistic write must roll back.
await page.route(
`**/api/executive-meetings/${meetingId}`,
async (route) => {
if (route.request().method() !== "PATCH") {
await route.continue();
return;
}
await route.fulfill({
status: 500,
contentType: "application/json",
body: JSON.stringify({ message: "Internal server error" }),
});
},
);
const timeCell = page.getByTestId(`em-time-${meetingId}`);
await timeCell.click();
// Overwrite the saved 07:0008:00 with 11:00 12:30 (24h shortcut).
// After the 500, the row's read-only display must snap BACK to the
// original 7:008:00 (rollback succeeded), and the editor must
// re-open with the user's draft (11:00 12:30) so they can retry
// without retyping.
const startHour = page.getByTestId(`em-time-start-${meetingId}`);
const startMinute = page.getByTestId(`em-time-start-minute-${meetingId}`);
const startAm = page.getByTestId(`em-time-start-period-am-${meetingId}`);
const endHour = page.getByTestId(`em-time-end-${meetingId}`);
const endMinute = page.getByTestId(`em-time-end-minute-${meetingId}`);
const endPm = page.getByTestId(`em-time-end-period-pm-${meetingId}`);
await startHour.click();
await page.keyboard.press("ControlOrMeta+a");
await page.keyboard.press("Delete");
await page.keyboard.type("11", { delay: 10 });
await startMinute.click();
await page.keyboard.press("ControlOrMeta+a");
await page.keyboard.press("Delete");
await page.keyboard.type("00", { delay: 10 });
await startAm.click();
await expect(startAm).toHaveAttribute("aria-pressed", "true");
await endHour.click();
await page.keyboard.press("ControlOrMeta+a");
await page.keyboard.press("Delete");
await page.keyboard.type("12", { delay: 10 });
await endMinute.click();
await page.keyboard.press("ControlOrMeta+a");
await page.keyboard.press("Delete");
await page.keyboard.type("30", { delay: 10 });
await endPm.click();
await expect(endPm).toHaveAttribute("aria-pressed", "true");
await page.getByTestId(`em-time-save-${meetingId}`).click();
// Editor must re-open after the rollback fires.
await expect(
page.locator(`[data-testid="em-time-${meetingId}"][data-editing="true"]`),
).toHaveCount(1, { timeout: 5_000 });
// Draft is preserved so the user can retry without retyping.
await expect(page.getByTestId(`em-time-start-${meetingId}`)).toHaveValue("11");
await expect(page.getByTestId(`em-time-start-minute-${meetingId}`)).toHaveValue("00");
await expect(page.getByTestId(`em-time-start-period-am-${meetingId}`)).toHaveAttribute("aria-pressed", "true");
await expect(page.getByTestId(`em-time-end-${meetingId}`)).toHaveValue("12");
await expect(page.getByTestId(`em-time-end-minute-${meetingId}`)).toHaveValue("30");
await expect(page.getByTestId(`em-time-end-period-pm-${meetingId}`)).toHaveAttribute("aria-pressed", "true");
// Cancel out and confirm the read-only display shows the ORIGINAL
// 7:008:00 — proves the optimistic cache write was rolled back.
await page.getByTestId(`em-time-cancel-${meetingId}`).click();
await expect(
page.locator(`[data-testid="em-time-${meetingId}"][data-editing="true"]`),
).toHaveCount(0);
const displayText = await timeCell.innerText();
expect(displayText).toMatch(/7:00/);
expect(displayText).toMatch(/8:00/);
// DB must be untouched.
const { rows } = await pool.query(
`SELECT start_time, end_time FROM executive_meetings WHERE id = $1`,
[meetingId],
);
expect(String(rows[0].start_time)).toBe("07:00:00");
expect(String(rows[0].end_time)).toBe("08:00:00");
await page.unroute(`**/api/executive-meetings/${meetingId}`);
});
// ─── #317: optimistic-cache repaint for non-time inline editors ─────
//
// made the time cell repaint instantly via an optimistic
// React Query cache write. generalises that pattern to the
// title, attendee-name, and merge-text editors. The three tests below
// mirror the time-cell tests for the title cell:
//
// 1. With the PATCH delayed 1500 ms, the cell shows the new title
// within ~300 ms — proves the optimistic cache write repaints
// the read-only display before the network round-trip resolves.
// 2. With the PATCH stubbed to 500, the cell rolls back to the
// original title and an error toast surfaces — proves the
// rollback path actually restores cache state.
// 3. Attendee-name save shows the renamed attendee instantly with
// the PATCH delayed — proves the optimistic pattern covers the
// attendees PUT endpoint too, not just the meeting PATCH.
test("Schedule title editor: optimistic repaint shows the new title before the PATCH round-trip resolves", async ({
page,
}) => {
await setLangEn(page);
const date = uniqueFutureDate(330);
const uniq = Date.now().toString(36);
const originalTitle = `Optimistic Original ${uniq}`;
const meetingId = await insertMeeting({
meetingDate: date,
dailyNumber: 1,
titleEn: originalTitle,
titleAr: originalTitle,
startTime: "09:00:00",
endTime: "10:00:00",
});
await gotoMeetingRow(page, meetingId, date);
// Hold the PATCH for 1500 ms. The optimistic cache write must
// repaint the cell well before the response lands.
let patchHeld = false;
await page.route(
`**/api/executive-meetings/${meetingId}`,
async (route) => {
if (route.request().method() !== "PATCH") {
await route.continue();
return;
}
patchHeld = true;
await new Promise((r) => setTimeout(r, 1500));
await route.continue();
},
);
const titleCell = page.getByTestId(`em-edit-title-${meetingId}`);
await expect(titleCell).toContainText(originalTitle);
await titleCell.evaluate((el) => el.focus());
await page.keyboard.press("Enter");
const editorContent = titleCell.locator('[contenteditable="true"]');
await expect(editorContent).toBeFocused();
const appended = ` FAST-${uniq}`;
await page.keyboard.type(appended);
const t0 = Date.now();
await page.keyboard.press("Enter");
// The cell must show the new title within 300 ms of pressing Enter,
// long before the held PATCH (1500 ms) resolves. This proves the
// optimistic cache write is what's repainting the cell — not the
// GET refetch that fires after the PATCH.
await expect(titleCell).toContainText(originalTitle + appended, {
timeout: 300,
});
const elapsed = Date.now() - t0;
expect(elapsed).toBeLessThan(800);
expect(patchHeld).toBe(true);
// Editor must have collapsed (toolbar gone) too.
await expect(page.getByTestId("em-edit-toolbar")).toBeHidden();
// Wait for the held PATCH to actually land + the GET refetch to
// complete so the test cleanup doesn't race the in-flight request.
await 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 waitForDayRefetch(page, date);
await page.unroute(`**/api/executive-meetings/${meetingId}`);
});
test("Schedule title editor: a failed PATCH rolls back the optimistic title and the cell snaps back to the original", async ({
page,
}) => {
await setLangEn(page);
const date = uniqueFutureDate(331);
const uniq = Date.now().toString(36);
const originalTitle = `Rollback Title ${uniq}`;
const meetingId = await insertMeeting({
meetingDate: date,
dailyNumber: 1,
titleEn: originalTitle,
titleAr: originalTitle,
startTime: "09:00:00",
endTime: "10:00:00",
});
await gotoMeetingRow(page, meetingId, date);
// Stub PATCH to 500 so the optimistic write must roll back.
await page.route(
`**/api/executive-meetings/${meetingId}`,
async (route) => {
if (route.request().method() !== "PATCH") {
await route.continue();
return;
}
await route.fulfill({
status: 500,
contentType: "application/json",
body: JSON.stringify({ message: "Internal server error" }),
});
},
);
const titleCell = page.getByTestId(`em-edit-title-${meetingId}`);
await titleCell.evaluate((el) => el.focus());
await page.keyboard.press("Enter");
const editorContent = titleCell.locator('[contenteditable="true"]');
await expect(editorContent).toBeFocused();
const appended = ` BROKEN-${uniq}`;
await page.keyboard.type(appended);
await page.keyboard.press("Enter");
// Wait for the 500 to actually arrive so the rollback runs.
await page.waitForResponse(
(resp) => {
const u = new URL(resp.url());
return (
u.pathname === `/api/executive-meetings/${meetingId}` &&
resp.request().method() === "PATCH" &&
resp.status() === 500
);
},
{ timeout: 15_000 },
);
// Failure UX (mirrors #316 time-cell): the editor must re-open with
// the user's typed draft intact so they can fix the error and
// retry without retyping. The contenteditable still shows
// "BROKEN-..." while the underlying read-only `value` (driving
// titleEn in the cache) is rolled back to the original.
await expect(
titleCell.locator('[contenteditable="true"]'),
).toContainText(originalTitle + appended, { timeout: 3_000 });
await expect(titleCell.locator('[contenteditable="true"]')).toBeFocused();
// Cancel the draft → cell must collapse back to the rolled-back
// ORIGINAL title with no trace of the appended fragment.
await page.keyboard.press("Escape");
await expect(
titleCell.locator('[contenteditable="true"]'),
).toHaveCount(0, { timeout: 3_000 });
await expect(titleCell).toContainText(originalTitle);
await expect(titleCell).not.toContainText(appended);
// DB must be untouched.
const { rows } = await pool.query(
`SELECT title_en, title_ar FROM executive_meetings WHERE id = $1`,
[meetingId],
);
expect(String(rows[0].title_en)).not.toContain(appended);
expect(String(rows[0].title_ar)).not.toContain(appended);
await page.unroute(`**/api/executive-meetings/${meetingId}`);
});
test("Schedule title editor: rapid click-switch from cell A to cell B commits BOTH edits to the server (no dropped saves)", async ({
page,
}) => {
await setLangEn(page);
const date = uniqueFutureDate(333);
const uniq = Date.now().toString(36);
const aOriginal = `RapidA ${uniq}`;
const bOriginal = `RapidB ${uniq}`;
const meetingA = await insertMeeting({
meetingDate: date,
dailyNumber: 1,
titleEn: aOriginal,
titleAr: aOriginal,
startTime: "09:00:00",
endTime: "10:00:00",
});
const meetingB = await insertMeeting({
meetingDate: date,
dailyNumber: 2,
titleEn: bOriginal,
titleAr: bOriginal,
startTime: "10:00:00",
endTime: "11:00:00",
});
await gotoMeetingRow(page, meetingA, date);
// Verify both rows are visible before we start poking at them.
await expect(page.getByTestId(`em-row-${meetingB}`)).toBeVisible();
// Record every PATCH that lands so we can assert both commits hit
// the server even though we never blurred cell A explicitly — the
// outside-pointerdown flush in EditableCell must commit it.
const patches = [];
page.on("request", (req) => {
if (req.method() !== "PATCH") return;
const u = new URL(req.url());
const m = u.pathname.match(/^\/api\/executive-meetings\/(\d+)$/);
if (!m) return;
patches.push({ id: Number(m[1]), body: req.postDataJSON() ?? null });
});
const cellA = page.getByTestId(`em-edit-title-${meetingA}`);
const cellB = page.getByTestId(`em-edit-title-${meetingB}`);
// Enter edit mode on A and type without committing.
await cellA.evaluate((el) => el.focus());
await page.keyboard.press("Enter");
const editorA = cellA.locator('[contenteditable="true"]');
await expect(editorA).toBeFocused();
const aAppended = ` -A-${uniq}`;
await page.keyboard.type(aAppended);
// Now move focus to cell B WITHOUT pressing Enter on A. We
// dispatch a pointerdown directly on cell B's element to trigger
// EditableCell's outside-pointerdown flush in cell A. Doing this
// via .evaluate() avoids Playwright's hit-test rejecting the
// click when cell A's portalled floating toolbar happens to
// overlap cell B.
await cellB.evaluate((el) => {
el.dispatchEvent(
new PointerEvent("pointerdown", { bubbles: true, cancelable: true }),
);
});
// Then enter edit mode on cell B the same way every other test
// in this file does — focus + Enter.
await cellB.evaluate((el) => el.focus());
await page.keyboard.press("Enter");
const editorB = cellB.locator('[contenteditable="true"]');
await expect(editorB).toBeFocused();
const bAppended = ` -B-${uniq}`;
await page.keyboard.type(bAppended);
await page.keyboard.press("Enter");
// Both PATCHes must have reached the server with the right bodies.
await expect
.poll(() => patches.filter((p) => p.id === meetingA).length, {
timeout: 5_000,
})
.toBeGreaterThanOrEqual(1);
await expect
.poll(() => patches.filter((p) => p.id === meetingB).length, {
timeout: 5_000,
})
.toBeGreaterThanOrEqual(1);
// Body may use titleEn or titleAr depending on the i18n language
// active at save-time (the page may default to Arabic) — assert
// against whichever field the patch carries.
const titleOf = (p) =>
String(p?.body?.titleEn ?? p?.body?.titleAr ?? "");
const aPatch = patches.find((p) => p.id === meetingA);
const bPatch = patches.find((p) => p.id === meetingB);
expect(titleOf(aPatch)).toContain(aAppended.trim());
expect(titleOf(bPatch)).toContain(bAppended.trim());
// And the DB must reflect both edits — proves nothing was silently
// dropped by the rapid cell switch.
await waitForDayRefetch(page, date);
const { rows } = await pool.query(
`SELECT id, title_en, title_ar FROM executive_meetings WHERE id = ANY($1::int[])`,
[[meetingA, meetingB]],
);
const byId = Object.fromEntries(
rows.map((r) => [r.id, `${r.title_en ?? ""} ${r.title_ar ?? ""}`]),
);
expect(byId[meetingA]).toContain(aAppended.trim());
expect(byId[meetingB]).toContain(bAppended.trim());
});
test("Schedule title editor: rapid Tab traversal from cell A to cell B commits BOTH edits exactly once", async ({
page,
}) => {
await setLangEn(page);
const date = uniqueFutureDate(334);
const uniq = Date.now().toString(36);
const aOriginal = `TabA ${uniq}`;
const bOriginal = `TabB ${uniq}`;
const meetingA = await insertMeeting({
meetingDate: date,
dailyNumber: 1,
titleEn: aOriginal,
titleAr: aOriginal,
startTime: "09:00:00",
endTime: "10:00:00",
});
const meetingB = await insertMeeting({
meetingDate: date,
dailyNumber: 2,
titleEn: bOriginal,
titleAr: bOriginal,
startTime: "10:00:00",
endTime: "11:00:00",
});
await gotoMeetingRow(page, meetingA, date);
await expect(page.getByTestId(`em-row-${meetingB}`)).toBeVisible();
// Count PATCHes per meeting so we can assert no duplicate commits
// (savingRef guard) AND that the keyboard-only path still saves.
const patches = [];
page.on("request", (req) => {
if (req.method() !== "PATCH") return;
const u = new URL(req.url());
const m = u.pathname.match(/^\/api\/executive-meetings\/(\d+)$/);
if (!m) return;
patches.push({ id: Number(m[1]), body: req.postDataJSON() ?? null });
});
const cellA = page.getByTestId(`em-edit-title-${meetingA}`);
const cellB = page.getByTestId(`em-edit-title-${meetingB}`);
await cellA.evaluate((el) => el.focus());
await page.keyboard.press("Enter");
await expect(cellA.locator('[contenteditable="true"]')).toBeFocused();
const aAppended = ` -TabA-${uniq}`;
await page.keyboard.type(aAppended);
// Pure keyboard exit: Tab pulls focus out of cell A. Title cells
// have no onTabNext, so the editor lets the browser handle Tab —
// focusout fires and the 100 ms blur timer commits A's edit.
await page.keyboard.press("Tab");
// Now activate B via keyboard and edit it. Pressing Enter twice
// (once on cellB which is focusable, then Enter again to commit
// the inner editor's content) is too brittle, so just focus +
// Enter the way every other test does.
await cellB.evaluate((el) => el.focus());
await page.keyboard.press("Enter");
await expect(cellB.locator('[contenteditable="true"]')).toBeFocused();
const bAppended = ` -TabB-${uniq}`;
await page.keyboard.type(bAppended);
await page.keyboard.press("Enter");
// Both PATCHes must have arrived.
await expect
.poll(() => patches.filter((p) => p.id === meetingA).length, {
timeout: 5_000,
})
.toBeGreaterThanOrEqual(1);
await expect
.poll(() => patches.filter((p) => p.id === meetingB).length, {
timeout: 5_000,
})
.toBeGreaterThanOrEqual(1);
// …and EXACTLY once each — proves the savingRef re-entrancy guard
// suppresses duplicate commits between the pointerdown flush, the
// blur timer, and any onTabNext-style commit chains.
// Wait briefly so any late blur-timer PATCH would have landed.
await page.waitForTimeout(500);
expect(patches.filter((p) => p.id === meetingA).length).toBe(1);
expect(patches.filter((p) => p.id === meetingB).length).toBe(1);
const titleOf = (p) =>
String(p?.body?.titleEn ?? p?.body?.titleAr ?? "");
expect(titleOf(patches.find((p) => p.id === meetingA))).toContain(
aAppended.trim(),
);
expect(titleOf(patches.find((p) => p.id === meetingB))).toContain(
bAppended.trim(),
);
});
test("Schedule attendees editor: optimistic repaint shows the renamed attendee before the PUT round-trip resolves", async ({
page,
}) => {
await setLangEn(page);
const date = uniqueFutureDate(332);
const uniq = Date.now().toString(36);
const originalName = `OrigAtt ${uniq}`;
const meetingId = await insertMeeting({
meetingDate: date,
dailyNumber: 1,
titleEn: `Att Optimistic ${uniq}`,
titleAr: `حضور تفاؤلي ${uniq}`,
startTime: "09:00:00",
endTime: "10:00:00",
});
await insertAttendee({
meetingId,
name: originalName,
attendanceType: "internal",
sortOrder: 0,
});
await gotoMeetingRow(page, meetingId, date);
// Hold the attendees PUT for 1500 ms.
let putHeld = false;
await page.route(
`**/api/executive-meetings/${meetingId}/attendees`,
async (route) => {
if (route.request().method() !== "PUT") {
await route.continue();
return;
}
putHeld = true;
await new Promise((r) => setTimeout(r, 1500));
await route.continue();
},
);
// The attendee's EditableCell wrapper (`em-edit-attendee-${i}`) is
// index-keyed and the index is list-wide. Locate the cell that
// shows the original name, then capture its concrete testid so the
// post-rename assertions don't lose the locator (a hasText filter
// would stop matching once the optimistic update repaints the
// cell with the new name).
const meetingRow = page.getByTestId(`em-row-${meetingId}`);
const initialCell = meetingRow
.locator('[data-testid^="em-edit-attendee-"]')
.filter({ hasText: originalName })
.first();
await expect(initialCell).toBeVisible();
const cellTestId = await initialCell.getAttribute("data-testid");
if (!cellTestId) throw new Error("attendee cell testid missing");
const attendeeCell = page.getByTestId(cellTestId);
await attendeeCell.click();
const editor = attendeeCell.locator('[contenteditable="true"]');
await expect(editor).toBeFocused();
const renamed = `RENAMED-${uniq}`;
await page.keyboard.press("ControlOrMeta+a");
await page.keyboard.type(renamed);
const t0 = Date.now();
await page.keyboard.press("Enter");
// Cell must show the renamed attendee within 300 ms — well before
// the held 1500 ms PUT resolves.
await expect(attendeeCell).toContainText(renamed, { timeout: 300 });
const elapsed = Date.now() - t0;
expect(elapsed).toBeLessThan(800);
expect(putHeld).toBe(true);
// The original name must be gone from the optimistic display.
await expect(attendeeCell).not.toContainText(originalName);
// Wait for the held PUT to land + the GET refetch to complete so
// the test cleanup doesn't race the in-flight request.
await 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 waitForDayRefetch(page, date);
await page.unroute(`**/api/executive-meetings/${meetingId}/attendees`);
});