DEV Community

Cover image for Writing Scrum Toolkit #2 - React, TypeScript & Websocket Setup for Client
Meat Boy
Meat Boy

Posted on

Writing Scrum Toolkit #2 - React, TypeScript & Websocket Setup for Client

It's a while since I wrote the last article about progress with Scrum Toolkit. 😀 Today I'm going to show you the setup for the client I made. Application it's written in React using TypeScript. Communication with the backend is done via Socket.io with Websocket transportation.

Application is using Redux for global app store. It's matching paths via react-router and using react-dnd for card drag and drop. So setup of everything together in index.tsx:

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

App store in redux consists of four main entities: cards, users, votes and board. Board is the centre point and the user is per board. Each board can handle multiple users and cards. The card may be written by only one user and have multiple votes from the same or different users.

// 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;
};
Enter fullscreen mode Exit fullscreen mode

Where raw entities are:

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;
};
Enter fullscreen mode Exit fullscreen mode

Communication with API is done with socket.io. I wrote a custom hook to connect, register handlers and manage socket handlers with "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 };
}
Enter fullscreen mode Exit fullscreen mode

Every handler accepts a socket where register listeners for specific events. Thanks to this approach are easy to maintain multiple events. The client responds to events by dispatching the incoming event to reducers.

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;
Enter fullscreen mode Exit fullscreen mode

Board is a simple container component that holds all the common logic for the board. Depending on board mode can open Retro or Planning view.

On initial load, the app is trying to get a nickname and avatar from local storage using a hook. If it fails, then generate a nickname for the user and pick a random avatar. Both information can be changed later.


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;
Enter fullscreen mode Exit fullscreen mode

The retro view displays in three columns cards of a different types. In the first stage, only own cards are visible, on the second all cards but only own votes and in the third stage all cards, all votes and the third column. This approach prevents users from preassuming or suggesting each other during writing tasks or voting.

Cards can be stacked, so when rendering we have to filter out all cards that are dependent on others (are in the middle or bottom of the stack). Here are all the handlers to manipulate card state, CRUD operations, upvoting, downvoting, stacking, unstacking etc.


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>
          )}
// ...
Enter fullscreen mode Exit fullscreen mode

Each card register drag and drop with ref. They change opacity and border slightly to indicate it's being a drag or over. Stacked cards are positioned to look like physical cards messed irregularly on deck.

Kudos on cards are done by looking for the word "kudos" whenever in the content. If it appears, then the background is changed to an animated meme gif. With this, the board look more engaging and interesting during the ceremony.

// ..

  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;
Enter fullscreen mode Exit fullscreen mode

The planning view displays a set of cards regarding the Fibonacci sequence, where the next card is the sum of two previous. Two additional cards mean "I don't know how to estimate" and "ceremony is too long". After selecting a card it's automatically changing the user state to ready so others know you choose the card and you are ready to reveal.

After revealing on the top you can see the average from numbered cards and a small prompt inspired by the "Knowledge is Power" game for PS4. The selected card is a little animated so you know what you choose and make the board more dynamic.

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;
Enter fullscreen mode Exit fullscreen mode

Final part of the client is sidebar. You can set timer to timestamp in the future, you can toggle your ready status, open user modal and see other participants. Sidebar can be wide and open or narrow and close.

// ...
  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);


// ...
Enter fullscreen mode Exit fullscreen mode

That's pretty much everything regarding the client aspect of the tool. The next part will be about the setup of the WebSocket in Node.js with TypeScript and TypeORM. Bye:)

Latest comments (0)