diff --git a/artifacts/tx-os/src/locales/ar.json b/artifacts/tx-os/src/locales/ar.json index da3ba1e0..692943db 100644 --- a/artifacts/tx-os/src/locales/ar.json +++ b/artifacts/tx-os/src/locales/ar.json @@ -1076,6 +1076,7 @@ "moveDown": "تحريك للأسفل", "dragHandle": "اسحب لإعادة الترتيب", "addPersonHere": "إضافة شخص هنا", + "addPersonHereAria": "إضافة شخص في نهاية هذا القسم", "removeAll": "حذف جميع الحضور", "removeAllConfirm": "حذف جميع الحضور ({{count}})؟\n\nسيتم تطبيق التغيير عند حفظ الاجتماع." }, diff --git a/artifacts/tx-os/src/locales/en.json b/artifacts/tx-os/src/locales/en.json index 0fd0b3eb..d0c48439 100644 --- a/artifacts/tx-os/src/locales/en.json +++ b/artifacts/tx-os/src/locales/en.json @@ -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." }, diff --git a/artifacts/tx-os/src/pages/executive-meetings.tsx b/artifacts/tx-os/src/pages/executive-meetings.tsx index 43b976ea..870011f2 100644 --- a/artifacts/tx-os/src/pages/executive-meetings.tsx +++ b/artifacts/tx-os/src/pages/executive-meetings.tsx @@ -3601,15 +3601,22 @@ function AttendeeFlow({ )} , ); - // 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(
  • @@ -3676,6 +3683,39 @@ function AttendeeFlow({ )}
  • , ); + // 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( +
  • + +
  • , + ); + } 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) => ( - 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[] = [ + 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( +
    + +
    , + ); + } + return nodes; + })} {state.attendees.length === 0 && ( diff --git a/artifacts/tx-os/tests/executive-meetings-attendee-insert-reorder.spec.mjs b/artifacts/tx-os/tests/executive-meetings-attendee-insert-reorder.spec.mjs index 97d8186d..bd4532a0 100644 --- a/artifacts/tx-os/tests/executive-meetings-attendee-insert-reorder.spec.mjs +++ b/artifacts/tx-os/tests/executive-meetings-attendee-insert-reorder.spec.mjs @@ -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
  • 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. "

    X

    "), - // 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(