diff --git a/frontend/src/components/backlog/StoryBlock.tsx b/frontend/src/components/backlog/StoryBlock.tsx index 6bc9e0d..fab109e 100644 --- a/frontend/src/components/backlog/StoryBlock.tsx +++ b/frontend/src/components/backlog/StoryBlock.tsx @@ -1,24 +1,21 @@ +import { MouseEvent } from "react"; import { Socket } from "socket.io-client"; import { useOutletContext } from "react-router-dom"; -import useShowDetail from "../../hooks/pages/backlog/useShowDetail"; -import { BacklogStatusType, EpicCategoryDTO } from "../../types/DTO/backlogDTO"; +import EpicDropdown from "./EpicDropdown"; import BacklogStatusChip from "./BacklogStatusChip"; import CategoryChip from "./CategoryChip"; -import ChevronDown from "../../assets/icons/chevron-down.svg?react"; -import ChevronRight from "../../assets/icons/chevron-right.svg?react"; -import TaskContainer from "./TaskContainer"; -import TaskHeader from "./TaskHeader"; import BacklogStatusDropdown from "./BacklogStatusDropdown"; -import useStoryEmitEvent from "../../hooks/pages/backlog/useStoryEmitEvent"; +import ConfirmModal from "../common/ConfirmModal"; +import useShowDetail from "../../hooks/pages/backlog/useShowDetail"; import useBacklogInputChange from "../../hooks/pages/backlog/useBacklogInputChange"; -import { MouseEvent } from "react"; -import { MOUSE_KEY } from "../../constants/event"; +import useStoryEmitEvent from "../../hooks/pages/backlog/useStoryEmitEvent"; import useDropdownState from "../../hooks/common/dropdown/useDropdownState"; -import TrashCan from "../../assets/icons/trash-can.svg?react"; import { useModal } from "../../hooks/common/modal/useModal"; -import ConfirmModal from "../common/ConfirmModal"; -import EpicDropdown from "./EpicDropdown"; -import TaskCreateBlock from "./TaskCreateBlock"; +import ChevronRight from "../../assets/icons/chevron-right.svg?react"; +import ChevronDown from "../../assets/icons/chevron-down.svg?react"; +import TrashCan from "../../assets/icons/trash-can.svg?react"; +import { MOUSE_KEY } from "../../constants/event"; +import { BacklogStatusType, EpicCategoryDTO } from "../../types/DTO/backlogDTO"; interface StoryBlockProps { id: number; @@ -27,11 +24,8 @@ interface StoryBlockProps { point: number | null; progress: number; status: BacklogStatusType; - children: React.ReactNode; taskExist: boolean; epicList?: EpicCategoryDTO[]; - finished?: boolean; - lastTaskRankValue?: string; } const StoryBlock = ({ @@ -43,9 +37,6 @@ const StoryBlock = ({ status, taskExist, epicList, - finished = false, - lastTaskRankValue, - children, }: StoryBlockProps) => { const { socket }: { socket: Socket } = useOutletContext(); const { showDetail, handleShowDetail } = useShowDetail(); @@ -280,15 +271,6 @@ const StoryBlock = ({ )} - {showDetail && ( - - - {children} - {!finished && ( - - )} - - )} ); }; diff --git a/frontend/src/components/backlog/StoryDragContainer.tsx b/frontend/src/components/backlog/StoryDragContainer.tsx new file mode 100644 index 0000000..8964258 --- /dev/null +++ b/frontend/src/components/backlog/StoryDragContainer.tsx @@ -0,0 +1,36 @@ +import React, { DragEvent } from "react"; + +interface StoryDragContainerProps { + index: number; + setRef: (index: number) => (element: HTMLDivElement) => void; + onDragStart: () => void; + onDragEnd: (event: DragEvent) => void; + currentlyDraggedOver: boolean; + children: React.ReactNode; +} + +const StoryDragContainer = ({ + index, + setRef, + onDragStart, + onDragEnd, + currentlyDraggedOver, + children, +}: StoryDragContainerProps) => ( +
+
+ {children} +
+); + +export default StoryDragContainer; diff --git a/frontend/src/components/backlog/TaskDragContainer.tsx b/frontend/src/components/backlog/TaskDragContainer.tsx new file mode 100644 index 0000000..3c532d1 --- /dev/null +++ b/frontend/src/components/backlog/TaskDragContainer.tsx @@ -0,0 +1,41 @@ +import { DragEvent } from "react"; + +interface TaskDragContainerProps { + storyIndex: number; + taskIndex: number; + setRef: ( + storyIndex: number, + taskIndex: number + ) => (element: HTMLDivElement) => void; + onDragStart: () => void; + onDragEnd: (event: DragEvent) => void; + currentlyDraggedOver: boolean; + children: React.ReactNode; +} + +const TaskDragContainer = ({ + storyIndex, + taskIndex, + setRef, + onDragStart, + onDragEnd, + currentlyDraggedOver, + children, +}: TaskDragContainerProps) => ( +
+
+ {children} +
+); + +export default TaskDragContainer; diff --git a/frontend/src/hooks/pages/backlog/useBacklogSocket.ts b/frontend/src/hooks/pages/backlog/useBacklogSocket.ts index b18d3c9..30838fc 100644 --- a/frontend/src/hooks/pages/backlog/useBacklogSocket.ts +++ b/frontend/src/hooks/pages/backlog/useBacklogSocket.ts @@ -78,7 +78,7 @@ const useBacklogSocket = (socket: Socket) => { if (content.epicId) { setBacklog((prevBacklog) => { let targetStory: StoryDTO | null = null; - backlog.epicList.some((epic) => { + prevBacklog.epicList.some((epic) => { const foundStory = epic.storyList.find( (story) => story.id === content.id ); @@ -168,6 +168,56 @@ const useBacklogSocket = (socket: Socket) => { }); break; case BacklogSocketTaskAction.UPDATE: + if (content.storyId) { + setBacklog((prevBacklog) => { + let targetTask: TaskDTO | null = null; + prevBacklog.epicList.some((epic) => { + epic.storyList.some((story) => { + const foundTask = story.taskList.find( + (task) => task.id === content.id + ); + + if (foundTask) { + targetTask = { ...foundTask }; + return true; + } + + return false; + }); + + if (targetTask) { + return true; + } + + return false; + }); + + if (!targetTask) { + return prevBacklog; + } + + const newEpicList = prevBacklog.epicList.map((epic) => { + const newStoryList = epic.storyList.map((story) => { + const newTaskList = story.taskList.filter( + (task) => task.id !== content.id + ); + + if (story.id === content.storyId) { + newTaskList.push({ + ...targetTask, + ...content, + } as TaskDTO); + } + return { ...story, taskList: newTaskList }; + }); + + return { ...epic, storyList: newStoryList }; + }); + return { epicList: newEpicList }; + }); + break; + } + setBacklog((prevBacklog) => { const newEpicList = prevBacklog.epicList.map((epic) => { const newStoryList = epic.storyList.map((story) => { diff --git a/frontend/src/pages/backlog/EpicPage.tsx b/frontend/src/pages/backlog/EpicPage.tsx index bef7450..5f73559 100644 --- a/frontend/src/pages/backlog/EpicPage.tsx +++ b/frontend/src/pages/backlog/EpicPage.tsx @@ -27,7 +27,7 @@ const EpicPage = () => { {...backlog.epicList.map( ({ id: epicId, name, color, rankValue, storyList }) => ( 1} + storyExist={storyList.length > 0} epic={{ id: epicId, name, color, rankValue }} > {...storyList.map(({ id, title, point, status, taskList }) => { @@ -40,19 +40,15 @@ const EpicPage = () => { : 0; return ( - 0} - lastTaskRankValue={ - taskList.length - ? taskList[taskList.length - 1].rankValue - : undefined - } - > + <> + 0} + /> {...taskList.map((task) => )} - + ); })} {showDetail ? ( diff --git a/frontend/src/pages/backlog/FinishedStoryPage.tsx b/frontend/src/pages/backlog/FinishedStoryPage.tsx index ec0b765..37dbcb8 100644 --- a/frontend/src/pages/backlog/FinishedStoryPage.tsx +++ b/frontend/src/pages/backlog/FinishedStoryPage.tsx @@ -38,16 +38,16 @@ const FinishedStoryPage = () => { : 0; return ( - 0} - epicList={epicCategoryList} - finished={true} - > + <> + 0} + epicList={epicCategoryList} + /> {...taskList.map((task) => )} - + ); })}
diff --git a/frontend/src/pages/backlog/UnfinishedStoryPage.tsx b/frontend/src/pages/backlog/UnfinishedStoryPage.tsx index 4847143..0cfabff 100644 --- a/frontend/src/pages/backlog/UnfinishedStoryPage.tsx +++ b/frontend/src/pages/backlog/UnfinishedStoryPage.tsx @@ -1,28 +1,57 @@ +import { DragEvent, useEffect, useMemo, useRef, useState } from "react"; import { useOutletContext } from "react-router-dom"; import { Socket } from "socket.io-client"; import { LexoRank } from "lexorank"; -import { BacklogDTO } from "../../types/DTO/backlogDTO"; import StoryCreateButton from "../../components/backlog/StoryCreateButton"; import StoryCreateForm from "../../components/backlog/StoryCreateForm"; -import { DragEvent, useEffect, useMemo, useRef, useState } from "react"; -import changeEpicListToStoryList from "../../utils/changeEpicListToStoryList"; import StoryBlock from "../../components/backlog/StoryBlock"; -import TaskBlock from "../../components/backlog/TaskBlock"; import useShowDetail from "../../hooks/pages/backlog/useShowDetail"; import useStoryEmitEvent from "../../hooks/pages/backlog/useStoryEmitEvent"; +import changeEpicListToStoryList from "../../utils/changeEpicListToStoryList"; import getDragElementIndex from "../../utils/getDragElementIndex"; import { BacklogSocketData } from "../../types/common/backlog"; +import { BacklogDTO, TaskDTO } from "../../types/DTO/backlogDTO"; +import StoryDragContainer from "../../components/backlog/StoryDragContainer"; +import TaskBlock from "../../components/backlog/TaskBlock"; +import TaskDragContainer from "../../components/backlog/TaskDragContainer"; +import TaskContainer from "../../components/backlog/TaskContainer"; +import TaskHeader from "../../components/backlog/TaskHeader"; +import TaskCreateBlock from "../../components/backlog/TaskCreateBlock"; +import useTaskEmitEvent from "../../hooks/pages/backlog/useTaskEmitEvent"; const UnfinishedStoryPage = () => { const { socket, backlog }: { socket: Socket; backlog: BacklogDTO } = useOutletContext(); const { showDetail, handleShowDetail } = useShowDetail(); const [storyElementIndex, setStoryElementIndex] = useState(); + const [taskElementIndex, setTaskElementIndex] = useState<{ + storyId?: number; + taskIndex?: number; + }>({ + storyId: undefined, + taskIndex: undefined, + }); + const [draggingStoryId, setDraggingStoryId] = useState(); + const [draggingTaskId, setDraggingTaskId] = useState(); const storyComponentRefList = useRef([]); - const draggingComponentIdRef = useRef(); + const taskComponentRefList = useRef([]); const storyList = useMemo( () => changeEpicListToStoryList(backlog.epicList) + .filter(({ status }) => status !== "완료") + .map((story) => { + const newTaskList = story.taskList.slice(); + newTaskList.sort((taskA, taskB) => { + if (taskA.rankValue < taskB.rankValue) { + return -1; + } + if (taskA.rankValue > taskB.rankValue) { + return 1; + } + return 0; + }); + return { ...story, taskList: newTaskList }; + }) .sort((storyA, storyB) => { if (storyA.rankValue < storyB.rankValue) { return -1; @@ -31,8 +60,7 @@ const UnfinishedStoryPage = () => { return 1; } return 0; - }) - .filter(({ status }) => status !== "완료"), + }), [backlog.epicList] ); const epicCategoryList = useMemo( @@ -46,16 +74,26 @@ const UnfinishedStoryPage = () => { [backlog.epicList] ); const { emitStoryUpdateEvent } = useStoryEmitEvent(socket); + const { emitTaskUpdateEvent } = useTaskEmitEvent(socket); const setStoryComponentRef = (index: number) => (element: HTMLDivElement) => { storyComponentRefList.current[index] = element; }; + const setTaskComponentRef = + (storyIndex: number, taskIndex: number) => (element: HTMLDivElement) => { + taskComponentRefList.current[storyIndex][taskIndex] = element; + }; + const handleDragOver = (event: DragEvent) => { + if (draggingTaskId) { + return; + } + event.preventDefault(); const index = getDragElementIndex( storyComponentRefList.current, - draggingComponentIdRef.current, + storyList.findIndex(({ id }) => id === draggingStoryId), event.clientY ); @@ -63,18 +101,23 @@ const UnfinishedStoryPage = () => { }; const handleDragStart = (id: number) => { - draggingComponentIdRef.current = id; + if (draggingTaskId) { + return; + } + setDraggingStoryId(id); }; const handleDragEnd = (event: DragEvent) => { + if (draggingTaskId) { + return; + } + event.stopPropagation(); - const targetIndex = storyList.findIndex( - ({ id }) => id === draggingComponentIdRef.current - ); + const targetIndex = storyList.findIndex(({ id }) => id === draggingStoryId); let rankValue; if (storyElementIndex === targetIndex) { - draggingComponentIdRef.current = undefined; + setDraggingStoryId(undefined); setStoryElementIndex(undefined); return; } @@ -96,11 +139,11 @@ const UnfinishedStoryPage = () => { } emitStoryUpdateEvent({ - id: draggingComponentIdRef.current as number, + id: draggingStoryId as number, rankValue, }); - draggingComponentIdRef.current = undefined; + setDraggingStoryId(undefined); setStoryElementIndex(undefined); }; @@ -113,7 +156,7 @@ const UnfinishedStoryPage = () => { if ( domain === "story" && action === "delete" && - content.id === draggingComponentIdRef.current + content.id === draggingStoryId ) { setStoryElementIndex(undefined); } @@ -126,6 +169,76 @@ const UnfinishedStoryPage = () => { }; }, []); + const handleTaskDragOver = (event: DragEvent, storyIndex: number) => { + if (draggingStoryId) { + return; + } + + event.preventDefault(); + const mouseIndex = storyList[storyIndex].taskList.findIndex( + ({ id }) => id === draggingTaskId + ); + + const index = getDragElementIndex( + taskComponentRefList.current[storyIndex], + mouseIndex, + event.clientY + ); + + setTaskElementIndex({ + storyId: storyList[storyIndex].id, + taskIndex: index, + }); + }; + + const handleTaskDragStart = (taskId: number) => { + setDraggingTaskId(taskId); + }; + + const handleTaskDragEnd = () => { + const { storyId, taskIndex } = taskElementIndex; + const taskList = storyList.find(({ id }) => id === storyId) + ?.taskList as TaskDTO[]; + const targetIndex = taskList?.findIndex(({ id }) => id === draggingTaskId); + + let rankValue; + + if (taskIndex === targetIndex) { + setDraggingTaskId(undefined); + setTaskElementIndex({ storyId: undefined, taskIndex: undefined }); + return; + } + + if (taskIndex === 0 && !taskList.length) { + console.log("아무 것도 없을 때"); + + rankValue = LexoRank.middle().toString(); + } else if (taskIndex === 0) { + const firstTaskRank = taskList[0].rankValue; + rankValue = LexoRank.parse(firstTaskRank).genPrev().toString(); + } else if (taskIndex === taskList.length) { + const lastTaskRank = taskList[taskList.length - 1].rankValue; + rankValue = LexoRank.parse(lastTaskRank).genNext().toString(); + } else { + const prevTaskRank = LexoRank.parse( + taskList[(taskIndex as number) - 1].rankValue + ); + const nextTaskRank = LexoRank.parse( + taskList[taskIndex as number].rankValue + ); + rankValue = prevTaskRank.between(nextTaskRank).toString(); + } + + emitTaskUpdateEvent({ + id: draggingTaskId as number, + storyId, + rankValue, + }); + + setDraggingTaskId(undefined); + setTaskElementIndex({ storyId: undefined, taskIndex: undefined }); + }; + return (
@@ -138,34 +251,62 @@ const UnfinishedStoryPage = () => { 100 ) : 0; + taskComponentRefList.current[index] = []; return (
handleDragStart(id)} - onDragEnd={handleDragEnd} + onDragOver={(event) => handleTaskDragOver(event, index)} > -
- 0} - epicList={epicCategoryList} - lastTaskRankValue={ - taskList.length - ? taskList[taskList.length - 1].rankValue - : undefined - } + handleDragStart(id)} + onDragEnd={handleDragEnd} + currentlyDraggedOver={index === storyElementIndex} > - {...taskList.map((task) => )} - + 0} + epicList={epicCategoryList} + /> + + + + {...taskList.map((task, taskIndex) => ( + handleTaskDragStart(task.id)} + currentlyDraggedOver={ + id === taskElementIndex.storyId && + taskIndex === taskElementIndex.taskIndex + } + > + + + ))} +
+ +
); }