27c5cc381c
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.
192 lines
6.7 KiB
JavaScript
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);
|
|
});
|