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

Original request: Add a per-section "+ شخص هنا" inline chip in the
attendees cell so users can splice a new person right after a
subheading without opening the manage dialog, and add drag-and-drop
reordering inside the manage dialog (with up/down arrows kept as a
keyboard fallback).

Changes (artifacts/tx-os/src/pages/executive-meetings.tsx):
- Attendee gets an optional client-only `_sid` (stripped by the
  manage-save projection so it never crosses the wire).
- AttendeeFlow now renders a "+ شخص هنا" chip right after every
  subheading row; clicking it opens the pending-add editor anchored
  to that section's tail and threads `insertAtIndex` through to
  `commitAddAttendee`, which splices + renumbers contiguously.
- Manage dialog wraps its attendee list in `DndContext` +
  `SortableContext` (vertical strategy). Each row is now a
  `SortableAttendeeRow` driven by `useSortable`; only the new
  `GripVertical` handle is wired to dnd-kit listeners so inputs,
  selects, chevrons and the delete button stay interactive.
- `reorderAttendeesByDrag(activeSid, overSid)`, memoised
  `sortableIds`, and pointer + keyboard sensors live on the manage
  section. `openEdit` and add-row helpers stamp `_sid` so each row
  has a stable React key.
- AR/EN locale keys for the chip label/aria and drag handle aria
  were already in place from the prior compaction.

Tests (new): artifacts/tx-os/tests/executive-meetings-attendee-insert-reorder.spec.mjs
- 6/6 passing (3 scenarios × AR + EN):
  1) "+ person here" inserts at the right slot after a subheading.
  2) Drag-reorder via keyboard (Space + ArrowDown) inside the manage
     dialog persists the new order.
  3) Mixed person + subheading drag preserves `kind` and order
     across the round-trip (architect-flagged coverage).

Verification:
- 6/6 new e2e pass (45.3s).
- 6/6 existing subheading e2e still pass (Task #207 untouched).
- tx-os typecheck clean for executive-meetings.tsx (admin.tsx errors
  are pre-existing/unrelated codegen drift).
- Architect review: PASS — DnD wiring composed correctly, `_sid`
  properly stripped from save payload, no security findings.

Follow-ups proposed: #221 (assert _sid never leaks to API) and #222
(quick-add chip between any two persons, not just after subheadings).
This commit is contained in:
riyadhafraa
2026-04-30 14:52:22 +00:00
parent c03ea006e1
commit a0e12a15f7
3 changed files with 919 additions and 126 deletions
Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 25 KiB

+408 -126
View File
@@ -110,6 +110,13 @@ type Attendee = {
// that don't set it explicitly default to "person" — same as the DB and
// Zod defaults.
kind?: "person" | "subheading";
// Client-only stable id used by the manage-dialog drag-and-drop. Never
// sent to the server (the save projection at the PATCH/POST call site
// explicitly enumerates wire fields and omits this). Persisted attendees
// can fall back to `id` for sortable identity but new rows have no `id`
// until the first save, so we always assign `_sid` when the dialog opens
// or a new row is added.
_sid?: string;
};
type Meeting = {
@@ -1804,8 +1811,8 @@ function ScheduleSection({
onAutoEditTitleConsumed={clearAutoEditTitleId}
pendingAttendee={pendingAttendee}
onStartAddAttendee={startAddAttendee}
onCommitAddAttendee={(type, html, kind) =>
commitAddAttendee(m, type, html, kind)
onCommitAddAttendee={(type, html, kind, insertAtIndex) =>
commitAddAttendee(m, type, html, kind, insertAtIndex)
}
onCancelAddAttendee={cancelAddAttendee}
bulkSelectable={effectiveCanMutate}
@@ -2429,16 +2436,19 @@ function MeetingRow({
attendanceType: Attendee["attendanceType"];
kind: "person" | "subheading";
key: number;
insertAtIndex?: number;
} | null;
onStartAddAttendee?: (
meetingId: number,
attendanceType: Attendee["attendanceType"],
kind?: "person" | "subheading",
insertAtIndex?: number,
) => void;
onCommitAddAttendee?: (
attendanceType: Attendee["attendanceType"],
html: string,
kind?: "person" | "subheading",
insertAtIndex?: number,
) => Promise<void>;
onCancelAddAttendee?: () => void;
bulkSelectable: boolean;
@@ -3112,16 +3122,19 @@ function AttendeesCell({
attendanceType: Attendee["attendanceType"];
kind: "person" | "subheading";
key: number;
insertAtIndex?: number;
} | null;
onStartAddAttendee?: (
meetingId: number,
attendanceType: Attendee["attendanceType"],
kind?: "person" | "subheading",
insertAtIndex?: number,
) => void;
onCommitAddAttendee?: (
attendanceType: Attendee["attendanceType"],
html: string,
kind?: "person" | "subheading",
insertAtIndex?: number,
) => Promise<void>;
onCancelAddAttendee?: () => void;
}) {
@@ -3172,7 +3185,8 @@ function AttendeesCell({
? (
type: Attendee["attendanceType"],
kind: "person" | "subheading" = "person",
) => onStartAddAttendee(meeting.id, type, kind)
insertAtIndex?: number,
) => onStartAddAttendee(meeting.id, type, kind, insertAtIndex)
: undefined;
const commitAdd = onCommitAddAttendee;
@@ -3184,11 +3198,20 @@ function AttendeesCell({
pendingKind: (pendingAttendee?.kind ?? "person") as "person" | "subheading",
isPending: false,
pendingKey: pendingAttendee?.key ?? 0,
// Tells AttendeeFlow where in the full meeting.attendees array the
// pending ghost belongs. Used both to render the ghost at the right
// slot and to forward the value through to onCommitAdd so the splice
// in commitAddAttendee lands in the same place.
pendingInsertAtIndex: pendingAttendee?.insertAtIndex,
onStartAdd: hasAnyPending ? undefined : startAdd,
onCommitAdd: commitAdd,
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(
@@ -3328,18 +3351,31 @@ type AttendeeFlowSharedProps = {
// numbered list, or a full-width subheading line).
pendingKind: "person" | "subheading";
pendingKey: number;
// Position in meeting.attendees where the pending ghost should land.
// When undefined the ghost falls back to the legacy "trailing" slot
// (rendered after the last item in the group). When set, the ghost is
// rendered inline before the first item whose original index is >= the
// value, so the user sees the input materialize right where they
// clicked "+ شخص هنا" after a subheading.
pendingInsertAtIndex?: number;
onStartAdd?: (
type: Attendee["attendanceType"],
kind?: "person" | "subheading",
insertAtIndex?: number,
) => void;
onCommitAdd?: (
type: Attendee["attendanceType"],
html: string,
kind?: "person" | "subheading",
insertAtIndex?: number,
) => Promise<void>;
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;
@@ -3379,11 +3415,14 @@ function AttendeeFlow({
isPending,
pendingKind,
pendingKey,
pendingInsertAtIndex,
onStartAdd,
onCommitAdd,
onCancelAdd,
addLabel,
addSubheadingLabel,
addPersonHereLabel,
addPersonHereAriaLabel,
editAriaLabel,
newAriaLabel,
editSubheadingAriaLabel,
@@ -3391,6 +3430,14 @@ 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;
// Each subheading starts a fresh numbered section. Within a section,
// persons are numbered 1..N starting over after every subheading.
@@ -3439,15 +3486,96 @@ function AttendeeFlow({
return <span className="text-xs text-gray-500"></span>;
}
// Locate the listIdx where the inline pending ghost should appear, when
// an inline insert is in flight. The ghost lands BEFORE the first item
// whose original index in meeting.attendees is >= pendingInsertAtIndex.
// If no such item exists (e.g. insert after the last item), the ghost
// is rendered after the loop. Computed once up front so the JSX is
// straightforward.
let inlineGhostTargetListIdx: number | null = null;
if (hasInlineInsert) {
for (let k = 0; k < items.length; k++) {
if (items[k].i >= pendingInsertAtIndex!) {
inlineGhostTargetListIdx = k;
break;
}
}
}
// 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
// section index when the cell has subheadings or the surrounding
// section already has people; otherwise it stays unnumbered just like
// a sole attendee in a no-subheading cell.
const renderPendingPersonLi = (key: string) => {
if (!(canMutate && isPending && pendingKind === "person" && onCommitAdd)) {
return null;
}
// When inserting inline after a subheading at array index K (so
// insertAtIndex = K + 1) we want the ghost to be numbered "1-" so the
// user sees a fresh section starting. Compute the section-local index
// for the inline slot from the per-item meta we already built.
let ghostDisplayNumber: number | null = null;
if (hasInlineInsert && inlineGhostTargetListIdx !== null) {
const target = inlineGhostTargetListIdx;
// The slot's section-local index = sectionMeta[target].idxInSection
// (0-based) for the would-be inserted person. That row pushes the
// current target row down, so the ghost takes the target's slot.
ghostDisplayNumber = sectionMeta[target].idxInSection + 1;
} else if (hasInlineInsert && inlineGhostTargetListIdx === null) {
// Insert at end (after the last item). Number = persons in last
// section + 1, mirroring the trailing case.
ghostDisplayNumber = lastSectionPersonCount + 1;
} else {
// Legacy trailing case.
ghostDisplayNumber =
lastSectionPersonCount > 0 || hasAnySubheading
? lastSectionPersonCount + 1
: null;
}
return (
<li
key={key}
className="min-w-[6rem]"
data-testid={`em-add-attendee-pending-${addType}`}
>
{ghostDisplayNumber !== null && (
<span className="text-gray-500 me-1">{ghostDisplayNumber}-</span>
)}
<EditableCell
key={`pending-${pendingKey}`}
value=""
onSave={(html) =>
onCommitAdd(addType, html, "person", pendingInsertAtIndex)
}
ariaLabel={newAriaLabel}
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-attendee-pending-input-${addType}`}
startInEditMode
forceSaveOnBlur
onCancel={onCancelAdd}
/>
</li>
);
};
return (
<ul className="flex flex-wrap justify-center items-baseline gap-x-4 gap-y-0.5 text-[#0B1E3F] leading-snug">
{items.map(({ a, i }, listIdx) => {
{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 (isSub) {
// Subheading row: full-width line break inside the flex-wrap so
// it visually separates the persons before/after. No leading
// index, no inline-flex — pure label.
return (
nodes.push(
<li
key={a.id ?? `sub-${i}`}
data-testid={`em-attendee-subheading-${i}`}
@@ -3471,13 +3599,40 @@ function AttendeeFlow({
dangerouslySetInnerHTML={{ __html: safeHtml(a.name) }}
/>
)}
</li>
</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) {
nodes.push(
<li
key={`add-here-${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.
const meta = sectionMeta[listIdx];
const myDisplayIdx = meta.idxInSection;
return (
nodes.push(
<li
key={a.id ?? i}
data-testid={`em-attendee-row-${i}`}
@@ -3519,36 +3674,17 @@ function AttendeeFlow({
dangerouslySetInnerHTML={{ __html: safeHtml(a.name) }}
/>
)}
</li>
</li>,
);
return nodes;
})}
{canMutate && isPending && pendingKind === "person" && onCommitAdd && (
<li className="min-w-[6rem]" data-testid={`em-add-attendee-pending-${addType}`}>
{(lastSectionPersonCount > 0 || hasAnySubheading) && (
<span className="text-gray-500 me-1">
{lastSectionPersonCount + 1}-
</span>
)}
<EditableCell
// pendingKey changes every time the user clicks "+" so React
// remounts the editor with a fresh focus.
key={`pending-${pendingKey}`}
value=""
onSave={(html) => onCommitAdd(addType, html, "person")}
ariaLabel={newAriaLabel}
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-attendee-pending-input-${addType}`}
startInEditMode
// Force a save call on blur even when the user typed nothing,
// so the parent can clear the pending ghost row.
forceSaveOnBlur
// Escape should also clear the ghost.
onCancel={onCancelAdd}
/>
</li>
)}
{/* Inline ghost pinned to the trailing slot (insert-at-end case). */}
{hasInlineInsert &&
inlineGhostTargetListIdx === null &&
renderPendingPersonLi(`pending-inline-trailing-${pendingKey}`)}
{/* Legacy trailing ghost (no inline insert in flight). */}
{!hasInlineInsert &&
renderPendingPersonLi(`pending-trailing-${pendingKey}`)}
{canMutate &&
isPending &&
pendingKind === "subheading" &&
@@ -3621,6 +3757,16 @@ type MeetingFormState = {
attendees: Attendee[];
};
// Generate a stable client-only attendee id for the manage-dialog DnD.
// Combines an incrementing counter with a random suffix so we don't
// collide even when several openEdit() calls fire in the same tick (e.g.
// after a duplicate-and-edit gesture). The id never leaves the browser.
let __attendeeSidCounter = 0;
function genAttendeeSid(): string {
__attendeeSidCounter += 1;
return `sid-${__attendeeSidCounter}-${Math.random().toString(36).slice(2, 8)}`;
}
function emptyMeetingForm(date: string): MeetingFormState {
return {
id: null,
@@ -3717,7 +3863,13 @@ function ManageSection({
status: m.status,
isHighlighted: m.isHighlighted === 1,
notes: m.notes ?? "",
attendees: m.attendees.map((a) => ({ ...a })),
// Stamp every attendee with a stable client-side id used by the
// dnd-kit SortableContext in the manage dialog. Persisted attendees
// also have a server `id` we could reuse, but new rows added inside
// the dialog don't have one until after save, so we always assign
// `_sid` here for uniformity. The save projection enumerates wire
// fields and naturally drops `_sid`.
attendees: m.attendees.map((a) => ({ ...a, _sid: genAttendeeSid() })),
});
}
@@ -3961,6 +4113,151 @@ function ManageSection({
);
}
// One row inside the manage-dialog attendee list. Uses dnd-kit's
// useSortable so the row can be picked up by its dedicated drag handle
// (the GripVertical button on the side). The visible up/down chevrons,
// inputs and delete button continue to fire their callbacks normally —
// the drag handle is the only element that initiates a drag.
function SortableAttendeeRow({
attendee,
index,
last,
t,
onMoveUp,
onMoveDown,
onChangeName,
onChangeTitle,
onChangeAttendanceType,
onRemove,
}: {
attendee: Attendee;
index: number;
last: boolean;
t: (k: string) => string;
onMoveUp: () => void;
onMoveDown: () => void;
onChangeName: (v: string) => void;
onChangeTitle: (v: string) => void;
onChangeAttendanceType: (v: Attendee["attendanceType"]) => void;
onRemove: () => void;
}) {
const sid = attendee._sid ?? `__missing-sid-${index}`;
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: sid });
const style: CSSProperties = {
transform: CSS.Transform.toString(transform),
transition,
// Subtle lift while the row is being dragged so the user sees the
// active item clearly above the others.
zIndex: isDragging ? 10 : undefined,
opacity: isDragging ? 0.85 : undefined,
};
const isSub = (attendee.kind ?? "person") === "subheading";
return (
<div
ref={setNodeRef}
style={style}
className={
"flex items-center gap-2" +
(isSub ? " bg-blue-50/60 rounded px-2 py-1" : "")
}
data-testid={`em-attendee-row-${index}`}
data-attendee-kind={attendee.kind ?? "person"}
data-attendee-sid={sid}
>
{/* Drag handle — the ONLY element wired to dnd-kit listeners, so
the row's inputs and buttons remain interactive. */}
<button
type="button"
className="cursor-grab active:cursor-grabbing text-gray-400 hover:text-gray-700 px-1 touch-none"
aria-label={t("executiveMeetings.manage.attendees.dragHandle")}
data-testid={`em-attendee-drag-${index}`}
{...attributes}
{...listeners}
>
<GripVertical className="w-4 h-4" />
</button>
<div className="flex flex-col">
<Button
size="icon"
variant="ghost"
className="h-5 w-5"
onClick={onMoveUp}
disabled={index === 0}
aria-label={t("executiveMeetings.manage.attendees.moveUp")}
data-testid={`em-attendee-up-${index}`}
>
<ChevronUp className="w-3 h-3" />
</Button>
<Button
size="icon"
variant="ghost"
className="h-5 w-5"
onClick={onMoveDown}
disabled={last}
aria-label={t("executiveMeetings.manage.attendees.moveDown")}
data-testid={`em-attendee-down-${index}`}
>
<ChevronDown className="w-3 h-3" />
</Button>
</div>
{isSub && (
<span
className="inline-flex items-center text-[10px] font-semibold uppercase tracking-wide text-blue-700 bg-blue-100 rounded-full px-2 py-0.5"
data-testid={`em-attendee-kind-badge-${index}`}
>
{t("executiveMeetings.manage.attendees.subheadingBadge")}
</span>
)}
<Input
className={isSub ? "flex-[2]" : "flex-1"}
placeholder={
isSub
? t("executiveMeetings.manage.attendees.subheadingName")
: t("executiveMeetings.manage.attendees.name")
}
value={attendee.name}
onChange={(e) => onChangeName(e.target.value)}
/>
{!isSub && (
<Input
className="flex-1"
placeholder={t("executiveMeetings.manage.attendees.title")}
value={attendee.title ?? ""}
onChange={(e) => onChangeTitle(e.target.value)}
data-testid={`em-attendee-title-${index}`}
/>
)}
<Select
value={attendee.attendanceType}
onValueChange={(v) =>
onChangeAttendanceType(v as Attendee["attendanceType"])
}
>
<SelectTrigger className="w-32">
<SelectValue />
</SelectTrigger>
<SelectContent>
{(["internal", "virtual", "external"] as const).map((tp) => (
<SelectItem key={tp} value={tp}>
{t(`executiveMeetings.manage.attendanceType.${tp}`)}
</SelectItem>
))}
</SelectContent>
</Select>
<Button size="icon" variant="ghost" onClick={onRemove}>
<Trash2 className="w-4 h-4 text-red-600" />
</Button>
</div>
);
}
function MeetingFormDialog({
state,
onChange,
@@ -3990,6 +4287,7 @@ function MeetingFormDialog({
attendanceType: "internal",
sortOrder: state.attendees.length,
kind: "person",
_sid: genAttendeeSid(),
},
],
});
@@ -4010,6 +4308,7 @@ function MeetingFormDialog({
attendanceType: "internal",
sortOrder: state.attendees.length,
kind: "subheading",
_sid: genAttendeeSid(),
},
],
});
@@ -4044,6 +4343,39 @@ function MeetingFormDialog({
[arr[i], arr[j]] = [arr[j], arr[i]];
onChange({ ...state, attendees: arr });
}
// Drag-and-drop reorder driven by dnd-kit. Items are keyed by their
// client `_sid` so the SortableContext stays stable across renames,
// attendance-type changes, etc. We resolve the over/active sids to
// their current array positions inside the handler so consecutive
// drags compose correctly.
function reorderAttendeesByDrag(activeSid: string, overSid: string) {
if (activeSid === overSid) return;
const arr = state.attendees;
const from = arr.findIndex((a) => a._sid === activeSid);
const to = arr.findIndex((a) => a._sid === overSid);
if (from < 0 || to < 0) return;
const next = arr.slice();
const [moved] = next.splice(from, 1);
next.splice(to, 0, moved);
onChange({ ...state, attendees: next });
}
// Pointer + keyboard sensors with a small activation distance so that
// clicking the inputs / buttons inside a row doesn't start a drag.
const dragSensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 4 } }),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
}),
);
// Memoize the id list so SortableContext only re-resolves when the
// attendee order actually changes.
const sortableIds = useMemo(
() =>
state.attendees.map(
(a, i) => a._sid ?? `__missing-sid-${i}`,
),
[state.attendees],
);
return (
<Dialog open={true} onOpenChange={(o) => !o && onClose()}>
@@ -4201,98 +4533,48 @@ function MeetingFormDialog({
</div>
</div>
<div className="space-y-2">
{state.attendees.map((a, i) => {
const isSub = (a.kind ?? "person") === "subheading";
return (
<div
key={i}
className={
"flex items-center gap-2" +
(isSub ? " bg-blue-50/60 rounded px-2 py-1" : "")
}
data-testid={`em-attendee-row-${i}`}
data-attendee-kind={a.kind ?? "person"}
>
<div className="flex flex-col">
<Button
size="icon"
variant="ghost"
className="h-5 w-5"
onClick={() => moveAttendee(i, -1)}
disabled={i === 0}
aria-label={t("executiveMeetings.manage.attendees.moveUp")}
data-testid={`em-attendee-up-${i}`}
>
<ChevronUp className="w-3 h-3" />
</Button>
<Button
size="icon"
variant="ghost"
className="h-5 w-5"
onClick={() => moveAttendee(i, 1)}
disabled={i === state.attendees.length - 1}
aria-label={t("executiveMeetings.manage.attendees.moveDown")}
data-testid={`em-attendee-down-${i}`}
>
<ChevronDown className="w-3 h-3" />
</Button>
</div>
{isSub && (
// Tiny chip so the row visually reads as "this is a
// subheading" instead of just an empty-title attendee.
<span
className="inline-flex items-center text-[10px] font-semibold uppercase tracking-wide text-blue-700 bg-blue-100 rounded-full px-2 py-0.5"
data-testid={`em-attendee-kind-badge-${i}`}
>
{t("executiveMeetings.manage.attendees.subheadingBadge")}
</span>
)}
<Input
className={isSub ? "flex-[2]" : "flex-1"}
placeholder={
isSub
? t("executiveMeetings.manage.attendees.subheadingName")
: t("executiveMeetings.manage.attendees.name")
{/* Drag-and-drop reordering wraps the whole list. The
up/down arrow buttons inside each row continue to work
in parallel — DnD is an additive convenience, not a
replacement, so keyboard-only flows keep functioning
even if the SortableContext fails to mount. */}
<DndContext
sensors={dragSensors}
collisionDetection={closestCenter}
onDragEnd={(e: DragEndEvent) => {
const { active, over } = e;
if (!over) return;
reorderAttendeesByDrag(
String(active.id),
String(over.id),
);
}}
>
<SortableContext
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 })
}
value={a.name}
onChange={(e) => setAttendee(i, { name: e.target.value })}
onChangeAttendanceType={(v) =>
setAttendee(i, { attendanceType: v })
}
onRemove={() => removeAttendee(i)}
/>
{!isSub && (
<Input
className="flex-1"
placeholder={t("executiveMeetings.manage.attendees.title")}
value={a.title ?? ""}
onChange={(e) =>
setAttendee(i, { title: e.target.value || null })
}
data-testid={`em-attendee-title-${i}`}
/>
)}
<Select
value={a.attendanceType}
onValueChange={(v) =>
setAttendee(i, {
attendanceType: v as Attendee["attendanceType"],
})
}
>
<SelectTrigger className="w-32">
<SelectValue />
</SelectTrigger>
<SelectContent>
{(["internal", "virtual", "external"] as const).map((tp) => (
<SelectItem key={tp} value={tp}>
{t(`executiveMeetings.manage.attendanceType.${tp}`)}
</SelectItem>
))}
</SelectContent>
</Select>
<Button size="icon" variant="ghost" onClick={() => removeAttendee(i)}>
<Trash2 className="w-4 h-4 text-red-600" />
</Button>
</div>
);
})}
))}
</SortableContext>
</DndContext>
{state.attendees.length === 0 && (
<div className="text-xs text-gray-500"></div>
)}
@@ -0,0 +1,511 @@
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.
//
// 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.
// 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.
// Both scenarios run in BOTH locales so the RTL/LTR mirror behaves the
// same way.
const DATABASE_URL = process.env.DATABASE_URL;
if (!DATABASE_URL) {
throw new Error(
"DATABASE_URL must be set to run the executive-meetings insert/reorder UI tests",
);
}
const pool = new pg.Pool({ connectionString: DATABASE_URL });
const createdMeetingIds = [];
// Pick a far-future, randomised base so we never collide with other
// specs that also seed in the future.
const RANDOM_DATE_BASE_DAYS =
2200 + Math.floor(Math.random() * 1000) + ((Date.now() / 1000) | 0) % 500;
function uniqueFutureDate(offsetDays) {
const d = new Date();
d.setUTCDate(d.getUTCDate() + RANDOM_DATE_BASE_DAYS + offsetDays * 7);
return d.toISOString().slice(0, 10);
}
async function insertMeeting({
meetingDate,
dailyNumber,
titleEn,
titleAr,
startTime,
endTime,
}) {
const { rows } = await pool.query(
`INSERT INTO executive_meetings
(daily_number, title_ar, title_en, meeting_date, start_time, end_time, status)
VALUES ($1, $2, $3, $4, $5, $6, 'scheduled')
RETURNING id`,
[dailyNumber, titleAr, titleEn, meetingDate, startTime, endTime],
);
const id = rows[0].id;
createdMeetingIds.push(id);
return id;
}
async function insertAttendee({
meetingId,
name,
attendanceType,
sortOrder,
kind,
}) {
await pool.query(
`INSERT INTO executive_meeting_attendees
(meeting_id, name, attendance_type, sort_order, kind)
VALUES ($1, $2, $3, $4, $5)`,
[meetingId, name, attendanceType, sortOrder, kind ?? "person"],
);
}
async function getAttendeesOrdered(meetingId) {
const { rows } = await pool.query(
`SELECT name, attendance_type, sort_order, kind
FROM executive_meeting_attendees
WHERE meeting_id = $1
ORDER BY sort_order ASC`,
[meetingId],
);
return rows;
}
async function loginViaUi(page) {
await page.goto("/login");
await page.locator("#username").fill("admin");
await page.locator("#password").fill("admin123");
await Promise.all([
page.waitForURL((url) => !url.pathname.endsWith("/login"), {
timeout: 15_000,
}),
page.locator('form button[type="submit"]').click(),
]);
}
let originalAdminPreferredLanguage = null;
async function setAdminPreferredLanguage(lang) {
const { rows } = await pool.query(
`UPDATE users SET preferred_language = $1
WHERE username = 'admin'
RETURNING preferred_language`,
[lang],
);
return rows[0]?.preferred_language;
}
// Toggle the global edit-mode toggle so the inline "+ شخص هنا" chip and
// inline editing affordances render. The toggle lives in the schedule
// toolbar with data-testid="em-edit-mode-toggle".
async function ensureEditMode(page) {
const toggle = page.getByTestId("em-edit-mode-toggle");
await expect(toggle).toBeVisible({ timeout: 10_000 });
const pressed = await toggle.getAttribute("aria-pressed");
if (pressed !== "true") {
await toggle.click();
// Wait for the aria-pressed state to actually flip — the localStorage
// write + state setter is async-ish in React, and clicking the chip
// before edit mode commits would silently miss the conditional render.
await expect(toggle).toHaveAttribute("aria-pressed", "true", {
timeout: 5_000,
});
}
}
test.beforeAll(async () => {
const { rows } = await pool.query(
`SELECT preferred_language FROM users WHERE username = 'admin'`,
);
originalAdminPreferredLanguage = rows[0]?.preferred_language ?? "ar";
});
test.afterAll(async () => {
if (originalAdminPreferredLanguage) {
await pool
.query(`UPDATE users SET preferred_language = $1 WHERE username = 'admin'`, [
originalAdminPreferredLanguage,
])
.catch(() => {
/* best-effort */
});
}
if (createdMeetingIds.length > 0) {
await pool.query(
`DELETE FROM executive_meeting_attendees WHERE meeting_id = ANY($1::int[])`,
[createdMeetingIds],
);
await pool.query(
`DELETE FROM executive_meeting_audit_logs WHERE entity_type = 'meeting' AND entity_id = ANY($1::int[])`,
[createdMeetingIds],
);
await pool.query(
`DELETE FROM executive_meetings WHERE id = ANY($1::int[])`,
[createdMeetingIds],
);
}
await pool.end();
});
const langOffsets = { en: 1, ar: 2 };
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 ({
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)}`,
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.
await insertAttendee({
meetingId,
name: "PersonAlpha",
attendanceType: "internal",
sortOrder: 0,
kind: "person",
});
await insertAttendee({
meetingId,
name: "PersonBeta",
attendanceType: "internal",
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 {
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);
// 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");
await expect(chip).toBeVisible({ timeout: 10_000 });
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();
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 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.
await expect(
row.locator('[data-testid^="em-attendee-row-"]'),
).toHaveCount(4, { 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
// BETWEEN the subheading and the previously-trailing person.
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",
"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).
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-"]);
} finally {
if (originalAdminPreferredLanguage) {
await setAdminPreferredLanguage(originalAdminPreferredLanguage).catch(
() => {
/* best-effort; afterAll also restores */
},
);
}
}
});
test(`Manage [${lang}]: drag handle reorders attendees inside the dialog`, async ({
page,
}) => {
const date = uniqueFutureDate(langOffsets2[lang]);
const meetingId = await insertMeeting({
meetingDate: date,
dailyNumber: 1,
titleEn: `Drag Reorder ${lang} ${Date.now().toString(36)}`,
titleAr: `سحب وإعادة ترتيب ${lang} ${Date.now().toString(36)}`,
startTime: "11:00:00",
endTime: "12:00:00",
});
await insertAttendee({
meetingId,
name: "Drag-A",
attendanceType: "internal",
sortOrder: 0,
kind: "person",
});
await insertAttendee({
meetingId,
name: "Drag-B",
attendanceType: "internal",
sortOrder: 1,
kind: "person",
});
await insertAttendee({
meetingId,
name: "Drag-C",
attendanceType: "internal",
sortOrder: 2,
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,
});
// Switch to the Manage section via the page-level nav. The
// manage dialog is only mounted from inside the ManageSection.
const manageNav = page.getByTestId("em-nav-manage");
await expect(manageNav).toBeVisible({ timeout: 10_000 });
await manageNav.click();
await page.locator('input[type="date"]').first().fill(date);
// Open the edit dialog for our meeting (the pencil button on the
// row uses the meeting id as its testid suffix).
const editBtn = page.getByTestId(`em-edit-${meetingId}`);
await expect(editBtn).toBeVisible({ timeout: 10_000 });
await editBtn.click();
// The dialog renders three sortable rows — verify drag handles
// are present and aria-labelled.
const handle0 = page.getByTestId("em-attendee-drag-0");
await expect(handle0).toBeVisible({ timeout: 5_000 });
// Use dnd-kit's KeyboardSensor flow to move row 0 down two
// slots. This is more reliable across Playwright browser engines
// than synthesised pointer drags. dnd-kit picks up Space and
// moves with arrow keys, then drops with Space again.
await handle0.focus();
await page.keyboard.press("Space");
await page.keyboard.press("ArrowDown");
await page.keyboard.press("ArrowDown");
await page.keyboard.press("Space");
// Verify the visible name inputs reordered to [B, C, A] before we
// even save — this catches DnD wiring without relying on a save
// round-trip.
const nameInputs = page.locator(
'[data-testid^="em-attendee-row-"] input[placeholder]',
);
// Filter to the first input of each row (the name field).
const firstNames = await page
.locator('[data-testid^="em-attendee-row-"]')
.evaluateAll((rows) =>
rows.map((r) => {
const inp = r.querySelector("input");
return inp ? inp.value : "";
}),
);
expect(firstNames).toEqual(["Drag-B", "Drag-C", "Drag-A"]);
void nameInputs;
// Save and verify persistence.
const saveBtn = page.getByTestId("em-form-save");
await saveBtn.click();
// Wait for the dialog to close.
await expect(saveBtn).toBeHidden({ timeout: 10_000 });
const persisted = await getAttendeesOrdered(meetingId);
expect(persisted.map((r) => r.name)).toEqual([
"Drag-B",
"Drag-C",
"Drag-A",
]);
expect(persisted.map((r) => r.sort_order)).toEqual([0, 1, 2]);
} finally {
if (originalAdminPreferredLanguage) {
await setAdminPreferredLanguage(originalAdminPreferredLanguage).catch(
() => {
/* best-effort; afterAll also restores */
},
);
}
}
});
test(`Manage [${lang}]: drag-reorder works across mixed person + subheading rows`, async ({
page,
}) => {
// Architect-flagged coverage: a meeting that contains BOTH person
// rows and a subheading row, then dragged so the subheading lands
// in a new section position. We verify (a) the kind survives the
// round-trip, (b) the new order persists, (c) numbering on the
// schedule view recomputes correctly after the kinds shift.
const date = uniqueFutureDate(langOffsets3[lang]);
const meetingId = await insertMeeting({
meetingDate: date,
dailyNumber: 1,
titleEn: `Mixed Drag ${lang} ${Date.now().toString(36)}`,
titleAr: `سحب مختلط ${lang} ${Date.now().toString(36)}`,
startTime: "13:00:00",
endTime: "14:00:00",
});
// Seed: [PersonM, SubX, PersonN, PersonO]. We will pick up
// SubX (index 1) and move it down past PersonN (one ArrowDown).
// Expected after drag: [PersonM, PersonN, SubX, PersonO].
await insertAttendee({
meetingId,
name: "PersonM",
attendanceType: "internal",
sortOrder: 0,
kind: "person",
});
await insertAttendee({
meetingId,
name: "MixSubX-K3",
attendanceType: "internal",
sortOrder: 1,
kind: "subheading",
});
await insertAttendee({
meetingId,
name: "PersonN",
attendanceType: "internal",
sortOrder: 2,
kind: "person",
});
await insertAttendee({
meetingId,
name: "PersonO",
attendanceType: "internal",
sortOrder: 3,
kind: "person",
});
await setAdminPreferredLanguage(lang);
try {
await loginViaUi(page);
await page.goto("/executive-meetings");
const manageNav = page.getByTestId("em-nav-manage");
await expect(manageNav).toBeVisible({ timeout: 10_000 });
await manageNav.click();
await page.locator('input[type="date"]').first().fill(date);
const editBtn = page.getByTestId(`em-edit-${meetingId}`);
await expect(editBtn).toBeVisible({ timeout: 10_000 });
await editBtn.click();
// Pick up the subheading row (index 1) and move it down once.
const handle1 = page.getByTestId("em-attendee-drag-1");
await expect(handle1).toBeVisible({ timeout: 5_000 });
await handle1.focus();
await page.keyboard.press("Space");
await page.keyboard.press("ArrowDown");
await page.keyboard.press("Space");
// Pre-save check: the row at index 1 is now PersonN (was the
// subheading), and the row at index 2 is the subheading.
const kinds = await page
.locator('[data-testid^="em-attendee-row-"]')
.evaluateAll((rows) =>
rows.map((r) => r.getAttribute("data-attendee-kind")),
);
expect(kinds).toEqual(["person", "person", "subheading", "person"]);
const saveBtn = page.getByTestId("em-form-save");
await saveBtn.click();
await expect(saveBtn).toBeHidden({ timeout: 10_000 });
const persisted = await getAttendeesOrdered(meetingId);
expect(persisted.map((r) => r.name)).toEqual([
"PersonM",
"PersonN",
"MixSubX-K3",
"PersonO",
]);
expect(persisted.map((r) => r.kind)).toEqual([
"person",
"person",
"subheading",
"person",
]);
expect(persisted.map((r) => r.sort_order)).toEqual([0, 1, 2, 3]);
} finally {
if (originalAdminPreferredLanguage) {
await setAdminPreferredLanguage(originalAdminPreferredLanguage).catch(
() => {
/* best-effort; afterAll also restores */
},
);
}
}
});
}