Skip to content

Commit

Permalink
Merge pull request #321 from boostcampwm2023/feature/story-drag-and-drop
Browse files Browse the repository at this point in the history
feat: 스토리 드래그 앤 드롭 기능, 에픽별 백로그 페이지 구현
  • Loading branch information
surinkwon authored Aug 4, 2024
2 parents 45d8057 + 833ea86 commit cc39073
Show file tree
Hide file tree
Showing 19 changed files with 441 additions and 81 deletions.
1 change: 1 addition & 0 deletions frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
"dependencies": {
"@tanstack/react-query": "^5.28.14",
"axios": "^1.6.7",
"lexorank": "^1.0.5",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-error-boundary": "^4.0.13",
Expand Down
3 changes: 2 additions & 1 deletion frontend/src/AppRouter.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import InvitePage from "./pages/invite/InvitePage";
import UnfinishedStoryPage from "./pages/backlog/UnfinishedStoryPage";
import BacklogPage from "./pages/backlog/BacklogPage";
import FinishedStoryPage from "./pages/backlog/FinishedStoryPage";
import EpicPage from "./pages/backlog/EpicPage";

type RouteType = "PRIVATE" | "PUBLIC";

Expand Down Expand Up @@ -86,7 +87,7 @@ const router = createBrowserRouter([
},
{
path: ROUTER_URL.BACKLOG.EPIC,
element: <div>backlog epic Page</div>,
element: <EpicPage />,
},
{
path: ROUTER_URL.BACKLOG.COMPLETED,
Expand Down
74 changes: 74 additions & 0 deletions frontend/src/components/backlog/EpicBlock.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import useShowDetail from "../../hooks/pages/backlog/useShowDetail";
import ChevronDown from "../../assets/icons/chevron-down.svg?react";
import ChevronRight from "../../assets/icons/chevron-right.svg?react";
import CategoryChip from "./CategoryChip";
import { EpicCategoryDTO } from "../../types/DTO/backlogDTO";
import useDropdownState from "../../hooks/common/dropdown/useDropdownState";
import EpicDropdown from "./EpicDropdown";

interface EpicBlockProps {
storyExist: boolean;
epic: EpicCategoryDTO;
children: React.ReactNode;
}

const EpicBlock = ({ storyExist, epic, children }: EpicBlockProps) => {
const { showDetail, handleShowDetail } = useShowDetail();
const {
open: epicUpdating,
handleOpen: handleEpicUpdateOpen,
dropdownRef: epicRef,
} = useDropdownState();

const handleEpicColumnClick = () => {
if (!epicUpdating) {
handleEpicUpdateOpen();
}
};

return (
<>
<div className="flex items-center justify-start py-1 border-t border-b text-s">
<button
className="flex items-center justify-center w-5 h-5 rounded-md hover:bg-dark-gray hover:bg-opacity-20"
type="button"
onClick={(event) => {
event.stopPropagation();
handleShowDetail(!showDetail);
}}
>
{showDetail ? (
<ChevronDown
width={16}
height={16}
fill={storyExist ? "black" : "#C5C5C5"}
/>
) : (
<ChevronRight
width={16}
height={16}
fill={storyExist ? "black" : "#C5C5C5"}
/>
)}
</button>
<div
className="h-[2.25rem] hover:cursor-pointer"
ref={epicRef}
onClick={handleEpicColumnClick}
>
<CategoryChip content={epic.name} bgColor={epic.color} />
{epicUpdating && (
<EpicDropdown
selectedEpic={epic}
epicList={[epic]}
onEpicChange={() => {}}
/>
)}
</div>
</div>
{showDetail && <div className="w-[65rem] ml-auto">{children}</div>}
</>
);
};

export default EpicBlock;
9 changes: 8 additions & 1 deletion frontend/src/components/backlog/EpicDropdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
BacklogSocketEpicAction,
} from "../../types/common/backlog";
import EpicDropdownOption from "./EpicDropdownOption";
import { LexoRank } from "lexorank";

interface EpicDropdownProps {
selectedEpic?: EpicCategoryDTO;
Expand Down Expand Up @@ -53,8 +54,14 @@ const EpicDropdown = ({
return;
}

const rankValue = epicList.length
? LexoRank.parse(epicList[epicList.length - 1].rankValue)
.genNext()
.toString()
: LexoRank.middle().toString();

setValue("");
emitEpicCreateEvent({ name: value, color: epicColor });
emitEpicCreateEvent({ name: value, color: epicColor, rankValue });
}
};

Expand Down
39 changes: 23 additions & 16 deletions frontend/src/components/backlog/StoryBlock.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,9 @@ interface StoryBlockProps {
status: BacklogStatusType;
children: React.ReactNode;
taskExist: boolean;
epicList: EpicCategoryDTO[];
epicList?: EpicCategoryDTO[];
finished?: boolean;
lastTaskRankValue?: string;
}

const StoryBlock = ({
Expand All @@ -43,6 +44,7 @@ const StoryBlock = ({
taskExist,
epicList,
finished = false,
lastTaskRankValue,
children,
}: StoryBlockProps) => {
const { socket }: { socket: Socket } = useOutletContext();
Expand Down Expand Up @@ -170,21 +172,24 @@ const StoryBlock = ({
onContextMenu={(event) => event.preventDefault()}
ref={blockRef}
>
<div
className="w-[5rem] mr-5 hover:cursor-pointer"
onClick={handleEpicColumnClick}
ref={epicRef}
>
<CategoryChip content={epic.name} bgColor={epic.color} />
{epicList && (
<div
className="w-[5rem] mr-5 hover:cursor-pointer"
onClick={handleEpicColumnClick}
ref={epicRef}
>
<CategoryChip content={epic.name} bgColor={epic.color} />

{epicUpdating && (
<EpicDropdown
selectedEpic={epic}
epicList={epicList}
onEpicChange={updateEpic}
/>
)}
</div>
)}

{epicUpdating && (
<EpicDropdown
selectedEpic={epic}
epicList={epicList}
onEpicChange={updateEpic}
/>
)}
</div>
<div
className="flex items-center gap-1 w-[40.9rem] mr-4 hover:cursor-pointer"
onClick={() => handleTitleUpdatingOpen(true)}
Expand Down Expand Up @@ -279,7 +284,9 @@ const StoryBlock = ({
<TaskContainer>
<TaskHeader />
{children}
{!finished && <TaskCreateBlock storyId={id} />}
{!finished && (
<TaskCreateBlock storyId={id} {...{ lastTaskRankValue }} />
)}
</TaskContainer>
)}
</>
Expand Down
80 changes: 52 additions & 28 deletions frontend/src/components/backlog/StoryCreateForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,33 +9,44 @@ import { useOutletContext } from "react-router-dom";
import EpicDropdown from "./EpicDropdown";
import { EpicCategoryDTO } from "../../types/DTO/backlogDTO";
import useDropdownState from "../../hooks/common/dropdown/useDropdownState";
import { LexoRank } from "lexorank";

interface StoryCreateFormProps {
onCloseClick: () => void;
epicList: EpicCategoryDTO[];
epic?: EpicCategoryDTO;
lastStoryRankValue?: string;
}

const StoryCreateForm = ({ onCloseClick, epicList }: StoryCreateFormProps) => {
const StoryCreateForm = ({
onCloseClick,
epicList,
epic,
lastStoryRankValue,
}: StoryCreateFormProps) => {
const { socket }: { socket: Socket } = useOutletContext();
const [{ title, point, epicId, status }, setStoryFormData] =
const [{ title, point, epicId, status, rankValue }, setStoryFormData] =
useState<StoryForm>({
title: "",
point: undefined,
status: "시작전",
epicId: undefined,
epicId: epic?.id,
rankValue: lastStoryRankValue
? LexoRank.parse(lastStoryRankValue).genNext().toString()
: LexoRank.middle().toString(),
});
const { open, handleClose, handleOpen, dropdownRef } = useDropdownState();
const { emitStoryCreateEvent } = useStoryEmitEvent(socket);

const handleTitleChange = ({ target }: ChangeEvent<HTMLInputElement>) => {
const { value } = target;
setStoryFormData({ title: value, point, epicId, status });
setStoryFormData({ title: value, point, epicId, status, rankValue });
};

const handlePointChange = ({ target }: ChangeEvent<HTMLInputElement>) => {
const { value } = target;
const newPoint = value === "" ? undefined : Number(value);
setStoryFormData({ title, point: newPoint, epicId, status });
setStoryFormData({ title, point: newPoint, epicId, status, rankValue });
};

const handleSubmit = (event: FormEvent) => {
Expand Down Expand Up @@ -71,12 +82,18 @@ const StoryCreateForm = ({ onCloseClick, epicList }: StoryCreateFormProps) => {
return;
}

emitStoryCreateEvent({ title, status, epicId, point });
emitStoryCreateEvent({ title, status, epicId, point, rankValue });
onCloseClick();
};

const handleEpicChange = (selectedEpicId: number | undefined) => {
setStoryFormData({ title, status, point, epicId: selectedEpicId });
setStoryFormData({
title,
status,
point,
epicId: selectedEpicId,
rankValue,
});
handleClose();
};

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

useEffect(() => {
if (!epicList.filter(({ id }) => id === epicId).length) {
setStoryFormData({ title, point, status, epicId: undefined });
setStoryFormData({ title, point, status, epicId: undefined, rankValue });
}
}, [epicList]);

Expand All @@ -102,32 +119,39 @@ const StoryCreateForm = ({ onCloseClick, epicList }: StoryCreateFormProps) => {
className="flex items-center w-full py-1 border-t border-b"
onSubmit={handleSubmit}
>
<div
className="w-[5rem] min-h-[1.75rem] bg-light-gray rounded-md mr-7 hover:cursor-pointer relative"
onClick={handleEpicColumnClick}
ref={dropdownRef}
>
{epicId && (
<CategoryChip
content={selectedEpic?.name}
bgColor={selectedEpic?.color}
/>
)}
{open && (
<EpicDropdown
selectedEpic={selectedEpic}
epicList={epicList}
onEpicChange={handleEpicChange}
/>
)}
</div>
{!epic ? (
<div
className="w-[5rem] min-h-[1.75rem] bg-light-gray rounded-md mr-7 hover:cursor-pointer relative"
onClick={handleEpicColumnClick}
ref={dropdownRef}
>
{epicId && (
<CategoryChip
content={selectedEpic?.name}
bgColor={selectedEpic?.color}
/>
)}
{open && (
<EpicDropdown
selectedEpic={selectedEpic}
epicList={epicList}
onEpicChange={handleEpicChange}
/>
)}
</div>
) : (
<div className="w-[1.45rem]" />
)}

<input
className="w-[34.7rem] h-[1.75rem] mr-[1.5rem] bg-light-gray rounded-md focus:outline-none"
type="text"
value={title}
onChange={handleTitleChange}
/>
<div className="flex items-center mr-[2.8rem] ">
<div
className={`flex items-center ${epic ? "mr-[1.85rem]" : "mr-[2.8rem]"}`}
>
<input
className="w-24 h-[1.75rem] mr-1 text-right rounded-md bg-light-gray no-arrows focus:outline-none"
type="number"
Expand Down
8 changes: 6 additions & 2 deletions frontend/src/components/backlog/TaskCreateBlock.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,19 @@ import TaskCreateForm from "./TaskCreateForm";

interface TaskCreateBlockProps {
storyId: number;
lastTaskRankValue?: string;
}

const TaskCreateBlock = ({ storyId }: TaskCreateBlockProps) => {
const TaskCreateBlock = ({
storyId,
lastTaskRankValue,
}: TaskCreateBlockProps) => {
const { showDetail, handleShowDetail } = useShowDetail();
return (
<>
{showDetail ? (
<TaskCreateForm
{...{ storyId }}
{...{ storyId, lastTaskRankValue }}
onCloseClick={() => handleShowDetail(false)}
/>
) : (
Expand Down
11 changes: 10 additions & 1 deletion frontend/src/components/backlog/TaskCreateForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,20 +5,29 @@ import Check from "../../assets/icons/check.svg?react";
import Closed from "../../assets/icons/closed.svg?react";
import useTaskEmitEvent from "../../hooks/pages/backlog/useTaskEmitEvent";
import { TaskForm } from "../../types/common/backlog";
import { LexoRank } from "lexorank";

interface TaskCreateFormProps {
onCloseClick: () => void;
storyId: number;
lastTaskRankValue?: string;
}

const TaskCreateForm = ({ onCloseClick, storyId }: TaskCreateFormProps) => {
const TaskCreateForm = ({
onCloseClick,
storyId,
lastTaskRankValue,
}: TaskCreateFormProps) => {
const [taskFormData, setTaskFormData] = useState<TaskForm>({
title: "",
expectedTime: null,
actualTime: null,
status: "시작전",
assignedMemberId: null,
storyId,
rankValue: lastTaskRankValue
? LexoRank.parse(lastTaskRankValue).genNext().toString()
: LexoRank.middle().toString(),
});
const { socket }: { socket: Socket } = useOutletContext();
const { emitTaskCreateEvent } = useTaskEmitEvent(socket);
Expand Down
1 change: 1 addition & 0 deletions frontend/src/hooks/pages/backlog/useEpicEmitEvent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ const useEpicEmitEvent = (socket: Socket) => {
const emitEpicCreateEvent = (content: {
name: string;
color: BacklogCategoryColor;
rankValue: string;
}) => {
socket.emit("epic", { action: "create", content });
};
Expand Down
Loading

0 comments on commit cc39073

Please sign in to comment.