34dd8d50a4
- ScheduleSection: gate `quickActionsCanMutate` on `canMutate && !editMode` so clicking a row in edit mode no longer opens the Postpone dialog. In view mode the quick-actions surface still works as before. - MeetingRow: add a useEffect that force-closes local `quickOpen` when capability flips off (edit-mode toggle while dialog is open) and gate `quickOpenShadow` + `data-quick-open` on capability so no stale frame paints. Caught by code review. - Remove the amber `em-rotate-blocked-hint` missing-time banner per product feedback. Kept `missingTimeCount` / `dayRotatable` so row drag still aborts client-side and the per-row tooltip + native title still explain why. - Drop now-unused i18n keys `executiveMeetings.rotate.needsTimeWindow.hint*` from en.json and ar.json (zero/one/two/few/many/other variants). `tooltip` + `errorToast` strings stay (consumed by tooltip button + rotate-content catch handler). - Update executive-meetings-rotate-needs-time-window.spec.mjs to drop the banner visibility assertion and the post-unblock `toHaveCount(0)` re-check. Tooltip / aria-disabled / no-rotate-POST / successful- rotate-after-unblock checks remain. Verified: rotate-needs-time-window + row-quick-actions specs (7 tests) all pass with --workers=1.
310 lines
11 KiB
JavaScript
310 lines
11 KiB
JavaScript
// #492: e2e for the client-side guard that blocks row-drag rotation
|
|
// when ANY meeting on the day is missing a (start_time, end_time)
|
|
// tuple. The server's /api/executive-meetings/rotate-content endpoint
|
|
// hard-rejects such payloads with `code: "no_time_window"`; before
|
|
// #492 the client surfaced the raw English error in a toast even for
|
|
// AR users. The fix:
|
|
// * disable useSortable on every row of the day (aria-disabled +
|
|
// dimmed cursor + bilingual tooltip) so the drag never starts;
|
|
// * show an inline amber hint banner above the table with the
|
|
// count of meetings still missing a time;
|
|
// * once every meeting has a time, drag re-enables and rotation
|
|
// succeeds end-to-end.
|
|
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 rotate-needs-time-window test",
|
|
);
|
|
}
|
|
const pool = new pg.Pool({ connectionString: DATABASE_URL });
|
|
const createdMeetingIds = [];
|
|
|
|
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 = pickFutureDate(123);
|
|
|
|
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 setMeetingTimes(id, startTime, endTime) {
|
|
await pool.query(
|
|
`UPDATE executive_meetings
|
|
SET start_time = $2, end_time = $3, updated_at = NOW()
|
|
WHERE id = $1`,
|
|
[id, startTime, endTime],
|
|
);
|
|
}
|
|
|
|
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];
|
|
}
|
|
|
|
async function setLangEn(page) {
|
|
await page.addInitScript(() => {
|
|
try {
|
|
window.localStorage.setItem("tx-lang", "en");
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
});
|
|
}
|
|
|
|
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 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");
|
|
}
|
|
|
|
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();
|
|
});
|
|
|
|
test("blocks row-drag while any meeting on the day is missing a time, then re-enables once times are set", async ({
|
|
page,
|
|
}) => {
|
|
const timedId = await insertMeeting({
|
|
date: DATE,
|
|
titleEn: "NeedsTime Timed",
|
|
startTime: "09:00:00",
|
|
endTime: "09:30:00",
|
|
});
|
|
const untimedId = await insertMeeting({
|
|
date: DATE,
|
|
titleEn: "NeedsTime Untimed",
|
|
startTime: null,
|
|
endTime: null,
|
|
});
|
|
const otherId = await insertMeeting({
|
|
date: DATE,
|
|
titleEn: "NeedsTime Other",
|
|
startTime: "11:00:00",
|
|
endTime: "11:30:00",
|
|
});
|
|
|
|
await setLangEn(page);
|
|
await loginViaUi(page, "admin", "admin123");
|
|
await gotoTestDate(page, DATE);
|
|
|
|
// Force EN regardless of the admin user's stored preferredLanguage
|
|
// (AuthContext reapplies it on every login, which would otherwise
|
|
// flip the page back to AR for the demo admin).
|
|
await page.evaluate(async () => {
|
|
const w = window;
|
|
if (w.i18next && typeof w.i18next.changeLanguage === "function") {
|
|
await w.i18next.changeLanguage("en");
|
|
}
|
|
});
|
|
|
|
// #496: the amber missing-time hint banner was removed — drag is
|
|
// still blocked client-side and surfaced via the per-row tooltip
|
|
// affordance asserted further down. We jump straight to the row
|
|
// assertions.
|
|
|
|
// Every row on the day is aria-disabled for drag (even the rows
|
|
// that DO have a time — rotation is whole-day).
|
|
const timedRow = page.getByTestId(`em-row-${timedId}`);
|
|
const untimedRow = page.getByTestId(`em-row-${untimedId}`);
|
|
const otherRow = page.getByTestId(`em-row-${otherId}`);
|
|
await expect(timedRow).toHaveAttribute("aria-disabled", "true");
|
|
await expect(untimedRow).toHaveAttribute("aria-disabled", "true");
|
|
await expect(otherRow).toHaveAttribute("aria-disabled", "true");
|
|
await expect(timedRow).toHaveAttribute("data-drag-blocked", "no_time_window");
|
|
|
|
// Touch-friendly tooltip affordance: every blocked row gets a
|
|
// warning button inside its # cell. The button's aria-label and
|
|
// tooltip body must equal the localized explanation so iPad users
|
|
// (where native `title` long-press is unreliable) can read it.
|
|
// Accept either AR or EN copy — the demo admin's stored
|
|
// preferredLanguage may flip the page between renders, but EITHER
|
|
// localization satisfies the bilingual requirement.
|
|
const expectedTooltipEn =
|
|
"Set a start and end time on every meeting before you can reorder this day.";
|
|
const expectedTooltipAr =
|
|
"حدّد وقت بداية ونهاية لكل اجتماع قبل إعادة ترتيب هذا اليوم.";
|
|
const infoBtn = page.getByTestId(`em-row-drag-blocked-info-${timedId}`);
|
|
await expect(infoBtn).toBeVisible();
|
|
const ariaLabel = await infoBtn.getAttribute("aria-label");
|
|
expect([expectedTooltipEn, expectedTooltipAr]).toContain(ariaLabel);
|
|
// Tap the button → Radix tooltip becomes visible with the same copy.
|
|
// The parent <tr> has aria-disabled="true" (drag is paused), which
|
|
// makes Playwright skip its enabled-actionability check on every
|
|
// descendant — the button itself is fully clickable, so we use
|
|
// { force: true } to bypass the inherited check.
|
|
await infoBtn.click({ force: true });
|
|
const tooltip = page
|
|
.getByTestId(`em-row-drag-blocked-tooltip-${timedId}`)
|
|
.first();
|
|
await expect(tooltip).toBeVisible({ timeout: 5_000 });
|
|
// Radix Tooltip renders both a visible bubble and a sr-only
|
|
// duplicate via aria-describedby, so .textContent() may include
|
|
// the localized string twice. Substring-match is sufficient.
|
|
const tooltipText = (await tooltip.textContent())?.trim() ?? "";
|
|
expect(
|
|
tooltipText.includes(expectedTooltipEn) ||
|
|
tooltipText.includes(expectedTooltipAr),
|
|
).toBe(true);
|
|
// Dismiss the tooltip so it doesn't intercept the drag gesture below.
|
|
await infoBtn.click({ force: true });
|
|
|
|
// Attempting to drag must NOT trigger a rotate-content POST. Wire a
|
|
// listener; if any rotate request fires during the next ~2s we
|
|
// record it. We try the same drag gesture the real e2e drag spec
|
|
// uses (drag from the # cell of timedId down past otherId).
|
|
let rotateAttempts = 0;
|
|
page.on("request", (req) => {
|
|
if (
|
|
req.url().includes("/api/executive-meetings/rotate-content") &&
|
|
req.method() === "POST"
|
|
) {
|
|
rotateAttempts += 1;
|
|
}
|
|
});
|
|
const numCell = page
|
|
.locator(`[data-testid="em-row-${timedId}"] td`)
|
|
.first();
|
|
const numBox = await numCell.boundingBox();
|
|
const otherBox = await otherRow.boundingBox();
|
|
if (!numBox || !otherBox) throw new Error("rows must be visible");
|
|
const startX = numBox.x + numBox.width / 2;
|
|
const startY = numBox.y + numBox.height / 2;
|
|
await page.mouse.move(startX, startY);
|
|
await page.mouse.down();
|
|
await page.mouse.move(startX, startY + 12, { steps: 5 });
|
|
await page.mouse.move(startX, otherBox.y + otherBox.height - 4, {
|
|
steps: 15,
|
|
});
|
|
await page.mouse.up();
|
|
await page.waitForTimeout(800);
|
|
expect(rotateAttempts).toBe(0);
|
|
|
|
// DB state unchanged.
|
|
const timedBefore = await readMeeting(timedId);
|
|
expect(timedBefore.start_time).toBe("09:00:00");
|
|
|
|
// Now give the untimed meeting a real time window. Once the day
|
|
// becomes fully-timed aria-disabled must lift on every row — the
|
|
// actual rotation drag path is already covered by
|
|
// executive-meetings-row-drag.spec.mjs, so we only verify the
|
|
// block state lifts here.
|
|
await setMeetingTimes(untimedId, "10:00:00", "10:30:00");
|
|
await page.reload();
|
|
await page.evaluate(async () => {
|
|
const w = window;
|
|
if (w.i18next && typeof w.i18next.changeLanguage === "function") {
|
|
await w.i18next.changeLanguage("en");
|
|
}
|
|
});
|
|
await page.locator('input[type="date"]').first().fill(DATE);
|
|
await expect(timedRow).toBeVisible({ timeout: 10_000 });
|
|
await expect(timedRow).not.toHaveAttribute("aria-disabled", "true");
|
|
await expect(timedRow).not.toHaveAttribute(
|
|
"data-drag-blocked",
|
|
"no_time_window",
|
|
);
|
|
await expect(
|
|
page.getByTestId(`em-row-drag-blocked-info-${timedId}`),
|
|
).toHaveCount(0);
|
|
|
|
// Full unblock flow: drag now succeeds end-to-end. Rotate-content
|
|
// POST fires and the timedId row content moves into the next slot
|
|
// (start_time becomes the second row's 10:00:00).
|
|
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: 20_000 },
|
|
);
|
|
const numCell2 = page
|
|
.locator(`[data-testid="em-row-${timedId}"] td`)
|
|
.first();
|
|
await numCell2.scrollIntoViewIfNeeded();
|
|
const otherRow2 = page.getByTestId(`em-row-${untimedId}`);
|
|
const numBox2 = await numCell2.boundingBox();
|
|
const otherBox2 = await otherRow2.boundingBox();
|
|
if (!numBox2 || !otherBox2) throw new Error("rows must be visible");
|
|
const sX = numBox2.x + numBox2.width / 2;
|
|
const sY = numBox2.y + numBox2.height / 2;
|
|
await page.mouse.move(sX, sY);
|
|
await page.mouse.down();
|
|
await page.mouse.move(sX, sY + 12, { steps: 5 });
|
|
await page.mouse.move(sX, otherBox2.y + otherBox2.height - 4, {
|
|
steps: 15,
|
|
});
|
|
await page.mouse.up();
|
|
await rotatePromise;
|
|
|
|
await expect
|
|
.poll(async () => (await readMeeting(timedId)).start_time, {
|
|
timeout: 10_000,
|
|
})
|
|
.toBe("10:00:00");
|
|
});
|