Archive

[React] 칸반보드 Kanban Board Drag & Drop (Recoil, TypeScript, React DnD)

manon_e 2022. 3. 23. 14:31
반응형

 

 

 

     


     

    Kanban Board

     

     

    Drag & Drop 

     

     

     

     

    kanban card 추가, 수정, 삭제 

     

     

     

     

     

     

     

     


     

     

     

     

     

     

    kanban 기본틀 

     

     

     

     

     

     

    kanban의 기본구성은 'To do' 'In progress' 'Done' 'Notes & Reference' 4개의 list로 구성한다.

    Add task 버튼으로 card를 생성하고, card는 수정과 삭제가 가능하며 list간 drag,drop으로 이동이 가능하게 구현한다.

     

     

     

     

     

     

     

     

    Recoil 기본setting

     

    https://recoiljs.org/ko/docs/basic-tutorial/atoms

     

    Atoms | Recoil

    Atoms는 애플리케이션 상태의 source of truth를 갖는다. todo 리스트에서 source of truth는 todo 아이템을 나타내는 객체로 이루어진 배열이 될 것이다.

    recoiljs.org

     

     

    Recoil 공식사이트에 간단한 todo list를 만들면서 기본적인 기능들을 써볼 수 있도록 코드가 상세하게 나와있다.

    todo list에 기능을 더하여 kanban board를 구현하였다.

     

    redux에 비하면 recoil은 말도안되게 간단하게 상태관리를 할 수 있었다.

    key값과 default값만 설정해주면 간편하게 전역state 생성이 가능하며,

    useState를 사용하는 것 처럼 useRecoilState로 state사용이 가능하다.

     

    //recoil/index.tsx
    import { atom } from 'recoil';
    
    export const kanbanListState = atom<cardtype[]>({
      key: 'kanbanState',
      default: [],
    });
      const [list, setList] = useRecoilState(kanbanListState);

     

     

     

     

     

    kanban 기본틀 구현

     

    kanban에 입력한 데이터를 map으로 돌려서 list title name ('To do'등 4개의 category)과 category가 일치하는

    데이터를 card component에 넣어서 UI에 출력되게 한다.

    //App.tsx
      const kanbanList = useRecoilValue(kanbanListState);
      const titleName = [
        { id: 1, title: 'To do' },
        { id: 2, title: 'In progress' },
        { id: 3, title: 'Done' },
        { id: 4, title: 'Notes & Reference' },
      ];
    
      const cardDataHandler = (cardTitle: string) => {
        return kanbanList
          .filter((data) => data.category === cardTitle)
          .map((item, index) => <Card key={item.id} item={item} />);
      };
    
      return (
        <>
          <header>
            <span className="title">KANBAN BOARD</span>
          </header>
          <section className="kanbanListContainer">
              {titleName.map((data: any) => (
                <KanbanList title={`${data.title}`}>
                  {cardDataHandler(data.title)}
                </KanbanList>
              ))}
          </section>
        </>
      );
    }

     

     

    KanbanList 

    : 총4개의 list 생성

    export default function KanbanList({ title, children }) {
      return (
        <>
          <div className="kanbanListWrap" ref={drop}>
            <div className="kanbanTitle">{title}</div>
            {children}
            <KanbanCreator title={title} />
          </div>
        </>
      );
    }

     

     

    KanbanCreator component

    : Add card button을 클릭해서 새로운 카드를 생성

    export default function KanbanCreator({ title }: { title: string }) {
      const [kanbanList, setKanbanList] = useRecoilState(kanbanListState);
    
      const getId: number =
        kanbanList.length > 0 ? kanbanList[kanbanList.length - 1].id + 1 : 0;
    
      const addCard = useCallback(
        (e) => {
          setKanbanList((prev) => [
            ...prev,
            {
              id: getId,
              title: '',
              content: '',
              category: title,
            },
          ]);
        },
        [getId, setKanbanList, title]
      );
    
      return (
        <div className="addBtnWrap">
          <button className="cardAddBtn" onClick={addCard}>
            + Add task
          </button>
        </div>
      );
    }

     

     

    Card component

    : edit(title, content) , delete 기능

       content의 textarea에 text양에따라 height 동적으로 커지게 handleResizeHeight 함수적용

    function Card({ item }: { item: cardtype }) {
      const [list, setList] = useRecoilState(kanbanListState);
      const index = list.findIndex((data) => data === item);
      const ref = useRef<HTMLTextAreaElement>(null);
    
      const replaceIndex = (list: cardtype[], index: number, data: cardtype) => {
        return [...list.slice(0, index), data, ...list.slice(index + 1)];
      };
    
      const editTitle = (e: React.ChangeEvent<HTMLInputElement>) => {
        const newList = replaceIndex(list, index, {
          ...item,
          title: e.target.value,
        });
        setList(newList);
      };
      const editText = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
        const newList = replaceIndex(list, index, {
          ...item,
          content: e.target.value,
        });
        setList(newList);
      };
    
      const handleResizeHeight = useCallback(() => {
        if (ref === null || ref.current === null) {
          return;
        }
        ref.current.style.height = '70px';
        ref.current.style.height = ref.current.scrollHeight + 'px';
      }, []);
    
      const deleteItem = () => {
        setList([...list.slice(0, index), ...list.slice(index + 1)]);
      };
      
      return (
        <div
          className="cardWrap"
        >
          <div className="cardHeaderWrap">
            <span
              className="cardTitleBadge"
              style={{ backgroundColor: badgeColor }}
            >
              {item.category}
            </span>
            <img
              className="deleteimg"
              src="images/cancel.png"
              alt="delete"
              onClick={deleteItem}
            />
          </div>
          <input
            className="cardTitle"
            type="text"
            value={item.title}
            onChange={editTitle}
            placeholder="제목을 입력하세요"
          />
          <textarea
            className="cardContent"
            value={item.content}
            onChange={editText}
            onInput={handleResizeHeight}
            ref={ref}
            placeholder="내용을 입력하세요"
            spellCheck="false"
          />
        </div>
      );
    }

     

     

     

     

     

     


     

     

    반응형

     

     

     

     

     

     

     

    React DnD - Drag

     

    yarn add react-dnd react-dnd-html5-backend
    // OR
    npm install react-dnd react-dnd-html5-backend

     

     

    Drag하고 Drop할 컴포넌트 상위를

    <DndProvider backend={HTML5Backend}> 로 감싸준다.

    function App() {
    
      return (
        <>
    		...
            <DndProvider backend={HTML5Backend}>
              {titleName.map((data: any) => (
                <KanbanList title={`${data.title}`}>
                  {cardDataHandler(data.title)}
                </KanbanList>
              ))}
            </DndProvider>
    		...
        </>
      );
    }

     

     

    Drag할 Card component에 useDrag를 추가한다.

    Ref로 카드를 지정하고 drag가 완료되었을 때 drop된 list의 카테고리로 drag한 카드의 category state를 변경시켜준다.

    //드래그가 완료되면 drag된 카드가 drop한 card의 카테고리로 변경되는 함수.
     const changeItemCategory = (selectedItem: cardtype, title: string) => {
        setList((prev) => {
          return prev.map((e) => {
            return {
              ...e,
              category: e.id === selectedItem.id ? title : e.category,
            };
          });
        });
      };
    
      const [{ isDragging }, dragRef] = useDrag(() => ({
        //isDragging : card가 드래깅중일때 true, 아닐때 false값을 리턴한다.
        //dragRef : 드래그될 부분에 ref 적용해준다
        type: 'card',
        item: item,
        //드래그될 card에 넣어질 정보
        
        collect: (monitor) => ({
          isDragging: monitor.isDragging(),
        }),
        // 현재 드래깅중인지 리턴
        
        end: (item: cardtype, monitor) => {
        //드래그가 끝났을때 작동할 코드
          const dropResult: drop | null = monitor.getDropResult();
          if (dropResult) {
            switch (dropResult.name) {
              case TO_DO:
                changeItemCategory(item, TO_DO);
                break;
              case IN_PROGRESS:
                changeItemCategory(item, IN_PROGRESS);
                break;
              case DONE:
                changeItemCategory(item, DONE);
                break;
              case NOTE:
                changeItemCategory(item, NOTE);
                break;
            }
          }
        },
      }));
    
      return (
        <div
          className="cardWrap"
          //Ref지정
          ref={dragRef}
          //드래그중일 때 card의 투명도를 30%로 한다.
          style={{ opacity: isDragging ? '0.3' : '1' }}
        >

     

     

     

     

     


     

     

     

     

     

    React DnD - Drop

     

     

    Drop되는 kanbanList에 useDrop을 추가한다.

    위에 Card component에 추가해준 useDrag의 type과 useDrop의 accept가 같아야한다.

      const [{ canDrop, isOver }, drop] = useDrop({
        accept: 'card',
        // useDrag의 type과 같아야한다.
        drop: () => ({ name: title }),
        collect: (monitor) => ({
          isOver: monitor.isOver(),
          canDrop: monitor.canDrop(),
          //drag진행동안 canDrop : true
          //drop할 영역 접근시 isOver : true
        }),
      });
    
      return (
        <>
          <div className="kanbanListWrap" ref={drop}>
            <div className="kanbanTitle">{title}</div>
            {children}
            <KanbanCreator title={title} />
          </div>
        </>
      );
    }

     

     

     

     

     

     

     

     


     

     

     

     

     

     

     

    코드 & 참고사이트

     

     

    https://github.com/manonkim/kanban-Board

     

    GitHub - manonkim/kanban-Board

    Contribute to manonkim/kanban-Board development by creating an account on GitHub.

    github.com

     

     

     

    https://ichi.pro/ko/yeje-ui-react-dnd-226845595632541

     

    예제의 React DnD

    React DnD는 복잡한 드래그 앤 드롭 인터페이스를 구축하는 데 도움이되는 React 유틸리티 세트입니다. 자체 칸반 보드를 구현하여 기본 기능을 살펴 보겠습니다.

    ichi.pro

     

     

     

     

     

     

     

     

    반응형