본문 바로가기
카테고리 없음

5단원 정리[4 - 1] Do It 리액트 모던 웹 개발 with 타입스크립트 (리액트) 공부

by ㅋ ㅅㅋ 2024. 12. 25.

트렐로 따라만들기

트렐로는 많은 개발자가 사용하는 웹 기반의 프로젝트 관리 소프트웨어로서, 드래그 앤 드롭 방식의 칸반 보드를 기반으로 동작합니다.

 

칸반보드란 ? 

칸반은 '시각 신호'를 뜻하는 일본어로서 도요타 자동차 창업주가 처음 고안했습니다. 칸반 보드는 작업을 시각적으로 표시해 주어 프로젝트 관리를 쉽게 할 수 있도록 돕습니다. 카드 목록을 수직 방향으로 구성하며 각 목록이나 카드는 드래그 앤 드롭으로 소속이나 순서를 자유롭게 변경 할 수 있습니다.

칸반보드의 예시 이미지

react-dnd 패키지

웹 브라우저는 드래그 앤 드롭 방식으로 HTML 요소의 위치를 변경할 수 있는 기능을 제공합니다. 자바스크립트 커뮤니티에서는 많은 패키지가 존재하는데 그중에 react-dnd패키지가 대표적 입니다.

react-dnd 설치방법(명령어)
npm i react-dnd react-dnd-html5-backend
npm i -D @types/react-dnd

 

react-dnd는 리액트 컨텍스트에 기반하여 설계되었으며 react-dnd가 제공하는 컴포넌트를 사용하려면, App.tsx에 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-dnd 패키지는 카드를 같은 목록에서는 자유롭게 드래그 앤 드롭 할 수 있지만 다른 목록으로는 옮기지 못합니다. 그래서 react-beautiful-dnd 패키지를 추가로 사용하여 만들어보겠습니다.

react-beautiful-dnd 패키지 설치하기
npm i --legacy-peer-deps react-beautiful-dnd
npm i -D @types/react-beautiful-dnd

앱 상태를 구성하는 멤버 상태 만들기

src/pages에 테스트용 컴포넌트 만들기

CreateListForm 컴포넌트 구현하기

src\pages\Board\CreateListForm.tsx

import { 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])

    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 btn-xs" />
        </div>
    )
}

export default CreateListForm

 

src\pages\Board\CreateListForm.tsx

import { 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])

    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 btn-xs" />
        </div>
    )
}

export default CreateListForm

 

src\pages\Board\index.tsx

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

export default function Board() {
    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>
    )
}

 

실행 결과

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

목록을 다음처럼 배열에 담으면 위치를 자유롭게 옮길수가 없습니다.

lists = [list, list2, ...]

 

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

다음은 redux-tookit.js.org 사이트에서 인용한 ids와 entities의 역할을 보인 것입니다.

ids와 entities의 역할
{
   ids: []      // 각 항목의 고유 ID. 문자열 또는 숫자
   entities: {}   // 엔티티 ID를 해당 엔티티 객체에 매핑하는 조회(lookup) 테이블
}

 

위에 구조를 가진 타입을 단순히 엔티티(entity)라고 부르겠습니다. 그런데 타입스크립트로 엔티티를 구현할 때는 타입스크립트가 기본으로 제공하는 Record 제네릭 타입을 이해해야 합니다.

 

타입스크립트의 Record 타입

자바스크립트는 다음 코드에서 보듯 색인 연산지[]를 사용하여 객체의 속성값을 얻을 수 있습니다.

객체의 속성값을 색인 연산자로 얻기
import {makeRandomCard} '../../date'
const card = makeRandomCard()
const uuid = card['uuid']  // 색인 연산자로 객체의 uuid 속성값 얻기

 

그런데 이형태로 코드를 작성할 때 card['id'] 처럼 속성의 이름을 잘못 입력할 수도 있습니다. 이럴때는 코드가 실행되고나서 오류가 나야 알 수있는데 이것때문에 타입스크립트는 기본으로 객체의 속성값을 색인 연산자로 얻을 수 없게 합니다.

대신 Record란 이름의 특별한 타입을 제공합니다.

 

다음은  Record 타입의 선언문으로 코드에 사용된 keyof는 타입스크립트 키워드이고 [P in K] 이부분은 색인 연산자로 객체의 특정 속성에 접근할 수 있게 하는 타입스크립트의 색인 타입 구문입니다. 

Record 타입을 사용하려면 Record<키_타입, 값_타입> 형태로 2개의 타입 변수를 지정해햐합니다. 

엔티티 방식으로 구현한 예
type Record<K extends keyof any, T> = {
   [P in K]: T;
}
예시1)
// 1. 역할을 정의하는 타입을 만듭니다.
type UserRoles = 'admin' | 'editor' | 'viewer';

// 2. Record를 사용하여 각 역할에 대한 설명을 저장하는 객체 타입을 정의합니다.
type UserRoleMap = Record<UserRoles, string>;

// 3. 실제 객체를 생성합니다.
const roles: UserRoleMap = {
    admin: 'Administrator - has full access',
    editor: 'Editor - can edit content',
    viewer: 'Viewer - can only view content',
};

// 4. 객체를 사용합니다.
console.log(roles.admin); // "Administrator - has full access"
console.log(roles.editor); // "Editor - can edit content"
console.log(roles.viewer); // "Viewer - can only view content"

예시2)
typescript


type User = {
    name: string;
    age: number;
    email: string;
};

type OptionalUser = { [P in keyof User]?: User[P] };

// OptionalUser 타입은 다음과 같은 형태를 가집니다:
// {
//     name?: string;
//     age?: number;
//     email?: string;
// }

const user1: OptionalUser = {
    name: 'Alice',
    // age와 email은 선택적입니다.
};

const user2: OptionalUser = {
    email: 'bob@example.com',
};

 

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

src\store\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
}

 

listidOrders 멤버 상태 구현하기

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

 

src\store\listidOrders\types.ts

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

export type State = 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

 

목록은 아무대나 생성할 수 있고, 드로그앤 드랍으로 변경 할 수도 있고 삭제 할 수도있으므로 3개를 만듭니다.

다음은 해당의 액션 생성기를 구현한 내용입니다.

 

src\store\listidOrders\actions.ts

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
})

 

그리고 액션에 대응하는 리듀서를 구현하는 내용입니다.

 

src\store\listidOrders\reducers.ts

import * as T from './types'

const initialState: T.State =[]

export const reducer = (state: T.State = initialState, action: T.Actions) => {
    switch (action.type) {
        case '@listidOrders/set':
            return 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이라는 속성을 가지고 있습니다.

List의 타입
export type List = {
    uuid: UUID
    title: string
}

 

src\store\listEntities\types.ts

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

export type State = Record<string, List>

export type AddListAction = Action<'@listEntities/add'> & {
    payload: List
}

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

export type Actions =  AddListAction | RemoveListAction

 

src\store\listEntities\actions.ts

import type * as T from './types'

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

export const removeList = (payload: T.UUID): T.RemoveListAction => ({
    type: '@listEntities/remove',
    payload
})

액션 생성기까지 만들었습니다.

 

다음처럼 작성된 리듀서 코드에서 각 액션의 return state 부분을 수정하여 새로운 목록을 state에 저장하거나 state에서 특정 uuid를 가진 목록을 제거해야합니다.

 

기존 리듀서 코드

export const reducer = (state: T.State = initialState, action: T.Actions) => {
    switch (action.type) {
        case '@listEntities/add':
            return state   // 이부분을 수정해야함
        case '@listEntities/remove':
            return state   // 이부분을 수정해야함
    }
    return state
}

 

먼저 @listEntities/add 액션을 구현해 보겠습니다. 현재 새로운 목록은 다음처럼 action.payload에 담겨 있습니다.

const list = action.payload
const {uuid} = list

 

그런데 state는 Record<UUID, List>타입의 엔티티이므로 다음처럼 state[uuid] = list 형태로 구현할 수 있습니다. 하지만 이 구현 방법은 리듀서의 입력 매개변숫값을 수정하는 것이므로 리듀서를 불순 함수로 만듭니다. 따라서 리듀서가 순수 함수가 되려면 {...state, [uuid]: card} 형태로 구현해야 합니다.

state[uuid] = list; return state  // 리듀서는 순수 함수여야 하므로 잘못된 구현
return {...state, [uuid]: list }     // 올바른 표현

 

이번에는 목록을 제거하는 @listEntities/remove 액션을 구현해 보겠습니다. 현재 제거할 목록의 uuid값은 다음처럼 action.payload에 담겨 있습니다.

const {uuid} = action.payload

 

타입스크립트에서 객체의 특정 속성을 삭제할 때는 다음처럼 특정 속성을 대상으로 delete연산자를 사용합니다. 그런데 이방식은 리듀서를 불순 함수로 만들므로 잘못된 구현 입니다.

delete state[uuid]; return state // 리듀서는 순수 함수이여야 하므로 잘못 구현

 

반변에 다음처럼 전개 연산자를 사용하여 깊은 복사 방식으로 새로운 상태를 만든 뒤 delete연사자를 적용하면 리듀서의 매개변수 state값을 바꾸지 않으므로 올바른 구현 방법이 됩니다.

const newState = {...state}
delete newState[uuid]
return newState

 

이제 위에 알아본대로 코드를 작성해 보겠습니다

src\store\listEntities\reducers.ts

import * as T from './types'

const initialState: T.State ={}

export const reducer = (state: T.State = initialState, 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컴포넌트에 적용합시다.

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 Board() {
    const dispatch = useDispatch();

    const onCreateList = useCallback((uuid: string, title: string) => {
        const list = {uuid, title}
        dispatch(LO.addListidToOrders(list.uuid));
        dispatch(L.addList(list));
        console.log('test', list);
    }, [dispatch])
    return (
        <section className="mt-4">
            <Title>Board</Title>
            <div className="mt-4">
                <CreateListForm onCreateList={onCreateList} />
            </div>
        </section>
    )
}

실행화면

BoardList 컴포넌트 구현하기

앞에서 목록을 생서하는 기능을 만들었는데 이제 이목록을 그림처럼 화면에 표시하고 빼기버튼을 눌렀을때는 없애는 기능을 만들어 보겠습니다.

 

src\pages\BoardList\index.tsx

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

 

이제 위에 BoardList 컴포넌트를 Board 컴포넌트에 적용할 차례인데, 그전에 엔티티를 배열로 바꾸는 방법을 알아보겠습니다. listEntities타입 Record<UUID, List>가 아니라, List[] 타입 배열로 바꿔줘야 합니다.

import type {AppState} from '../store'
import type {List} from '../store/commonTypes'
import * 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의 순서를 결정한 다음, 각 uudi에 해당하는 목록을 listEntities에서 얻어올 수 있습니다.

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

 

이제 합쳐보겠습니다.

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 Board() {
    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={onRemoveList(list.uuid)} />
            )),
            [lists, onRemoveList]
    )
    return (
        <section className="mt-4">
            <Title>Board</Title>
            <div className="mt-4">
                {children}
                <CreateListForm onCreateList={onCreateList} />
            </div>
        </section>
    )
}

실행결과

 

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

현재 리덕스 기능을 그대로 구현하고있어서 코드가 복잡합니다. 더 간결하게 해보겠습니다.

 

src\store\useLists.ts

import { useCallback } from "react";
import { useDispatch, useSelector } from "react-redux";
import type { AppState } from "../store"
import type { 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(uuid))
            dispatch(L.addList(list))
        },
        [dispatch]
    )

    const onRemoveList = useCallback(
        (listid: string) => () => {
            dispatch(L.removeList(listid))
            dispatch(LO.removeListidFromOrders(listid))
        },
        [dispatch]
    )

    return {lists, onCreateList, onRemoveList}
}

위에 있는 코드를 추가하여 간결하게 만들어보겠습니다

 

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';

import { useLists } from "../../store/useLists";

export default function Board() {
    const {lists, onRemoveList, onCreateList} = useLists()

    const children = useMemo(
        () => 
            lists.map(list => (
                <BoardList key={list.uuid} list={list} onRemoveList={onRemoveList(list.uuid)} />
            )),
            [lists, onRemoveList]
    )
    return (
        <section className="mt-4">
            <Title>Board</Title>
            <div className="flex flex-wrap p-2 mt-4">
                {children}
                <CreateListForm onCreateList={onCreateList} />
            </div>
        </section>
    )
}

간결하게 만들었습니다.

 

ListCard 컴포넌트 구현하기

이번에는 카드를 추가하는 기능을 구현하겠습니다.

src\pages\ListCard\index.tsx

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 ListCard: 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 ListCard

먼저 index를 만듭니다.

 

cardEntities 멤버 상태 구현하기

사실 이내용은 List타입 대신 Card 타입을 사용했을뿐 앞에 listEntities 내용과 같습니다.

 

src\store\cardEntities\types.ts

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

export type State = Record<string, Card>

export type AddCardAction = Action<'@cardEntities/add'> & {
    payload: Card
}

export type RemoveCardAction = Action<'@cardEntities/remove'> & {
    payload: string
}

export type Actions =  AddCardAction | RemoveCardAction

 

src\store\cardEntities\actions.ts

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
})

 

src\store\cardEntities\reducers.ts

import * as T from './types'

const initialState: T.State ={}

export const reducer = (state: T.State = initialState, 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 멤버 상태 구현하기

카드는 각각 특정 목록에 소속되어있으며 드래그앤 드롭으로 순서를 바꿀수 있습니다. 따라서 특정 목록이 어떤 카드를 어떤 순서로 가지고잇는지를 나타내는 정보가 필요합니다.

listidCardidOrders 멤버 상태는 Record<리스트_uuid, 카드_uuid[]> 타입의 엔티티를 가집니다.

 

src\store\listidCardidOrders\types.ts

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 RemoveListAction = Action<'@listidCardids/remove'> & {
    payload: CT.UUID
}

export type PrependCardidToListidAction = Action<'@listidCardids/prependCardid'> & {
    payload: CT.ListidCardid
}

export type AppendCardidToListidAction = Action<'@listidCardids/appendCardid'> & {
    payload: CT.ListidCardid
}

export type RemoveCardidFromListidAction = Action<'@listidCardids/removeCardid'> & {
    payload: CT.ListidCardid
}

export type Actions = SetListidCardids | RemoveListAction | PrependCardidToListidAction | AppendCardidToListidAction | RemoveCardidFromListidAction

 

src\store\listidCardidOrders\actions.ts

import type * as T from './types'

export const setListidCardids = (payload: T.ListidCardidS): T.SetListidCardids => ({
    type: '@listidCardids/set',
    payload
})

export const removeListid = (payload: string): T.RemoveListAction => ({
    type: '@listidCardids/remove',
    payload
})

export const prependCardidToListid = (payload: T.ListidCardid): T.PrependCardidToListidAction => ({
    type: '@listidCardids/prependCardid',
    payload
})

export const appendCardidToListid = (payload: T.ListidCardid): T.AppendCardidToListidAction => ({
    type: '@listidCardids/appendCardid',
    payload
})

export const removeCardIdfromListId = (payload: T.ListidCardid): T.RemoveCardidFromListidAction => ({
    type: '@listidCardids/removeCardid',
    payload
})

 

먼저 @listidCardids/set 액션은 목록에 추가할 카드 uuid의 순서를 목록 uuid에 한꺼번에 설정하려고 사용합니다.

카드 uuid 배열을 일괄 설정하기
state[목록_uuid] = [카드_uuid]

 

@listidCardids/remove 액션은 lists에서 특정 목록이 삭제되면 listidCardids 쪽에서도 이에 맞추어 state[삭제된_리스트_uuid]부분을 삭제해 메모리가 낭비되지 않게 합니다.

@listidCardids/prependCardid 액션은 다음 코드 형태로 특정 목록이 이미 가지고 있는 카드 uuid들 앞에 새로운 카드 uuid를 삽입하는 용도입니다.

const cardIds = state[리스트_uuid]      // 기존 카드_uuid들
return {...state, [리스트_uuid]: [새_카드_uuid, ...cardIds]}

 

@listidCardids/appendCardid 액션은 다음 코드 형태로 특정 목록이 이미 가지고있는 uuid들 맨 뒤에 새로운 카드 uuid를 삽입하는 용도입니다.

const cardIds = state[리스트_uuid]   // 기존 카드_uuid들
return {...state, [리스트_uuid]: [...cardIds, 새_카드_uuid,]}

 

@listidCardids/removeCardid 액션은 cards쪽에서 특정 카드가 삭제될 때 이에 맞추어 카드가 속해 있는 목록의 카드 순서를 기억하는 배열에서도 삭제된 카드의 uuid를 지우는 용도입니다.

const cardIds = state[리스트_uuid]
return {
     ...state,
     [action.payload.listid]: cards.filter(id => id !=== 제거할카드_uuid)
}

 

예시)
만약 state가 다음과 같고:
const state = { list1: ['uuid1', 'uuid2'], list2: ['uuid4'] };
리스트_uuid가 'list1'이고,
새_카드_uuid가 'uuid3'라면: cardIds는 ['uuid1', 'uuid2']가 되고,
[새_카드_uuid, ...cardIds]는 ['uuid3', 'uuid1', 'uuid2']가 됩니다.
최종적으로 반환되는 객체는 다음과 같습니다:
{ list1: ['uuid3', 'uuid1', 'uuid2'], list2: ['uuid4'] }

키 : value라는것..

 

위 내용을 토대로 reducer를 구현해봅시다.

 

src\store\listidCardidOrders\reducers.ts

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':
      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 { useCallback } from "react";
import { useDispatch, useSelector } from "react-redux";
import type { AppState } from "../store";
import type { List } from "../store/commonTypes";
import * as LO from '../store/listidOrders'
import * as L from '../store/listEntities';
import * as C from '../store/cardEntities';
import * as LC from '../store/listidCardidOrders';

export const useLists = () => {
    const dispatch = useDispatch()

    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]
    )

    return {lists, onCreateList, onRemoveList}
}

 

리스트가 지워지면 카드도 지워지도록 추가하구.. 추가도 하면 추가되게..

 

그리고 

useCards 커스텀 훅 만들기

앞서 커스텀훅을 만든것처럼 이것도 제작해보겠습니다.

 

src\store\useCards.ts

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}
}

 

이제 적용해봅시다

 

src\pages\BoardList\index.tsx

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

실행결과창