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"); });