Block EM row-drag when any meeting on the day lacks a time window (#492)
The /api/executive-meetings/rotate-content endpoint hard-rejects with
`code: "no_time_window"` whenever any visible non-cancelled meeting on
the date is missing (start_time, end_time). Before this change, dragging
a row on such a day produced an opaque English error toast ("Every
meeting must have a scheduled time window to rotate") that confused AR
users and didn't tell them what to fix.
Fix:
- Compute `missingTimeCount` / `dayRotatable` from `orderedMeetings`
in executive-meetings.tsx and thread `dayRotatable` into MeetingRow.
- MeetingRow now gates useSortable + safeRowDragListeners on
`effectiveDragEnabled = dragEnabled && dayRotatable`. When drag is
blocked specifically by missing time, the <tr> shows
`cursor-not-allowed`, dimmed opacity, `aria-disabled="true"`,
`data-drag-blocked="no_time_window"`, and a bilingual `title`
tooltip. The aria-disabled is spread AFTER dnd-kit's attributes so
it wins the prop merge.
- Inline amber `em-rotate-blocked-hint` banner above the bulk toolbar
surfaces the missing-time count so mutators see why drag is paused.
- rotateContent's catch handler now detects ApiError code
"no_time_window" (local apiJson now attaches .code/.status to the
thrown Error) and shows the bilingual
`executiveMeetings.rotate.needsTimeWindow.errorToast` instead of
the raw server message.
- Locale keys added under `executiveMeetings.rotate.needsTimeWindow`
in en.json + ar.json (tooltip, hint_one/_other [+ AR plurals],
errorToast).
Tests:
- New tests/executive-meetings-rotate-needs-time-window.spec.mjs
inserts one untimed + two timed meetings and asserts the hint
banner + every row's aria-disabled + data-drag-blocked, then
attempts a drag and asserts NO rotate-content POST is sent and
DB state is unchanged. After UPDATE-ing the missing time, asserts
the hint and aria-disabled lift.
- Existing executive-meetings-row-drag and touch-reorder specs still
pass — drag works exactly as before on fully-timed days.
Files: artifacts/tx-os/src/pages/executive-meetings.tsx,
artifacts/tx-os/src/locales/{en,ar}.json,
artifacts/tx-os/tests/executive-meetings-rotate-needs-time-window.spec.mjs
Server route untouched.
This commit is contained in:
@@ -1433,6 +1433,18 @@
|
||||
"moveDown": "نقل لأسفل",
|
||||
"postpone": "تأجيل"
|
||||
},
|
||||
"rotate": {
|
||||
"needsTimeWindow": {
|
||||
"tooltip": "حدّد وقت بداية ونهاية لكل اجتماع قبل إعادة ترتيب هذا اليوم.",
|
||||
"hint_zero": "إعادة الترتيب بالسحب متوقفة — لا توجد اجتماعات بدون وقت.",
|
||||
"hint_one": "إعادة الترتيب بالسحب متوقفة — اجتماع واحد في هذا اليوم بدون وقت بداية ونهاية.",
|
||||
"hint_two": "إعادة الترتيب بالسحب متوقفة — اجتماعان في هذا اليوم بدون وقت بداية ونهاية.",
|
||||
"hint_few": "إعادة الترتيب بالسحب متوقفة — {{count}} اجتماعات في هذا اليوم بدون وقت بداية ونهاية.",
|
||||
"hint_many": "إعادة الترتيب بالسحب متوقفة — {{count}} اجتماعاً في هذا اليوم بدون وقت بداية ونهاية.",
|
||||
"hint_other": "إعادة الترتيب بالسحب متوقفة — {{count}} اجتماع في هذا اليوم بدون وقت بداية ونهاية.",
|
||||
"errorToast": "حدّد وقت بداية ونهاية لكل اجتماع قبل إعادة ترتيب هذا اليوم."
|
||||
}
|
||||
},
|
||||
"merge": {
|
||||
"label": "دمج الخلايا",
|
||||
"editLabel": "تعديل الخلية المدموجة",
|
||||
|
||||
@@ -1147,6 +1147,14 @@
|
||||
"moveDown": "Move down",
|
||||
"postpone": "Postpone"
|
||||
},
|
||||
"rotate": {
|
||||
"needsTimeWindow": {
|
||||
"tooltip": "Set a start and end time on every meeting before you can reorder this day.",
|
||||
"hint_one": "Drag-to-reorder is paused — 1 meeting on this day is missing a start/end time.",
|
||||
"hint_other": "Drag-to-reorder is paused — {{count}} meetings on this day are missing a start/end time.",
|
||||
"errorToast": "Set a start and end time on every meeting before you can reorder this day."
|
||||
}
|
||||
},
|
||||
"merge": {
|
||||
"label": "Merge cells",
|
||||
"editLabel": "Edit merged cell",
|
||||
|
||||
@@ -561,7 +561,16 @@ async function apiJson<T>(
|
||||
if (res.status === 204) return undefined as T;
|
||||
const data = (await res.json().catch(() => ({}))) as T & { error?: string };
|
||||
if (!res.ok) {
|
||||
throw new Error((data as { error?: string }).error || `HTTP ${res.status}`);
|
||||
const message =
|
||||
(data as { error?: string }).error || `HTTP ${res.status}`;
|
||||
const err = new Error(message) as Error & {
|
||||
code?: string;
|
||||
status?: number;
|
||||
};
|
||||
const rawCode = (data as { code?: unknown }).code;
|
||||
if (typeof rawCode === "string") err.code = rawCode;
|
||||
err.status = res.status;
|
||||
throw err;
|
||||
}
|
||||
return data;
|
||||
}
|
||||
@@ -2204,7 +2213,20 @@ 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);
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
// #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);
|
||||
toast({
|
||||
title: t("common.error"),
|
||||
description: msg,
|
||||
@@ -2218,6 +2240,21 @@ function ScheduleSection({
|
||||
[orderedMeetings, optimisticOrder, date, qc, refreshDay, toast, t],
|
||||
);
|
||||
|
||||
// #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;
|
||||
|
||||
// #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
|
||||
@@ -2638,6 +2675,30 @@ function ScheduleSection({
|
||||
<div className="text-sm">{date}</div>
|
||||
</div>
|
||||
|
||||
{/* #492: inline hint banner — surfaces the reason row-drag is
|
||||
paused for the current day so mutators don't try to drag and
|
||||
get a server 400. Only shown when the user actually has
|
||||
mutate permission (otherwise drag was never available) and
|
||||
at least one meeting is missing a (start, end) tuple. */}
|
||||
{canMutate && missingTimeCount > 0 && (
|
||||
<div
|
||||
className="flex items-start gap-2 rounded-md border border-amber-300 bg-amber-50 px-3 py-2 mb-2 text-sm text-amber-900 print:hidden"
|
||||
role="status"
|
||||
data-testid="em-rotate-blocked-hint"
|
||||
>
|
||||
<span className="font-medium" aria-hidden="true">⚠</span>
|
||||
<span>
|
||||
{(() => {
|
||||
const key =
|
||||
missingTimeCount === 1
|
||||
? "executiveMeetings.rotate.needsTimeWindow.hint_one"
|
||||
: "executiveMeetings.rotate.needsTimeWindow.hint_other";
|
||||
return t(key).replace("{{count}}", String(missingTimeCount));
|
||||
})()}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Floating bulk-action toolbar. Per spec it appears ONLY when
|
||||
≥1 row is selected (and in edit mode). The tri-state
|
||||
"select all" checkbox lives in the table header (see
|
||||
@@ -2893,6 +2954,13 @@ 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)
|
||||
@@ -3716,6 +3784,7 @@ function MeetingRow({
|
||||
onBulkSelectChange,
|
||||
displayNumber,
|
||||
dragEnabled,
|
||||
dayRotatable,
|
||||
quickActionsCanMutate,
|
||||
onQuickPostpone,
|
||||
}: {
|
||||
@@ -3734,6 +3803,12 @@ 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: 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
|
||||
@@ -3799,6 +3874,13 @@ 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;
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
@@ -3806,7 +3888,7 @@ function MeetingRow({
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({ id: meeting.id, disabled: !dragEnabled });
|
||||
} = useSortable({ id: meeting.id, disabled: !effectiveDragEnabled });
|
||||
const isSkipDragTarget = useCallback(
|
||||
(target: EventTarget | null): boolean => {
|
||||
const el = target as HTMLElement | null;
|
||||
@@ -3836,7 +3918,7 @@ function MeetingRow({
|
||||
[meeting.id],
|
||||
);
|
||||
const safeRowDragListeners = useMemo(() => {
|
||||
if (!listeners || !dragEnabled) return undefined;
|
||||
if (!listeners || !effectiveDragEnabled) 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];
|
||||
@@ -3847,7 +3929,7 @@ function MeetingRow({
|
||||
};
|
||||
}
|
||||
return out;
|
||||
}, [listeners, dragEnabled, isSkipDragTarget]);
|
||||
}, [listeners, effectiveDragEnabled, isSkipDragTarget]);
|
||||
|
||||
const numberCellCls =
|
||||
"border border-gray-300 px-2 py-2 text-center align-middle font-semibold " +
|
||||
@@ -4291,15 +4373,41 @@ function MeetingRow({
|
||||
<>
|
||||
<tr
|
||||
ref={setNodeRef}
|
||||
className={`group${dragEnabled ? " cursor-grab active:cursor-grabbing" : ""}`}
|
||||
className={
|
||||
"group" +
|
||||
(effectiveDragEnabled
|
||||
? " cursor-grab active:cursor-grabbing"
|
||||
: dragBlockedByMissingTime
|
||||
? " cursor-not-allowed opacity-80"
|
||||
: "")
|
||||
}
|
||||
style={trStyle}
|
||||
data-testid={`em-row-${meeting.id}`}
|
||||
data-current-meeting={isCurrent ? "true" : undefined}
|
||||
data-bulk-selected={bulkSelected ? "true" : undefined}
|
||||
data-quick-open={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,230 @@
|
||||
// #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");
|
||||
}
|
||||
});
|
||||
|
||||
// Hint banner shows up with the missing count. Both AR and EN
|
||||
// strings include the digit "1" plus the testid "em-rotate-blocked-
|
||||
// hint" — we assert visibility + the count digit so the test stays
|
||||
// language-agnostic if the demo admin's language ever changes.
|
||||
const hint = page.getByTestId("em-rotate-blocked-hint");
|
||||
await expect(hint).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
// 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");
|
||||
|
||||
// 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 the hint must disappear and 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.locator('input[type="date"]').first().fill(DATE);
|
||||
await expect(timedRow).toBeVisible({ timeout: 10_000 });
|
||||
await expect(page.getByTestId("em-rotate-blocked-hint")).toHaveCount(0, {
|
||||
timeout: 10_000,
|
||||
});
|
||||
await expect(timedRow).not.toHaveAttribute("aria-disabled", "true");
|
||||
await expect(timedRow).not.toHaveAttribute(
|
||||
"data-drag-blocked",
|
||||
"no_time_window",
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user