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:
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user