#479: Render upcoming-alert attendees as numbered tables per group

- DetailsPanel in upcoming-meeting-alert.tsx: replaced the per-group
  `<ul class="list-disc">` with a real `<table class="w-full">`. Each
  attendee row is `<tr>` with two `<td>`s: a narrow tabular-nums index
  cell (`{idx + 1}.`, scope="row") and the cleaned name cell. The
  group heading is rendered as `<caption>` (text-start, font-bold,
  text-xs) so it spans both columns and is announced as the table
  caption to AT. Each table also carries `aria-label={group.heading}`.
- Numbering restarts at 1 per group (each group is its own table); a
  code comment notes the one-line tweak to use a continuous count.
- Preserved: `data-testid="alert-details-attendees"` wrapper,
  `max-h-40 overflow-y-auto` scroll, empty-state, location/URL rows,
  and the section header total ("الحضور (n)"). RTL/LTR work via
  `text-start` and `pe-1`.
- e2e: added a new test in
  `artifacts/tx-os/tests/executive-meetings-upcoming-alert.spec.mjs`
  that seeds a meeting with two subheading-delimited groups (3
  external + 1 internal), expands details, and asserts two tables
  render with rows starting at "1." in each group.

Verification:
- `pnpm exec tsc --noEmit` clean.
- New attendees-table spec passes (1/1, 16.5s).

No server, schema, or i18n key changes; Tailwind utilities only.
This commit is contained in:
Riyadh
2026-05-10 15:44:23 +00:00
parent eb50ea80cb
commit a464e45523
2 changed files with 82 additions and 9 deletions
@@ -1991,29 +1991,44 @@ function DetailsPanel({
className="max-h-40 overflow-y-auto"
data-testid="alert-details-attendees"
>
{/* #479: render each attendee group as its own numbered
table. Numbering restarts at 1 per group; if a future
spec wants a single continuous count across all groups,
swap the per-group `idx + 1` for a counter incremented in
the outer .map. */}
{attendeeGroups.map((group, gIdx) => {
if (group.members.length === 0) return null;
return (
<div key={`group-${gIdx}`} className="mb-2 last:mb-0">
<table
key={`group-${gIdx}`}
className="mb-2 w-full last:mb-0"
aria-label={group.heading || undefined}
>
{group.heading ? (
<div className="mb-0.5 text-xs font-bold">
<caption className="mb-0.5 text-start text-xs font-bold">
{group.heading}
</div>
</caption>
) : null}
<ul className="list-disc ps-4 leading-snug">
<tbody className="leading-snug">
{group.members.map((p, idx) => {
const cleanName = p.name
.replace(/<[^>]+>/g, "")
.trim();
if (!cleanName) return null;
return (
<li key={`${gIdx}-${p.id ?? idx}-${p.name}`}>
{cleanName}
</li>
<tr key={`${gIdx}-${p.id ?? idx}-${p.name}`}>
<td
scope="row"
className="w-7 pe-1 text-start align-top tabular-nums opacity-70"
>
{idx + 1}.
</td>
<td className="align-top">{cleanName}</td>
</tr>
);
})}
</ul>
</div>
</tbody>
</table>
);
})}
</div>
@@ -1228,3 +1228,61 @@ test("Cascade prompt UI: no followers => prompt skipped, direct submit with casc
await page.unroute(/\/api\/executive-meetings\/\d+\/postpone-minutes$/);
});
test("Upcoming-meeting alert: details panel renders attendees as numbered tables per group", async ({
page,
}) => {
// #479: each attendee group inside the expanded details panel is a
// real <table> with a 1-based index column and the attendee name —
// verifies the bulleted list was replaced and that index restarts
// per group.
await setLang(page, "en");
const m = await insertImminentMeeting({
titleAr: `${TEST_TAG} attlist AR`,
titleEn: `${TEST_TAG} attlist EN`,
deltaMins: 3,
});
// Two distinct groups so we can also assert numbering restarts at 1
// for the second group.
// Groups in the alert are delimited by `kind='subheading'` rows, not
// by attendance_type — mirror the production data shape.
const seedAttendees = [
{ name: "External Group", attendanceType: "external", sortOrder: 1, kind: "subheading" },
{ name: "External One", attendanceType: "external", sortOrder: 2, kind: "person" },
{ name: "External Two", attendanceType: "external", sortOrder: 3, kind: "person" },
{ name: "Internal Group", attendanceType: "internal", sortOrder: 4, kind: "subheading" },
{ name: "Internal One", attendanceType: "internal", sortOrder: 5, kind: "person" },
];
for (const a of seedAttendees) {
await pool.query(
`INSERT INTO executive_meeting_attendees
(meeting_id, name, attendance_type, sort_order, kind)
VALUES ($1, $2, $3, $4, $5)`,
[m.id, a.name, a.attendanceType, a.sortOrder, a.kind],
);
}
await loginViaUi(page, "admin", "admin123");
await page.goto("/");
await expect(page.getByTestId("upcoming-meeting-alert")).toBeVisible({
timeout: 15_000,
});
await page.getByTestId("alert-details-toggle").click();
const wrapper = page.getByTestId("alert-details-attendees");
await expect(wrapper).toBeVisible();
const tables = wrapper.locator("table");
await expect(tables).toHaveCount(2);
// First group: row 1 shows "1." next to the first external attendee.
const firstGroupRows = tables.nth(0).locator("tbody tr");
await expect(firstGroupRows.first()).toContainText("1.");
await expect(firstGroupRows.first()).toContainText("External One");
await expect(firstGroupRows.nth(1)).toContainText("2.");
await expect(firstGroupRows.nth(1)).toContainText("External Two");
// Second group: numbering restarts at 1.
const secondGroupRows = tables.nth(1).locator("tbody tr");
await expect(secondGroupRows.first()).toContainText("1.");
await expect(secondGroupRows.first()).toContainText("Internal One");
});