93c77f587c
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.
523 lines
16 KiB
TypeScript
523 lines
16 KiB
TypeScript
import {
|
|
useCallback,
|
|
useEffect,
|
|
useRef,
|
|
useState,
|
|
type CSSProperties,
|
|
} from "react";
|
|
import { useEditor, EditorContent, type Editor, Extension } from "@tiptap/react";
|
|
import StarterKit from "@tiptap/starter-kit";
|
|
import { Underline } from "@tiptap/extension-underline";
|
|
import { TextStyle } from "@tiptap/extension-text-style";
|
|
import { Color } from "@tiptap/extension-color";
|
|
import { FontFamily } from "@tiptap/extension-font-family";
|
|
import { TextAlign } from "@tiptap/extension-text-align";
|
|
import { safeHtml } from "@/lib/safe-html";
|
|
import {
|
|
Bold as BoldIcon,
|
|
Italic as ItalicIcon,
|
|
Underline as UnderlineIcon,
|
|
AlignLeft as AlignLeftIcon,
|
|
AlignCenter as AlignCenterIcon,
|
|
AlignRight as AlignRightIcon,
|
|
Check,
|
|
X as XIcon,
|
|
} from "lucide-react";
|
|
|
|
const COLOR_SWATCHES = [
|
|
{ value: "", label: "default" },
|
|
{ value: "#0B1E3F", label: "navy" },
|
|
{ value: "#dc2626", label: "red" },
|
|
{ value: "#ea580c", label: "orange" },
|
|
{ value: "#ca8a04", label: "amber" },
|
|
{ value: "#16a34a", label: "green" },
|
|
{ value: "#2563eb", label: "blue" },
|
|
{ value: "#7c3aed", label: "violet" },
|
|
{ value: "#6b7280", label: "gray" },
|
|
];
|
|
|
|
const FONT_OPTIONS = [
|
|
{ value: "", label: "default" },
|
|
{ value: "Cairo", label: "Cairo" },
|
|
{ value: "Tajawal", label: "Tajawal" },
|
|
{ value: "Amiri", label: "Amiri" },
|
|
{ value: "Noto Naskh Arabic", label: "Noto" },
|
|
{ value: "IBM Plex Sans Arabic", label: "IBM Plex" },
|
|
];
|
|
|
|
const FONT_SIZE_OPTIONS = [
|
|
{ value: "", label: "default" },
|
|
{ value: "10px", label: "10" },
|
|
{ value: "12px", label: "12" },
|
|
{ value: "14px", label: "14" },
|
|
{ value: "16px", label: "16" },
|
|
{ value: "18px", label: "18" },
|
|
{ value: "20px", label: "20" },
|
|
{ value: "24px", label: "24" },
|
|
{ value: "28px", label: "28" },
|
|
];
|
|
|
|
// Tiptap doesn't ship a font-size mark in v3.22 — we add one as a textStyle
|
|
// attribute so it round-trips through `<span style="font-size: …">` (which
|
|
// the server allowlists in sanitize.ts).
|
|
//
|
|
// Module augmentation: register setFontSize/unsetFontSize on Tiptap's
|
|
// Commands map so call-sites stay strongly typed (no `as never` casts).
|
|
declare module "@tiptap/core" {
|
|
interface Commands<ReturnType> {
|
|
fontSize: {
|
|
setFontSize: (size: string) => ReturnType;
|
|
unsetFontSize: () => ReturnType;
|
|
};
|
|
}
|
|
}
|
|
|
|
const FontSize = Extension.create({
|
|
name: "fontSize",
|
|
addOptions() {
|
|
return { types: ["textStyle"] };
|
|
},
|
|
addGlobalAttributes() {
|
|
return [
|
|
{
|
|
types: this.options.types,
|
|
attributes: {
|
|
fontSize: {
|
|
default: null,
|
|
parseHTML: (element: HTMLElement) =>
|
|
element.style.fontSize?.replace(/['"]+/g, "") || null,
|
|
renderHTML: (attrs: { fontSize?: string | null }) => {
|
|
if (!attrs.fontSize) return {};
|
|
return { style: `font-size: ${attrs.fontSize}` };
|
|
},
|
|
},
|
|
},
|
|
},
|
|
];
|
|
},
|
|
addCommands() {
|
|
return {
|
|
setFontSize:
|
|
(size: string) =>
|
|
({ chain }) =>
|
|
chain().setMark("textStyle", { fontSize: size }).run(),
|
|
unsetFontSize:
|
|
() =>
|
|
({ chain }) =>
|
|
chain()
|
|
.setMark("textStyle", { fontSize: null })
|
|
.removeEmptyTextStyle()
|
|
.run(),
|
|
};
|
|
},
|
|
});
|
|
|
|
/**
|
|
* In-place rich text editor used in the executive-meetings schedule for
|
|
* Word-like editing of meeting names + attendee names.
|
|
*
|
|
* - When not editing, renders the saved sanitized HTML via dangerouslySetInnerHTML.
|
|
* - Click (or focus + Enter) enters edit mode with a floating toolbar.
|
|
* - Saves on blur outside the editor+toolbar or Ctrl/Cmd+Enter.
|
|
* - Esc cancels.
|
|
*
|
|
* The server is the source of sanitization. This component only sends what
|
|
* Tiptap produces; the server strips anything outside the allowlist.
|
|
*/
|
|
export function EditableCell({
|
|
value,
|
|
onSave,
|
|
placeholder,
|
|
ariaLabel,
|
|
dir,
|
|
className,
|
|
multiline = false,
|
|
disabled = false,
|
|
fontStyle,
|
|
testId,
|
|
}: {
|
|
value: string;
|
|
onSave: (html: string) => Promise<void> | void;
|
|
placeholder?: string;
|
|
ariaLabel?: string;
|
|
dir?: "rtl" | "ltr";
|
|
className?: string;
|
|
multiline?: boolean;
|
|
disabled?: boolean;
|
|
fontStyle?: CSSProperties;
|
|
testId?: string;
|
|
}) {
|
|
const [editing, setEditing] = useState(false);
|
|
const wrapperRef = useRef<HTMLDivElement | null>(null);
|
|
const blurTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
|
|
const editor = useEditor(
|
|
{
|
|
extensions: [
|
|
StarterKit.configure({
|
|
heading: false,
|
|
codeBlock: false,
|
|
blockquote: false,
|
|
horizontalRule: false,
|
|
bulletList: false,
|
|
orderedList: false,
|
|
listItem: false,
|
|
}),
|
|
Underline,
|
|
TextStyle,
|
|
Color,
|
|
FontFamily.configure({ types: ["textStyle"] }),
|
|
FontSize,
|
|
TextAlign.configure({
|
|
types: ["paragraph", "heading"],
|
|
alignments: ["left", "center", "right"],
|
|
}),
|
|
],
|
|
content: value || "",
|
|
editable: editing && !disabled,
|
|
// Editor renders into a contenteditable div. We don't auto-focus on
|
|
// mount; we focus when the user enters edit mode.
|
|
editorProps: {
|
|
attributes: {
|
|
class:
|
|
"outline-none focus:outline-none min-h-[1.5em] " +
|
|
(multiline ? "" : "whitespace-nowrap overflow-hidden"),
|
|
dir: dir ?? "auto",
|
|
},
|
|
handleKeyDown: (_view, event) => {
|
|
if (event.key === "Escape") {
|
|
cancelEdit();
|
|
return true;
|
|
}
|
|
if (event.key === "Enter" && (event.ctrlKey || event.metaKey)) {
|
|
saveEdit();
|
|
return true;
|
|
}
|
|
if (event.key === "Enter" && !multiline && !event.shiftKey) {
|
|
// Single-line cells: Enter saves instead of inserting newline.
|
|
event.preventDefault();
|
|
saveEdit();
|
|
return true;
|
|
}
|
|
return false;
|
|
},
|
|
},
|
|
},
|
|
[],
|
|
);
|
|
|
|
// Keep the editor's content in sync when the value prop changes (e.g.
|
|
// after a successful save the parent re-renders with the new server value).
|
|
useEffect(() => {
|
|
if (!editor) return;
|
|
if (!editing && editor.getHTML() !== (value || "<p></p>")) {
|
|
editor.commands.setContent(value || "", { emitUpdate: false });
|
|
}
|
|
}, [editor, value, editing]);
|
|
|
|
useEffect(() => {
|
|
if (!editor) return;
|
|
editor.setEditable(editing && !disabled);
|
|
if (editing) {
|
|
// Focus on the next tick so the editor is rendered first.
|
|
const id = setTimeout(() => editor.commands.focus("end"), 0);
|
|
return () => clearTimeout(id);
|
|
}
|
|
return undefined;
|
|
}, [editor, editing, disabled]);
|
|
|
|
const enterEdit = () => {
|
|
if (disabled) return;
|
|
setEditing(true);
|
|
};
|
|
|
|
const cancelEdit = useCallback(() => {
|
|
if (!editor) return;
|
|
editor.commands.setContent(value || "", { emitUpdate: false });
|
|
setEditing(false);
|
|
}, [editor, value]);
|
|
|
|
const saveEdit = useCallback(async () => {
|
|
if (!editor) return;
|
|
const html = editor.getHTML();
|
|
// Treat an empty editor (just <p></p>) as empty string.
|
|
const stripped = html === "<p></p>" ? "" : html;
|
|
setEditing(false);
|
|
if (stripped !== value) {
|
|
try {
|
|
await onSave(stripped);
|
|
} catch {
|
|
// Parent shows toast; revert local content to last known good value.
|
|
editor.commands.setContent(value || "", { emitUpdate: false });
|
|
}
|
|
}
|
|
}, [editor, onSave, value]);
|
|
|
|
// Click outside the editor+toolbar saves and exits edit mode. We use a
|
|
// tiny defer on blur because clicking a toolbar button briefly takes focus
|
|
// out of the editor.
|
|
const onFocusOut = (e: React.FocusEvent<HTMLDivElement>) => {
|
|
if (!editing) return;
|
|
const next = e.relatedTarget as Node | null;
|
|
if (next && wrapperRef.current?.contains(next)) return;
|
|
if (blurTimerRef.current) clearTimeout(blurTimerRef.current);
|
|
blurTimerRef.current = setTimeout(() => {
|
|
saveEdit();
|
|
}, 100);
|
|
};
|
|
|
|
const onFocusIn = () => {
|
|
if (blurTimerRef.current) {
|
|
clearTimeout(blurTimerRef.current);
|
|
blurTimerRef.current = null;
|
|
}
|
|
};
|
|
|
|
// Static (read-only) rendering. The server is the source of truth for
|
|
// sanitization on write, but we still re-sanitize on read with DOMPurify
|
|
// as a defense-in-depth boundary in case any legacy/out-of-band row
|
|
// slipped past the server allowlist.
|
|
if (!editor) {
|
|
return (
|
|
<div
|
|
className={className}
|
|
style={fontStyle}
|
|
dangerouslySetInnerHTML={{
|
|
__html: value ? safeHtml(value) : (placeholder ?? ""),
|
|
}}
|
|
/>
|
|
);
|
|
}
|
|
|
|
if (!editing) {
|
|
return (
|
|
<div
|
|
role={disabled ? undefined : "button"}
|
|
tabIndex={disabled ? -1 : 0}
|
|
aria-label={ariaLabel}
|
|
className={
|
|
(className ?? "") +
|
|
(disabled
|
|
? ""
|
|
: " cursor-text hover:bg-blue-50/50 focus:bg-blue-50/50 focus:outline-none rounded transition-colors")
|
|
}
|
|
style={fontStyle}
|
|
data-testid={testId}
|
|
onClick={enterEdit}
|
|
onKeyDown={(e) => {
|
|
if (e.key === "Enter" || e.key === " ") {
|
|
e.preventDefault();
|
|
enterEdit();
|
|
}
|
|
}}
|
|
>
|
|
{value ? (
|
|
<span dangerouslySetInnerHTML={{ __html: safeHtml(value) }} />
|
|
) : (
|
|
<span className="text-muted-foreground italic">
|
|
{placeholder ?? ""}
|
|
</span>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div
|
|
ref={wrapperRef}
|
|
className={"relative " + (className ?? "")}
|
|
onFocus={onFocusIn}
|
|
onBlur={onFocusOut}
|
|
data-testid={testId}
|
|
data-editing="true"
|
|
style={fontStyle}
|
|
>
|
|
<FormattingToolbar editor={editor} onSave={saveEdit} onCancel={cancelEdit} />
|
|
<div
|
|
className="border border-blue-400 rounded bg-white px-1.5 py-1 shadow-sm focus-within:border-blue-600"
|
|
style={fontStyle}
|
|
>
|
|
<EditorContent editor={editor} />
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function FormattingToolbar({
|
|
editor,
|
|
onSave,
|
|
onCancel,
|
|
}: {
|
|
editor: Editor;
|
|
onSave: () => void;
|
|
onCancel: () => void;
|
|
}) {
|
|
// Re-render when selection changes so active states reflect cursor position.
|
|
const [, setTick] = useState(0);
|
|
useEffect(() => {
|
|
const update = () => setTick((n) => n + 1);
|
|
editor.on("selectionUpdate", update);
|
|
editor.on("transaction", update);
|
|
return () => {
|
|
editor.off("selectionUpdate", update);
|
|
editor.off("transaction", update);
|
|
};
|
|
}, [editor]);
|
|
|
|
const isActive = (mark: string, attrs?: Record<string, unknown>) =>
|
|
editor.isActive(mark, attrs);
|
|
|
|
const btn = (active: boolean) =>
|
|
"p-1 rounded text-xs " +
|
|
(active
|
|
? "bg-[#0B1E3F] text-white"
|
|
: "bg-white text-[#0B1E3F] hover:bg-gray-100 border border-gray-200");
|
|
|
|
return (
|
|
<div
|
|
className="absolute -top-9 inline-start-0 z-20 flex items-center gap-1 bg-white border border-gray-200 rounded shadow-md px-1 py-0.5"
|
|
// Prevent toolbar buttons from stealing focus → keeps editor selection
|
|
// intact when toggling marks.
|
|
onMouseDown={(e) => e.preventDefault()}
|
|
data-testid="em-edit-toolbar"
|
|
>
|
|
<button
|
|
type="button"
|
|
className={btn(isActive("bold"))}
|
|
onClick={() => editor.chain().focus().toggleBold().run()}
|
|
aria-label="Bold"
|
|
data-testid="em-edit-bold"
|
|
>
|
|
<BoldIcon className="w-3.5 h-3.5" />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className={btn(isActive("italic"))}
|
|
onClick={() => editor.chain().focus().toggleItalic().run()}
|
|
aria-label="Italic"
|
|
data-testid="em-edit-italic"
|
|
>
|
|
<ItalicIcon className="w-3.5 h-3.5" />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className={btn(isActive("underline"))}
|
|
onClick={() => editor.chain().focus().toggleUnderline().run()}
|
|
aria-label="Underline"
|
|
data-testid="em-edit-underline"
|
|
>
|
|
<UnderlineIcon className="w-3.5 h-3.5" />
|
|
</button>
|
|
<span className="w-px h-4 bg-gray-200 mx-0.5" />
|
|
{COLOR_SWATCHES.map((c) => (
|
|
<button
|
|
key={c.value || "default"}
|
|
type="button"
|
|
className={
|
|
"w-4 h-4 rounded-sm border " +
|
|
(c.value
|
|
? "border-gray-300 hover:scale-110"
|
|
: "border-gray-400 bg-[repeating-linear-gradient(45deg,#fff,#fff_3px,#e5e7eb_3px,#e5e7eb_6px)]")
|
|
}
|
|
style={c.value ? { backgroundColor: c.value } : undefined}
|
|
onClick={() => {
|
|
if (c.value) {
|
|
editor.chain().focus().setColor(c.value).run();
|
|
} else {
|
|
editor.chain().focus().unsetColor().run();
|
|
}
|
|
}}
|
|
aria-label={`color ${c.label}`}
|
|
data-testid={`em-edit-color-${c.label}`}
|
|
/>
|
|
))}
|
|
<span className="w-px h-4 bg-gray-200 mx-0.5" />
|
|
<button
|
|
type="button"
|
|
className={btn(isActive("paragraph", { textAlign: "right" }))}
|
|
onClick={() => editor.chain().focus().setTextAlign("right").run()}
|
|
aria-label="Align right"
|
|
data-testid="em-edit-align-right"
|
|
>
|
|
<AlignRightIcon className="w-3.5 h-3.5" />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className={btn(isActive("paragraph", { textAlign: "center" }))}
|
|
onClick={() => editor.chain().focus().setTextAlign("center").run()}
|
|
aria-label="Align center"
|
|
data-testid="em-edit-align-center"
|
|
>
|
|
<AlignCenterIcon className="w-3.5 h-3.5" />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className={btn(isActive("paragraph", { textAlign: "left" }))}
|
|
onClick={() => editor.chain().focus().setTextAlign("left").run()}
|
|
aria-label="Align left"
|
|
data-testid="em-edit-align-left"
|
|
>
|
|
<AlignLeftIcon className="w-3.5 h-3.5" />
|
|
</button>
|
|
<span className="w-px h-4 bg-gray-200 mx-0.5" />
|
|
<select
|
|
className="text-xs border border-gray-200 rounded bg-white text-[#0B1E3F] px-1 py-0.5"
|
|
value={(editor.getAttributes("textStyle").fontFamily as string) || ""}
|
|
onChange={(e) => {
|
|
const v = e.target.value;
|
|
if (v) editor.chain().focus().setFontFamily(v).run();
|
|
else editor.chain().focus().unsetFontFamily().run();
|
|
}}
|
|
aria-label="Font"
|
|
data-testid="em-edit-font"
|
|
>
|
|
{FONT_OPTIONS.map((f) => (
|
|
<option key={f.value || "default"} value={f.value}>
|
|
{f.label}
|
|
</option>
|
|
))}
|
|
</select>
|
|
<select
|
|
className="text-xs border border-gray-200 rounded bg-white text-[#0B1E3F] px-1 py-0.5"
|
|
value={(editor.getAttributes("textStyle").fontSize as string) || ""}
|
|
onChange={(e) => {
|
|
const v = e.target.value;
|
|
// setFontSize / unsetFontSize are registered on Tiptap's Commands
|
|
// map via the `declare module "@tiptap/core"` augmentation above,
|
|
// so they're strongly typed without casts.
|
|
if (v) editor.chain().focus().setFontSize(v).run();
|
|
else editor.chain().focus().unsetFontSize().run();
|
|
}}
|
|
aria-label="Font size"
|
|
data-testid="em-edit-font-size"
|
|
>
|
|
{FONT_SIZE_OPTIONS.map((f) => (
|
|
<option key={f.value || "default"} value={f.value}>
|
|
{f.label}
|
|
</option>
|
|
))}
|
|
</select>
|
|
<span className="w-px h-4 bg-gray-200 mx-0.5" />
|
|
<button
|
|
type="button"
|
|
className="p-1 rounded bg-green-600 text-white hover:bg-green-700"
|
|
onClick={onSave}
|
|
aria-label="Save"
|
|
data-testid="em-edit-save"
|
|
>
|
|
<Check className="w-3.5 h-3.5" />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="p-1 rounded bg-gray-200 text-[#0B1E3F] hover:bg-gray-300"
|
|
onClick={onCancel}
|
|
aria-label="Cancel"
|
|
data-testid="em-edit-cancel"
|
|
>
|
|
<XIcon className="w-3.5 h-3.5" />
|
|
</button>
|
|
</div>
|
|
);
|
|
}
|
|
|