Task #220: insert persons mid-list + drag-reorder attendees

Schedule grid (AttendeeFlow):
- Per-section "+ شخص هنا" chip rendered AFTER the last PERSON of each
  section (a section ends right before the next subheading or at
  end-of-list), gated by hasAnySubheading. Clicking it opens a ghost
  row with insertAtIndex = lastPersonIdx + 1 so the new row lands at
  the TAIL of THAT section, before the next subheading. The chip
  belongs to the section ABOVE it, matching the user's spec.
- Empty-section fallback chip kept on subheading rows: when a
  subheading is followed by another subheading or end-of-list, render
  the chip there so empty sections stay growable.

Manage dialog:
- Added _sid client-only field with genAttendeeSid() so dnd-kit ids
  stay stable across renames; stripped from save projection.
- DnD via dnd-kit (DndContext / SortableContext / SortableAttendeeRow
  with GripVertical handle, keyboard + pointer sensors). New
  reorderAttendeesByDrag + sortableIds memo.
- New insertAttendeeAt(idx) helper + per-section "+ شخص هنا" buttons
  injected as non-sortable siblings inside SortableContext after each
  subheading (sortableIds remains a pure _sid list so the keyboard
  sensor still works).

Locales: addPersonHere + addPersonHereAria in both ar.json and
en.json (schedule + manage scopes).

Tests:
- New e2e spec executive-meetings-attendee-insert-reorder.spec.mjs:
  (1) chip after last person of section inserts BEFORE the next
  subheading; asserts em-add-person-here-2 (subheading idx) does NOT
  exist in [A,B,SUB,C]; (2) keyboard DnD reorders 3 persons;
  (3) mixed person + subheading drag preserves kind. 6/6 pass in
  AR + EN.
- Existing subheading e2e (Task #207): 6/6 still pass.

Process notes:
- First mark_task_complete REJECTED by code review for placing chip
  after subheading instead of after last person of prior section;
  this commit is the corrected pass. Architect re-review approved
  the semantic; only follow-up was the missing manage-scope
  addPersonHereAria locale key, which is added here.
This commit is contained in:
riyadhafraa
2026-04-30 15:03:29 +00:00
parent a0e12a15f7
commit 6132d2a00a
4 changed files with 156 additions and 52 deletions
+1
View File
@@ -1076,6 +1076,7 @@
"moveDown": "تحريك للأسفل",
"dragHandle": "اسحب لإعادة الترتيب",
"addPersonHere": "إضافة شخص هنا",
"addPersonHereAria": "إضافة شخص في نهاية هذا القسم",
"removeAll": "حذف جميع الحضور",
"removeAllConfirm": "حذف جميع الحضور ({{count}})؟\n\nسيتم تطبيق التغيير عند حفظ الاجتماع."
},
+1
View File
@@ -977,6 +977,7 @@
"moveDown": "Move down",
"dragHandle": "Drag to reorder",
"addPersonHere": "Add person here",
"addPersonHereAria": "Add a person at the end of this section",
"removeAll": "Remove all attendees",
"removeAllConfirm": "Remove all {{count}} attendees?\n\nThe change applies when you save the meeting."
},
+127 -26
View File
@@ -3601,15 +3601,22 @@ function AttendeeFlow({
)}
</li>,
);
// Inline "+ person here" chip — lets the user grow this section
// without scrolling to the trailing "+". Hidden while any ghost
// is open (showAdd already encodes that). The chip occupies a
// full-width line below the subheading, mirroring its layout,
// so it visually anchors to the section it adds into.
if (showAdd) {
// 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-${i}`}
key={`add-here-sub-${i}`}
className="basis-full flex justify-center print:hidden"
data-testid={`em-add-person-here-row-${i}`}
>
@@ -3676,6 +3683,39 @@ function AttendeeFlow({
)}
</li>,
);
// Section-tail "+ شخص هنا" chip — appears AFTER the last person
// of each section (a section ends right before the next
// subheading, or at end-of-list). The chip's insertAtIndex is
// the slot immediately after this person's global attendees
// index — so the new row lands at the end of THIS section, not
// at the start of the next one. We only render this when the
// cell already has at least one subheading: in a no-subheading
// cell the trailing global "+" already covers the same slot.
const nextItemForPerson = items[listIdx + 1];
const isLastInSection =
!nextItemForPerson ||
(nextItemForPerson.a.kind ?? "person") === "subheading";
if (showAdd && hasAnySubheading && isLastInSection) {
nodes.push(
<li
key={`add-here-after-${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;
})}
{/* Inline ghost pinned to the trailing slot (insert-at-end case). */}
@@ -4292,6 +4332,35 @@ 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() {
onChange({
...state,
@@ -4554,25 +4623,57 @@ function MeetingFormDialog({
items={sortableIds}
strategy={verticalListSortingStrategy}
>
{state.attendees.map((a, i) => (
<SortableAttendeeRow
key={a._sid ?? `__missing-sid-${i}`}
attendee={a}
index={i}
last={i === state.attendees.length - 1}
t={t}
onMoveUp={() => moveAttendee(i, -1)}
onMoveDown={() => moveAttendee(i, 1)}
onChangeName={(v) => setAttendee(i, { name: v })}
onChangeTitle={(v) =>
setAttendee(i, { title: v || null })
}
onChangeAttendanceType={(v) =>
setAttendee(i, { attendanceType: v })
}
onRemove={() => removeAttendee(i)}
/>
))}
{state.attendees.flatMap((a, i) => {
const nodes: ReactNode[] = [
<SortableAttendeeRow
key={a._sid ?? `__missing-sid-${i}`}
attendee={a}
index={i}
last={i === state.attendees.length - 1}
t={t}
onMoveUp={() => moveAttendee(i, -1)}
onMoveDown={() => moveAttendee(i, 1)}
onChangeName={(v) => setAttendee(i, { name: v })}
onChangeTitle={(v) =>
setAttendee(i, { title: v || null })
}
onChangeAttendanceType={(v) =>
setAttendee(i, { attendanceType: v })
}
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>
</DndContext>
{state.attendees.length === 0 && (
@@ -5,11 +5,13 @@ import pg from "pg";
// attendees" inside executive-meetings.
//
// What we verify:
// 1. Per-section "+ شخص هنا" chip on the schedule grid: clicking the
// inline chip rendered after a subheading opens a ghost row at the
// slot right after that subheading (not at the trailing slot), and
// committing the typed name persists the new attendee BETWEEN the
// subheading and the next existing person.
// 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
// row by its grip handle and moving it down with the keyboard
// reorders the attendee array, and saving persists the new order.
@@ -162,7 +164,7 @@ 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 a subheading inserts at that slot`, async ({
test(`Schedule [${lang}]: "+ person here" chip after the last person of a section inserts at the section tail`, async ({
page,
}) => {
const date = uniqueFutureDate(langOffsets[lang]);
@@ -174,9 +176,12 @@ for (const lang of ["en", "ar"]) {
startTime: "09:00:00",
endTime: "10:00:00",
});
// Internal section with subheading wedged between persons. After the
// user clicks "+ person here" right after "Sec2Header" we want the
// new person to land at sort_order 3, pushing "PersonGamma" to 4.
// 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.
await insertAttendee({
meetingId,
name: "PersonAlpha",
@@ -219,18 +224,20 @@ for (const lang of ["en", "ar"]) {
await expect(row).toBeVisible({ timeout: 15_000 });
await ensureEditMode(page);
// The "+ person here" chip renders right below each subheading
// row, indexed by the subheading's array position (2 here).
const chip = row.getByTestId("em-add-person-here-2");
// 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();
// 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");
await expect(pending).toBeVisible({ timeout: 5_000 });
// The EditableCell mounts a contenteditable div — find it inside
// the pending wrapper and type into it.
const editor = pending.locator('[contenteditable="true"]').first();
await expect(editor).toBeVisible({ timeout: 5_000 });
await editor.click();
@@ -250,33 +257,27 @@ for (const lang of ["en", "ar"]) {
).toHaveCount(1);
// Verify the persisted order via DB — the new person must land
// BETWEEN the subheading and the previously-trailing person.
// BEFORE the subheading (at the tail of section 1), not after.
const persisted = await getAttendeesOrdered(meetingId);
// The Tiptap editor stores names as rich HTML (e.g. "<p>X</p>"),
// while DB-seeded rows are plain strings. Strip tags before
// comparing so the assertion stays focused on order, not on the
// wrapper element the editor happens to emit.
const stripTags = (s) => s.replace(/<[^>]+>/g, "").trim();
const orderedNames = persisted.map((r) => stripTags(r.name));
expect(orderedNames).toEqual([
"PersonAlpha",
"PersonBeta",
"Sec2Header-Q7",
"InsertedDelta",
"Sec2Header-Q7",
"PersonGamma",
]);
// sort_order must be a contiguous 0..N-1 sequence after the
// splice + renumber path in commitAddAttendee.
expect(persisted.map((r) => r.sort_order)).toEqual([0, 1, 2, 3, 4]);
// Numbering: section 1 = [Alpha, Beta] → 1-, 2-; section 2 =
// [InsertedDelta, Gamma] → 1-, 2- (cell has a subheading so all
// persons are numbered).
// 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-", "1-", "2-"]);
expect(trimmed).toEqual(["1-", "2-", "3-", "1-"]);
} finally {
if (originalAdminPreferredLanguage) {
await setAdminPreferredLanguage(originalAdminPreferredLanguage).catch(