Skip to content

Commit

Permalink
Feat: #33 특정 프로젝트 칸반 보드 UI 작성 & 할일 목록 DnD 기능 추가 (#36)
Browse files Browse the repository at this point in the history
* Config: #33 react-beautiful-dnd를 계승한 @hello-pangea/dnd 설치

* Chore: #33 mock 데이터 추가 및 관련 Types 추가 정의

* Config: #33 ESLint 설정 완화

* Feat: #33 칸반 보드 UI 작성 & 칸반 보드 할일 드래그 앤 드롭 기능 추가

* UI: #33 ProjectLayout 수정

* Formmating: #33 deepClone의 매개변수명 변경
  • Loading branch information
Seok93 authored Jul 3, 2024
1 parent f6fbe3b commit bc32295
Show file tree
Hide file tree
Showing 10 changed files with 424 additions and 8 deletions.
3 changes: 3 additions & 0 deletions .eslintrc.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@ module.exports = {
'consistent-return': 'off',
'object-curly-newline': 'off',
'@typescript-eslint/no-unused-vars': 'warn',
'@typescript-eslint/no-shadow': 'warn',
'no-param-reassign': 'warn',
'no-return-assign': 'warn',
'no-unused-vars': 'warn',
'react/react-in-jsx-scope': 'off',
'react/prop-types': 'off',
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
"prepare": "husky"
},
"dependencies": {
"@hello-pangea/dnd": "^16.6.0",
"@tanstack/react-query": "^5.36.2",
"axios": "^1.6.8",
"react": "^18.2.0",
Expand Down
3 changes: 2 additions & 1 deletion src/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@
/* ========= Scrollbar Custom ========= */
::-webkit-scrollbar {
width: 10px;
height: 10px;
}
::-webkit-scrollbar-track {
border: 1px solid var(--border-scroll);
Expand All @@ -93,6 +94,6 @@

@layer components {
.selected::before {
@apply absolute left-0 top-0 block h-30 w-4 bg-main content-[''];
@apply absolute left-0 top-0 block h-30 w-3 bg-main content-[''];
}
}
10 changes: 5 additions & 5 deletions src/layouts/page/ProjectLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -57,15 +57,15 @@ export default function ProjectLayout() {
<RiSettings5Fill /> Project Setting
</div>
</header>
<div className="grow p-10">
<div className="flex justify-between">
<ul className="flex border-b *:mr-15">
<li>
<div className="flex grow flex-col overflow-auto p-10">
<div className="flex items-center justify-between border-b">
<ul className="*:mr-15">
<li className="inline">
<NavLink to="calendar" className={({ isActive }) => (isActive ? 'text-main' : 'text-emphasis')}>
Calendar
</NavLink>
</li>
<li>
<li className="inline">
<NavLink to="kanban" className={({ isActive }) => (isActive ? 'text-main' : 'text-emphasis')}>
Kanban
</NavLink>
Expand Down
143 changes: 143 additions & 0 deletions src/mocks/mockData.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
import type { TodoStatus } from '@/types/TodoStatusType';
import type { TodoWithStatus } from '@/types/TodoType';

export const USER_DUMMY = [
{
userId: 1,
email: '[email protected]',
nickname: '꾸르',
bio: '풀스택 개발자를 목표중',
},
{
userId: 2,
email: '[email protected]',
nickname: '무드메이커',
bio: '디자이너 + 프론트엔드 육각형 인재',
},
{
userId: 3,
email: '[email protected]',
nickname: 'SOL천사',
bio: '프론트엔드 취준생',
},
];

export const STATUS_DUMMY: TodoStatus[] = [
{
statusId: 1,
name: 'To Do',
color: '#c83c00',
order: 1,
},
{
statusId: 2,
name: 'In Progress',
color: '#dab700',
order: 2,
},
{
statusId: 3,
name: 'Done',
color: '#237700',
order: 3,
},
];

export const TODO_DUMMY: TodoWithStatus[] = [
{
statusId: 1,
name: 'To Do',
color: '#c83c00',
order: 1,
tasks: [
{
taskId: 7,
name: '할일 추가 모달 구현하기',
order: 1,
userId: 3,
files: [],
startDate: '2024-06-26',
endDate: '2024-07-02',
},
{
taskId: 8,
name: 'ID 찾기 페이지 작성하기',
order: 2,
userId: 3,
files: [],
startDate: '2024-07-03',
endDate: '2024-07-05',
},
{
taskId: 9,
name: 'DnD 구현하기',
order: 3,
userId: 1,
files: [],
startDate: '2024-06-30',
endDate: '2024-07-02',
},
],
},
{
statusId: 2,
name: 'In Progress',
color: '#dab700',
order: 2,
tasks: [
{
taskId: 5,
name: 'DnD 기술 조사하기',
order: 1,
userId: 1,
files: [],
startDate: '2024-06-27',
endDate: '2024-06-29',
},
{
taskId: 4,
name: 'API 명세서 작성하기',
order: 2,
userId: 2,
files: [],
startDate: '2024-06-27',
endDate: '2024-06-29',
},
],
},
{
statusId: 3,
name: 'Done',
color: '#237700',
order: 3,
tasks: [
{
taskId: 1,
name: 'todo 상태 추가 모달 작업하기',
order: 1,
userId: 2,
files: [],
startDate: '2024-06-22',
endDate: '2024-06-26',
},
{
taskId: 2,
name: 'project layout 작성하기',
order: 2,
userId: 1,
files: [],
startDate: '2024-06-18',
endDate: '2024-06-21',
},
{
taskId: 3,
name: 'tailwindcss 설정하기',
order: 3,
userId: 3,
files: [],
startDate: '2024-06-14',
endDate: '2024-06-18',
},
],
},
];
142 changes: 141 additions & 1 deletion src/pages/project/KanbanPage.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,143 @@
import { useState } from 'react';
import { DragDropContext, Draggable, Droppable } from '@hello-pangea/dnd';
import { BsPencil } from 'react-icons/bs';
import deepClone from '@utils/deepClone';

import { TODO_DUMMY } from '@mocks/mockData';
import type { DropResult } from '@hello-pangea/dnd';
import type { Todo, TodoWithStatus } from '@/types/TodoType';

// ToDo: 유틸리티로 분리할지 고려하기
function generatorPrefixId(id: number | string, prefix: string, delimiter: string = '-') {
const result = prefix + delimiter + id;
return result;
}
function parserPrefixId(prefixId: string, delimiter: string = '-') {
const result = prefixId.split(delimiter);
return result[result.length - 1];
}

function createChangedTodoForSameStatus(todo: TodoWithStatus[], dropResult: DropResult) {
const { source, draggableId } = dropResult;

const newTodo = deepClone(todo);
const sourceStatusId = Number(parserPrefixId(source.droppableId));
const taskId = Number(parserPrefixId(draggableId));

const { tasks: sourceTasks } = newTodo.find((data) => data.statusId === sourceStatusId)! as TodoWithStatus;
const task = sourceTasks.find((data) => data.taskId === taskId)! as Todo;

sourceTasks.splice(source.index, 1);
sourceTasks.splice(source.index, 0, task);
sourceTasks.forEach((task, index) => (task.order = index + 1));

return newTodo;
}

function createChangedTodoForOtherStatus(todo: TodoWithStatus[], dropResult: DropResult) {
const { source, destination, draggableId } = dropResult;

// ToDo: 메세지 포맷 정하고 수정하기
if (!destination) throw Error('Error: DnD destination is null');

const newTodo = deepClone(todo);
const sourceStatusId = Number(parserPrefixId(source.droppableId));
const destinationStatusId = Number(parserPrefixId(destination.droppableId));
const taskId = Number(parserPrefixId(draggableId));

const { tasks: sourceTasks } = newTodo.find((data) => data.statusId === sourceStatusId)! as TodoWithStatus;
const { tasks: destinationTasks } = newTodo.find((data) => data.statusId === destinationStatusId)! as TodoWithStatus;
const task = sourceTasks.find((data) => data.taskId === taskId)! as Todo;

sourceTasks.splice(source.index, 1);
destinationTasks.splice(destination.index, 0, task);

sourceTasks.forEach((task, index) => {
task.order = index + 1;
});
destinationTasks.forEach((task, index) => {
task.order = index + 1;
});

return newTodo;
}

// ToDo: 할일 상태 Vertical DnD 추가할 것
// ToDo: DnD시 가시성을 위한 애니메이션 처리 추가할 것
// ToDo: 칸반보드 ItemList, Item 컴포넌트로 분리할 것
export default function KanbanPage() {
return <div>KanbanPage</div>;
const [todo, setTodo] = useState<TodoWithStatus[]>(TODO_DUMMY);

const handleDragEnd = (dropResult: DropResult) => {
const { source, destination } = dropResult;

if (!destination) return;
if (source.droppableId === destination.droppableId && source.index === destination.index) return;

const newTodo =
source.droppableId !== destination.droppableId
? createChangedTodoForOtherStatus(todo, dropResult)
: createChangedTodoForSameStatus(todo, dropResult);

setTodo(newTodo);
};

return (
<section className="flex grow gap-10 pt-10">
<DragDropContext onDragEnd={handleDragEnd}>
{todo.map((data) => {
const { statusId, name, color, tasks } = data;
const droppableId = generatorPrefixId(statusId, 'status');
return (
<article className="flex min-w-125 grow basis-1/3 flex-col" key={statusId}>
<header className="flex items-center gap-4">
<h2 className="font-bold text-emphasis">{name}</h2>
<span>
<BsPencil className="cursor-pointer" />
</span>
</header>
<div className="grow">
<Droppable droppableId={droppableId} type="TODO">
{(dropProvided) => {
return (
<article
style={{ borderColor: color }}
className="inline-block min-h-full w-full border-l-[3px] bg-scroll"
ref={dropProvided.innerRef}
{...dropProvided.droppableProps}
>
{tasks.map((task) => {
const { taskId, name, order } = task;
const draggableId = generatorPrefixId(taskId, 'task');
const index = order - 1;
return (
<Draggable key={taskId} draggableId={draggableId} index={index}>
{(dragProvided) => {
return (
<div
className="m-5 flex h-30 items-center justify-start gap-5 rounded-sl bg-[#FEFEFE] p-5"
ref={dragProvided.innerRef}
{...dragProvided.draggableProps}
{...dragProvided.dragHandleProps}
>
<div style={{ borderColor: color }} className="h-8 w-8 rounded-full border" />
<div className="select-none overflow-hidden text-ellipsis text-nowrap">{name}</div>
</div>
);
}}
</Draggable>
);
})}
{dropProvided.placeholder}
</article>
);
}}
</Droppable>
</div>
</article>
);
})}
</DragDropContext>
</section>
);
}
1 change: 1 addition & 0 deletions src/types/TodoStatusType.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ export type TodoStatus = {
statusId: number;
name: string;
color: string;
order: number;
};

export type TodoStatusForm = {
Expand Down
14 changes: 14 additions & 0 deletions src/types/TodoType.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { TodoStatus } from './TodoStatusType';

// ToDo: API 설계 완료시 데이터 타입 변경할 것
export type Todo = {
taskId: number;
name: string;
order: number;
userId: number;
files: string[];
startDate: string;
endDate: string;
};

export type TodoWithStatus = TodoStatus & { tasks: Todo[] };
22 changes: 22 additions & 0 deletions src/utils/deepClone.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
export default function deepClone<T>(data: T): T {
if (data === undefined || data === null) return data;

if (typeof data !== 'object') return data;

if (data instanceof Date) return new Date(data.getTime()) as T;

if (Array.isArray(data)) {
const arrCopy = [] as unknown[];
data.forEach((item) => arrCopy.push(deepClone(item)));
return arrCopy as T;
}

const objCopy = {} as { [key: string]: unknown };
Object.keys(data).forEach((key) => {
if (Object.prototype.hasOwnProperty.call(data, key)) {
objCopy[key] = deepClone((data as { [key: string]: unknown })[key]);
}
});

return objCopy as T;
}
Loading

0 comments on commit bc32295

Please sign in to comment.