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');
|
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
|
deleteTask: publicProcedure
|
||||||
.input(z.object({ id: z.string().min(1) }))
|
.input(z.object({ id: z.string().min(1) }))
|
||||||
.mutation(async ({ ctx, input }) => {
|
.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 { motion, AnimatePresence } from "motion/react";
|
||||||
import { X, Trash2 } from "lucide-react";
|
import { X, Trash2 } from "lucide-react";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { StatusBadge } from "@/components/StatusBadge";
|
import { StatusBadge } from "@/components/StatusBadge";
|
||||||
import { StatusDot } from "@/components/StatusDot";
|
import { StatusDot } from "@/components/StatusDot";
|
||||||
|
import { TiptapEditor } from "@/components/editor/TiptapEditor";
|
||||||
import { getCategoryConfig } from "@/lib/category";
|
import { getCategoryConfig } from "@/lib/category";
|
||||||
import { markdownToSafeHtml } from "@/lib/markdown-to-tiptap";
|
import { markdownToTiptapJson } from "@/lib/markdown-to-tiptap";
|
||||||
import { useExecutionContext } from "./ExecutionContext";
|
import { useExecutionContext } from "./ExecutionContext";
|
||||||
import { trpc } from "@/lib/trpc";
|
import { trpc } from "@/lib/trpc";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
@@ -15,6 +16,7 @@ export function TaskSlideOver() {
|
|||||||
const { selectedEntry, setSelectedTaskId } = useExecutionContext();
|
const { selectedEntry, setSelectedTaskId } = useExecutionContext();
|
||||||
const queueTaskMutation = trpc.queueTask.useMutation();
|
const queueTaskMutation = trpc.queueTask.useMutation();
|
||||||
const deleteTaskMutation = trpc.deleteTask.useMutation();
|
const deleteTaskMutation = trpc.deleteTask.useMutation();
|
||||||
|
const updateTaskMutation = trpc.updateTask.useMutation();
|
||||||
|
|
||||||
const close = useCallback(() => setSelectedTaskId(null), [setSelectedTaskId]);
|
const close = useCallback(() => setSelectedTaskId(null), [setSelectedTaskId]);
|
||||||
|
|
||||||
@@ -28,6 +30,46 @@ export function TaskSlideOver() {
|
|||||||
return () => document.removeEventListener("keydown", onKeyDown);
|
return () => document.removeEventListener("keydown", onKeyDown);
|
||||||
}, [selectedEntry, close]);
|
}, [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 task = selectedEntry?.task ?? null;
|
||||||
const dependencies = selectedEntry?.blockedBy ?? [];
|
const dependencies = selectedEntry?.blockedBy ?? [];
|
||||||
const dependents = selectedEntry?.dependents ?? [];
|
const dependents = selectedEntry?.dependents ?? [];
|
||||||
@@ -36,10 +78,18 @@ export function TaskSlideOver() {
|
|||||||
dependencies.every((d) => d.status === "completed");
|
dependencies.every((d) => d.status === "completed");
|
||||||
const canQueue = task !== null && task.status === "pending" && allDepsComplete;
|
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;
|
if (!task?.description) return null;
|
||||||
try {
|
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 {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -56,7 +106,10 @@ export function TaskSlideOver() {
|
|||||||
animate={{ opacity: 1 }}
|
animate={{ opacity: 1 }}
|
||||||
exit={{ opacity: 0 }}
|
exit={{ opacity: 0 }}
|
||||||
transition={{ duration: 0.2 }}
|
transition={{ duration: 0.2 }}
|
||||||
onClick={close}
|
onClick={() => {
|
||||||
|
flushDescription();
|
||||||
|
close();
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Panel */}
|
{/* Panel */}
|
||||||
@@ -77,9 +130,17 @@ export function TaskSlideOver() {
|
|||||||
{selectedEntry.phaseName}
|
{selectedEntry.phaseName}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
{updateTaskMutation.isPending && (
|
||||||
|
<span className="shrink-0 text-[10px] text-muted-foreground">
|
||||||
|
Saving...
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
className="shrink-0 rounded-md p-1 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
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" />
|
<X className="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
@@ -108,18 +169,14 @@ export function TaskSlideOver() {
|
|||||||
</MetaField>
|
</MetaField>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Description */}
|
{/* Description — editable tiptap */}
|
||||||
<Section title="Description">
|
<Section title="Description">
|
||||||
{descriptionHtml ? (
|
<TiptapEditor
|
||||||
<div
|
entityId={task.id}
|
||||||
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"
|
content={editorContent}
|
||||||
dangerouslySetInnerHTML={{ __html: descriptionHtml }}
|
onUpdate={handleDescriptionUpdate}
|
||||||
/>
|
enablePageLinks={false}
|
||||||
) : (
|
/>
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
No description
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
{/* Dependencies */}
|
{/* Dependencies */}
|
||||||
|
|||||||
@@ -73,6 +73,7 @@ Each procedure uses `require*Repository(ctx)` helpers that throw `TRPCError(INTE
|
|||||||
| listTasks | query | Child tasks of parent |
|
| listTasks | query | Child tasks of parent |
|
||||||
| getTask | query | Single task |
|
| getTask | query | Single task |
|
||||||
| updateTaskStatus | mutation | Change task status |
|
| updateTaskStatus | mutation | Change task status |
|
||||||
|
| updateTask | mutation | Update task fields (name, description) |
|
||||||
| createInitiativeTask | mutation | Create task on initiative |
|
| createInitiativeTask | mutation | Create task on initiative |
|
||||||
| createPhaseTask | mutation | Create task on phase |
|
| createPhaseTask | mutation | Create task on phase |
|
||||||
| listInitiativeTasks | query | All tasks for initiative |
|
| listInitiativeTasks | query | All tasks for initiative |
|
||||||
|
|||||||
Reference in New Issue
Block a user