` with two ``s: a narrow tabular-nums index
cell (`{idx + 1}.`, scope="row") and the cleaned name cell. The
group heading is rendered as `` (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.
---
.../upcoming-meeting-alert.tsx | 33 ++++++++---
...executive-meetings-upcoming-alert.spec.mjs | 58 +++++++++++++++++++
2 files changed, 82 insertions(+), 9 deletions(-)
diff --git a/artifacts/tx-os/src/components/executive-meetings/upcoming-meeting-alert.tsx b/artifacts/tx-os/src/components/executive-meetings/upcoming-meeting-alert.tsx
index bc8b3636..9c69d7a3 100644
--- a/artifacts/tx-os/src/components/executive-meetings/upcoming-meeting-alert.tsx
+++ b/artifacts/tx-os/src/components/executive-meetings/upcoming-meeting-alert.tsx
@@ -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 (
-
+
{group.heading ? (
-
+
{group.heading}
-
+
) : null}
-
+
{group.members.map((p, idx) => {
const cleanName = p.name
.replace(/<[^>]+>/g, "")
.trim();
if (!cleanName) return null;
return (
- -
- {cleanName}
-
+
+ |
+ {idx + 1}.
+ |
+ {cleanName} |
+
);
})}
-
-
+
+
);
})}
diff --git a/artifacts/tx-os/tests/executive-meetings-upcoming-alert.spec.mjs b/artifacts/tx-os/tests/executive-meetings-upcoming-alert.spec.mjs
index 762822e6..6c922a1f 100644
--- a/artifacts/tx-os/tests/executive-meetings-upcoming-alert.spec.mjs
+++ b/artifacts/tx-os/tests/executive-meetings-upcoming-alert.spec.mjs
@@ -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 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");
+});
|