From 1b85a1c2a2e06d0a71b87081dc883cdaeaa74fec Mon Sep 17 00:00:00 2001 From: Riyadh Date: Mon, 11 May 2026 14:35:19 +0000 Subject: [PATCH] Block EM row-drag when any meeting on the day lacks a time window (#492) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 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. --- .../tx-os/src/pages/executive-meetings.tsx | 82 +++++++++++++++--- ...meetings-rotate-needs-time-window.spec.mjs | 84 +++++++++++++++++++ 2 files changed, 156 insertions(+), 10 deletions(-) diff --git a/artifacts/tx-os/src/pages/executive-meetings.tsx b/artifacts/tx-os/src/pages/executive-meetings.tsx index 8b7abdbf..50f82ce1 100644 --- a/artifacts/tx-os/src/pages/executive-meetings.tsx +++ b/artifacts/tx-os/src/pages/executive-meetings.tsx @@ -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; font: FontPrefs; canMutate: boolean; userId: number | null; @@ -2688,13 +2693,14 @@ function ScheduleSection({ > - {(() => { - 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, + })} )} @@ -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 ( + + + + + + {label} + + + ); +} + 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. */} -
+
{displayNumber ?? meeting.dailyNumber} + {dragBlockedByMissingTime ? ( + // #492: explicit Tooltip-wrapped warning affordance. + // Native `title` on the 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. + + ) : null}
{cellBulkOverlay} {actionsOverlay} diff --git a/artifacts/tx-os/tests/executive-meetings-rotate-needs-time-window.spec.mjs b/artifacts/tx-os/tests/executive-meetings-rotate-needs-time-window.spec.mjs index 8824266c..72c618eb 100644 --- a/artifacts/tx-os/tests/executive-meetings-rotate-needs-time-window.spec.mjs +++ b/artifacts/tx-os/tests/executive-meetings-rotate-needs-time-window.spec.mjs @@ -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 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"); });