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:
@@ -143,6 +143,25 @@ export function taskProcedures(publicProcedure: ProcedureBuilder) {
|
||||
return tasks.filter((t) => t.category !== 'detail');
|
||||
}),
|
||||
|
||||
updateTask: publicProcedure
|
||||
.input(z.object({
|
||||
id: z.string().min(1),
|
||||
name: z.string().min(1).optional(),
|
||||
description: z.string().nullable().optional(),
|
||||
}))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const taskRepository = requireTaskRepository(ctx);
|
||||
const existing = await taskRepository.findById(input.id);
|
||||
if (!existing) {
|
||||
throw new TRPCError({
|
||||
code: 'NOT_FOUND',
|
||||
message: `Task '${input.id}' not found`,
|
||||
});
|
||||
}
|
||||
const { id, ...data } = input;
|
||||
return taskRepository.update(id, data);
|
||||
}),
|
||||
|
||||
deleteTask: publicProcedure
|
||||
.input(z.object({ id: z.string().min(1) }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -73,6 +73,7 @@ Each procedure uses `require*Repository(ctx)` helpers that throw `TRPCError(INTE
|
||||
| listTasks | query | Child tasks of parent |
|
||||
| getTask | query | Single task |
|
||||
| updateTaskStatus | mutation | Change task status |
|
||||
| updateTask | mutation | Update task fields (name, description) |
|
||||
| createInitiativeTask | mutation | Create task on initiative |
|
||||
| createPhaseTask | mutation | Create task on phase |
|
||||
| listInitiativeTasks | query | All tasks for initiative |
|
||||
|
||||
Reference in New Issue
Block a user