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:
@@ -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");
|
||||
});
|
||||
@@ -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:00–10:00 slot, and A should fall to daily_number=2 with
|
||||
// the later 11:00–12: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:00–10:00, dn=1) and A inherits slot 2
|
||||
// (11:00–12: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");
|
||||
});
|
||||
|
||||
@@ -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
|
||||
>;
|
||||
|
||||
Reference in New Issue
Block a user