Files
TX/artifacts/tx-os/tests/executive-meetings-edit-toggle.spec.mjs
T
riyadhafraa 7877cb173f Schedule attendees: force number+name onto the same visual line
Task #175. Follow-up to #173. The first fix added `whitespace-nowrap`
on each attendee `<li>`, but users still saw the index span (`1-`,
`2-`) stacked above the name — even with very short names like
"رياض" / "محمد" that obviously fit on one line. Two screenshots
(before and after the merge) showed the same stacked layout, ruling
out narrow-column wrapping.

Root cause: attendee names are saved as tiptap HTML such as
`<p>محمد</p>`. Inside the inline-block EditableCell shell (and even
inside the plain view-mode `<span>`), the default block-level `<p>`
with its 1em top/bottom margins forced the name onto its own visual
row beneath the index span. `whitespace-nowrap` cannot pull a block
child back onto the parent line.

Fix (artifacts/tx-os/src/pages/executive-meetings.tsx):
- Each attendee `<li>` is now `inline-flex items-baseline
  whitespace-nowrap` so the index span and the name wrapper become
  flex children that structurally cannot break apart.
- The view-mode `<span>` (plain dangerouslySetInnerHTML) gets
  `[&_p]:inline [&_p]:m-0` — tiptap `<p>` renders as inline with no
  margins.
- The editable EditableCell wrapper gets the more-scoped
  `[&>span_p]:inline [&>span_p]:m-0`, which matches only the
  view-mode shell `<div> > <span> > <p>` and deliberately does NOT
  match the editing shell `<div> > <div(border)> > EditorContent`,
  so pressing Enter inside the editor still creates a real new
  paragraph.

Tests (artifacts/tx-os/tests/executive-meetings-edit-toggle.spec.mjs):
- New regression spec asserts that the index span and the name
  wrapper share the same vertical center (within 8px) for the first
  attendee in BOTH view mode and edit mode. Without the fix the
  centers differ by a full line height (~20px+).
- The new spec is parameterised over `tx-lang` so it runs once for
  English (LTR) and once for Arabic (RTL) — the bug originally
  surfaced on the Arabic schedule, so RTL coverage matters.
- The new spec self-skips (rather than fails) if the schedule has
  no attendees, so an empty environment doesn't masquerade as a
  layout regression.
- All passing.

Out of scope (unchanged): grouping/sorting, index format, multi-
group Virtual/Internal/External rows, pending +Add ghost row,
other EditableCell call sites (title, time, notes, manage tab).
2026-04-29 19:04:00 +00:00

262 lines
9.6 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 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);
});
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);
});
// 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);
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(() => {
/* fall through to the skip check below */
});
// 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-"]')
.first();
if ((await firstAttendeeRow.count()) === 0) {
test.skip(
true,
`No attendees present on the schedule for lang=${lang}; cannot assert layout. Seed an attendee 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(
firstAttendeeRow,
firstAttendeeRow.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 firstAttendeeRowEditable = page
.locator('[data-testid^="em-attendee-row-"]')
.filter({
has: page.locator('[data-testid^="em-edit-attendee-"]'),
})
.first();
if ((await firstAttendeeRowEditable.count()) > 0) {
await assertSameLine(
firstAttendeeRowEditable,
firstAttendeeRowEditable.locator(
'[data-testid^="em-edit-attendee-"]',
),
);
}
}
});
}