FrontEnd/React

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

일락。 2024. 12. 17. 14:31

칸반 보드란?

 칸반(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

 

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