kaneo (minimalist kanban) fork to experiment adding a tangled integration
github.com/usekaneo/kaneo
1import { useNavigate } from "@tanstack/react-router";
2import { AnimatePresence } from "framer-motion";
3import { ChevronDown, ChevronRight, Plus } from "lucide-react";
4import { useCallback, useEffect, useRef, useState } from "react";
5import { useTranslation } from "react-i18next";
6import {
7 AlertDialog,
8 AlertDialogClose,
9 AlertDialogContent,
10 AlertDialogDescription,
11 AlertDialogFooter,
12 AlertDialogHeader,
13 AlertDialogTitle,
14} from "@/components/ui/alert-dialog";
15import { Button } from "@/components/ui/button";
16import CircularProgress from "@/components/ui/circular-progress";
17import {
18 Collapsible,
19 CollapsibleContent,
20 CollapsibleTrigger,
21} from "@/components/ui/collapsible";
22import { Input } from "@/components/ui/input";
23import useCreateTask from "@/hooks/mutations/task/use-create-task";
24import { useDeleteTask } from "@/hooks/mutations/task/use-delete-task";
25import useCreateTaskRelation from "@/hooks/mutations/task-relation/use-create-task-relation";
26import useGetTaskRelations from "@/hooks/queries/task-relation/use-get-task-relations";
27import useActiveWorkspace from "@/hooks/queries/workspace/use-active-workspace";
28import { useGetActiveWorkspaceUsers } from "@/hooks/queries/workspace-users/use-get-active-workspace-users";
29import { toast } from "@/lib/toast";
30import queryClient from "@/query-client";
31import type Task from "@/types/task";
32import SubtaskRow from "./subtask-row";
33
34type TaskSubtasksProps = {
35 taskId: string;
36 projectId: string;
37 workspaceId: string;
38};
39
40export default function TaskSubtasks({
41 taskId,
42 projectId,
43 workspaceId,
44}: TaskSubtasksProps) {
45 const { t } = useTranslation();
46 const navigate = useNavigate();
47 const [isOpen, setIsOpen] = useState(true);
48 const [isAdding, setIsAdding] = useState(false);
49 const [newTitle, setNewTitle] = useState("");
50 const [deleteTaskId, setDeleteTaskId] = useState<string | null>(null);
51 const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
52 const [focusedIndex, setFocusedIndex] = useState(-1);
53 const containerRef = useRef<HTMLDivElement>(null);
54
55 const { data: relations = [] } = useGetTaskRelations(taskId);
56 const { data: workspace } = useActiveWorkspace();
57 const { data: workspaceUsers } = useGetActiveWorkspaceUsers(
58 workspace?.id ?? "",
59 );
60 const createTask = useCreateTask();
61 const createRelation = useCreateTaskRelation();
62 const { mutateAsync: deleteTask } = useDeleteTask();
63
64 const subtasks = relations
65 .filter(
66 (rel) => rel.relationType === "subtask" && rel.sourceTaskId === taskId,
67 )
68 .map((rel) => ({ relation: rel, task: rel.targetTask }))
69 .filter(
70 (item): item is typeof item & { task: NonNullable<typeof item.task> } =>
71 item.task !== null,
72 );
73
74 const completedCount = subtasks.filter(
75 (s) => s.task.status === "done",
76 ).length;
77 const totalCount = subtasks.length;
78 const hasSelection = selectedIds.size > 0;
79
80 const toggleSelection = useCallback((id: string) => {
81 setSelectedIds((prev) => {
82 const next = new Set(prev);
83 if (next.has(id)) {
84 next.delete(id);
85 } else {
86 next.add(id);
87 }
88 return next;
89 });
90 }, []);
91
92 const clearSelection = useCallback(() => {
93 setSelectedIds(new Set());
94 setFocusedIndex(-1);
95 }, []);
96
97 const buildTaskObject = (subtask: (typeof subtasks)[number]): Task => ({
98 id: subtask.task.id,
99 title: subtask.task.title,
100 number: subtask.task.number,
101 description: null,
102 status: subtask.task.status,
103 priority: subtask.task.priority,
104 startDate: null,
105 dueDate: null,
106 position: null,
107 createdAt: "",
108 userId: subtask.task.userId,
109 assigneeId: subtask.task.userId,
110 assigneeName: subtask.task.assigneeName,
111 projectId: subtask.task.projectId,
112 });
113
114 const getTargetTasks = (currentTask: Task): Task[] => {
115 if (hasSelection && selectedIds.has(currentTask.id)) {
116 return subtasks
117 .filter((s) => selectedIds.has(s.task.id))
118 .map(buildTaskObject);
119 }
120 return [currentTask];
121 };
122
123 const getAssignee = (userId: string | null) => {
124 if (!userId || !workspaceUsers?.members) return null;
125 return (
126 workspaceUsers.members.find((member) => member.userId === userId) ?? null
127 );
128 };
129
130 const getSelectionRadius = (index: number, isSelected: boolean) => {
131 if (!isSelected) return "rounded-md";
132
133 const prevSelected =
134 index > 0 && selectedIds.has(subtasks[index - 1].task.id);
135 const nextSelected =
136 index < subtasks.length - 1 &&
137 selectedIds.has(subtasks[index + 1].task.id);
138
139 if (prevSelected && nextSelected) return "rounded-none";
140 if (prevSelected) return "rounded-t-none rounded-b-md";
141 if (nextSelected) return "rounded-t-md rounded-b-none";
142 return "rounded-md";
143 };
144
145 // Keyboard navigation
146 useEffect(() => {
147 const container = containerRef.current;
148 if (!container || totalCount === 0) return;
149
150 const handleKeyDown = (e: KeyboardEvent) => {
151 const target = e.target as HTMLElement | null;
152 if (
153 target?.closest(
154 "input, textarea, [contenteditable='true'], .ProseMirror",
155 )
156 )
157 return;
158
159 if (!container.contains(document.activeElement) && focusedIndex === -1)
160 return;
161
162 switch (e.key) {
163 case "ArrowDown":
164 case "j": {
165 e.preventDefault();
166 setFocusedIndex((prev) => (prev < totalCount - 1 ? prev + 1 : prev));
167 break;
168 }
169 case "ArrowUp":
170 case "k": {
171 e.preventDefault();
172 setFocusedIndex((prev) => (prev > 0 ? prev - 1 : prev));
173 break;
174 }
175 case " ": {
176 if (focusedIndex >= 0 && focusedIndex < totalCount) {
177 e.preventDefault();
178 toggleSelection(subtasks[focusedIndex].task.id);
179 }
180 break;
181 }
182 case "Enter": {
183 if (focusedIndex >= 0 && focusedIndex < totalCount) {
184 e.preventDefault();
185 navigate({
186 to: "/dashboard/workspace/$workspaceId/project/$projectId/task/$taskId",
187 params: {
188 workspaceId,
189 projectId,
190 taskId: subtasks[focusedIndex].task.id,
191 },
192 });
193 }
194 break;
195 }
196 case "Escape": {
197 if (hasSelection) {
198 e.preventDefault();
199 clearSelection();
200 } else if (focusedIndex >= 0) {
201 e.preventDefault();
202 setFocusedIndex(-1);
203 }
204 break;
205 }
206 }
207 };
208
209 document.addEventListener("keydown", handleKeyDown);
210 return () => document.removeEventListener("keydown", handleKeyDown);
211 }, [
212 focusedIndex,
213 totalCount,
214 subtasks,
215 hasSelection,
216 clearSelection,
217 navigate,
218 workspaceId,
219 projectId,
220 toggleSelection,
221 ]);
222
223 const handleAddSubtask = async () => {
224 if (!newTitle.trim()) return;
225
226 try {
227 const newTask = await createTask.mutateAsync({
228 title: newTitle.trim(),
229 description: "",
230 projectId,
231 status: "to-do",
232 priority: "no-priority",
233 });
234
235 await createRelation.mutateAsync({
236 sourceTaskId: taskId,
237 targetTaskId: newTask.id,
238 relationType: "subtask",
239 });
240
241 setNewTitle("");
242 setIsAdding(false);
243 } catch {
244 toast.error(t("tasks:subtasks.createError"));
245 }
246 };
247
248 const handleDeleteTask = async () => {
249 if (!deleteTaskId) return;
250 try {
251 await deleteTask(deleteTaskId);
252 queryClient.invalidateQueries({ queryKey: ["tasks", projectId] });
253 queryClient.invalidateQueries({ queryKey: ["task-relations", taskId] });
254 setSelectedIds((prev) => {
255 const next = new Set(prev);
256 next.delete(deleteTaskId);
257 return next;
258 });
259 toast.success(t("tasks:subtasks.deleteSuccess"));
260 } catch (error) {
261 toast.error(
262 error instanceof Error
263 ? error.message
264 : t("tasks:subtasks.deleteError"),
265 );
266 } finally {
267 setDeleteTaskId(null);
268 }
269 };
270
271 return (
272 <>
273 <Collapsible open={isOpen} onOpenChange={setIsOpen} className="w-full">
274 <div className="flex items-center justify-between">
275 <div className="flex items-center gap-1.5">
276 <CollapsibleTrigger asChild>
277 <button
278 type="button"
279 className="flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground transition-colors"
280 >
281 {isOpen ? (
282 <ChevronDown className="size-4" />
283 ) : (
284 <ChevronRight className="size-4" />
285 )}
286 <span>{t("tasks:subtasks.title")}</span>
287 </button>
288 </CollapsibleTrigger>
289 {totalCount > 0 && (
290 <span className="flex items-center gap-1.5 ml-0.5">
291 <CircularProgress
292 completed={completedCount}
293 total={totalCount}
294 />
295 <span className="text-xs text-muted-foreground">
296 {completedCount}/{totalCount}
297 </span>
298 </span>
299 )}
300 </div>
301 <Button
302 variant="ghost"
303 size="xs"
304 className="text-muted-foreground"
305 onClick={() => setIsAdding(true)}
306 >
307 <Plus className="size-3.5" />
308 </Button>
309 </div>
310
311 <CollapsibleContent>
312 {/* biome-ignore lint/a11y/noStaticElementInteractions: keyboard nav managed via document listener */}
313 <div
314 ref={containerRef}
315 className="flex flex-col mt-1"
316 onMouseDown={() => {
317 if (focusedIndex === -1 && !hasSelection) {
318 setFocusedIndex(0);
319 }
320 }}
321 >
322 <AnimatePresence initial={false}>
323 {subtasks.map((subtask, index) => {
324 const taskObj = buildTaskObject(subtask);
325 const isSelected = selectedIds.has(subtask.task.id);
326
327 return (
328 <SubtaskRow
329 key={subtask.task.id}
330 task={taskObj}
331 tasks={getTargetTasks(taskObj)}
332 projectId={projectId}
333 workspaceId={workspace?.id ?? workspaceId}
334 isSelected={isSelected}
335 isFocused={focusedIndex === index}
336 selectionRadius={getSelectionRadius(index, isSelected)}
337 assignee={getAssignee(subtask.task.userId)}
338 onToggleSelection={() => toggleSelection(subtask.task.id)}
339 onNavigate={() =>
340 navigate({
341 to: "/dashboard/workspace/$workspaceId/project/$projectId/task/$taskId",
342 params: {
343 workspaceId,
344 projectId,
345 taskId: subtask.task.id,
346 },
347 })
348 }
349 onDeleteClick={() => setDeleteTaskId(subtask.task.id)}
350 />
351 );
352 })}
353 </AnimatePresence>
354 </div>
355
356 {isAdding && (
357 <div className="flex items-center gap-2 mt-2">
358 <Input
359 size="sm"
360 placeholder={t("tasks:subtasks.inputPlaceholder")}
361 value={newTitle}
362 onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
363 setNewTitle(e.target.value)
364 }
365 onKeyDown={(e: React.KeyboardEvent<HTMLInputElement>) => {
366 if (e.key === "Enter") handleAddSubtask();
367 if (e.key === "Escape") {
368 setIsAdding(false);
369 setNewTitle("");
370 }
371 }}
372 autoFocus
373 />
374 <Button
375 size="xs"
376 onClick={handleAddSubtask}
377 disabled={!newTitle.trim() || createTask.isPending}
378 >
379 {t("tasks:subtasks.addAction")}
380 </Button>
381 <Button
382 variant="ghost"
383 size="xs"
384 onClick={() => {
385 setIsAdding(false);
386 setNewTitle("");
387 }}
388 >
389 {t("common:actions.cancel")}
390 </Button>
391 </div>
392 )}
393
394 {!isAdding && totalCount === 0 && (
395 <p className="text-xs text-muted-foreground px-2 py-1">
396 {t("tasks:subtasks.empty")}
397 </p>
398 )}
399 </CollapsibleContent>
400 </Collapsible>
401
402 <AlertDialog
403 open={!!deleteTaskId}
404 onOpenChange={(open) => !open && setDeleteTaskId(null)}
405 >
406 <AlertDialogContent>
407 <AlertDialogHeader>
408 <AlertDialogTitle>
409 {t("tasks:subtasks.deleteDialogTitle")}
410 </AlertDialogTitle>
411 <AlertDialogDescription>
412 {t("tasks:subtasks.deleteDialogDescription")}
413 </AlertDialogDescription>
414 </AlertDialogHeader>
415 <AlertDialogFooter>
416 <AlertDialogClose>
417 <Button variant="outline" size="sm">
418 {t("common:actions.cancel")}
419 </Button>
420 </AlertDialogClose>
421 <AlertDialogClose onClick={handleDeleteTask}>
422 <Button variant="destructive" size="sm">
423 {t("tasks:subtasks.deleteAction")}
424 </Button>
425 </AlertDialogClose>
426 </AlertDialogFooter>
427 </AlertDialogContent>
428 </AlertDialog>
429 </>
430 );
431}