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:
Riyadh
2026-05-11 14:35:19 +00:00
parent 77493df365
commit 1b85a1c2a2
2 changed files with 156 additions and 10 deletions
@@ -109,7 +109,12 @@ import {
sortableKeyboardCoordinates,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { GripVertical } from "lucide-react";
import { GripVertical, AlertTriangle } from "lucide-react";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
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.
@@ -1063,7 +1068,7 @@ function ScheduleSection({
onDateChange: (d: string) => void;
isLoading: boolean;
isRtl: boolean;
t: (k: string) => string;
t: (k: string, opts?: Record<string, unknown>) => string;
font: FontPrefs;
canMutate: boolean;
userId: number | null;
@@ -2688,13 +2693,14 @@ function ScheduleSection({
>
<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));
})()}
{/* #492: use i18next's count-driven plural resolver so AR
picks the correct one of zero/one/two/few/many/other
forms (defined in ar.json) and EN picks one/other.
Manually selecting hint_one/_other would skip the AR
grammar variants entirely. */}
{t("executiveMeetings.rotate.needsTimeWindow.hint", {
count: missingTimeCount,
})}
</span>
</div>
)}
@@ -3756,6 +3762,49 @@ 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>
);
}
function MeetingRow({
meeting,
isRtl,
@@ -4124,8 +4173,21 @@ function MeetingRow({
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">
<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}
@@ -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");
});