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:
@@ -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");
|
||||
});
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 83 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 8.7 KiB |
Reference in New Issue
Block a user