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).
This commit is contained in:
@@ -147,86 +147,118 @@ test("Schedule: turning edit mode OFF cancels any open inline editor and discard
|
||||
expect(afterText).toBe(originalText);
|
||||
});
|
||||
|
||||
test("Schedule: attendee index prefix renders on the same visual line as the name", async ({
|
||||
page,
|
||||
}) => {
|
||||
// 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.
|
||||
await page.addInitScript(() => {
|
||||
try {
|
||||
window.localStorage.setItem("tx-lang", "en");
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
});
|
||||
|
||||
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);
|
||||
}
|
||||
// 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 */
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
});
|
||||
await page.reload();
|
||||
}, lang);
|
||||
|
||||
// Find the first attendee LI in the schedule that actually has
|
||||
// an index prefix span and a name span. We bind the assertion to
|
||||
// the LI's structure (index span + sibling name span) so it
|
||||
// stays meaningful even if specific data changes.
|
||||
const firstAttendeeLi = page
|
||||
.locator(
|
||||
'li.inline-flex:has(> span.text-gray-500):has(> span:not(.text-gray-500))',
|
||||
)
|
||||
.first();
|
||||
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();
|
||||
|
||||
async function assertInline(li) {
|
||||
await expect(li).toBeVisible();
|
||||
const indexBox = await li.locator("> span.text-gray-500").boundingBox();
|
||||
const nameBox = await li
|
||||
.locator("> span:not(.text-gray-500), > div")
|
||||
// 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()
|
||||
.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 had wrapped onto a row below the index, the
|
||||
// vertical centers would differ by at least one full line
|
||||
// height (~18-22px). We allow up to 8px of slack for normal
|
||||
// baseline alignment between an inline span and an inline-block.
|
||||
expect(Math.abs(indexCenter - nameCenter)).toBeLessThan(8);
|
||||
}
|
||||
.waitFor({ state: "visible", timeout: 10_000 })
|
||||
.catch(() => {
|
||||
/* fall through to the skip check below */
|
||||
});
|
||||
|
||||
// 1. View mode (toggle off): name renders as a plain <span>
|
||||
// next to the index span.
|
||||
await assertInline(firstAttendeeLi);
|
||||
|
||||
// 2. Edit mode (toggle on): name renders inside an EditableCell
|
||||
// inline-block <div>; should still sit beside the index span.
|
||||
const toggle = page.getByTestId("em-edit-mode-toggle");
|
||||
if (await toggle.isVisible().catch(() => false)) {
|
||||
await toggle.click();
|
||||
await expect(toggle).toHaveAttribute("aria-pressed", "true");
|
||||
// Re-locate after re-render — the matching attendee LI now
|
||||
// wraps an EditableCell <div> instead of a plain <span>.
|
||||
const firstAttendeeLiEditable = page
|
||||
// Find the first attendee LI in the schedule that actually has
|
||||
// an index prefix span and a name span. We bind the assertion to
|
||||
// the LI's structure (index span + sibling name span) so it
|
||||
// stays meaningful even if specific data changes.
|
||||
const firstAttendeeLi = page
|
||||
.locator(
|
||||
'li.inline-flex:has(> span.text-gray-500):has(> div[data-testid^="em-edit-attendee-"])',
|
||||
'li.inline-flex:has(> span.text-gray-500):has(> span:not(.text-gray-500))',
|
||||
)
|
||||
.first();
|
||||
await assertInline(firstAttendeeLiEditable);
|
||||
}
|
||||
});
|
||||
|
||||
if ((await firstAttendeeLi.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 assertInline(li) {
|
||||
await expect(li).toBeVisible();
|
||||
const indexBox = await li
|
||||
.locator("> span.text-gray-500")
|
||||
.boundingBox();
|
||||
const nameBox = await li
|
||||
.locator("> span:not(.text-gray-500), > div")
|
||||
.first()
|
||||
.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 had wrapped onto a row below the index, the
|
||||
// vertical centers would differ by at least one full line
|
||||
// height (~18-22px). We allow up to 8px of slack for normal
|
||||
// baseline alignment between an inline span and an
|
||||
// inline-block.
|
||||
expect(Math.abs(indexCenter - nameCenter)).toBeLessThan(8);
|
||||
}
|
||||
|
||||
// 1. View mode (toggle off): name renders as a plain <span>
|
||||
// next to the index span.
|
||||
await assertInline(firstAttendeeLi);
|
||||
|
||||
// 2. Edit mode (toggle on): name renders inside an EditableCell
|
||||
// inline-block <div>; should still sit beside the index span.
|
||||
const toggle = page.getByTestId("em-edit-mode-toggle");
|
||||
if (await toggle.isVisible().catch(() => false)) {
|
||||
await toggle.click();
|
||||
await expect(toggle).toHaveAttribute("aria-pressed", "true");
|
||||
// Re-locate after re-render — the matching attendee LI now
|
||||
// wraps an EditableCell <div> instead of a plain <span>.
|
||||
const firstAttendeeLiEditable = page
|
||||
.locator(
|
||||
'li.inline-flex:has(> span.text-gray-500):has(> div[data-testid^="em-edit-attendee-"])',
|
||||
)
|
||||
.first();
|
||||
if ((await firstAttendeeLiEditable.count()) > 0) {
|
||||
await assertInline(firstAttendeeLiEditable);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user