Task #490: iPad row drag + Postpone button polish

The user reported that on iPad they could no longer drag a meeting
row up or down, and that the Postpone button in the row quick-actions
popover looked plain.

Changes:
- Sensor split for the schedule row DnD: replaced PointerSensor with
  MouseSensor (mouse only, distance:6) + kept TouchSensor (touch,
  delay:200ms / tolerance:8px). On iOS Safari, PointerSensor was
  receiving touch-derived pointer events and competing with the OS
  scroll claim — the browser reclaimed the gesture as a vertical
  scroll before the finger moved 6px, so drag never activated.
  Splitting sensors lets each input modality use the activation
  constraint that actually works for it. PointerSensor remains
  imported because the attendee-reorder dialog still uses it
  (out of scope).
- Polished the Postpone button in the row quick-actions popover:
  swapped the bespoke <button> for the project's <Button> component
  (variant=default, size=sm), gave it a 40px tap target (h-10),
  shadow-sm, gap-2, min-w-[11rem], and a flex-row-reverse flip in
  RTL so the icon stays adjacent to the label in Arabic.
- Rewrote tests/executive-meetings-touch-reorder.spec.mjs which was
  stale — it referenced the removed grip and the retired /reorder
  endpoint. New spec runs under iPad emulation (hasTouch + isMobile,
  768x1024), clears the persisted edit-mode pref + asserts edit mode
  reads OFF (so the "drag works without edit mode" regression is
  explicitly guarded), long-presses the # cell, drags down, and
  asserts /api/executive-meetings/rotate-content fires + DB rotation
  matches expectation.

Tests: touch-reorder (iPad TouchSensor), row-drag (desktop mouse via
MouseSensor), and popover (4/4) all pass. Backend unchanged.
This commit is contained in:
Riyadh
2026-05-11 12:58:17 +00:00
parent 205b9f650d
commit e45e15a70a
2 changed files with 143 additions and 183 deletions
@@ -93,6 +93,7 @@ import { ToastAction } from "@/components/ui/toast";
import {
DndContext,
PointerSensor,
MouseSensor,
KeyboardSensor,
TouchSensor,
useSensor,
@@ -2455,18 +2456,23 @@ function ScheduleSection({
// dnd-kit: sortable column headers + sortable rows.
//
// - PointerSensor for desktop mouse: a 6px activation distance keeps a
// normal click on cell content from being mistaken for a drag (so the
// inline editors on title/attendee/time cells still open on click).
// - TouchSensor for iPad and other touch devices: PointerSensor alone
// does not work on touch because the browser claims the gesture as a
// vertical scroll before the finger has moved 6px, so the drag never
// activates. A long-press activation (delay+tolerance) lets the user
// press-and-hold the row's grip handle to start dragging while still
// leaving normal scrolling intact everywhere else on the row.
// - MouseSensor for desktop mouse only: a 6px activation distance keeps
// a normal click on cell content from being mistaken for a drag (so
// the inline editors on title/attendee/time cells still open on
// click). We deliberately do NOT use PointerSensor here because on
// iOS Safari (iPad) pointer events fire for touch too, and a
// distance-based PointerSensor competes with the OS scroll claim:
// the browser reclaims the gesture as a vertical scroll before the
// finger has moved 6px, so the drag never activates. Splitting into
// MouseSensor + TouchSensor lets each input modality use the
// activation constraint that actually works for it.
// - TouchSensor for iPad and other touch devices: a long-press
// activation (delay+tolerance) lets the user press-and-hold the row
// to start dragging while still leaving normal vertical scrolling
// intact everywhere on the row when not long-pressing.
// - KeyboardSensor for accessibility / keyboard reordering.
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 6 } }),
useSensor(MouseSensor, { activationConstraint: { distance: 6 } }),
useSensor(TouchSensor, {
activationConstraint: { delay: 200, tolerance: 8 },
}),
@@ -3789,7 +3795,7 @@ function MeetingRow({
// so the user can grab the row from anywhere on it — except over an
// interactive control (button, link, input, contenteditable, time
// picker, row-actions menu) where the existing handler should win.
// The 6px PointerSensor distance + 200ms TouchSensor delay configured
// The 6px MouseSensor distance + 200ms TouchSensor delay configured
// at the page level keep clicks/taps on inert cell area from being
// mistaken for drags, so the quick-actions popover still opens on
// tap-and-release.
@@ -4287,28 +4293,31 @@ function MeetingRow({
</PopoverAnchor>
{quickActionsCanMutate ? (
<PopoverContent
className="w-auto p-1"
className="w-auto p-2"
align={isRtl ? "end" : "start"}
side="bottom"
sideOffset={4}
sideOffset={6}
data-testid={`em-row-quick-${meeting.id}`}
onClick={(e) => e.stopPropagation()}
onInteractOutside={() => setQuickOpen(false)}
>
<div className="flex flex-col min-w-[10rem]">
<button
type="button"
onClick={() => {
setQuickOpen(false);
onQuickPostpone?.();
}}
className="flex items-center gap-2 rounded px-2 py-1.5 text-sm hover:bg-accent text-start"
data-testid={`em-row-quick-postpone-${meeting.id}`}
>
<Clock className="w-4 h-4" aria-hidden="true" />
<span className="flex-1">{t("executiveMeetings.quickActions.postpone")}</span>
</button>
</div>
{/* #490: polished Postpone button primary-styled, larger
tap target for iPad, icon flips with locale via flex
direction so it stays adjacent to the label in RTL. */}
<Button
type="button"
variant="default"
size="sm"
onClick={() => {
setQuickOpen(false);
onQuickPostpone?.();
}}
className={`min-w-[11rem] gap-2 px-3 py-2 h-10 text-sm font-medium shadow-sm ${isRtl ? "flex-row-reverse" : ""}`}
data-testid={`em-row-quick-postpone-${meeting.id}`}
>
<Clock className="w-4 h-4" aria-hidden="true" />
<span>{t("executiveMeetings.quickActions.postpone")}</span>
</Button>
</PopoverContent>
) : null}
</Popover>
@@ -1,17 +1,21 @@
import { test, expect } from "@playwright/test";
import pg from "pg";
// E2E coverage for the iPad / touch path of drag-to-reorder on the
// Executive Meetings daily schedule. Task #141 fixed the touch path by
// adding dnd-kit's TouchSensor with a long-press activation
// (delay: 200ms, tolerance: 8px) plus `touch-action: none` on the row
// grip handle. The desktop reorder test in
// `executive-meetings-schedule-features.spec.mjs` only exercises the
// PointerSensor (mouse) path, so a regression that breaks touch
// activation would be silent on CI but break every iPad user. This
// spec runs in a touch-enabled mobile viewport and dispatches real
// CDP touch events so the TouchSensor is the sensor that actually
// activates — exactly mirroring an iPad user's gesture.
// #490: e2e for the iPad / touch path of drag-to-rotate on the
// Executive Meetings daily schedule. After #489 the dedicated grip
// handle is gone — the whole <tr> is the drag handle, drag works in
// view mode (no edit toggle), and the server endpoint is
// /api/executive-meetings/rotate-content (NOT /reorder).
//
// On iOS Safari (iPad), pointer events fire for touch and a
// PointerSensor with a small distance threshold competes with the OS
// scroll claim — the browser reclaims the gesture as a vertical scroll
// before the finger has moved 6px, so the drag never activates. The
// fix in #490 splits sensors into MouseSensor (mouse only) +
// TouchSensor (delay-based long-press), which is what this spec
// guards. A regression that brings PointerSensor back for the row
// sensors would silently break every iPad user but pass the desktop
// reorder test.
const DATABASE_URL = process.env.DATABASE_URL;
if (!DATABASE_URL) {
@@ -48,30 +52,6 @@ async function loginViaUi(page, username, password) {
]);
}
// Wipe any persisted schedule UI prefs so this test starts from the
// same baseline regardless of what previous runs (or other specs that
// share the demo `admin` user) left behind. Must be called after
// login because the edit-mode key is namespaced by user id.
async function resetSchedulePrefs(page) {
await page.evaluate(() => {
try {
const keys = Object.keys(window.localStorage);
for (const k of keys) {
if (
k.startsWith("em-schedule-edit-mode-v1") ||
k === "em-schedule-cols-v1" ||
k === "em-schedule-row-colors-v1" ||
k === "em-current-meeting-highlight-v1"
) {
window.localStorage.removeItem(k);
}
}
} catch {
/* ignore */
}
});
}
async function setLangEn(page) {
await page.addInitScript(() => {
try {
@@ -82,29 +62,47 @@ async function setLangEn(page) {
});
}
async function nextDailyNumberForDate(date) {
const { rows } = await pool.query(
`SELECT COALESCE(MAX(daily_number), 0) + 1 AS n
FROM executive_meetings
WHERE meeting_date = $1`,
[date],
);
return rows[0].n;
}
async function insertMeeting({
meetingDate,
dailyNumber,
titleEn,
titleAr,
startTime,
endTime,
}) {
const dn = await nextDailyNumberForDate(meetingDate);
const { rows } = await pool.query(
`INSERT INTO executive_meetings
(daily_number, title_ar, title_en, meeting_date, start_time, end_time, status)
VALUES ($1, $2, $3, $4, $5, $6, 'scheduled')
RETURNING id`,
[dailyNumber, titleAr, titleEn, meetingDate, startTime, endTime],
[dn, titleAr, titleEn, meetingDate, startTime, endTime],
);
const id = rows[0].id;
createdMeetingIds.push(id);
return id;
}
async function readMeeting(id) {
const { rows } = await pool.query(
`SELECT title_en, start_time, end_time, daily_number
FROM executive_meetings WHERE id = $1`,
[id],
);
return rows[0];
}
// Pick a meeting date that won't collide with real schedule data OR
// with stale rows leftover from a previously-failed run. Mirrors the
// jittering strategy used by the desktop schedule spec.
// with stale rows leftover from a previously-failed run.
const RANDOM_DATE_BASE_DAYS =
365 + Math.floor(Math.random() * 1000) + ((Date.now() / 1000) | 0) % 500;
function uniqueFutureDate(offsetDays) {
@@ -131,7 +129,7 @@ test.afterAll(async () => {
await pool.end();
});
test("Touch: long-press on the row grip and drag down past the next row reorders via the TouchSensor (iPad)", async ({
test("Touch (iPad): long-press on a row and drag it down rotates content via TouchSensor without edit mode", async ({
page,
}) => {
await setLangEn(page);
@@ -142,7 +140,6 @@ test("Touch: long-press on the row grip and drag down past the next row reorders
.slice(2, 6)}`;
const idA = await insertMeeting({
meetingDate: date,
dailyNumber: 1,
titleEn: `Touch Drag A ${uniq}`,
titleAr: `سحب لمسي أ ${uniq}`,
startTime: "09:00:00",
@@ -150,7 +147,6 @@ test("Touch: long-press on the row grip and drag down past the next row reorders
});
const idB = await insertMeeting({
meetingDate: date,
dailyNumber: 2,
titleEn: `Touch Drag B ${uniq}`,
titleAr: `سحب لمسي ب ${uniq}`,
startTime: "11:00:00",
@@ -159,66 +155,65 @@ test("Touch: long-press on the row grip and drag down past the next row reorders
await loginViaUi(page, "admin", "admin123");
await page.goto("/executive-meetings");
await resetSchedulePrefs(page);
// #490: explicitly clear any persisted edit-mode pref left by other
// specs that share the demo `admin` user, then reload — the spec
// regression we're guarding is "drag works on iPad WITHOUT entering
// edit mode", so we must start from a known view-mode baseline.
await page.evaluate(() => {
try {
const keys = Object.keys(window.localStorage);
for (const k of keys) {
if (k.startsWith("em-schedule-edit-mode-v1")) {
window.localStorage.removeItem(k);
}
}
} catch {
/* ignore */
}
});
await page.reload();
// Jump to the seeded date.
await page.locator('input[type="date"]').first().fill(date);
await expect(page.getByTestId(`em-row-${idA}`)).toBeVisible({
timeout: 10_000,
});
await expect(page.getByTestId(`em-row-${idB}`)).toBeVisible();
// Sanity: the edit-mode toggle must read OFF — drag should still work.
const editToggle = page.getByTestId("em-edit-mode-toggle");
if (await editToggle.count()) {
await expect(editToggle).toHaveAttribute("aria-pressed", "false");
}
// Edit mode is required for the grip handle to render.
const toggle = page.getByTestId("em-edit-mode-toggle");
await toggle.click();
await expect(toggle).toHaveAttribute("aria-pressed", "true");
const gripA = page.getByTestId(`em-row-grip-${idA}`);
const rowA = page.getByTestId(`em-row-${idA}`);
const rowB = page.getByTestId(`em-row-${idB}`);
await expect(gripA).toBeVisible();
await expect(rowA).toBeVisible({ timeout: 10_000 });
await expect(rowB).toBeVisible();
// Scroll the grip into view BEFORE measuring bounding boxes — on the
// narrow iPad viewport the grip can sit above the fold, and any
// post-measurement scroll would invalidate the coordinates we use to
// dispatch touch events.
await gripA.scrollIntoViewIfNeeded();
const gripBox = await gripA.boundingBox();
// #489 + #490: the dedicated grip handle is gone — drag from the
// # cell (first <td>) which contains a plain <span> with the daily
// number and no skip-listed children. Drag works in view mode (no
// edit toggle required).
const numberCellA = rowA.locator("td").first();
await numberCellA.scrollIntoViewIfNeeded();
const numBox = await numberCellA.boundingBox();
const targetBox = await rowB.boundingBox();
expect(gripBox).not.toBeNull();
expect(numBox).not.toBeNull();
expect(targetBox).not.toBeNull();
const startX = gripBox.x + gripBox.width / 2;
const startY = gripBox.y + gripBox.height / 2;
const startX = numBox.x + numBox.width / 2;
const startY = numBox.y + numBox.height / 2;
const endX = startX;
// Aim BELOW the second row's vertical center so dnd-kit places A
// after B and the resulting order becomes [B, A].
const endX = targetBox.x + targetBox.width / 2;
// after B and the rotated content order becomes [B, A].
const endY = targetBox.y + targetBox.height - 4;
// We drive touches through CDP because Playwright's high-level
// Drive touches through CDP because Playwright's high-level
// `page.touchscreen` API only exposes `tap()` — no long-press +
// drag primitive — and dnd-kit's TouchSensor specifically listens
// for native touchstart/touchmove/touchend events.
const cdp = await page.context().newCDPSession(page);
const reorderRequestPromise = page.waitForRequest(
(req) => {
const u = new URL(req.url());
return (
u.pathname === "/api/executive-meetings/reorder" &&
req.method() === "POST"
);
},
{ timeout: 15_000 },
);
const reorderResponsePromise = page.waitForResponse(
const rotatePromise = page.waitForResponse(
(resp) => {
const u = new URL(resp.url());
return (
u.pathname === "/api/executive-meetings/reorder" &&
u.pathname === "/api/executive-meetings/rotate-content" &&
resp.request().method() === "POST" &&
resp.ok()
);
@@ -226,21 +221,20 @@ test("Touch: long-press on the row grip and drag down past the next row reorders
{ timeout: 15_000 },
);
// 1. Press down on the grip and HOLD without moving so the
// TouchSensor's 200ms activation delay can elapse. We deliberately
// wait > 250ms (the task's spec) and well above dnd-kit's 200ms
// threshold to avoid timing flakiness on slow CI machines.
// 1. Press down and HOLD without moving so the TouchSensor's 200ms
// activation delay can elapse. We wait > 250ms (well above the
// threshold) to avoid timing flakiness on slow CI machines, and
// stay inside the 8px tolerance window which is what tells the
// TouchSensor "this is a long-press, not a scroll".
await cdp.send("Input.dispatchTouchEvent", {
type: "touchStart",
touchPoints: [{ x: startX, y: startY, id: 1 }],
});
// Hold still — staying inside the 8px tolerance window is what tells
// the TouchSensor "this is a long-press, not a scroll".
await page.waitForTimeout(350);
// 2. Now drag down toward the target row in several intermediate
// steps so dnd-kit sees enough touchmove events to compute the
// overlap with the row below and emit a sortable change.
// 2. Drag down toward the target row in several intermediate steps
// so dnd-kit sees enough touchmove events to compute the overlap
// with the row below and emit a sortable change.
const steps = 12;
for (let i = 1; i <= steps; i++) {
const x = startX + ((endX - startX) * i) / steps;
@@ -249,8 +243,6 @@ test("Touch: long-press on the row grip and drag down past the next row reorders
type: "touchMove",
touchPoints: [{ x, y, id: 1 }],
});
// A short wait between moves lets React/dnd-kit run their
// collision-detection ticks between samples.
await page.waitForTimeout(20);
}
@@ -260,70 +252,29 @@ test("Touch: long-press on the row grip and drag down past the next row reorders
touchPoints: [],
});
// The reorder POST must fire AND succeed.
const reorderRequest = await reorderRequestPromise;
await reorderResponsePromise;
await rotatePromise;
// Inspect the request body to confirm the client sent the new
// order (B before A) — this is what proves the TouchSensor
// actually activated and dnd-kit re-emitted onDragEnd. A no-op
// drag would either send no request or send the original order.
const requestPayload = reorderRequest.postDataJSON();
expect(requestPayload).toMatchObject({ meetingDate: date });
expect(Array.isArray(requestPayload.orderedIds)).toBe(true);
expect(requestPayload.orderedIds).toEqual([idB, idA]);
// Wait for the rotation to land in the DB. After dragging A (was
// 09:00) below B, A should carry slot[2] (11:00) and B should carry
// slot[1] (09:00) — content rotates while time/daily-number slots
// stay anchored.
await expect
.poll(async () => (await readMeeting(idA)).start_time, {
timeout: 10_000,
})
.toBe("11:00:00");
// The DB must reflect the swap: B owns daily_number 1 and the
// earlier 09:00-10:00 slot, A drops to 2 and the 11:00-12:00 slot.
// This mirrors the assertion in the desktop reorder test and
// catches any client-side-only "looks reordered" regression.
const { rows } = await pool.query(
`SELECT id, daily_number, start_time, end_time
FROM executive_meetings WHERE id = ANY($1::int[]) ORDER BY id`,
[[idA, idB]],
);
const byId = Object.fromEntries(rows.map((r) => [r.id, r]));
expect(byId[idB].daily_number).toBe(1);
expect(String(byId[idB].start_time)).toBe("09:00:00");
expect(String(byId[idB].end_time)).toBe("10:00:00");
expect(byId[idA].daily_number).toBe(2);
expect(String(byId[idA].start_time)).toBe("11:00:00");
expect(String(byId[idA].end_time)).toBe("12:00:00");
// The DOM order in the visible table must show the swap as well —
// this is the assertion that fails if the touch path silently
// regressed (e.g. someone removed TouchSensor or the touch-action
// CSS, so the gesture turns into a vertical scroll instead of a
// drag).
const orderedIdsAfterDrag = await page
.locator('tr[data-testid^="em-row-"]')
.evaluateAll((els) =>
els
.map((el) => el.getAttribute("data-testid"))
.map((tid) => Number(tid?.replace("em-row-", ""))),
);
const swappedOrder = orderedIdsAfterDrag.filter(
(n) => n === idA || n === idB,
);
expect(swappedOrder).toEqual([idB, idA]);
// Reload — the persisted order must come back the same way, which
// proves the server actually accepted and stored the new sequence
// (not just a transient optimistic update on the client).
await page.reload();
await page.locator('input[type="date"]').first().fill(date);
await expect(page.getByTestId(`em-row-${idA}`)).toBeVisible({
timeout: 10_000,
});
const orderedIdsAfterReload = await page
.locator('tr[data-testid^="em-row-"]')
.evaluateAll((els) =>
els
.map((el) => el.getAttribute("data-testid"))
.map((tid) => Number(tid?.replace("em-row-", ""))),
);
const persistedOrder = orderedIdsAfterReload.filter(
(n) => n === idA || n === idB,
);
expect(persistedOrder).toEqual([idB, idA]);
const after = {
A: await readMeeting(idA),
B: await readMeeting(idB),
};
expect(after.A.title_en).toBe(`Touch Drag A ${uniq}`);
expect(after.B.title_en).toBe(`Touch Drag B ${uniq}`);
expect(String(after.A.start_time)).toBe("11:00:00");
expect(String(after.A.end_time)).toBe("12:00:00");
expect(String(after.B.start_time)).toBe("09:00:00");
expect(String(after.B.end_time)).toBe("10:00:00");
// Daily numbers stay anchored chronologically: B (now earlier) keeps
// the smaller dn, A (now later) the larger.
expect(after.B.daily_number).toBeLessThan(after.A.daily_number);
});