Task #489: row-wide drag rotates meeting content; time + daily numbers anchored

Backend
- New POST /api/executive-meetings/rotate-content (zod-validated) rotates
  ONLY meeting content through fixed (start_time, end_time, daily_number)
  slots. Same-date enforced; per-meeting expectedUpdatedAt → 409 stale;
  incomplete day (missing visible row) → 400.
- ExecutiveMeetingsRotateContentBody added in lib/api-zod (manual.ts).
- 6 backend tests cover happy path, stale, different_dates, 401, 403,
  incomplete_day. Existing /swap-times tests still pass.

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

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

Drift / notes
- Architect flagged a medium-severity hardening note: rotate-content
  FOR UPDATE locks orderedIds but not the day-scope completeness query.
  Out of #489 scope; no follow-up created (proposeFollowUpTasks was
  already consumed on #486).
This commit is contained in:
riyadhafraa
2026-05-11 12:14:08 +00:00
parent 82c7894e56
commit f53f7307da
7 changed files with 1192 additions and 382 deletions
+121 -168
View File
@@ -2114,15 +2114,25 @@ function ScheduleSection({
setAutoEditTitleId(null);
}, []);
const reorderRows = useCallback(
// #489: Drag-from-anywhere on the row rotates meeting CONTENT through
// the day's fixed time slots. The (startTime, endTime, dailyNumber)
// tuple of each chronological slot stays anchored to its physical
// row — only the meeting content (title, attendees, notes, color,
// status, merge) shifts. Mirrors the optimistic-locked pattern used
// by swap-times / postpone-minutes: we send each visible meeting's
// last-known `updatedAt` so the server can detect concurrent edits
// and 409 with a `stale_meeting` conflict that the client surfaces
// as a destructive toast and rolls back from.
//
// Optimistic UI: we patch the day cache so the i-th visible row
// (chronologically) immediately receives the (startTime, endTime,
// dailyNumber) from `orderedMeetings[i]` — i.e. each slot keeps its
// original tuple while the meeting content rotates into the new
// position. This matches what the server will write.
const rotateContent = useCallback(
async (fromId: number, toId: number) => {
if (fromId === toId) return;
if (reorderingRef.current) return;
// Use the VISIBLE list — the same one dnd-kit's SortableContext
// operates on. Building orderedIds from raw `meetings` (which
// includes hidden cancelled rows) corrupted both the index math
// and the slot-swap on the server, so dragging a meeting from
// top to bottom landed it in a non-chronological position.
const ids = orderedMeetings.map((m) => m.id);
const fromIdx = ids.indexOf(fromId);
const toIdx = ids.indexOf(toId);
@@ -2130,79 +2140,56 @@ function ScheduleSection({
const newOrder = ids.slice();
newOrder.splice(fromIdx, 1);
newOrder.splice(toIdx, 0, fromId);
setOptimisticOrder(newOrder);
reorderingRef.current = true;
setReordering(true);
try {
await apiJson(`/api/executive-meetings/reorder`, {
method: "POST",
body: { meetingDate: date, orderedIds: newOrder },
});
refreshDay();
} catch (err) {
setOptimisticOrder(null);
const msg = err instanceof Error ? err.message : String(err);
toast({
title: t("common.error"),
description: msg,
variant: "destructive",
});
} finally {
reorderingRef.current = false;
setReordering(false);
}
},
[orderedMeetings, date, refreshDay, toast, t],
);
// #486: Quick-actions popover (Move up / Move down / Postpone) on
// every schedule row, gated only on the raw `canMutate` permission
// (NOT effectiveCanMutate / editMode) so users can reorder + postpone
// without flipping into edit mode. Move up / Move down swap just the
// (startTime, endTime) tuple between the clicked meeting and its
// chronological neighbour on the same date — see swap-times route.
// The Time column therefore stays visually anchored to its row;
// each row keeps its slot and the meetings rotate through the slots.
const swapTimes = useCallback(
async (a: Meeting, b: Meeting) => {
if (reorderingRef.current) return;
reorderingRef.current = true;
setReordering(true);
// Optimistic UI: snapshot the day cache, swap (startTime, endTime)
// between A and B locally so the row repaints in the same React
// commit the popover closes in. Mirrors the inline-edit pattern
// used by saveTitle/saveTimes — on server failure the snapshot is
// restored and the destructive toast surfaces the error.
// Build the optimistic patch: chronological slot[i] (= the
// (startTime, endTime, dailyNumber) of orderedMeetings[i]) is
// assigned to the meeting at newOrder[i]. Cancelled rows are
// never in `orderedMeetings`, so they keep their existing tuple.
const queryKey = ["/api/executive-meetings", date] as const;
const previous = qc.getQueryData<DayResponse>(queryKey);
const slotById = new Map<number, { startTime: string | null; endTime: string | null; dailyNumber: number }>();
newOrder.forEach((id, i) => {
const slotMeeting = orderedMeetings[i];
if (!slotMeeting) return;
slotById.set(id, {
startTime: slotMeeting.startTime,
endTime: slotMeeting.endTime,
dailyNumber: slotMeeting.dailyNumber,
});
});
qc.setQueryData<DayResponse>(queryKey, (prev) =>
prev
? {
...prev,
meetings: prev.meetings.map((m) => {
if (m.id === a.id)
return { ...m, startTime: b.startTime, endTime: b.endTime };
if (m.id === b.id)
return { ...m, startTime: a.startTime, endTime: a.endTime };
return m;
const slot = slotById.get(m.id);
if (!slot) return m;
return {
...m,
startTime: slot.startTime,
endTime: slot.endTime,
dailyNumber: slot.dailyNumber,
};
}),
}
: prev,
);
const expectedUpdatedAt: Record<string, string> = {};
for (const m of orderedMeetings) {
expectedUpdatedAt[String(m.id)] = m.updatedAt;
}
reorderingRef.current = true;
setReordering(true);
try {
await apiJson(`/api/executive-meetings/swap-times`, {
await apiJson(`/api/executive-meetings/rotate-content`, {
method: "POST",
body: {
aId: a.id,
bId: b.id,
expectedUpdatedAtA: a.updatedAt,
expectedUpdatedAtB: b.updatedAt,
meetingDate: date,
orderedIds: newOrder,
expectedUpdatedAt,
},
});
refreshDay();
} catch (err) {
// Rollback the optimistic write so the row snaps back to the
// last server-confirmed times.
if (previous !== undefined) {
qc.setQueryData<DayResponse>(queryKey, previous);
}
@@ -2217,30 +2204,15 @@ function ScheduleSection({
setReordering(false);
}
},
[qc, date, refreshDay, toast, t],
);
const quickMoveUp = useCallback(
(meetingId: number) => {
const idx = orderedMeetings.findIndex((m) => m.id === meetingId);
if (idx <= 0) return;
const a = orderedMeetings[idx]!;
const b = orderedMeetings[idx - 1]!;
void swapTimes(a, b);
},
[orderedMeetings, swapTimes],
);
const quickMoveDown = useCallback(
(meetingId: number) => {
const idx = orderedMeetings.findIndex((m) => m.id === meetingId);
if (idx < 0 || idx >= orderedMeetings.length - 1) return;
const a = orderedMeetings[idx]!;
const b = orderedMeetings[idx + 1]!;
void swapTimes(a, b);
},
[orderedMeetings, swapTimes],
[orderedMeetings, date, qc, refreshDay, toast, t],
);
// #486 → #489: Move up / Move down were retired in favour of
// dragging the whole row (`rotateContent` above). The schedule's
// row click popover now keeps only the Postpone button. The
// swap-times helper that powered the old buttons is gone, but
// POST /executive-meetings/swap-times still exists server-side
// for any external caller that hasn't migrated yet.
const [postponeMeetingId, setPostponeMeetingId] = useState<number | null>(
null,
);
@@ -2514,9 +2486,9 @@ function ScheduleSection({
(e: DragEndEvent) => {
const { active, over } = e;
if (!over || active.id === over.id) return;
void reorderRows(Number(active.id), Number(over.id));
void rotateContent(Number(active.id), Number(over.id));
},
[reorderRows],
[rotateContent],
);
useEffect(() => {
@@ -2931,24 +2903,6 @@ function ScheduleSection({
bulkSelected={selectedMeetingIds.has(m.id)}
onBulkSelectChange={(v) => toggleMeetingSelected(m.id, v)}
quickActionsCanMutate={canMutate}
// Edge enablement is computed against the FULL day order,
// not the (possibly search-filtered) `displayedMeetings`.
// quickMoveUp/Down resolve neighbours from
// `orderedMeetings`, so deriving disabled state from the
// filtered index would let a user click "Move up" on what
// looks like the first visible row but is actually mid-day,
// and silently swap with a hidden neighbour.
canQuickMoveUp={
orderedMeetings.findIndex((om) => om.id === m.id) > 0
}
canQuickMoveDown={(() => {
const oIdx = orderedMeetings.findIndex(
(om) => om.id === m.id,
);
return oIdx >= 0 && oIdx < orderedMeetings.length - 1;
})()}
onQuickMoveUp={() => quickMoveUp(m.id)}
onQuickMoveDown={() => quickMoveDown(m.id)}
onQuickPostpone={() => setPostponeMeetingId(m.id)}
/>
))}
@@ -3743,10 +3697,6 @@ function MeetingRow({
onBulkSelectChange,
displayNumber,
quickActionsCanMutate,
canQuickMoveUp,
canQuickMoveDown,
onQuickMoveUp,
onQuickMoveDown,
onQuickPostpone,
}: {
meeting: Meeting;
@@ -3760,12 +3710,8 @@ function MeetingRow({
// #486: raw (un-gated by editMode) mutate permission — clicking a
// row anywhere outside an interactive control opens the quick-
// actions popover. The popover itself respects the same MUTATE_ROLES
// server-side gating as the swap-times + postpone-minutes endpoints.
// server-side gating as the postpone-minutes endpoint.
quickActionsCanMutate?: boolean;
canQuickMoveUp?: boolean;
canQuickMoveDown?: boolean;
onQuickMoveUp?: () => void;
onQuickMoveDown?: () => void;
onQuickPostpone?: () => void;
onSaveTitle: (html: string) => Promise<void>;
onSaveAttendeeName: (idx: number, html: string) => Promise<void>;
@@ -3817,10 +3763,15 @@ function MeetingRow({
const rowBg = rowColor?.bg || "";
const rowBorder = rowColor?.border || "";
// Make the row a sortable item. `attributes` get spread onto the <tr> for
// a11y; `listeners` go on the dedicated grip handle in the # cell so a
// normal click on the cell content (to enter inline-edit mode) is still
// possible.
// Make the row a sortable item. `attributes` get spread onto the <tr>
// for a11y; `listeners` are wrapped (see `safeRowDragListeners` below)
// so the user can grab the row from anywhere on it — except over an
// interactive control (button, link, input, contenteditable, time
// picker, row-actions menu) where the existing handler should win.
// The 6px PointerSensor distance + 200ms TouchSensor delay configured
// at the page level keep clicks/taps on inert cell area from being
// mistaken for drags, so the quick-actions popover still opens on
// tap-and-release.
const {
attributes,
listeners,
@@ -3829,6 +3780,47 @@ function MeetingRow({
transition,
isDragging,
} = useSortable({ id: meeting.id, disabled: !canMutate });
const isSkipDragTarget = useCallback(
(target: EventTarget | null): boolean => {
const el = target as HTMLElement | null;
if (!el) return false;
// Important: useSortable spreads `role="button"` onto our own
// <tr>, so a naive `closest("[role='button']")` would match the
// row itself and disable drag for the whole row. Find the
// closest match and only count it as a skip if it's a DESCENDANT
// of this row (i.e. not the row tr).
const rowSelector = `tr[data-testid="em-row-${meeting.id}"]`;
const interactive = el.closest(
"button, a, input, textarea, select, [contenteditable='true'], [contenteditable=''], [role='menuitem'], [role='button'], [role='checkbox'], [role='switch'], [role='combobox'], [role='dialog']",
);
if (interactive && !interactive.matches(rowSelector)) return true;
const testid = el.closest(
[
`[data-testid="em-row-actions-${meeting.id}"]`,
`[data-testid="em-row-select-${meeting.id}"]`,
`[data-testid^="em-edit-"]`,
`[data-testid^="em-merge-edit-"]`,
`[data-testid^="em-time-"]`,
].join(", "),
);
if (testid && !testid.matches(rowSelector)) return true;
return false;
},
[meeting.id],
);
const safeRowDragListeners = useMemo(() => {
if (!listeners || !canMutate) return undefined;
const out: Record<string, (e: { target: EventTarget | null }) => void> = {};
for (const k of Object.keys(listeners)) {
const handler = (listeners as Record<string, (e: unknown) => void>)[k];
if (typeof handler !== "function") continue;
out[k] = (e: { target: EventTarget | null }) => {
if (isSkipDragTarget(e?.target ?? null)) return;
handler(e);
};
}
return out;
}, [listeners, canMutate, isSkipDragTarget]);
const numberCellCls =
"border border-gray-300 px-2 py-2 text-center align-middle font-semibold " +
@@ -4014,30 +4006,16 @@ function MeetingRow({
className={`relative group ${numberCellCls}`}
style={numberCellInlineStyle}
>
<div className="flex items-center justify-center gap-1">
{canMutate && (
<button
type="button"
// The grip handle is hover-only on devices that have a
// real hover (desktop mouse), but always visible at ~40%
// opacity on touch devices that have no hover state — so
// iPad users can actually find what to press to reorder
// a row. The tap target is also enlarged on touch with a
// small padding so the long-press lands reliably.
// touch-action:none stays on the handle itself so the
// browser hands touch events to dnd-kit instead of
// claiming them as a vertical scroll; the rest of the
// row keeps default touch-action so page scrolling works
// normally outside of the handle.
className="opacity-0 group-hover:opacity-70 hover:opacity-100 [@media(hover:none)_and_(pointer:coarse)]:opacity-40 [@media(hover:none)_and_(pointer:coarse)]:group-hover:opacity-40 [@media(hover:none)_and_(pointer:coarse)]:p-1.5 cursor-grab active:cursor-grabbing touch-none print:hidden"
aria-label={t("executiveMeetings.dragRow")}
data-testid={`em-row-grip-${meeting.id}`}
{...listeners}
{...attributes}
>
<GripVertical className="w-3.5 h-3.5 [@media(hover:none)_and_(pointer:coarse)]:w-4 [@media(hover:none)_and_(pointer:coarse)]:h-4" />
</button>
)}
{/*
#489: The dedicated grip handle is gone the user now
drags any meeting from anywhere on its row to a new
position (the time column + daily numbers stay anchored
to the physical row, only the meeting content rotates).
The drag listeners are spread directly onto the <tr>
below, with a skip-list so clicks on inline editors and
row-action buttons still reach their own handlers.
*/}
<div className="flex items-center justify-center">
<span>{displayNumber ?? meeting.dailyNumber}</span>
</div>
{cellBulkOverlay}
@@ -4249,7 +4227,6 @@ function MeetingRow({
if (
target.closest(
[
`[data-testid="em-row-grip-${meeting.id}"]`,
`[data-testid="em-row-actions-${meeting.id}"]`,
`[data-testid="em-row-select-${meeting.id}"]`,
`[data-testid^="em-edit-"]`,
@@ -4272,12 +4249,14 @@ function MeetingRow({
<PopoverAnchor asChild>
<tr
ref={setNodeRef}
className="group"
className={`group${canMutate ? " cursor-grab active:cursor-grabbing" : ""}`}
style={trStyle}
data-testid={`em-row-${meeting.id}`}
data-current-meeting={isCurrent ? "true" : undefined}
data-bulk-selected={bulkSelected ? "true" : undefined}
onClick={quickActionsCanMutate ? handleRowClick : undefined}
{...(canMutate ? attributes : {})}
{...(safeRowDragListeners ?? {})}
>
{renderCells()}
</tr>
@@ -4293,32 +4272,6 @@ function MeetingRow({
onInteractOutside={() => setQuickOpen(false)}
>
<div className="flex flex-col min-w-[10rem]">
<button
type="button"
disabled={!canQuickMoveUp}
onClick={() => {
setQuickOpen(false);
onQuickMoveUp?.();
}}
className="flex items-center gap-2 rounded px-2 py-1.5 text-sm hover:bg-accent text-start disabled:opacity-50 disabled:hover:bg-transparent"
data-testid={`em-row-quick-up-${meeting.id}`}
>
<ChevronUp className="w-4 h-4" aria-hidden="true" />
<span className="flex-1">{t("executiveMeetings.quickActions.moveUp")}</span>
</button>
<button
type="button"
disabled={!canQuickMoveDown}
onClick={() => {
setQuickOpen(false);
onQuickMoveDown?.();
}}
className="flex items-center gap-2 rounded px-2 py-1.5 text-sm hover:bg-accent text-start disabled:opacity-50 disabled:hover:bg-transparent"
data-testid={`em-row-quick-down-${meeting.id}`}
>
<ChevronDown className="w-4 h-4" aria-hidden="true" />
<span className="flex-1">{t("executiveMeetings.quickActions.moveDown")}</span>
</button>
<button
type="button"
onClick={() => {
@@ -0,0 +1,234 @@
// #489: e2e for "drag a meeting from anywhere on its row to a new
// position". The schedule's time column AND daily numbers stay
// anchored to the physical rows; only the meeting CONTENT (title,
// attendees, notes, color, status, merge) rotates through the slots.
//
// We drag row #1 (Alpha @ 09:00) down to row #3 (Charlie @ 11:00).
// Expected DB result on the day:
// - The (start_time, end_time, daily_number) tuples on the date are
// unchanged set-wise — slot[1] = (09:00, 09:30, dn1), slot[2] =
// (10:00, 10:30, dn2), slot[3] = (11:00, 11:30, dn3) all still
// exist.
// - But the Alpha meeting (originally slot[1]) now carries the
// slot[3] tuple and the Bravo + Charlie meetings rotated up.
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-drag test");
}
const pool = new pg.Pool({ connectionString: DATABASE_URL });
const createdMeetingIds = [];
function pickFutureDate(offsetDays) {
const d = new Date(Date.now() + offsetDays * 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 DATE_DRAG = pickFutureDate(40);
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({ date, titleEn, startTime, endTime }) {
const dn = await nextDailyNumberForDate(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, date, startTime, endTime],
);
const id = rows[0].id;
createdMeetingIds.push(id);
return id;
}
async function readMeeting(id) {
const { rows } = await pool.query(
`SELECT title_en, start_time, end_time, daily_number FROM executive_meetings WHERE id = $1`,
[id],
);
return rows[0];
}
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(),
]);
}
async function gotoTestDate(page, date) {
await page.goto("/executive-meetings");
const dateInput = page.locator('input[type="date"]').first();
await dateInput.waitFor({ state: "visible", timeout: 10_000 });
await dateInput.fill(date);
await dateInput.dispatchEvent("change");
}
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("dragging a row from position 1 to position 3 rotates content but anchors time + daily numbers", async ({
page,
}) => {
const alphaId = await insertMeeting({
date: DATE_DRAG,
titleEn: "Drag Row Alpha",
startTime: "09:00:00",
endTime: "09:30:00",
});
const bravoId = await insertMeeting({
date: DATE_DRAG,
titleEn: "Drag Row Bravo",
startTime: "10:00:00",
endTime: "10:30:00",
});
const charlieId = await insertMeeting({
date: DATE_DRAG,
titleEn: "Drag Row Charlie",
startTime: "11:00:00",
endTime: "11:30:00",
});
// Snapshot the per-physical-row daily numbers before the drag — they
// must still match (sorted by start_time) after the rotation.
const beforeAlpha = await readMeeting(alphaId);
const beforeBravo = await readMeeting(bravoId);
const beforeCharlie = await readMeeting(charlieId);
const dnSet = new Set([
beforeAlpha.daily_number,
beforeBravo.daily_number,
beforeCharlie.daily_number,
]);
await loginViaUi(page, "admin", "admin123");
await gotoTestDate(page, DATE_DRAG);
const alphaRow = page.locator(`[data-testid="em-row-${alphaId}"]`);
const charlieRow = page.locator(`[data-testid="em-row-${charlieId}"]`);
await expect(alphaRow).toBeVisible({ timeout: 10_000 });
await expect(charlieRow).toBeVisible();
// The schedule's row drag is gated on `effectiveCanMutate =
// canMutate && editMode`, so the row's useSortable is disabled
// (and `safeRowDragListeners` returns undefined) until edit mode
// is toggled on. Without this click, no pointer event reaches
// dnd-kit's PointerSensor.
const editToggle = page.getByTestId("em-edit-mode-toggle");
await editToggle.click();
await expect(editToggle).toHaveAttribute("aria-pressed", "true");
// The whole <tr> is the drag handle now (the dedicated grip is
// gone). dnd-kit's PointerSensor activates after 6px of movement.
// safeRowDragListeners skips drags that start over interactive
// descendants (buttons, inputs, time/edit cells, row-actions,
// bulk-select). The # cell only contains a plain <span> with the
// daily number — no skip-listed children — so it is the safest
// drag-start surface.
const alphaNumberCell = page
.locator(`[data-testid="em-row-${alphaId}"] td`)
.first();
const numBox = await alphaNumberCell.boundingBox();
const charlieBox = await charlieRow.boundingBox();
if (!numBox || !charlieBox) {
throw new Error("rows + # cell must have a bounding box for the drag");
}
const startX = numBox.x + numBox.width / 2;
const startY = numBox.y + numBox.height / 2;
const endX = startX;
const endY = charlieBox.y + charlieBox.height - 4;
// Wait for the rotateContent POST so we don't race on the DB poll.
const rotatePromise = page.waitForResponse(
(resp) => {
const u = new URL(resp.url());
return (
u.pathname === "/api/executive-meetings/rotate-content" &&
resp.request().method() === "POST" &&
resp.ok()
);
},
{ timeout: 15_000 },
);
await page.mouse.move(startX, startY);
await page.mouse.down();
// Trip the 6px activation distance with vertical motion (the row
// axis we actually want to reorder along), then continue to the
// target row's lower edge so dnd-kit's collision detection settles
// on Charlie as the over-target rather than Bravo.
await page.mouse.move(startX, startY + 12, { steps: 5 });
await page.mouse.move(endX, endY, { steps: 15 });
await page.mouse.up();
await rotatePromise;
// Wait for the rotation to land in the DB.
await expect
.poll(async () => (await readMeeting(alphaId)).start_time, {
timeout: 10_000,
})
.toBe("11:00:00");
const alphaAfter = await readMeeting(alphaId);
const bravoAfter = await readMeeting(bravoId);
const charlieAfter = await readMeeting(charlieId);
// Alpha rotated to position 3 → carries the slot[3] tuple.
expect(alphaAfter.start_time).toBe("11:00:00");
expect(alphaAfter.end_time).toBe("11:30:00");
// Titles are GLUED to their meeting rows — the content moves as a
// unit. The user dragged the Alpha CONTENT to the bottom slot.
expect(alphaAfter.title_en).toBe("Drag Row Alpha");
expect(bravoAfter.title_en).toBe("Drag Row Bravo");
expect(charlieAfter.title_en).toBe("Drag Row Charlie");
// Bravo + Charlie rotated up by one slot.
expect(bravoAfter.start_time).toBe("09:00:00");
expect(bravoAfter.end_time).toBe("09:30:00");
expect(charlieAfter.start_time).toBe("10:00:00");
expect(charlieAfter.end_time).toBe("10:30:00");
// Daily numbers — the SET on the day is unchanged (the # column
// stays anchored to physical rows in chronological order). After the
// renumber-by-start-time, Bravo (now 09:00) holds the smallest dn,
// Charlie (10:00) the middle, Alpha (11:00) the largest.
const afterDnSet = new Set([
alphaAfter.daily_number,
bravoAfter.daily_number,
charlieAfter.daily_number,
]);
expect(afterDnSet).toEqual(dnSet);
expect(bravoAfter.daily_number).toBeLessThan(charlieAfter.daily_number);
expect(charlieAfter.daily_number).toBeLessThan(alphaAfter.daily_number);
});
@@ -1,9 +1,9 @@
// #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.
// #486 → #489: e2e for the Executive Meetings schedule row quick-
// actions popover. After #489, the Move up / Move down buttons are
// gone (the user drags the whole row instead — see
// executive-meetings-row-drag.spec.mjs). The popover now exposes only
// the Postpone action, and the gating + skip rules around interactive
// row affordances stay the same.
import { test, expect } from "@playwright/test";
import pg from "pg";
@@ -17,8 +17,6 @@ const pool = new pg.Pool({ connectionString: DATABASE_URL });
const createdMeetingIds = [];
const createdUserIds = [];
// bcrypt hash for "TestPass123!" — same fixture used by other e2e specs
// (see notes-thread-dialog.spec.mjs) so we don't pull bcrypt into tests.
const TEST_PASSWORD = "TestPass123!";
const TEST_PASSWORD_HASH =
"$2b$10$Bs636ukPMyz01nKrsi.5m.JlDXSN22AVCvn8cgPWWDbo5yJRQX2vu";
@@ -55,8 +53,6 @@ async function loginViaUi(page, username, password) {
}
function pickFutureDate(offsetDays) {
// 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() + offsetDays * 86_400_000);
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, "0");
@@ -64,20 +60,13 @@ function pickFutureDate(offsetDays) {
return `${y}-${m}-${day}`;
}
// Each test gets its own dedicated future date so leftover rows from
// the previous test (which only get cleaned in afterAll) don't pollute
// the next test's "first row / last row / solo row" assumptions.
const DATE_SWAP = pickFutureDate(30);
const DATE_EDGE = pickFutureDate(31);
const DATE_POSTPONE = pickFutureDate(32);
const DATE_SKIP = pickFutureDate(33);
const DATE_VIEWER = pickFutureDate(34);
const DATE_NO_MOVE_BUTTONS = pickFutureDate(35);
async function gotoTestDate(page, date) {
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(date);
@@ -142,142 +131,6 @@ test.afterAll(async () => {
await pool.end();
});
test("row click opens quick-actions popover, Move up swaps times with neighbour", async ({
page,
}) => {
const aId = await insertMeeting({
date: DATE_SWAP,
titleEn: "QA Row Alpha",
startTime: "09:00:00",
endTime: "09:30:00",
});
const bId = await insertMeeting({
date: DATE_SWAP,
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, DATE_SWAP);
// 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("edge rows expose disabled Move up / Move down at the boundaries", async ({
page,
}) => {
// First-and-only-row scenario: a single meeting on the test day
// means BOTH directions should be disabled (no neighbour either way).
const onlyId = await insertMeeting({
date: DATE_EDGE,
titleEn: "QA Edge Solo",
startTime: "11:00:00",
endTime: "11:30:00",
});
await loginViaUi(page, "admin", "admin123");
await gotoTestDate(page, DATE_EDGE);
await expect(page.locator(`[data-testid="em-row-${onlyId}"]`)).toBeVisible({
timeout: 10_000,
});
await page.locator(`[data-testid="em-row-${onlyId}"]`).click();
const soloPop = page.locator(`[data-testid="em-row-quick-${onlyId}"]`);
await expect(soloPop).toBeVisible();
await expect(
soloPop.locator(`[data-testid="em-row-quick-up-${onlyId}"]`),
).toBeDisabled();
await expect(
soloPop.locator(`[data-testid="em-row-quick-down-${onlyId}"]`),
).toBeDisabled();
// Close the popover before adding more rows so subsequent waits see
// the freshly-rendered first/last rows.
await page.keyboard.press("Escape");
// Now add neighbours so onlyId becomes the FIRST chronological row.
// First row → Move up disabled, Move down enabled. Last row → mirror.
const middleId = await insertMeeting({
date: DATE_EDGE,
titleEn: "QA Edge Middle",
startTime: "11:30:00",
endTime: "12:00:00",
});
const lastId = await insertMeeting({
date: DATE_EDGE,
titleEn: "QA Edge Last",
startTime: "12:00:00",
endTime: "12:30:00",
});
// Re-load so the day query sees all three rows.
await gotoTestDate(page, DATE_EDGE);
await expect(page.locator(`[data-testid="em-row-${lastId}"]`)).toBeVisible({
timeout: 10_000,
});
await page.locator(`[data-testid="em-row-${onlyId}"]`).click();
const firstPop = page.locator(`[data-testid="em-row-quick-${onlyId}"]`);
await expect(firstPop).toBeVisible();
await expect(
firstPop.locator(`[data-testid="em-row-quick-up-${onlyId}"]`),
).toBeDisabled();
await expect(
firstPop.locator(`[data-testid="em-row-quick-down-${onlyId}"]`),
).toBeEnabled();
await page.keyboard.press("Escape");
await page.locator(`[data-testid="em-row-${lastId}"]`).click();
const lastPop = page.locator(`[data-testid="em-row-quick-${lastId}"]`);
await expect(lastPop).toBeVisible();
await expect(
lastPop.locator(`[data-testid="em-row-quick-down-${lastId}"]`),
).toBeDisabled();
await expect(
lastPop.locator(`[data-testid="em-row-quick-up-${lastId}"]`),
).toBeEnabled();
// Sanity: the middle row exposes BOTH directions enabled.
await page.keyboard.press("Escape");
await page.locator(`[data-testid="em-row-${middleId}"]`).click();
const midPop = page.locator(`[data-testid="em-row-quick-${middleId}"]`);
await expect(midPop).toBeVisible();
await expect(
midPop.locator(`[data-testid="em-row-quick-up-${middleId}"]`),
).toBeEnabled();
await expect(
midPop.locator(`[data-testid="em-row-quick-down-${middleId}"]`),
).toBeEnabled();
});
test("Postpone quick-action runs a 5-minute postpone end-to-end", async ({
page,
}) => {
@@ -298,8 +151,6 @@ test("Postpone quick-action runs a 5-minute postpone end-to-end", async ({
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; drive
// its 5-minute chip → confirm flow and assert the new times land.
const dialog = page.locator('[data-testid="postpone-dialog"]');
await expect(dialog).toBeVisible({ timeout: 5_000 });
await dialog.locator('[data-testid="postpone-chip-5"]').click();
@@ -311,7 +162,44 @@ test("Postpone quick-action runs a 5-minute postpone end-to-end", async ({
expect(after.end_time).toBe("13:35:00");
});
test("clicking row affordances (grip, time cell, row-actions) does NOT open the popover", async ({
test("popover only exposes Postpone — Move up/down buttons retired in #489", async ({
page,
}) => {
// Two rows so neighbours exist either way — confirms the popover
// omits Move up/down even when there's a chronological neighbour.
const id = await insertMeeting({
date: DATE_NO_MOVE_BUTTONS,
titleEn: "QA NoMove A",
startTime: "10:00:00",
endTime: "10:30:00",
});
await insertMeeting({
date: DATE_NO_MOVE_BUTTONS,
titleEn: "QA NoMove B",
startTime: "11:00:00",
endTime: "11:30:00",
});
await loginViaUi(page, "admin", "admin123");
await gotoTestDate(page, DATE_NO_MOVE_BUTTONS);
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 expect(
popover.locator(`[data-testid="em-row-quick-postpone-${id}"]`),
).toBeVisible();
// Hard guard: the old up/down buttons must no longer mount.
await expect(
popover.locator(`[data-testid="em-row-quick-up-${id}"]`),
).toHaveCount(0);
await expect(
popover.locator(`[data-testid="em-row-quick-down-${id}"]`),
).toHaveCount(0);
});
test("clicking row affordances (time cell, row-actions) does NOT open the popover", async ({
page,
}) => {
const id = await insertMeeting({
@@ -327,19 +215,17 @@ test("clicking row affordances (grip, time cell, row-actions) does NOT open the
});
const popover = page.locator(`[data-testid="em-row-quick-${id}"]`);
// Drag grip handled by dnd-kit, must not open the popover.
const grip = page.locator(`[data-testid="em-row-grip-${id}"]`);
if (await grip.count()) {
await grip.click({ force: true });
await expect(popover).toBeHidden();
}
// #489: the dedicated grip handle is gone; the whole row is the drag
// handle. The row's own click handler still skips interactive cells.
await expect(
page.locator(`[data-testid="em-row-grip-${id}"]`),
).toHaveCount(0);
// Time cell — clicking it enters inline edit mode in TimeRangeCell.
// Time cell — clicking enters inline edit mode in TimeRangeCell.
// Must NOT also open the quick-actions popover.
const timeCell = page.locator(`[data-testid="em-time-${id}"]`);
await timeCell.click();
await expect(popover).toBeHidden();
// Clean up the inline editor so it doesn't leak into the next assertion.
await page.keyboard.press("Escape");
// Row-actions trigger — opens the row's own dropdown, not our popover.
@@ -351,7 +237,9 @@ test("clicking row affordances (grip, time cell, row-actions) does NOT open the
}
// Sanity: clicking an empty cell of the row DOES open the popover.
await page.locator(`[data-testid="em-row-${id}"]`).click({ position: { x: 5, y: 5 } });
await page
.locator(`[data-testid="em-row-${id}"]`)
.click({ position: { x: 5, y: 5 } });
await expect(popover).toBeVisible({ timeout: 5_000 });
});
@@ -359,25 +247,19 @@ test("non-mutator (executive_viewer) row click does NOT open quick-actions popov
page,
}) => {
const id = await insertMeeting({
date: pickFutureDate(34),
date: DATE_VIEWER,
titleEn: "QA Viewer Click",
startTime: "15:00:00",
endTime: "15:30:00",
});
// executive_viewer passes requireExecutiveAccess (so the page renders)
// but is NOT in MUTATE_ROLES — canMutate=false on the client, so the
// row's quickActionsCanMutate gate must short-circuit the click and
// refuse to mount the popover.
const viewer = await createUserWithRole("qa_viewer", "executive_viewer");
await loginViaUi(page, viewer.username, TEST_PASSWORD);
await gotoTestDate(page, pickFutureDate(34));
await gotoTestDate(page, DATE_VIEWER);
await expect(page.locator(`[data-testid="em-row-${id}"]`)).toBeVisible({
timeout: 10_000,
});
await page.locator(`[data-testid="em-row-${id}"]`).click();
// 750ms is more than enough for Radix Popover to mount; if the gate
// works, the popover element never appears in the DOM.
await page.waitForTimeout(750);
await expect(
page.locator(`[data-testid="em-row-quick-${id}"]`),
@@ -330,13 +330,19 @@ test("Schedule: dragging row 2 above row 1 swaps daily numbers and start times",
await toggle.click();
await expect(toggle).toHaveAttribute("aria-pressed", "true");
const gripA = page.getByTestId(`em-row-grip-${idA}`);
const gripB = page.getByTestId(`em-row-grip-${idB}`);
await expect(gripA).toBeVisible();
await expect(gripB).toBeVisible();
const fromBox = await gripB.boundingBox();
const toBox = await gripA.boundingBox();
// #489: the dedicated GripVertical handle was retired. The whole
// <tr> is now the drag handle, gated by `effectiveCanMutate =
// canMutate && editMode`. We drag from the # cell (the first <td>)
// because it contains only the daily-number <span> — no skip-listed
// descendants (buttons, inputs, em-edit-* / em-time-*) that
// safeRowDragListeners filters out.
const numberCellB = page
.locator(`[data-testid="em-row-${idB}"] td`)
.first();
const fromBox = await numberCellB.boundingBox();
const toBox = await page
.locator(`[data-testid="em-row-${idA}"]`)
.boundingBox();
expect(fromBox).not.toBeNull();
expect(toBox).not.toBeNull();
@@ -347,16 +353,17 @@ test("Schedule: dragging row 2 above row 1 swaps daily numbers and start times",
// them up reliably.
const startX = fromBox.x + fromBox.width / 2;
const startY = fromBox.y + fromBox.height / 2;
const endX = toBox.x + toBox.width / 2;
const endX = startX;
// Aim slightly ABOVE the target row's vertical center so dnd-kit
// inserts the dragged row before the target instead of after.
const endY = toBox.y + 4;
// Drag-row reorder now goes through /rotate-content (#489).
const reorderPromise = page.waitForResponse(
(resp) => {
const u = new URL(resp.url());
return (
u.pathname === "/api/executive-meetings/reorder" &&
u.pathname === "/api/executive-meetings/rotate-content" &&
resp.request().method() === "POST" &&
resp.ok()
);
@@ -372,11 +379,12 @@ test("Schedule: dragging row 2 above row 1 swaps daily numbers and start times",
await reorderPromise;
// The reorder API rewrites both daily_number and start_time/end_time
// so that the requested order owns the chronologically-sorted slots.
// After moving B above A, B should have daily_number=1 and the
// earlier 09:0010:00 slot, and A should fall to daily_number=2 with
// the later 11:0012:00 slot.
// rotate-content keeps each chronological slot's (start_time,
// end_time, daily_number) anchored and rotates only the meeting
// CONTENT into the new positions. After moving B above A, B
// inherits slot 1 (09:0010:00, dn=1) and A inherits slot 2
// (11:0012:00, dn=2) — same observable end-state as the legacy
// /reorder API for this two-row case.
const { rows } = await pool.query(
`SELECT id, daily_number, start_time, end_time
FROM executive_meetings WHERE id = ANY($1::int[]) ORDER BY id`,
@@ -890,30 +898,35 @@ test("Schedule: when the reorder API rejects a row drag, the UI rolls back to th
await toggle.click();
await expect(toggle).toHaveAttribute("aria-pressed", "true");
// Intercept the reorder PATCH/POST and respond with a 500 so the
// mutation in reorderRows() throws and triggers the onError rollback
// branch (setOptimisticOrder(null) + destructive toast). We track the
// hits so the assertion below can prove the request really fired
// (otherwise a missed drag would silently pass the rollback check).
// Intercept the rotate-content POST (#489 replaced /reorder for
// drag-driven reorders) and respond with a 500 so the mutation
// throws and triggers the onError rollback branch (re-set query
// cache + destructive toast). We track the hits so the assertion
// below can prove the request really fired (otherwise a missed
// drag would silently pass the rollback check).
let reorderHits = 0;
await page.route("**/api/executive-meetings/reorder", async (route) => {
reorderHits += 1;
await route.fulfill({
status: 500,
contentType: "application/json",
// apiJson() reads `data.error` for the thrown message, so use
// `error` (not `message`) to make the description deterministic.
body: JSON.stringify({ error: "Simulated reorder failure" }),
});
});
await page.route(
"**/api/executive-meetings/rotate-content",
async (route) => {
reorderHits += 1;
await route.fulfill({
status: 500,
contentType: "application/json",
// apiJson() reads `data.error` for the thrown message, so use
// `error` (not `message`) to make the description deterministic.
body: JSON.stringify({ error: "Simulated reorder failure" }),
});
},
);
const gripA = page.getByTestId(`em-row-grip-${idA}`);
const gripB = page.getByTestId(`em-row-grip-${idB}`);
await expect(gripA).toBeVisible();
await expect(gripB).toBeVisible();
const fromBox = await gripB.boundingBox();
const toBox = await gripA.boundingBox();
// #489: drag from the # cell of row B (the dedicated grip is gone).
const numberCellB = page
.locator(`[data-testid="em-row-${idB}"] td`)
.first();
const fromBox = await numberCellB.boundingBox();
const toBox = await page
.locator(`[data-testid="em-row-${idA}"]`)
.boundingBox();
expect(fromBox).not.toBeNull();
expect(toBox).not.toBeNull();
@@ -924,14 +937,14 @@ test("Schedule: when the reorder API rejects a row drag, the UI rolls back to th
// inserts B before A.
const startX = fromBox.x + fromBox.width / 2;
const startY = fromBox.y + fromBox.height / 2;
const endX = toBox.x + toBox.width / 2;
const endX = startX;
const endY = toBox.y + 4;
const failedReorderPromise = page.waitForResponse(
(resp) => {
const u = new URL(resp.url());
return (
u.pathname === "/api/executive-meetings/reorder" &&
u.pathname === "/api/executive-meetings/rotate-content" &&
resp.status() === 500
);
},
@@ -993,5 +1006,5 @@ test("Schedule: when the reorder API rejects a row drag, the UI rolls back to th
expect(String(byId[idB].start_time)).toBe("11:00:00");
expect(String(byId[idB].end_time)).toBe("12:00:00");
await page.unroute("**/api/executive-meetings/reorder");
await page.unroute("**/api/executive-meetings/rotate-content");
});