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:
Binary file not shown.
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 25 KiB |
@@ -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 */
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user