Files
TX/artifacts/tx-os/src/components/editable-cell.tsx
T
riyadhafraa 93c77f587c 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.
2026-04-29 08:03:25 +00:00

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>
);
}