Files
TX/artifacts/tx-os/tests/executive-meetings-edit-toggle.spec.mjs
T
riyadhafraa 7a2ae8434d Update project documentation and code comments to remove platform-specific references
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
2026-05-14 06:23:49 +00:00

303 lines
11 KiB
JavaScript

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, reveals them when on, and resets to view mode on reload / re-navigation (#484)", 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");
await page.goto("/executive-meetings");
// 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();
// #484: edit mode is intentionally NOT persisted. Reloading the
// page must drop the toggle back to view mode.
await page.reload();
const toggleAfterReload = page.getByTestId("em-edit-mode-toggle");
await expect(toggleAfterReload).toHaveAttribute("aria-pressed", "false");
await expect(page.getByTestId("em-add-row-button")).toHaveCount(0);
// #484: navigating away to another app and coming back also resets
// the toggle (the page unmounts on route change).
await toggleAfterReload.click();
await expect(toggleAfterReload).toHaveAttribute("aria-pressed", "true");
await page.goto("/");
await page.goto("/executive-meetings");
const toggleAfterRenav = page.getByTestId("em-edit-mode-toggle");
await expect(toggleAfterRenav).toHaveAttribute("aria-pressed", "false");
await expect(page.getByTestId("em-add-row-button")).toHaveCount(0);
// Within a single visit the toggle still works normally.
await toggleAfterRenav.click();
await expect(toggleAfterRenav).toHaveAttribute("aria-pressed", "true");
await expect(page.getByTestId("em-add-row-button")).toBeVisible();
await toggleAfterRenav.click();
await expect(toggleAfterRenav).toHaveAttribute("aria-pressed", "false");
await expect(page.getByTestId("em-add-row-button")).toHaveCount(0);
});
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);
});
// Shared setup for the attendee-prefix specs below.
async function gotoSchedule(page, lang) {
await page.addInitScript((l) => {
try {
window.localStorage.setItem("tx-lang", l);
} catch {
/* ignore */
}
}, lang);
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);
}
}
} catch {
/* ignore */
}
});
await page.reload();
// 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-"]')
.first()
.waitFor({ state: "visible", timeout: 10_000 })
.catch(() => {
/* let the caller decide whether to skip */
});
}
// 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 in a multi-attendee group,
// 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 index prefix is intentionally hidden when the
// attendee group has only one entry, so this test scopes itself to
// rows that actually carry an `em-attendee-index-*` span.
for (const lang of ["en", "ar"]) {
test(`Schedule [${lang}]: attendee index prefix renders on the same visual line as the name (multi-attendee group)`, async ({
page,
}) => {
await gotoSchedule(page, lang);
// Only consider rows that actually have an index span — i.e. rows
// whose attendee group has 2+ entries. Single-attendee rows render
// no `em-attendee-index-*` (see the sibling spec below) and would
// make the layout assertion meaningless.
const multiAttendeeRow = page
.locator('[data-testid^="em-attendee-row-"]')
.filter({ has: page.locator('[data-testid^="em-attendee-index-"]') })
.first();
if ((await multiAttendeeRow.count()) === 0) {
test.skip(
true,
`No multi-attendee group present on the schedule for lang=${lang}; cannot assert layout. Seed two attendees in one group to enable this guard.`,
);
return;
}
async function assertSameLine(rowLocator, nameLocator) {
await expect(rowLocator).toBeVisible();
const indexBox = await rowLocator
.locator('[data-testid^="em-attendee-index-"]')
.boundingBox();
const nameBox = await nameLocator.boundingBox();
expect(indexBox).not.toBeNull();
expect(nameBox).not.toBeNull();
const indexCenter = indexBox.y + indexBox.height / 2;
const nameCenter = nameBox.y + nameBox.height / 2;
// 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.
expect(Math.abs(indexCenter - nameCenter)).toBeLessThan(8);
}
// 1. View mode (toggle off): name renders as a plain <span>.
await assertSameLine(
multiAttendeeRow,
multiAttendeeRow.locator('[data-testid^="em-attendee-name-"]'),
);
// 2. Edit mode (toggle on): name renders inside an EditableCell.
const toggle = page.getByTestId("em-edit-mode-toggle");
if (await toggle.isVisible().catch(() => false)) {
await toggle.click();
await expect(toggle).toHaveAttribute("aria-pressed", "true");
const multiAttendeeRowEditable = page
.locator('[data-testid^="em-attendee-row-"]')
.filter({ has: page.locator('[data-testid^="em-attendee-index-"]') })
.filter({
has: page.locator('[data-testid^="em-edit-attendee-"]'),
})
.first();
if ((await multiAttendeeRowEditable.count()) > 0) {
await assertSameLine(
multiAttendeeRowEditable,
multiAttendeeRowEditable.locator(
'[data-testid^="em-edit-attendee-"]',
),
);
}
}
});
// when an attendee group has exactly one entry the
// leading `1-` index prefix is hidden because there is nothing to
// enumerate. Assert that for at least one solo-attendee row, no
// `em-attendee-index-*` span exists inside it, in BOTH locales.
test(`Schedule [${lang}]: attendee index prefix is hidden when the group has only one attendee`, async ({
page,
}) => {
await gotoSchedule(page, lang);
// A solo-attendee row is one that contains an attendee name span
// but does NOT contain an index span. Filtering with `hasNot`
// keeps the spec robust against future per-group changes.
const soloAttendeeRow = page
.locator('[data-testid^="em-attendee-row-"]')
.filter({ has: page.locator('[data-testid^="em-attendee-name-"]') })
.filter({
hasNot: page.locator('[data-testid^="em-attendee-index-"]'),
})
.first();
if ((await soloAttendeeRow.count()) === 0) {
test.skip(
true,
`No single-attendee group present on the schedule for lang=${lang}; cannot assert prefix-hidden. Seed a meeting with exactly one attendee to enable this guard.`,
);
return;
}
await expect(soloAttendeeRow).toBeVisible();
// The name itself is still rendered.
await expect(
soloAttendeeRow.locator('[data-testid^="em-attendee-name-"]'),
).toBeVisible();
// ...but the index span must be absent inside the row.
await expect(
soloAttendeeRow.locator('[data-testid^="em-attendee-index-"]'),
).toHaveCount(0);
});
}