Task #227: Restructure attendee cell controls around section headings
User feedback on Task #220's UX (in Arabic): per-section "+ شخص هنا" chips felt redundant; the user wanted the "+ عنوان فرعي" controls placed at the TOP of each cell and after each section instead, and the "+ شخص هنا" chips removed entirely. Schedule cell (AttendeeFlow): - TOP "+ عنوان فرعي" chip renders BEFORE the first item of each AttendeeFlow group when the cell is empty OR when it already contains at least one subheading. For cells that only contain persons and no subheadings, the chip is intentionally hidden so the pre-#227 visual is preserved (per task's "no regression" clause). Testid: em-add-subheading-top-{addType}. insertAtIndex = items[0].i for non-empty cells, undefined for empty (so the commit appends at slot 0). - After-section "+ عنوان فرعي" chip renders AFTER every section that is followed by another section, including: * person-tail boundary (last person whose next item is subheading) * empty-section boundary (subheading whose next item is subheading) The trailing section is skipped — the trailing "+ عنوان فرعي" button below already covers that slot. Testid: em-add-subheading-after-{i}; insertAtIndex = i + 1. - Both "+ شخص هنا" chips removed (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 (executive-meetings-attendee-insert-reorder.spec.mjs): - Removed the per-section "+ شخص هنا" chip test. - Added 4 new schedule tests (en + ar each): 1. Top chip on an EMPTY cell inserts a subheading at slot 0. 2. Top chip is NOT rendered on a flat list with zero subheadings (preserves the pre-#227 visual). 3. After-section chip inserts a subheading between two existing sections (BEFORE the next-section subheading). 4. After-section chip renders after an EMPTY section (consecutive subheadings) and inserts a subheading at the boundary. - Pre-existing drag tests #2 and #3 unchanged and still pass. - Pre-existing #4 (Manage [ar] mixed person+subheading drag) 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 — pre-existing RTL keyboard-sensor flake from #220. Architect review: PASS on first run; second-pass code-review verdict flagged scope alignment which has now been addressed (top chip on empty cell, no top chip on flat lists, after-section chip on consecutive subheadings, plus matching tests).
This commit is contained in:
@@ -3590,9 +3590,13 @@ function AttendeeFlow({
|
||||
<ul className="flex flex-wrap justify-center items-baseline gap-x-4 gap-y-0.5 text-[#0B1E3F] leading-snug">
|
||||
{/* 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) && (
|
||||
<li
|
||||
key="add-subheading-top"
|
||||
className="basis-full flex justify-center print:hidden"
|
||||
@@ -3601,7 +3605,11 @@ function AttendeeFlow({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
onStartAdd!(addType, "subheading", items[0].i)
|
||||
onStartAdd!(
|
||||
addType,
|
||||
"subheading",
|
||||
items.length > 0 ? items[0].i : undefined,
|
||||
)
|
||||
}
|
||||
data-testid={`em-add-subheading-top-${addType}`}
|
||||
aria-label={addSubheadingLabel}
|
||||
@@ -3657,6 +3665,39 @@ function AttendeeFlow({
|
||||
)}
|
||||
</li>,
|
||||
);
|
||||
// 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(
|
||||
<li
|
||||
key={`add-sub-after-${i}`}
|
||||
className="basis-full flex justify-center print:hidden"
|
||||
data-testid={`em-add-subheading-after-row-${i}`}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
onStartAdd!(addType, "subheading", i + 1)
|
||||
}
|
||||
data-testid={`em-add-subheading-after-${i}`}
|
||||
aria-label={addSubheadingLabel}
|
||||
className="text-[10px] text-blue-600 hover:text-blue-700 hover:bg-blue-50 rounded-full px-2 py-0.5 min-h-[1.25rem] border border-dashed border-blue-300 hover:border-blue-500 transition-colors"
|
||||
>
|
||||
{addSubheadingLabel}
|
||||
</button>
|
||||
</li>,
|
||||
);
|
||||
}
|
||||
return nodes;
|
||||
}
|
||||
// Person row — section-local 0-based index pre-computed above.
|
||||
|
||||
@@ -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 <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("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 <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("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,
|
||||
}) => {
|
||||
|
||||
Reference in New Issue
Block a user