kaneo (minimalist kanban) fork to experiment adding a tangled integration github.com/usekaneo/kaneo
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

at main 431 lines 14 kB view raw
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}