데브코스

[12주차 - DAY4] 게시판 만들기(2)

미안하다 강림이 좀 늦었다 2024. 5. 16. 14:56

 

 

컴포넌트 생성

빨간색 네모가 BoardList 컴포넌트, 파란색 네모가 ListsContainer 컴포넌트이다.

function App() {
  const [activeBoardId, setActiveBoardId] = useState('board-0');
  const boards = useTypedSelector(state => state.boards.boardArray);

  const getActiveBoard = boards.filter(board => board.boardId === activeBoardId)[0];
  const lists = getActiveBoard.lists;

  return (
    <div className={appContainer}>
      <BoardList
        activeBoardId={activeBoardId}
        setActiveBoardId={setActiveBoardId}>
      </BoardList>
      <div className={board}>
        <ListsContainer lists={lists} boardId={getActiveBoard.boardId} />
      </div>

      ...
    </div>
  )
}

 

Board List 생성

  • 활성화된 게시판 id(activeBoardId)와 활성화된 게시판 id를 설정할 수 있는 함수(setIsFormOpen)를 인수로 받는다.
    • activeBoardId를 이용하여 활성화된 게시판 버튼과 아닌 버튼에 각각 스타일을 적용한다.
  • isFormOpen은 게시판을 추가할 수 있는 입력란이 활성화되어 있는지를 나타낸다.
    • false면 플러스 모양 아이콘을, true면 입력란(SideForm 컴포넌트)을 보여준다.
...
type TBoardListProps = {
    activeBoardId: string;
    setActiveBoardId: React.Dispatch<React.SetStateAction<string>>;
}

const BoardList: FC<TBoardListProps> = ({
    activeBoardId,
    setActiveBoardId
}) => {

    const { boardArray } = useTypedSelector(state => state.boards);
    const [isFormOpen, setIsFormOpen] = useState(false);

    return (
        <div className={container}>
            <div className={title}>
                게시판:
            </div>
            {
                boardArray.map((board, index) => (
                    <div key={board.boardId}
                        onClick={() => setActiveBoardId(board.boardId)}
                        className={
                            clsx(
                                {
                                    [boardItemActive]:
                                        boardArray.findIndex(b => b.boardId === activeBoardId) === index
                                },
                                {
                                    [boardItem]:
                                        boardArray.findIndex(b => b.boardId === activeBoardId) !== index
                                })}
                    >
                        <div>
                            {board.boardName}
                        </div>
                    </div>
                ))
            }
            <div className={addSection}>
                {
                    isFormOpen ?
                        <SideForm setIsFormOpen={setIsFormOpen} /> :
                        <FiPlusCircle className={addButton} onClick={() => setIsFormOpen(!isFormOpen)} />
                }
            </div>
        </div>
    )
}

 

SideForm 생성

  • 입력란을 벗어나면 다시 플러스 아이콘이 보여야 하기 때문에 인수로 setIsFormOpen을 받는다.
  • 입력란을 벗어나는 이벤트는 blur다. blur가 발생하면 상위 컴포넌트인 BoardList 컴포넌트의 isFormOpen을 false로 바꿔서 플러스 아이콘이 보이도록 한다.
  • 이벤트 발생 순서는 mouseDown -> blur -> mouseUp -> click 순이기 때문에 onClick으로 작성하면 blur 이벤트가 먼저 발생하여 새로운 게시판이 생성되지 않으므로 onMouseDown을 사용한다.
type TSideFormProps = {
    setIsFormOpen: React.Dispatch<React.SetStateAction<boolean>>;
}

const SideForm: FC<TSideFormProps> = ({
    setIsFormOpen
}) => {
    const [inputText, setInputText] = useState('');
    const dispatch = useTypedDispatch();

    const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
        setInputText(e.target.value);
    }

    const handleOnBlur = () => {
        setIsFormOpen(false);
    }

    const handleClick = () => {
        ...
    }

    return (
        <div className={sideForm}>
            <input
                autoFocus
                className={input}
                type="text"
                placeholder='새로운 게시판 등록하기'
                value={inputText}
                onChange={handleChange}
                onBlur={handleOnBlur}
            />
            <FiCheck className={icon} onMouseDown={handleClick} />
        </div>
    )
}

 

  • 게시판 이름을 입력하고 체크 버튼을 누르면 새로운 게시판이 생성되므로 게시판 목록과 로그 목록에 상태 변화가 발생한다.
    • 게시판 목록의 상태와 로그 목록을 변화시키기 위해서  boardSlice의 reducer에 게시판을 추가하는 함수와 loggerSlice의 reducer에 로그를 추가하는 함수를 정의한다.
    • immer이라는 라이브러리를 사용하기 때문에 불변성을 신경 쓰지 않아도 된다.
const boardsSlice = createSlice({
    name: 'boards',
    initialState,
    reducers: {
        addBoard: (state, { payload }: PayloadAction<TAddBoardAction>) => {
            state.boardArray.push(payload.board);
        }
    }
})
const loggerSlice = createSlice({
    name: 'logger',
    initialState,
    reducers: {
        addLog: (state, { payload }: PayloadAction<ILogItem>) => {
            state.logArray.push(payload);
        }
    }
})
const handleClick = () => {
        if (inputText) {
            dispatch(
                addBoard({
                    board: {
                        boardId: uuidv4(),
                        boardName: inputText,
                        lists: []
                    }
                })
            );

            dispatch(
                addLog({
                    logId: uuidv4(),
                    logMessage: `게시판 등록: ${inputText}`,
                    logAuthor: 'User',
                    logTimestamp: String(Date.now())
                })
            );
        }
    }

 

DropDownForm 생성

  • 새로운 일 등록 버튼이나 새로운 리스트 등록 버튼을 클릭했을 때 나오는 입력창 컴포넌트이다.
  • 인수로 게시판 id와 해당 리스트의 id, 추가하기 버튼을 클릭했을 때 입력창을 닫기 위한 setIsFormOpen 상태 설정함수를 받는다. 그리고 list라는 인자를 받는데, list 존재 여부에 따라 task를 추가하는 것인지, 리스트를 추가하는 것인지 결정한다.
type TDropDownFormProps = {
    boardId: string;
    listId: string;
    setIsFormOpen: React.Dispatch<React.SetStateAction<boolean>>
    list?: boolean;
}

const DropDownForm: FC<TDropDownFormProps> = ({
    boardId,
    list,
    listId,
    setIsFormOpen
}) => {
    const dispatch = useTypedDispatch();
    const [text, setText] = useState('');
    const formPlaceholder = list ? '리스트에 제목을 입력하세요.' : '일의 제목을 입력하세요.'

    const buttonTitle = list ? '리스트 추가하기' : '일 추가하기';

    const handleTextChange = (e: ChangeEvent<HTMLTextAreaElement>) => {
        setText(e.target.value)
    }

    const handleButtonClick = () => {
        if (text) {
            if (list) {
                // list 추가 코드
                // 로그 추가 코드
            } else {
                // task 추가 코드
                // 로그 추가 코드
            }
        }
    }

    return (
        <div className={list ? listForm : taskForm}>
            <textarea
                className={input}
                value={text}
                onChange={handleTextChange}
                autoFocus
                placeholder={formPlaceholder}
                onBlur={() => setIsFormOpen(false)}
            />
            <div className={buttons}>
                <button
                    className={button}
                    onMouseDown={handleButtonClick}
                >
                    {buttonTitle}
                </button>
                <FiX className={close} />
            </div>
        </div>
    )
}

 

  • 새로운 리스트나 새로운 task를 생성했을 경우 컴포넌트가 리렌더링 되어야 한다.
    • 새로운 리스트를 추가하는 함수 addList와 새로운 task를 추가하는 함수 addTask를 boardSlice에 아래와 같이 작성한다.
  • 입력란에 텍스트를 입력하고 추가하기 버튼을 클릭하면 addList 또는 addTask와 로그를 추가하는 함수인 addLog가 실행되어야 한다.
const boardsSlice = createSlice({
    name: 'boards',
    initialState,
    reducers: {
        ...
        addList: (state, { payload }: PayloadAction<TAddListAction>) => {
            state.boardArray.map(board =>
                board.boardId === payload.boardId
                    ? { ...board, lists: board.lists.push(payload.list) }
                    : board
            )
        },
        addTask: (state, { payload }: PayloadAction<TAddTaskAction>) => {
            state.boardArray.map(board =>
                board.boardId === payload.boardId
                    ? {
                        ...board,
                        lists: board.lists.map(list =>
                            list.listId === payload.listId
                                ? { ...list, tasks: list.tasks.push(payload.task) }
                                : list
                        )
                    }
                    : board
            )
        }
    }
})
const handleButtonClick = () => {
        if (text) {
            if (list) {
                dispatch(
                    addList({
                        boardId,
                        list: { listId: v4(), listName: text, tasks: [] }
                    })
                )

                dispatch(
                    addLog({
                        logId: v4(),
                        logMessage: `리스트 생성하기: ${text}`,
                        logAuthor: 'User',
                        logTimestamp: String(Date.now())
                    })
                )
            } else {
                dispatch(
                    addTask({
                        boardId: boardId,
                        listId: listId,
                        task: {
                            taskId: v4(),
                            taskName: text,
                            taskDescription: '',
                            taskOwner: 'User'
                        }
                    })
                )

                dispatch(
                    addLog({
                        logId: v4(),
                        logMessage: `일 생성하기: ${text}`,
                        logAuthor: 'User',
                        logTimestamp: String(Date.now())
                    })
                )
            }
        }
    }

 

Action Button 생성

  • ActionButton도 list 인자 존재 여부에 따라 리스트 생성의 버튼인지, task 생성의 버튼인지가 결정된다.
  • 이 버튼이 클릭되면 입력창(DropDownForm)이 나타나야 한다.
    • DropDownForm에서 리스트 또는 task를 추가하기 때문에 boardId 인자를 전달해줘야 한다.
type TActionButtonProps = {
    boardId: string;
    listId: string;
    list?: boolean
}

const ActionButton: FC<TActionButtonProps> = ({
    boardId,
    listId,
    list
}) => {
    const [isFormOpen, setIsFormOpen] = useState(false);
    const buttonText = list ? '새로운 리스트 등록' : '새로운 일 등록';

    return isFormOpen ? (
        <DropDownForm
            setIsFormOpen={setIsFormOpen}
            list={list ? true : false}
            boardId={boardId}
            listId={listId}
        />
    )
        :
        (
            <div
                className={list ? listButton : taskButton}
                onClick={() => setIsFormOpen(true)}
            >
                <IoIosAdd />
                <p>{buttonText}</p>
            </div>
        )
}

 

List Container 생성

  • 현재 활성화되어 있는 게시판 id와 해당 게시판의 리스트 목록을 인수로 받는다.
  • 각 리스트를 리스트 컴포넌트로 전달해 주고, 활성화되어 있는 게시판 id도 함께 전달한다.
    • 게시판 id도 전달하는 이유는 리스트 컴포넌트에서 리스트를 삭제할 수 있게 하기 위함이다.
type TListsContainerProps = {
    boardId: string;
    lists: IList[];
}

const ListsContainer: FC<TListsContainerProps> = ({
    lists,
    boardId
}) => {
    return (
        <div className={listsContainer}>
            {
                lists.map(list => (
                    <List
                        key={list.listId}
                        list={list}
                        boardId={boardId}
                    />
                ))
            }
            <ActionButton
                boardId={boardId}
                listId={''}
                list
            />
        </div>
    )
}

 

List 생성

  • 해당 리스트의 tasks 배열을 순회하며 Task 컴포넌트들을 생성한다.
  • 리스트 제목 옆에 있는 (-) 버튼을 클릭하면 해당 리스트가 삭제되어야 한다.
  • task를 클릭하면 상세 정보를 표시해 주는 모달 창이 띄워져야 한다.
type TListProps = {
    boardId: string;
    list: IList;
}

const List: FC<TListProps> = ({
    list,
    boardId
}) => {
    const dispatch = useTypedDispatch();
    const handleListDelete = (listId: string) => {
        // 클릭된 리스트 삭제 코드
        // 로그 생성 코드
    }

    const handleTaskChange = (
        boardId: string,
        listId: string,
        taskId: string,
        task: ITask
    ) => {
        // 모달에 표시할 데이터 저장 코드
        // 모달 활성화 코드
    }

    return (
        <div 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>
                ))
            }
            <ActionButton
                boardId={boardId}
                listId={list.listId}
            />
        </div>
    )
}
  • 리스트를 삭제해 주는 함수 deleteList와 task 클릭 시 모달에 표시할 task를 설정하는 setModalData 함수, 모달 활성화 여부를 설정해 주는 setModalActive 함수를 boardSlice에 정의한다.
const boardsSlice = createSlice({
    name: 'boards',
    initialState,
    reducers: {
        ...
        deleteList: (state, { payload }: PayloadAction<TDeleteListAction>) => {
            state.boardArray = state.boardArray.map(
                board => board.boardId === payload.boardId
                    ?
                    {
                        ...board,
                        lists: board.lists.filter(
                            list => list.listId !== payload.listId
                        )
                    }
                    :
                    board
            )
        },
        setModalActive: (state, { payload }: PayloadAction<boolean>) => {
            state.modalActive = payload
        }
    }
})
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));
    }

 

Task 생성

  • task의 제목과 설명을 보여준다.
  • 인자 중 id는 해당 task의 id이고, index는 추후 drag and drop 기능을 구현할 때 사용된다.
type TTaskProps = {
    index: number;
    id: string;
    boardId: string;
    taskName: string;
    taskDescription: string;
}

const Task: FC<TTaskProps> = ({
    index,
    id,
    boardId,
    taskName,
    taskDescription
}) => {
    return (
        <div className={container}>
            <div className={title}>{taskName}</div>
            <div className={description}>{taskDescription}</div>
        </div>
    )
}

 

 

배운 점

  • 생성한 Slice의 reducer에 상태를 변경하는 함수를 작성한다.
  • 위 함수를 export 하면 다른 컴포넌트에서 import 하여 dispatch로 해당 함수를 사용할 수 있다.
  • 이벤트 발생 순서는 mouseDown -> blur -> mouseUp -> click 순이다.
  • immer이라는 라이브러리를 사용하기 때문에 불변성을 신경 쓰지 않아도 되기 때문에 Slice의 reducer에 정의한 함수에서는 push를 사용해도 된다.