b1b77395d0
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.
181 lines
6.2 KiB
JavaScript
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 });
|
|
});
|