Files
TX/artifacts/tx-os/tests/executive-meetings-row-quick-actions.spec.mjs
T
riyadhafraa b1b77395d0 #486 Executive Meetings: row-click quick actions popover (Move up / Move down / Postpone)
Clicking any meeting row on the schedule now opens a small popover with three
quick actions: Move up, Move down, and Postpone. Gated only on canMutate (NOT
editMode) per spec.

Backend
- New POST /executive-meetings/swap-times endpoint (artifacts/api-server/src/
  routes/executive-meetings.ts). FOR UPDATE locks both rows by ascending id to
  avoid deadlocks, optimistic-lock check via expectedUpdatedAt{A,B} (returns
  409 stale_meeting + conflict.lastActor — same shape PostponeDialog
  understands), guards different_dates and no_time_window, swaps only
  (startTime, endTime), audits each row as `meeting_swap_times`, calls
  renumberDayByStartTime so the # column matches the new chronological order,
  and broadcasts emitExecutiveMeetingsDayChanged.
- New zod body schema ExecutiveMeetingsSwapTimesBody in lib/api-zod/src/manual.ts.

Frontend
- New shared lib/api-json.ts (ApiError + apiJson) extracted from upcoming-
  meeting-alert.tsx so the page can reuse the same fetch/error contract.
- upcoming-meeting-alert.tsx exports PostponeDialog so the page can mount it
  for quick-action postpone without duplicating UI.
- artifacts/tx-os/src/pages/executive-meetings.tsx: Schedule wires
  swapTimes/quickMoveUp/quickMoveDown/postpone state, computes per-day
  neighbours via meetingNumbersById, and mounts a single page-level
  PostponeDialog. MeetingRow wraps `<tr>` in a Popover/PopoverAnchor; row
  onClick opens the popover with skip rules for buttons/inputs/contenteditable
  and ARIA roles (button/checkbox/switch/combobox/dialog) plus testid
  prefixes (em-row-grip/-actions/-select, em-edit-*, em-merge-edit-*, em-time-*)
  so the time cell's inline editor and other affordances don't collide.
- en/ar locales gain executiveMeetings.quickActions.{label,moveUp,moveDown,postpone}.

Tests
- artifacts/api-server/tests/executive-meetings-swap-times.test.mjs (new):
  happy path, 409 stale_meeting (with conflict actor), 400 different_dates,
  400 no_time_window. Each scenario uses a distinct far-future date to avoid
  daily_number races with seeded data.
- artifacts/tx-os/tests/executive-meetings-row-quick-actions.spec.mjs (new):
  drives the date input, verifies row click → popover, Move up swap reflected
  in DB, and Postpone item opens the dialog.

Code review (architect) flagged an edit-mode conflict where the time cell
(role=button div) would also bubble to the row handler; fixed by adding ARIA
role and em-time-* skip rules. Architect also flagged keyboard-trigger gap
and edit-mode test gaps — proposed as follow-ups.

Other test failures in the repo (executive-meetings reorder, font-settings,
notes-share, service-orders) pre-date this change and are unrelated.
2026-05-11 10:55:34 +00:00

181 lines
6.2 KiB
JavaScript

// #486: e2e for the Executive Meetings schedule row quick-actions
// popover. Clicking any row (gated only on canMutate, NOT editMode)
// opens a small menu with Move up / Move down / Postpone. Move up/
// down swap the (startTime, endTime) tuple between the clicked
// meeting and its chronological neighbour on the same date — the
// Time column stays visually anchored to its row position.
import { test, expect } from "@playwright/test";
import pg from "pg";
const DATABASE_URL = process.env.DATABASE_URL;
if (!DATABASE_URL) {
throw new Error(
"DATABASE_URL must be set to run the row-quick-actions test",
);
}
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(),
]);
}
function pickFutureDate() {
// Pick a date well in the future so it can't collide with the
// upcoming-meeting alert, recurring seeded data, or other suite fixtures.
const d = new Date(Date.now() + 30 * 86_400_000);
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, "0");
const day = String(d.getDate()).padStart(2, "0");
return `${y}-${m}-${day}`;
}
const TEST_DATE = pickFutureDate();
async function gotoTestDate(page) {
await page.goto("/executive-meetings");
// The schedule's date is internal React state, not a URL param, so
// drive it through the actual `<input type="date">` in the toolbar
// (same control the user clicks). It's the only date input on the
// page until the user opens a side panel.
const dateInput = page.locator('input[type="date"]').first();
await dateInput.waitFor({ state: "visible", timeout: 10_000 });
await dateInput.fill(TEST_DATE);
await dateInput.dispatchEvent("change");
}
async function nextDailyNumberForDate(date) {
const { rows } = await pool.query(
`SELECT COALESCE(MAX(daily_number), 0) + 1 AS n
FROM executive_meetings
WHERE meeting_date = $1`,
[date],
);
return rows[0].n;
}
async function insertMeeting({ titleEn, startTime, endTime }) {
const dn = await nextDailyNumberForDate(TEST_DATE);
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, $2, $3, $4, $5, 'scheduled')
RETURNING id`,
[dn, titleEn, TEST_DATE, startTime, endTime],
);
const id = rows[0].id;
createdMeetingIds.push(id);
return id;
}
async function readMeeting(id) {
const { rows } = await pool.query(
`SELECT start_time, end_time, daily_number FROM executive_meetings WHERE id = $1`,
[id],
);
return rows[0];
}
test.afterAll(async () => {
if (createdMeetingIds.length > 0) {
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_meeting_attendees WHERE meeting_id = ANY($1::int[])`,
[createdMeetingIds],
);
await pool.query(
`DELETE FROM executive_meetings WHERE id = ANY($1::int[])`,
[createdMeetingIds],
);
}
await pool.end();
});
test("row click opens quick-actions popover, Move up swaps times with neighbour", async ({
page,
}) => {
const aId = await insertMeeting({
titleEn: "QA Row Alpha",
startTime: "09:00:00",
endTime: "09:30:00",
});
const bId = await insertMeeting({
titleEn: "QA Row Bravo",
startTime: "10:00:00",
endTime: "10:30:00",
});
// admin/admin123 is the canonical seeded credential the rest of the
// executive-meetings e2e suite uses, and it has the mutate role
// (canMutate=true) without needing to flip on edit mode.
await loginViaUi(page, "admin", "admin123");
await gotoTestDate(page);
// Wait for both rows to render.
await expect(page.locator(`[data-testid="em-row-${aId}"]`)).toBeVisible({
timeout: 10_000,
});
await expect(page.locator(`[data-testid="em-row-${bId}"]`)).toBeVisible();
// Click the SECOND row (Bravo, 10:00) — it should expose Move up.
await page.locator(`[data-testid="em-row-${bId}"]`).click();
const popover = page.locator(`[data-testid="em-row-quick-${bId}"]`);
await expect(popover).toBeVisible();
await expect(
popover.locator(`[data-testid="em-row-quick-up-${bId}"]`),
).toBeEnabled();
await expect(
popover.locator(`[data-testid="em-row-quick-postpone-${bId}"]`),
).toBeVisible();
await popover.locator(`[data-testid="em-row-quick-up-${bId}"]`).click();
// Wait for the swap to land in the DB (refetch picks it up via the
// day-changed socket but we assert the DB directly for determinism).
await expect.poll(async () => (await readMeeting(bId)).start_time).toBe(
"09:00:00",
);
const aAfter = await readMeeting(aId);
const bAfter = await readMeeting(bId);
// Times swapped, daily numbers re-sorted by start time.
expect(aAfter.start_time).toBe("10:00:00");
expect(aAfter.end_time).toBe("10:30:00");
expect(bAfter.start_time).toBe("09:00:00");
expect(bAfter.end_time).toBe("09:30:00");
});
test("Postpone item from quick-actions popover opens the postpone dialog", async ({
page,
}) => {
const id = await insertMeeting({
titleEn: "QA Postpone Target",
startTime: "13:00:00",
endTime: "13:30:00",
});
await loginViaUi(page, "admin", "admin123");
await gotoTestDate(page);
await expect(page.locator(`[data-testid="em-row-${id}"]`)).toBeVisible({
timeout: 10_000,
});
await page.locator(`[data-testid="em-row-${id}"]`).click();
const popover = page.locator(`[data-testid="em-row-quick-${id}"]`);
await expect(popover).toBeVisible();
await popover.locator(`[data-testid="em-row-quick-postpone-${id}"]`).click();
// PostponeDialog is the same one upcoming-meeting-alert uses, so we
// assert by role=dialog presence rather than coupling to the
// dialog's internal markup.
await expect(page.getByRole("dialog")).toBeVisible({ timeout: 5_000 });
});