Files
TX/artifacts/tx-os/tests/executive-meetings-touch-reorder.spec.mjs
T
riyadhafraa c7168a959b 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.
2026-05-11 12:58:17 +00:00

281 lines
9.4 KiB
JavaScript

import { test, expect } from "@playwright/test";
import pg from "pg";
// #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) {
throw new Error(
"DATABASE_URL must be set to run the executive-meetings touch-reorder UI test",
);
}
const pool = new pg.Pool({ connectionString: DATABASE_URL });
const createdMeetingIds = [];
// Configure this whole spec to use an iPad-shaped, touch-enabled
// browser context. `isMobile` enables Chromium's mobile emulation
// (which makes the browser actually emit TouchEvents in addition to
// pointer/mouse), and `hasTouch` enables the touch input pipeline so
// CDP `Input.dispatchTouchEvent` calls are honored. Both are required
// for dnd-kit's TouchSensor to receive `touchstart`/`touchmove`/
// `touchend` and activate on long-press.
test.use({
viewport: { width: 768, height: 1024 },
hasTouch: true,
isMobile: true,
});
async function loginViaUi(page, username, password) {
await page.goto("/login");
await page.locator("#username").fill(username);
await page.locator("#password").fill(password);
await Promise.all([
page.waitForURL((url) => !url.pathname.endsWith("/login"), {
timeout: 15_000,
}),
page.locator('form button[type="submit"]').click(),
]);
}
async function setLangEn(page) {
await page.addInitScript(() => {
try {
window.localStorage.setItem("tx-lang", "en");
} catch {
/* localStorage may not be ready before navigation */
}
});
}
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,
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`,
[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.
const RANDOM_DATE_BASE_DAYS =
365 + Math.floor(Math.random() * 1000) + ((Date.now() / 1000) | 0) % 500;
function uniqueFutureDate(offsetDays) {
const d = new Date();
d.setUTCDate(d.getUTCDate() + RANDOM_DATE_BASE_DAYS + offsetDays * 7);
return d.toISOString().slice(0, 10);
}
test.afterAll(async () => {
if (createdMeetingIds.length > 0) {
await pool.query(
`DELETE FROM executive_meeting_attendees WHERE meeting_id = ANY($1::int[])`,
[createdMeetingIds],
);
await pool.query(
`DELETE FROM executive_meeting_audit_logs WHERE entity_type = 'meeting' AND entity_id = ANY($1::int[])`,
[createdMeetingIds],
);
await pool.query(
`DELETE FROM executive_meetings WHERE id = ANY($1::int[])`,
[createdMeetingIds],
);
}
await pool.end();
});
test("Touch (iPad): long-press on a row and drag it down rotates content via TouchSensor without edit mode", async ({
page,
}) => {
await setLangEn(page);
const date = uniqueFutureDate(1);
const uniq = `${Date.now().toString(36)}_${Math.random()
.toString(36)
.slice(2, 6)}`;
const idA = await insertMeeting({
meetingDate: date,
titleEn: `Touch Drag A ${uniq}`,
titleAr: `سحب لمسي أ ${uniq}`,
startTime: "09:00:00",
endTime: "10:00:00",
});
const idB = await insertMeeting({
meetingDate: date,
titleEn: `Touch Drag B ${uniq}`,
titleAr: `سحب لمسي ب ${uniq}`,
startTime: "11:00:00",
endTime: "12:00:00",
});
await loginViaUi(page, "admin", "admin123");
await page.goto("/executive-meetings");
// #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();
await page.locator('input[type="date"]').first().fill(date);
// 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");
}
const rowA = page.getByTestId(`em-row-${idA}`);
const rowB = page.getByTestId(`em-row-${idB}`);
await expect(rowA).toBeVisible({ timeout: 10_000 });
await expect(rowB).toBeVisible();
// #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(numBox).not.toBeNull();
expect(targetBox).not.toBeNull();
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 rotated content order becomes [B, A].
const endY = targetBox.y + targetBox.height - 4;
// 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 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: 15_000 },
);
// 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 }],
});
await page.waitForTimeout(350);
// 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;
const y = startY + ((endY - startY) * i) / steps;
await cdp.send("Input.dispatchTouchEvent", {
type: "touchMove",
touchPoints: [{ x, y, id: 1 }],
});
await page.waitForTimeout(20);
}
// 3. Release.
await cdp.send("Input.dispatchTouchEvent", {
type: "touchEnd",
touchPoints: [],
});
await rotatePromise;
// 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");
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);
});