feat: Add tiptap editor for task descriptions in slide-over panel
- Add updateTask tRPC mutation (name, description fields) - Replace static description with TiptapEditor in TaskSlideOver - Auto-detect existing markdown descriptions and convert to tiptap JSON - Debounced auto-save with flush on close/unmount - Saving indicator in header
This commit is contained in:
@@ -1,12 +1,13 @@
|
||||
import { useCallback, useEffect, useMemo } from "react";
|
||||
import { useCallback, useEffect, useRef, useMemo } from "react";
|
||||
import { motion, AnimatePresence } from "motion/react";
|
||||
import { X, Trash2 } from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { StatusBadge } from "@/components/StatusBadge";
|
||||
import { StatusDot } from "@/components/StatusDot";
|
||||
import { TiptapEditor } from "@/components/editor/TiptapEditor";
|
||||
import { getCategoryConfig } from "@/lib/category";
|
||||
import { markdownToSafeHtml } from "@/lib/markdown-to-tiptap";
|
||||
import { markdownToTiptapJson } from "@/lib/markdown-to-tiptap";
|
||||
import { useExecutionContext } from "./ExecutionContext";
|
||||
import { trpc } from "@/lib/trpc";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -15,6 +16,7 @@ export function TaskSlideOver() {
|
||||
const { selectedEntry, setSelectedTaskId } = useExecutionContext();
|
||||
const queueTaskMutation = trpc.queueTask.useMutation();
|
||||
const deleteTaskMutation = trpc.deleteTask.useMutation();
|
||||
const updateTaskMutation = trpc.updateTask.useMutation();
|
||||
|
||||
const close = useCallback(() => setSelectedTaskId(null), [setSelectedTaskId]);
|
||||
|
||||
@@ -28,6 +30,46 @@ export function TaskSlideOver() {
|
||||
return () => document.removeEventListener("keydown", onKeyDown);
|
||||
}, [selectedEntry, close]);
|
||||
|
||||
// Debounced description save
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const pendingRef = useRef<{ id: string; description: string } | null>(null);
|
||||
|
||||
const flushDescription = useCallback(() => {
|
||||
if (timerRef.current) {
|
||||
clearTimeout(timerRef.current);
|
||||
timerRef.current = null;
|
||||
}
|
||||
if (pendingRef.current) {
|
||||
const data = pendingRef.current;
|
||||
pendingRef.current = null;
|
||||
updateTaskMutation.mutate(data);
|
||||
}
|
||||
}, [updateTaskMutation]);
|
||||
|
||||
const handleDescriptionUpdate = useCallback(
|
||||
(json: string) => {
|
||||
const task = selectedEntry?.task;
|
||||
if (!task) return;
|
||||
pendingRef.current = { id: task.id, description: json };
|
||||
if (timerRef.current) clearTimeout(timerRef.current);
|
||||
timerRef.current = setTimeout(flushDescription, 1000);
|
||||
},
|
||||
[selectedEntry?.task, flushDescription],
|
||||
);
|
||||
|
||||
// Flush on close / unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (timerRef.current) clearTimeout(timerRef.current);
|
||||
if (pendingRef.current) {
|
||||
const data = pendingRef.current;
|
||||
pendingRef.current = null;
|
||||
updateTaskMutation.mutate(data);
|
||||
}
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const task = selectedEntry?.task ?? null;
|
||||
const dependencies = selectedEntry?.blockedBy ?? [];
|
||||
const dependents = selectedEntry?.dependents ?? [];
|
||||
@@ -36,10 +78,18 @@ export function TaskSlideOver() {
|
||||
dependencies.every((d) => d.status === "completed");
|
||||
const canQueue = task !== null && task.status === "pending" && allDepsComplete;
|
||||
|
||||
const descriptionHtml = useMemo(() => {
|
||||
// Convert existing markdown description to tiptap JSON for the editor.
|
||||
// If it's already valid tiptap JSON, use as-is.
|
||||
const editorContent = useMemo(() => {
|
||||
if (!task?.description) return null;
|
||||
try {
|
||||
return markdownToSafeHtml(task.description);
|
||||
const parsed = JSON.parse(task.description);
|
||||
if (parsed?.type === "doc") return task.description;
|
||||
} catch {
|
||||
// Not JSON — treat as markdown
|
||||
}
|
||||
try {
|
||||
return JSON.stringify(markdownToTiptapJson(task.description));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
@@ -56,7 +106,10 @@ export function TaskSlideOver() {
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
onClick={close}
|
||||
onClick={() => {
|
||||
flushDescription();
|
||||
close();
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Panel */}
|
||||
@@ -77,9 +130,17 @@ export function TaskSlideOver() {
|
||||
{selectedEntry.phaseName}
|
||||
</p>
|
||||
</div>
|
||||
{updateTaskMutation.isPending && (
|
||||
<span className="shrink-0 text-[10px] text-muted-foreground">
|
||||
Saving...
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
className="shrink-0 rounded-md p-1 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
||||
onClick={close}
|
||||
onClick={() => {
|
||||
flushDescription();
|
||||
close();
|
||||
}}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
@@ -108,18 +169,14 @@ export function TaskSlideOver() {
|
||||
</MetaField>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
{/* Description — editable tiptap */}
|
||||
<Section title="Description">
|
||||
{descriptionHtml ? (
|
||||
<div
|
||||
className="prose prose-sm prose-p:my-1 prose-headings:mb-1 prose-headings:mt-3 prose-ul:my-1 prose-ol:my-1 prose-li:my-0.5 prose-blockquote:my-1 prose-pre:my-1 prose-hr:my-2 dark:prose-invert max-w-none"
|
||||
dangerouslySetInnerHTML={{ __html: descriptionHtml }}
|
||||
/>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No description
|
||||
</p>
|
||||
)}
|
||||
<TiptapEditor
|
||||
entityId={task.id}
|
||||
content={editorContent}
|
||||
onUpdate={handleDescriptionUpdate}
|
||||
enablePageLinks={false}
|
||||
/>
|
||||
</Section>
|
||||
|
||||
{/* Dependencies */}
|
||||
|
||||
Reference in New Issue
Block a user