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
-4
View File
@@ -964,8 +964,6 @@
"timeOrderError": "وقت النهاية قبل وقت البداية", "timeOrderError": "وقت النهاية قبل وقت البداية",
"addAttendee": "أضف حاضر", "addAttendee": "أضف حاضر",
"addSubheading": "+ عنوان فرعي", "addSubheading": "+ عنوان فرعي",
"addPersonHere": "+ شخص هنا",
"addPersonHereAria": "إضافة شخص في نهاية هذا القسم",
"addVirtualAttendee": "+ حاضر عبر الاتصال المرئي", "addVirtualAttendee": "+ حاضر عبر الاتصال المرئي",
"addInternalAttendee": "+ حاضر داخلي", "addInternalAttendee": "+ حاضر داخلي",
"addExternalAttendee": "+ حاضر خارجي", "addExternalAttendee": "+ حاضر خارجي",
@@ -1075,8 +1073,6 @@
"moveUp": "تحريك للأعلى", "moveUp": "تحريك للأعلى",
"moveDown": "تحريك للأسفل", "moveDown": "تحريك للأسفل",
"dragHandle": "اسحب لإعادة الترتيب", "dragHandle": "اسحب لإعادة الترتيب",
"addPersonHere": "إضافة شخص هنا",
"addPersonHereAria": "إضافة شخص في نهاية هذا القسم",
"removeAll": "حذف جميع الحضور", "removeAll": "حذف جميع الحضور",
"removeAllConfirm": "حذف جميع الحضور ({{count}})؟\n\nسيتم تطبيق التغيير عند حفظ الاجتماع." "removeAllConfirm": "حذف جميع الحضور ({{count}})؟\n\nسيتم تطبيق التغيير عند حفظ الاجتماع."
}, },
-4
View File
@@ -881,8 +881,6 @@
"timeOrderError": "End time is before start time", "timeOrderError": "End time is before start time",
"addAttendee": "Add attendee", "addAttendee": "Add attendee",
"addSubheading": "+ subheading", "addSubheading": "+ subheading",
"addPersonHere": "+ person here",
"addPersonHereAria": "Add a person at the end of this section",
"addVirtualAttendee": "+ Virtual", "addVirtualAttendee": "+ Virtual",
"addInternalAttendee": "+ Internal", "addInternalAttendee": "+ Internal",
"addExternalAttendee": "+ External", "addExternalAttendee": "+ External",
@@ -976,8 +974,6 @@
"moveUp": "Move up", "moveUp": "Move up",
"moveDown": "Move down", "moveDown": "Move down",
"dragHandle": "Drag to reorder", "dragHandle": "Drag to reorder",
"addPersonHere": "Add person here",
"addPersonHereAria": "Add a person at the end of this section",
"removeAll": "Remove all attendees", "removeAll": "Remove all attendees",
"removeAllConfirm": "Remove all {{count}} attendees?\n\nThe change applies when you save the meeting." "removeAllConfirm": "Remove all {{count}} attendees?\n\nThe change applies when you save the meeting."
}, },
+127 -175
View File
@@ -3208,10 +3208,6 @@ function AttendeesCell({
onCancelAdd: cancelAdd, onCancelAdd: cancelAdd,
addLabel: t("executiveMeetings.schedule.addAttendee"), addLabel: t("executiveMeetings.schedule.addAttendee"),
addSubheadingLabel: t("executiveMeetings.schedule.addSubheading"), addSubheadingLabel: t("executiveMeetings.schedule.addSubheading"),
addPersonHereLabel: t("executiveMeetings.schedule.addPersonHere"),
addPersonHereAriaLabel: t(
"executiveMeetings.schedule.addPersonHereAria",
),
editAriaLabel: t("executiveMeetings.schedule.editAttendeeAria"), editAriaLabel: t("executiveMeetings.schedule.editAttendeeAria"),
newAriaLabel: t("executiveMeetings.schedule.newAttendeeAria"), newAriaLabel: t("executiveMeetings.schedule.newAttendeeAria"),
editSubheadingAriaLabel: t( editSubheadingAriaLabel: t(
@@ -3372,10 +3368,6 @@ type AttendeeFlowSharedProps = {
onCancelAdd?: () => void; onCancelAdd?: () => void;
addLabel: string; addLabel: string;
addSubheadingLabel: string; addSubheadingLabel: string;
// Inline "+ person here" chip rendered right after each subheading row
// so the user can grow a section without scrolling to the trailing "+".
addPersonHereLabel: string;
addPersonHereAriaLabel: string;
editAriaLabel: string; editAriaLabel: string;
newAriaLabel: string; newAriaLabel: string;
editSubheadingAriaLabel: string; editSubheadingAriaLabel: string;
@@ -3421,8 +3413,6 @@ function AttendeeFlow({
onCancelAdd, onCancelAdd,
addLabel, addLabel,
addSubheadingLabel, addSubheadingLabel,
addPersonHereLabel,
addPersonHereAriaLabel,
editAriaLabel, editAriaLabel,
newAriaLabel, newAriaLabel,
editSubheadingAriaLabel, editSubheadingAriaLabel,
@@ -3430,14 +3420,13 @@ function AttendeeFlow({
}: { items: AttendeeWithIndex[] } & AttendeeFlowSharedProps) { }: { items: AttendeeWithIndex[] } & AttendeeFlowSharedProps) {
const editable = canMutate && !!onSaveAttendeeName; const editable = canMutate && !!onSaveAttendeeName;
const showAdd = canMutate && !!onStartAdd && !isPending; const showAdd = canMutate && !!onStartAdd && !isPending;
// True only when the in-flight ghost is targeted at a specific slot // True when the in-flight ghost is targeted at a specific slot — set
// (set by clicking a per-subheading "+ person here" chip). When unset // by clicking either the top "+ عنوان فرعي" chip or the after-section
// we fall back to the legacy trailing render so existing screenshots // "+ عنوان فرعي" chip. The same flag drives inline rendering for the
// and the trailing "+" / "+ subheading" buttons keep working. // person ghost (still wired through the manage dialog flow). When
const hasInlineInsert = // unset we fall back to the legacy trailing render so the trailing
isPending && // "+" / "+ subheading" buttons keep working.
pendingKind === "person" && const hasInlineInsert = isPending && pendingInsertAtIndex !== undefined;
pendingInsertAtIndex !== undefined;
// Each subheading starts a fresh numbered section. Within a section, // Each subheading starts a fresh numbered section. Within a section,
// persons are numbered 1..N starting over after every subheading. // persons are numbered 1..N starting over after every subheading.
@@ -3502,6 +3491,41 @@ function AttendeeFlow({
} }
} }
// The pending subheading ghost <li>. Used for inline rendering when
// the user clicks the top "+ عنوان فرعي" chip or an after-section
// chip — the ghost materializes at the requested slot rather than the
// trailing fallback so the user sees their click land where expected.
const renderPendingSubheadingLi = (key: string) => {
if (
!(canMutate && isPending && pendingKind === "subheading" && onCommitAdd)
) {
return null;
}
return (
<li
key={key}
className="basis-full text-center font-semibold text-[#0B1E3F]/90 mt-1.5 mb-0.5"
data-testid={`em-add-subheading-pending-${addType}`}
>
<EditableCell
key={`pending-sub-${pendingKey}`}
value=""
onSave={(html) =>
onCommitAdd(addType, html, "subheading", pendingInsertAtIndex)
}
ariaLabel={newSubheadingAriaLabel}
dir="auto"
placeholder="…"
className="inline-block align-baseline min-w-[6rem] min-h-[1.5rem] px-0.5 py-0.5 border-b border-dashed border-blue-500 data-[editing=true]:border-b-0 print:border-b-0 print:py-0 print:min-h-0"
testId={`em-add-subheading-pending-input-${addType}`}
startInEditMode
forceSaveOnBlur
onCancel={onCancelAdd}
/>
</li>
);
};
// The pending person ghost <li>. Defined once so we can render it // The pending person ghost <li>. Defined once so we can render it
// either inline (at the slot the user clicked) or as a trailing row // either inline (at the slot the user clicked) or as a trailing row
// (legacy "+" button at the end of the cell). It always shows the next // (legacy "+" button at the end of the cell). It always shows the next
@@ -3564,12 +3588,44 @@ function AttendeeFlow({
return ( return (
<ul className="flex flex-wrap justify-center items-baseline gap-x-4 gap-y-0.5 text-[#0B1E3F] leading-snug"> <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 && (
<li
key="add-subheading-top"
className="basis-full flex justify-center print:hidden"
data-testid={`em-add-subheading-top-row-${addType}`}
>
<button
type="button"
onClick={() =>
onStartAdd!(addType, "subheading", items[0].i)
}
data-testid={`em-add-subheading-top-${addType}`}
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>
)}
{items.flatMap(({ a, i }, listIdx) => { {items.flatMap(({ a, i }, listIdx) => {
const isSub = (a.kind ?? "person") === "subheading"; const isSub = (a.kind ?? "person") === "subheading";
const nodes: ReactNode[] = []; const nodes: ReactNode[] = [];
if (inlineGhostTargetListIdx === listIdx) { if (inlineGhostTargetListIdx === listIdx) {
const ghost = renderPendingPersonLi(`pending-inline-${pendingKey}`); if (pendingKind === "subheading") {
if (ghost) nodes.push(ghost); const ghost = renderPendingSubheadingLi(
`pending-inline-sub-${pendingKey}`,
);
if (ghost) nodes.push(ghost);
} else {
const ghost = renderPendingPersonLi(
`pending-inline-${pendingKey}`,
);
if (ghost) nodes.push(ghost);
}
} }
if (isSub) { if (isSub) {
// Subheading row: full-width line break inside the flex-wrap so // Subheading row: full-width line break inside the flex-wrap so
@@ -3601,39 +3657,6 @@ function AttendeeFlow({
)} )}
</li>, </li>,
); );
// Subheading-only section edge case: if the very next item in
// the list is ALSO a subheading (or this subheading is the
// last item with no person after it), the section it just
// started has zero persons. Render a chip right after the
// subheading so the user can still grow that empty section
// without opening the manage dialog. The chip's insert index
// is the slot immediately after this subheading's global
// attendees index.
const nextItem = items[listIdx + 1];
const sectionIsEmpty =
!nextItem ||
(nextItem.a.kind ?? "person") === "subheading";
if (showAdd && sectionIsEmpty) {
nodes.push(
<li
key={`add-here-sub-${i}`}
className="basis-full flex justify-center print:hidden"
data-testid={`em-add-person-here-row-${i}`}
>
<button
type="button"
onClick={() =>
onStartAdd!(addType, "person", i + 1)
}
data-testid={`em-add-person-here-${i}`}
aria-label={addPersonHereAriaLabel}
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"
>
{addPersonHereLabel}
</button>
</li>,
);
}
return nodes; return nodes;
} }
// Person row — section-local 0-based index pre-computed above. // Person row — section-local 0-based index pre-computed above.
@@ -3683,71 +3706,61 @@ function AttendeeFlow({
)} )}
</li>, </li>,
); );
// Section-tail "+ شخص هنا" chip — appears AFTER the last person // After-section "+ عنوان فرعي" chip — appears AFTER the last
// of each section (a section ends right before the next // person of each section that is followed by ANOTHER section
// subheading, or at end-of-list). The chip's insertAtIndex is // (i.e. the next item is a subheading). This lets the user
// the slot immediately after this person's global attendees // insert a new section heading at the boundary between two
// index — so the new row lands at the end of THIS section, not // existing sections. The trailing section is intentionally
// at the start of the next one. We only render this when the // skipped: the trailing "+ عنوان فرعي" button below already
// cell already has at least one subheading: in a no-subheading // covers that slot, so duplicating the chip would be noise.
// cell the trailing global "+" already covers the same slot. // The chip's insertAtIndex is the slot immediately after this
// person's global attendees index, so the new subheading lands
// BEFORE the existing next-section subheading.
const nextItemForPerson = items[listIdx + 1]; const nextItemForPerson = items[listIdx + 1];
const isLastInSection = const nextIsSubheading =
!nextItemForPerson || nextItemForPerson &&
(nextItemForPerson.a.kind ?? "person") === "subheading"; (nextItemForPerson.a.kind ?? "person") === "subheading";
if (showAdd && hasAnySubheading && isLastInSection) { if (showAdd && nextIsSubheading) {
nodes.push( nodes.push(
<li <li
key={`add-here-after-${i}`} key={`add-sub-after-${i}`}
className="basis-full flex justify-center print:hidden" className="basis-full flex justify-center print:hidden"
data-testid={`em-add-person-here-row-${i}`} data-testid={`em-add-subheading-after-row-${i}`}
> >
<button <button
type="button" type="button"
onClick={() => onClick={() =>
onStartAdd!(addType, "person", i + 1) onStartAdd!(addType, "subheading", i + 1)
} }
data-testid={`em-add-person-here-${i}`} data-testid={`em-add-subheading-after-${i}`}
aria-label={addPersonHereAriaLabel} 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" 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"
> >
{addPersonHereLabel} {addSubheadingLabel}
</button> </button>
</li>, </li>,
); );
} }
return nodes; return nodes;
})} })}
{/* Inline ghost pinned to the trailing slot (insert-at-end case). */} {/* Inline ghost pinned to the trailing slot (insert-at-end case).
Either kind may land here: a person from manage flows, or a
subheading from the trailing "+ عنوان فرعي" button when the
insertAtIndex equals items.length. */}
{hasInlineInsert && {hasInlineInsert &&
inlineGhostTargetListIdx === null && inlineGhostTargetListIdx === null &&
pendingKind === "person" &&
renderPendingPersonLi(`pending-inline-trailing-${pendingKey}`)} renderPendingPersonLi(`pending-inline-trailing-${pendingKey}`)}
{/* Legacy trailing ghost (no inline insert in flight). */} {hasInlineInsert &&
inlineGhostTargetListIdx === null &&
pendingKind === "subheading" &&
renderPendingSubheadingLi(`pending-inline-trailing-sub-${pendingKey}`)}
{/* Legacy trailing ghost (no inline insert in flight) — used for
the global trailing "+" / "+ subheading" buttons. */}
{!hasInlineInsert && {!hasInlineInsert &&
renderPendingPersonLi(`pending-trailing-${pendingKey}`)} renderPendingPersonLi(`pending-trailing-${pendingKey}`)}
{canMutate && {!hasInlineInsert &&
isPending && renderPendingSubheadingLi(`pending-trailing-sub-${pendingKey}`)}
pendingKind === "subheading" &&
onCommitAdd && (
<li
className="basis-full text-center font-semibold text-[#0B1E3F]/90 mt-1.5 mb-0.5"
data-testid={`em-add-subheading-pending-${addType}`}
>
<EditableCell
key={`pending-sub-${pendingKey}`}
value=""
onSave={(html) => onCommitAdd(addType, html, "subheading")}
ariaLabel={newSubheadingAriaLabel}
dir="auto"
placeholder="…"
className="inline-block align-baseline min-w-[6rem] min-h-[1.5rem] px-0.5 py-0.5 border-b border-dashed border-blue-500 data-[editing=true]:border-b-0 print:border-b-0 print:py-0 print:min-h-0"
testId={`em-add-subheading-pending-input-${addType}`}
startInEditMode
forceSaveOnBlur
onCancel={onCancelAdd}
/>
</li>
)}
{showAdd && ( {showAdd && (
<> <>
<li className="print:hidden"> <li className="print:hidden">
@@ -4332,35 +4345,6 @@ function MeetingFormDialog({
], ],
}); });
} }
// Insert a fresh person row at a specific slot (used by the per-section
// "+ شخص هنا" buttons rendered after each subheading inside the manage
// dialog). Falls back to appending when the index is out of range.
function insertAttendeeAt(insertAtIndex: number) {
const safeIdx = Math.max(
0,
Math.min(insertAtIndex, state.attendees.length),
);
const newRow: Attendee = {
name: "",
title: null,
// Inherit the previous row's attendance type so the inserted row
// visually belongs to the same section the user clicked into.
attendanceType:
safeIdx > 0
? state.attendees[safeIdx - 1].attendanceType
: "internal",
sortOrder: safeIdx,
kind: "person",
_sid: genAttendeeSid(),
};
const arr = state.attendees.slice();
arr.splice(safeIdx, 0, newRow);
// Renumber sortOrder so the array stays a contiguous 0..N-1 sequence.
arr.forEach((row, idx) => {
row.sortOrder = idx;
});
onChange({ ...state, attendees: arr });
}
function addSubheading() { function addSubheading() {
onChange({ onChange({
...state, ...state,
@@ -4623,57 +4607,25 @@ function MeetingFormDialog({
items={sortableIds} items={sortableIds}
strategy={verticalListSortingStrategy} strategy={verticalListSortingStrategy}
> >
{state.attendees.flatMap((a, i) => { {state.attendees.map((a, i) => (
const nodes: ReactNode[] = [ <SortableAttendeeRow
<SortableAttendeeRow key={a._sid ?? `__missing-sid-${i}`}
key={a._sid ?? `__missing-sid-${i}`} attendee={a}
attendee={a} index={i}
index={i} last={i === state.attendees.length - 1}
last={i === state.attendees.length - 1} t={t}
t={t} onMoveUp={() => moveAttendee(i, -1)}
onMoveUp={() => moveAttendee(i, -1)} onMoveDown={() => moveAttendee(i, 1)}
onMoveDown={() => moveAttendee(i, 1)} onChangeName={(v) => setAttendee(i, { name: v })}
onChangeName={(v) => setAttendee(i, { name: v })} onChangeTitle={(v) =>
onChangeTitle={(v) => setAttendee(i, { title: v || null })
setAttendee(i, { title: v || null }) }
} onChangeAttendanceType={(v) =>
onChangeAttendanceType={(v) => setAttendee(i, { attendanceType: v })
setAttendee(i, { attendanceType: v }) }
} onRemove={() => removeAttendee(i)}
onRemove={() => removeAttendee(i)} />
/>, ))}
];
// Per-section "+ شخص هنا" button: appears AFTER every
// subheading row so the user can grow the section it
// starts without scrolling to the bottom of the list
// and then dragging the new row up. Insertion target
// is the slot right after the subheading (i + 1).
if ((a.kind ?? "person") === "subheading") {
nodes.push(
<div
key={`mgr-add-here-${a._sid ?? i}`}
className="flex justify-center"
data-testid={`em-mgr-add-person-here-row-${i}`}
>
<button
type="button"
onClick={() => insertAttendeeAt(i + 1)}
data-testid={`em-mgr-add-person-here-${i}`}
aria-label={t(
"executiveMeetings.manage.attendees.addPersonHereAria",
)}
className="text-xs text-blue-600 hover:text-blue-700 hover:bg-blue-50 rounded-full px-2.5 py-1 border border-dashed border-blue-300 hover:border-blue-500 transition-colors"
>
+{" "}
{t(
"executiveMeetings.manage.attendees.addPersonHere",
)}
</button>
</div>,
);
}
return nodes;
})}
</SortableContext> </SortableContext>
</DndContext> </DndContext>
{state.attendees.length === 0 && ( {state.attendees.length === 0 && (
@@ -1,21 +1,24 @@
import { test, expect } from "@playwright/test"; import { test, expect } from "@playwright/test";
import pg from "pg"; import pg from "pg";
// E2E coverage for Task #220 — "Insert persons mid-list and drag-reorder // E2E coverage for Task #227 — "Restructure attendee cell controls
// attendees" inside executive-meetings. // around section headings" inside executive-meetings.
// //
// What we verify: // What we verify:
// 1. Per-section "+ شخص هنا" chip on the schedule grid: the chip is // 1. Top "+ عنوان فرعي" chip on the schedule grid: the chip is
// rendered AFTER THE LAST PERSON of each section (a section ends // rendered BEFORE the first attendee of each AttendeeFlow group.
// right before the next subheading or at end-of-list). Clicking it // Clicking it opens a pending subheading ghost at slot 0 (or the
// opens a ghost row at the slot right after that last person — i.e. // group's first global index) and committing the typed text
// at the TAIL of the section the chip belongs to — and committing // persists the new subheading at the very top of the cell.
// the typed name persists the new attendee at that exact slot, // 2. After-section "+ عنوان فرعي" chip: rendered after the last
// pushing the next subheading down by one. // person of each section that is followed by ANOTHER section
// 2. Drag-and-drop reordering inside the manage dialog: picking up a // (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 // row by its grip handle and moving it down with the keyboard
// reorders the attendee array, and saving persists the new order. // 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. // same way.
const DATABASE_URL = process.env.DATABASE_URL; const DATABASE_URL = process.env.DATABASE_URL;
@@ -164,24 +167,21 @@ const langOffsets2 = { en: 3, ar: 4 };
const langOffsets3 = { en: 5, ar: 6 }; const langOffsets3 = { en: 5, ar: 6 };
for (const lang of ["en", "ar"]) { 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, page,
}) => { }) => {
const date = uniqueFutureDate(langOffsets[lang]); const date = uniqueFutureDate(langOffsets[lang]);
const meetingId = await insertMeeting({ const meetingId = await insertMeeting({
meetingDate: date, meetingDate: date,
dailyNumber: 1, dailyNumber: 1,
titleEn: `Insert Mid Section ${lang} ${Date.now().toString(36)}`, titleEn: `Top Subheading ${lang} ${Date.now().toString(36)}`,
titleAr: `إدراج وسط القسم ${lang} ${Date.now().toString(36)}`, titleAr: `عنوان فرعي علوي ${lang} ${Date.now().toString(36)}`,
startTime: "09:00:00", startTime: "09:00:00",
endTime: "10:00:00", endTime: "10:00:00",
}); });
// Layout: [Alpha, Beta, Sec2Header, Gamma]. // Seed: two internal persons, no subheadings. The top chip should
// Section 1 (implicit) = [Alpha, Beta] ← chip after Beta (idx 1) // render BEFORE PersonAlpha and click-to-insert at attendees idx 0,
// Section 2 = [Gamma] ← chip after Gamma (idx 3) // pushing both persons down by one slot.
// 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.
await insertAttendee({ await insertAttendee({
meetingId, meetingId,
name: "PersonAlpha", name: "PersonAlpha",
@@ -196,20 +196,6 @@ for (const lang of ["en", "ar"]) {
sortOrder: 1, sortOrder: 1,
kind: "person", 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); await setAdminPreferredLanguage(lang);
try { try {
@@ -224,60 +210,168 @@ for (const lang of ["en", "ar"]) {
await expect(row).toBeVisible({ timeout: 15_000 }); await expect(row).toBeVisible({ timeout: 15_000 });
await ensureEditMode(page); await ensureEditMode(page);
// The "+ person here" chip is rendered AFTER each section's last // Top "+ subheading" chip lives at the very top of the
// person row, indexed by that person's global attendees idx. // AttendeeFlow group, addType = internal here.
// Section 1 ends at PersonBeta (idx 1) → chip testid -1. const topChip = row.getByTestId("em-add-subheading-top-internal");
// The chip on the subheading row itself should NOT exist (the await expect(topChip).toBeVisible({ timeout: 10_000 });
// section above it is non-empty). await topChip.click();
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();
// The pending ghost <li> appears, with a Tiptap editor that has // The pending subheading ghost <li> appears with a Tiptap editor.
// started in edit mode. Type the new name and blur to commit. const pending = row.getByTestId("em-add-subheading-pending-internal");
const pending = row.getByTestId("em-add-attendee-pending-internal");
await expect(pending).toBeVisible({ timeout: 5_000 }); await expect(pending).toBeVisible({ timeout: 5_000 });
const editor = pending.locator('[contenteditable="true"]').first(); const editor = pending.locator('[contenteditable="true"]').first();
await expect(editor).toBeVisible({ timeout: 5_000 }); await expect(editor).toBeVisible({ timeout: 5_000 });
await editor.click(); await editor.click();
await editor.fill("InsertedDelta"); await editor.fill("TopSection");
// Blur the editor (forceSaveOnBlur fires the commit) by clicking // Blur to commit by clicking somewhere unrelated.
// the meeting title cell, which is unrelated to the attendee
// input and won't open another editor.
await page.locator("body").click({ position: { x: 5, y: 5 } }); await page.locator("body").click({ position: { x: 5, y: 5 } });
// Wait for the round-trip: the cell should now show 4 person // After the round-trip the cell should show 2 person rows + 1
// rows and 1 subheading. // subheading.
await expect( await expect(
row.locator('[data-testid^="em-attendee-row-"]'), row.locator('[data-testid^="em-attendee-row-"]'),
).toHaveCount(4, { timeout: 10_000 }); ).toHaveCount(2, { timeout: 10_000 });
await expect( await expect(
row.locator('[data-testid^="em-attendee-subheading-"]'), row.locator('[data-testid^="em-attendee-subheading-"]'),
).toHaveCount(1); ).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 persisted = await getAttendeesOrdered(meetingId);
const stripTags = (s) => s.replace(/<[^>]+>/g, "").trim(); const stripTags = (s) => s.replace(/<[^>]+>/g, "").trim();
const orderedNames = persisted.map((r) => stripTags(r.name)); const orderedNames = persisted.map((r) => stripTags(r.name));
expect(orderedNames).toEqual([ expect(orderedNames).toEqual([
"TopSection",
"PersonAlpha", "PersonAlpha",
"PersonBeta", "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", "PersonGamma",
]); ]);
expect(persisted.map((r) => r.sort_order)).toEqual([0, 1, 2, 3, 4]); expect(persisted.map((r) => r.kind)).toEqual([
"subheading",
// Numbering: section 1 = [Alpha, Beta, InsertedDelta] → 1-, 2-, 3-; "person",
// section 2 = [Gamma] → 1- (cell has a subheading so all persons "person",
// are numbered). "subheading",
const indexTexts = await row "subheading",
.locator('[data-testid^="em-attendee-index-"]') "person",
.allInnerTexts(); ]);
const trimmed = indexTexts.map((s) => s.trim().replace(/\s+/g, "")); expect(persisted.map((r) => r.sort_order)).toEqual([0, 1, 2, 3, 4, 5]);
expect(trimmed).toEqual(["1-", "2-", "3-", "1-"]);
} finally { } finally {
if (originalAdminPreferredLanguage) { if (originalAdminPreferredLanguage) {
await setAdminPreferredLanguage(originalAdminPreferredLanguage).catch( await setAdminPreferredLanguage(originalAdminPreferredLanguage).catch(