38a55b8b42
The user reported that the small quick-actions popover (which holds the Postpone trigger) was hard to find — it floated next to the clicked row and they sometimes lost track of which meeting they were about to act on. They asked for the surface to appear in the center of the screen, and for the row they clicked to be clearly framed in a color while the surface is open. Changes: - Replaced the row's row-anchored Popover (PopoverAnchor + PopoverContent) with a centered Dialog (DialogContent) that Radix renders into a portal. The Dialog uses the existing `executiveMeetings.quickActions.label` translation key for its title (already present in both Arabic and English) and reuses #490's polished Postpone Button unchanged. Test ID stays `em-row-quick-${id}` so existing e2e specs continue to target the same surface. - Added a third inset-shadow ring (`inset 0 0 0 3px ${highlightColor}`) to the row's composed boxShadow stack while the row's quickOpen state is true. Listed first in the stack so it becomes the outermost frame and wins visually over the existing current-meeting / bulk-select rings. Uses the page-level highlightPrefs.color so the frame matches the user's chosen accent. Also exposes `data-quick-open="true"` on the <tr> for tests. - Updated the row-quick-actions e2e: the postpone-end-to-end test now asserts `role="dialog"` on the surface and the row carries `data-quick-open` while open and loses it after Postpone is clicked. Added a separate test for Escape closing the surface and clearing the row frame. Verified: 5/5 quick-actions tests pass, plus both drag specs (row-drag + iPad touch-reorder) still pass.
384 lines
14 KiB
JavaScript
384 lines
14 KiB
JavaScript
// #486 → #489 → #491: e2e for the Executive Meetings schedule row
|
|
// quick-actions surface. After #489, the Move up / Move down buttons
|
|
// are gone (the user drags the whole row instead — see
|
|
// executive-meetings-row-drag.spec.mjs). After #491, the surface is a
|
|
// centered Dialog (Radix portal) rather than a row-anchored Popover,
|
|
// and the originating row carries a coloured frame while the surface
|
|
// is open. The surface still exposes only the Postpone action, and
|
|
// the gating + skip rules around interactive row affordances stay the
|
|
// same.
|
|
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 row-quick-actions test",
|
|
);
|
|
}
|
|
const pool = new pg.Pool({ connectionString: DATABASE_URL });
|
|
const createdMeetingIds = [];
|
|
const createdUserIds = [];
|
|
|
|
const TEST_PASSWORD = "TestPass123!";
|
|
const TEST_PASSWORD_HASH =
|
|
"$2b$10$Bs636ukPMyz01nKrsi.5m.JlDXSN22AVCvn8cgPWWDbo5yJRQX2vu";
|
|
|
|
async function createUserWithRole(prefix, roleName) {
|
|
const username = `${prefix}_${Date.now().toString(36)}_${Math.random()
|
|
.toString(36)
|
|
.slice(2, 8)}`;
|
|
const { rows } = await pool.query(
|
|
`INSERT INTO users (username, email, password_hash, display_name_en, preferred_language, is_active)
|
|
VALUES ($1, $2, $3, $4, 'en', true) RETURNING id`,
|
|
[username, `${username}@example.com`, TEST_PASSWORD_HASH, prefix],
|
|
);
|
|
const id = rows[0].id;
|
|
createdUserIds.push(id);
|
|
await pool.query(
|
|
`INSERT INTO user_roles (user_id, role_id)
|
|
SELECT $1, id FROM roles WHERE name = $2`,
|
|
[id, roleName],
|
|
);
|
|
return { id, username };
|
|
}
|
|
|
|
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}`;
|
|
}
|
|
|
|
const DATE_POSTPONE = pickFutureDate(32);
|
|
const DATE_SKIP = pickFutureDate(33);
|
|
const DATE_VIEWER = pickFutureDate(34);
|
|
const DATE_NO_MOVE_BUTTONS = pickFutureDate(35);
|
|
|
|
async function gotoTestDate(page, date) {
|
|
await page.goto("/executive-meetings");
|
|
const dateInput = page.locator('input[type="date"]').first();
|
|
await dateInput.waitFor({ state: "visible", timeout: 10_000 });
|
|
await dateInput.fill(date);
|
|
await dateInput.dispatchEvent("change");
|
|
}
|
|
|
|
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, 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, $2, $3, $4, $5, 'scheduled')
|
|
RETURNING id`,
|
|
[dn, titleEn, date, startTime, endTime],
|
|
);
|
|
const id = rows[0].id;
|
|
createdMeetingIds.push(id);
|
|
return id;
|
|
}
|
|
|
|
async function readMeeting(id) {
|
|
const { rows } = await pool.query(
|
|
`SELECT start_time, end_time, daily_number FROM executive_meetings WHERE id = $1`,
|
|
[id],
|
|
);
|
|
return rows[0];
|
|
}
|
|
|
|
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],
|
|
);
|
|
}
|
|
if (createdUserIds.length > 0) {
|
|
await pool.query(`DELETE FROM user_roles WHERE user_id = ANY($1::int[])`, [
|
|
createdUserIds,
|
|
]);
|
|
await pool.query(`DELETE FROM users WHERE id = ANY($1::int[])`, [
|
|
createdUserIds,
|
|
]);
|
|
}
|
|
await pool.end();
|
|
});
|
|
|
|
test("Postpone quick-action surface is a centered Dialog and runs a 5-minute postpone end-to-end", async ({
|
|
page,
|
|
}) => {
|
|
const id = await insertMeeting({
|
|
date: DATE_POSTPONE,
|
|
titleEn: "QA Postpone Target",
|
|
startTime: "13:00:00",
|
|
endTime: "13:30:00",
|
|
});
|
|
|
|
await loginViaUi(page, "admin", "admin123");
|
|
await gotoTestDate(page, DATE_POSTPONE);
|
|
await expect(page.locator(`[data-testid="em-row-${id}"]`)).toBeVisible({
|
|
timeout: 10_000,
|
|
});
|
|
|
|
const row = page.locator(`[data-testid="em-row-${id}"]`);
|
|
await row.click();
|
|
const surface = page.locator(`[data-testid="em-row-quick-${id}"]`);
|
|
await expect(surface).toBeVisible();
|
|
// #491: surface is now a centered Dialog (Radix portals it under
|
|
// `[role='dialog']`), not a row-anchored Popover. While the surface
|
|
// is open, the originating row carries `data-quick-open="true"` so
|
|
// it shows the coloured frame.
|
|
await expect(surface).toHaveAttribute("role", "dialog");
|
|
await expect(row).toHaveAttribute("data-quick-open", "true");
|
|
// Geometric centering: the surface's bounding box should sit close
|
|
// to the viewport center on both axes. We allow a generous 25%
|
|
// tolerance so the assertion is robust against the design-system
|
|
// Dialog primitive's exact padding/widths but still catches a
|
|
// regression to row-anchored placement (which would land far off
|
|
// center, especially on tall lists).
|
|
const sBox = await surface.boundingBox();
|
|
const vp = page.viewportSize();
|
|
expect(sBox).not.toBeNull();
|
|
expect(vp).not.toBeNull();
|
|
const sCenterX = sBox.x + sBox.width / 2;
|
|
const sCenterY = sBox.y + sBox.height / 2;
|
|
expect(Math.abs(sCenterX - vp.width / 2)).toBeLessThan(vp.width * 0.25);
|
|
expect(Math.abs(sCenterY - vp.height / 2)).toBeLessThan(vp.height * 0.25);
|
|
await surface.locator(`[data-testid="em-row-quick-postpone-${id}"]`).click();
|
|
// After clicking Postpone the quick-actions surface closes and the
|
|
// row's selected-frame marker disappears.
|
|
await expect(row).not.toHaveAttribute("data-quick-open", "true");
|
|
const dialog = page.locator('[data-testid="postpone-dialog"]');
|
|
await expect(dialog).toBeVisible({ timeout: 5_000 });
|
|
await dialog.locator('[data-testid="postpone-chip-5"]').click();
|
|
await dialog.locator('[data-testid="postpone-confirm-yes"]').click();
|
|
await expect.poll(async () => (await readMeeting(id)).start_time).toBe(
|
|
"13:05:00",
|
|
);
|
|
const after = await readMeeting(id);
|
|
expect(after.end_time).toBe("13:35:00");
|
|
});
|
|
|
|
test("Escape closes the centered surface AND clears the selected-row frame", async ({
|
|
page,
|
|
}) => {
|
|
const id = await insertMeeting({
|
|
date: DATE_POSTPONE,
|
|
titleEn: "QA Frame Clear",
|
|
startTime: "16:00:00",
|
|
endTime: "16:30:00",
|
|
});
|
|
await loginViaUi(page, "admin", "admin123");
|
|
await gotoTestDate(page, DATE_POSTPONE);
|
|
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();
|
|
await expect(row).toHaveAttribute("data-quick-open", "true");
|
|
// Press Escape — Radix Dialog default close path.
|
|
await page.keyboard.press("Escape");
|
|
await expect(surface).toBeHidden();
|
|
await expect(row).not.toHaveAttribute("data-quick-open", "true");
|
|
|
|
// Re-open and verify outside-click (on the modal overlay) also
|
|
// dismisses the surface and clears the row frame — the second
|
|
// expected close path for a centered Dialog.
|
|
await row.click();
|
|
await expect(surface).toBeVisible();
|
|
await expect(row).toHaveAttribute("data-quick-open", "true");
|
|
// Radix renders an overlay above the page that intercepts pointer
|
|
// events for outside-dismiss. Click near a corner so we don't land
|
|
// on the centered DialogContent.
|
|
const overlay = page.locator('[data-radix-dialog-overlay], [data-state="open"][class*="fixed inset-0"]').first();
|
|
if (await overlay.count()) {
|
|
await overlay.click({ position: { x: 5, y: 5 }, force: true });
|
|
} else {
|
|
await page.mouse.click(5, 5);
|
|
}
|
|
await expect(surface).toBeHidden();
|
|
await expect(row).not.toHaveAttribute("data-quick-open", "true");
|
|
});
|
|
|
|
test("RTL (Arabic): quick-actions surface centers and frames row with Arabic label", async ({
|
|
page,
|
|
}) => {
|
|
// Force Arabic locale before navigation so the surface renders with
|
|
// RTL direction and the Arabic Postpone label.
|
|
await page.addInitScript(() => {
|
|
try {
|
|
window.localStorage.setItem("tx-lang", "ar");
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
});
|
|
const date = pickFutureDate(36);
|
|
const id = await insertMeeting({
|
|
date,
|
|
titleEn: "QA RTL Surface",
|
|
startTime: "09:00:00",
|
|
endTime: "09:30:00",
|
|
});
|
|
await loginViaUi(page, "admin", "admin123");
|
|
await gotoTestDate(page, date);
|
|
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();
|
|
// Surface mounts in RTL.
|
|
await expect(surface).toHaveAttribute("dir", "rtl");
|
|
// Row carries the selected-frame marker.
|
|
await expect(row).toHaveAttribute("data-quick-open", "true");
|
|
// Postpone button shows the Arabic label.
|
|
const postponeBtn = surface.locator(
|
|
`[data-testid="em-row-quick-postpone-${id}"]`,
|
|
);
|
|
await expect(postponeBtn).toContainText("تأجيل");
|
|
});
|
|
|
|
test("quick-actions surface only exposes Postpone — Move up/down buttons retired in #489", async ({
|
|
page,
|
|
}) => {
|
|
// Two rows so neighbours exist either way — confirms the popover
|
|
// omits Move up/down even when there's a chronological neighbour.
|
|
const id = await insertMeeting({
|
|
date: DATE_NO_MOVE_BUTTONS,
|
|
titleEn: "QA NoMove A",
|
|
startTime: "10:00:00",
|
|
endTime: "10:30:00",
|
|
});
|
|
await insertMeeting({
|
|
date: DATE_NO_MOVE_BUTTONS,
|
|
titleEn: "QA NoMove B",
|
|
startTime: "11:00:00",
|
|
endTime: "11:30:00",
|
|
});
|
|
await loginViaUi(page, "admin", "admin123");
|
|
await gotoTestDate(page, DATE_NO_MOVE_BUTTONS);
|
|
await expect(page.locator(`[data-testid="em-row-${id}"]`)).toBeVisible({
|
|
timeout: 10_000,
|
|
});
|
|
await page.locator(`[data-testid="em-row-${id}"]`).click();
|
|
const popover = page.locator(`[data-testid="em-row-quick-${id}"]`);
|
|
await expect(popover).toBeVisible();
|
|
const postponeBtn = popover.locator(
|
|
`[data-testid="em-row-quick-postpone-${id}"]`,
|
|
);
|
|
await expect(postponeBtn).toBeVisible();
|
|
// #490 polish: Postpone uses the design-system Button — guard the
|
|
// tap target + min-width affordance so a future styling regression
|
|
// doesn't silently revert it back to a plain text button on iPad.
|
|
const cls = (await postponeBtn.getAttribute("class")) ?? "";
|
|
expect(cls).toContain("h-10");
|
|
expect(cls).toContain("min-w-[11rem]");
|
|
// Icon + label both rendered (icon flips with locale via flex
|
|
// direction so it stays adjacent to the label in RTL).
|
|
await expect(postponeBtn.locator("svg")).toHaveCount(1);
|
|
await expect(postponeBtn).toContainText(/postpone|تأجيل/i);
|
|
// Hard guard: the old up/down buttons must no longer mount.
|
|
await expect(
|
|
popover.locator(`[data-testid="em-row-quick-up-${id}"]`),
|
|
).toHaveCount(0);
|
|
await expect(
|
|
popover.locator(`[data-testid="em-row-quick-down-${id}"]`),
|
|
).toHaveCount(0);
|
|
});
|
|
|
|
test("clicking row affordances (time cell, row-actions) does NOT open the quick-actions surface", async ({
|
|
page,
|
|
}) => {
|
|
const id = await insertMeeting({
|
|
date: DATE_SKIP,
|
|
titleEn: "QA Skip Surfaces",
|
|
startTime: "14:00:00",
|
|
endTime: "14:30:00",
|
|
});
|
|
await loginViaUi(page, "admin", "admin123");
|
|
await gotoTestDate(page, DATE_SKIP);
|
|
await expect(page.locator(`[data-testid="em-row-${id}"]`)).toBeVisible({
|
|
timeout: 10_000,
|
|
});
|
|
const popover = page.locator(`[data-testid="em-row-quick-${id}"]`);
|
|
|
|
// #489: the dedicated grip handle is gone; the whole row is the drag
|
|
// handle. The row's own click handler still skips interactive cells.
|
|
await expect(
|
|
page.locator(`[data-testid="em-row-grip-${id}"]`),
|
|
).toHaveCount(0);
|
|
|
|
// Time cell — clicking enters inline edit mode in TimeRangeCell.
|
|
// Must NOT also open the quick-actions popover.
|
|
const timeCell = page.locator(`[data-testid="em-time-${id}"]`);
|
|
await timeCell.click();
|
|
await expect(popover).toBeHidden();
|
|
await page.keyboard.press("Escape");
|
|
|
|
// Row-actions trigger — opens the row's own dropdown, not our popover.
|
|
const rowActions = page.locator(`[data-testid="em-row-actions-${id}"]`);
|
|
if (await rowActions.count()) {
|
|
await rowActions.click();
|
|
await expect(popover).toBeHidden();
|
|
await page.keyboard.press("Escape");
|
|
}
|
|
|
|
// Sanity: clicking an empty cell of the row DOES open the popover.
|
|
await page
|
|
.locator(`[data-testid="em-row-${id}"]`)
|
|
.click({ position: { x: 5, y: 5 } });
|
|
await expect(popover).toBeVisible({ timeout: 5_000 });
|
|
});
|
|
|
|
test("non-mutator (executive_viewer) row click does NOT open quick-actions surface", async ({
|
|
page,
|
|
}) => {
|
|
const id = await insertMeeting({
|
|
date: DATE_VIEWER,
|
|
titleEn: "QA Viewer Click",
|
|
startTime: "15:00:00",
|
|
endTime: "15:30:00",
|
|
});
|
|
const viewer = await createUserWithRole("qa_viewer", "executive_viewer");
|
|
await loginViaUi(page, viewer.username, TEST_PASSWORD);
|
|
await gotoTestDate(page, DATE_VIEWER);
|
|
await expect(page.locator(`[data-testid="em-row-${id}"]`)).toBeVisible({
|
|
timeout: 10_000,
|
|
});
|
|
|
|
await page.locator(`[data-testid="em-row-${id}"]`).click();
|
|
await page.waitForTimeout(750);
|
|
await expect(
|
|
page.locator(`[data-testid="em-row-quick-${id}"]`),
|
|
).toHaveCount(0);
|
|
});
|