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 that
confused AR users and didn't tell them what to fix.

UI changes:
- Compute `missingTimeCount` / `dayRotatable` from `orderedMeetings`
  in executive-meetings.tsx and thread `dayRotatable` into MeetingRow.
- MeetingRow 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 native `title`
  (desktop hover fallback). The aria-disabled is spread AFTER dnd-kit's
  attributes so it wins the prop merge.
- New `DragBlockedTooltipButton` rendered inside each blocked row's #
  cell — a Radix Tooltip-wrapped warning icon that opens on hover,
  focus, AND tap (manual open toggle) so iPad users can read the
  explanation (native title long-press is unreliable on touch).
- Inline amber `em-rotate-blocked-hint` banner above the bulk toolbar
  surfaces the missing-time count using i18next's CLDR plural resolver
  (`t(key, { count })`) so AR picks the correct
  zero/one/two/few/many/other form and EN picks one/other.
- rotateContent's catch handler detects ApiError code "no_time_window"
  (local apiJson now attaches .code/.status to thrown Errors) 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 plurals, errorToast). AR has
  full zero/one/two/few/many/other variants.
- ScheduleSection's `t` prop type widened to accept i18next options.

Tests:
- New tests/executive-meetings-rotate-needs-time-window.spec.mjs:
  inserts one untimed + two timed meetings, asserts the hint banner +
  every row's aria-disabled + data-drag-blocked, asserts the
  Tooltip-wrapped info button is visible with the correct localized
  aria-label + tooltip body (substring match — Radix renders sr-only
  duplicate), attempts a drag and asserts NO rotate-content POST is
  sent and DB state is unchanged. Then UPDATE-s the missing time and
  asserts: hint disappears, aria-disabled lifts on every row, info
  button is gone, drag now succeeds (rotate-content POST + DB
  start_time mutation).
- Existing executive-meetings-row-drag, touch-reorder, and
  row-quick-actions specs still pass.

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:35:19 +00:00
parent e19506aac7
commit 8fd16eb2dc
4 changed files with 156 additions and 10 deletions
@@ -175,6 +175,42 @@ test("blocks row-drag while any meeting on the day is missing a time, then re-en
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
@@ -217,6 +253,12 @@ test("blocks row-drag while any meeting on the day is missing a time, then re-en
// 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(page.getByTestId("em-rotate-blocked-hint")).toHaveCount(0, {
@@ -227,4 +269,46 @@ test("blocks row-drag while any meeting on the day is missing a time, then re-en
"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");
});