7a2ae8434d
Refactor documentation files and code comments to remove references to Replit, specific task numbers, and other platform-specific identifiers. Replit-Commit-Author: Agent Replit-Commit-Session-Id: 77cfe984-2c65-4152-bb7a-0df28274fe66 Replit-Commit-Checkpoint-Type: full_checkpoint Replit-Commit-Event-Id: fa18e5d4-a810-4bd5-8cde-2a60d64d9e3f Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/c3c252e4-c83d-40ca-9fff-99a3ea60701e/77cfe984-2c65-4152-bb7a-0df28274fe66/kI0sxlu Replit-Helium-Checkpoint-Created: true
434 lines
15 KiB
JavaScript
434 lines
15 KiB
JavaScript
// E2E + UI coverage for the cell-merge feature added by.
|
|
//
|
|
// 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();
|
|
});
|