310baa41ab
Header
- Removed the standalone Font Settings nav button and the bilingual
language toggle. The header now exposes only Export PDF.
- Renamed the SECTIONS key `fontSettings` → `settings` (Settings icon
retained) and updated visibility checks + render switch.
Settings tab
- New SettingsSection wraps the existing FontSettingsSection plus a
new ColumnsCustomizerPanel (extracted from the old popover body) so
column visibility / current-meeting highlight live with the rest of
user prefs.
- Removed the old ColumnsCustomizer popover trigger from the schedule
toolbar; the inline panel inside Settings is the single entry point.
State lifting
- columns/setColumns and highlightPrefs/setHighlightPrefs lifted from
ScheduleSection up to ExecutiveMeetingsPage so both tabs read/write
the same state. Storage keys (COLS_STORAGE_KEY, HIGHLIGHT_STORAGE_KEY)
unchanged so existing user prefs continue to load.
- Per code-reviewer follow-up: moved the columns localStorage write
effect up to the page as well so edits made in Settings persist even
when ScheduleSection is unmounted.
Locales
- ar.json + en.json: renamed nav.fontSettings → nav.settings
("الإعدادات" / "Settings"); removed the now-unused
executiveMeetings.fontSettings header label.
- Removed unused Languages and Type lucide imports.
Tests
- Updated executive-meetings-bulk-actions.spec.mjs and
executive-meetings-schedule-features.spec.mjs to navigate via
em-nav-settings instead of the removed em-customize-columns-trigger.
- All 226 sequential api tests pass; both updated playwright specs
pass.
408 lines
15 KiB
JavaScript
408 lines
15 KiB
JavaScript
import { test, expect } from "@playwright/test";
|
|
import pg from "pg";
|
|
|
|
// E2E coverage for the three "bulk-edit polish" features:
|
|
// 1. Edit toggle flips between an "Edit" label and a "Save" label
|
|
// (and a Pencil↔Check icon), and a second click exits edit mode.
|
|
// 2. The new-meeting / edit-meeting dialog has a one-click
|
|
// "Remove all attendees" button that empties the list.
|
|
// 3. Multi-select on the schedule + a single "Delete selected"
|
|
// action removes every checked meeting and clears the selection.
|
|
//
|
|
// Assertions are language-agnostic: we use testIDs, aria attributes,
|
|
// and regexes rather than English/Arabic copy so the suite works
|
|
// regardless of which locale the persisted localStorage has stuck on.
|
|
|
|
const DATABASE_URL = process.env.DATABASE_URL;
|
|
if (!DATABASE_URL) {
|
|
throw new Error(
|
|
"DATABASE_URL must be set to run the bulk-actions UI tests",
|
|
);
|
|
}
|
|
|
|
const pool = new pg.Pool({ connectionString: DATABASE_URL });
|
|
const createdMeetingIds = [];
|
|
|
|
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);
|
|
}
|
|
|
|
async function insertMeeting({
|
|
meetingDate,
|
|
dailyNumber,
|
|
titleEn,
|
|
titleAr,
|
|
startTime,
|
|
endTime,
|
|
}) {
|
|
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`,
|
|
[dailyNumber, titleAr, titleEn, meetingDate, startTime, endTime],
|
|
);
|
|
const id = rows[0].id;
|
|
createdMeetingIds.push(id);
|
|
return id;
|
|
}
|
|
|
|
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 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 */
|
|
}
|
|
});
|
|
}
|
|
|
|
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: edit toggle flips its label and icon when on, and a second click exits edit mode", async ({
|
|
page,
|
|
}) => {
|
|
await loginViaUi(page, "admin", "admin123");
|
|
await page.goto("/executive-meetings");
|
|
await resetSchedulePrefs(page);
|
|
await page.reload();
|
|
|
|
const toggle = page.getByTestId("em-edit-mode-toggle");
|
|
await expect(toggle).toBeVisible();
|
|
await expect(toggle).toHaveAttribute("aria-pressed", "false");
|
|
|
|
// Capture the OFF-state label so we can prove it actually changes.
|
|
const offLabel = (await toggle.textContent())?.trim() ?? "";
|
|
expect(offLabel.length).toBeGreaterThan(0);
|
|
// The icon SVG inside the OFF state must be the Pencil (lucide marks
|
|
// its icons with a `lucide-pencil` class).
|
|
await expect(toggle.locator("svg.lucide-pencil")).toHaveCount(1);
|
|
await expect(toggle.locator("svg.lucide-check")).toHaveCount(0);
|
|
|
|
// Flip on: aria-pressed flips, label/icon swap, and at least one
|
|
// edit-mode-only affordance becomes visible (the per-row select
|
|
// checkbox overlay) — proof we really entered edit mode.
|
|
await toggle.click();
|
|
await expect(toggle).toHaveAttribute("aria-pressed", "true");
|
|
const onLabel = (await toggle.textContent())?.trim() ?? "";
|
|
expect(onLabel.length).toBeGreaterThan(0);
|
|
expect(onLabel).not.toBe(offLabel);
|
|
await expect(toggle.locator("svg.lucide-check")).toHaveCount(1);
|
|
await expect(toggle.locator("svg.lucide-pencil")).toHaveCount(0);
|
|
await expect(
|
|
page.locator('[data-testid^="em-row-select-"]').first(),
|
|
).toBeVisible();
|
|
|
|
// Second click — back to OFF, original label/icon return.
|
|
await toggle.click();
|
|
await expect(toggle).toHaveAttribute("aria-pressed", "false");
|
|
await expect(toggle).toHaveText(offLabel);
|
|
await expect(toggle.locator("svg.lucide-pencil")).toHaveCount(1);
|
|
});
|
|
|
|
test("New-meeting dialog: 'Remove all attendees' button only renders when ≥1 attendee, and clears the in-memory list", async ({
|
|
page,
|
|
}) => {
|
|
await loginViaUi(page, "admin", "admin123");
|
|
await page.goto("/executive-meetings");
|
|
|
|
// Open the Manage tab → click "Add meeting" to launch the dialog.
|
|
await page.getByTestId("em-nav-manage").click();
|
|
await page.getByTestId("em-add-meeting").click();
|
|
|
|
// Empty list → the bulk-clear button is NOT rendered.
|
|
await expect(page.getByTestId("em-attendee-remove-all")).toHaveCount(0);
|
|
|
|
// Add two attendees via the in-dialog "+ Add attendee" affordance.
|
|
const addAttendeeBtn = page.getByTestId("em-attendee-add");
|
|
await expect(addAttendeeBtn).toBeVisible();
|
|
await addAttendeeBtn.click();
|
|
await addAttendeeBtn.click();
|
|
await expect(page.getByTestId("em-attendee-row-0")).toBeVisible();
|
|
await expect(page.getByTestId("em-attendee-row-1")).toBeVisible();
|
|
|
|
// Bulk-clear button now visible. Auto-confirm the native confirm()
|
|
// and click it.
|
|
await expect(page.getByTestId("em-attendee-remove-all")).toBeVisible();
|
|
page.once("dialog", (d) => d.accept());
|
|
await page.getByTestId("em-attendee-remove-all").click();
|
|
|
|
// List is empty again — neither attendee row nor the bulk button
|
|
// should be in the DOM.
|
|
await expect(page.getByTestId("em-attendee-row-0")).toHaveCount(0);
|
|
await expect(page.getByTestId("em-attendee-row-1")).toHaveCount(0);
|
|
await expect(page.getByTestId("em-attendee-remove-all")).toHaveCount(0);
|
|
});
|
|
|
|
test("Schedule: multi-select + 'Delete selected' removes every checked meeting and clears the selection", async ({
|
|
page,
|
|
}) => {
|
|
const date = uniqueFutureDate(2);
|
|
|
|
// Seed three meetings on the same future date so the schedule has
|
|
// a known set we can multi-select against.
|
|
const id1 = await insertMeeting({
|
|
meetingDate: date,
|
|
dailyNumber: 1,
|
|
titleEn: `Bulk1 ${Date.now()}`,
|
|
titleAr: `Bulk1 ${Date.now()}`,
|
|
startTime: "09:00:00",
|
|
endTime: "10:00:00",
|
|
});
|
|
const id2 = await insertMeeting({
|
|
meetingDate: date,
|
|
dailyNumber: 2,
|
|
titleEn: `Bulk2 ${Date.now()}`,
|
|
titleAr: `Bulk2 ${Date.now()}`,
|
|
startTime: "10:00:00",
|
|
endTime: "11:00:00",
|
|
});
|
|
const id3 = await insertMeeting({
|
|
meetingDate: date,
|
|
dailyNumber: 3,
|
|
titleEn: `Bulk3 ${Date.now()}`,
|
|
titleAr: `Bulk3 ${Date.now()}`,
|
|
startTime: "11:00:00",
|
|
endTime: "12:00:00",
|
|
});
|
|
|
|
await loginViaUi(page, "admin", "admin123");
|
|
await page.goto("/executive-meetings");
|
|
await resetSchedulePrefs(page);
|
|
await page.reload();
|
|
|
|
// Jump to the seeded date.
|
|
const dateInput = page.locator('input[type="date"]').first();
|
|
await dateInput.fill(date);
|
|
|
|
// Wait for all three rows.
|
|
await expect(page.getByTestId(`em-row-${id1}`)).toBeVisible({
|
|
timeout: 10_000,
|
|
});
|
|
await expect(page.getByTestId(`em-row-${id2}`)).toBeVisible();
|
|
await expect(page.getByTestId(`em-row-${id3}`)).toBeVisible();
|
|
|
|
// View mode → no row checkboxes, no select-all in header, no toolbar.
|
|
await expect(page.locator('[data-testid^="em-row-select-"]')).toHaveCount(0);
|
|
await expect(page.getByTestId("em-bulk-select-all")).toHaveCount(0);
|
|
await expect(page.getByTestId("em-bulk-toolbar")).toHaveCount(0);
|
|
|
|
// Flip edit mode on. The tri-state "select all" appears in the
|
|
// table header and per-row checkboxes appear, but the floating
|
|
// bulk toolbar is NOT visible yet — it only renders once ≥1 row
|
|
// is checked.
|
|
await page.getByTestId("em-edit-mode-toggle").click();
|
|
await expect(page.getByTestId("em-bulk-select-all")).toBeVisible();
|
|
await expect(page.getByTestId(`em-row-select-${id1}`)).toBeVisible();
|
|
await expect(page.getByTestId(`em-row-select-${id2}`)).toBeVisible();
|
|
await expect(page.getByTestId(`em-row-select-${id3}`)).toBeVisible();
|
|
await expect(page.getByTestId("em-bulk-toolbar")).toHaveCount(0);
|
|
|
|
// Check rows 1 and 3 (skip 2 to confirm the selection is honored).
|
|
await page.getByTestId(`em-row-select-${id1}`).click();
|
|
await page.getByTestId(`em-row-select-${id3}`).click();
|
|
// Bulk toolbar now appears with the count + delete button.
|
|
await expect(page.getByTestId("em-bulk-toolbar")).toBeVisible();
|
|
await expect(page.getByTestId("em-bulk-selection-count")).toHaveText(/2/);
|
|
await expect(page.getByTestId("em-bulk-selection-count")).toHaveText(/3/);
|
|
await expect(page.getByTestId("em-bulk-delete-selected")).toBeVisible();
|
|
// Header tri-state is in the indeterminate visual state.
|
|
await expect(page.getByTestId("em-bulk-select-all")).toHaveAttribute(
|
|
"data-state",
|
|
"indeterminate",
|
|
);
|
|
|
|
// Delete the two selected rows. Auto-accept the native confirm.
|
|
page.once("dialog", (d) => d.accept());
|
|
await page.getByTestId("em-bulk-delete-selected").click();
|
|
|
|
// Rows 1 and 3 are gone; row 2 remains. Selection has cleared, so
|
|
// the floating toolbar disappears entirely.
|
|
await expect(page.getByTestId(`em-row-${id1}`)).toHaveCount(0, {
|
|
timeout: 10_000,
|
|
});
|
|
await expect(page.getByTestId(`em-row-${id3}`)).toHaveCount(0);
|
|
await expect(page.getByTestId(`em-row-${id2}`)).toBeVisible();
|
|
await expect(page.getByTestId("em-bulk-toolbar")).toHaveCount(0);
|
|
await expect(page.getByTestId("em-bulk-delete-selected")).toHaveCount(0);
|
|
// Header select-all is back to unchecked.
|
|
await expect(page.getByTestId("em-bulk-select-all")).toHaveAttribute(
|
|
"data-state",
|
|
"unchecked",
|
|
);
|
|
|
|
// DB should agree: ids 1 + 3 are gone, id 2 still present.
|
|
const { rows: remaining } = await pool.query(
|
|
`SELECT id FROM executive_meetings WHERE id = ANY($1::int[]) ORDER BY id`,
|
|
[[id1, id2, id3]],
|
|
);
|
|
expect(remaining.map((r) => r.id)).toEqual([id2]);
|
|
});
|
|
|
|
test("Schedule: per-row select checkbox stays usable even when the # column is hidden via the column customizer", async ({
|
|
page,
|
|
}) => {
|
|
const date = uniqueFutureDate(3);
|
|
|
|
const idA = await insertMeeting({
|
|
meetingDate: date,
|
|
dailyNumber: 1,
|
|
titleEn: `HiddenCol1 ${Date.now()}`,
|
|
titleAr: `HiddenCol1 ${Date.now()}`,
|
|
startTime: "09:00:00",
|
|
endTime: "10:00:00",
|
|
});
|
|
const idB = await insertMeeting({
|
|
meetingDate: date,
|
|
dailyNumber: 2,
|
|
titleEn: `HiddenCol2 ${Date.now()}`,
|
|
titleAr: `HiddenCol2 ${Date.now()}`,
|
|
startTime: "10:00:00",
|
|
endTime: "11:00:00",
|
|
});
|
|
|
|
await loginViaUi(page, "admin", "admin123");
|
|
await page.goto("/executive-meetings");
|
|
await resetSchedulePrefs(page);
|
|
await page.reload();
|
|
|
|
// Jump to seeded date and wait for both rows.
|
|
const dateInput = page.locator('input[type="date"]').first();
|
|
await dateInput.fill(date);
|
|
await expect(page.getByTestId(`em-row-${idA}`)).toBeVisible({
|
|
timeout: 10_000,
|
|
});
|
|
await expect(page.getByTestId(`em-row-${idB}`)).toBeVisible();
|
|
|
|
// #265: Customize-columns now lives in the Settings tab.
|
|
// Hop over, toggle # off, hop back to the schedule.
|
|
await page.getByTestId("em-nav-settings").click();
|
|
await page.getByTestId("em-customize-toggle-number").click();
|
|
await page.getByTestId("em-nav-schedule").click();
|
|
|
|
// Enter edit mode. Per-row checkboxes must still appear (now as
|
|
// overlays in the first visible cell), and selecting them must
|
|
// still drive the bulk toolbar — even with the # column hidden,
|
|
// the tri-state header overlay falls back to the new first
|
|
// visible header.
|
|
await page.getByTestId("em-edit-mode-toggle").click();
|
|
await expect(page.getByTestId("em-bulk-select-all")).toBeVisible();
|
|
await expect(page.getByTestId(`em-row-select-${idA}`)).toBeVisible();
|
|
await expect(page.getByTestId(`em-row-select-${idB}`)).toBeVisible();
|
|
// Toolbar is hidden until something is selected.
|
|
await expect(page.getByTestId("em-bulk-toolbar")).toHaveCount(0);
|
|
|
|
await page.getByTestId(`em-row-select-${idA}`).click();
|
|
await expect(page.getByTestId("em-bulk-toolbar")).toBeVisible();
|
|
await expect(page.getByTestId("em-bulk-selection-count")).toHaveText(/1/);
|
|
await expect(page.getByTestId("em-bulk-delete-selected")).toBeVisible();
|
|
|
|
// Restore the # column so we leave the persisted UI prefs in a
|
|
// sane state for the next test.
|
|
await page.getByTestId("em-nav-settings").click();
|
|
await page.getByTestId("em-customize-toggle-number").click();
|
|
await page.getByTestId("em-nav-schedule").click();
|
|
});
|
|
|
|
// Regression guard for merged rows. Earlier the per-row bulk-select
|
|
// overlay was anchored to the first visible *non-merged* cell, which
|
|
// meant a row whose merge swallowed the leading cells lost its
|
|
// checkbox entirely. The fix mirrors the overlay onto the merged
|
|
// <td>, so this test merges a row end-to-end via the row-actions
|
|
// menu and asserts the checkbox still renders + drives the bulk
|
|
// toolbar.
|
|
test("Schedule: a merged row still exposes the per-row bulk select checkbox", async ({
|
|
page,
|
|
}) => {
|
|
const date = uniqueFutureDate(4);
|
|
|
|
const idA = await insertMeeting({
|
|
meetingDate: date,
|
|
dailyNumber: 1,
|
|
titleEn: `MergedSel1 ${Date.now()}`,
|
|
titleAr: `MergedSel1 ${Date.now()}`,
|
|
startTime: "09:00:00",
|
|
endTime: "10:00:00",
|
|
});
|
|
|
|
await loginViaUi(page, "admin", "admin123");
|
|
await page.goto("/executive-meetings");
|
|
await resetSchedulePrefs(page);
|
|
await page.reload();
|
|
|
|
const dateInput = page.locator('input[type="date"]').first();
|
|
await dateInput.fill(date);
|
|
await expect(page.getByTestId(`em-row-${idA}`)).toBeVisible({
|
|
timeout: 10_000,
|
|
});
|
|
|
|
// The row-actions kebab only mounts when canMutate is true (edit
|
|
// mode on). Turn edit mode on first, then open the kebab and pick
|
|
// "Merge all". The header tri-state confirms edit mode is live.
|
|
await page.getByTestId("em-edit-mode-toggle").click();
|
|
await expect(page.getByTestId("em-bulk-select-all")).toBeVisible();
|
|
await page
|
|
.getByTestId(`em-row-actions-${idA}`)
|
|
.click({ force: true });
|
|
await page.getByTestId(`em-merge-trigger-${idA}`).click();
|
|
await page.getByTestId(`em-merge-opt-all-${idA}`).click();
|
|
|
|
// After merge the row collapses its scheduling columns into one
|
|
// <td>; confirm the merged cell rendered.
|
|
await expect(page.getByTestId(`em-merge-cell-${idA}`)).toBeVisible({
|
|
timeout: 10_000,
|
|
});
|
|
|
|
// Edit mode is still on; assert the per-row checkbox is still
|
|
// reachable on the merged row, and that selecting it lights up the
|
|
// bulk toolbar exactly the same way an unmerged row would.
|
|
await expect(page.getByTestId(`em-row-select-${idA}`)).toBeVisible();
|
|
|
|
await page.getByTestId(`em-row-select-${idA}`).click();
|
|
await expect(page.getByTestId("em-bulk-toolbar")).toBeVisible();
|
|
await expect(page.getByTestId("em-bulk-selection-count")).toHaveText(/1/);
|
|
await expect(page.getByTestId("em-bulk-delete-selected")).toBeVisible();
|
|
});
|