React Beautiful Dnd
터미널에 npm i react-beautiful-dnd 입력하여 모듈 설치
import logo from './logo.svg';
import { useState } from 'react'
import { DragDropContext } from 'react-beautiful-dnd'
import './App.css';
const finalSpaceCharacters = [
{
id: 'gray',
name: 'Gary Goodspeed'
},
{
id: 'cato',
name: 'Little Cato'
},
{
id: 'abc',
name: 'ABC'
}
]
function App() {
const [characters, setCharacters] = useState(finalSpaceCharacters)
const handleEnd = (result) =>{
//result 매개변수에는 source 항목 및 대상 위치와 같은 Drag 이벤트에 대항 정보가 포함
console.log(result)
if(!result.destination) return;
//리액트 불변성을 지켜주기 위해 새로운 todoDate생성
const items = Array.from(characters);
const [reorderedItem] = items.splice(result.source.index, 1)
//변경시키려는 item을 배열에서 지우고 return값으로 지워진 item을 가져온다.
//원하는 자리에 reorderedItem을 insert해준다.
items.splice(result.destination.index, 0, reorderedItem);
setCharacters(items)
}
return (
<div className="App">
<header className="App-header">
<h1>Final Space Characters</h1>
<DragDropContext onDragEnd={handleEnd}>
<Droppable droppableId='characters'>
{(provided) => (
<ul className='characters' {...provided.droppableProps}
ref={provided.innerRef}>
{
characters.map(({id, name}, index) => {
return (
<Draggable key={id} draggable={id} index={index}>
{(provided) => (
<li ref={provided.innerRef}
{...provided.draggableProps}
{...provided.dragHandleProps}
>
<p>
{name}
</p>
</li>
)}
</Draggable>
)
})
}
{provided.placeholder}
</ul>
)}
</Droppable>
</DragDropContext>
</header>
</div>
);
}
export default App;
Drag & Drop
Drag와 Drop을 구현하기 위해 최상위범위인 App.tsx에 DragDropContext를 선언해줍니다.
그 다음 List.tsx에 Droppable를 선언해줍니다.
마지막 범위이자 객체인 task에 Draggable을 선언해줍니다.
board에서 변화가 생기는 것이기에 boardSlice에 sort를 추가해줍니다.
같은리스트에서 옮길경우와 다른리스트로 옮길 경우를 모두 고려해야하기에 분기처리로 작성을 해줍니다.
//App.tsx
import { useState } from 'react'
import { appContainer, board, buttons, deleteBoardButton, loggerButton } from './App.css'
import BoardList from './components/BoardList/BoardList'
import ListsContainer from './components/ListsContainer/ListsContainer';
import { useTypedDispatch, useTypedSelector } from './hooks/redux';
import EditModal from './components/EditModal/EditModal';
import LoggerModal from './components/LoggerModal/LoggerModal';
import { deleteBoard, sort } from './store/slices/boardSlice';
import { addLog } from './store/slices/loggerSlice';
import { v4 } from 'uuid';
import { DragDropContext } from 'react-beautiful-dnd';
function App() {
const dispatch = useTypedDispatch();
const [isLoggerOpen, setIsLoggerOpen] = useState(false);
const [activeBoardId, setActiveBoardId] = useState('board-0');
const modalActive = useTypedSelector(state => state.boards.modalActive);
const boards = useTypedSelector(state => state.boards.boardArray)
//특정 객체만 가져온다.
const getActiveBoard = boards.filter(board => board.boardId === activeBoardId)[0]; //클릭할때 마다 바뀌는값
const lists = getActiveBoard.lists;
const handleDeleteBoard = () => {
if(boards.length > 1) {
dispatch(
deleteBoard({boardId: getActiveBoard.boardId})
)
dispatch(
addLog({
logId: v4(),
logMessage: `게시판 지우기: ${getActiveBoard.boardName}`,
logAuthor: 'User',
logTimestamp: String(Date.now())
})
)
//activeBoardId를 넘겨준다
const newIndexToSet = () => {
const indexToBeDeleted = boards.findIndex(
board => board.boardId === activeBoardId
)
return indexToBeDeleted === 0
? indexToBeDeleted + 1
: indexToBeDeleted - 1;
}
//newIndexToSet의 boardId를 ActiveBoardId에 셋팅한다.
setActiveBoardId(boards[newIndexToSet()].boardId)
} else {
alert('최소 게시판의 개수는 1개입니다.')
}
}
const handleDragEnd = (result: any) => {
console.log(result);
const {destination, source, draggableId} = result;
console.log('lists', lists)
const sourceList = lists.filter(
list => list.listId === source.droppableId
)[0];
console.log('source list', sourceList);
dispatch(
sort({
boardIndex: boards.findIndex(board => board.boardId == activeBoardId),
droppableIdStart: source.droppableId,
droppableIdEnd: destination.droppableId,
droppableIndexStart: source.index,
droppableIndexEnd: destination.index,
draggableId
})
)
dispatch(
addLog({
logId: v4(),
logMessage: `
리스트 "${sourceList.listName}" 에서
리스트 "${lists.filter(list => list.listId === destination.droppableId)[0].listName}으로
${sourceList.tasks.filter(task => task.taskId === draggableId)[0].taskName}을 옮겼습니다.
`,
logAuthor: 'User',
logTimestamp: String(Date.now())
})
)
}
return (
<div className={appContainer}>
{isLoggerOpen ? <LoggerModal setIsLoggerOpen={setIsLoggerOpen} /> : null}
{modalActive ? <EditModal /> : null}
<BoardList activeBoardId={activeBoardId} setActiveBoardId={setActiveBoardId}/>
<div className={board}>
<DragDropContext onDragEnd={handleDragEnd}>
<ListsContainer lists = {lists} boardId = {getActiveBoard.boardId}/>
</DragDropContext>
</div>
<div className={buttons}>
<button className={deleteBoardButton} onClick={handleDeleteBoard}>
이 게시판 삭제하기
</button>
<button className={loggerButton} onClick={() => setIsLoggerOpen(!isLoggerOpen)}>
{isLoggerOpen ? "활동 목록 숨기기" : "활동 목록 보이기"}
</button>
</div>
</div>
)
}
export default App
//list.tsx
import React, { FC } from 'react'
import { GrSubtract } from 'react-icons/gr'
import Task from '../Task/Task'
import ActionButton from '../ActionButton/ActionButton'
import { IList, ITask } from '../../types'
import { useTypedDispatch } from '../../hooks/redux'
import { deleteList, setModalActive } from '../../store/slices/boardSlice'
import { addLog } from '../../store/slices/loggerSlice'
import { v4 } from 'uuid'
import { setModalData } from '../../store/slices/modalSlice'
import { deleteButton, header, listWrapper, name } from './List.css'
import { Droppable } from 'react-beautiful-dnd'
type TListProps = {
boardId: string;
list: IList;
}
const List: FC<TListProps> = ({
list,
boardId
}) => {
const dispatch = useTypedDispatch();
const handleListDelete = (listId: string) => {
dispatch(deleteList({boardId, listId}));
dispatch(
addLog({
logId: v4(),
logMessage: `리스트 삭제하기 : ${list.listName}`,
logAuthor: 'User',
logTimestamp: String(Date.now())
})
)
}
const handleTaskChange = (
boardId: string,
listId: string,
taskId: string,
task: ITask
) => {
dispatch(setModalData({boardId, listId, task}))
dispatch(setModalActive(true));
}
return (
<Droppable droppableId={list.listId}>
{provided => (
<div
{...provided.droppableProps}
ref={provided.innerRef}
className={listWrapper}
>
<div className={header}>
<div className={name}>{list.listName}</div>
<GrSubtract
className={deleteButton}
onClick={() => handleListDelete(list.listId)}
/>
</div>
{list.tasks.map((task, index) => (
<div
onClick={() => handleTaskChange(boardId, list.listId, task.taskId, task)}
key = {task.taskId}
>
<Task
taskName = {task.taskName}
taskDescription = {task.taskDescription}
boardId = {boardId}
id = {task.taskId}
index = {index}
/>
</div>
))}
{provided.placeholder}
<ActionButton
boardId={boardId}
listId={list.listId}
/> {/*Pops로 분기처리*/}
</div>
)}
</Droppable>
)
}
export default List;
//Task.tsx
import React, { FC } from 'react'
import { container, description, title } from './Task.css';
import { Draggable } from 'react-beautiful-dnd';
type TTaskProps = {
index: number;
id: string;
boardId: string;
taskName: string;
taskDescription: string;
}
const Task: FC<TTaskProps> = ({
index,
id,
boardId,
taskName,
taskDescription
}) => {
return (
<Draggable draggableId={id} index={index}>
{provided => (
<div className={container}
ref={provided.innerRef}
{...provided.draggableProps}
{...provided.dragHandleProps}
>
<div className={title}>{taskName}</div>
<div className={description}>{taskDescription}</div>
</div>
)}
</Draggable>
)
}
export default Task
//boardSlice.ts
~~~~~
sort: (state, {payload}: PayloadAction<TSortAction>) => {
if(payload.droppableIdStart === payload.droppableIdEnd) {
const list = state.boardArray[payload.boardIndex].lists.find(
list => list.listId === payload.droppableIdStart
)
const card = list?.tasks.splice(payload.droppableIndexStart, 1);
list?.tasks.splice(payload.droppableIndexEnd, 0, ...card!)
}
if(payload.droppableIdStart !== payload.droppableIdEnd) {
const listStart = state.boardArray[payload.boardIndex].lists.find(
list => list.listId === payload.droppableIdStart
)
const card = listStart!.tasks.splice(payload.droppableIndexStart, 1);
const listEnd = state.boardArray[payload.boardIndex].lists.find(
list => list.listId === payload.droppableIdEnd
)
listEnd?.tasks.splice(payload.droppableIndexEnd, 0, ...card);
}
}
}
})
export const { sort, addBoard, deleteList, setModalActive, addList, addTask, updateTask, deleteTask, deleteBoard } = boardsSlice.actions;
export const boardsReducer = boardsSlice.reducer;
FireBase연결
FireBase 홈페이지에 가서 프로젝트 생성 후
웹으로 추가를 해줍니다.
VScode로 가서 firebase 패키지를 설치해줍니다.
firebase.ts를 생성 후
위에 화면에 나왔던 내용을 복사 후 붙여넣고 외부로 내보낼 수 있도록
마지막 줄을 export const app = initializeApp(firebaseConfig);로 바꿔 준 후 저장합니다.
login 만들기
//BoardList.tsx
const auth = getAuth(app);
const provider = new GoogleAuthProvider();
const handleLogin = () => {
signInWithPopup(auth, provider)
.then(userCrendential => {
console.log(userCrendential);
dispatch(
setUser({
email: userCrendential.user.email,
id: userCrendential.user.uid
})
)
})
.catch(error => {
console.error(error);
})
}
})
}
<GoSignOut className={addButton} />
<FiLogIn className={addButton} onClick={handleLogin}/>
google로그인을 하기 위해 BoardList.tsx에 위의 코드를 추가합니다
아래 2줄은 로그인과 로그아웃 버튼입니다.
프로젝트 생성한 firebase에서 Authentication에 들어간 후 로그인 제공업체를 선택 후 이메일 or 계정을 입력 하고 나서 저장합니다.
유저 데이터를 전역으로 사용하기 위해 userSlice를 만들어줍니다.
//userSlice.ts
import { createSlice } from "@reduxjs/toolkit";
const initialState = {
email: '',
id: ''
}
const userSlice = createSlice({
name: 'user',
initialState,
reducers: {
setUser: (state, action) => {
state.email = action.payload.email,
state.id = action.payload.id
}
}
})
export const {setUser} = userSlice.actions;
export const userReducer = userSlice.reducer;
Slice로 받은 데이터를 redux에 넣어줍니다.
로그아웃을 위해 Hook에 useAuth.ts를 만듭니다.
useAuth는 로그인 유무를 확인하는 역할을 합니다. redux에 있는 유저 데이터를 가져옵니다.
import { useTypedSelector } from "./redux"
export function useAuth() {
const { id, email } = useTypedSelector((state) => state.user);
//로그인이 되어있는지 안되어있는지, 되어있으면 email과 id출력
return {
isAuth: !!email,
email,
id
}
}
import { createSlice } from "@reduxjs/toolkit";
const initialState = {
email: '',
id: ''
}
const userSlice = createSlice({
name: 'user',
initialState,
reducers: {
setUser: (state, action) => {
state.email = action.payload.email,
state.id = action.payload.id
},
removeUser: (state) => {
state.email = '';
state.id = '';
}
}
})
export const {setUser, removeUser} = userSlice.actions;
export const userReducer = userSlice.reducer;
로그아웃을 하기위해 removeUser를 선언하고 이메일과 id값을 공백으로 state시키도록합니다.
import React, { FC, useRef, useState } from 'react'
import { useTypedDispatch, useTypedSelector } from '../../hooks/redux';
import { FiLogIn, FiPlusCircle } from 'react-icons/fi';
import SideForm from './SideForm/SideForm';
import { addSection, container, title, addButton, boardItemActive, boardItem } from './BoardList.css';
import clsx from 'clsx';
import { GoSignOut } from 'react-icons/go';
import { GoogleAuthProvider, getAuth, signInWithPopup, signOut } from 'firebase/auth';
import { app } from '../../firebase';
import { removeUser, setUser } from '../../store/slices/userSlice';
import { useAuth } from '../../hooks/useAuth';
type TBoardListProps = {
activeBoardId: string;
setActiveBoardId:
React.Dispatch<React.SetStateAction<string>>
}
const BoardList: FC<TBoardListProps> = ({
activeBoardId,
setActiveBoardId
}) => {
const dispatch = useTypedDispatch();
const { boardArray } = useTypedSelector(state => state.boards);
const [isFormOpen, setIsFormOpen] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const auth = getAuth(app);
const provider = new GoogleAuthProvider();
const { isAuth } = useAuth();
const handleLogin = () => {
signInWithPopup(auth, provider)
.then(userCrendential => {
console.log(userCrendential);
dispatch(
setUser({
email: userCrendential.user.email,
id: userCrendential.user.uid
})
)
})
.catch(error => {
console.error(error);
})
}
const handleClick = () => {
setIsFormOpen(!isFormOpen)
inputRef.current?.focus();
}
const handleSignOut = () => {
signOut(auth)
.then(() => {
dispatch(
removeUser()
)
})
.catch((error) => {
console.error(error);
})
}
return (
<div className={container}>
<div className={title}>
게시판 :
</div>
{boardArray.map((board, index) => (
<div key={board.boardId}
onClick={() => setActiveBoardId(boardArray[index].boardId)}
className= {
clsx(
{
[boardItemActive]:
boardArray.findIndex(board => board.boardId === activeBoardId) === index
},
{
[boardItem]:
boardArray.findIndex(board => board.boardId === activeBoardId) !== index
}
)
}
>
<div>
{board.boardName}
</div>
</div>
))}
<div className={addSection}>
{
isFormOpen ?
<SideForm inputRef={inputRef} setIsFormOpen = {setIsFormOpen} />
:
<FiPlusCircle className={addButton}onClick={handleClick}/>
}
{ isAuth
?
<GoSignOut className={addButton} onClick={handleSignOut}/>
:
<FiLogIn className={addButton} onClick={handleLogin}/>
}
</div>
</div>
)
}
export default BoardList
firebase 서비스를 이용하기 위해 firebase-tools를 전역으로 설치해줍니다.
npm install -g firebase-tools