Skip to content

Commit cc39073

Browse files
authored
Merge pull request #321 from boostcampwm2023/feature/story-drag-and-drop
feat: 스토리 드래그 앤 드롭 기능, 에픽별 백로그 페이지 구현
2 parents 45d8057 + 833ea86 commit cc39073

19 files changed

+441
-81
lines changed

frontend/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
"dependencies": {
1414
"@tanstack/react-query": "^5.28.14",
1515
"axios": "^1.6.7",
16+
"lexorank": "^1.0.5",
1617
"react": "^18.2.0",
1718
"react-dom": "^18.2.0",
1819
"react-error-boundary": "^4.0.13",

frontend/src/AppRouter.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import InvitePage from "./pages/invite/InvitePage";
2222
import UnfinishedStoryPage from "./pages/backlog/UnfinishedStoryPage";
2323
import BacklogPage from "./pages/backlog/BacklogPage";
2424
import FinishedStoryPage from "./pages/backlog/FinishedStoryPage";
25+
import EpicPage from "./pages/backlog/EpicPage";
2526

2627
type RouteType = "PRIVATE" | "PUBLIC";
2728

@@ -86,7 +87,7 @@ const router = createBrowserRouter([
8687
},
8788
{
8889
path: ROUTER_URL.BACKLOG.EPIC,
89-
element: <div>backlog epic Page</div>,
90+
element: <EpicPage />,
9091
},
9192
{
9293
path: ROUTER_URL.BACKLOG.COMPLETED,
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import useShowDetail from "../../hooks/pages/backlog/useShowDetail";
2+
import ChevronDown from "../../assets/icons/chevron-down.svg?react";
3+
import ChevronRight from "../../assets/icons/chevron-right.svg?react";
4+
import CategoryChip from "./CategoryChip";
5+
import { EpicCategoryDTO } from "../../types/DTO/backlogDTO";
6+
import useDropdownState from "../../hooks/common/dropdown/useDropdownState";
7+
import EpicDropdown from "./EpicDropdown";
8+
9+
interface EpicBlockProps {
10+
storyExist: boolean;
11+
epic: EpicCategoryDTO;
12+
children: React.ReactNode;
13+
}
14+
15+
const EpicBlock = ({ storyExist, epic, children }: EpicBlockProps) => {
16+
const { showDetail, handleShowDetail } = useShowDetail();
17+
const {
18+
open: epicUpdating,
19+
handleOpen: handleEpicUpdateOpen,
20+
dropdownRef: epicRef,
21+
} = useDropdownState();
22+
23+
const handleEpicColumnClick = () => {
24+
if (!epicUpdating) {
25+
handleEpicUpdateOpen();
26+
}
27+
};
28+
29+
return (
30+
<>
31+
<div className="flex items-center justify-start py-1 border-t border-b text-s">
32+
<button
33+
className="flex items-center justify-center w-5 h-5 rounded-md hover:bg-dark-gray hover:bg-opacity-20"
34+
type="button"
35+
onClick={(event) => {
36+
event.stopPropagation();
37+
handleShowDetail(!showDetail);
38+
}}
39+
>
40+
{showDetail ? (
41+
<ChevronDown
42+
width={16}
43+
height={16}
44+
fill={storyExist ? "black" : "#C5C5C5"}
45+
/>
46+
) : (
47+
<ChevronRight
48+
width={16}
49+
height={16}
50+
fill={storyExist ? "black" : "#C5C5C5"}
51+
/>
52+
)}
53+
</button>
54+
<div
55+
className="h-[2.25rem] hover:cursor-pointer"
56+
ref={epicRef}
57+
onClick={handleEpicColumnClick}
58+
>
59+
<CategoryChip content={epic.name} bgColor={epic.color} />
60+
{epicUpdating && (
61+
<EpicDropdown
62+
selectedEpic={epic}
63+
epicList={[epic]}
64+
onEpicChange={() => {}}
65+
/>
66+
)}
67+
</div>
68+
</div>
69+
{showDetail && <div className="w-[65rem] ml-auto">{children}</div>}
70+
</>
71+
);
72+
};
73+
74+
export default EpicBlock;

frontend/src/components/backlog/EpicDropdown.tsx

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
BacklogSocketEpicAction,
1414
} from "../../types/common/backlog";
1515
import EpicDropdownOption from "./EpicDropdownOption";
16+
import { LexoRank } from "lexorank";
1617

1718
interface EpicDropdownProps {
1819
selectedEpic?: EpicCategoryDTO;
@@ -53,8 +54,14 @@ const EpicDropdown = ({
5354
return;
5455
}
5556

57+
const rankValue = epicList.length
58+
? LexoRank.parse(epicList[epicList.length - 1].rankValue)
59+
.genNext()
60+
.toString()
61+
: LexoRank.middle().toString();
62+
5663
setValue("");
57-
emitEpicCreateEvent({ name: value, color: epicColor });
64+
emitEpicCreateEvent({ name: value, color: epicColor, rankValue });
5865
}
5966
};
6067

frontend/src/components/backlog/StoryBlock.tsx

Lines changed: 23 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -29,8 +29,9 @@ interface StoryBlockProps {
2929
status: BacklogStatusType;
3030
children: React.ReactNode;
3131
taskExist: boolean;
32-
epicList: EpicCategoryDTO[];
32+
epicList?: EpicCategoryDTO[];
3333
finished?: boolean;
34+
lastTaskRankValue?: string;
3435
}
3536

3637
const StoryBlock = ({
@@ -43,6 +44,7 @@ const StoryBlock = ({
4344
taskExist,
4445
epicList,
4546
finished = false,
47+
lastTaskRankValue,
4648
children,
4749
}: StoryBlockProps) => {
4850
const { socket }: { socket: Socket } = useOutletContext();
@@ -170,21 +172,24 @@ const StoryBlock = ({
170172
onContextMenu={(event) => event.preventDefault()}
171173
ref={blockRef}
172174
>
173-
<div
174-
className="w-[5rem] mr-5 hover:cursor-pointer"
175-
onClick={handleEpicColumnClick}
176-
ref={epicRef}
177-
>
178-
<CategoryChip content={epic.name} bgColor={epic.color} />
175+
{epicList && (
176+
<div
177+
className="w-[5rem] mr-5 hover:cursor-pointer"
178+
onClick={handleEpicColumnClick}
179+
ref={epicRef}
180+
>
181+
<CategoryChip content={epic.name} bgColor={epic.color} />
182+
183+
{epicUpdating && (
184+
<EpicDropdown
185+
selectedEpic={epic}
186+
epicList={epicList}
187+
onEpicChange={updateEpic}
188+
/>
189+
)}
190+
</div>
191+
)}
179192

180-
{epicUpdating && (
181-
<EpicDropdown
182-
selectedEpic={epic}
183-
epicList={epicList}
184-
onEpicChange={updateEpic}
185-
/>
186-
)}
187-
</div>
188193
<div
189194
className="flex items-center gap-1 w-[40.9rem] mr-4 hover:cursor-pointer"
190195
onClick={() => handleTitleUpdatingOpen(true)}
@@ -279,7 +284,9 @@ const StoryBlock = ({
279284
<TaskContainer>
280285
<TaskHeader />
281286
{children}
282-
{!finished && <TaskCreateBlock storyId={id} />}
287+
{!finished && (
288+
<TaskCreateBlock storyId={id} {...{ lastTaskRankValue }} />
289+
)}
283290
</TaskContainer>
284291
)}
285292
</>

frontend/src/components/backlog/StoryCreateForm.tsx

Lines changed: 52 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -9,33 +9,44 @@ import { useOutletContext } from "react-router-dom";
99
import EpicDropdown from "./EpicDropdown";
1010
import { EpicCategoryDTO } from "../../types/DTO/backlogDTO";
1111
import useDropdownState from "../../hooks/common/dropdown/useDropdownState";
12+
import { LexoRank } from "lexorank";
1213

1314
interface StoryCreateFormProps {
1415
onCloseClick: () => void;
1516
epicList: EpicCategoryDTO[];
17+
epic?: EpicCategoryDTO;
18+
lastStoryRankValue?: string;
1619
}
1720

18-
const StoryCreateForm = ({ onCloseClick, epicList }: StoryCreateFormProps) => {
21+
const StoryCreateForm = ({
22+
onCloseClick,
23+
epicList,
24+
epic,
25+
lastStoryRankValue,
26+
}: StoryCreateFormProps) => {
1927
const { socket }: { socket: Socket } = useOutletContext();
20-
const [{ title, point, epicId, status }, setStoryFormData] =
28+
const [{ title, point, epicId, status, rankValue }, setStoryFormData] =
2129
useState<StoryForm>({
2230
title: "",
2331
point: undefined,
2432
status: "시작전",
25-
epicId: undefined,
33+
epicId: epic?.id,
34+
rankValue: lastStoryRankValue
35+
? LexoRank.parse(lastStoryRankValue).genNext().toString()
36+
: LexoRank.middle().toString(),
2637
});
2738
const { open, handleClose, handleOpen, dropdownRef } = useDropdownState();
2839
const { emitStoryCreateEvent } = useStoryEmitEvent(socket);
2940

3041
const handleTitleChange = ({ target }: ChangeEvent<HTMLInputElement>) => {
3142
const { value } = target;
32-
setStoryFormData({ title: value, point, epicId, status });
43+
setStoryFormData({ title: value, point, epicId, status, rankValue });
3344
};
3445

3546
const handlePointChange = ({ target }: ChangeEvent<HTMLInputElement>) => {
3647
const { value } = target;
3748
const newPoint = value === "" ? undefined : Number(value);
38-
setStoryFormData({ title, point: newPoint, epicId, status });
49+
setStoryFormData({ title, point: newPoint, epicId, status, rankValue });
3950
};
4051

4152
const handleSubmit = (event: FormEvent) => {
@@ -71,12 +82,18 @@ const StoryCreateForm = ({ onCloseClick, epicList }: StoryCreateFormProps) => {
7182
return;
7283
}
7384

74-
emitStoryCreateEvent({ title, status, epicId, point });
85+
emitStoryCreateEvent({ title, status, epicId, point, rankValue });
7586
onCloseClick();
7687
};
7788

7889
const handleEpicChange = (selectedEpicId: number | undefined) => {
79-
setStoryFormData({ title, status, point, epicId: selectedEpicId });
90+
setStoryFormData({
91+
title,
92+
status,
93+
point,
94+
epicId: selectedEpicId,
95+
rankValue,
96+
});
8097
handleClose();
8198
};
8299

@@ -93,7 +110,7 @@ const StoryCreateForm = ({ onCloseClick, epicList }: StoryCreateFormProps) => {
93110

94111
useEffect(() => {
95112
if (!epicList.filter(({ id }) => id === epicId).length) {
96-
setStoryFormData({ title, point, status, epicId: undefined });
113+
setStoryFormData({ title, point, status, epicId: undefined, rankValue });
97114
}
98115
}, [epicList]);
99116

@@ -102,32 +119,39 @@ const StoryCreateForm = ({ onCloseClick, epicList }: StoryCreateFormProps) => {
102119
className="flex items-center w-full py-1 border-t border-b"
103120
onSubmit={handleSubmit}
104121
>
105-
<div
106-
className="w-[5rem] min-h-[1.75rem] bg-light-gray rounded-md mr-7 hover:cursor-pointer relative"
107-
onClick={handleEpicColumnClick}
108-
ref={dropdownRef}
109-
>
110-
{epicId && (
111-
<CategoryChip
112-
content={selectedEpic?.name}
113-
bgColor={selectedEpic?.color}
114-
/>
115-
)}
116-
{open && (
117-
<EpicDropdown
118-
selectedEpic={selectedEpic}
119-
epicList={epicList}
120-
onEpicChange={handleEpicChange}
121-
/>
122-
)}
123-
</div>
122+
{!epic ? (
123+
<div
124+
className="w-[5rem] min-h-[1.75rem] bg-light-gray rounded-md mr-7 hover:cursor-pointer relative"
125+
onClick={handleEpicColumnClick}
126+
ref={dropdownRef}
127+
>
128+
{epicId && (
129+
<CategoryChip
130+
content={selectedEpic?.name}
131+
bgColor={selectedEpic?.color}
132+
/>
133+
)}
134+
{open && (
135+
<EpicDropdown
136+
selectedEpic={selectedEpic}
137+
epicList={epicList}
138+
onEpicChange={handleEpicChange}
139+
/>
140+
)}
141+
</div>
142+
) : (
143+
<div className="w-[1.45rem]" />
144+
)}
145+
124146
<input
125147
className="w-[34.7rem] h-[1.75rem] mr-[1.5rem] bg-light-gray rounded-md focus:outline-none"
126148
type="text"
127149
value={title}
128150
onChange={handleTitleChange}
129151
/>
130-
<div className="flex items-center mr-[2.8rem] ">
152+
<div
153+
className={`flex items-center ${epic ? "mr-[1.85rem]" : "mr-[2.8rem]"}`}
154+
>
131155
<input
132156
className="w-24 h-[1.75rem] mr-1 text-right rounded-md bg-light-gray no-arrows focus:outline-none"
133157
type="number"

frontend/src/components/backlog/TaskCreateBlock.tsx

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,19 @@ import TaskCreateForm from "./TaskCreateForm";
44

55
interface TaskCreateBlockProps {
66
storyId: number;
7+
lastTaskRankValue?: string;
78
}
89

9-
const TaskCreateBlock = ({ storyId }: TaskCreateBlockProps) => {
10+
const TaskCreateBlock = ({
11+
storyId,
12+
lastTaskRankValue,
13+
}: TaskCreateBlockProps) => {
1014
const { showDetail, handleShowDetail } = useShowDetail();
1115
return (
1216
<>
1317
{showDetail ? (
1418
<TaskCreateForm
15-
{...{ storyId }}
19+
{...{ storyId, lastTaskRankValue }}
1620
onCloseClick={() => handleShowDetail(false)}
1721
/>
1822
) : (

frontend/src/components/backlog/TaskCreateForm.tsx

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,20 +5,29 @@ import Check from "../../assets/icons/check.svg?react";
55
import Closed from "../../assets/icons/closed.svg?react";
66
import useTaskEmitEvent from "../../hooks/pages/backlog/useTaskEmitEvent";
77
import { TaskForm } from "../../types/common/backlog";
8+
import { LexoRank } from "lexorank";
89

910
interface TaskCreateFormProps {
1011
onCloseClick: () => void;
1112
storyId: number;
13+
lastTaskRankValue?: string;
1214
}
1315

14-
const TaskCreateForm = ({ onCloseClick, storyId }: TaskCreateFormProps) => {
16+
const TaskCreateForm = ({
17+
onCloseClick,
18+
storyId,
19+
lastTaskRankValue,
20+
}: TaskCreateFormProps) => {
1521
const [taskFormData, setTaskFormData] = useState<TaskForm>({
1622
title: "",
1723
expectedTime: null,
1824
actualTime: null,
1925
status: "시작전",
2026
assignedMemberId: null,
2127
storyId,
28+
rankValue: lastTaskRankValue
29+
? LexoRank.parse(lastTaskRankValue).genNext().toString()
30+
: LexoRank.middle().toString(),
2231
});
2332
const { socket }: { socket: Socket } = useOutletContext();
2433
const { emitTaskCreateEvent } = useTaskEmitEvent(socket);

frontend/src/hooks/pages/backlog/useEpicEmitEvent.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ const useEpicEmitEvent = (socket: Socket) => {
55
const emitEpicCreateEvent = (content: {
66
name: string;
77
color: BacklogCategoryColor;
8+
rankValue: string;
89
}) => {
910
socket.emit("epic", { action: "create", content });
1011
};

0 commit comments

Comments
 (0)