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 437 lines 16 kB view raw
1import { useNavigate } from "@tanstack/react-router"; 2import { 3 ChevronDown, 4 ChevronRight, 5 Link2, 6 Plus, 7 Search, 8 X, 9} from "lucide-react"; 10import { Fragment, useEffect, useMemo, useState } from "react"; 11import { useTranslation } from "react-i18next"; 12import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; 13import { Button } from "@/components/ui/button"; 14import { 15 Collapsible, 16 CollapsibleContent, 17 CollapsibleTrigger, 18} from "@/components/ui/collapsible"; 19import { 20 Command, 21 CommandCollection, 22 CommandDialog, 23 CommandDialogPopup, 24 CommandEmpty, 25 CommandFooter, 26 CommandGroup, 27 CommandGroupLabel, 28 CommandInput, 29 CommandItem, 30 CommandList, 31 CommandPanel, 32 CommandSeparator, 33} from "@/components/ui/command"; 34import { 35 ContextMenu, 36 ContextMenuContent, 37 ContextMenuItem, 38 ContextMenuSeparator, 39 ContextMenuTrigger, 40} from "@/components/ui/context-menu"; 41import useCreateTaskRelation from "@/hooks/mutations/task-relation/use-create-task-relation"; 42import useDeleteTaskRelation from "@/hooks/mutations/task-relation/use-delete-task-relation"; 43import useGetProject from "@/hooks/queries/project/use-get-project"; 44import { useGetTasks } from "@/hooks/queries/task/use-get-tasks"; 45import useGetTaskRelations from "@/hooks/queries/task-relation/use-get-task-relations"; 46import useActiveWorkspace from "@/hooks/queries/workspace/use-active-workspace"; 47import { useGetActiveWorkspaceUsers } from "@/hooks/queries/workspace-users/use-get-active-workspace-users"; 48import { getColumnIcon } from "@/lib/column"; 49import { toast } from "@/lib/toast"; 50import type Task from "@/types/task"; 51import SubtaskAssigneePopover from "./subtask-assignee-popover"; 52import SubtaskStatusPopover from "./subtask-status-popover"; 53 54type TaskRelationsProps = { 55 taskId: string; 56 projectId: string; 57 workspaceId: string; 58}; 59 60type TaskItem = { 61 id: string; 62 title: string; 63 number: number | null; 64 status: string; 65}; 66 67type TaskGroup = { 68 value: string; 69 label: string; 70 items: TaskItem[]; 71}; 72 73export default function TaskRelations({ 74 taskId, 75 projectId, 76 workspaceId, 77}: TaskRelationsProps) { 78 const { t } = useTranslation(); 79 const navigate = useNavigate(); 80 const [isOpen, setIsOpen] = useState(true); 81 const [commandOpen, setCommandOpen] = useState(false); 82 const [searchQuery, setSearchQuery] = useState(""); 83 const [selectedRelationType, setSelectedRelationType] = useState< 84 "blocks" | "related" 85 >("related"); 86 87 const { data: relations = [] } = useGetTaskRelations(taskId); 88 const { data: projectData } = useGetTasks(projectId); 89 const { data: project } = useGetProject({ id: projectId, workspaceId }); 90 const { data: workspace } = useActiveWorkspace(); 91 const { data: workspaceUsers } = useGetActiveWorkspaceUsers( 92 workspace?.id ?? "", 93 ); 94 const createRelation = useCreateTaskRelation(); 95 const deleteRelation = useDeleteTaskRelation(taskId); 96 97 useEffect(() => { 98 if (!commandOpen) { 99 setSearchQuery(""); 100 } 101 }, [commandOpen]); 102 103 const nonSubtaskRelations = relations.filter( 104 (rel) => rel.relationType !== "subtask", 105 ); 106 107 const groupedRelations = useMemo(() => { 108 const groups: Record< 109 string, 110 Array<{ 111 id: string; 112 relationType: string; 113 task: NonNullable<(typeof nonSubtaskRelations)[number]["sourceTask"]>; 114 }> 115 > = {}; 116 117 for (const rel of nonSubtaskRelations) { 118 const isSource = rel.sourceTaskId === taskId; 119 const linkedTask = isSource ? rel.targetTask : rel.sourceTask; 120 if (!linkedTask) continue; 121 122 const type = rel.relationType; 123 if (!groups[type]) { 124 groups[type] = []; 125 } 126 groups[type].push({ 127 id: rel.id, 128 relationType: type, 129 task: linkedTask, 130 }); 131 } 132 133 return groups; 134 }, [nonSubtaskRelations, taskId]); 135 136 const existingRelatedTaskIds = new Set( 137 nonSubtaskRelations.flatMap((rel) => [rel.sourceTaskId, rel.targetTaskId]), 138 ); 139 existingRelatedTaskIds.add(taskId); 140 141 const allTasks = useMemo(() => { 142 if (!projectData) return []; 143 const tasks: TaskItem[] = []; 144 145 if ("columns" in projectData && Array.isArray(projectData.columns)) { 146 for (const col of projectData.columns as Array<{ 147 tasks: TaskItem[]; 148 }>) { 149 if (col.tasks) { 150 for (const t of col.tasks) { 151 tasks.push(t); 152 } 153 } 154 } 155 } 156 157 return tasks; 158 }, [projectData]); 159 160 const filteredTasks = allTasks.filter( 161 (t) => !existingRelatedTaskIds.has(t.id), 162 ); 163 164 const commandGroups = useMemo<TaskGroup[]>(() => { 165 return [ 166 { 167 value: "tasks", 168 label: t("tasks:relations.tasksInProject"), 169 items: filteredTasks, 170 }, 171 ]; 172 }, [filteredTasks, t]); 173 174 const handleLinkTask = async (targetTaskId: string) => { 175 try { 176 await createRelation.mutateAsync({ 177 sourceTaskId: taskId, 178 targetTaskId, 179 relationType: selectedRelationType, 180 }); 181 setCommandOpen(false); 182 setSearchQuery(""); 183 } catch { 184 toast.error(t("tasks:relations.linkError")); 185 } 186 }; 187 188 const handleRemoveRelation = (relationId: string) => { 189 deleteRelation.mutate(relationId); 190 }; 191 192 const handleNavigateToTask = (linkedTaskId: string) => { 193 navigate({ 194 to: "/dashboard/workspace/$workspaceId/project/$projectId/task/$taskId", 195 params: { workspaceId, projectId, taskId: linkedTaskId }, 196 }); 197 }; 198 199 const getAssignee = (userId: string | null) => { 200 if (!userId || !workspaceUsers?.members) return null; 201 return workspaceUsers.members.find((member) => member.userId === userId); 202 }; 203 204 const buildTaskObject = (item: { 205 task: NonNullable<(typeof nonSubtaskRelations)[number]["sourceTask"]>; 206 }): Task => ({ 207 id: item.task.id, 208 title: item.task.title, 209 number: item.task.number, 210 description: null, 211 status: item.task.status, 212 priority: item.task.priority, 213 dueDate: null, 214 position: null, 215 createdAt: "", 216 userId: item.task.userId, 217 assigneeId: item.task.userId, 218 assigneeName: item.task.assigneeName, 219 projectId: item.task.projectId, 220 }); 221 222 const totalCount = nonSubtaskRelations.length; 223 224 return ( 225 <> 226 <Collapsible open={isOpen} onOpenChange={setIsOpen} className="w-full"> 227 <div className="flex items-center justify-between"> 228 <div className="flex items-center gap-1.5"> 229 <CollapsibleTrigger asChild> 230 <button 231 type="button" 232 className="flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground transition-colors" 233 > 234 {isOpen ? ( 235 <ChevronDown className="size-4" /> 236 ) : ( 237 <ChevronRight className="size-4" /> 238 )} 239 <span>{t("tasks:relations.title")}</span> 240 </button> 241 </CollapsibleTrigger> 242 {totalCount > 0 && ( 243 <span className="text-xs text-muted-foreground"> 244 {totalCount} 245 </span> 246 )} 247 </div> 248 <Button 249 variant="ghost" 250 size="xs" 251 className="text-muted-foreground" 252 onClick={() => setCommandOpen(true)} 253 > 254 <Plus className="size-3.5" /> 255 </Button> 256 </div> 257 258 <CollapsibleContent> 259 {Object.entries(groupedRelations).map(([type, items]) => ( 260 <div key={type} className="mt-1.5"> 261 <span className="text-[11px] text-muted-foreground/70 px-2"> 262 {t(`tasks:relations.types.${type}`, { defaultValue: type })} 263 </span> 264 <div className="flex flex-col mt-0.5"> 265 {items.map((item) => { 266 const assignee = getAssignee(item.task.userId); 267 const taskObj = buildTaskObject(item); 268 269 return ( 270 <ContextMenu key={item.id}> 271 <ContextMenuTrigger asChild> 272 <div className="group flex items-center gap-2 py-1 px-2 rounded-md hover:bg-accent/50 transition-colors cursor-default"> 273 <SubtaskStatusPopover 274 tasks={[taskObj]} 275 projectId={projectId} 276 > 277 <button 278 type="button" 279 className="shrink-0 flex items-center justify-center rounded p-0.5 transition-colors outline-none [&_svg]:text-muted-foreground hover:[&_svg]:text-foreground" 280 > 281 {getColumnIcon(item.task.status, false)} 282 </button> 283 </SubtaskStatusPopover> 284 285 <button 286 type="button" 287 className="flex-1 min-w-0 text-left outline-none" 288 onClick={() => handleNavigateToTask(item.task.id)} 289 > 290 <span 291 className={`text-sm truncate block ${item.task.status === "done" ? "line-through text-muted-foreground" : "text-foreground/90"}`} 292 > 293 {item.task.title} 294 </span> 295 </button> 296 297 <SubtaskAssigneePopover 298 tasks={[taskObj]} 299 workspaceId={workspaceId} 300 > 301 <button 302 type="button" 303 className="shrink-0 flex items-center justify-center rounded p-0.5 transition-colors outline-none" 304 > 305 {item.task.userId && assignee ? ( 306 <Avatar className="h-5 w-5"> 307 <AvatarImage 308 src={assignee?.user?.image ?? ""} 309 alt={assignee?.user?.name || ""} 310 /> 311 <AvatarFallback className="text-[9px] font-medium border border-border/30"> 312 {assignee?.user?.name 313 ?.charAt(0) 314 .toUpperCase()} 315 </AvatarFallback> 316 </Avatar> 317 ) : ( 318 <div 319 className="flex h-5 w-5 items-center justify-center rounded-full border border-dashed border-border/70" 320 title={t("tasks:popover.assignee.unassigned")} 321 > 322 <span className="text-[9px] font-medium text-muted-foreground"> 323 ? 324 </span> 325 </div> 326 )} 327 </button> 328 </SubtaskAssigneePopover> 329 </div> 330 </ContextMenuTrigger> 331 332 <ContextMenuContent className="w-40"> 333 <ContextMenuItem 334 onClick={() => handleNavigateToTask(item.task.id)} 335 > 336 <span>{t("tasks:relations.openTask")}</span> 337 </ContextMenuItem> 338 <ContextMenuSeparator /> 339 <ContextMenuItem 340 className="text-destructive" 341 onClick={() => handleRemoveRelation(item.id)} 342 > 343 <span>{t("tasks:relations.removeRelation")}</span> 344 </ContextMenuItem> 345 </ContextMenuContent> 346 </ContextMenu> 347 ); 348 })} 349 </div> 350 </div> 351 ))} 352 353 {totalCount === 0 && ( 354 <p className="text-xs text-muted-foreground px-2 py-1"> 355 {t("tasks:relations.empty")} 356 </p> 357 )} 358 </CollapsibleContent> 359 </Collapsible> 360 361 <CommandDialog open={commandOpen} onOpenChange={setCommandOpen}> 362 <CommandDialogPopup> 363 <Command items={commandGroups}> 364 <CommandInput 365 placeholder={t("tasks:relations.searchPlaceholder")} 366 value={searchQuery} 367 onChange={(e) => setSearchQuery(e.target.value)} 368 /> 369 <CommandPanel> 370 <CommandEmpty> 371 <div className="text-center py-6"> 372 <Search className="h-8 w-8 mx-auto mb-2 text-muted-foreground" /> 373 <p className="text-sm text-muted-foreground"> 374 {t("tasks:relations.noTasksFound")} 375 </p> 376 </div> 377 </CommandEmpty> 378 <CommandList> 379 {(group: TaskGroup, groupIndex: number) => ( 380 <Fragment key={group.value}> 381 <CommandGroup items={group.items}> 382 <CommandGroupLabel>{group.label}</CommandGroupLabel> 383 <CommandCollection> 384 {(item: TaskItem) => ( 385 <CommandItem 386 key={item.id} 387 value={`${project?.slug}-${item.number} ${item.title}`} 388 onClick={() => handleLinkTask(item.id)} 389 className="flex items-center gap-3 py-2" 390 > 391 {getColumnIcon(item.status, false)} 392 <span className="text-xs text-muted-foreground shrink-0 font-mono"> 393 {project?.slug}-{item.number} 394 </span> 395 <span className="text-sm truncate flex-1"> 396 {item.title} 397 </span> 398 </CommandItem> 399 )} 400 </CommandCollection> 401 </CommandGroup> 402 {groupIndex < commandGroups.length - 1 && ( 403 <CommandSeparator /> 404 )} 405 </Fragment> 406 )} 407 </CommandList> 408 </CommandPanel> 409 <CommandFooter> 410 <div className="flex items-center gap-3"> 411 <button 412 type="button" 413 className={`flex items-center gap-1.5 text-xs px-2 py-1 rounded-md transition-colors ${selectedRelationType === "related" ? "bg-accent text-foreground" : "text-muted-foreground hover:text-foreground"}`} 414 onClick={() => setSelectedRelationType("related")} 415 > 416 <Link2 className="size-3" /> 417 {t("tasks:relations.related")} 418 </button> 419 <button 420 type="button" 421 className={`flex items-center gap-1.5 text-xs px-2 py-1 rounded-md transition-colors ${selectedRelationType === "blocks" ? "bg-accent text-foreground" : "text-muted-foreground hover:text-foreground"}`} 422 onClick={() => setSelectedRelationType("blocks")} 423 > 424 <X className="size-3" /> 425 {t("tasks:relations.blocks")} 426 </button> 427 </div> 428 <span className="text-muted-foreground/60"> 429 {t("tasks:relations.selectTask")} 430 </span> 431 </CommandFooter> 432 </Command> 433 </CommandDialogPopup> 434 </CommandDialog> 435 </> 436 ); 437}