2026-04-29 18:22:49 +00:00
|
|
|
import { test, expect } from "@playwright/test";
|
|
|
|
|
|
|
|
|
|
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(),
|
|
|
|
|
]);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
test("Schedule: Edit toggle hides editing affordances by default and reveals them when on", async ({
|
|
|
|
|
page,
|
|
|
|
|
}) => {
|
|
|
|
|
await page.addInitScript(() => {
|
|
|
|
|
try {
|
|
|
|
|
window.localStorage.setItem("tx-lang", "en");
|
|
|
|
|
} catch {
|
|
|
|
|
/* localStorage may not be ready before navigation */
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await loginViaUi(page, "admin", "admin123");
|
|
|
|
|
// Make sure we always start the test in view mode regardless of any
|
|
|
|
|
// previously-persisted toggle state from another run. We clear AFTER
|
|
|
|
|
// login so the cleanup isn't undone by the initScript on every nav.
|
|
|
|
|
await page.goto("/executive-meetings");
|
|
|
|
|
// The toggle's storage key is namespaced per-user
|
|
|
|
|
// (`em-schedule-edit-mode-v1:<userId>`), so we wipe every entry that
|
|
|
|
|
// starts with the base key to make sure we begin in view mode no
|
|
|
|
|
// matter who admin's userId resolves to in this run.
|
|
|
|
|
await page.evaluate(() => {
|
|
|
|
|
try {
|
|
|
|
|
const keys = Object.keys(window.localStorage);
|
|
|
|
|
for (const k of keys) {
|
|
|
|
|
if (k.startsWith("em-schedule-edit-mode-v1")) {
|
|
|
|
|
window.localStorage.removeItem(k);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
} catch {
|
|
|
|
|
/* ignore */
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
await page.reload();
|
|
|
|
|
|
|
|
|
|
// Toggle is rendered (admin has edit permission), and starts in
|
|
|
|
|
// pressed=false / view mode.
|
|
|
|
|
const toggle = page.getByTestId("em-edit-mode-toggle");
|
|
|
|
|
await expect(toggle).toBeVisible();
|
|
|
|
|
await expect(toggle).toHaveAttribute("aria-pressed", "false");
|
|
|
|
|
|
|
|
|
|
// In view mode the row-level "Add meeting" button must be hidden.
|
|
|
|
|
await expect(page.getByTestId("em-add-row-button")).toHaveCount(0);
|
|
|
|
|
// And no row-level overlays (delete, color picker, merge trigger,
|
|
|
|
|
// grip handle) should exist anywhere in the table.
|
|
|
|
|
await expect(page.locator('[data-testid^="em-delete-row-"]')).toHaveCount(0);
|
|
|
|
|
await expect(page.getByTestId("em-row-color-trigger")).toHaveCount(0);
|
|
|
|
|
await expect(page.locator('[data-testid^="em-merge-trigger-"]')).toHaveCount(0);
|
|
|
|
|
await expect(page.locator('[data-testid^="em-row-grip-"]')).toHaveCount(0);
|
|
|
|
|
|
|
|
|
|
// Flip toggle on. aria-pressed becomes true and the affordances appear.
|
|
|
|
|
await toggle.click();
|
|
|
|
|
await expect(toggle).toHaveAttribute("aria-pressed", "true");
|
|
|
|
|
|
|
|
|
|
// The "+ Add meeting" row button is back.
|
|
|
|
|
await expect(page.getByTestId("em-add-row-button")).toBeVisible();
|
|
|
|
|
|
|
|
|
|
// Persist + survive a reload: the toggle should still be on.
|
|
|
|
|
await page.reload();
|
|
|
|
|
const toggleAfterReload = page.getByTestId("em-edit-mode-toggle");
|
|
|
|
|
await expect(toggleAfterReload).toHaveAttribute("aria-pressed", "true");
|
|
|
|
|
await expect(page.getByTestId("em-add-row-button")).toBeVisible();
|
|
|
|
|
|
|
|
|
|
// Toggle back off — affordances disappear again.
|
|
|
|
|
await toggleAfterReload.click();
|
|
|
|
|
await expect(toggleAfterReload).toHaveAttribute("aria-pressed", "false");
|
|
|
|
|
await expect(page.getByTestId("em-add-row-button")).toHaveCount(0);
|
|
|
|
|
});
|
2026-04-29 18:25:00 +00:00
|
|
|
|
|
|
|
|
test("Schedule: turning edit mode OFF cancels any open inline editor and discards the draft", async ({
|
|
|
|
|
page,
|
|
|
|
|
}) => {
|
|
|
|
|
await page.addInitScript(() => {
|
|
|
|
|
try {
|
|
|
|
|
window.localStorage.setItem("tx-lang", "en");
|
|
|
|
|
} catch {
|
|
|
|
|
/* ignore */
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await loginViaUi(page, "admin", "admin123");
|
|
|
|
|
await page.goto("/executive-meetings");
|
|
|
|
|
// Start clean (view mode), then flip edit mode on.
|
|
|
|
|
await page.evaluate(() => {
|
|
|
|
|
try {
|
|
|
|
|
const keys = Object.keys(window.localStorage);
|
|
|
|
|
for (const k of keys) {
|
|
|
|
|
if (k.startsWith("em-schedule-edit-mode-v1")) {
|
|
|
|
|
window.localStorage.removeItem(k);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
} catch {
|
|
|
|
|
/* ignore */
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
await page.reload();
|
|
|
|
|
|
|
|
|
|
const toggle = page.getByTestId("em-edit-mode-toggle");
|
|
|
|
|
await expect(toggle).toBeVisible();
|
|
|
|
|
await toggle.click();
|
|
|
|
|
await expect(toggle).toHaveAttribute("aria-pressed", "true");
|
|
|
|
|
|
|
|
|
|
// Make sure there's at least one row to edit. If the schedule is
|
|
|
|
|
// empty for today, add a row via the now-visible "+ Add meeting"
|
|
|
|
|
// button so the test has a stable target.
|
|
|
|
|
const addRow = page.getByTestId("em-add-row-button");
|
|
|
|
|
await expect(addRow).toBeVisible();
|
|
|
|
|
let titleCells = page.locator('[data-testid^="em-edit-title-"]');
|
|
|
|
|
if ((await titleCells.count()) === 0) {
|
|
|
|
|
await addRow.click();
|
|
|
|
|
await expect(titleCells.first()).toBeVisible({ timeout: 5_000 });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Open the first inline title editor and type a draft we expect
|
|
|
|
|
// to be discarded.
|
|
|
|
|
const firstTitle = titleCells.first();
|
|
|
|
|
const originalText = (await firstTitle.innerText()).trim();
|
|
|
|
|
await firstTitle.click();
|
|
|
|
|
// The editor renders a contenteditable inside the cell. Type a
|
|
|
|
|
// unique draft so we can later assert it never landed.
|
|
|
|
|
const draft = "DRAFT-DO-NOT-PERSIST-" + Date.now();
|
|
|
|
|
await page.keyboard.type(draft);
|
|
|
|
|
|
|
|
|
|
// Now flip edit mode OFF while the editor is still open. The
|
|
|
|
|
// EditableCell `disabled` effect should exit edit mode and reset
|
|
|
|
|
// the displayed content back to its saved value.
|
|
|
|
|
await toggle.click();
|
|
|
|
|
await expect(toggle).toHaveAttribute("aria-pressed", "false");
|
|
|
|
|
|
|
|
|
|
// The displayed cell should NOT contain our draft text — i.e. the
|
|
|
|
|
// open editor was cancelled and the unsaved draft was discarded.
|
|
|
|
|
const afterText = (await firstTitle.innerText()).trim();
|
|
|
|
|
expect(afterText).not.toContain(draft);
|
|
|
|
|
expect(afterText).toBe(originalText);
|
|
|
|
|
});
|
2026-04-29 18:57:26 +00:00
|
|
|
|
2026-04-29 19:01:21 +00:00
|
|
|
// Regression guard for the "number stacked above the name" bug
|
|
|
|
|
// (Tasks #173 / #175). Attendee names are saved as tiptap HTML
|
|
|
|
|
// (`<p>name</p>`); without a fix, the block-level <p> pushes the
|
|
|
|
|
// name onto its own visual row beneath the small index span. We
|
|
|
|
|
// assert that for at least one attendee, the index span and the
|
|
|
|
|
// name wrapper share roughly the same vertical center, in BOTH
|
|
|
|
|
// view mode and edit mode, AND in BOTH LTR (English) and RTL
|
|
|
|
|
// (Arabic) locales — since the bug originally surfaced on the
|
|
|
|
|
// Arabic schedule.
|
|
|
|
|
//
|
|
|
|
|
// The test is parameterised over `tx-lang` so we get one run per
|
|
|
|
|
// locale, and the test self-skips (rather than fails) if the
|
|
|
|
|
// current schedule has no attendees yet — that's an environment
|
|
|
|
|
// state issue, not a layout regression.
|
|
|
|
|
for (const lang of ["en", "ar"]) {
|
|
|
|
|
test(`Schedule [${lang}]: attendee index prefix renders on the same visual line as the name`, async ({
|
|
|
|
|
page,
|
|
|
|
|
}) => {
|
|
|
|
|
await page.addInitScript((l) => {
|
|
|
|
|
try {
|
|
|
|
|
window.localStorage.setItem("tx-lang", l);
|
|
|
|
|
} catch {
|
|
|
|
|
/* ignore */
|
|
|
|
|
}
|
|
|
|
|
}, lang);
|
2026-04-29 18:57:26 +00:00
|
|
|
|
2026-04-29 19:01:21 +00:00
|
|
|
await loginViaUi(page, "admin", "admin123");
|
|
|
|
|
await page.goto("/executive-meetings");
|
|
|
|
|
// Reset edit-mode toggle so we start in view mode.
|
|
|
|
|
await page.evaluate(() => {
|
|
|
|
|
try {
|
|
|
|
|
const keys = Object.keys(window.localStorage);
|
|
|
|
|
for (const k of keys) {
|
|
|
|
|
if (k.startsWith("em-schedule-edit-mode-v1")) {
|
|
|
|
|
window.localStorage.removeItem(k);
|
|
|
|
|
}
|
2026-04-29 18:57:26 +00:00
|
|
|
}
|
2026-04-29 19:01:21 +00:00
|
|
|
} catch {
|
|
|
|
|
/* ignore */
|
2026-04-29 18:57:26 +00:00
|
|
|
}
|
2026-04-29 19:01:21 +00:00
|
|
|
});
|
|
|
|
|
await page.reload();
|
2026-04-29 18:57:26 +00:00
|
|
|
|
2026-04-29 19:01:21 +00:00
|
|
|
// Wait for the schedule to render at least one row (or bail
|
|
|
|
|
// out if the page genuinely has no meetings — see below).
|
|
|
|
|
await page
|
|
|
|
|
.locator('[data-testid^="em-meeting-row-"]')
|
2026-04-29 18:57:26 +00:00
|
|
|
.first()
|
2026-04-29 19:01:21 +00:00
|
|
|
.waitFor({ state: "visible", timeout: 10_000 })
|
|
|
|
|
.catch(() => {
|
|
|
|
|
/* fall through to the skip check below */
|
|
|
|
|
});
|
2026-04-29 18:57:26 +00:00
|
|
|
|
2026-04-29 19:04:00 +00:00
|
|
|
// Locate attendees by stable testids on the row, the index span,
|
|
|
|
|
// and either the static name span or the editable cell. This keeps
|
|
|
|
|
// the regression guard decoupled from CSS class names.
|
|
|
|
|
const firstAttendeeRow = page
|
|
|
|
|
.locator('[data-testid^="em-attendee-row-"]')
|
2026-04-29 18:57:26 +00:00
|
|
|
.first();
|
2026-04-29 19:01:21 +00:00
|
|
|
|
2026-04-29 19:04:00 +00:00
|
|
|
if ((await firstAttendeeRow.count()) === 0) {
|
2026-04-29 19:01:21 +00:00
|
|
|
test.skip(
|
|
|
|
|
true,
|
|
|
|
|
`No attendees present on the schedule for lang=${lang}; cannot assert layout. Seed an attendee to enable this guard.`,
|
|
|
|
|
);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-29 19:04:00 +00:00
|
|
|
async function assertSameLine(rowLocator, nameLocator) {
|
|
|
|
|
await expect(rowLocator).toBeVisible();
|
|
|
|
|
const indexBox = await rowLocator
|
|
|
|
|
.locator('[data-testid^="em-attendee-index-"]')
|
2026-04-29 19:01:21 +00:00
|
|
|
.boundingBox();
|
2026-04-29 19:04:00 +00:00
|
|
|
const nameBox = await nameLocator.boundingBox();
|
2026-04-29 19:01:21 +00:00
|
|
|
expect(indexBox).not.toBeNull();
|
|
|
|
|
expect(nameBox).not.toBeNull();
|
|
|
|
|
const indexCenter = indexBox.y + indexBox.height / 2;
|
|
|
|
|
const nameCenter = nameBox.y + nameBox.height / 2;
|
2026-04-29 19:04:00 +00:00
|
|
|
// If the name wrapped onto a row below the index, centers would
|
|
|
|
|
// differ by ~a full line height (~18-22px). 8px of slack covers
|
|
|
|
|
// normal inline-vs-inline-block baseline differences.
|
2026-04-29 19:01:21 +00:00
|
|
|
expect(Math.abs(indexCenter - nameCenter)).toBeLessThan(8);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-29 19:04:00 +00:00
|
|
|
// 1. View mode (toggle off): name renders as a plain <span>.
|
|
|
|
|
await assertSameLine(
|
|
|
|
|
firstAttendeeRow,
|
|
|
|
|
firstAttendeeRow.locator('[data-testid^="em-attendee-name-"]'),
|
|
|
|
|
);
|
2026-04-29 19:01:21 +00:00
|
|
|
|
2026-04-29 19:04:00 +00:00
|
|
|
// 2. Edit mode (toggle on): name renders inside an EditableCell.
|
2026-04-29 19:01:21 +00:00
|
|
|
const toggle = page.getByTestId("em-edit-mode-toggle");
|
|
|
|
|
if (await toggle.isVisible().catch(() => false)) {
|
|
|
|
|
await toggle.click();
|
|
|
|
|
await expect(toggle).toHaveAttribute("aria-pressed", "true");
|
2026-04-29 19:04:00 +00:00
|
|
|
const firstAttendeeRowEditable = page
|
|
|
|
|
.locator('[data-testid^="em-attendee-row-"]')
|
|
|
|
|
.filter({
|
|
|
|
|
has: page.locator('[data-testid^="em-edit-attendee-"]'),
|
|
|
|
|
})
|
2026-04-29 19:01:21 +00:00
|
|
|
.first();
|
2026-04-29 19:04:00 +00:00
|
|
|
if ((await firstAttendeeRowEditable.count()) > 0) {
|
|
|
|
|
await assertSameLine(
|
|
|
|
|
firstAttendeeRowEditable,
|
|
|
|
|
firstAttendeeRowEditable.locator(
|
|
|
|
|
'[data-testid^="em-edit-attendee-"]',
|
|
|
|
|
),
|
|
|
|
|
);
|
2026-04-29 19:01:21 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
}
|