8d998bf8e8
Adds API + UI/e2e coverage for the cell-merge overlay introduced by task #152 on the executive-meetings schedule. Two new test files, no production code changes. API tests (artifacts/api-server/tests/executive-meetings-merge.test.mjs): - Setting a merge writes mergeStartColumn/mergeEndColumn/mergeText and the row round-trips back through GET ?date=. - Clearing with `merge: null` nulls all three columns while leaving the rest of the row intact (combined with a titleEn change in the same PATCH to prove only the merge fields move). - Invalid range start>end is rejected with 400 and the row is unchanged (also covers an unknown enum value). - mergeText is sanitized: <script>, onerror, javascript:, and <img> are stripped; visible text, <strong>, and the allowed inline color style (rgb(220,38,38)) survive — locks the contract that Tiptap-style formatting on the merged label is preserved. - A non-mutate user (executive_viewer role) gets 403 on PATCH merge and a follow-up DB SELECT confirms no fields changed. UI / e2e tests (artifacts/tx-os/tests/executive-meetings-merge.spec.mjs): - Two-tab realtime: separate browser contexts both open the same day; applying a merge in tab A makes the merged cell appear in tab B without a manual reload (relies on the existing `executive_meetings_changed` Socket.IO emit + client invalidator). - Hidden # column: with `em-schedule-cols-v1` localStorage flagging number as invisible, the row-actions kebab still mounts on the next visible cell and the Merge submenu is reachable. - Non-contiguous reorder: a row with a stored merge spanning meeting+attendees is loaded after reordering columns to [number, meeting, time, attendees]. The merged cell is NOT rendered (correct fallback) but the kebab still surfaces the Unmerge action, and clicking it clears all three merge columns in the DB. Both files clean up their own meeting rows + audit log entries + seeded users in afterAll/after. All 5 API tests + 3 Playwright tests pass against the running dev workflows. Follow-ups proposed: - #217 Show merged cells in the meeting PDF & archive views - #218 Test the merge popover in Arabic / RTL Replit-Task-Id: c696223a-209b-4111-9921-e0540e1e16a5
434 lines
15 KiB
JavaScript
434 lines
15 KiB
JavaScript
// E2E + UI coverage for the cell-merge feature added by task #152.
|
|
//
|
|
// Two scenarios live here:
|
|
//
|
|
// 1. Two-tab realtime merge propagation. Two browser contexts open
|
|
// the same day's schedule. Tab A applies a merge via its own
|
|
// MergeMenu; tab B should pick up the new merge (rendered as a
|
|
// single colSpan cell) without a manual refresh, driven by the
|
|
// `executive_meetings_changed` Socket.IO event.
|
|
//
|
|
// 2. UI fallbacks for the merge trigger / Unmerge action under
|
|
// column-customizer churn:
|
|
// a. Hiding the leading "#" column doesn't strip the row-actions
|
|
// kebab — the kebab re-anchors to the next visible cell so an
|
|
// editor can still merge from a row whose # column is off.
|
|
// b. After a column reorder makes a *stored* merge cover
|
|
// non-contiguous visible columns (so we degrade to the
|
|
// unmerged layout to avoid colSpan-ing the wrong cells), the
|
|
// kebab still surfaces a working Unmerge action.
|
|
//
|
|
// All seeded meetings are inserted directly via pg on a far-future
|
|
// date so the tests don't collide with real schedule data, and an
|
|
// afterAll DELETE removes everything we created.
|
|
|
|
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 executive-meetings merge UI tests",
|
|
);
|
|
}
|
|
|
|
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(),
|
|
]);
|
|
}
|
|
|
|
// Wipe the schedule's persisted UI prefs so each test starts from a
|
|
// known baseline (view mode off, default columns, default highlight
|
|
// color, no per-row tints, etc.). Must be called AFTER login because
|
|
// the edit-mode storage key is namespaced by userId.
|
|
async function resetSchedulePrefs(page) {
|
|
await page.evaluate(() => {
|
|
try {
|
|
const keys = Object.keys(window.localStorage);
|
|
for (const k of keys) {
|
|
if (
|
|
k.startsWith("em-schedule-edit-mode-v1") ||
|
|
k === "em-schedule-cols-v1" ||
|
|
k === "em-schedule-row-colors-v1" ||
|
|
k === "em-current-meeting-highlight-v1"
|
|
) {
|
|
window.localStorage.removeItem(k);
|
|
}
|
|
}
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
});
|
|
}
|
|
|
|
async function setLangEn(page) {
|
|
await page.addInitScript(() => {
|
|
try {
|
|
window.localStorage.setItem("tx-lang", "en");
|
|
} catch {
|
|
/* localStorage may not be ready before navigation */
|
|
}
|
|
});
|
|
}
|
|
|
|
async function ensureEditModeOn(page) {
|
|
const toggle = page.getByTestId("em-edit-mode-toggle");
|
|
await expect(toggle).toBeVisible();
|
|
if ((await toggle.getAttribute("aria-pressed")) === "false") {
|
|
await toggle.click();
|
|
}
|
|
await expect(toggle).toHaveAttribute("aria-pressed", "true");
|
|
}
|
|
|
|
async function insertMeeting({
|
|
meetingDate,
|
|
dailyNumber,
|
|
titleEn,
|
|
titleAr,
|
|
startTime,
|
|
endTime,
|
|
mergeStartColumn = null,
|
|
mergeEndColumn = null,
|
|
mergeText = null,
|
|
}) {
|
|
const { rows } = await pool.query(
|
|
`INSERT INTO executive_meetings
|
|
(daily_number, title_ar, title_en, meeting_date, start_time, end_time,
|
|
status, merge_start_column, merge_end_column, merge_text)
|
|
VALUES ($1, $2, $3, $4, $5, $6, 'scheduled', $7, $8, $9)
|
|
RETURNING id`,
|
|
[
|
|
dailyNumber,
|
|
titleAr,
|
|
titleEn,
|
|
meetingDate,
|
|
startTime,
|
|
endTime,
|
|
mergeStartColumn,
|
|
mergeEndColumn,
|
|
mergeText,
|
|
],
|
|
);
|
|
const id = rows[0].id;
|
|
createdMeetingIds.push(id);
|
|
return id;
|
|
}
|
|
|
|
// Pick a meeting date well past any seeded data and jitter the base
|
|
// per-process so concurrent reruns don't trip the daily_number unique
|
|
// index.
|
|
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("Schedule: a merge applied in one tab propagates to a second tab via realtime without reload", async ({
|
|
browser,
|
|
}) => {
|
|
// Two isolated browser contexts so each tab gets its own session
|
|
// cookie + Socket.IO connection. Sharing a context would multiplex
|
|
// the same socket for both tabs and defeat the purpose.
|
|
const ctxA = await browser.newContext();
|
|
const ctxB = await browser.newContext();
|
|
const tabA = await ctxA.newPage();
|
|
const tabB = await ctxB.newPage();
|
|
|
|
const date = uniqueFutureDate(5);
|
|
const uniq = `${Date.now().toString(36)}_${Math.random()
|
|
.toString(36)
|
|
.slice(2, 6)}`;
|
|
const meetingId = await insertMeeting({
|
|
meetingDate: date,
|
|
dailyNumber: 1,
|
|
titleEn: `Realtime Merge ${uniq}`,
|
|
titleAr: `دمج حي ${uniq}`,
|
|
startTime: "09:00:00",
|
|
endTime: "10:00:00",
|
|
});
|
|
|
|
try {
|
|
await setLangEn(tabA);
|
|
await setLangEn(tabB);
|
|
|
|
await loginViaUi(tabA, "admin", "admin123");
|
|
await loginViaUi(tabB, "admin", "admin123");
|
|
|
|
await tabA.goto("/executive-meetings");
|
|
await tabB.goto("/executive-meetings");
|
|
await resetSchedulePrefs(tabA);
|
|
await resetSchedulePrefs(tabB);
|
|
await tabA.reload();
|
|
await tabB.reload();
|
|
|
|
// Both tabs jump to the seeded date.
|
|
await tabA.locator('input[type="date"]').first().fill(date);
|
|
await tabB.locator('input[type="date"]').first().fill(date);
|
|
|
|
const rowA = tabA.getByTestId(`em-row-${meetingId}`);
|
|
const rowB = tabB.getByTestId(`em-row-${meetingId}`);
|
|
await expect(rowA).toBeVisible({ timeout: 10_000 });
|
|
await expect(rowB).toBeVisible({ timeout: 10_000 });
|
|
|
|
// Neither tab shows a merged cell yet.
|
|
await expect(
|
|
tabA.getByTestId(`em-merge-cell-${meetingId}`),
|
|
).toHaveCount(0);
|
|
await expect(
|
|
tabB.getByTestId(`em-merge-cell-${meetingId}`),
|
|
).toHaveCount(0);
|
|
|
|
// Apply a merge in tab A through the row-actions kebab → Merge
|
|
// submenu → "Merge meeting + attendees + time" option.
|
|
await ensureEditModeOn(tabA);
|
|
const kebabA = rowA.locator('[data-testid^="em-row-actions-"]').first();
|
|
await kebabA.click({ force: true });
|
|
await tabA.getByTestId(`em-merge-trigger-${meetingId}`).click();
|
|
|
|
// Wait for the PATCH to land before relying on the socket fan-out.
|
|
const savePromise = tabA.waitForResponse(
|
|
(resp) => {
|
|
const u = new URL(resp.url());
|
|
return (
|
|
u.pathname === `/api/executive-meetings/${meetingId}` &&
|
|
resp.request().method() === "PATCH" &&
|
|
resp.ok()
|
|
);
|
|
},
|
|
{ timeout: 15_000 },
|
|
);
|
|
await tabA.getByTestId(`em-merge-opt-meeting-time-${meetingId}`).click();
|
|
await savePromise;
|
|
|
|
// Tab A renders the merged cell immediately (optimistic update).
|
|
await expect(
|
|
tabA.getByTestId(`em-merge-cell-${meetingId}`),
|
|
).toBeVisible({ timeout: 10_000 });
|
|
|
|
// Tab B should pick up the new merge via the realtime
|
|
// `executive_meetings_changed` event invalidating its cached
|
|
// query for the same date — no manual reload here.
|
|
await expect(
|
|
tabB.getByTestId(`em-merge-cell-${meetingId}`),
|
|
).toBeVisible({ timeout: 15_000 });
|
|
} finally {
|
|
await ctxA.close();
|
|
await ctxB.close();
|
|
}
|
|
});
|
|
|
|
test("Schedule: hiding the # column still exposes the merge trigger on an unmerged row", async ({
|
|
page,
|
|
}) => {
|
|
await setLangEn(page);
|
|
|
|
const date = uniqueFutureDate(7);
|
|
const uniq = `${Date.now().toString(36)}_${Math.random()
|
|
.toString(36)
|
|
.slice(2, 6)}`;
|
|
const meetingId = await insertMeeting({
|
|
meetingDate: date,
|
|
dailyNumber: 1,
|
|
titleEn: `NumHidden ${uniq}`,
|
|
titleAr: `بدون رقم ${uniq}`,
|
|
startTime: "09:00:00",
|
|
endTime: "10:00:00",
|
|
});
|
|
|
|
await loginViaUi(page, "admin", "admin123");
|
|
await page.goto("/executive-meetings");
|
|
await resetSchedulePrefs(page);
|
|
|
|
// Hide the # column directly via the persisted prefs so we don't
|
|
// depend on the customize popover's exact UX for this regression.
|
|
// Order is preserved (number stays at index 0), only `visible` flips.
|
|
await page.evaluate(() => {
|
|
const cols = [
|
|
{ id: "number", visible: false, width: 56 },
|
|
{ id: "meeting", visible: true, width: 360 },
|
|
{ id: "attendees", visible: true, width: 280 },
|
|
{ id: "time", visible: true, width: 200 },
|
|
];
|
|
window.localStorage.setItem("em-schedule-cols-v1", JSON.stringify(cols));
|
|
});
|
|
await page.reload();
|
|
|
|
await page.locator('input[type="date"]').first().fill(date);
|
|
|
|
const row = page.getByTestId(`em-row-${meetingId}`);
|
|
await expect(row).toBeVisible({ timeout: 10_000 });
|
|
|
|
// Sanity: the # column header must actually be gone — otherwise the
|
|
// test isn't exercising the regression.
|
|
await expect(page.getByTestId("em-col-header-number")).toHaveCount(0);
|
|
|
|
// Edit mode is required for the row-actions kebab to render at all.
|
|
await ensureEditModeOn(page);
|
|
|
|
// The kebab anchors to the FIRST VISIBLE non-merged cell, so it
|
|
// should still be present on the row even though the # cell is
|
|
// hidden. Open it and confirm the Merge submenu is reachable.
|
|
const kebab = row.locator('[data-testid^="em-row-actions-"]').first();
|
|
await expect(kebab).toHaveCount(1);
|
|
await kebab.click({ force: true });
|
|
await expect(
|
|
page.getByTestId(`em-merge-trigger-${meetingId}`),
|
|
).toBeVisible();
|
|
|
|
// Drilling into the Merge submenu surfaces the three "Merge ..."
|
|
// options just like on a row whose # column is visible.
|
|
await page.getByTestId(`em-merge-trigger-${meetingId}`).click();
|
|
await expect(
|
|
page.getByTestId(`em-merge-opt-meeting-attendees-${meetingId}`),
|
|
).toBeVisible();
|
|
await expect(
|
|
page.getByTestId(`em-merge-opt-meeting-time-${meetingId}`),
|
|
).toBeVisible();
|
|
await expect(
|
|
page.getByTestId(`em-merge-opt-all-${meetingId}`),
|
|
).toBeVisible();
|
|
});
|
|
|
|
test("Schedule: a stored merge that becomes non-contiguous after a column reorder still exposes Unmerge", async ({
|
|
page,
|
|
}) => {
|
|
await setLangEn(page);
|
|
|
|
const date = uniqueFutureDate(9);
|
|
const uniq = `${Date.now().toString(36)}_${Math.random()
|
|
.toString(36)
|
|
.slice(2, 6)}`;
|
|
// Seed a row that already has a stored merge spanning meeting →
|
|
// attendees (canonical indices 1 → 2). Once we reorder the visible
|
|
// columns to [number, meeting, time, attendees], "meeting" sits at
|
|
// visible index 1 and "attendees" at visible index 3 — that gap of
|
|
// one (the "time" column dropped between them) is exactly the
|
|
// non-contiguous case that forces the schedule to fall back to
|
|
// rendering the cells unmerged.
|
|
const meetingId = await insertMeeting({
|
|
meetingDate: date,
|
|
dailyNumber: 1,
|
|
titleEn: `NonContig ${uniq}`,
|
|
titleAr: `غير متلاصق ${uniq}`,
|
|
startTime: "09:00:00",
|
|
endTime: "10:00:00",
|
|
mergeStartColumn: "meeting",
|
|
mergeEndColumn: "attendees",
|
|
mergeText: "<p>spanning meeting + attendees</p>",
|
|
});
|
|
|
|
await loginViaUi(page, "admin", "admin123");
|
|
await page.goto("/executive-meetings");
|
|
await resetSchedulePrefs(page);
|
|
|
|
// Reorder the schedule columns so "time" sits between meeting and
|
|
// attendees, breaking the contiguity of the stored merge.
|
|
await page.evaluate(() => {
|
|
const cols = [
|
|
{ id: "number", visible: true, width: 56 },
|
|
{ id: "meeting", visible: true, width: 360 },
|
|
{ id: "time", visible: true, width: 200 },
|
|
{ id: "attendees", visible: true, width: 280 },
|
|
];
|
|
window.localStorage.setItem("em-schedule-cols-v1", JSON.stringify(cols));
|
|
});
|
|
await page.reload();
|
|
|
|
await page.locator('input[type="date"]').first().fill(date);
|
|
|
|
const row = page.getByTestId(`em-row-${meetingId}`);
|
|
await expect(row).toBeVisible({ timeout: 10_000 });
|
|
|
|
// Confirm the column header order matches what we wrote — order
|
|
// matters for the contiguity calculation, so a misread here would
|
|
// give a misleading green.
|
|
const headerIds = await page
|
|
.locator('[data-testid^="em-col-header-"]')
|
|
.evaluateAll((els) =>
|
|
els
|
|
.map((el) => el.getAttribute("data-testid"))
|
|
.filter((id) => id && id.startsWith("em-col-header-"))
|
|
.map((id) => id.replace("em-col-header-", "")),
|
|
);
|
|
expect(headerIds).toEqual(["number", "meeting", "time", "attendees"]);
|
|
|
|
// Because the stored merge no longer maps to contiguous visible
|
|
// indices, the schedule should NOT render a single colSpan cell —
|
|
// the original cells stay intact.
|
|
await expect(
|
|
page.getByTestId(`em-merge-cell-${meetingId}`),
|
|
).toHaveCount(0);
|
|
|
|
// Edit mode is required for the kebab to mount.
|
|
await ensureEditModeOn(page);
|
|
|
|
// The kebab is still anchored to the first visible cell of the
|
|
// unmerged-rendering row, and opening Merge → … must surface the
|
|
// Unmerge option (because hasStoredMerge is true even though the
|
|
// current visible layout can't render the merge).
|
|
const kebab = row.locator('[data-testid^="em-row-actions-"]').first();
|
|
await expect(kebab).toHaveCount(1);
|
|
await kebab.click({ force: true });
|
|
await page.getByTestId(`em-merge-trigger-${meetingId}`).click();
|
|
const unmerge = page.getByTestId(`em-merge-opt-unmerge-${meetingId}`);
|
|
await expect(unmerge).toBeVisible();
|
|
|
|
// Clicking Unmerge actually clears the stored merge — confirm the
|
|
// PATCH lands and the DB row is wiped clean. Without this assertion
|
|
// a regression that stops sending `merge: null` (e.g. wires the
|
|
// button up but forgets to invoke onChangeMerge(null)) would slip.
|
|
const clearPromise = page.waitForResponse(
|
|
(resp) => {
|
|
const u = new URL(resp.url());
|
|
return (
|
|
u.pathname === `/api/executive-meetings/${meetingId}` &&
|
|
resp.request().method() === "PATCH" &&
|
|
resp.ok()
|
|
);
|
|
},
|
|
{ timeout: 15_000 },
|
|
);
|
|
await unmerge.click();
|
|
await clearPromise;
|
|
|
|
const { rows } = await pool.query(
|
|
`SELECT merge_start_column, merge_end_column, merge_text
|
|
FROM executive_meetings WHERE id = $1`,
|
|
[meetingId],
|
|
);
|
|
expect(rows[0].merge_start_column).toBeNull();
|
|
expect(rows[0].merge_end_column).toBeNull();
|
|
expect(rows[0].merge_text).toBeNull();
|
|
});
|