#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:
@@ -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).
|
||||
|
||||
Reference in New Issue
Block a user