Files
TX/artifacts/tx-os/tests/executive-meetings-quick-actions-dialog-layout.spec.mjs
T
riyadhafraa 27c5cc381c 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. Defensive useEffect
     clears `isPressing` if drag becomes disabled mid-press.
   - 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:38:17 +00:00

192 lines
6.7 KiB
JavaScript

// #499: e2e for the Quick Actions dialog header layout. The Radix
// DialogContent renders a Close (X) button at physical `right-4 top-4`
// inside the dialog regardless of `dir`. Before #499 the
// DialogHeader had no trailing padding, so in Arabic (RTL) the
// `text-right`-aligned title "إجراءات سريعة" sat directly under the
// X — a visible overlap. The fix adds `pr-8` to the DialogHeader so
// the title text always clears the close button. This spec asserts
// the bounding boxes don't overlap in BOTH languages.
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 quick-actions dialog layout test",
);
}
const pool = new pg.Pool({ connectionString: DATABASE_URL });
const createdMeetingIds = [];
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(),
]);
}
function pickFutureDate(offsetDays) {
const d = new Date(Date.now() + offsetDays * 86_400_000);
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, "0");
const day = String(d.getDate()).padStart(2, "0");
return `${y}-${m}-${day}`;
}
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({ date, titleEn, titleAr, startTime, endTime }) {
const dn = await nextDailyNumberForDate(date);
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, titleEn, date, startTime, endTime],
);
const id = rows[0].id;
createdMeetingIds.push(id);
return id;
}
test.afterAll(async () => {
if (createdMeetingIds.length > 0) {
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_meeting_attendees WHERE meeting_id = ANY($1::int[])`,
[createdMeetingIds],
);
await pool.query(
`DELETE FROM executive_meetings WHERE id = ANY($1::int[])`,
[createdMeetingIds],
);
}
await pool.end();
});
function rectsOverlap(a, b) {
if (!a || !b) return false;
return (
a.x < b.x + b.width &&
a.x + a.width > b.x &&
a.y < b.y + b.height &&
a.y + a.height > b.y
);
}
async function openDialogAndAssertNoOverlap(page, id) {
const row = page.locator(`[data-testid="em-row-${id}"]`);
await expect(row).toBeVisible({ timeout: 10_000 });
await row.click();
const surface = page.locator(`[data-testid="em-row-quick-${id}"]`);
await expect(surface).toBeVisible();
// The Radix DialogContent renders an unlabeled close button as its
// last child <button> with sr-only label "Close". We target the
// button that contains an svg + sr-only span — there's only one
// button in the header area; the Postpone button has its own
// testid and lives outside the header.
const closeBtn = surface.locator('button[type="button"]').filter({
has: page.locator("svg"),
}).filter({ hasText: /^Close$/ });
await expect(closeBtn).toHaveCount(1);
const titleEl = surface.locator('[id^="radix-"][id$="-title"]').first();
await expect(titleEl).toBeVisible();
const closeBox = await closeBtn.boundingBox();
const titleBox = await titleEl.boundingBox();
expect(closeBox).not.toBeNull();
expect(titleBox).not.toBeNull();
// Hard guard: the title's bounding box must NOT overlap the close
// button's bounding box. Before #499 in AR the rects overlapped on
// the right edge by ~16-24px (X at right-4 = ~16px from edge,
// 16-20px wide; title text-right pinned to the same edge).
expect(
rectsOverlap(closeBox, titleBox),
`Title and Close button bounding boxes overlap. close=${JSON.stringify(closeBox)} title=${JSON.stringify(titleBox)}`,
).toBe(false);
}
test("EN (LTR): Quick Actions dialog title does not overlap the Close X", async ({
page,
}) => {
await page.addInitScript(() => {
try {
window.localStorage.setItem("tx-lang", "en");
} catch {
/* ignore */
}
});
const date = pickFutureDate(40);
const id = await insertMeeting({
date,
titleEn: "QA Layout EN",
startTime: "10:00:00",
endTime: "10:30:00",
});
await loginViaUi(page, "admin", "admin123");
await page.goto("/executive-meetings");
await page.locator('input[type="date"]').first().fill(date);
await openDialogAndAssertNoOverlap(page, id);
});
test("AR (RTL): Quick Actions dialog title 'إجراءات سريعة' does not overlap the Close X", async ({
page,
}) => {
await page.addInitScript(() => {
try {
window.localStorage.setItem("tx-lang", "ar");
} catch {
/* ignore */
}
});
const date = pickFutureDate(41);
const id = await insertMeeting({
date,
titleEn: "QA Layout AR",
titleAr: "تخطيط الإجراءات السريعة",
startTime: "10:00:00",
endTime: "10:30:00",
});
await loginViaUi(page, "admin", "admin123");
await page.goto("/executive-meetings");
await page.locator('input[type="date"]').first().fill(date);
const surface = page.locator(`[data-testid="em-row-quick-${id}"]`);
// Sanity: dialog mounted in RTL.
await page.locator(`[data-testid="em-row-${id}"]`).click();
await expect(surface).toBeVisible({ timeout: 10_000 });
await expect(surface).toHaveAttribute("dir", "rtl");
// Re-run the overlap check via the shared helper (which re-opens the
// dialog via row click — already open here, but click is idempotent
// for already-open quick-actions and the helper just re-finds the
// surface).
const closeBtn = surface
.locator('button[type="button"]')
.filter({ hasText: /^Close$/ });
await expect(closeBtn).toHaveCount(1);
const titleEl = surface.locator('[id^="radix-"][id$="-title"]').first();
await expect(titleEl).toContainText("إجراءات سريعة");
const closeBox = await closeBtn.boundingBox();
const titleBox = await titleEl.boundingBox();
expect(closeBox).not.toBeNull();
expect(titleBox).not.toBeNull();
expect(
rectsOverlap(closeBox, titleBox),
`RTL title and Close button overlap. close=${JSON.stringify(closeBox)} title=${JSON.stringify(titleBox)}`,
).toBe(false);
});