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:
@@ -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");
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user