Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: 스토리 드래그 앤 드롭 기능, 에픽별 백로그 페이지 구현 #321

Merged
merged 8 commits into from
Aug 4, 2024
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
Loading