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:
@@ -964,8 +964,6 @@
|
||||
"timeOrderError": "وقت النهاية قبل وقت البداية",
|
||||
"addAttendee": "أضف حاضر",
|
||||
"addSubheading": "+ عنوان فرعي",
|
||||
"addPersonHere": "+ شخص هنا",
|
||||
"addPersonHereAria": "إضافة شخص في نهاية هذا القسم",
|
||||
"addVirtualAttendee": "+ حاضر عبر الاتصال المرئي",
|
||||
"addInternalAttendee": "+ حاضر داخلي",
|
||||
"addExternalAttendee": "+ حاضر خارجي",
|
||||
@@ -1075,8 +1073,6 @@
|
||||
"moveUp": "تحريك للأعلى",
|
||||
"moveDown": "تحريك للأسفل",
|
||||
"dragHandle": "اسحب لإعادة الترتيب",
|
||||
"addPersonHere": "إضافة شخص هنا",
|
||||
"addPersonHereAria": "إضافة شخص في نهاية هذا القسم",
|
||||
"removeAll": "حذف جميع الحضور",
|
||||
"removeAllConfirm": "حذف جميع الحضور ({{count}})؟\n\nسيتم تطبيق التغيير عند حفظ الاجتماع."
|
||||
},
|
||||
|
||||
@@ -881,8 +881,6 @@
|
||||
"timeOrderError": "End time is before start time",
|
||||
"addAttendee": "Add attendee",
|
||||
"addSubheading": "+ subheading",
|
||||
"addPersonHere": "+ person here",
|
||||
"addPersonHereAria": "Add a person at the end of this section",
|
||||
"addVirtualAttendee": "+ Virtual",
|
||||
"addInternalAttendee": "+ Internal",
|
||||
"addExternalAttendee": "+ External",
|
||||
@@ -976,8 +974,6 @@
|
||||
"moveUp": "Move up",
|
||||
"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."
|
||||
},
|
||||
|
||||
@@ -3208,10 +3208,6 @@ function AttendeesCell({
|
||||
onCancelAdd: cancelAdd,
|
||||
addLabel: t("executiveMeetings.schedule.addAttendee"),
|
||||
addSubheadingLabel: t("executiveMeetings.schedule.addSubheading"),
|
||||
addPersonHereLabel: t("executiveMeetings.schedule.addPersonHere"),
|
||||
addPersonHereAriaLabel: t(
|
||||
"executiveMeetings.schedule.addPersonHereAria",
|
||||
),
|
||||
editAriaLabel: t("executiveMeetings.schedule.editAttendeeAria"),
|
||||
newAriaLabel: t("executiveMeetings.schedule.newAttendeeAria"),
|
||||
editSubheadingAriaLabel: t(
|
||||
@@ -3372,10 +3368,6 @@ type AttendeeFlowSharedProps = {
|
||||
onCancelAdd?: () => void;
|
||||
addLabel: 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;
|
||||
newAriaLabel: string;
|
||||
editSubheadingAriaLabel: string;
|
||||
@@ -3421,8 +3413,6 @@ function AttendeeFlow({
|
||||
onCancelAdd,
|
||||
addLabel,
|
||||
addSubheadingLabel,
|
||||
addPersonHereLabel,
|
||||
addPersonHereAriaLabel,
|
||||
editAriaLabel,
|
||||
newAriaLabel,
|
||||
editSubheadingAriaLabel,
|
||||
@@ -3430,14 +3420,13 @@ function AttendeeFlow({
|
||||
}: { items: AttendeeWithIndex[] } & AttendeeFlowSharedProps) {
|
||||
const editable = canMutate && !!onSaveAttendeeName;
|
||||
const showAdd = canMutate && !!onStartAdd && !isPending;
|
||||
// True only when the in-flight ghost is targeted at a specific slot
|
||||
// (set by clicking a per-subheading "+ person here" chip). When unset
|
||||
// we fall back to the legacy trailing render so existing screenshots
|
||||
// and the trailing "+" / "+ subheading" buttons keep working.
|
||||
const hasInlineInsert =
|
||||
isPending &&
|
||||
pendingKind === "person" &&
|
||||
pendingInsertAtIndex !== undefined;
|
||||
// True when the in-flight ghost is targeted at a specific slot — set
|
||||
// by clicking either the top "+ عنوان فرعي" chip or the after-section
|
||||
// "+ عنوان فرعي" chip. The same flag drives inline rendering for the
|
||||
// person ghost (still wired through the manage dialog flow). When
|
||||
// unset we fall back to the legacy trailing render so the trailing
|
||||
// "+" / "+ subheading" buttons keep working.
|
||||
const hasInlineInsert = isPending && pendingInsertAtIndex !== undefined;
|
||||
|
||||
// Each subheading starts a fresh numbered section. Within a section,
|
||||
// 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
|
||||
// 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
|
||||
@@ -3564,12 +3588,44 @@ function AttendeeFlow({
|
||||
|
||||
return (
|
||||
<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) => {
|
||||
const isSub = (a.kind ?? "person") === "subheading";
|
||||
const nodes: ReactNode[] = [];
|
||||
if (inlineGhostTargetListIdx === listIdx) {
|
||||
const ghost = renderPendingPersonLi(`pending-inline-${pendingKey}`);
|
||||
if (ghost) nodes.push(ghost);
|
||||
if (pendingKind === "subheading") {
|
||||
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) {
|
||||
// Subheading row: full-width line break inside the flex-wrap so
|
||||
@@ -3601,39 +3657,6 @@ function AttendeeFlow({
|
||||
)}
|
||||
</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;
|
||||
}
|
||||
// Person row — section-local 0-based index pre-computed above.
|
||||
@@ -3683,71 +3706,61 @@ 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.
|
||||
// After-section "+ عنوان فرعي" chip — appears AFTER the last
|
||||
// person of each section that is followed by ANOTHER section
|
||||
// (i.e. the next item is a subheading). This lets the user
|
||||
// insert a new section heading at the boundary between two
|
||||
// existing sections. The trailing section is intentionally
|
||||
// skipped: the trailing "+ عنوان فرعي" button below already
|
||||
// covers that slot, so duplicating the chip would be noise.
|
||||
// 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 isLastInSection =
|
||||
!nextItemForPerson ||
|
||||
const nextIsSubheading =
|
||||
nextItemForPerson &&
|
||||
(nextItemForPerson.a.kind ?? "person") === "subheading";
|
||||
if (showAdd && hasAnySubheading && isLastInSection) {
|
||||
if (showAdd && nextIsSubheading) {
|
||||
nodes.push(
|
||||
<li
|
||||
key={`add-here-after-${i}`}
|
||||
key={`add-sub-after-${i}`}
|
||||
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
|
||||
type="button"
|
||||
onClick={() =>
|
||||
onStartAdd!(addType, "person", i + 1)
|
||||
onStartAdd!(addType, "subheading", i + 1)
|
||||
}
|
||||
data-testid={`em-add-person-here-${i}`}
|
||||
aria-label={addPersonHereAriaLabel}
|
||||
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"
|
||||
>
|
||||
{addPersonHereLabel}
|
||||
{addSubheadingLabel}
|
||||
</button>
|
||||
</li>,
|
||||
);
|
||||
}
|
||||
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 &&
|
||||
inlineGhostTargetListIdx === null &&
|
||||
pendingKind === "person" &&
|
||||
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 &&
|
||||
renderPendingPersonLi(`pending-trailing-${pendingKey}`)}
|
||||
{canMutate &&
|
||||
isPending &&
|
||||
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>
|
||||
)}
|
||||
{!hasInlineInsert &&
|
||||
renderPendingSubheadingLi(`pending-trailing-sub-${pendingKey}`)}
|
||||
{showAdd && (
|
||||
<>
|
||||
<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() {
|
||||
onChange({
|
||||
...state,
|
||||
@@ -4623,57 +4607,25 @@ function MeetingFormDialog({
|
||||
items={sortableIds}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
{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;
|
||||
})}
|
||||
{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)}
|
||||
/>
|
||||
))}
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
{state.attendees.length === 0 && (
|
||||
|
||||
@@ -1,21 +1,24 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
import pg from "pg";
|
||||
|
||||
// E2E coverage for Task #220 — "Insert persons mid-list and drag-reorder
|
||||
// attendees" inside executive-meetings.
|
||||
// E2E coverage for Task #227 — "Restructure attendee cell controls
|
||||
// around section headings" inside executive-meetings.
|
||||
//
|
||||
// What we verify:
|
||||
// 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
|
||||
// 1. Top "+ عنوان فرعي" chip on the schedule grid: the chip is
|
||||
// rendered BEFORE the first attendee of each AttendeeFlow group.
|
||||
// Clicking it opens a pending subheading ghost at slot 0 (or the
|
||||
// group's first global index) and committing the typed text
|
||||
// persists the new subheading at the very top of the cell.
|
||||
// 2. After-section "+ عنوان فرعي" chip: rendered after the last
|
||||
// person of each section that is followed by ANOTHER section
|
||||
// (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
|
||||
// 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.
|
||||
|
||||
const DATABASE_URL = process.env.DATABASE_URL;
|
||||
@@ -164,24 +167,21 @@ 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 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,
|
||||
}) => {
|
||||
const date = uniqueFutureDate(langOffsets[lang]);
|
||||
const meetingId = await insertMeeting({
|
||||
meetingDate: date,
|
||||
dailyNumber: 1,
|
||||
titleEn: `Insert Mid Section ${lang} ${Date.now().toString(36)}`,
|
||||
titleAr: `إدراج وسط القسم ${lang} ${Date.now().toString(36)}`,
|
||||
titleEn: `Top Subheading ${lang} ${Date.now().toString(36)}`,
|
||||
titleAr: `عنوان فرعي علوي ${lang} ${Date.now().toString(36)}`,
|
||||
startTime: "09:00:00",
|
||||
endTime: "10:00:00",
|
||||
});
|
||||
// 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.
|
||||
// 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.
|
||||
await insertAttendee({
|
||||
meetingId,
|
||||
name: "PersonAlpha",
|
||||
@@ -196,20 +196,6 @@ for (const lang of ["en", "ar"]) {
|
||||
sortOrder: 1,
|
||||
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);
|
||||
try {
|
||||
@@ -224,60 +210,168 @@ for (const lang of ["en", "ar"]) {
|
||||
await expect(row).toBeVisible({ timeout: 15_000 });
|
||||
await ensureEditMode(page);
|
||||
|
||||
// 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();
|
||||
// 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 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");
|
||||
// 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("InsertedDelta");
|
||||
// Blur the editor (forceSaveOnBlur fires the commit) by clicking
|
||||
// the meeting title cell, which is unrelated to the attendee
|
||||
// input and won't open another editor.
|
||||
await editor.fill("TopSection");
|
||||
// Blur to commit by clicking somewhere unrelated.
|
||||
await page.locator("body").click({ position: { x: 5, y: 5 } });
|
||||
|
||||
// Wait for the round-trip: the cell should now show 4 person
|
||||
// rows and 1 subheading.
|
||||
// After the round-trip the cell should show 2 person rows + 1
|
||||
// subheading.
|
||||
await expect(
|
||||
row.locator('[data-testid^="em-attendee-row-"]'),
|
||||
).toHaveCount(4, { timeout: 10_000 });
|
||||
).toHaveCount(2, { timeout: 10_000 });
|
||||
await expect(
|
||||
row.locator('[data-testid^="em-attendee-subheading-"]'),
|
||||
).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 stripTags = (s) => s.replace(/<[^>]+>/g, "").trim();
|
||||
const orderedNames = persisted.map((r) => stripTags(r.name));
|
||||
expect(orderedNames).toEqual([
|
||||
"TopSection",
|
||||
"PersonAlpha",
|
||||
"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",
|
||||
]);
|
||||
expect(persisted.map((r) => r.sort_order)).toEqual([0, 1, 2, 3, 4]);
|
||||
|
||||
// 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-", "3-", "1-"]);
|
||||
expect(persisted.map((r) => r.kind)).toEqual([
|
||||
"subheading",
|
||||
"person",
|
||||
"person",
|
||||
"subheading",
|
||||
"subheading",
|
||||
"person",
|
||||
]);
|
||||
expect(persisted.map((r) => r.sort_order)).toEqual([0, 1, 2, 3, 4, 5]);
|
||||
} finally {
|
||||
if (originalAdminPreferredLanguage) {
|
||||
await setAdminPreferredLanguage(originalAdminPreferredLanguage).catch(
|
||||
|
||||
Reference in New Issue
Block a user