본문 바로가기

FrontEnd/React

[React] 트렐로 따라 만들기 (1)

칸반 보드란?

 칸반(Kanban)은 '시각 신호'를 뜻하는 일본어로서 도요타 자동차 창업주가 처음 고안했다.  칸반 보드는 작업을 시각적으로 표시해 주어 프로젝트 관리를 쉽게 할수 있도록 도와준다.

 

 카드 목록을 수직 방향으로 구성해 목록이나 카드는 드래그 앤 드롭으로 소속이나 순서를 자유롭게 변경할 수 있다.

 

 

 react-dnd 패키지 설치하기

  react-dnd패키지는 드래그 앤 드롭 기능을 좀 더 쉽게 구현할 수 있게 하는 패키지들 중 하나이다.

> npm i react-dnd react-dnd-html5-backend
> npm i -D @types/react-dnd

 

  

  react-dnd는 리액트 컨텍스트 기반으로 설계 되었으며 제공하는 컴포넌트를 사용하기 위해선 DndProvider 컴포넌트가 최상위 컴포넌트로 동작해야 한다.

import { DndProvider } from 'react-dnd'
import { HTML5Backend } from 'react-dnd-html5-backend'

export default function App() {
    return (
        <DndProvider backend={HTML5Backend}>
            /* react-dnd 기능 사용 컴포넌트 */
        </DndProvider>
    )
}

 

 

 react-beautiful-dnd 패키지 설치하기

  react-beautiful-dnd 패키지는 현재 아틀라시안사가 유지보수하는 패키지이지만 새로운 기능이 추가되고 있지는 않다.  또한 react-beautiful-dnd패키지는 현재 리액트 18을 peerDependencies로 설정하지 않고 있으므로 --legacy-peer-deps 옵션을 붙이지 않으면 설치할 수 없다.

> npm i --legacy-peer-deps react-beautiful-dnd
> npm i -D @types/react-beautiful-dnd

 

 

 앱 상태를 구성하는 멤버 상태와 테스트용 컴포넌트 만들기

  우선 칸반보드의 앱의 상태를 관리하기 위한 멤버상태를 구성 후 AppState와 rootReducer에 각 멤버상태를 설정한다.

  • listEntities : 목록의 상태를 관리하기 위해 사용된다.
  • listidOrders : 목록의 순서를 관리하기 위해 사용된다.
  • cardEntities : 목록에 포함될 카드의 상태를 관리하기 위해 사용된다.
  • listidCardidOrders : 어떤 목록에 카드가 속해있고 순서는 몇번째인지를 확인하기 위해 사용된다.

 

  맴버상태를 구성 후 칸반보드를 테스트하기 위한 컴포넌트를 생성한다.

  • Board :
  • BoardList :
  • ListCard : 

 

 

CreateListForm 컴포넌트 구현하기

 새로운 목록을 생성하기 위해 Board 컴포넌트에 포함될 CreateListForm 을 추가한다.

import type {FC, ChangeEvent} from 'react'
import {useState, useCallback} from 'react'
import {Icon} from '../../theme/daisyui'
import * as D from '../../data'

export type CreateListFormProps = {
  onCreateList: (uuid: string, title: string) => void
}

const CreateListForm: FC<CreateListFormProps> = ({onCreateList}) => {
  const [value, setValue] = useState<string>(D.randomTitleText())
  const onChange = useCallback((e: ChangeEvent<HTMLInputElement>) => {
    setValue(() => e.target.value)
  }, [])
  const addList = useCallback(() => {
    onCreateList(D.randomUUID(), value)
    setValue(() => D.randomTitleText())
  }, [value, onCreateList])
  // prettier-ignore
  return (
    <div className="flex p-2">
      <input placeholder="title"
        value={value} onChange={onChange}
        className="input-xs input-bordered input input-primary" />
      <Icon name="add" onClick={addList} disabled={!value.length}
        className="ml-2 btn-primary" />
    </div>
  )
}
export default CreateListForm

 

 

  추가한 CreateListForm 컴포넌트를 index.tsx 파일에 코드 추가한다.

import {useCallback} from 'react'
import {Title} from '../../components'
import CreateListForm from './CreateListForm'

export default function CopyMe() {
  const onCreateList = useCallback((uuid: string, title: string) => {
    console.log('onCreateList', uuid, title)
  }, [])
  return (
    <section className="mt-4">
      <Title>Board</Title>
      <div className="mt-4">
        <CreateListForm onCreateList={onCreateList} />
      </div>
    </section>
  )
}

 

실행결과로 + 버튼을 누를 경우 onCreateList 콜백을 통해 console.log로 결과를 출력하는 것을 확인할 수 있다.

 

 

 

 배열 대신 ids와 entities로 상태 구현하기

  칸반 보드는 n개의 목록(list)이 있고 각 목록은 n개의 카드(card)를 가질 수 있다.  그리고 각 목록은 드래그 앤 드롭으로 위치를 자유롭게 옴길 수 있다.  예를들어 목록을 배열에 담으면 이러한 기능을 구현하기 까다롭다.

lists = [list1, list2, ...]   // 까다로운 배열

 

 

  앵귤러 프레임워크는 리덕스 기능을 구현하며 @ngrx/entity라는 패키지를 사용해 배열에 들어갈 아이템은 모두 서로를 구분할 수 있는 고유 ID값을 가진다고 가정할 수 있다. 

 

  그리고 entities란 객체를 하나 만들어, id_값: 아이템(1:1) 형태로 여러 개의 아이템을 저장한다.  이런 구조를 가지는 타입을 엔티티(entity)라고 부르겠다.  다만 타입스크립트는 기본으로 제공되는 Record 제네릭 타입을 이해해야 한다.

// redux-toolkit.js.org 사이트에서 인용한 ids와 entities의 역할
{
    ids: []      // 각 항목 고유 ID, 문자열 또는 숫자
    entities: {} // 엔티티 ID를 해당 엔티티 객체에 매핑하는 조회(lookup) 테이블
}

 

 

 타입스크립트의 Record 타입

  자바스크립트는 색인 연산자(index operator) []를 사용해 객체의 속성값을 얻어올 수 있다.  다만 속성 이름을 잘못 입력 시 오류가 발생한다.  

import {makeRandomCard} '../../data'
const card = makeRandomCard()
const uuid = card['uuid']  // 색인 연산자로 객체의 'uuid' 속성 값 설정

 

 

 

  이 때문에 타입스크립트는 객체의 속성값을 색인 연산자로 얻을 수 없다.  대신 Record란 특별한 타입을 제공한다.

// ReCord선언문(제네릭 타입으로 선언)
// K extends keyof any : 키로 사용될 수 있는 타입설정(any는 모든)
// T : 객체의 값의 타입을 의미하며 모든 값이 동일한 T타입을 가지게 된다.
type Record<K extends keyof any, T> = {
    // Mapped Type은 Typescript에서 객체의 프로퍼티를 동적으로 정의할 수 있게 한다.
    // [P in K]는 K 타입의 각 요소를 돌며 객체의 키로 사용하겠다는 의미이다.
    // K요소는 키가되고, 그 키들의 값은 T가 된다.
    [P in K]: T;
};

 

 

공통으로 사용하는 타입 구현하기

 멤버 상태들이 공통으로 사용하는 타입이 있으므로 먼저 commonTypes.ts를 추가한다.

import type {ICard} from '../data'

export type UUID = string
export type List = {  // 목록
  uuid: UUID
  title: string
}
export type Card = ICard
export type CardidListid = {  // 카드와 목록을 매핑
  cardid: UUID
  listid: UUID
}
export type listidCardid = CardidListid
export type ListidCardidS = {listid: UUID; cardids: UUID[]}
export type CardidListidIndex = CardidListid & {
  index: number  // 카드목록매핑 타입에 index를 추가
}

 

 

 

listidOrders 멤버 상태 구현하기

 listidOrders 멤버 상태는 생성한 목록의 uuid값을 배열에 담아 웹 페이지에 어떤 순서로 표시할 것인지 결정하는 역할을 한다. 

 

 type은 uuid값을 담을 배열을 선언한 후 목록의 추가, 드래그 앤 드롭에 의한 순서변경, 삭제할 수 있으므로 대응할 3가지 액션을 설정한다.

import type {Action} from 'redux'
import type {UUID} from '../commonTypes'
export * from '../commonTypes'

export type State = UUID[]  // UUID값을 배열로 갖고있음을 암시

export type SetListidOrders = Action<'@listidOrders/set'> & {
  payload: State
}
export type AddListidToOrders = Action<'@listidOrders/add'> & {
  payload: UUID
}
export type RemoveListidFromOrders = Action<'@listidOrders/remove'> & {
  payload: UUID
}
export type Actions = SetListidOrders | AddListidToOrders | RemoveListidFromOrders

 

 

 앞서 type에서 선언한 3가지 액션타입의 액션 생성기 코드를 추가한다.

import type * as T from './types'

export const setListidOrders = (payload: T.State): T.SetListidOrders => ({
  type: '@listidOrders/set',
  payload
})
export const AddListidToOrders = (payload: T.UUID): T.AddListidToOrders => ({
  type: '@listidOrders/add',
  payload
})
export const RemoveListidFromOrders = (payload: T.UUID): T.RemoveListidFromOrders => ({
  type: '@listidOrders/remove',
  payload
})

 

 액션 생성기로 생성한 3개의 액션을 대응하는 리듀서를 구현한다.

import * as T from './types'

const initialAppState: T.State = []

export const reducer = (state: T.State = initialAppState, action: T.Actions) => {
  switch (action.type) {
    case '@listidOrders/set':            // 
      return (state = action.payload)
    case '@listidOrders/add':            // 목록의 추가
      return [...state, action.payload]
    case '@listidOrders/remove':         // 목록의 삭제
      return state.filter(uuid => uuid !== action.payload)
  }
  return state
}

 

 

 

listEntities 멤버 상태 구현하기

 listEntities 멤버 상태는 commonTypes.ts 파일에 선언했던 List 타입 객체들을 엔티티 방식으로 저장하는 역할을 수행한다.  List타입은 string 타입의 uuid속성과 카드 목록의 용도를 구분할 수 있도록 string 타입의 title 속성을 가지고 있다.

export type List = {
    uuid : UUID
    title: string
}

 

 

 타입에 카드목록을 추가하거나 삭제할 수 있는 액션을 2개 정의한다.

import type {Action} from 'redux'
import type {List as commonList} from '../commonTypes'

export type List = commonList
export type State = Record<string, List>

export type AddListAction = Action<'@listEntities/add'> & {
  payload: commonList
}
export type RemoveListAction = Action<'@listEntities/remove'> & {
  payload: string
}

export type Actions = AddListAction | RemoveListAction

 

 다음으로 type에서 선언한 2개의 액션 타입에 대한 각각의 액션 생성기 코드를 추가한다.

import type * as T from './types'

export const addList = (payload: T.List): T.AddListAction => ({
  type: '@listEntities/add',
  payload
})
export const removeList = (payload: string): T.RemoveListAction => ({
  type: '@listEntities/remove',
  payload
})

 

 

 리듀서 구현 시 액션을 통해 State에 포함된 Record<string, List> 객체에 값을 추가하거나 삭제해 주는 과정을 준비해야 한다.  목록을 생성하는 @listEntities/add 액션 구현 시 새로운 목록은 action.payload에 담겨있다.

 

 

 state는 Record<UUID, List> 타입의 엔티티이므로 state[uuid] = list 형태로구현할 수 있다.  다만 리듀서에서는 매개변수의 수정은 불순함수이므로 문제가 발생한다.  따라서 깊은복사인 {...state, [uuid]: card} 형태로 구현해야 한다.

state[uuid] = list; return state // 리듀서는 순수 함수만 가능하므로 불순함수는 불가하다.
return {...state, [uuid]: list}  // 순수함수로 올바른 구현

 

 

 목록을 제거하는 @listEntities/remove 액션은 제거할 목록의 uuid값은 action.payload에 담겨있다.  타입스크립트에서 객체 특정 속성을 삭제하 땐 delete 연산자를 통해 삭제할 수 있다.  다만 리듀서에는 불순함수이므로 잘못된 구현이므로 깊은 복사 방식으로 구현해야 한다.

delete state[uuid]; return state  // 불순함수이므로 잘못된 구현(매개변수값의 변경)

const newstate = {...State}   // 깊은복사 방식을 통한 순수함수 구현
delete newState[uuid]
return newState

 

 

 위 내용을 토대로 순수함수만을 이용하여 리듀서 코드를 추가한다.

import * as T from './types'

const initialAppState: T.State = {}

export const reducer = (state: T.State = initialAppState, action: T.Actions) => {
  switch (action.type) {
    case '@listEntities/add':
      return {...state, [action.payload.uuid]: action.payload}
    case '@listEntities/remove':
      const newState = {...state}
      delete newState[action.payload]
      return newState
  }
  return state
}

 

 

  앞서 만든 listidOrders와 listEntities 멤버 상태를 Board 컴포넌트에 적용한다.  listidOrders는 list의 순서를 설정 목록이고 listEntities는 목록의 Object를 key:value로 관리하기 위한 형태이다.

import {useCallback} from 'react'
import {useDispatch} from 'react-redux'
import {Title} from '../../components'
import CreateListForm from './CreateListForm'

import * as LO from '../../store/listidOrders'
import * as L from '../../store/listEntities'

export default function CopyMe() {
  const dispatch = useDispatch()

  const onCreateList = useCallback(
    (uuid: string, title: string) => {
      const list = {uuid, title}
      dispatch(LO.addListidToOrders(list.uuid)) // 배열을 관리
      dispatch(L.addList(list))
    },
    [dispatch]
  )
  return (
    <section className="mt-4">
      <Title>Board</Title>
      <div className="mt-4">
        <CreateListForm onCreateList={onCreateList} />
      </div>
    </section>
  )
}

 

실행결과는 +버튼을 누르면 listidOrders와 listEntities가 1개씩 추가된 것을 확인할 수 있다.

 

 

 

BoardList 컴포넌트 구현하기

앞선 listidOrders와 listEntities는 상태에 목록정보를 생성하는 기능을 제공했기에 화면상에 변화는 없다.  이번에는 화면상에 추가된 상태정보의 목록을 추가/삭제하는 BoardList 컴포넌트를 추가해보자.

import type {FC} from 'react'
import type {List} from '../../store/commonTypes'
import {Icon} from '../../theme/daisyui'

export type BoardListProps = {
  list: List
  onRemoveList?: () => void
}
const BoardList: FC<BoardListProps> = ({list, onRemoveList, ...props}) => {
  return (
    <div {...props} className="p-2 m-2 border border-gray-300 rounded-lg">
      <div className="flex justify-between mb-2">
        <p className="w-32 text-sm font-bold underline line-clamp-1">{list.title}</p>
        <div className="flex justify-between ml-2">
          <Icon name="remove" className="btn-error btn-xs" onClick={onRemoveList} />
        </div>
      </div>
    </div>
  )
}
export default BoardList

 

 

 listEntities 객체에 담긴 목록을 화면에 표현하기 위해서는 타입을 기존 List에서 List[] 타입으로 변경해야 한다.

import type {AppState} from '../store'
import type {List} from '../store/commonTypes'
import type as L from '../store/listEntities'

const lists = useSelector<AppState, List[]>(({listEntities}) => ???)

 

 그리고 각 목록의 순서는 listidOrders에 담겨 있다.

import * as LO from '../store/listidOrders'
const listidOrders = useSelector<AppState, LO.State>(({listidOrders}) => listidOrders)

 

 결과적으로 List[] 타입 배열은 listidOrders를 통해 uuid의 순서를 결정하고, 각 uuid에 해당하는 목록을 listEntities에서 얻어올 수 있다.

const lists = useSelector<AppState, List[]>(({listidOrders, listEntities}) => 
    listidOrders.map(uuid => listEntities[uuid])
)

 

 

 위 과정을 통해 BoardList를 화면에 표시해 줄 수 있으며 해당 정보를 Baord에 내용을 추가해보자.

import {useCallback, useMemo} from 'react'
import {useSelector, useDispatch} from 'react-redux'
import {Title} from '../../components'
import CreateListForm from './CreateListForm'
import BoardList from '../BoardList'
import type {AppState} from '../../store'
import type {List} from '../../store/commonTypes'
import * as LO from '../../store/listidOrders'
import * as L from '../../store/listEntities'

export default function CopyMe() {
  const dispatch = useDispatch()

  const lists = useSelector<AppState, List[]>(({listidOrders, listEntities}) =>
    listidOrders.map(uuid => listEntities[uuid])
  )

  const onCreateList = useCallback(
    (uuid: string, title: string) => {
      const list = {uuid, title}
      dispatch(LO.addListidToOrders(list.uuid)) 
      dispatch(L.addList(list))
  }, [dispatch])
  
  const onRemoveList = useCallback((listid: string) => () => {
      dispatch(L.removeList(listid))
      dispatch(LO.removeListidFromOrders(listid))
  }, [dispatch])

  const children = useMemo(() =>
      lists.map(list => (
        <BoardList key={list.uuid} list={list} onRemoveList={onRemvoeList(list.uuid)} />
  )), [lists, onRemvoeList])

  return (
    <section className="mt-4">
      <Title>Board</Title>
      <div className="flex flex-wrap p-2 mt-4">
        {children}
        <CreateListForm onCreateList={onCreateList} />
      </div>
    </section>
  )
}

 

실행결과로 화면에 정상적으로 표시되면 값들 또한 정상적으로 설정된 것을 확인할 수 있다.

 

 

 

리덕스 기능을 커스텀 훅으로 만들기

 Board컴포넌트 내에 있는 리덕스의 기능을 분리해 코드의 복잡성을 줄이기 위해 useLists 커스텀 훅을 추가한다.

import {useCallback} from 'react'
import {useSelector, useDispatch} from 'react-redux'
import type {AppState} from './AppState'
import {List} from '../store/commonTypes'
import * as LO from '../store/listidOrders'
import * as L from '../store/listEntities'

export const useLists = () => {
  const dispatch = useDispatch()
  const lists = useSelector<AppState, List[]>(({listidOrders, listEntities}) =>
    listidOrders.map(uuid => listEntities[uuid])
  )

  const onCreateList = useCallback(
    (uuid: string, title: string) => {
      const list = {uuid, title}
      dispatch(LO.addListidToOrders(list.uuid)) // 배열을 관리
      dispatch(L.addList(list))
  }, [dispatch])
  
  const onRemvoeList = useCallback((listid: string) => () => {
      dispatch(L.removeList(listid))
      dispatch(LO.removeListidFromOrders(listid))
  }, [dispatch])

  return {lists, onCreateList, onRemoveList}
}

 

 기존 Board 컴포넌트에 useLists 훅을 적용해 코드를 간결하게 만든다.

...(생략) ...
import {useLists} from '../../store/useLists'

export default function CopyMe() {
  const {lsits, onRemoveList, oncreateList} = useLists()
  ...(생략)....
}

 

 

 

ListCatd 컴포넌트 구현하기

 목록에 추가될 카드목록 컴포넌트를 추가하자.

import type {FC} from 'react'
import type {ICard} from '../../data'
import {Div, Avatar} from '../../components'
import {Icon} from '../../theme/daisyui'

export type UserCardProps = {
  card: ICard
  onRemove?: () => void
  onClick?: () => void
}

const Card: FC<UserCardProps> = ({card, onRemove, onClick}) => {
  const {image, writer} = card
  const {avatar, name, jobTitle} = writer

  return (
    <Div className="m-2 border shadow-lg rounded-xl" width="10rem" onClick={onClick}>
      <Div src={image} className="relative h-20">
        <Icon
          name="remove"
          className="absolute right-1 top-1 btn-primary btn-xs"
          onClick={onRemove}
        />
      </Div>
      <Div className="flex flex-col p-2">
        <Div minHeight="4rem" height="4rem" maxHeight="4rem">
          <Div className="flex flex-row items-center">
            <Avatar src={avatar} size="2rem" />
            <Div className="ml-2">
              <p className="text-xs font-bold">{name}</p>
              <p className="text-xs text-gray-500">{jobTitle}</p>
            </Div>
          </Div>
        </Div>
      </Div>
    </Div>
  )
}

export default Card

 

 

cardEntities 멤버 상태 구현하기

cardEntities를 관리하기 위한 타입선언, 액션생성기, 리듀서를 순차적으로 추가하자.  이는 카드의 상태를 관리하고 카드를 추가/삭제하기 위해 사용된다.

export type ICard = {  // ICard인터페이스이며 card 타입의 형태이다.
  uuid: string
  writer: IUser
  image: string
  title: string
  paragraphs: string
  dayMonthYearDate: string
  relativeDate: string | null
}
import type {Action} from 'redux'
import type {Card, UUID} from '../commonTypes'
export * from '../commonTypes'
// 상태설정
export type State = Record<UUID, Card>
// 카드 추가
export type AddCardAction = Action<'@cardEntities/add'> & {
  payload: Card
}
// 카드 삭제
export type RemoveCardAction = Action<'@cardEntities/remove'> & {
  payload: UUID
}

export type Actions = AddCardAction | RemoveCardAction
import type * as T from './types'
// 액션 생성기
export const AddCard = (payload: T.Card): T.AddCardAction => ({
  type: '@cardEntities/add',
  payload
})
export const removeCard = (payload: T.UUID): T.RemoveCardAction => ({
  type: '@cardEntities/remove',
  payload
})
import * as T from './types'

const initialAppState: T.State = {}
// 리듀서
export const reducer = (state: T.State = initialAppState, action: T.Actions) => {
  switch (action.type) {
    case '@cardEntities/add':    // 카드 추가
      return {...state, [action.payload.uuid]: action.payload}
    case '@cardEntities/remove': // 카드 삭제
      const newState = {...state}
      delete newState[action.payload]
      return newState
  }
  return state

 

 

listidCardidOrders 멤버 상태 구현하기

 카드는 목록에 추가/삭제, 순서변경(drag&drop)이 가능해야 한다.  그러기 위해 listidCardidOrers 멤버상태 구현하며 Record<리스트_uuid, 카드_uuid[]> 타입의 엔티티를 갖는다.

 

 방금전과  마찬가지로 타입선언, 액션 생성기, 리듀서 순으로 코드를 추가하자.

export type ListidCardidS = {listid: UUID; cardids: UUID[]}

 

import type {Action} from 'redux'
import * as CT from '../commonTypes'
export * from '../commonTypes'
// 타입 선언
export type State = Record<CT.UUID, CT.UUID[]>

export type SetListidCardids = Action<'@listidCardids/set'> & {  // 카드목록 일괄등록
  payload: CT.ListidCardidS
}
export type RemoveListidAction = Action<'@listidCardids/remove'> & { // 카드목록 일괄삭제
  payload: CT.UUID
}
export type PrependCardidToListidAction = Action<'@listidCardids/prependCardid'> & {
  payload: CT.CardidListid  // 카드를 목록 맨 앞에 추가
}
export type AppendCardidToListidAction = Action<'@listidCardids/appendCardid'> & {
  payload: CT.CardidListid  // 카드를 목록 맨 뒤에 추가
}
export type RemoveCardidFromListidAction = Action<'@listidCardids/removeCardid'> & {
  payload: CT.CardidListid  // 목록의 카드 삭제
}
export type Actions =
  | SetListidCardids
  | RemoveListidAction
  | PrependCardidToListidAction
  | AppendCardidToListidAction
  | RemoveCardidFromListidAction
import * as T from './types'

const initialAppState: T.State = {}

export const reducer = (state: T.State = initialAppState, action: T.Actions) => {
  switch (action.type) {
    case '@listidCardids/set':
      // [action.payload.listid] : key, action.payload.cardids : value
      // return {...state, "신규키" : [1,2,3]} <-- 아래와 동일한 결과
      return {...state, [action.payload.listid]: action.payload.cardids}
    case '@listidCardids/remove':
      const newState = {...state}
      delete newState[action.payload]
      return newState
    case '@listidCardids/prependCardid': {
      const cardids = state[action.payload.listid]
      return {...state, [action.payload.listid]: [action.payload.cardid, ...cardids]}
    }
    case '@listidCardids/appendCardid': {
      const cardids = state[action.payload.listid]
      return {...state, [action.payload.listid]: [...cardids, action.payload.cardid]}
    }
    case '@listidCardids/removeCardid': {
      const cardids = state[action.payload.listid]
      return {
        ...state,
        [action.payload.listid]: cardids.filter(id => id !== action.payload.cardid)
      }
    }
  }
  return state
}

 

 마지막으로 useLists 커스텀 훅에 cardEntities와 listidCardidOrders 기능을 추가한다.

...(생략)...
import * as C from '../store/cardEntities'
import * as LC from '../store/listidcardidOrders'

export const useLists = () => {
  const lists = useSelector<AppState, List[]>(({listidOrders, listEntities}) =>
    listidOrders.map(uuid => listEntities[uuid])
  )
  
  const listidCardidOrders = useSelector<AppState, LC.State>( // 추가
    ({listidCardidOrders}) => listidCardidOrders
  )

  const onCreateList = useCallback(
    (uuid: string, title: string) => {
      const list = {uuid, title}
      dispatch(LO.addListidToOrders(uuid))
      dispatch(L.addList(list))
      dispatch(LC.setListidCardids({listid: list.uuid, cardids: []})) // 추가
    },
    [dispatch]
  )

  const onRemoveList = useCallback(
    (listid: string) => () => {
      listidCardidOrders[listid].forEach(cardid => { // 추가
        dispatch(C.removeCard(cardid))
      })
      dispatch(LC.removeListid(listid)) // 추가

      dispatch(L.removeList(listid))
      dispatch(LO.removeListidFromOrders(listid))
    },
    [dispatch, listidCardidOrders] // listidCardidOrders 추가
  )

  return {lists, onCreateList, onRemoveList}
}

 

 

useCard 커스텀 훅 만들기

 useLists 커스텀 훅과 마찬가지로 카드의 경우도 소스를 간결하게 관리하기 위해 useCards 커스텀 훅을 추가한다.

import {useCallback} from 'react'
import {useDispatch, useSelector} from 'react-redux'
import type {AppState} from './AppState'
import type {Card, UUID} from './commonTypes'
import * as C from '../store/cardEntities'
import * as LC from '../store/listidcardidOrders'
import * as D from '../data'

export const useCards = (listid: UUID) => {
  const dispatch = useDispatch()
  const cards = useSelector<AppState, Card[]>(({cardEntities, listidCardidOrders}) =>
    listidCardidOrders[listid].map(uuid => cardEntities[uuid])
  )

  const onPrependCard = useCallback(() => {
    const card = D.makeRandomCard()
    dispatch(C.AddCard(card))
    dispatch(LC.prependCardidToListid({listid, cardid: card.uuid}))
  }, [dispatch])

  const onAppendCard = useCallback(() => {
    const card = D.makeRandomCard()
    dispatch(C.AddCard(card))
    dispatch(LC.appendCardidToListid({listid, cardid: card.uuid}))
  }, [dispatch])

  const onRemoveCard = useCallback(
    (uuid: UUID) => () => {
      dispatch(C.removeCard(uuid))
      dispatch(LC.removeCardIdFromListId({listid, cardid: uuid}))
    },
    [dispatch]
  )

  return {cards, onPrependCard, onAppendCard, onRemoveCard}
}

 

 

 useCards를 BoardList 컴포넌트에 추가해 카드관리 기능을 구현한다.

import type {FC} from 'react'
import type {List} from '../../store/commonTypes'
import {useMemo} from 'react'
import {Div} from '../../components'
import {Icon} from '../../theme/daisyui'
import ListCard from '../ListCard'
import {useCards} from '../../store/useCards'

export type BoardListProps = {
  list: List
  onRemoveList?: () => void
}
const BoardList: FC<BoardListProps> = ({list, onRemoveList, ...props}) => {
  const {cards, onPrependCard, onAppendCard, onRemoveCard} = useCards(list.uuid)

  const children = useMemo(
    () =>
      cards.map((card, index) => (
        <ListCard key={card.uuid} card={card} onRemove={onRemoveCard(card.uuid)} />
      )),
    [cards, onRemoveCard]
  )
  return (
    <Div {...props} className="p-2 m-2 border border-gray-300 rounded-lg">
      <div className="flex justify-between mb-2">
        <p className="w-32 text-sm font-bold underline line-clamp-1">{list.title}</p>
      </div>
      <div className="flex justify-between ml-2">
        <Icon name="remove" className="btn-error btn-xs" onClick={onRemoveList} />
        <div className="flex">
          <Icon name="post_add" className="btn-success btn-xs" onClick={onPrependCard} />
          <Icon name="playlist_add" className="ml-2 btn-success btn-xs" onClick={onAppendCard} />
        </div>
      </div>
      <div className="flex flex-col p-2">{children}</div>
    </Div>
  )
}

export default BoardList

 

기존 목록에서 카드를 추가하는 기능까지 구현된 결과를 확인할 수 있다.