Files
TX/artifacts/tx-os/tests/executive-meetings-row-touch-press-feedback.spec.mjs
T
Riyadh bd369cf636 Task #499: fix Quick Actions dialog X overlap + iPad long-press drag feedback
Two scoped UX fixes on Executive Meetings schedule, both confined to
artifacts/tx-os/src/pages/executive-meetings.tsx (shared
src/components/ui/dialog.tsx untouched per task spec).

1) Quick Actions dialog header overlap
   - Radix DialogContent renders the Close (X) at physical
     `right-4 top-4` regardless of `dir`. In AR (RTL) the
     `text-right`-aligned title "إجراءات سريعة" pinned to the same
     edge and visibly overlapped the X.
   - Fix: added `pr-8` to the per-row Quick Actions DialogHeader so
     the title's right edge always clears the close button. Works
     for AR (overlap eliminated) and EN (just adds trailing slack).

2) iPad long-press drag feedback
   - dnd-kit's TouchSensor has a 200ms activation delay; previously
     the row gave no cue during the hold window so users lifted
     early thinking the schedule didn't register.
   - Fix: added `isPressing` state toggled via capture-phase
     onPointerDown/Up/Cancel handlers on the row tr, gated on
     `pointerType === "touch"` && `effectiveDragEnabled`. Capture
     phase chosen so handlers don't collide with safeRowDragListeners
     (which spreads dnd-kit's bubble-phase onPointerDown).
   - Visual cue: subtle `inset 0 0 0 2px hexToRgba(highlightColor,
     0.45)` ring composed AFTER quickOpen/selection/current rings so
     it never outranks a real selection state. Per spec, the cue
     appears during the 200ms hold AND continues throughout the
     active drag (we deliberately do NOT clear isPressing on the
     rising edge of isDragging). Two defensive useEffects clear
     `isPressing`: one when drag becomes disabled (capability flip /
     edit-mode toggle), one tied to the FALLING edge of isDragging
     (drag just ended) as a belt-and-suspenders guard against stale
     visual state when dnd-kit absorbs the touch sequence and our
     row's own pointerup never fires.
   - data-pressing="true" attribute exposed on tr for tests.
   - TouchSensor delay (200ms) and tolerance (8px) unchanged.

Tests added:
 - executive-meetings-quick-actions-dialog-layout.spec.mjs: asserts
   close button + title bounding boxes don't overlap in AR + EN.
 - executive-meetings-row-touch-press-feedback.spec.mjs: uses
   hasTouch+isMobile + CDP Input.dispatchTouchEvent, asserts
   data-pressing flips true on touchstart, clears on touchend, and
   tap-to-open quick-actions dialog still works for short taps;
   second test verifies the cue persists past the 200ms window.

Verification: pnpm tsc --noEmit clean. Pre-existing `test` workflow
failures (rotate-content/font-settings/notes-share) are unrelated to
this change.
2026-05-11 15:43:30 +00:00

313 lines
11 KiB
JavaScript

// #499: e2e for the iPad long-press visual feedback. dnd-kit's
// TouchSensor has a 200ms activation delay before a touch becomes a
// drag. Before #499, the row gave NO visual cue during that window —
// users would tap-and-hold a meeting to drag it but, seeing nothing
// happen, lift their finger early thinking the schedule didn't
// register the touch. The fix paints a subtle inset ring on the row
// while a touch pointer is pressed, gated on `pointerType === "touch"`
// so mouse / pen pointers are unaffected. We expose the press state
// as `data-pressing="true"` on the row tr for deterministic testing.
//
// We use Chromium mobile emulation (`isMobile` + `hasTouch`) so the
// browser actually emits TouchEvents and PointerEvents with
// `pointerType === "touch"`, then drive presses through CDP because
// Playwright's high-level touchscreen API only exposes `tap()`.
import { test, expect } from "@playwright/test";
import pg from "pg";
const DATABASE_URL = process.env.DATABASE_URL;
if (!DATABASE_URL) {
throw new Error(
"DATABASE_URL must be set to run the touch press-feedback test",
);
}
const pool = new pg.Pool({ connectionString: DATABASE_URL });
const createdMeetingIds = [];
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 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, 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, $2, $3, $4, $5, 'scheduled')
RETURNING id`,
[dn, titleEn, meetingDate, startTime, endTime],
);
const id = rows[0].id;
createdMeetingIds.push(id);
return id;
}
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): pressing a row paints data-pressing=true within the 200ms TouchSensor delay, clears on release, and tap-to-open dialog still works", async ({
page,
}) => {
await page.addInitScript(() => {
try {
window.localStorage.setItem("tx-lang", "en");
} catch {
/* ignore */
}
});
const date = uniqueFutureDate(1);
const id = await insertMeeting({
meetingDate: date,
titleEn: `Touch Press Feedback ${Date.now().toString(36)}`,
startTime: "09:00:00",
endTime: "10:00:00",
});
await loginViaUi(page, "admin", "admin123");
await page.goto("/executive-meetings");
await page.locator('input[type="date"]').first().fill(date);
const row = page.getByTestId(`em-row-${id}`);
await expect(row).toBeVisible({ timeout: 10_000 });
// Baseline: not pressed.
await expect(row).not.toHaveAttribute("data-pressing", "true");
const numberCell = row.locator("td").first();
await numberCell.scrollIntoViewIfNeeded();
const box = await numberCell.boundingBox();
expect(box).not.toBeNull();
const x = box.x + box.width / 2;
const y = box.y + box.height / 2;
const cdp = await page.context().newCDPSession(page);
// 1. Touch down — DON'T move (stay inside the 8px tolerance window).
// The press cue should appear immediately on pointerdown, well
// inside the TouchSensor's 200ms activation delay.
await cdp.send("Input.dispatchTouchEvent", {
type: "touchStart",
touchPoints: [{ x, y, id: 1 }],
});
// Allow React to flush the state update from the capture-phase
// pointerdown handler. Should be effectively immediate, but we
// give 250ms upper bound so the assertion is robust on slow CI.
await expect(row).toHaveAttribute("data-pressing", "true", {
timeout: 250,
});
// 2. Release WITHOUT dragging — the cue must clear immediately and
// the tap-to-open quick-actions dialog must still fire (since
// the touch was a tap, not a long-press-into-drag).
await cdp.send("Input.dispatchTouchEvent", {
type: "touchEnd",
touchPoints: [],
});
await expect(row).not.toHaveAttribute("data-pressing", "true", {
timeout: 500,
});
// The original press WAS shorter than the 200ms drag-activation
// delay (no waitForTimeout between touchStart and touchEnd above),
// so dnd-kit treats it as a plain tap and the row's onClick fires
// on touchend → quick-actions dialog opens.
const surface = page.locator(`[data-testid="em-row-quick-${id}"]`);
await expect(surface).toBeVisible({ timeout: 5_000 });
});
test("Touch (iPad): drag activation + drop reliably clears data-pressing (no stale ring after drag)", async ({
page,
}) => {
// #499 review: dnd-kit owns the touch sequence once a drag
// activates and on some release paths the row's own pointerup
// does not fire. The MeetingRow has a defensive effect that
// watches `isDragging` and force-clears `isPressing` when drag
// activates / ends — this test guards that path: long-press
// (drag activates), move (drag in progress), release (drag ends),
// assert `data-pressing` is cleared after the drop.
await page.addInitScript(() => {
try {
window.localStorage.setItem("tx-lang", "en");
} catch {
/* ignore */
}
});
const date = uniqueFutureDate(3);
const idA = await insertMeeting({
meetingDate: date,
titleEn: `Touch Drag Cleanup A ${Date.now().toString(36)}`,
startTime: "13:00:00",
endTime: "14:00:00",
});
const idB = await insertMeeting({
meetingDate: date,
titleEn: `Touch Drag Cleanup B ${Date.now().toString(36)}`,
startTime: "15:00:00",
endTime: "16:00:00",
});
await loginViaUi(page, "admin", "admin123");
await page.goto("/executive-meetings");
await page.locator('input[type="date"]').first().fill(date);
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();
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 endY = targetBox.y + targetBox.height - 4;
const cdp = await page.context().newCDPSession(page);
await cdp.send("Input.dispatchTouchEvent", {
type: "touchStart",
touchPoints: [{ x: startX, y: startY, id: 1 }],
});
// Hold past 200ms TouchSensor activation delay.
await page.waitForTimeout(350);
// Per task spec, the press cue must remain visible AFTER drag
// activates (not just during the pre-drag hold). Sanity-check the
// attribute is still set right before we start dragging.
await expect(rowA).toHaveAttribute("data-pressing", "true");
// Drag down toward rowB.
const steps = 12;
for (let i = 1; i <= steps; i++) {
const y = startY + ((endY - startY) * i) / steps;
await cdp.send("Input.dispatchTouchEvent", {
type: "touchMove",
touchPoints: [{ x: startX, y, id: 1 }],
});
await page.waitForTimeout(20);
}
// Mid-drag: cue must STILL be visible — feedback continues
// throughout the active drag, not just during the activation
// delay. This is the explicit "continues while drag is active"
// requirement from #499.
await expect(rowA).toHaveAttribute("data-pressing", "true");
// Release — drag ends. Whether the row's own pointerup fires or
// dnd-kit cancels it, the `isDragging` cleanup effect must clear
// `data-pressing`.
await cdp.send("Input.dispatchTouchEvent", {
type: "touchEnd",
touchPoints: [],
});
// Both rows should settle without any lingering pressing state.
await expect(rowA).not.toHaveAttribute("data-pressing", "true", {
timeout: 1_000,
});
await expect(rowB).not.toHaveAttribute("data-pressing", "true", {
timeout: 1_000,
});
});
test("Touch (iPad): press cue persists during the long-press hold (>200ms TouchSensor delay)", async ({
page,
}) => {
await page.addInitScript(() => {
try {
window.localStorage.setItem("tx-lang", "en");
} catch {
/* ignore */
}
});
const date = uniqueFutureDate(2);
const id = await insertMeeting({
meetingDate: date,
titleEn: `Touch Press Hold ${Date.now().toString(36)}`,
startTime: "11:00:00",
endTime: "12:00:00",
});
await loginViaUi(page, "admin", "admin123");
await page.goto("/executive-meetings");
await page.locator('input[type="date"]').first().fill(date);
const row = page.getByTestId(`em-row-${id}`);
await expect(row).toBeVisible({ timeout: 10_000 });
const numberCell = row.locator("td").first();
await numberCell.scrollIntoViewIfNeeded();
const box = await numberCell.boundingBox();
expect(box).not.toBeNull();
const x = box.x + box.width / 2;
const y = box.y + box.height / 2;
const cdp = await page.context().newCDPSession(page);
await cdp.send("Input.dispatchTouchEvent", {
type: "touchStart",
touchPoints: [{ x, y, id: 1 }],
});
// Hold past the 200ms TouchSensor activation delay — the cue must
// still be on the row (this is the whole point of the fix: feedback
// during the hold window so users don't lift early).
await page.waitForTimeout(350);
await expect(row).toHaveAttribute("data-pressing", "true");
// Release — cue clears.
await cdp.send("Input.dispatchTouchEvent", {
type: "touchEnd",
touchPoints: [],
});
await expect(row).not.toHaveAttribute("data-pressing", "true", {
timeout: 500,
});
});