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:
riyadhafraa
2026-05-11 14:21:02 +00:00
parent 71e9b6abda
commit e19506aac7
4 changed files with 364 additions and 6 deletions
@@ -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",
);
});