kaneo (minimalist kanban) fork to experiment adding a tangled integration
github.com/usekaneo/kaneo
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}