#486: Executive Meetings row click → quick-actions popover

Clicking any meeting row on the Executive Meetings schedule (gated only
on canMutate, not editMode) opens a small popover with Move up / Move
down / Postpone. Move up/down swap only the (startTime, endTime) tuple
between the clicked meeting and its chronological neighbour on the same
date — the Time column stays visually anchored to its row position.

Backend
- POST /executive-meetings/swap-times: transactional swap with FOR
  UPDATE row locking, optimistic-lock conflict shape (stale_meeting +
  conflict payload), date/time-window guards, audit logging, and
  renumberDayByStartTime + day-changed broadcast.
- Zod schema in lib/api-zod/src/manual.ts.

Frontend
- Shared lib/api-json.ts JSON helper.
- ScheduleSection.swapTimes does an optimistic (startTime, endTime)
  swap against the day query cache and rolls back on failure (mirrors
  the existing inline-edit UX).
- MeetingRow uses Popover/PopoverAnchor with skip rules: ARIA roles
  (button/checkbox/switch/combobox/dialog) and em-time-* / em-edit-* /
  em-row-grip / em-row-actions data-testid prefixes do NOT open the
  popover.
- PostponeDialog reused from upcoming-meeting-alert.tsx.

Tests
- Backend swap-times: happy path, stale_meeting (409), different_dates
  (400), no_time_window (400), unauth (401), viewer-no-mutate (403) —
  all 6 pass.
- E2E: Move up swap, edge-disable states (solo / first / middle /
  last), Postpone 5-min chip end-to-end, click-exclusion on grip /
  time cell / row-actions — all 4 pass. Each test uses its own future
  date to avoid cross-test pollution.

Code review approved on second pass. Pre-existing failures in other
suites (executive-meetings reorder, font-settings, notes-share,
service-orders) are unrelated to this task and predate it.

Follow-ups proposed: #487 (keyboard a11y on the popover), #488 (edit-
mode test gaps).
This commit is contained in:
riyadhafraa
2026-05-11 11:03:24 +00:00
parent b1b77395d0
commit 02b3b374b0
3 changed files with 234 additions and 16 deletions
@@ -96,12 +96,18 @@ let userA = null;
let cookieA = null;
let userB = null;
let cookieB = null;
let viewerCookie = null;
before(async () => {
userA = await createUser("em_swap_a", "executive_coord_lead", "Alice Swap");
cookieA = await login(userA.username, TEST_PASSWORD);
userB = await createUser("em_swap_b", "executive_coord_lead", "Bob Swap");
cookieB = await login(userB.username, TEST_PASSWORD);
// executive_viewer is the read-only role — passes
// requireExecutiveAccess but is NOT in MUTATE_ROLES, so it lets us
// assert that swap-times rejects non-mutators with 403.
const viewer = await createUser("em_swap_view", "executive_viewer", "Vee Viewer");
viewerCookie = await login(viewer.username, TEST_PASSWORD);
});
after(async () => {
@@ -304,6 +310,50 @@ test("swap-times: different dates → 400 different_dates", async () => {
assert.equal(body.code, "different_dates");
});
test("swap-times: unauthenticated request → 401", async () => {
const res = await fetch(`${API_BASE}/api/executive-meetings/swap-times`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
aId: 1,
bId: 2,
expectedUpdatedAtA: new Date().toISOString(),
expectedUpdatedAtB: new Date().toISOString(),
}),
});
assert.equal(res.status, 401);
});
test("swap-times: viewer (no mutate role) → 403 forbidden", async () => {
// Create the pair as a mutator first, then try to swap as a viewer.
const a = await createMeeting({
date: futureDate(35),
titleEn: "Viewer A",
startTime: "08:00",
endTime: "08:30",
});
const b = await createMeeting({
date: futureDate(35),
titleEn: "Viewer B",
startTime: "09:00",
endTime: "09:30",
});
const aFresh = await fetchMeeting(cookieA, a.id);
const bFresh = await fetchMeeting(cookieA, b.id);
const res = await api(
viewerCookie,
"POST",
"/api/executive-meetings/swap-times",
{
aId: a.id,
bId: b.id,
expectedUpdatedAtA: aFresh.updatedAt,
expectedUpdatedAtB: bFresh.updatedAt,
},
);
assert.equal(res.status, 403, "viewer must not be able to swap times");
});
test("swap-times: meeting without a time window → 400 no_time_window", async () => {
// Create A normally, then null out B's start/end directly so we can
// exercise the guard without going through PATCH (which validates).