diff --git a/apps/client/package.json b/apps/client/package.json index 9ccb98d..d356238 100644 --- a/apps/client/package.json +++ b/apps/client/package.json @@ -45,7 +45,8 @@ "sonner": "^1.7.0", "tailwind-merge": "^2.5.4", "tailwindcss-animate": "^1.0.7", - "zod": "^3.23.8" + "zod": "^3.23.8", + "zustand": "^5.0.1" }, "devDependencies": { "@eslint/js": "^9.13.0", diff --git a/apps/client/src/features/project/board/api.ts b/apps/client/src/features/project/board/api.ts index 80b288a..64534ab 100644 --- a/apps/client/src/features/project/board/api.ts +++ b/apps/client/src/features/project/board/api.ts @@ -8,9 +8,9 @@ export const boardAPI = { return response.data.result; }, - getEvent: async (projectId: number, config: AxiosRequestConfig = {}) => { + getEvent: async (projectId: number, version: number, config: AxiosRequestConfig = {}) => { const response = await axiosInstance.get( - `/event?projectId=${projectId}`, + `/event?projectId=${projectId}&version=${version}`, config ); return response.data.result; diff --git a/apps/client/src/features/project/board/components/KanbanBoard.tsx b/apps/client/src/features/project/board/components/KanbanBoard.tsx index 108bf6a..9e88377 100644 --- a/apps/client/src/features/project/board/components/KanbanBoard.tsx +++ b/apps/client/src/features/project/board/components/KanbanBoard.tsx @@ -1,15 +1,9 @@ -import { ReactNode, useEffect, useState, DragEvent, useCallback } from 'react'; -import { Link, useLoaderData } from '@tanstack/react-router'; +import { ReactNode, useState, DragEvent } from 'react'; +import { Link } from '@tanstack/react-router'; import { motion, AnimatePresence } from 'framer-motion'; import { HamburgerMenuIcon, PlusIcon } from '@radix-ui/react-icons'; import { PanelLeftOpen } from 'lucide-react'; -import { AxiosError } from 'axios'; -import { - Section as TSection, - Task, - TaskEvent, - TaskEventType, -} from '@/features/project/board/types.ts'; + import { Section, SectionContent, @@ -23,270 +17,76 @@ import { DropdownMenuTrigger, } from '@/components/ui/dropdown-menu.tsx'; import { Button } from '@/components/ui/button.tsx'; -import { Card, CardContent, CardHeader } from '@/components/ui/card.tsx'; +import { Card, CardContent, CardFooter, CardHeader } from '@/components/ui/card.tsx'; import { cn } from '@/lib/utils.ts'; import { Badge } from '@/components/ui/badge.tsx'; -import { boardAPI } from '@/features/project/board/api.ts'; import { useToast } from '@/lib/useToast.tsx'; import { useBoardMutations } from '@/features/project/board/useBoardMutations.ts'; -import { throttle } from '@/shared/utils/throttle.ts'; import { AssigneeAvatars } from '@/features/project/board/components/AssigneeAvatars.tsx'; import { calculatePosition, findDiff, findTask } from '@/features/project/board/utils.ts'; import { TaskTextarea } from '@/features/project/board/components/TaskTextarea.tsx'; +import { useBoardStore } from '@/features/project/board/useBoardStore.ts'; +import { SubtaskProgress } from '@/features/project/board/components/SubtaskProgress.tsx'; + +interface KanbanBoardProps { + projectId: number; +} -export function KanbanBoard({ sections: initialSections }: { sections: TSection[] }) { - const { projectId } = useLoaderData({ - from: '/_auth/$project/board', - }); +export function KanbanBoard({ projectId }: KanbanBoardProps) { + const { sections } = useBoardStore(); + const { updateTaskPosition, updateTaskTitle, createTask, restoreState } = useBoardStore(); const toast = useToast(); - const { createTask, updatePosition, updateTitle } = useBoardMutations(projectId); - const [sections, setSections] = useState(initialSections); + const mutations = useBoardMutations(projectId); const [belowSectionId, setBelowSectionId] = useState(-1); const [belowTaskId, setBelowTaskId] = useState(-1); - const handleEvent = useCallback((event: TaskEvent) => { - setSections((currentSections) => { - const handleTaskCreated = (sections: TSection[]): TSection[] => { - return sections.map((section) => - section.id === event.task.sectionId - ? { - ...section, - tasks: [ - ...section.tasks, - { - id: event.task.id, - title: event.task.title ?? '', - sectionId: event.task.sectionId!, - position: event.task.position!, - assignees: event.task.assignees ?? [], - labels: event.task.labels ?? [], - subtasks: event.task.subtasks ?? { total: 0, completed: 0 }, - } as Task, - ], - } - : section - ); - }; - - const handlePositionUpdated = (sections: TSection[]): TSection[] => { - const task = findTask(sections, event.task.id); - if (!task) return sections; - - return sections.map((section) => { - const filteredTasks = section.tasks.filter((t) => t.id !== task.id); - - if (section.id === event.task.sectionId) { - return { - ...section, - tasks: [ - ...filteredTasks, - { - ...task, - sectionId: event.task.sectionId!, - position: event.task.position!, + const handleTitleChange = (taskId: number, newTitle: string) => { + const task = findTask(sections, taskId); + if (!task) return; + + const diff = findDiff(task.title, newTitle); + + if (diff.originalContent.length > 0) { + mutations.updateTitle.mutate( + { + event: 'DELETE_TITLE', + taskId, + title: { + position: diff.position, + content: diff.originalContent, + length: diff.originalContent.length, + }, + }, + { + onSuccess: () => { + if (diff.content.length > 0) { + mutations.updateTitle.mutate({ + event: 'INSERT_TITLE', + taskId, + title: { + position: diff.position, + content: diff.content, + length: diff.content.length, }, - ].sort((a, b) => a.position.localeCompare(b.position)), - }; - } - - return { - ...section, - tasks: filteredTasks, - }; - }); - }; - - const handleTaskUpdated = (sections: TSection[]): TSection[] => { - return sections.map((section) => ({ - ...section, - tasks: section.tasks.map((task) => - task.id === event.task.id ? ({ ...task, ...event.task } as Task) : task - ), - })); - }; - - const handleTaskDeleted = (sections: TSection[]): TSection[] => { - return sections.map((section) => ({ - ...section, - tasks: section.tasks.filter((task) => task.id !== event.task.id), - })); - }; - - let updatedSections = currentSections; - - switch (event.event) { - case TaskEventType.TASK_CREATED: - updatedSections = handleTaskCreated(currentSections); - break; - - case TaskEventType.POSITION_UPDATED: - updatedSections = handlePositionUpdated(currentSections); - break; - - case TaskEventType.TITLE_UPDATED: - updatedSections = handleTaskUpdated(currentSections); - break; - - case TaskEventType.TASK_DELETED: - updatedSections = handleTaskDeleted(currentSections); - break; - - case TaskEventType.ASSIGNEES_CHANGED: - case TaskEventType.LABELS_CHANGED: - case TaskEventType.SUBTASKS_CHANGED: - updatedSections = handleTaskUpdated(currentSections); - break; - - default: - break; - } - - return updatedSections.map((section) => ({ - ...section, - tasks: [...section.tasks].sort((a, b) => a.position.localeCompare(b.position)), - })); - }); - }, []); - - const handleTitleChange = useCallback( - (taskId: number, newTitle: string) => { - setSections((currentSections) => { - const task = findTask(currentSections, taskId); - if (!task) return currentSections; - - const diff = findDiff(task.title, newTitle); - - if (diff.originalContent.length > 0) { - updateTitle.mutate( - { - event: 'DELETE_TITLE', - taskId, - title: { - position: diff.position, - content: diff.originalContent, - length: diff.originalContent.length, - }, - }, - { - onSuccess: () => { - if (diff.content.length > 0) { - updateTitle.mutate({ - event: 'INSERT_TITLE', - taskId, - title: { - position: diff.position, - content: diff.content, - length: diff.content.length, - }, - }); - } - }, + }); } - ); - } else if (diff.content.length > 0) { - updateTitle.mutate({ - event: 'INSERT_TITLE', - taskId, - title: { - position: diff.position, - content: diff.content, - length: diff.content.length, - }, - }); + }, } - - return currentSections.map((section) => ({ - ...section, - tasks: section.tasks.map((t) => (t.id === taskId ? { ...t, title: newTitle } : t)), - })); + ); + } else if (diff.content.length > 0) { + mutations.updateTitle.mutate({ + event: 'INSERT_TITLE', + taskId, + title: { + position: diff.position, + content: diff.content, + length: diff.content.length, + }, }); - }, - [updateTitle] - ); - - useEffect(() => { - let timeoutId: number; - const controller = new AbortController(); - let isPolling = false; - let isStopped = false; - - let retryCount = 0; - const MAX_RETRY_COUNT = 5; - const POLLING_INTERVAL = 500; - - const pollEvent = async () => { - if (isPolling || isStopped) return; - - try { - isPolling = true; - - const event = await boardAPI.getEvent(projectId, { - signal: controller.signal, - }); - - if (event) { - handleEvent(event); - retryCount = 0; - } - } catch (error) { - retryCount += 1; - - if ((error as AxiosError).status === 404) { - retryCount = 0; - } - - if (retryCount >= MAX_RETRY_COUNT) { - toast.error('Failed to poll event. Please refresh the page.', 5000); - isStopped = true; - controller.abort(); - clearTimeout(timeoutId); - return; - } - } finally { - isPolling = false; - if (!isStopped) { - timeoutId = setTimeout(pollEvent, POLLING_INTERVAL); - } - } - }; - - timeoutId = setTimeout(pollEvent, POLLING_INTERVAL); - - return () => { - isStopped = true; - controller.abort(); - clearTimeout(timeoutId); - }; - }, [projectId, handleEvent, toast]); - - const handleDragStart = (event: DragEvent, sectionId: number, taskId: number) => { - event.dataTransfer.setData('taskId', taskId.toString()); - event.dataTransfer.setData('sectionId', sectionId.toString()); - }; - - const handleDragOver = (e: DragEvent, sectionId: number, taskId?: number) => { - e.preventDefault(); - setBelowSectionId(sectionId); - - if (!taskId) { - setBelowTaskId(-1); - return; } - setBelowTaskId(taskId); - }; - - const handleDragLeave = () => { - setBelowTaskId(-1); - setBelowSectionId(-1); - }; - - const throttledDragLeave = throttle(handleDragLeave); - - const handleDragEnd = () => { - setBelowTaskId(-1); - setBelowSectionId(-1); + updateTaskTitle(taskId, newTitle); }; const handleDrop = (event: DragEvent, sectionId: number) => { @@ -311,39 +111,12 @@ export function KanbanBoard({ sections: initialSections }: { sections: TSection[ const previousSections = [...sections]; - setSections((currentSections) => { - const task = findTask(currentSections, taskId); - if (!task) return currentSections; - - const updatedTask = { - ...task, - position, - sectionId, - }; - - return currentSections.map((section) => { - const filteredTasks = section.tasks.filter((t) => t.id !== taskId); - - if (section.id === sectionId) { - return { - ...section, - tasks: [...filteredTasks, updatedTask].sort((a, b) => - a.position.localeCompare(b.position) - ), - }; - } - - return { - ...section, - tasks: filteredTasks, - }; - }); - }); + updateTaskPosition(sectionId, taskId, position); setBelowTaskId(-1); setBelowSectionId(-1); - updatePosition.mutate( + mutations.updatePosition.mutate( { event: 'UPDATE_POSITION', sectionId, @@ -352,7 +125,7 @@ export function KanbanBoard({ sections: initialSections }: { sections: TSection[ }, { onError: () => { - setSections(previousSections); + restoreState(previousSections); toast.error('Failed to update task position'); }, } @@ -365,7 +138,7 @@ export function KanbanBoard({ sections: initialSections }: { sections: TSection[ const position = calculatePosition(section.tasks, -1); - createTask.mutate( + mutations.createTask.mutate( { event: 'CREATE_TASK', sectionId, @@ -373,28 +146,15 @@ export function KanbanBoard({ sections: initialSections }: { sections: TSection[ }, { onSuccess: (response) => { - setSections((currentSections) => - currentSections.map((section) => { - if (section.id === sectionId) { - return { - ...section, - tasks: [ - ...section.tasks, - { - id: response.id, - title: '', - sectionId, - position, - assignees: [], - labels: [], - subtasks: { total: 0, completed: 0 }, - }, - ].sort((a, b) => a.position.localeCompare(b.position)), - }; - } - return section; - }) - ); + createTask(sectionId, { + id: response.id, + title: '', + sectionId, + position, + assignees: [], + labels: [], + subtasks: { total: 0, completed: 0 }, + }); }, onError: () => { toast.error('Failed to create task'); @@ -403,6 +163,33 @@ export function KanbanBoard({ sections: initialSections }: { sections: TSection[ ); }; + const handleDragStart = (event: DragEvent, sectionId: number, taskId: number) => { + event.dataTransfer.setData('taskId', taskId.toString()); + event.dataTransfer.setData('sectionId', sectionId.toString()); + }; + + const handleDragEnd = () => { + setBelowTaskId(-1); + setBelowSectionId(-1); + }; + + const handleDragOver = (e: DragEvent, sectionId: number, taskId?: number) => { + e.preventDefault(); + setBelowSectionId(sectionId); + + if (!taskId) { + setBelowTaskId(-1); + return; + } + + setBelowTaskId(taskId); + }; + + const handleDragLeave = () => { + setBelowTaskId(-1); + setBelowSectionId(-1); + }; + return (
@@ -414,8 +201,6 @@ export function KanbanBoard({ sections: initialSections }: { sections: TSection[ 'bg-transparent', section.id === belowSectionId && belowTaskId === -1 && 'border-2 border-blue-400' )} - onDragOver={(e) => handleDragOver(e, section.id)} - onDrop={(e) => handleDrop(e, section.id)} >
@@ -436,82 +221,84 @@ export function KanbanBoard({ sections: initialSections }: { sections: TSection[ handleDragOver(e, section.id)} - onDragLeave={throttledDragLeave} + onDragLeave={handleDragLeave} onDrop={(e) => handleDrop(e, section.id)} onDragEnd={handleDragEnd} > - {section.tasks - .sort((a, b) => a.position.localeCompare(b.position)) - .map((task) => ( - - handleDragStart( - e as unknown as DragEvent, - section.id, - task.id - ) - } - onDrop={(e) => handleDrop(e, section.id)} - onDragOver={(e) => { - e.preventDefault(); - e.stopPropagation(); - handleDragOver(e, section.id, task.id); - }} - onDragLeave={throttledDragLeave} + {section.tasks.map((task) => ( + + handleDragStart(e as unknown as DragEvent, section.id, task.id) + } + onDrop={(e) => handleDrop(e, section.id)} + onDragOver={(e) => { + e.preventDefault(); + e.stopPropagation(); + handleDragOver(e, section.id, task.id); + }} + onDragLeave={handleDragLeave} + > + - - - + + + + +
+ {task.labels.map((label) => ( + + {label.name} + + ))} +
+ +
+ {task.subtasks.total > 0 && ( + + - - - -
- {task.labels.map((label) => ( - - {label.name} - - ))} -
- -
-
-
- ))} + + )} + +
+ ))}