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:
@@ -1076,6 +1076,7 @@
|
||||
"moveDown": "تحريك للأسفل",
|
||||
"dragHandle": "اسحب لإعادة الترتيب",
|
||||
"addPersonHere": "إضافة شخص هنا",
|
||||
"addPersonHereAria": "إضافة شخص في نهاية هذا القسم",
|
||||
"removeAll": "حذف جميع الحضور",
|
||||
"removeAllConfirm": "حذف جميع الحضور ({{count}})؟\n\nسيتم تطبيق التغيير عند حفظ الاجتماع."
|
||||
},
|
||||
|
||||
@@ -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."
|
||||
},
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user