Task #497: Allow row-drag rotation without time windows
Lifted the per-row missing-time drag block (originally #492). Meetings without a (startTime, endTime) tuple are a normal state and now rotate freely alongside timed rows. Server (artifacts/api-server/src/routes/executive-meetings.ts): - Dropped the `no_time_window` early-return guard in /api/executive-meetings/rotate-content. - Sort + slot construction now tolerate null start times (NULLS LAST, tie-break by id) so a null tuple rotates as a real slot. - All other safeguards (cancelled_in_rotate, optimistic lock, renumberDayByStartTime, audit logging) remain intact. Client (artifacts/tx-os/src/pages/executive-meetings.tsx): - Removed missingTimeCount / dayRotatable memos, the dayRotatable prop wiring, dragBlockedByMissingTime + effectiveDragEnabled composition, the row's data-drag-blocked / native title / cursor-not-allowed-opacity-80 / aria-disabled overrides, the entire DragBlockedTooltipButton component + its render site, and the no_time_window errorToast branch in rotateContent. Pruned now-dead Tooltip + AlertTriangle imports. i18n: removed the `executiveMeetings.rotate.needsTimeWindow` block (tooltip + errorToast) from en.json and ar.json. Tests: deleted `executive-meetings-rotate-needs-time-window.spec.mjs` and added `executive-meetings-rotate-allows-missing-times.spec.mjs`, which inserts 3 meetings (one with null times), verifies no drag-blocked warning button / aria-disabled, drags the top row to the bottom slot, asserts the rotate-content POST succeeds, and confirms exactly one row still has a null tuple after rotation. Verified: row-drag, row-quick-actions, and the new spec all pass (8/8). Architect code review PASSED.
This commit is contained in:
@@ -2228,7 +2228,7 @@ function ScheduleSection({
|
||||
setReordering(false);
|
||||
}
|
||||
},
|
||||
[orderedMeetings, optimisticOrder, date, qc, refreshDay, toast],
|
||||
[orderedMeetings, optimisticOrder, date, qc, refreshDay, toast, t],
|
||||
);
|
||||
|
||||
// #497: the missing-time drag block (originally #492) was lifted —
|
||||
|
||||
@@ -186,60 +186,68 @@ test("row-drag rotation succeeds even when one meeting on the day has no time tu
|
||||
// Display order is chronological with NULL start times last (the
|
||||
// server's renumber + the page sort both honour NULLS LAST), so
|
||||
// the on-screen rows are: timedRow (09:00), otherRow (11:00),
|
||||
// untimedRow (null). Drag the top row to the bottom slot — that
|
||||
// crosses two midpoints and dnd-kit reliably picks the last row
|
||||
// as the over-target.
|
||||
// untimedRow (null). Drag the BOTTOM row (untimed) UP to the top
|
||||
// slot. Whether dnd-kit lands the drop at slot 1 or slot 2, the
|
||||
// untimed row's null tuple is GUARANTEED to leave its original
|
||||
// physical slot — which is exactly what we need to prove.
|
||||
const numCell = page
|
||||
.locator(`[data-testid="em-row-${timedId}"] td`)
|
||||
.locator(`[data-testid="em-row-${untimedId}"] td`)
|
||||
.first();
|
||||
await numCell.scrollIntoViewIfNeeded();
|
||||
const numBox = await numCell.boundingBox();
|
||||
const lastBox = await untimedRow.boundingBox();
|
||||
if (!numBox || !lastBox) throw new Error("rows must be visible");
|
||||
const topBox = await timedRow.boundingBox();
|
||||
if (!numBox || !topBox) throw new Error("rows must be visible");
|
||||
const startX = numBox.x + numBox.width / 2;
|
||||
const startY = numBox.y + numBox.height / 2;
|
||||
await page.mouse.move(startX, startY);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(startX, startY + 12, { steps: 5 });
|
||||
await page.mouse.move(startX, lastBox.y + lastBox.height - 4, {
|
||||
steps: 15,
|
||||
});
|
||||
await page.mouse.move(startX, startY - 12, { steps: 5 });
|
||||
await page.mouse.move(startX, topBox.y + 4, { steps: 15 });
|
||||
await page.mouse.up();
|
||||
await rotatePromise;
|
||||
|
||||
// After rotation, every original meeting still exists and the
|
||||
// null-time tuple is still null (it rotated as a slot, not as a
|
||||
// value). Two of the three time tuples must now hold the timed
|
||||
// values (09:00 and 11:00), and exactly one row must remain
|
||||
// null/null — the slot that previously belonged to the untimed
|
||||
// meeting moved with the rotation.
|
||||
// Per-meeting assertions (not a multiset invariant). The untimed
|
||||
// row was originally the only one with a null tuple; after the
|
||||
// rotation it MUST have a real time tuple (whichever slot it
|
||||
// landed in), proving the null tuple moved to a different physical
|
||||
// row. Exactly one of the other two rows now carries the null
|
||||
// tuple — that row's daily_number must sort to the end (the
|
||||
// server's renumber uses NULLS LAST).
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT start_time, end_time
|
||||
FROM executive_meetings
|
||||
WHERE id = ANY($1::int[])
|
||||
ORDER BY id`,
|
||||
[[timedId, untimedId, otherId]],
|
||||
);
|
||||
const nullCount = rows.filter(
|
||||
(r) => r.start_time === null && r.end_time === null,
|
||||
).length;
|
||||
const timedTimes = rows
|
||||
.map((r) => r.start_time)
|
||||
.filter((t) => t !== null)
|
||||
.sort();
|
||||
return { nullCount, timedTimes };
|
||||
},
|
||||
{ timeout: 10_000 },
|
||||
)
|
||||
.toEqual({ nullCount: 1, timedTimes: ["09:00:00", "11:00:00"] });
|
||||
|
||||
// Sanity: each row still has a daily_number assigned (renumber ran).
|
||||
for (const id of [timedId, untimedId, otherId]) {
|
||||
const m = await readMeeting(id);
|
||||
.poll(async () => (await readMeeting(untimedId)).start_time, {
|
||||
timeout: 10_000,
|
||||
})
|
||||
.not.toBeNull();
|
||||
const timedAfter = await readMeeting(timedId);
|
||||
const untimedAfter = await readMeeting(untimedId);
|
||||
const otherAfter = await readMeeting(otherId);
|
||||
// Untimed row gained a real time tuple (the null moved off it).
|
||||
expect(untimedAfter.start_time).not.toBeNull();
|
||||
expect(untimedAfter.end_time).not.toBeNull();
|
||||
// Exactly one of the OTHER two rows now carries the null tuple.
|
||||
const allAfter = [timedAfter, otherAfter];
|
||||
const nullCarriers = allAfter.filter(
|
||||
(m) => m.start_time === null && m.end_time === null,
|
||||
);
|
||||
expect(nullCarriers).toHaveLength(1);
|
||||
// The two real time tuples (09:00 and 11:00) are still both
|
||||
// present somewhere in the day — slots stay anchored to physical
|
||||
// rows even as content rotates between them.
|
||||
const allRows = [timedAfter, untimedAfter, otherAfter];
|
||||
const realStarts = allRows
|
||||
.map((m) => m.start_time)
|
||||
.filter((t) => t !== null)
|
||||
.sort();
|
||||
expect(realStarts).toEqual(["09:00:00", "11:00:00"]);
|
||||
// Sanity: each row still has a daily_number > 0 and the null-tuple
|
||||
// carrier sorted to the end (renumber honours NULLS LAST).
|
||||
for (const m of allRows) {
|
||||
expect(typeof m.daily_number).toBe("number");
|
||||
expect(m.daily_number).toBeGreaterThan(0);
|
||||
}
|
||||
const nullCarrier = nullCarriers[0];
|
||||
for (const m of allRows) {
|
||||
if (m === nullCarrier) continue;
|
||||
expect(nullCarrier.daily_number).toBeGreaterThan(m.daily_number);
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user