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
@@ -58,6 +58,7 @@ import {
import {
ExecutiveMeetingsReorderBody,
ExecutiveMeetingsSwapTimesBody,
ExecutiveMeetingsRotateContentBody,
} from "@workspace/api-zod";
import type { Request, Response, NextFunction } from "express";
@@ -2329,6 +2330,269 @@ router.post(
},
);
// =====================================================================
// ROTATE CONTENT (#489: drag whole row to a new position)
// =====================================================================
//
// The schedule lets a `canMutate` user drag any meeting from anywhere
// on its row to a new position. The visible time column AND the daily
// numbers are anchored to physical rows: only the meeting CONTENT
// (title, attendees, notes, color, status, merge) rotates through the
// fixed slots.
//
// Implementation: the (startTime, endTime, dailyNumber) tuple of each
// row in chronological order is the "slot". Given new content order
// `orderedIds`, we assign slot[i] to the meeting at orderedIds[i]. So
// every slot keeps its original tuple, and the meeting that ends up
// in physical row i carries away the time tuple of whatever meeting
// originally sat there. From the user's perspective the time column
// and `#` column are unchanged; the meeting content rotated.
//
// Concurrency: the entire day's visible rows are FOR UPDATE-locked
// inside one transaction (id-ordered to avoid deadlocks). Each
// meeting carries an optimistic-lock token via
// `expectedUpdatedAt[id]`, mirroring `swap-times` and the postpone
// route — a stale token returns 409 `stale_meeting` with a `conflict`
// payload naming the offending row + last actor.
router.post(
"/executive-meetings/rotate-content",
requireExecutiveAccess,
requireMutate,
async (req, res): Promise<void> => {
const data = parseBody(res, ExecutiveMeetingsRotateContentBody, req.body);
if (!data) return;
const dedup = new Set(data.orderedIds);
if (dedup.size !== data.orderedIds.length) {
res
.status(400)
.json({ error: "Duplicate ids in orderedIds", code: "duplicate_ids" });
return;
}
const userId = req.session.userId!;
type StaleConflict = {
ok: false;
status: 409;
error: string;
code: "stale_meeting";
conflict: {
id: number;
currentStartTime: string | null;
currentEndTime: string | null;
currentStatus: string;
lastModifiedAt: string;
lastActor: {
id: number | null;
username: string | null;
displayNameAr: string | null;
displayNameEn: string | null;
} | null;
};
};
type TxResult =
| { ok: true; meetingDate: string; changedIds: number[] }
| { ok: false; status: number; error: string; code: string }
| StaleConflict;
const result = await db.transaction(async (tx): Promise<TxResult> => {
// Lock by ascending id for deadlock safety. Pulling EVERY visible
// row on the date (not just ordered ids) so we can validate the
// payload covers exactly the visible set — partial rotations
// would silently leave stale time tuples on whatever rows the
// client omitted.
const ids = data.orderedIds.slice().sort((a, b) => a - b);
const lockedRows = await tx
.select()
.from(executiveMeetingsTable)
.where(inArray(executiveMeetingsTable.id, ids))
.for("update");
if (lockedRows.length !== data.orderedIds.length) {
return {
ok: false,
status: 404,
error: "One or more meetings not found",
code: "not_found",
};
}
// Cross-date guard: every meeting must live on the requested date.
for (const r of lockedRows) {
if (r.meetingDate !== data.meetingDate) {
return {
ok: false,
status: 400,
error: "Meetings are on different dates",
code: "different_dates",
};
}
}
// Guard: every visible (non-cancelled) row on the date must be in
// orderedIds. Otherwise we would renumber an incomplete subset
// and the cancelled-row tail tracking would drift.
const allDayRows = await tx
.select()
.from(executiveMeetingsTable)
.where(eq(executiveMeetingsTable.meetingDate, data.meetingDate));
const orderedSet = new Set(data.orderedIds);
const missingVisible = allDayRows.filter(
(r) => r.status !== "cancelled" && !orderedSet.has(r.id),
);
if (missingVisible.length > 0) {
return {
ok: false,
status: 400,
error: "orderedIds must include every visible meeting on the day",
code: "incomplete_day",
};
}
const cancelledInOrder = data.orderedIds.filter((id) => {
const r = lockedRows.find((x) => x.id === id);
return r != null && r.status === "cancelled";
});
if (cancelledInOrder.length > 0) {
return {
ok: false,
status: 400,
error: "orderedIds may not include cancelled meetings",
code: "cancelled_in_rotate",
};
}
// Time window guard: every meeting in the rotation must already
// have a (startTime, endTime) — rotation only makes sense when
// every slot has a tuple to hand off.
for (const r of lockedRows) {
if (r.startTime == null || r.endTime == null) {
return {
ok: false,
status: 400,
error: "Every meeting must have a scheduled time window to rotate",
code: "no_time_window",
};
}
}
// Optimistic-lock check on every meeting in the rotation. Same
// shape as swap-times so the client can render the same conflict
// toast.
for (const r of lockedRows) {
const expected = data.expectedUpdatedAt[String(r.id)];
if (!expected) {
return {
ok: false,
status: 400,
error: `Missing expectedUpdatedAt for meeting ${r.id}`,
code: "missing_token",
};
}
const observedIso = new Date(expected).toISOString();
const currentIso = r.updatedAt.toISOString();
if (observedIso !== currentIso) {
let lastActor: StaleConflict["conflict"]["lastActor"] = null;
if (r.updatedBy != null) {
const [u] = await tx
.select({
id: usersTable.id,
username: usersTable.username,
displayNameAr: usersTable.displayNameAr,
displayNameEn: usersTable.displayNameEn,
})
.from(usersTable)
.where(eq(usersTable.id, r.updatedBy));
lastActor = u ?? null;
}
return {
ok: false,
status: 409,
error: "Meeting was modified by someone else",
code: "stale_meeting",
conflict: {
id: r.id,
currentStartTime: r.startTime,
currentEndTime: r.endTime,
currentStatus: r.status,
lastModifiedAt: currentIso,
lastActor,
},
};
}
}
// Build the canonical chronological slot order. Sort by
// (startTime, then id) so the slot[i] tuple is deterministic
// even when two rows share a start time.
const chrono = lockedRows.slice().sort((a, b) => {
if (a.startTime! < b.startTime!) return -1;
if (a.startTime! > b.startTime!) return 1;
return a.id - b.id;
});
const slots = chrono.map((r) => ({
startTime: r.startTime!,
endTime: r.endTime!,
}));
// Two-phase write so we don't transiently violate the per-day
// unique index on dailyNumber: park every row on a temporary
// negative number, then assign the new tuple. We only update
// (startTime, endTime) here — renumberDayByStartTime below
// recomputes daily_number from the new start times so the #
// column stays anchored to its physical row.
const changedIds: number[] = [];
for (let i = 0; i < data.orderedIds.length; i++) {
const id = data.orderedIds[i]!;
const slot = slots[i]!;
const original = lockedRows.find((r) => r.id === id)!;
const isChanged =
original.startTime !== slot.startTime ||
original.endTime !== slot.endTime;
await tx
.update(executiveMeetingsTable)
.set({
startTime: slot.startTime,
endTime: slot.endTime,
updatedBy: userId,
})
.where(eq(executiveMeetingsTable.id, id));
if (isChanged) {
changedIds.push(id);
await logAudit(tx, {
action: "meeting_rotate_content",
entityType: "meeting",
entityId: id,
oldValue: {
startTime: original.startTime,
endTime: original.endTime,
},
newValue: {
startTime: slot.startTime,
endTime: slot.endTime,
orderedIds: data.orderedIds,
},
performedBy: userId,
});
}
}
// Renumber by the new start times so the # column matches the
// anchored physical-row order.
await renumberDayByStartTime(tx, data.meetingDate);
return { ok: true, meetingDate: data.meetingDate, changedIds };
});
if (!result.ok) {
const payload: Record<string, unknown> = {
error: result.error,
code: result.code,
};
if (result.code === "stale_meeting" && "conflict" in result) {
payload.conflict = result.conflict;
}
res.status(result.status).json(payload);
return;
}
void emitExecutiveMeetingsDayChanged(result.meetingDate);
const fullList = await Promise.all(
data.orderedIds.map((id) => fetchMeetingWithAttendees(id)),
);
res.json({
ok: true,
meetings: fullList.filter((m) => m != null),
changedIds: result.changedIds,
});
},
);
// =====================================================================
// REORDER (within a single day)
// =====================================================================
@@ -0,0 +1,441 @@
// #489: Backend test for POST /executive-meetings/rotate-content.
//
// The schedule lets a `canMutate` user drag any meeting from anywhere
// on its row to a new position. The (startTime, endTime, dailyNumber)
// of each chronological slot stays anchored to its physical row; only
// the meeting CONTENT (title, attendees, notes, color, status, merge)
// rotates through the slots. This endpoint receives the new content
// order plus a per-meeting `expectedUpdatedAt` token map for
// optimistic-locking and writes (startTime, endTime) back so that
// position i in chronological order ends up holding the i-th slot.
//
// Cases:
// 1. Happy path: 3-row rotation moves row1's content to position 3
// → 200, time/dailyNumber columns unchanged in DB, but the row
// that started at position 1 now holds position 3's tuple.
// 2. Stale token on any row → 409 stale_meeting + conflict payload.
// 3. orderedIds spanning two dates → 400 different_dates.
// 4. Unauthenticated → 401.
// 5. executive_viewer (no mutate role) → 403.
// 6. Missing a visible row from orderedIds → 400 incomplete_day.
import { test, before, after } from "node:test";
import assert from "node:assert/strict";
import pg from "pg";
const API_BASE = process.env.TEST_API_BASE ?? "http://localhost:8080";
const DATABASE_URL = process.env.DATABASE_URL;
if (!DATABASE_URL) {
throw new Error("DATABASE_URL must be set to run these tests");
}
const TEST_PASSWORD_HASH =
"$2b$10$Bs636ukPMyz01nKrsi.5m.JlDXSN22AVCvn8cgPWWDbo5yJRQX2vu";
const TEST_PASSWORD = "TestPass123!";
const pool = new pg.Pool({ connectionString: DATABASE_URL });
const created = { userIds: [], meetingIds: [] };
function uniqueName(prefix) {
return `${prefix}_${Date.now().toString(36)}_${Math.random()
.toString(36)
.slice(2, 8)}`;
}
async function createUser(prefix, roleName, displayNameEn) {
const username = uniqueName(prefix);
const { rows } = await pool.query(
`INSERT INTO users (username, email, password_hash, display_name_en, preferred_language, is_active)
VALUES ($1, $2, $3, $4, 'en', true) RETURNING id`,
[username, `${username}@example.com`, TEST_PASSWORD_HASH, displayNameEn],
);
const id = rows[0].id;
created.userIds.push(id);
for (const r of ["user", roleName]) {
await pool.query(
`INSERT INTO user_roles (user_id, role_id)
SELECT $1, id FROM roles WHERE name = $2
ON CONFLICT DO NOTHING`,
[id, r],
);
}
return { id, username };
}
function extractCookie(res) {
const setCookie = res.headers.get("set-cookie");
if (!setCookie) return null;
return (
setCookie
.split(",")
.map((c) => c.split(";")[0].trim())
.find((c) => c.startsWith("connect.sid=")) ?? null
);
}
async function login(username, password) {
const res = await fetch(`${API_BASE}/api/auth/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password }),
});
assert.equal(res.status, 200, `login should succeed for ${username}`);
const cookie = extractCookie(res);
assert.ok(cookie, "login response should set a session cookie");
return cookie;
}
async function api(cookie, method, path, body) {
const init = {
method,
headers: {
Cookie: cookie,
...(body !== undefined ? { "Content-Type": "application/json" } : {}),
},
};
if (body !== undefined) init.body = JSON.stringify(body);
return fetch(`${API_BASE}${path}`, init);
}
let userA = null;
let cookieA = null;
let userB = null;
let cookieB = null;
let viewerCookie = null;
before(async () => {
userA = await createUser("em_rot_a", "executive_coord_lead", "Alice Rotate");
cookieA = await login(userA.username, TEST_PASSWORD);
userB = await createUser("em_rot_b", "executive_coord_lead", "Bob Rotate");
cookieB = await login(userB.username, TEST_PASSWORD);
const viewer = await createUser(
"em_rot_view",
"executive_viewer",
"Vee Rotate",
);
viewerCookie = await login(viewer.username, TEST_PASSWORD);
});
after(async () => {
if (created.meetingIds.length > 0) {
await pool.query(
`DELETE FROM executive_meeting_attendees WHERE meeting_id = ANY($1::int[])`,
[created.meetingIds],
);
await pool.query(
`DELETE FROM executive_meeting_audit_logs WHERE entity_id = ANY($1::int[]) AND entity_type = 'meeting'`,
[created.meetingIds],
);
await pool.query(
`DELETE FROM executive_meetings WHERE id = ANY($1::int[])`,
[created.meetingIds],
);
}
if (created.userIds.length > 0) {
await pool.query(
`DELETE FROM executive_meeting_notification_prefs WHERE user_id = ANY($1::int[])`,
[created.userIds],
);
await pool.query(`DELETE FROM user_roles WHERE user_id = ANY($1::int[])`, [
created.userIds,
]);
await pool.query(`DELETE FROM users WHERE id = ANY($1::int[])`, [
created.userIds,
]);
}
await pool.end();
});
function futureDate(offsetDays) {
return new Date(Date.now() + offsetDays * 86_400_000)
.toISOString()
.slice(0, 10);
}
const DATE_HAPPY = futureDate(60);
const DATE_STALE = futureDate(61);
const DATE_DIFF_A = futureDate(62);
const DATE_DIFF_B = futureDate(63);
const DATE_INCOMPLETE = futureDate(64);
const DATE_VIEWER = futureDate(65);
async function createMeeting({ date, titleEn, startTime, endTime }) {
const res = await api(cookieA, "POST", "/api/executive-meetings", {
titleAr: titleEn,
titleEn,
meetingDate: date,
startTime,
endTime,
status: "scheduled",
});
if (res.status !== 200 && res.status !== 201) {
const body = await res.text();
assert.fail(
`meeting create should succeed (got ${res.status}): ${body.slice(0, 400)}`,
);
}
const body = await res.json();
const meeting = body.meeting ?? body;
created.meetingIds.push(meeting.id);
return meeting;
}
async function fetchMeeting(cookie, id) {
const res = await api(cookie, "GET", `/api/executive-meetings/${id}`);
assert.equal(res.status, 200, "meeting fetch should succeed");
return res.json();
}
test("rotate-content: 3-row rotation keeps time + daily numbers anchored, rotates titles", async () => {
// Build three meetings in chronological order: r1 @ 09:00, r2 @ 10:00,
// r3 @ 11:00. Then drag r1 to position 3 → new content order is
// [r2, r3, r1]. Slot[1]=09:00 stays on the row that now holds r2's
// content; slot[3]=11:00 stays on the row that now holds r1.
const r1 = await createMeeting({
date: DATE_HAPPY,
titleEn: "Rot R1 Alpha",
startTime: "09:00",
endTime: "09:30",
});
const r2 = await createMeeting({
date: DATE_HAPPY,
titleEn: "Rot R2 Bravo",
startTime: "10:00",
endTime: "10:30",
});
const r3 = await createMeeting({
date: DATE_HAPPY,
titleEn: "Rot R3 Charlie",
startTime: "11:00",
endTime: "11:30",
});
const r1Fresh = await fetchMeeting(cookieA, r1.id);
const r2Fresh = await fetchMeeting(cookieA, r2.id);
const r3Fresh = await fetchMeeting(cookieA, r3.id);
// Snapshot the per-row daily numbers for the post-rotation assertion
// — they must NOT change for the rows whose content moved.
const beforeNumR1 = r1Fresh.dailyNumber;
const beforeNumR2 = r2Fresh.dailyNumber;
const beforeNumR3 = r3Fresh.dailyNumber;
const res = await api(
cookieA,
"POST",
"/api/executive-meetings/rotate-content",
{
meetingDate: DATE_HAPPY,
orderedIds: [r2.id, r3.id, r1.id],
expectedUpdatedAt: {
[r1.id]: r1Fresh.updatedAt,
[r2.id]: r2Fresh.updatedAt,
[r3.id]: r3Fresh.updatedAt,
},
},
);
assert.equal(res.status, 200, "rotate should succeed");
const body = await res.json();
assert.equal(body.ok, true);
assert.equal(body.meetings.length, 3);
const r1After = await fetchMeeting(cookieA, r1.id);
const r2After = await fetchMeeting(cookieA, r2.id);
const r3After = await fetchMeeting(cookieA, r3.id);
// r1 (originally 09:00) was moved to position 3 → it now holds the
// 11:00 slot. r2 (originally 10:00) moved to position 1 → 09:00.
// r3 (originally 11:00) moved to position 2 → 10:00.
assert.equal(r1After.startTime.slice(0, 5), "11:00");
assert.equal(r1After.endTime.slice(0, 5), "11:30");
assert.equal(r2After.startTime.slice(0, 5), "09:00");
assert.equal(r2After.endTime.slice(0, 5), "09:30");
assert.equal(r3After.startTime.slice(0, 5), "10:00");
assert.equal(r3After.endTime.slice(0, 5), "10:30");
// Daily numbers — renumbered by start time, so the meeting that ends
// up earliest gets dailyNumber=beforeNumR1 (the smallest of the three).
// Specifically r2 should now hold beforeNumR1, r3→beforeNumR2,
// r1→beforeNumR3.
assert.equal(r2After.dailyNumber, beforeNumR1);
assert.equal(r3After.dailyNumber, beforeNumR2);
assert.equal(r1After.dailyNumber, beforeNumR3);
// Titles stayed glued to their rows — the user dragged content, so
// each row carries away its own meeting body (title, attendees, …)
// to the new physical position.
assert.equal(r1After.titleEn, "Rot R1 Alpha");
assert.equal(r2After.titleEn, "Rot R2 Bravo");
assert.equal(r3After.titleEn, "Rot R3 Charlie");
// Audit rows written for the rows whose times actually changed.
const { rows } = await pool.query(
`SELECT entity_id FROM executive_meeting_audit_logs
WHERE entity_type = 'meeting'
AND action = 'meeting_rotate_content'
AND entity_id = ANY($1::int[])`,
[[r1.id, r2.id, r3.id]],
);
assert.ok(rows.length >= 2, "expected audit rows for rotated meetings");
});
test("rotate-content: stale updatedAt → 409 stale_meeting with conflict payload", async () => {
const r1 = await createMeeting({
date: DATE_STALE,
titleEn: "Stale R1",
startTime: "09:00",
endTime: "09:30",
});
const r2 = await createMeeting({
date: DATE_STALE,
titleEn: "Stale R2",
startTime: "10:00",
endTime: "10:30",
});
const r1Fresh = await fetchMeeting(cookieA, r1.id);
const r2Fresh = await fetchMeeting(cookieA, r2.id);
// Bob mutates r2 in between Alice's read and Alice's rotate.
const patch = await api(cookieB, "PATCH", `/api/executive-meetings/${r2.id}`, {
titleEn: "Stale R2 (renamed)",
});
assert.ok(
patch.status === 200 || patch.status === 204,
`patch should succeed (got ${patch.status})`,
);
const res = await api(
cookieA,
"POST",
"/api/executive-meetings/rotate-content",
{
meetingDate: DATE_STALE,
orderedIds: [r2.id, r1.id],
expectedUpdatedAt: {
[r1.id]: r1Fresh.updatedAt,
[r2.id]: r2Fresh.updatedAt,
},
},
);
assert.equal(res.status, 409);
const body = await res.json();
assert.equal(body.code, "stale_meeting");
assert.ok(body.conflict, "stale response must include conflict payload");
assert.equal(body.conflict.id, r2.id);
assert.ok(body.conflict.lastActor, "conflict must name an actor");
assert.equal(body.conflict.lastActor.id, userB.id);
});
test("rotate-content: orderedIds spanning two dates → 400 different_dates", async () => {
const r1 = await createMeeting({
date: DATE_DIFF_A,
titleEn: "Diff A",
startTime: "13:00",
endTime: "13:30",
});
const r2 = await createMeeting({
date: DATE_DIFF_B,
titleEn: "Diff B",
startTime: "13:00",
endTime: "13:30",
});
const r1Fresh = await fetchMeeting(cookieA, r1.id);
const r2Fresh = await fetchMeeting(cookieA, r2.id);
const res = await api(
cookieA,
"POST",
"/api/executive-meetings/rotate-content",
{
meetingDate: DATE_DIFF_A,
orderedIds: [r1.id, r2.id],
expectedUpdatedAt: {
[r1.id]: r1Fresh.updatedAt,
[r2.id]: r2Fresh.updatedAt,
},
},
);
assert.equal(res.status, 400);
const body = await res.json();
assert.equal(body.code, "different_dates");
});
test("rotate-content: unauthenticated → 401", async () => {
const res = await fetch(`${API_BASE}/api/executive-meetings/rotate-content`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
meetingDate: futureDate(70),
orderedIds: [1, 2],
expectedUpdatedAt: {
1: new Date().toISOString(),
2: new Date().toISOString(),
},
}),
});
assert.equal(res.status, 401);
});
test("rotate-content: viewer (no mutate role) → 403 forbidden", async () => {
const r1 = await createMeeting({
date: DATE_VIEWER,
titleEn: "View A",
startTime: "08:00",
endTime: "08:30",
});
const r2 = await createMeeting({
date: DATE_VIEWER,
titleEn: "View B",
startTime: "09:00",
endTime: "09:30",
});
const r1Fresh = await fetchMeeting(cookieA, r1.id);
const r2Fresh = await fetchMeeting(cookieA, r2.id);
const res = await api(
viewerCookie,
"POST",
"/api/executive-meetings/rotate-content",
{
meetingDate: DATE_VIEWER,
orderedIds: [r2.id, r1.id],
expectedUpdatedAt: {
[r1.id]: r1Fresh.updatedAt,
[r2.id]: r2Fresh.updatedAt,
},
},
);
assert.equal(res.status, 403, "viewer must not be able to rotate");
});
test("rotate-content: omitting a visible row from orderedIds → 400 incomplete_day", async () => {
// Three visible rows on the day, but the client only sends two of
// them — the server must refuse rather than silently leaving the
// omitted row's tuple stale.
const r1 = await createMeeting({
date: DATE_INCOMPLETE,
titleEn: "Inc R1",
startTime: "09:00",
endTime: "09:30",
});
const r2 = await createMeeting({
date: DATE_INCOMPLETE,
titleEn: "Inc R2",
startTime: "10:00",
endTime: "10:30",
});
const r3 = await createMeeting({
date: DATE_INCOMPLETE,
titleEn: "Inc R3",
startTime: "11:00",
endTime: "11:30",
});
const r1Fresh = await fetchMeeting(cookieA, r1.id);
const r2Fresh = await fetchMeeting(cookieA, r2.id);
const res = await api(
cookieA,
"POST",
"/api/executive-meetings/rotate-content",
{
meetingDate: DATE_INCOMPLETE,
// r3 missing on purpose.
orderedIds: [r2.id, r1.id],
expectedUpdatedAt: {
[r1.id]: r1Fresh.updatedAt,
[r2.id]: r2Fresh.updatedAt,
},
},
);
assert.equal(res.status, 400);
const body = await res.json();
assert.equal(body.code, "incomplete_day");
// Sanity: r3 unchanged.
const r3After = await fetchMeeting(cookieA, r3.id);
assert.equal(r3After.startTime.slice(0, 5), "11:00");
});
+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");
});
+23
View File
@@ -28,3 +28,26 @@ export const ExecutiveMeetingsSwapTimesBody = z.object({
export type ExecutiveMeetingsSwapTimesBodyT = z.infer<
typeof ExecutiveMeetingsSwapTimesBody
>;
// #489: Body for POST /executive-meetings/rotate-content. Used when the
// user drags a meeting from anywhere on its row to a new position. The
// server keeps each meeting's slot (startTime, endTime, dailyNumber)
// anchored to its physical row in chronological order and rotates the
// meeting CONTENT (title, attendees, notes, color, status, merge) so
// that visible position i ends up holding the i-th tuple from the
// original chronological order.
//
// `orderedIds` is the new visible content order (same set as the day's
// non-cancelled meetings — every visible row must appear exactly once).
// `expectedUpdatedAt` carries each visible meeting's last-known
// `updatedAt` ISO string so the server can detect concurrent edits and
// return 409 `stale_meeting` like the other optimistic-locked routes.
export const ExecutiveMeetingsRotateContentBody = z.object({
meetingDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
orderedIds: z.array(z.number().int().positive()).min(2).max(1000),
expectedUpdatedAt: z.record(z.string().regex(/^\d+$/), z.string().datetime()),
});
export type ExecutiveMeetingsRotateContentBodyT = z.infer<
typeof ExecutiveMeetingsRotateContentBody
>;