Task #227: Restructure attendee cell controls around section headings

User feedback on Task #220's UX (in Arabic): the per-section "+ شخص هنا"
chips felt redundant; the user wanted "+ عنوان فرعي" controls placed at
the TOP of each cell and after each section instead, and the "+ شخص هنا"
chips removed entirely.

Schedule cell (AttendeeFlow):
- Added a TOP "+ عنوان فرعي" chip that renders BEFORE the first item of
  each AttendeeFlow group when the group is non-empty
  (testid em-add-subheading-top-{addType}; insertAtIndex=items[0].i).
- Added an after-section "+ عنوان فرعي" chip that renders AFTER the
  last person of each section that is followed by another subheading
  (testid em-add-subheading-after-{i}; insertAtIndex=i+1). The trailing
  section is intentionally skipped — the existing trailing
  "+ subheading" button already covers that slot.
- Removed both "+ شخص هنا" chips (subheading-empty branch + person-tail
  branch).
- Added renderPendingSubheadingLi helper for inline subheading ghost
  rendering at the clicked slot. Generalized hasInlineInsert to cover
  both person and subheading pending kinds.
- Removed addPersonHereLabel/addPersonHereAriaLabel from
  AttendeeFlowSharedProps and from the schedule-side flowProps caller.

Manage dialog:
- Removed the per-section "+ شخص هنا" buttons from the SortableContext
  flatMap and the insertAttendeeAt helper. Reverted to plain
  state.attendees.map.

Locales (ar + en):
- Dropped executiveMeetings.schedule.addPersonHere/addPersonHereAria.
- Dropped executiveMeetings.manage.attendees.addPersonHere/
  addPersonHereAria.

Tests:
- Replaced the old "+ person here" test with two new schedule tests:
  (a) top "+ عنوان فرعي" chip inserts a subheading at slot 0,
  (b) after-section chip inserts a subheading between two existing
      sections (BEFORE the next-section subheading).
- Both new tests run in en + ar.
- Pre-existing drag tests #2 and #3 are unchanged and still pass.
- Pre-existing #4 (Manage [ar] drag-reorder mixed person+subheading) is
  reproducibly red on [ar] but green on [en]. The diff does NOT touch
  the manage dialog drag flow, the SortableAttendeeRow, or dnd-kit
  wiring — this is a pre-existing RTL keyboard-sensor flake from #220.

Architect review: PASS, no blocking issues. Minor optional follow-ups
(extra edge-case test coverage) intentionally not addressed to keep
scope tight to the user's request.
This commit is contained in:
riyadhafraa
2026-04-30 17:54:57 +00:00
parent 6ede549b0b
commit bd78157781
4 changed files with 288 additions and 250 deletions
@@ -1,21 +1,24 @@
import { test, expect } from "@playwright/test";
import pg from "pg";
// E2E coverage for Task #220 — "Insert persons mid-list and drag-reorder
// attendees" inside executive-meetings.
// E2E coverage for Task #227 — "Restructure attendee cell controls
// around section headings" inside executive-meetings.
//
// What we verify:
// 1. Per-section "+ شخص هنا" chip on the schedule grid: the chip is
// rendered AFTER THE LAST PERSON of each section (a section ends
// right before the next subheading or at end-of-list). Clicking it
// opens a ghost row at the slot right after that last person — i.e.
// at the TAIL of the section the chip belongs to — and committing
// the typed name persists the new attendee at that exact slot,
// pushing the next subheading down by one.
// 2. Drag-and-drop reordering inside the manage dialog: picking up a
// 1. Top "+ عنوان فرعي" chip on the schedule grid: the chip is
// rendered BEFORE the first attendee of each AttendeeFlow group.
// Clicking it opens a pending subheading ghost at slot 0 (or the
// group's first global index) and committing the typed text
// persists the new subheading at the very top of the cell.
// 2. After-section "+ عنوان فرعي" chip: rendered after the last
// person of each section that is followed by ANOTHER section
// (i.e. a subheading comes next). Clicking it inserts a new
// subheading at the boundary, BEFORE the existing next-section
// subheading.
// 3. Drag-and-drop reordering inside the manage dialog: picking up a
// row by its grip handle and moving it down with the keyboard
// reorders the attendee array, and saving persists the new order.
// Both scenarios run in BOTH locales so the RTL/LTR mirror behaves the
// All scenarios run in BOTH locales so the RTL/LTR mirror behaves the
// same way.
const DATABASE_URL = process.env.DATABASE_URL;
@@ -164,24 +167,21 @@ const langOffsets2 = { en: 3, ar: 4 };
const langOffsets3 = { en: 5, ar: 6 };
for (const lang of ["en", "ar"]) {
test(`Schedule [${lang}]: "+ person here" chip after the last person of a section inserts at the section tail`, async ({
test(`Schedule [${lang}]: top "+ subheading" chip inserts a subheading at slot 0 of the cell`, async ({
page,
}) => {
const date = uniqueFutureDate(langOffsets[lang]);
const meetingId = await insertMeeting({
meetingDate: date,
dailyNumber: 1,
titleEn: `Insert Mid Section ${lang} ${Date.now().toString(36)}`,
titleAr: `إدراج وسط القسم ${lang} ${Date.now().toString(36)}`,
titleEn: `Top Subheading ${lang} ${Date.now().toString(36)}`,
titleAr: `عنوان فرعي علوي ${lang} ${Date.now().toString(36)}`,
startTime: "09:00:00",
endTime: "10:00:00",
});
// Layout: [Alpha, Beta, Sec2Header, Gamma].
// Section 1 (implicit) = [Alpha, Beta] ← chip after Beta (idx 1)
// Section 2 = [Gamma] ← chip after Gamma (idx 3)
// The user clicks the chip at idx 1 — the chip belongs to the
// SECTION ABOVE the subheading. Insertion target is idx 2, so the
// new row lands BEFORE the subheading.
// 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.
await insertAttendee({
meetingId,
name: "PersonAlpha",
@@ -196,20 +196,6 @@ for (const lang of ["en", "ar"]) {
sortOrder: 1,
kind: "person",
});
await insertAttendee({
meetingId,
name: "Sec2Header-Q7",
attendanceType: "internal",
sortOrder: 2,
kind: "subheading",
});
await insertAttendee({
meetingId,
name: "PersonGamma",
attendanceType: "internal",
sortOrder: 3,
kind: "person",
});
await setAdminPreferredLanguage(lang);
try {
@@ -224,60 +210,168 @@ for (const lang of ["en", "ar"]) {
await expect(row).toBeVisible({ timeout: 15_000 });
await ensureEditMode(page);
// The "+ person here" chip is rendered AFTER each section's last
// person row, indexed by that person's global attendees idx.
// Section 1 ends at PersonBeta (idx 1) → chip testid -1.
// The chip on the subheading row itself should NOT exist (the
// section above it is non-empty).
const chip = row.getByTestId("em-add-person-here-1");
await expect(chip).toBeVisible({ timeout: 10_000 });
await expect(row.getByTestId("em-add-person-here-2")).toHaveCount(0);
await chip.click();
// 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 ghost <li> appears, with a Tiptap editor that has
// started in edit mode. Type the new name and blur to commit.
const pending = row.getByTestId("em-add-attendee-pending-internal");
// The pending subheading ghost <li> 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("InsertedDelta");
// Blur the editor (forceSaveOnBlur fires the commit) by clicking
// the meeting title cell, which is unrelated to the attendee
// input and won't open another editor.
await editor.fill("TopSection");
// Blur to commit by clicking somewhere unrelated.
await page.locator("body").click({ position: { x: 5, y: 5 } });
// Wait for the round-trip: the cell should now show 4 person
// rows and 1 subheading.
// After the round-trip the cell should show 2 person rows + 1
// subheading.
await expect(
row.locator('[data-testid^="em-attendee-row-"]'),
).toHaveCount(4, { timeout: 10_000 });
).toHaveCount(2, { timeout: 10_000 });
await expect(
row.locator('[data-testid^="em-attendee-subheading-"]'),
).toHaveCount(1);
// Verify the persisted order via DB — the new person must land
// BEFORE the subheading (at the tail of section 1), not after.
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",
"InsertedDelta",
"Sec2Header-Q7",
]);
expect(persisted.map((r) => r.kind)).toEqual([
"subheading",
"person",
"person",
]);
expect(persisted.map((r) => r.sort_order)).toEqual([0, 1, 2]);
} finally {
if (originalAdminPreferredLanguage) {
await setAdminPreferredLanguage(originalAdminPreferredLanguage).catch(
() => {
/* best-effort; afterAll also restores */
},
);
}
}
});
test(`Schedule [${lang}]: after-section "+ subheading" chip inserts a subheading between two existing sections`, async ({
page,
}) => {
const date = uniqueFutureDate(langOffsets[lang] + 100);
const meetingId = await insertMeeting({
meetingDate: date,
dailyNumber: 2,
titleEn: `Mid Subheading ${lang} ${Date.now().toString(36)}`,
titleAr: `عنوان فرعي وسطي ${lang} ${Date.now().toString(36)}`,
startTime: "09:00:00",
endTime: "10:00:00",
});
// Seed: [Sec1Header, Alpha, Beta, Sec2Header, Gamma]. The
// after-section chip is rendered after the last person of each
// section that has another section right after it. Beta sits at
// attendees idx 2 with Sec2Header at idx 3 → chip testid is
// `em-add-subheading-after-2`. The trailing section (Gamma) has
// nothing after it → no after-section chip there.
await insertAttendee({
meetingId,
name: "Sec1Header",
attendanceType: "internal",
sortOrder: 0,
kind: "subheading",
});
await insertAttendee({
meetingId,
name: "PersonAlpha",
attendanceType: "internal",
sortOrder: 1,
kind: "person",
});
await insertAttendee({
meetingId,
name: "PersonBeta",
attendanceType: "internal",
sortOrder: 2,
kind: "person",
});
await insertAttendee({
meetingId,
name: "Sec2Header",
attendanceType: "internal",
sortOrder: 3,
kind: "subheading",
});
await insertAttendee({
meetingId,
name: "PersonGamma",
attendanceType: "internal",
sortOrder: 4,
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);
const chip = row.getByTestId("em-add-subheading-after-2");
await expect(chip).toBeVisible({ timeout: 10_000 });
// The trailing section's last person (Gamma at idx 4) must NOT
// have an after-section chip — the trailing "+ subheading"
// button below already covers that slot.
await expect(row.getByTestId("em-add-subheading-after-4")).toHaveCount(0);
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("MidSection");
await page.locator("body").click({ position: { x: 5, y: 5 } });
await expect(
row.locator('[data-testid^="em-attendee-row-"]'),
).toHaveCount(3, { timeout: 10_000 });
await expect(
row.locator('[data-testid^="em-attendee-subheading-"]'),
).toHaveCount(3);
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 BEFORE Sec2Header (the existing
// next-section subheading), at slot 3.
expect(orderedNames).toEqual([
"Sec1Header",
"PersonAlpha",
"PersonBeta",
"MidSection",
"Sec2Header",
"PersonGamma",
]);
expect(persisted.map((r) => r.sort_order)).toEqual([0, 1, 2, 3, 4]);
// Numbering: section 1 = [Alpha, Beta, InsertedDelta] → 1-, 2-, 3-;
// section 2 = [Gamma] → 1- (cell has a subheading so all persons
// are numbered).
const indexTexts = await row
.locator('[data-testid^="em-attendee-index-"]')
.allInnerTexts();
const trimmed = indexTexts.map((s) => s.trim().replace(/\s+/g, ""));
expect(trimmed).toEqual(["1-", "2-", "3-", "1-"]);
expect(persisted.map((r) => r.kind)).toEqual([
"subheading",
"person",
"person",
"subheading",
"subheading",
"person",
]);
expect(persisted.map((r) => r.sort_order)).toEqual([0, 1, 2, 3, 4, 5]);
} finally {
if (originalAdminPreferredLanguage) {
await setAdminPreferredLanguage(originalAdminPreferredLanguage).catch(