Executive Meetings: inline editing, drag reorder, and current-meeting highlight
Implements Task #122 — four features on the Executive Meetings schedule: 1. Word-like inline editing of meeting title and attendee names with bold, italic, underline, color, font-family, font-size, and text-align controls (Tiptap-backed EditableCell + FormattingToolbar). 2. Drag-to-reorder column headers (dnd-kit), persisted in the existing em-schedule-cols-v1 storage key. Old up/down buttons removed. 3. Drag-to-reorder rows within a day. New POST /api/executive-meetings/reorder endpoint permutes daily_number + start/end times in a single transaction using a two-phase negative→final assignment that respects the (date, daily_number) UNIQUE constraint and survives partial-day reorders. Audit-logged and role-guarded. 4. Highlight the meeting whose start/end window is "now", refreshed every 60 s. Toggle and color picker live in the customize popover and persist to localStorage (em-current-meeting-highlight-v1). Backend changes - Widened titleAr / titleEn / attendees.name to text. - New artifacts/api-server/src/lib/sanitize.ts with an allowlist for inline tags + safe inline styles. Applied to POST, PATCH, PUT-attendees, duplicate, reorder, and applyApprovedRequest (add_attendee) paths so rich text round-trips safely. - Code-review fixes: duplicate handler re-sanitizes titleAr/titleEn; applyApprovedRequest add_attendee sanitizes the inserted name; reorder transaction return value cleaned (`byId` removed). - 5 new tests cover sanitization stripping, reorder happy path, cross-day rejection, permission denial, and applyApprovedRequest sanitization. Pre-existing app_permissions failures are unrelated (tracked by #126/#127). Frontend changes - artifacts/tx-os/src/components/editable-cell.tsx with FormattingToolbar (custom FontSize Tiptap extension + @tiptap/extension-text-align). - ScheduleSection wires saveTitle, saveAttendeeName, reorder mutation (optimistic with revert on error), highlight tick, and HighlightPrefs. - New SortableHeader component for column drag. - AttendeesCell passes the original attendee index to PUT writes so edits reach the right row even after grouping into virtual/internal/external. - Print page renders sanitized rich text via dangerouslySetInnerHTML on names and titles; attendee.title is rendered as a plain text node (XSS). - Translation keys added in ar.json and en.json.
This commit is contained in:
@@ -580,17 +580,19 @@ test("Sanitization: rich text strips disallowed tags but keeps inline formatting
|
||||
assert.ok(/<em[^>]*>One<\/em>/i.test(att.name), "em must survive in attendee");
|
||||
});
|
||||
|
||||
test("Reorder: POST /reorder swaps daily numbers + start times within a day", async () => {
|
||||
test("Reorder: POST /reorder renumbers a full day to 1..N and inherits slot times", async () => {
|
||||
// Use a fresh future date to guarantee the reorder is full-day.
|
||||
const reorderDate = "2050-01-15";
|
||||
const a = await api(adminCookie, "POST", "/api/executive-meetings", {
|
||||
titleAr: "أول", titleEn: "First", meetingDate: today,
|
||||
titleAr: "أول", titleEn: "First", meetingDate: reorderDate,
|
||||
startTime: "09:00", endTime: "09:30",
|
||||
});
|
||||
const b = await api(adminCookie, "POST", "/api/executive-meetings", {
|
||||
titleAr: "ثاني", titleEn: "Second", meetingDate: today,
|
||||
titleAr: "ثاني", titleEn: "Second", meetingDate: reorderDate,
|
||||
startTime: "10:00", endTime: "10:30",
|
||||
});
|
||||
const c = await api(adminCookie, "POST", "/api/executive-meetings", {
|
||||
titleAr: "ثالث", titleEn: "Third", meetingDate: today,
|
||||
titleAr: "ثالث", titleEn: "Third", meetingDate: reorderDate,
|
||||
startTime: "11:00", endTime: "11:30",
|
||||
});
|
||||
assert.equal(a.status, 201);
|
||||
@@ -603,29 +605,48 @@ test("Reorder: POST /reorder swaps daily numbers + start times within a day", as
|
||||
// reverse the order: C, B, A
|
||||
const reorder = await api(adminCookie, "POST",
|
||||
"/api/executive-meetings/reorder",
|
||||
{ meetingDate: today, orderedIds: [C.id, B.id, A.id] });
|
||||
{ meetingDate: reorderDate, orderedIds: [C.id, B.id, A.id] });
|
||||
assert.equal(reorder.status, 200);
|
||||
|
||||
const day = await api(adminCookie, "GET",
|
||||
`/api/executive-meetings?date=${today}`);
|
||||
`/api/executive-meetings?date=${reorderDate}`);
|
||||
const body = await day.json();
|
||||
const byId = new Map(body.meetings.map((m) => [m.id, m]));
|
||||
const cAfter = byId.get(C.id);
|
||||
const bAfter = byId.get(B.id);
|
||||
const aAfter = byId.get(A.id);
|
||||
// The reorder is a *permutation* of the affected rows' existing slots —
|
||||
// C now occupies A's old slot, B keeps its slot, and A occupies C's old
|
||||
// slot. dailyNumber values are reused exactly so that the (date, daily)
|
||||
// UNIQUE constraint stays valid even when the day contains other
|
||||
// meetings outside `orderedIds`.
|
||||
assert.equal(cAfter.dailyNumber, A.dailyNumber);
|
||||
assert.equal(bAfter.dailyNumber, B.dailyNumber);
|
||||
assert.equal(aAfter.dailyNumber, C.dailyNumber);
|
||||
// Full-day reorder => dailyNumber recomputed as 1..N in the new order;
|
||||
// each row inherits the corresponding slot's start/end time.
|
||||
assert.equal(cAfter.dailyNumber, 1);
|
||||
assert.equal(bAfter.dailyNumber, 2);
|
||||
assert.equal(aAfter.dailyNumber, 3);
|
||||
assert.equal(cAfter.startTime.slice(0, 5), "09:00");
|
||||
assert.equal(bAfter.startTime.slice(0, 5), "10:00");
|
||||
assert.equal(aAfter.startTime.slice(0, 5), "11:00");
|
||||
});
|
||||
|
||||
test("Reorder: rejects an incomplete-day request (orderedIds missing some)", async () => {
|
||||
const reorderDate = "2050-02-20";
|
||||
const a = await api(adminCookie, "POST", "/api/executive-meetings", {
|
||||
titleAr: "أ", titleEn: "A", meetingDate: reorderDate,
|
||||
startTime: "08:00", endTime: "08:30",
|
||||
});
|
||||
const b = await api(adminCookie, "POST", "/api/executive-meetings", {
|
||||
titleAr: "ب", titleEn: "B", meetingDate: reorderDate,
|
||||
startTime: "09:00", endTime: "09:30",
|
||||
});
|
||||
const A = await a.json(); created.meetingIds.push(A.id);
|
||||
const B = await b.json(); created.meetingIds.push(B.id);
|
||||
|
||||
// Send only one of the two — server must refuse so 1..N renumber stays safe.
|
||||
const r = await api(adminCookie, "POST",
|
||||
"/api/executive-meetings/reorder",
|
||||
{ meetingDate: reorderDate, orderedIds: [A.id] });
|
||||
assert.equal(r.status, 400);
|
||||
const body = await r.json();
|
||||
assert.equal(body.code, "incomplete_day");
|
||||
});
|
||||
|
||||
test("Apply request (add_attendee) sanitizes attendee.name like direct writes", async () => {
|
||||
const m = await api(adminCookie, "POST", "/api/executive-meetings", {
|
||||
titleAr: "اجتماع طلب", titleEn: "Request meeting", meetingDate: today,
|
||||
|
||||
Reference in New Issue
Block a user