fix(executive-meetings): keep visible drag-reorder chronological with cancelled rows
Task #311. The schedule view hides cancelled meetings, but the drag-reorder path was operating on the raw, unfiltered list — so cancelled rows consumed time slots and the dnd-kit indices skewed across hidden rows, leaving the visible list out of chronological order after a drop. Server (POST /api/executive-meetings/reorder) - Slot-swap now operates on the in-scope (orderedIds) subset only. Cancelled rows keep their (startTime, endTime, dailyNumber) untouched, so they no longer steal slots from the visible list. - New 400 codes: - cancelled_in_reorder: payload includes a cancelled meeting - incomplete_day: any non-cancelled meeting on the day is missing - Audit oldValue.order now reflects the visible order the user actually saw. - Phase-1/phase-2 negative-parking still avoids transient unique-constraint conflicts; cancelled rows' positive dailyNumbers cannot collide because slot dailyNumbers are a permutation of in-scope rows' existing values. Client (executive-meetings.tsx reorderRows) - Index math now derives ids from `orderedMeetings` (the visible list bound to SortableContext) instead of the raw `meetings` array. useCallback deps updated. Tests - artifacts/api-server/tests/executive-meetings.test.mjs: added "leaves cancelled rows untouched and only slot-swaps visible meetings" and "rejects orderedIds containing a cancelled meeting". - artifacts/tx-os/tests/executive-meetings-keyboard-editing.spec.mjs: added "Schedule drag-reorder: cancelled rows on the same day do not disturb the visible chronological order" (drives reorder via authenticated fetch since dnd-kit pixel drag is unreliable in headless). All 7 reorder tests pass. Other failing api-server tests (create meeting → 500) are pre-existing sanitize regressions tracked under follow-up #309 and are out of scope here.
This commit is contained in:
@@ -1094,6 +1094,129 @@ test("Reorder: rejects orderedIds containing duplicates with code duplicate_ids"
|
||||
"duplicate ids must be reported with the duplicate_ids code");
|
||||
});
|
||||
|
||||
// Task #311: dragging a visible meeting from one row to another must not
|
||||
// disturb cancelled rows on the same day. The schedule view hides
|
||||
// cancelled meetings (#273), so dnd-kit can only ever drag visible
|
||||
// rows; the reorder endpoint must accept an orderedIds payload that
|
||||
// covers exactly the visible (non-cancelled) meetings, slot-swap among
|
||||
// them, and leave cancelled rows' time slots and daily numbers
|
||||
// completely untouched. Before the fix, cancelled meetings were
|
||||
// included in the slot pool and silently stole chronologically-sorted
|
||||
// slots from the visible list, which made dragging a meeting from top
|
||||
// to bottom land it in a non-chronological position on screen.
|
||||
test("Reorder: leaves cancelled rows untouched and only slot-swaps visible meetings", async () => {
|
||||
const reorderDate = "2050-05-21";
|
||||
const a = await api(adminCookie, "POST", "/api/executive-meetings", {
|
||||
titleAr: "أ", titleEn: "A", meetingDate: reorderDate,
|
||||
startTime: "09:00", endTime: "09:30",
|
||||
});
|
||||
const b = await api(adminCookie, "POST", "/api/executive-meetings", {
|
||||
titleAr: "ب", titleEn: "B (cancelled)", meetingDate: reorderDate,
|
||||
startTime: "10:00", endTime: "10:30",
|
||||
});
|
||||
const c = await api(adminCookie, "POST", "/api/executive-meetings", {
|
||||
titleAr: "ج", titleEn: "C", meetingDate: reorderDate,
|
||||
startTime: "11:00", endTime: "11:30",
|
||||
});
|
||||
const d = await api(adminCookie, "POST", "/api/executive-meetings", {
|
||||
titleAr: "د", titleEn: "D", meetingDate: reorderDate,
|
||||
startTime: "12:00", endTime: "12:30",
|
||||
});
|
||||
const A = await a.json(); created.meetingIds.push(A.id);
|
||||
const B = await b.json(); created.meetingIds.push(B.id);
|
||||
const C = await c.json(); created.meetingIds.push(C.id);
|
||||
const D = await d.json(); created.meetingIds.push(D.id);
|
||||
|
||||
// Cancel B in-place so the visible list is [A, C, D] but the day
|
||||
// still holds 4 rows. Capture B's pre-reorder slot to assert it
|
||||
// stays put after the reorder.
|
||||
await pool.query(
|
||||
`UPDATE executive_meetings SET status = 'cancelled' WHERE id = $1`,
|
||||
[B.id],
|
||||
);
|
||||
const { rows: bBeforeRows } = await pool.query(
|
||||
`SELECT daily_number, start_time, end_time, status
|
||||
FROM executive_meetings WHERE id = $1`,
|
||||
[B.id],
|
||||
);
|
||||
const bBefore = bBeforeRows[0];
|
||||
|
||||
// User drags D (bottom of the visible list) up to the top, so the
|
||||
// visible order becomes [D, A, C]. The orderedIds payload covers
|
||||
// ONLY visible meetings — that is what the patched client sends.
|
||||
const reorder = await api(adminCookie, "POST",
|
||||
"/api/executive-meetings/reorder",
|
||||
{ meetingDate: reorderDate, orderedIds: [D.id, A.id, C.id] });
|
||||
assert.equal(reorder.status, 200,
|
||||
"subset reorder (cancelled excluded) must be accepted");
|
||||
|
||||
const day = await api(adminCookie, "GET",
|
||||
`/api/executive-meetings?date=${reorderDate}`);
|
||||
const body = await day.json();
|
||||
const byId = new Map(body.meetings.map((m) => [m.id, m]));
|
||||
|
||||
// Visible meetings inherit each other's slots, sorted chronologically:
|
||||
// D ← (09:00, daily_number from A's slot)
|
||||
// A ← (11:00, daily_number from C's slot)
|
||||
// C ← (12:00, daily_number from D's slot)
|
||||
// After the swap the visible rows must read top-to-bottom in
|
||||
// chronological start-time order.
|
||||
const visible = body.meetings
|
||||
.filter((m) => m.status !== "cancelled")
|
||||
.sort((a, b) => a.dailyNumber - b.dailyNumber);
|
||||
assert.deepEqual(visible.map((m) => m.id), [D.id, A.id, C.id],
|
||||
"visible rows must be in the dropped order [D, A, C]");
|
||||
const starts = visible.map((m) => m.startTime.slice(0, 5));
|
||||
for (let i = 1; i < starts.length; i++) {
|
||||
assert.ok(starts[i - 1] <= starts[i],
|
||||
`visible row ${i - 1} (${starts[i - 1]}) must be <= row ${i} (${starts[i]}) chronologically`);
|
||||
}
|
||||
// Concretely the slot-swap should produce 09:00, 11:00, 12:00 for
|
||||
// [D, A, C] (B's 10:00 slot stays with B, untouched).
|
||||
assert.equal(visible[0].startTime.slice(0, 5), "09:00");
|
||||
assert.equal(visible[1].startTime.slice(0, 5), "11:00");
|
||||
assert.equal(visible[2].startTime.slice(0, 5), "12:00");
|
||||
|
||||
// Cancelled B: time slot AND daily_number must be unchanged.
|
||||
const bAfter = byId.get(B.id);
|
||||
assert.equal(bAfter.status, "cancelled");
|
||||
assert.equal(bAfter.startTime.slice(0, 5),
|
||||
String(bBefore.start_time).slice(0, 5),
|
||||
"cancelled meeting startTime must not change");
|
||||
assert.equal(bAfter.endTime.slice(0, 5),
|
||||
String(bBefore.end_time).slice(0, 5),
|
||||
"cancelled meeting endTime must not change");
|
||||
assert.equal(bAfter.dailyNumber, bBefore.daily_number,
|
||||
"cancelled meeting dailyNumber must not change");
|
||||
});
|
||||
|
||||
test("Reorder: rejects orderedIds containing a cancelled meeting with code cancelled_in_reorder", async () => {
|
||||
const reorderDate = "2050-05-22";
|
||||
const a = await api(adminCookie, "POST", "/api/executive-meetings", {
|
||||
titleAr: "أ", titleEn: "A", meetingDate: reorderDate,
|
||||
startTime: "09:00", endTime: "09:30",
|
||||
});
|
||||
const b = await api(adminCookie, "POST", "/api/executive-meetings", {
|
||||
titleAr: "ب", titleEn: "B (cancelled)", meetingDate: reorderDate,
|
||||
startTime: "10:00", endTime: "10:30",
|
||||
});
|
||||
const A = await a.json(); created.meetingIds.push(A.id);
|
||||
const B = await b.json(); created.meetingIds.push(B.id);
|
||||
await pool.query(
|
||||
`UPDATE executive_meetings SET status = 'cancelled' WHERE id = $1`,
|
||||
[B.id],
|
||||
);
|
||||
|
||||
const r = await api(adminCookie, "POST",
|
||||
"/api/executive-meetings/reorder",
|
||||
{ meetingDate: reorderDate, orderedIds: [B.id, A.id] });
|
||||
assert.equal(r.status, 400,
|
||||
"orderedIds containing a cancelled meeting must be rejected");
|
||||
const body = await r.json();
|
||||
assert.equal(body.code, "cancelled_in_reorder",
|
||||
"rejection must be reported with the cancelled_in_reorder code");
|
||||
});
|
||||
|
||||
test("Reorder: rejects an incomplete-day request (orderedIds missing some)", async () => {
|
||||
const reorderDate = "2050-02-20";
|
||||
const a = await api(adminCookie, "POST", "/api/executive-meetings", {
|
||||
|
||||
Reference in New Issue
Block a user