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", "rejects orderedIds containing a cancelled meeting", and "handles a day with a null-startTime meeting deterministically". - artifacts/tx-os/tests/executive-meetings-keyboard-editing.spec.mjs: added two real dnd-kit drag tests driven through the keyboard sensor (Space + ArrowUp + Space on the row's grip handle): - "Schedule drag-reorder: cancelled rows ... do not disturb the visible chronological order" — cancelled row keeps its slot, visible rows end up chronological after the drop. - "Schedule drag-reorder: a null-startTime row drags deterministically and inherits its new slot" — null-time row dragged to top inherits the first chronological slot; the originally-null slot shifts to the row that lands at the bottom. Code-review follow-ups addressed: - Reverted the unrelated artifacts/tx-os/public/opengraph.jpg binary change. - Added the explicit null-startTime regressions at both API and Playwright drag levels per review request. - Replaced the fetch-based UI test with real dnd-kit keyboard-sensor drags to fully exercise the client onDragEnd path including the patched index math. All 8 reorder API tests and both new Playwright drag 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:
@@ -993,6 +993,97 @@ test("Schedule drag-reorder: cancelled rows on the same day do not disturb the v
|
||||
expect(bAfter.daily_number).toBe(bBefore.daily_number);
|
||||
});
|
||||
|
||||
test("Schedule drag-reorder: a null-startTime row drags deterministically and inherits its new slot", async ({
|
||||
page,
|
||||
}) => {
|
||||
// The slot-swap sort puts non-null startTimes first (chronological)
|
||||
// and null-startTime rows last. After dragging the null row to the
|
||||
// top of a 3-row visible day, it should inherit the first
|
||||
// chronological slot (09:00) and the originally-null slot should
|
||||
// shift to the bottom row.
|
||||
await setLangEn(page);
|
||||
const date = uniqueFutureDate(25);
|
||||
const a = await insertMeeting({
|
||||
meetingDate: date,
|
||||
dailyNumber: 1,
|
||||
titleEn: `NullA ${Date.now().toString(36)}`,
|
||||
titleAr: `بلاأ ${Date.now().toString(36)}`,
|
||||
startTime: "09:00:00",
|
||||
endTime: "09:30:00",
|
||||
});
|
||||
const b = await insertMeeting({
|
||||
meetingDate: date,
|
||||
dailyNumber: 2,
|
||||
titleEn: `NullB ${Date.now().toString(36)}`,
|
||||
titleAr: `بلاب ${Date.now().toString(36)}`,
|
||||
startTime: "10:00:00",
|
||||
endTime: "10:30:00",
|
||||
});
|
||||
const n = await insertMeeting({
|
||||
meetingDate: date,
|
||||
dailyNumber: 3,
|
||||
titleEn: `NullN ${Date.now().toString(36)}`,
|
||||
titleAr: `بلان ${Date.now().toString(36)}`,
|
||||
startTime: null,
|
||||
endTime: null,
|
||||
});
|
||||
|
||||
await loginViaUi(page, "admin", "admin123");
|
||||
await page.goto("/executive-meetings");
|
||||
await resetSchedulePrefs(page);
|
||||
await page.reload();
|
||||
await page.locator('input[type="date"]').first().fill(date);
|
||||
const rowA = page.getByTestId(`em-row-${a}`);
|
||||
const rowB = page.getByTestId(`em-row-${b}`);
|
||||
const rowN = page.getByTestId(`em-row-${n}`);
|
||||
await expect(rowA).toBeVisible({ timeout: 10_000 });
|
||||
await expect(rowB).toBeVisible();
|
||||
await expect(rowN).toBeVisible();
|
||||
await ensureEditMode(page);
|
||||
|
||||
const gripN = page.getByTestId(`em-row-grip-${n}`);
|
||||
await expect(gripN).toBeVisible();
|
||||
await gripN.focus();
|
||||
const savePromise = page.waitForResponse(
|
||||
(resp) => {
|
||||
const u = new URL(resp.url());
|
||||
return (
|
||||
u.pathname === "/api/executive-meetings/reorder" &&
|
||||
resp.request().method() === "POST" &&
|
||||
resp.ok()
|
||||
);
|
||||
},
|
||||
{ timeout: 15_000 },
|
||||
);
|
||||
const refetchPromise = waitForDayRefetch(page, date);
|
||||
await page.keyboard.press("Space"); // pick up N (last row)
|
||||
await page.waitForTimeout(150);
|
||||
await page.keyboard.press("ArrowUp");
|
||||
await page.waitForTimeout(150);
|
||||
await page.keyboard.press("ArrowUp");
|
||||
await page.waitForTimeout(150);
|
||||
await page.keyboard.press("Space"); // drop -> visible order [N, A, B]
|
||||
await Promise.all([savePromise, refetchPromise]);
|
||||
|
||||
// Slots sorted chronologically (nulls last) were
|
||||
// slot[0] = (09:00, dn=A's), slot[1] = (10:00, dn=B's), slot[2] = (null, dn=N's).
|
||||
// Assignment N->slot[0], A->slot[1], B->slot[2] yields the row times below.
|
||||
const { rows: visibleAfter } = await pool.query(
|
||||
`SELECT id, daily_number, start_time, end_time
|
||||
FROM executive_meetings
|
||||
WHERE meeting_date = $1
|
||||
ORDER BY daily_number ASC`,
|
||||
[date],
|
||||
);
|
||||
const byId = Object.fromEntries(visibleAfter.map((r) => [r.id, r]));
|
||||
expect(String(byId[n].start_time)).toBe("09:00:00");
|
||||
expect(String(byId[a].start_time)).toBe("10:00:00");
|
||||
expect(byId[b].start_time).toBe(null);
|
||||
// dailyNumbers are a unique permutation 1..3 across the day.
|
||||
const dns = visibleAfter.map((r) => r.daily_number).slice().sort();
|
||||
expect(dns).toEqual([1, 2, 3]);
|
||||
});
|
||||
|
||||
test("Schedule keyboard: typing an unparseable time + Enter shows a toast and does NOT save", async ({
|
||||
page,
|
||||
}) => {
|
||||
|
||||
Reference in New Issue
Block a user