diff --git a/artifacts/tx-os/src/pages/executive-meetings.tsx b/artifacts/tx-os/src/pages/executive-meetings.tsx
index 586b61f6..d0f9c0e4 100644
--- a/artifacts/tx-os/src/pages/executive-meetings.tsx
+++ b/artifacts/tx-os/src/pages/executive-meetings.tsx
@@ -3590,9 +3590,13 @@ function AttendeeFlow({
{/* Top "+ عنوان فرعي" chip — lets the user start the cell with a
labeled section, slotted BEFORE the first existing item. We
- only render it when the cell already has at least one item;
- for empty cells the trailing "+ subheading" button suffices. */}
- {showAdd && items.length > 0 && (
+ render it for empty cells (so the user can begin the cell
+ with a heading) and for cells that already contain at least
+ one subheading (so the user can prepend a new section). For
+ cells that contain only persons and no subheadings we keep
+ the original visual unchanged — the trailing "+ subheading"
+ button below is the sole subheading affordance there. */}
+ {showAdd && (items.length === 0 || hasAnySubheading) && (
,
);
+ // Empty-section boundary: this subheading starts a section
+ // that has zero persons (the next item is ANOTHER subheading,
+ // i.e. the section it leads is empty). The user still needs a
+ // way to insert a NEW subheading between this section and the
+ // next one — so render the after-section chip right after the
+ // subheading itself. The trailing-section case (no next item)
+ // is intentionally skipped: the trailing "+ عنوان فرعي"
+ // button at the cell's end already covers that slot.
+ const nextItemForSub = items[listIdx + 1];
+ const nextSubIsSubheading =
+ nextItemForSub &&
+ (nextItemForSub.a.kind ?? "person") === "subheading";
+ if (showAdd && nextSubIsSubheading) {
+ nodes.push(
+
+
+
,
+ );
+ }
return nodes;
}
// Person row — section-local 0-based index pre-computed above.
diff --git a/artifacts/tx-os/tests/executive-meetings-attendee-insert-reorder.spec.mjs b/artifacts/tx-os/tests/executive-meetings-attendee-insert-reorder.spec.mjs
index 73f051df..0803e5c7 100644
--- a/artifacts/tx-os/tests/executive-meetings-attendee-insert-reorder.spec.mjs
+++ b/artifacts/tx-os/tests/executive-meetings-attendee-insert-reorder.spec.mjs
@@ -167,31 +167,104 @@ const langOffsets2 = { en: 3, ar: 4 };
const langOffsets3 = { en: 5, ar: 6 };
for (const lang of ["en", "ar"]) {
- test(`Schedule [${lang}]: top "+ subheading" chip inserts a subheading at slot 0 of the cell`, async ({
+ test(`Schedule [${lang}]: top "+ subheading" chip on an EMPTY cell starts a pending subheading at slot 0`, async ({
page,
}) => {
const date = uniqueFutureDate(langOffsets[lang]);
const meetingId = await insertMeeting({
meetingDate: date,
dailyNumber: 1,
- titleEn: `Top Subheading ${lang} ${Date.now().toString(36)}`,
- titleAr: `عنوان فرعي علوي ${lang} ${Date.now().toString(36)}`,
+ titleEn: `Top Subheading Empty ${lang} ${Date.now().toString(36)}`,
+ titleAr: `عنوان فرعي علوي فارغ ${lang} ${Date.now().toString(36)}`,
startTime: "09:00:00",
endTime: "10:00:00",
});
- // Seed: two internal persons, no subheadings. The top chip should
- // render BEFORE PersonAlpha and click-to-insert at attendees idx 0,
- // pushing both persons down by one slot.
+ // No attendees seeded — the cell is empty. The top chip should
+ // render in the empty AttendeeFlow group (default addType = internal)
+ // and click-to-insert a subheading at slot 0 of the cell.
+
+ await setAdminPreferredLanguage(lang);
+ try {
+ await loginViaUi(page);
+ await page.goto("/executive-meetings");
+ await expect(page.locator("html")).toHaveAttribute("lang", lang, {
+ timeout: 5_000,
+ });
+ await page.locator('input[type="date"]').first().fill(date);
+
+ const row = page.getByTestId(`em-row-${meetingId}`);
+ await expect(row).toBeVisible({ timeout: 15_000 });
+ await ensureEditMode(page);
+
+ // Top "+ subheading" chip lives at the very top of the
+ // AttendeeFlow group. For an empty meeting the default
+ // single-group addType is "internal".
+ const topChip = row.getByTestId("em-add-subheading-top-internal");
+ await expect(topChip).toBeVisible({ timeout: 10_000 });
+ await topChip.click();
+
+ // The pending subheading ghost
appears with a Tiptap editor.
+ const pending = row.getByTestId("em-add-subheading-pending-internal");
+ await expect(pending).toBeVisible({ timeout: 5_000 });
+ const editor = pending.locator('[contenteditable="true"]').first();
+ await expect(editor).toBeVisible({ timeout: 5_000 });
+ await editor.click();
+ await editor.fill("FirstSection");
+ // Blur to commit by clicking somewhere unrelated.
+ await page.locator("body").click({ position: { x: 5, y: 5 } });
+
+ // After the round-trip the cell should show 0 person rows + 1
+ // subheading at slot 0.
+ await expect(
+ row.locator('[data-testid^="em-attendee-subheading-"]'),
+ ).toHaveCount(1, { timeout: 10_000 });
+ await expect(
+ row.locator('[data-testid^="em-attendee-row-"]'),
+ ).toHaveCount(0);
+
+ const persisted = await getAttendeesOrdered(meetingId);
+ const stripTags = (s) => s.replace(/<[^>]+>/g, "").trim();
+ const orderedNames = persisted.map((r) => stripTags(r.name));
+ expect(orderedNames).toEqual(["FirstSection"]);
+ expect(persisted.map((r) => r.kind)).toEqual(["subheading"]);
+ expect(persisted.map((r) => r.sort_order)).toEqual([0]);
+ } finally {
+ if (originalAdminPreferredLanguage) {
+ await setAdminPreferredLanguage(originalAdminPreferredLanguage).catch(
+ () => {
+ /* best-effort; afterAll also restores */
+ },
+ );
+ }
+ }
+ });
+
+ test(`Schedule [${lang}]: top "+ subheading" chip is NOT rendered on a flat list with zero subheadings`, async ({
+ page,
+ }) => {
+ const date = uniqueFutureDate(langOffsets[lang] + 50);
+ const meetingId = await insertMeeting({
+ meetingDate: date,
+ dailyNumber: 3,
+ titleEn: `No Top Subheading ${lang} ${Date.now().toString(36)}`,
+ titleAr: `بدون عنوان فرعي علوي ${lang} ${Date.now().toString(36)}`,
+ startTime: "09:00:00",
+ endTime: "10:00:00",
+ });
+ // A flat list of persons, no subheadings. Per the task spec, this
+ // configuration must remain visually unchanged: the top chip must
+ // NOT render here so the cell still looks exactly like it did
+ // before Task #227.
await insertAttendee({
meetingId,
- name: "PersonAlpha",
+ name: "FlatPersonAlpha",
attendanceType: "internal",
sortOrder: 0,
kind: "person",
});
await insertAttendee({
meetingId,
- name: "PersonBeta",
+ name: "FlatPersonBeta",
attendanceType: "internal",
sortOrder: 1,
kind: "person",
@@ -210,45 +283,27 @@ for (const lang of ["en", "ar"]) {
await expect(row).toBeVisible({ timeout: 15_000 });
await ensureEditMode(page);
- // Top "+ subheading" chip lives at the very top of the
- // AttendeeFlow group, addType = internal here.
- const topChip = row.getByTestId("em-add-subheading-top-internal");
- await expect(topChip).toBeVisible({ timeout: 10_000 });
- await topChip.click();
-
- // The pending subheading ghost
appears with a Tiptap editor.
- const pending = row.getByTestId("em-add-subheading-pending-internal");
- await expect(pending).toBeVisible({ timeout: 5_000 });
- const editor = pending.locator('[contenteditable="true"]').first();
- await expect(editor).toBeVisible({ timeout: 5_000 });
- await editor.click();
- await editor.fill("TopSection");
- // Blur to commit by clicking somewhere unrelated.
- await page.locator("body").click({ position: { x: 5, y: 5 } });
-
- // After the round-trip the cell should show 2 person rows + 1
- // subheading.
+ // Both persons rendered, no subheading rows.
await expect(
row.locator('[data-testid^="em-attendee-row-"]'),
).toHaveCount(2, { timeout: 10_000 });
await expect(
row.locator('[data-testid^="em-attendee-subheading-"]'),
- ).toHaveCount(1);
+ ).toHaveCount(0);
- const persisted = await getAttendeesOrdered(meetingId);
- const stripTags = (s) => s.replace(/<[^>]+>/g, "").trim();
- const orderedNames = persisted.map((r) => stripTags(r.name));
- expect(orderedNames).toEqual([
- "TopSection",
- "PersonAlpha",
- "PersonBeta",
- ]);
- expect(persisted.map((r) => r.kind)).toEqual([
- "subheading",
- "person",
- "person",
- ]);
- expect(persisted.map((r) => r.sort_order)).toEqual([0, 1, 2]);
+ // Top chip must NOT exist — preserves the pre-#227 visual.
+ await expect(
+ row.getByTestId("em-add-subheading-top-internal"),
+ ).toHaveCount(0);
+ // After-section chip must not exist either (no sections).
+ await expect(
+ row.locator('[data-testid^="em-add-subheading-after-"]'),
+ ).toHaveCount(0);
+ // The trailing "+ subheading" button still exists so users can
+ // start sectioning the cell when they want.
+ await expect(
+ row.getByTestId("em-add-subheading-internal"),
+ ).toBeVisible();
} finally {
if (originalAdminPreferredLanguage) {
await setAdminPreferredLanguage(originalAdminPreferredLanguage).catch(
@@ -383,6 +438,104 @@ for (const lang of ["en", "ar"]) {
}
});
+ test(`Schedule [${lang}]: after-section "+ subheading" chip renders after an EMPTY section (consecutive subheadings)`, async ({
+ page,
+ }) => {
+ const date = uniqueFutureDate(langOffsets[lang] + 200);
+ const meetingId = await insertMeeting({
+ meetingDate: date,
+ dailyNumber: 4,
+ titleEn: `Empty Section ${lang} ${Date.now().toString(36)}`,
+ titleAr: `قسم فارغ ${lang} ${Date.now().toString(36)}`,
+ startTime: "09:00:00",
+ endTime: "10:00:00",
+ });
+ // Seed: [SubA, SubB, PersonZ]. SubA leads an EMPTY section (next
+ // item is also a subheading). The user must be able to insert a
+ // new subheading at the boundary between SubA and SubB — the chip
+ // is rendered AFTER SubA (testid em-add-subheading-after-0,
+ // insertAtIndex=1).
+ await insertAttendee({
+ meetingId,
+ name: "SubA",
+ attendanceType: "internal",
+ sortOrder: 0,
+ kind: "subheading",
+ });
+ await insertAttendee({
+ meetingId,
+ name: "SubB",
+ attendanceType: "internal",
+ sortOrder: 1,
+ kind: "subheading",
+ });
+ await insertAttendee({
+ meetingId,
+ name: "PersonZ",
+ attendanceType: "internal",
+ sortOrder: 2,
+ kind: "person",
+ });
+
+ await setAdminPreferredLanguage(lang);
+ try {
+ await loginViaUi(page);
+ await page.goto("/executive-meetings");
+ await expect(page.locator("html")).toHaveAttribute("lang", lang, {
+ timeout: 5_000,
+ });
+ await page.locator('input[type="date"]').first().fill(date);
+
+ const row = page.getByTestId(`em-row-${meetingId}`);
+ await expect(row).toBeVisible({ timeout: 15_000 });
+ await ensureEditMode(page);
+
+ // The empty-section chip lives right after SubA (idx 0), since
+ // SubB at idx 1 is also a subheading.
+ const chip = row.getByTestId("em-add-subheading-after-0");
+ await expect(chip).toBeVisible({ timeout: 10_000 });
+ await chip.click();
+
+ const pending = row.getByTestId("em-add-subheading-pending-internal");
+ await expect(pending).toBeVisible({ timeout: 5_000 });
+ const editor = pending.locator('[contenteditable="true"]').first();
+ await expect(editor).toBeVisible({ timeout: 5_000 });
+ await editor.click();
+ await editor.fill("InsertedMid");
+ await page.locator("body").click({ position: { x: 5, y: 5 } });
+
+ await expect(
+ row.locator('[data-testid^="em-attendee-subheading-"]'),
+ ).toHaveCount(3, { timeout: 10_000 });
+
+ const persisted = await getAttendeesOrdered(meetingId);
+ const stripTags = (s) => s.replace(/<[^>]+>/g, "").trim();
+ const orderedNames = persisted.map((r) => stripTags(r.name));
+ // The new subheading must land at idx 1 (between SubA and SubB).
+ expect(orderedNames).toEqual([
+ "SubA",
+ "InsertedMid",
+ "SubB",
+ "PersonZ",
+ ]);
+ expect(persisted.map((r) => r.kind)).toEqual([
+ "subheading",
+ "subheading",
+ "subheading",
+ "person",
+ ]);
+ expect(persisted.map((r) => r.sort_order)).toEqual([0, 1, 2, 3]);
+ } finally {
+ if (originalAdminPreferredLanguage) {
+ await setAdminPreferredLanguage(originalAdminPreferredLanguage).catch(
+ () => {
+ /* best-effort; afterAll also restores */
+ },
+ );
+ }
+ }
+ });
+
test(`Manage [${lang}]: drag handle reorders attendees inside the dialog`, async ({
page,
}) => {