Scrum Toolkit 작성 #2 - 클라이언트용 React, TypeScript 및 Websocket 설정

Scrum Toolkit의 진행 상황에 대한 마지막 기사를 쓴 지 오래되었습니다. 😀 오늘은 제가 만든 클라이언트의 설정을 보여드리겠습니다. 응용 프로그램은 TypeScript를 사용하여 React로 작성되었습니다. 백엔드와의 통신은 Websocket 전송과 함께 Socket.io를 통해 이루어집니다.

애플리케이션은 글로벌 앱 스토어에 Redux를 사용하고 있습니다. react-router를 통해 경로를 일치시키고 카드 드래그 앤 드롭에 react-dnd를 사용합니다. 따라서 index.tsx에서 모든 것을 함께 설정합니다.

root.render(
  <React.StrictMode>
    <BrowserRouter>
      <Provider store={store}>
        <DndProvider backend={HTML5Backend}>
          <App />
        </DndProvider>
      </Provider>
    </BrowserRouter>
  </React.StrictMode>,
);


redux의 앱 스토어는 카드, 사용자, 투표 및 보드의 네 가지 주요 엔터티로 구성됩니다. 보드는 중심점이며 사용자는 보드마다 있습니다. 각 보드는 여러 사용자와 카드를 처리할 수 있습니다. 카드는 한 명의 사용자만 작성할 수 있으며 동일하거나 다른 사용자로부터 여러 표를 받을 수 있습니다.

// cards state
export type CardsState = Array<RawCard>;

// config state
export type ConfigState = {
  localUser: RawUser;
  board: {
    boardId: string;
    stage: number;
    timerTo: number;
    maxVotes: number;
    mode: string;
  };
  users: Array<RawUser>;
  socket: Socket | null;
};


원시 엔티티는 다음과 같습니다.

export type RawVote = {
  id: string;
  userId: string;
};

export type RawUser = {
  id: string;
  nickname: string;
  avatar: number;
  isReady: boolean;
  selectedPlanningCard: number;
};

export type RawCard = {
  id: string;
  stackedOn: string;
  content: string;
  userId: string;
  column: number;
  votes: RawVote[];
  createdAt: number;
};


API와의 통신은 socket.io로 합니다. "Socket Manager"를 사용하여 연결, 핸들러 등록 및 소켓 핸들러 관리를 위한 사용자 지정 후크를 작성했습니다.


export type SocketHook = {
  connect: (nickname: string, avatar: number, boardId: string) => void;
  socket: Socket<IncomingEvents, OutgoingEvents> | null;
};

export function useSocket(): SocketHook {
  const socket = useAppSelector((state) => state.config.socket);
  const dispatch = useAppDispatch();
  const navigate = useNavigate();

  function connect(nickname: string, avatar: number, boardId: string) {
    if (socket?.connected) {
      socket.disconnect();
    }

    const newSocket: Socket<IncomingEvents, OutgoingEvents> = io('http://localhost:3001', { transports: ['websocket', 'polling'] });

    newSocket.on('connect', () => {
      newSocket.emit('Join', {
        nickname,
        boardId,
        avatar,
      });

      dispatch({
        type: actions.config.SetNickname,
        payload: {
          nickname,
        },
      });

      dispatch({
        type: actions.config.SetBoardId,
        payload: {
          boardId,
        },
      });
    });

    registerUsersHandlers(newSocket, dispatch, navigate);
    registerBoardsHandlers(newSocket, dispatch);
    registerCardsHandlers(newSocket, dispatch);

    dispatch({
      type: actions.config.SetSocket,
      payload: {
        socket: newSocket,
      },
    });
  }

  return { connect, socket };
}


모든 핸들러는 특정 이벤트에 대한 리스너를 등록하는 소켓을 허용합니다. 이 접근 방식 덕분에 여러 이벤트를 쉽게 관리할 수 있습니다. 클라이언트는 들어오는 이벤트를 감속기에 전달하여 이벤트에 응답합니다.

import { Socket } from 'socket.io-client';
import { IncomingEvents, OutgoingEvents } from './events';
import { RootDispatch } from '../utils/store';
import actions from '../actions';

function registerCardsHandlers(
  socket: Socket<IncomingEvents, OutgoingEvents>,
  dispatch: RootDispatch,
) {
  socket.on('CardState', (data) => {
    dispatch({
      type: actions.cards.SetOneCard,
      payload: {
        card: data.card,
      },
    });
  });

  // ...
}

export default registerCardsHandlers;


보드는 보드에 대한 모든 공통 논리를 보유하는 간단한 컨테이너 구성 요소입니다. 보드 모드에 따라 Retro 또는 Planning 보기를 열 수 있습니다.

초기 로드 시 앱은 후크를 사용하여 로컬 저장소에서 닉네임과 아바타를 가져오려고 합니다. 실패하면 사용자의 닉네임을 생성하고 임의의 아바타를 선택합니다. 두 정보는 나중에 변경할 수 있습니다.


function Board() {
  const { id } = useParams<{ id: string }>();
  const navigate = useNavigate();
  const [isNavbarOpen, setIsNavbarOpen] = useState(false);
  const socketController = useSocket();

  const [nickname, setNickname] = useLocalStorage<string>(
    'nickname',
    `Guest${Math.floor(Math.random() * 10000)}`,
  );

  const [avatar, setAvatar] = useLocalStorage<number>(
    'avatar',
    Math.floor(Math.random() * 89),
  );

  useEffect(() => {
    if (!socketController.socket?.connected) {
      if (!id) navigate('/');

      socketController.connect(nickname, avatar, id || '');
    }

    return () => {
      socketController.socket?.disconnect();
    };
  }, []);

  const localUser = useAppSelector((state) => state.config.localUser);
  const board = useAppSelector((state) => state.config.board);

  const [isUserModalOpen, setIsUserModalOpen] = useState(false);
  const [userModalNickname, setUserModalNickname] = useState('');
  const [userModalAvatar, setUserModalAvatar] = useState(0);

  const handleUserModalOpen = () => {
    setUserModalNickname(localUser.nickname);
    setUserModalAvatar(localUser.avatar);
    setIsUserModalOpen(true);
  };

  const handleUserModalSave = () => {
    if (!userModalNickname) return;

    socketController.socket?.emit('ChangeUserData', {
      nickname: userModalNickname,
      avatar: userModalAvatar,
    });
    setNickname(userModalNickname);
    setAvatar(userModalAvatar);
    setIsUserModalOpen(false);
  };

  return (
    <div>
      <Sidebar
        isOpen={isNavbarOpen}
        onSidebarToggleClick={() => setIsNavbarOpen(!isNavbarOpen)}
        onChangeUserData={handleUserModalOpen}
      />
      {board.mode === 'retro' && <Retro />}
      {(board.mode === 'planning_hidden' ||
        board.mode === 'planning_revealed') && <Planning />}

      <UserModal
        isOpen={isUserModalOpen}
        avatar={userModalAvatar}
        nickname={userModalNickname}
        onSave={handleUserModalSave}
        onChangeAvatar={setUserModalAvatar}
        onChangeNickname={setUserModalNickname}
        onClose={() => setIsUserModalOpen(false)}
      />
    </div>
  );
}

export default Board;


레트로 보기는 다른 유형의 카드를 세 개의 열로 표시합니다. 첫 번째 단계에서는 자신의 카드만 볼 수 있고, 두 번째 단계에서는 모든 카드를 볼 수 있지만 자신의 투표만 볼 수 있으며, 세 번째 단계에서는 모든 카드, 모든 투표 및 세 번째 열을 볼 수 있습니다. 이 접근 방식은 작업을 작성하거나 투표하는 동안 사용자가 서로를 추측하거나 제안하는 것을 방지합니다.

카드는 쌓일 수 있으므로 렌더링할 때 다른 카드에 의존하는 모든 카드를 필터링해야 합니다(스택의 중간 또는 맨 아래에 있음). 다음은 카드 상태, CRUD 작업, 업보팅, 다운보팅, 스태킹, 언스태킹 등을 조작하는 모든 핸들러입니다.


const getCardsStack = (firstCardId: string, allCards: Array<RawCard>) => {
  const cardsStack: Array<RawCard> = [];

  let cardOnTopOfStack = allCards.find((card) => card.id === firstCardId);
  while (cardOnTopOfStack && cardOnTopOfStack.stackedOn !== '') {
    cardOnTopOfStack = allCards.find(
      // eslint-disable-next-line no-loop-func
      (card) => card.id === cardOnTopOfStack?.stackedOn,
    );
    if (cardOnTopOfStack) cardsStack.push(cardOnTopOfStack);
  }
  return cardsStack;
};

const getVotes = (
  card: RawCard,
  allCards: Array<RawCard>,
  boardStage: number,
  localUserId: string,
) => {
  let votesCount = card.votes.length;

  if (boardStage === 1) {
    votesCount = card.votes.filter(
      (vote) => vote.userId === localUserId,
    ).length;
  }

  if (card.stackedOn) {
    const stack = getCardsStack(card.id, allCards);

    if (boardStage === 1) {
      for (let i = 0; i < stack.length; i++) {
        const item = stack[i];
        votesCount += item.votes.filter(
          (vote) => vote.userId === localUserId,
        ).length;
      }
    } else {
      for (let i = 0; i < stack.length; i++) {
        votesCount += stack[i].votes.length;
      }
    }
  }

  return votesCount;
};

// ...

const cards = useAppSelector((state) => state.cards);
  const board = useAppSelector((state) => state.config.board);
  const localUser = useAppSelector((state) => state.config.localUser);

  const socketController = useSocket();

  const handleCardGroup = (cardId: string, stackedOn: string) => {
    socketController.socket?.emit('GroupCards', { cardId, stackedOn });
  };

// ...

{(!isMobile || selectedColumn === 0) && (
            <List
              id={0}
              type="positive"
              columnWidth={columnWidth}
              selectedColumn={selectedColumn}
              onChangeColumn={setSelectedColumn}
            >
              {cards
                .filter(
                  (card) =>
                    card.column === 0 &&
                    !cards.some(
                      (nestedCard) => nestedCard.stackedOn === card.id,
                    ),
                )
                .filter(
                  (card) => board.stage !== 0 || card.userId === localUser.id,
                )
                .sort((a, b) => {
                  if (board.stage !== 2) {
                    return b.createdAt - a.createdAt;
                  }
                  return b.votes.length - a.votes.length;
                })
                .map((card) => {
                  const votesCount = getVotes(
                    card,
                    cards,
                    board.stage,
                    localUser.id,
                  );

                  return (
                    <Card
                      key={card.id}
                      id={card.id}
                      content={card.content}
                      onDecreaseVote={() => handleDownvote(card.id)}
                      votesCount={votesCount}
                      onDelete={() => handleCardDelete(card.id)}
                      onEdit={() => handleCardEdit(card.id, card.content)}
                      onGroup={handleCardGroup}
                      onUngroup={handleCardUngroup}
                      onIncreaseVote={() => handleUpvote(card.id)}
                      stack={!!card.stackedOn}
                      displayVotes={board.stage !== 0}
                      color="success"
                      createdAt={card.createdAt}
                    />
                  );
                })}
            </List>
          )}
// ...


심판으로 각 카드 레지스터 드래그 앤 드롭. 불투명도와 테두리를 약간 변경하여 드래그 중이거나 끝났음을 나타냅니다. 쌓인 카드는 데크에 불규칙하게 뒤섞인 실제 카드처럼 보이도록 배치됩니다.

카드에 대한 명성은 콘텐츠에서 항상 "명성"이라는 단어를 찾아 수행됩니다. 표시되면 배경이 애니메이션 밈 GIF로 변경됩니다. 이를 통해 보드는 의식 중에 더욱 매력적이고 흥미롭게 보입니다.

// ..

  const [{ isDragging }, drag] = useDrag(() => ({
    type: 'card',
    item: {
      id,
    },
    collect: (monitor) => ({
      isDragging: monitor.isDragging(),
    }),
  }));

  const [{ isOver }, drop] = useDrop(() => ({
    accept: 'card',
    drop: (item: { id: string }) => {
      onGroup(item.id, id);
    },
    collect: (monitor) => ({
      isOver: monitor.isOver(),
    }),
  }));

  const isKudos = content.toLowerCase().indexOf('kudos') > -1;
  const kudosHash = createdAt % 32;
  const kudosImage = `/kudos/q${kudosHash}.gif`;
  const kudosStyles = isKudos
    ? {
        backgroundImage: `url(${kudosImage})`,
        backgroundSize: 'cover',
        backgroundPosition: 'center',
      }
    : {};
  const cardColor =
    color === 'success' && !isKudos ? 'text-black' : 'text-white';

// ...

export default Card;


계획 보기에는 피보나치 수열에 관한 카드 세트가 표시되며, 다음 카드는 이전 두 카드의 합입니다. 두 개의 추가 카드는 "추산 방법을 모릅니다"와 "예식이 너무 깁니다"를 의미합니다. 카드를 선택하면 자동으로 사용자 상태가 준비됨으로 변경되어 다른 사람들이 귀하가 카드를 선택하고 공개할 준비가 되었음을 알립니다.

상단에 공개한 후 번호가 매겨진 카드의 평균과 PS4용 "Knowledge is Power"게임에서 영감을 받은 작은 프롬프트를 볼 수 있습니다. 선택한 카드는 약간의 애니메이션으로 표시되므로 무엇을 선택했는지 알 수 있고 보드를 더욱 역동적으로 만들 수 있습니다.

function Planning() {
  const socketController = useSocket();

  const localUser = useAppSelector((state) => state.config.localUser);
  const board = useAppSelector((state) => state.config.board);
  const users = useAppSelector((state) => state.config.users);

  const handleSetSelectPlanningCard = (selectedPlanningCard: number) => {
    socketController.socket?.emit('SetSelectedPlanningCard', {
      selectedPlanningCard,
    });
  };

// ...

  const cardsMap: Array<{
    number: number | undefined;
    icon: 'not sure' | 'break pls' | undefined;
  }> = [
    { number: 0, icon: undefined },
    { number: 1, icon: undefined },
    // ...
    { number: undefined, icon: 'not sure' },
    { number: undefined, icon: 'break pls' },
  ];

  const userVotes = users.filter((user) => user.selectedPlanningCard !== 0);

  const userVotesWithNumbers = userVotes.filter(
    (user) =>
      user.selectedPlanningCard !== 11 && user.selectedPlanningCard !== 12,
  );

  const sum = userVotesWithNumbers.reduce(
    (acc, user) => acc + (cardsMap[user.selectedPlanningCard].number || 0),
    0,
  );

  const average = Number((sum / (userVotesWithNumbers.length || 1)).toFixed(1));

  const comments = [
    'The voting is over.',
    'How did our players vote?',
    // ...
    'Time to check the valuation!',
  ];

  return (
    <ShiftedContent>
      <div className="vh-100 w-100 bg-planning overflow-y-auto">
        <div className="container d-flex align-items-center">
          <div className="row m-0 w-100">
            <div className="mt-5 col-12 col-lg-8 offset-lg-2 ">
              {board.mode === 'planning_hidden' && (
                <div className="d-flex flex-row flex-wrap justify-content-center">
                  {cardsMap
                    .filter((card) => card.number !== 0)
                    .map((card, index) => (
                      <PlanningCard
                        key={card.number}
                        number={card.number}
                        icon={card.icon}
                        selected={localUser.selectedPlanningCard === index + 1}
                        onClick={() => handleSetSelectPlanningCard(index + 1)}
                      />
                    ))}
                </div>
              )}
              {board.mode === 'planning_revealed' && (
                <div>
                  <div className="small text-white text-center">
                    {
                      comments[
                        (userVotesWithNumbers.length + sum + users.length) %
                          comments.length
                      ]
                    }
                  </div>
                  <h1 className="text-white text-center">{average}</h1>
                  <div className="d-flex flex-row flex-wrap justify-content-center">
                    {userVotes.map((user) => (
                      <PlanningCard
                        key={user.nickname}
                        number={cardsMap[user.selectedPlanningCard].number}
                        icon={cardsMap[user.selectedPlanningCard].icon}
                        voter={user.nickname}
                      />
                    ))}
                  </div>
                </div>
              )}
            </div>
            <div className="my-3 col-12 d-flex align-items-center justify-content-center">
              <button
                onClick={handleResetPlanning}
                type="button"
                className="btn btn-primary"
                disabled={board.mode === 'planning_hidden'}
              >
                Reset
              </button>
              <button
                onClick={handleRevealPlanning}
                type="button"
                className="ms-3 btn btn-success"
                disabled={board.mode === 'planning_revealed'}
              >
                Reveal
              </button>
            </div>
          </div>
        </div>
      </div>
    </ShiftedContent>
  );
}

export default Planning;


클라이언트의 마지막 부분은 사이드바입니다. 미래의 타임스탬프로 타이머를 설정하고 준비 상태를 전환하고 사용자 모달을 열고 다른 참가자를 볼 수 있습니다. 사이드바는 넓고 열리거나 좁고 닫힐 수 있습니다.

// ...
  const users = useAppSelector((state) => state.config.users);
  const board = useAppSelector((state) => state.config.board);
  const localUser = useAppSelector((state) => state.config.localUser);

  const socketController = useSocket();

  const handleNextStage = () => {
    if (board.stage < 2) {
      socketController.socket?.emit('SetStage', {
        stage: board.stage + 1,
      });
    }
  };

  const handlePreviousStage = () => {
    if (board.stage > 0) {
      socketController.socket?.emit('SetStage', {
        stage: board.stage - 1,
      });
    }
  };

  const handleToggleReady = () => {
    socketController.socket?.emit('ToggleReady');
  };

  const handleChangeMaxVotes = (maxVotes: number) => {
    socketController.socket?.emit('SetMaxVotes', {
      maxVotes,
    });
  };

  const handleSetTimer = (duration: number) => {
    socketController.socket?.emit('SetTimer', {
      duration,
    });
  };

  const handleSetBoardMode = () => {
    socketController.socket?.emit('SetBoardMode', {
      mode: board.mode === 'retro' ? 'planning_hidden' : 'retro',
    });
  };

  const timerTo = useAppSelector((state) => state.config.board.timerTo);
  const [timer, setTimer] = useState('');

  const getDiffFormat = (diff: number) =>
    dayjs(dayjs(diff).diff(dayjs())).format('m:ss');

  useEffect(() => {
    setTimer(getDiffFormat(board.timerTo));

    const intervalHandler = setInterval(() => {
      setTimer(getDiffFormat(board.timerTo));
    }, 500);

    return () => {
      clearInterval(intervalHandler);
    };
  }, [timerTo]);

  const ref = useRef(null);
  useOnClickOutside(ref, onSidebarToggleClick);


// ...


그것은 도구의 클라이언트 측면에 관한 거의 모든 것입니다. 다음 부분은 TypeScript 및 TypeORM을 사용하여 Node.js에서 WebSocket을 설정하는 방법입니다. 안녕히 계세요:)

좋은 웹페이지 즐겨찾기