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
@@ -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 */
},
);
}
}
});
}