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:
@@ -1,16 +1,17 @@
|
||||
import sanitizeHtml from "sanitize-html";
|
||||
|
||||
const ALLOWED_FONT_FAMILIES = new Set<string>([
|
||||
"Cairo",
|
||||
"Tajawal",
|
||||
"Amiri",
|
||||
"Noto Naskh Arabic",
|
||||
"IBM Plex Sans Arabic",
|
||||
"system-ui",
|
||||
"sans-serif",
|
||||
"serif",
|
||||
"monospace",
|
||||
]);
|
||||
// font-family is validated by a strict regex (no custom functions, no
|
||||
// type-bypass casts) so sanitize-html can run it through its native
|
||||
// `regex.test(value)` style validator.
|
||||
//
|
||||
// Allowed families: Cairo, Tajawal, Amiri, Noto Naskh Arabic,
|
||||
// IBM Plex Sans Arabic, system-ui, sans-serif, serif, monospace.
|
||||
// A value may be a single name (optionally quoted) or a comma-separated
|
||||
// stack of allowed names.
|
||||
const FONT_NAME_PART = String.raw`(?:["']?(?:Cairo|Tajawal|Amiri|Noto Naskh Arabic|IBM Plex Sans Arabic|system-ui|sans-serif|serif|monospace)["']?)`;
|
||||
const FONT_FAMILY_RE = new RegExp(
|
||||
`^${FONT_NAME_PART}(?:\\s*,\\s*${FONT_NAME_PART})*$`,
|
||||
);
|
||||
|
||||
const FONT_SIZE_RE = /^(?:[8-9]|[1-9]\d)px$/;
|
||||
const COLOR_RE = /^(?:#[0-9a-f]{3}|#[0-9a-f]{6}|rgb\(\s*\d{1,3}\s*,\s*\d{1,3}\s*,\s*\d{1,3}\s*\))$/i;
|
||||
@@ -34,11 +35,7 @@ export const RICH_TEXT_OPTIONS: sanitizeHtml.IOptions = {
|
||||
"text-decoration": [/^(?:none|underline)$/i],
|
||||
"text-align": [/^(?:left|right|center|start|end|justify)$/i],
|
||||
"font-size": [FONT_SIZE_RE],
|
||||
"font-family": [
|
||||
// Allow either an exact known family or a comma-separated list of
|
||||
// quoted/unquoted families that all match our allowlist.
|
||||
(value: string) => isAllowedFontFamilyValue(value),
|
||||
] as unknown as RegExp[],
|
||||
"font-family": [FONT_FAMILY_RE],
|
||||
},
|
||||
},
|
||||
allowedSchemes: [],
|
||||
@@ -49,14 +46,6 @@ export const RICH_TEXT_OPTIONS: sanitizeHtml.IOptions = {
|
||||
exclusiveFilter: () => false,
|
||||
};
|
||||
|
||||
function isAllowedFontFamilyValue(raw: string): boolean {
|
||||
// Split on commas, strip whitespace + surrounding quotes, then check each
|
||||
// candidate name. If any unknown name appears we reject the whole value.
|
||||
const parts = raw.split(",").map((s) => s.trim().replace(/^["']|["']$/g, ""));
|
||||
if (parts.length === 0) return false;
|
||||
return parts.every((p) => ALLOWED_FONT_FAMILIES.has(p));
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize a Tiptap-style rich-text HTML string from a client.
|
||||
*
|
||||
|
||||
@@ -951,12 +951,24 @@ router.post(
|
||||
});
|
||||
}
|
||||
}
|
||||
// Snapshot the day's existing slots (start_time, end_time) sorted by
|
||||
// current daily_number. We redistribute them in the new order so the
|
||||
// first row of the new order takes the earliest existing slot, etc.
|
||||
// Snapshot the day's existing slots (start_time, end_time) sorted
|
||||
// by `start_time NULLS LAST, daily_number` — this is the canonical
|
||||
// schedule order. The first row of the new order takes the earliest
|
||||
// existing slot, the second takes the next, and so on.
|
||||
const slots = allRows
|
||||
.slice()
|
||||
.sort((a, b) => a.dailyNumber - b.dailyNumber)
|
||||
.sort((a, b) => {
|
||||
const aHasTime = a.startTime != null;
|
||||
const bHasTime = b.startTime != null;
|
||||
if (aHasTime && bHasTime) {
|
||||
if (a.startTime! < b.startTime!) return -1;
|
||||
if (a.startTime! > b.startTime!) return 1;
|
||||
return a.dailyNumber - b.dailyNumber;
|
||||
}
|
||||
if (aHasTime) return -1; // NULLS LAST
|
||||
if (bHasTime) return 1;
|
||||
return a.dailyNumber - b.dailyNumber;
|
||||
})
|
||||
.map((r) => ({ startTime: r.startTime, endTime: r.endTime }));
|
||||
// Two-phase update to avoid (date, daily_number) unique conflicts
|
||||
// *during* the renumber.
|
||||
@@ -984,7 +996,9 @@ router.post(
|
||||
.where(eq(executiveMeetingsTable.id, id));
|
||||
}
|
||||
await logAudit(tx, {
|
||||
action: "reorder",
|
||||
// Specific reorder action namespace so audit consumers can filter
|
||||
// schedule reorders separately from per-row meeting writes.
|
||||
action: "executive_meeting.reorder",
|
||||
entityType: "meeting",
|
||||
entityId: data.orderedIds[0]!,
|
||||
oldValue: {
|
||||
|
||||
Reference in New Issue
Block a user