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:
@@ -2454,19 +2454,11 @@ router.post(
|
||||
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",
|
||||
};
|
||||
}
|
||||
}
|
||||
// #497: the previous `no_time_window` guard was removed —
|
||||
// meetings without a (startTime, endTime) tuple are a perfectly
|
||||
// normal state and the rotation algorithm below handles `null`
|
||||
// tuples just like real ones (a `null/null` slot rotates to its
|
||||
// new physical row alongside the timed slots).
|
||||
// Optimistic-lock check on every meeting in the rotation. Same
|
||||
// shape as swap-times so the client can render the same conflict
|
||||
// toast.
|
||||
@@ -2514,15 +2506,25 @@ router.post(
|
||||
}
|
||||
// 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.
|
||||
// even when two rows share a start time. #497: null start times
|
||||
// sort LAST (after every timed row) and tie-break by id so the
|
||||
// ordering is stable across runs. This keeps the visual
|
||||
// chronological order matching what the client renders (timed
|
||||
// rows first in time order, untimed rows trailing by insertion).
|
||||
const chrono = lockedRows.slice().sort((a, b) => {
|
||||
if (a.startTime! < b.startTime!) return -1;
|
||||
if (a.startTime! > b.startTime!) return 1;
|
||||
const aNull = a.startTime == null;
|
||||
const bNull = b.startTime == null;
|
||||
if (aNull && !bNull) return 1;
|
||||
if (!aNull && bNull) return -1;
|
||||
if (!aNull && !bNull) {
|
||||
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!,
|
||||
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
|
||||
|
||||
@@ -1433,12 +1433,6 @@
|
||||
"moveDown": "نقل لأسفل",
|
||||
"postpone": "تأجيل"
|
||||
},
|
||||
"rotate": {
|
||||
"needsTimeWindow": {
|
||||
"tooltip": "حدّد وقت بداية ونهاية لكل اجتماع قبل إعادة ترتيب هذا اليوم.",
|
||||
"errorToast": "حدّد وقت بداية ونهاية لكل اجتماع قبل إعادة ترتيب هذا اليوم."
|
||||
}
|
||||
},
|
||||
"merge": {
|
||||
"label": "دمج الخلايا",
|
||||
"editLabel": "تعديل الخلية المدموجة",
|
||||
|
||||
@@ -1147,12 +1147,6 @@
|
||||
"moveDown": "Move down",
|
||||
"postpone": "Postpone"
|
||||
},
|
||||
"rotate": {
|
||||
"needsTimeWindow": {
|
||||
"tooltip": "Set a start and end time on every meeting before you can reorder this day.",
|
||||
"errorToast": "Set a start and end time on every meeting before you can reorder this day."
|
||||
}
|
||||
},
|
||||
"merge": {
|
||||
"label": "Merge cells",
|
||||
"editLabel": "Edit merged cell",
|
||||
|
||||
@@ -109,12 +109,7 @@ import {
|
||||
sortableKeyboardCoordinates,
|
||||
} from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import { GripVertical, AlertTriangle } from "lucide-react";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { GripVertical } from "lucide-react";
|
||||
import { EditableCell } from "@/components/editable-cell";
|
||||
// #486: reuse the same dialog the upcoming-alert pops, so postponing
|
||||
// from the schedule row quick-actions menu shares one implementation.
|
||||
@@ -2218,20 +2213,11 @@ function ScheduleSection({
|
||||
// #489: restore the prior optimistic order on rollback so a
|
||||
// failed rotate doesn't leave the UI in the post-drop order.
|
||||
setOptimisticOrder(previousOptimisticOrder);
|
||||
// #492: localize the server's `no_time_window` 400 — the raw
|
||||
// English error string ("Every meeting must have a scheduled
|
||||
// time window to rotate") was leaking into the toast for AR
|
||||
// users and confusing everyone. The new tooltip + inline hint
|
||||
// already explain the gating in both languages, so the toast
|
||||
// mirrors that copy on this specific error code.
|
||||
const code = (err as { code?: unknown } | null)?.code;
|
||||
const isNoTimeWindow =
|
||||
typeof code === "string" && code === "no_time_window";
|
||||
const msg = isNoTimeWindow
|
||||
? t("executiveMeetings.rotate.needsTimeWindow.errorToast")
|
||||
: err instanceof Error
|
||||
? err.message
|
||||
: String(err);
|
||||
// #497: the previous `no_time_window` toast branch was
|
||||
// removed — the server no longer rejects rotations that
|
||||
// include a row with a NULL time tuple, so this code path is
|
||||
// unreachable. Surface whatever message the error carries.
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
toast({
|
||||
title: t("common.error"),
|
||||
description: msg,
|
||||
@@ -2242,23 +2228,14 @@ function ScheduleSection({
|
||||
setReordering(false);
|
||||
}
|
||||
},
|
||||
[orderedMeetings, optimisticOrder, date, qc, refreshDay, toast, t],
|
||||
[orderedMeetings, optimisticOrder, date, qc, refreshDay, toast],
|
||||
);
|
||||
|
||||
// #492: a row-drag rotation is only meaningful when EVERY visible
|
||||
// meeting on the day has both a startTime and endTime — the server's
|
||||
// /rotate-content endpoint hard-rejects any rotation that includes a
|
||||
// row with a NULL time tuple (`code: "no_time_window"`). We block the
|
||||
// drag client-side so the user gets a tooltip + inline hint instead
|
||||
// of an opaque error toast after they actually try to drag.
|
||||
const missingTimeCount = useMemo(
|
||||
() =>
|
||||
orderedMeetings.filter(
|
||||
(m) => m.startTime == null || m.endTime == null,
|
||||
).length,
|
||||
[orderedMeetings],
|
||||
);
|
||||
const dayRotatable = missingTimeCount === 0;
|
||||
// #497: the missing-time drag block (originally #492) was lifted —
|
||||
// meetings without a (startTime, endTime) tuple are a normal state
|
||||
// and the server's /rotate-content now accepts them. The
|
||||
// `missingTimeCount` / `dayRotatable` memos that gated drag are
|
||||
// gone along with the warning button + dimmed-row affordance.
|
||||
|
||||
// #486 → #489: Move up / Move down were retired in favour of
|
||||
// dragging the whole row (`rotateContent` above). The schedule's
|
||||
@@ -2680,12 +2657,10 @@ function ScheduleSection({
|
||||
<div className="text-sm">{date}</div>
|
||||
</div>
|
||||
|
||||
{/* #496: the amber missing-time hint banner introduced in #492
|
||||
was removed per product feedback — meetings without a
|
||||
start/end time are a perfectly normal state and don't warrant
|
||||
a warning. The underlying `dayRotatable` / `missingTimeCount`
|
||||
gating still aborts row-drag client-side (and per-row tooltip
|
||||
affordance + native title still explain why on hover). */}
|
||||
{/* #496/#497: the amber missing-time hint banner (#492) and the
|
||||
per-row drag block + warning button were both removed —
|
||||
meetings without a start/end time are a normal state and
|
||||
rotate freely alongside timed rows. */}
|
||||
|
||||
{/* Floating bulk-action toolbar. Per spec it appears ONLY when
|
||||
≥1 row is selected (and in edit mode). The tri-state
|
||||
@@ -2942,13 +2917,6 @@ function ScheduleSection({
|
||||
// #489: drag must work for mutators regardless
|
||||
// of edit mode — pass raw mutate permission.
|
||||
dragEnabled={canMutate}
|
||||
// #492: but block the actual drag (with a dimmed,
|
||||
// aria-disabled row + bilingual tooltip) when any
|
||||
// meeting on the day is missing a (start, end)
|
||||
// tuple — the server would 400 `no_time_window`
|
||||
// anyway. The hint banner above the table tells
|
||||
// the user how many rows still need a time.
|
||||
dayRotatable={dayRotatable}
|
||||
onSaveTitle={(html) => saveTitle(m, html)}
|
||||
onSaveAttendeeName={(idx, html) =>
|
||||
saveAttendeeName(m, idx, html)
|
||||
@@ -3744,48 +3712,10 @@ function BackToMainButton({
|
||||
);
|
||||
}
|
||||
|
||||
// #492: Touch-friendly tooltip affordance for the drag-blocked state.
|
||||
// Radix Tooltip opens on hover/focus by default, but iPad taps don't
|
||||
// fire those — we toggle `open` on click so a single tap shows the
|
||||
// localized explanation. The button is rendered inside the row's
|
||||
// number cell and inherits its dimmed visual state.
|
||||
function DragBlockedTooltipButton({
|
||||
meetingId,
|
||||
label,
|
||||
}: {
|
||||
meetingId: string | number;
|
||||
label: string;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
return (
|
||||
<Tooltip open={open} onOpenChange={setOpen} delayDuration={150}>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={label}
|
||||
title={label}
|
||||
data-testid={`em-row-drag-blocked-info-${meetingId}`}
|
||||
className="inline-flex items-center justify-center text-amber-600 hover:text-amber-700 focus:outline-none focus:ring-2 focus:ring-amber-400 rounded p-0.5"
|
||||
// Prevent the click from bubbling into the row-level
|
||||
// quick-actions handler or a parent dnd listener.
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setOpen((v) => !v);
|
||||
}}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<AlertTriangle className="w-4 h-4" aria-hidden="true" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side="top"
|
||||
data-testid={`em-row-drag-blocked-tooltip-${meetingId}`}
|
||||
>
|
||||
{label}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
// #497: the touch-friendly `DragBlockedTooltipButton` (the per-row
|
||||
// amber warning surface introduced in #492) was removed alongside
|
||||
// the missing-time drag gate. Meetings without a start/end time now
|
||||
// drag freely.
|
||||
|
||||
function MeetingRow({
|
||||
meeting,
|
||||
@@ -3815,7 +3745,6 @@ function MeetingRow({
|
||||
onBulkSelectChange,
|
||||
displayNumber,
|
||||
dragEnabled,
|
||||
dayRotatable,
|
||||
quickActionsCanMutate,
|
||||
onQuickPostpone,
|
||||
}: {
|
||||
@@ -3834,12 +3763,6 @@ function MeetingRow({
|
||||
// (which equals canMutate && editMode and powers inline-editing
|
||||
// affordances on cells).
|
||||
dragEnabled: boolean;
|
||||
// #492: false when ANY meeting on the day lacks a (startTime,
|
||||
// endTime) — the server's rotate-content endpoint hard-rejects the
|
||||
// payload, so we block the drag client-side. The row still renders
|
||||
// (no popover/edit affordances change), but useSortable is disabled
|
||||
// and the <tr> is dimmed + aria-disabled with a bilingual title.
|
||||
dayRotatable: boolean;
|
||||
// #486/#496: mutate permission for the row's quick-actions surface
|
||||
// (centered Postpone dialog opened on row click). #486 originally
|
||||
// wired this un-gated by editMode; #496 narrows it to
|
||||
@@ -3908,13 +3831,11 @@ function MeetingRow({
|
||||
// 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.
|
||||
// #492: actual drag is gated by BOTH the user's mutate permission
|
||||
// (dragEnabled) AND every meeting on the day having a (start, end)
|
||||
// tuple (dayRotatable). When dragEnabled is true but dayRotatable
|
||||
// is false we still show a "would be draggable" affordance, just
|
||||
// dimmed + cursor-not-allowed with a tooltip explaining why.
|
||||
const effectiveDragEnabled = dragEnabled && dayRotatable;
|
||||
const dragBlockedByMissingTime = dragEnabled && !dayRotatable;
|
||||
// #497: drag is gated only by the user's mutate permission. The
|
||||
// missing-time block (#492) is gone — every visible row is
|
||||
// draggable when the user has mutate access, regardless of whether
|
||||
// the meetings on the day have start/end times.
|
||||
const effectiveDragEnabled = dragEnabled;
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
@@ -4160,19 +4081,6 @@ function MeetingRow({
|
||||
*/}
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<span>{displayNumber ?? meeting.dailyNumber}</span>
|
||||
{dragBlockedByMissingTime ? (
|
||||
// #492: explicit Tooltip-wrapped warning affordance.
|
||||
// Native `title` on the <tr> works on desktop hover but
|
||||
// is unreliable on iPad long-press; this Radix tooltip
|
||||
// opens on hover, focus, AND tap (button onClick toggles
|
||||
// open state) so touch users can read why drag is paused.
|
||||
<DragBlockedTooltipButton
|
||||
meetingId={meeting.id}
|
||||
label={t(
|
||||
"executiveMeetings.rotate.needsTimeWindow.tooltip",
|
||||
)}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
{cellBulkOverlay}
|
||||
{actionsOverlay}
|
||||
@@ -4437,39 +4345,16 @@ function MeetingRow({
|
||||
ref={setNodeRef}
|
||||
className={
|
||||
"group" +
|
||||
(effectiveDragEnabled
|
||||
? " cursor-grab active:cursor-grabbing"
|
||||
: dragBlockedByMissingTime
|
||||
? " cursor-not-allowed opacity-80"
|
||||
: "")
|
||||
(effectiveDragEnabled ? " 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}
|
||||
data-quick-open={quickActionsCanMutate && quickOpen ? "true" : undefined}
|
||||
data-drag-blocked={
|
||||
dragBlockedByMissingTime ? "no_time_window" : undefined
|
||||
}
|
||||
title={
|
||||
dragBlockedByMissingTime
|
||||
? t("executiveMeetings.rotate.needsTimeWindow.tooltip")
|
||||
: undefined
|
||||
}
|
||||
onClick={quickActionsCanMutate ? handleRowClick : undefined}
|
||||
{...(dragEnabled ? attributes : {})}
|
||||
{...(safeRowDragListeners ?? {})}
|
||||
// #492: aria-disabled communicates the row's blocked drag
|
||||
// state to assistive tech. dnd-kit's `attributes` spread
|
||||
// above already sets aria-disabled based on useSortable's
|
||||
// own `disabled` flag, so this MUST come after the spread to
|
||||
// win. We force it to "true" only when drag is blocked by
|
||||
// missing-time (not when blocked by lack of mutate perms,
|
||||
// which is the existing dnd-kit behaviour). Native `title`
|
||||
// (above) provides the bilingual hover tooltip — wrapping a
|
||||
// <tr> in Radix's Tooltip primitive is fragile because
|
||||
// TooltipTrigger renders its own element wrapper.
|
||||
{...(dragBlockedByMissingTime ? { "aria-disabled": true } : {})}
|
||||
>
|
||||
{renderCells()}
|
||||
</tr>
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
// #497: e2e for the new rotation behaviour. Originally (#492) the
|
||||
// client + server hard-blocked row-drag rotation if ANY meeting on
|
||||
// the day was missing a (start_time, end_time) tuple — surfaced via
|
||||
// an amber per-row warning button, dimmed/aria-disabled rows, and a
|
||||
// `code: "no_time_window"` 400 from the server. Per product feedback
|
||||
// that block was lifted: meetings without a time tuple are a normal
|
||||
// state and rotate freely alongside timed rows. This spec verifies:
|
||||
// * no per-row drag-blocked warning button is rendered;
|
||||
// * the row is NOT aria-disabled and has no `data-drag-blocked`;
|
||||
// * dragging a row past an untimed sibling fires a successful
|
||||
// POST /api/executive-meetings/rotate-content;
|
||||
// * the untimed slot rotates with the rest — its NULL tuple ends
|
||||
// up on whichever physical row dnd-kit dropped it on.
|
||||
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 rotate-allows-missing-times 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 = pickFutureDate(124);
|
||||
|
||||
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 setLangEn(page) {
|
||||
await page.addInitScript(() => {
|
||||
try {
|
||||
window.localStorage.setItem("tx-lang", "en");
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
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("row-drag rotation succeeds even when one meeting on the day has no time tuple", async ({
|
||||
page,
|
||||
}) => {
|
||||
const timedId = await insertMeeting({
|
||||
date: DATE,
|
||||
titleEn: "MissingTimes Timed",
|
||||
startTime: "09:00:00",
|
||||
endTime: "09:30:00",
|
||||
});
|
||||
const untimedId = await insertMeeting({
|
||||
date: DATE,
|
||||
titleEn: "MissingTimes Untimed",
|
||||
startTime: null,
|
||||
endTime: null,
|
||||
});
|
||||
const otherId = await insertMeeting({
|
||||
date: DATE,
|
||||
titleEn: "MissingTimes Other",
|
||||
startTime: "11:00:00",
|
||||
endTime: "11:30:00",
|
||||
});
|
||||
|
||||
await setLangEn(page);
|
||||
await loginViaUi(page, "admin", "admin123");
|
||||
await gotoTestDate(page, DATE);
|
||||
|
||||
// Force EN regardless of the admin user's stored preferredLanguage.
|
||||
await page.evaluate(async () => {
|
||||
const w = window;
|
||||
if (w.i18next && typeof w.i18next.changeLanguage === "function") {
|
||||
await w.i18next.changeLanguage("en");
|
||||
}
|
||||
});
|
||||
|
||||
const timedRow = page.getByTestId(`em-row-${timedId}`);
|
||||
const untimedRow = page.getByTestId(`em-row-${untimedId}`);
|
||||
const otherRow = page.getByTestId(`em-row-${otherId}`);
|
||||
await expect(timedRow).toBeVisible({ timeout: 10_000 });
|
||||
await expect(untimedRow).toBeVisible();
|
||||
await expect(otherRow).toBeVisible();
|
||||
|
||||
// No row is aria-disabled or marked drag-blocked, and the per-row
|
||||
// amber warning button is gone (the underlying drag gate was
|
||||
// lifted in #497).
|
||||
for (const row of [timedRow, untimedRow, otherRow]) {
|
||||
const aria = await row.getAttribute("aria-disabled");
|
||||
expect(aria === null || aria === "false").toBe(true);
|
||||
expect(await row.getAttribute("data-drag-blocked")).toBeNull();
|
||||
}
|
||||
for (const id of [timedId, untimedId, otherId]) {
|
||||
await expect(
|
||||
page.getByTestId(`em-row-drag-blocked-info-${id}`),
|
||||
).toHaveCount(0);
|
||||
}
|
||||
|
||||
// Drag the timed row down past the untimed row. The rotate-content
|
||||
// POST must fire and succeed, proving the server no longer rejects
|
||||
// payloads that include a NULL-time row.
|
||||
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: 20_000 },
|
||||
);
|
||||
// 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.
|
||||
const numCell = page
|
||||
.locator(`[data-testid="em-row-${timedId}"] 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 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.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.
|
||||
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);
|
||||
expect(typeof m.daily_number).toBe("number");
|
||||
expect(m.daily_number).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
@@ -1,309 +0,0 @@
|
||||
// #492: e2e for the client-side guard that blocks row-drag rotation
|
||||
// when ANY meeting on the day is missing a (start_time, end_time)
|
||||
// tuple. The server's /api/executive-meetings/rotate-content endpoint
|
||||
// hard-rejects such payloads with `code: "no_time_window"`; before
|
||||
// #492 the client surfaced the raw English error in a toast even for
|
||||
// AR users. The fix:
|
||||
// * disable useSortable on every row of the day (aria-disabled +
|
||||
// dimmed cursor + bilingual tooltip) so the drag never starts;
|
||||
// * show an inline amber hint banner above the table with the
|
||||
// count of meetings still missing a time;
|
||||
// * once every meeting has a time, drag re-enables and rotation
|
||||
// succeeds end-to-end.
|
||||
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 rotate-needs-time-window 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 = pickFutureDate(123);
|
||||
|
||||
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 setMeetingTimes(id, startTime, endTime) {
|
||||
await pool.query(
|
||||
`UPDATE executive_meetings
|
||||
SET start_time = $2, end_time = $3, updated_at = NOW()
|
||||
WHERE id = $1`,
|
||||
[id, startTime, endTime],
|
||||
);
|
||||
}
|
||||
|
||||
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 setLangEn(page) {
|
||||
await page.addInitScript(() => {
|
||||
try {
|
||||
window.localStorage.setItem("tx-lang", "en");
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
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("blocks row-drag while any meeting on the day is missing a time, then re-enables once times are set", async ({
|
||||
page,
|
||||
}) => {
|
||||
const timedId = await insertMeeting({
|
||||
date: DATE,
|
||||
titleEn: "NeedsTime Timed",
|
||||
startTime: "09:00:00",
|
||||
endTime: "09:30:00",
|
||||
});
|
||||
const untimedId = await insertMeeting({
|
||||
date: DATE,
|
||||
titleEn: "NeedsTime Untimed",
|
||||
startTime: null,
|
||||
endTime: null,
|
||||
});
|
||||
const otherId = await insertMeeting({
|
||||
date: DATE,
|
||||
titleEn: "NeedsTime Other",
|
||||
startTime: "11:00:00",
|
||||
endTime: "11:30:00",
|
||||
});
|
||||
|
||||
await setLangEn(page);
|
||||
await loginViaUi(page, "admin", "admin123");
|
||||
await gotoTestDate(page, DATE);
|
||||
|
||||
// Force EN regardless of the admin user's stored preferredLanguage
|
||||
// (AuthContext reapplies it on every login, which would otherwise
|
||||
// flip the page back to AR for the demo admin).
|
||||
await page.evaluate(async () => {
|
||||
const w = window;
|
||||
if (w.i18next && typeof w.i18next.changeLanguage === "function") {
|
||||
await w.i18next.changeLanguage("en");
|
||||
}
|
||||
});
|
||||
|
||||
// #496: the amber missing-time hint banner was removed — drag is
|
||||
// still blocked client-side and surfaced via the per-row tooltip
|
||||
// affordance asserted further down. We jump straight to the row
|
||||
// assertions.
|
||||
|
||||
// Every row on the day is aria-disabled for drag (even the rows
|
||||
// that DO have a time — rotation is whole-day).
|
||||
const timedRow = page.getByTestId(`em-row-${timedId}`);
|
||||
const untimedRow = page.getByTestId(`em-row-${untimedId}`);
|
||||
const otherRow = page.getByTestId(`em-row-${otherId}`);
|
||||
await expect(timedRow).toHaveAttribute("aria-disabled", "true");
|
||||
await expect(untimedRow).toHaveAttribute("aria-disabled", "true");
|
||||
await expect(otherRow).toHaveAttribute("aria-disabled", "true");
|
||||
await expect(timedRow).toHaveAttribute("data-drag-blocked", "no_time_window");
|
||||
|
||||
// Touch-friendly tooltip affordance: every blocked row gets a
|
||||
// warning button inside its # cell. The button's aria-label and
|
||||
// tooltip body must equal the localized explanation so iPad users
|
||||
// (where native `title` long-press is unreliable) can read it.
|
||||
// Accept either AR or EN copy — the demo admin's stored
|
||||
// preferredLanguage may flip the page between renders, but EITHER
|
||||
// localization satisfies the bilingual requirement.
|
||||
const expectedTooltipEn =
|
||||
"Set a start and end time on every meeting before you can reorder this day.";
|
||||
const expectedTooltipAr =
|
||||
"حدّد وقت بداية ونهاية لكل اجتماع قبل إعادة ترتيب هذا اليوم.";
|
||||
const infoBtn = page.getByTestId(`em-row-drag-blocked-info-${timedId}`);
|
||||
await expect(infoBtn).toBeVisible();
|
||||
const ariaLabel = await infoBtn.getAttribute("aria-label");
|
||||
expect([expectedTooltipEn, expectedTooltipAr]).toContain(ariaLabel);
|
||||
// Tap the button → Radix tooltip becomes visible with the same copy.
|
||||
// The parent <tr> has aria-disabled="true" (drag is paused), which
|
||||
// makes Playwright skip its enabled-actionability check on every
|
||||
// descendant — the button itself is fully clickable, so we use
|
||||
// { force: true } to bypass the inherited check.
|
||||
await infoBtn.click({ force: true });
|
||||
const tooltip = page
|
||||
.getByTestId(`em-row-drag-blocked-tooltip-${timedId}`)
|
||||
.first();
|
||||
await expect(tooltip).toBeVisible({ timeout: 5_000 });
|
||||
// Radix Tooltip renders both a visible bubble and a sr-only
|
||||
// duplicate via aria-describedby, so .textContent() may include
|
||||
// the localized string twice. Substring-match is sufficient.
|
||||
const tooltipText = (await tooltip.textContent())?.trim() ?? "";
|
||||
expect(
|
||||
tooltipText.includes(expectedTooltipEn) ||
|
||||
tooltipText.includes(expectedTooltipAr),
|
||||
).toBe(true);
|
||||
// Dismiss the tooltip so it doesn't intercept the drag gesture below.
|
||||
await infoBtn.click({ force: true });
|
||||
|
||||
// Attempting to drag must NOT trigger a rotate-content POST. Wire a
|
||||
// listener; if any rotate request fires during the next ~2s we
|
||||
// record it. We try the same drag gesture the real e2e drag spec
|
||||
// uses (drag from the # cell of timedId down past otherId).
|
||||
let rotateAttempts = 0;
|
||||
page.on("request", (req) => {
|
||||
if (
|
||||
req.url().includes("/api/executive-meetings/rotate-content") &&
|
||||
req.method() === "POST"
|
||||
) {
|
||||
rotateAttempts += 1;
|
||||
}
|
||||
});
|
||||
const numCell = page
|
||||
.locator(`[data-testid="em-row-${timedId}"] td`)
|
||||
.first();
|
||||
const numBox = await numCell.boundingBox();
|
||||
const otherBox = await otherRow.boundingBox();
|
||||
if (!numBox || !otherBox) 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, otherBox.y + otherBox.height - 4, {
|
||||
steps: 15,
|
||||
});
|
||||
await page.mouse.up();
|
||||
await page.waitForTimeout(800);
|
||||
expect(rotateAttempts).toBe(0);
|
||||
|
||||
// DB state unchanged.
|
||||
const timedBefore = await readMeeting(timedId);
|
||||
expect(timedBefore.start_time).toBe("09:00:00");
|
||||
|
||||
// Now give the untimed meeting a real time window. Once the day
|
||||
// becomes fully-timed aria-disabled must lift on every row — the
|
||||
// actual rotation drag path is already covered by
|
||||
// executive-meetings-row-drag.spec.mjs, so we only verify the
|
||||
// block state lifts here.
|
||||
await setMeetingTimes(untimedId, "10:00:00", "10:30:00");
|
||||
await page.reload();
|
||||
await page.evaluate(async () => {
|
||||
const w = window;
|
||||
if (w.i18next && typeof w.i18next.changeLanguage === "function") {
|
||||
await w.i18next.changeLanguage("en");
|
||||
}
|
||||
});
|
||||
await page.locator('input[type="date"]').first().fill(DATE);
|
||||
await expect(timedRow).toBeVisible({ timeout: 10_000 });
|
||||
await expect(timedRow).not.toHaveAttribute("aria-disabled", "true");
|
||||
await expect(timedRow).not.toHaveAttribute(
|
||||
"data-drag-blocked",
|
||||
"no_time_window",
|
||||
);
|
||||
await expect(
|
||||
page.getByTestId(`em-row-drag-blocked-info-${timedId}`),
|
||||
).toHaveCount(0);
|
||||
|
||||
// Full unblock flow: drag now succeeds end-to-end. Rotate-content
|
||||
// POST fires and the timedId row content moves into the next slot
|
||||
// (start_time becomes the second row's 10:00:00).
|
||||
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: 20_000 },
|
||||
);
|
||||
const numCell2 = page
|
||||
.locator(`[data-testid="em-row-${timedId}"] td`)
|
||||
.first();
|
||||
await numCell2.scrollIntoViewIfNeeded();
|
||||
const otherRow2 = page.getByTestId(`em-row-${untimedId}`);
|
||||
const numBox2 = await numCell2.boundingBox();
|
||||
const otherBox2 = await otherRow2.boundingBox();
|
||||
if (!numBox2 || !otherBox2) throw new Error("rows must be visible");
|
||||
const sX = numBox2.x + numBox2.width / 2;
|
||||
const sY = numBox2.y + numBox2.height / 2;
|
||||
await page.mouse.move(sX, sY);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(sX, sY + 12, { steps: 5 });
|
||||
await page.mouse.move(sX, otherBox2.y + otherBox2.height - 4, {
|
||||
steps: 15,
|
||||
});
|
||||
await page.mouse.up();
|
||||
await rotatePromise;
|
||||
|
||||
await expect
|
||||
.poll(async () => (await readMeeting(timedId)).start_time, {
|
||||
timeout: 10_000,
|
||||
})
|
||||
.toBe("10:00:00");
|
||||
});
|
||||
Reference in New Issue
Block a user