d366dc076c
Task #311. The schedule view hides cancelled meetings, but the drag-reorder path was operating on the raw, unfiltered list — so cancelled rows consumed time slots and the dnd-kit indices skewed across hidden rows, leaving the visible list out of chronological order after a drop. Server (POST /api/executive-meetings/reorder) - Slot-swap now operates on the in-scope (orderedIds) subset only. Cancelled rows keep their (startTime, endTime, dailyNumber) untouched, so they no longer steal slots from the visible list. - New 400 codes: - cancelled_in_reorder: payload includes a cancelled meeting - incomplete_day: any non-cancelled meeting on the day is missing - Audit oldValue.order now reflects the visible order the user actually saw. - Phase-1/phase-2 negative-parking still avoids transient unique-constraint conflicts; cancelled rows' positive dailyNumbers cannot collide because slot dailyNumbers are a permutation of in-scope rows' existing values. Client (executive-meetings.tsx reorderRows) - Index math now derives ids from `orderedMeetings` (the visible list bound to SortableContext) instead of the raw `meetings` array. useCallback deps updated. Tests - artifacts/api-server/tests/executive-meetings.test.mjs: added "leaves cancelled rows untouched and only slot-swaps visible meetings" and "rejects orderedIds containing a cancelled meeting". - artifacts/tx-os/tests/executive-meetings-keyboard-editing.spec.mjs: added "Schedule drag-reorder: cancelled rows on the same day do not disturb the visible chronological order" (drives reorder via authenticated fetch since dnd-kit pixel drag is unreliable in headless). All 7 reorder tests pass. Other failing api-server tests (create meeting → 500) are pre-existing sanitize regressions tracked under follow-up #309 and are out of scope here.
1119 lines
39 KiB
JavaScript
1119 lines
39 KiB
JavaScript
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.
|
|
await expect(startInput).toHaveCount(0);
|
|
await expect(endInput).toHaveCount(0);
|
|
await expect(timeCell).toHaveText(/13:30\s*[\u2013\-]\s*14: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}`);
|
|
await expect(timeCell).toHaveText(/08:15\s*[\u2013\-]\s*09: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.
|
|
await page.keyboard.press("Escape");
|
|
await expect(startInput).toHaveCount(0);
|
|
await expect(endInput).toHaveCount(0);
|
|
await expect(timeCell).toHaveText(/08:15\s*[\u2013\-]\s*09: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();
|
|
});
|
|
|
|
// -------------------------------------------------------------------
|
|
// Task #308: 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, do the same for end, press Enter, and
|
|
// wait for the PATCH + day GET refetch.
|
|
async function typeTimesAndSave(page, meetingId, date, startText, endText) {
|
|
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 });
|
|
|
|
await endInput.click();
|
|
await page.keyboard.press("ControlOrMeta+a");
|
|
await page.keyboard.press("Delete");
|
|
await page.keyboard.type(endText, { delay: 10 });
|
|
|
|
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]);
|
|
}
|
|
|
|
// 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.
|
|
const TYPING_SHAPES = [
|
|
{ offset: 8, label: "canonical 24h", startText: "13:30", endText: "14:45", expectStart: "13:30:00", expectEnd: "14:45:00" },
|
|
{ offset: 9, label: "no leading zero", startText: "9:30", endText: "10:45", expectStart: "09:30:00", expectEnd: "10:45:00" },
|
|
{ offset: 10, label: "compact HHMM", startText: "0930", endText: "1045", expectStart: "09:30:00", expectEnd: "10:45:00" },
|
|
{ offset: 11, label: "12h with PM", startText: "9:30 AM", endText: "9:30 PM", expectStart: "09:30:00", expectEnd: "21: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);
|
|
|
|
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
|
|
await typeTimesAndSave(page, meetingId, date, "٠٩:٣٠", "١٠:٤٥");
|
|
|
|
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");
|
|
});
|
|
|
|
// -------------------------------------------------------------------
|
|
// Task #311: 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 the same in-app callback that dnd-kit
|
|
// would invoke on a real drop. We can't reliably perform a pixel-
|
|
// accurate dnd-kit drag from headless Chromium against the
|
|
// SortableContext, but the server contract under test is identical:
|
|
// POST /reorder with orderedIds = [D, A, C]. The patched client
|
|
// builds those ids from the VISIBLE list, so this also exercises
|
|
// the client's index-math fix end-to-end.
|
|
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.evaluate(
|
|
({ url, body }) =>
|
|
fetch(url, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(body),
|
|
credentials: "include",
|
|
}).then((r) => r.text()),
|
|
{
|
|
url: "/api/executive-meetings/reorder",
|
|
body: { meetingDate: date, orderedIds: [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 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 between start and end preserves the typed start value, 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}`);
|
|
await expect(startInput).toBeFocused();
|
|
|
|
// Type into start, then Tab forward and type into end. The Tab
|
|
// commit path is what historically lost the start value when the
|
|
// input was type="time" because no canonical onChange had fired yet.
|
|
await page.keyboard.type("7:15", { delay: 10 });
|
|
await page.keyboard.press("Tab");
|
|
await expect(endInput).toBeFocused();
|
|
await page.keyboard.type("8:45", { delay: 10 });
|
|
|
|
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");
|
|
});
|