hexlet-codebattle/codebattle

View on GitHub
services/app/apps/codebattle/assets/js/widgets/pages/lobby/LobbyWidget.jsx

Summary

Maintainability
F
3 days
Test Coverage
import React, {
  memo,
  useState,
  useRef,
  useEffect,
  useCallback,
} from 'react';

import cn from 'classnames';
import Gon from 'gon';
import find from 'lodash/find';
import groupBy from 'lodash/groupBy';
import isEmpty from 'lodash/isEmpty';
import orderBy from 'lodash/orderBy';
import sortBy from 'lodash/sortBy';
import moment from 'moment';
import Modal from 'react-bootstrap/Modal';
import OverlayTrigger from 'react-bootstrap/OverlayTrigger';
import Tooltip from 'react-bootstrap/Tooltip';
import { useDispatch, useSelector } from 'react-redux';

import GameLevelBadge from '../../components/GameLevelBadge';
import HorizontalScrollControls from '../../components/SideScrollControls';
import UserInfo from '../../components/UserInfo';
import gameStateCodes from '../../config/gameStateCodes';
import hashLinkNames from '../../config/hashLinkNames';
import levelRatio from '../../config/levelRatio';
import * as lobbyMiddlewares from '../../middlewares/Lobby';
import * as selectors from '../../selectors';
import { actions } from '../../slices';
import { getLobbyUrl, makeGameUrl } from '../../utils/urlBuilders';

import Announcement from './Announcement';
import ChatActionModal from './ChatActionModal';
import CompletedGames from './CompletedGames';
import CreateGameDialog from './CreateGameDialog';
import GameActionButton from './GameActionButton';
import GameCard from './GameCard';
import GameProgressBar from './GameProgressBar';
import GameStateBadge from './GameStateBadge';
import Leaderboard from './Leaderboard';
import LobbyChat from './LobbyChat';
import ShowButton from './ShowButton';
import TournamentCard from './TournamentCard';

const isActiveGame = game => [gameStateCodes.playing, gameStateCodes.waitingOpponent].includes(game.state);

const Players = memo(({ players, isBot, gameId }) => {
  if (players.length === 1) {
    const badgeClassName = cn('badge badge-pill ml-2', {
      'badge-secondary': isBot,
      'badge-warning text-white': !isBot,
    });
    const tooltipId = `tooltip-${gameId}-${players[0].id}`;
    const tooltipInfo = isBot
      ? 'No points are awarded - Only for games with other players'
      : 'Points are awarded for winning this game';

    return (
      <td className="p-3 align-middle text-nowrap" colSpan={2}>
        <div className="d-flex align-items-center">
          <UserInfo user={players[0]} />
          <OverlayTrigger
            overlay={<Tooltip id={tooltipId}>{tooltipInfo}</Tooltip>}
            placement="right"
          >
            <span className={badgeClassName}>
              {isBot ? 'No rating' : 'Rating'}
            </span>
          </OverlayTrigger>
        </div>
      </td>
    );
  }

  return (
    <>
      <td className="p-3 align-middle text-nowrap cb-username-td text-truncate">
        <div className="d-flex flex-column position-relative">
          <UserInfo
            user={players[0]}
            hideOnlineIndicator
            loading={players[0].checkResult.status === 'started'}
          />
          <GameProgressBar player={players[0]} position="left" />
        </div>
      </td>
      <td className="p-3 align-middle text-nowrap cb-username-td text-truncate">
        <div className="d-flex flex-column position-relative">
          <UserInfo
            user={players[1]}
            hideOnlineIndicator
            loading={players[1].checkResult.status === 'started'}
          />
          <GameProgressBar player={players[1]} position="right" />
        </div>
      </td>
    </>
  );
});

const LiveTournaments = ({ tournaments }) => {
  if (isEmpty(tournaments)) {
    return (
      <div className="d-flex flex-column text-center">
        <span className="mb-0 mt-3 p-3 text-muted">
          There are no active tournaments right now
        </span>
        <a className="text-primary" href="/tournaments/#create">
          <u>You may want to create one</u>
        </a>
      </div>
    );
  }

  const sortedTournaments = orderBy(tournaments, 'startsAt', 'desc');

  return (
    <div className="table-responsive">
      <h2 className="text-center mt-3">Live tournaments</h2>
      <div className="d-none d-md-block table-responsive rounded-bottom">
        <table className="table table-striped">
          <thead className="">
            <tr>
              <th className="p-3 border-0">Title</th>
              <th className="p-3 border-0">Starts_at</th>
              <th className="p-3 border-0">Actions</th>
            </tr>
          </thead>
          <tbody className="">
            {sortedTournaments.map(tournament => (
              <tr key={tournament.id}>
                <td className="p-3 align-middle">{tournament.name}</td>
                <td className="p-3 align-middle text-nowrap">
                  {moment
                    .utc(tournament.startsAt)
                    .local()
                    .format('YYYY-MM-DD HH:mm')}
                </td>
                <td className="p-3 align-middle">
                  <ShowButton url={`/tournaments/${tournament.id}/`} />
                </td>
              </tr>
            ))}
          </tbody>
        </table>
      </div>
      <HorizontalScrollControls className="d-md-none m-2">
        {sortedTournaments.map(tournament => (
          <TournamentCard
            key={`card-${tournament.id}`}
            type="active"
            tournament={tournament}
          />
        ))}
      </HorizontalScrollControls>
      <div className="text-center mt-3">
        <a href="/tournaments">
          <u>Tournaments Info</u>
        </a>
      </div>
    </div>
  );
};

const CompletedTournaments = ({ tournaments }) => {
  if (isEmpty(tournaments)) {
    return null;
  }

  const sortedTournaments = orderBy(tournaments, 'startsAt', 'desc');

  return (
    <div className="table-responsive">
      <h2 className="text-center mt-3">Completed tournaments</h2>
      <div className="d-none d-md-block table-responsive rounded-bottom">
        <table className="table table-striped">
          <thead className="">
            <tr>
              <th className="p-3 border-0">Title</th>
              <th className="p-3 border-0">Type</th>
              <th className="p-3 border-0">Starts_at</th>
              <th className="p-3 border-0">Actions</th>
            </tr>
          </thead>
          <tbody className="">
            {sortedTournaments.map(tournament => (
              <tr key={tournament.id}>
                <td className="p-3 align-middle">{tournament.name}</td>
                <td className="p-3 align-middle">{tournament.type}</td>
                <td className="p-3 align-middle text-nowrap">
                  {moment
                    .utc(tournament.startsAt)
                    .local()
                    .format('YYYY-MM-DD HH:mm')}
                </td>
                <td className="p-3 align-middle">
                  <ShowButton url={`/tournaments/${tournament.id}/`} />
                </td>
              </tr>
            ))}
          </tbody>
        </table>
      </div>
      <HorizontalScrollControls className="d-md-none m-2">
        {sortedTournaments.map(tournament => (
          <TournamentCard
            key={`card-${tournament.id}`}
            type="completed"
            tournament={tournament}
          />
        ))}
      </HorizontalScrollControls>
    </div>
  );
};

const ActiveGames = ({
  games, currentUserId, isGuest, isOnline,
}) => {
  if (!games) {
    return null;
  }

  const filterGames = game => {
    if (game.visibilityType === 'hidden') {
      return !!find(game.players, { id: currentUserId });
    }
    return true;
  };
  const filtetedGames = games.filter(filterGames);

  if (isEmpty(filtetedGames)) {
    return <p className="text-center">There are no active games right now.</p>;
  }

  const gamesSortByLevel = sortBy(filtetedGames, [
    game => levelRatio[game.level],
  ]);

  const {
    gamesWithCurrentUser = [],
    gamesWithActiveUsers = [],
    gamesWithBots = [],
  } = groupBy(gamesSortByLevel, game => {
    const isCurrentUserPlay = game.players.some(
      ({ id }) => id === currentUserId,
    );
    if (isCurrentUserPlay) {
      return 'gamesWithCurrentUser';
    }
    if (!game.isBot) {
      return 'gamesWithActiveUsers';
    }
    return 'gamesWithBots';
  });

  const sortedGames = [
    ...gamesWithCurrentUser,
    ...gamesWithActiveUsers,
    ...gamesWithBots,
  ];

  return (
    <>
      <div className="d-none d-md-block table-responsive rounded-bottom">
        <table className="table table-striped mb-0">
          <thead className="text-center">
            <tr>
              <th className="p-3 border-0">Level</th>
              <th className="p-3 border-0">State</th>
              <th className="p-3 border-0 text-center" colSpan={2}>
                Players
              </th>
              <th className="p-3 border-0">Actions</th>
            </tr>
          </thead>
          <tbody>
            {sortedGames.map(
              game => isActiveGame(game) && (
                <tr key={game.id} className="text-dark game-item">
                  <td className="p-3 align-middle text-nowrap">
                    <GameLevelBadge level={game.level} />
                  </td>
                  <td className="p-3 align-middle text-center text-nowrap">
                    <GameStateBadge state={game.state} />
                  </td>
                  <Players
                    gameId={game.id}
                    players={game.players}
                    isBot={game.isBot}
                  />
                  <td className="p-3 align-middle text-center">
                    <GameActionButton
                      type="table"
                      game={game}
                      currentUserId={currentUserId}
                      isGuest={isGuest}
                      isOnline={isOnline}
                    />
                  </td>
                </tr>
              ),
            )}
          </tbody>
        </table>
      </div>
      <HorizontalScrollControls className="d-md-none m-2">
        {sortedGames.map(game => isActiveGame(game) && (
          <GameCard
            key={`card-${game.id}`}
            type="active"
            game={game}
            currentUserId={currentUserId}
            isGuest={isGuest}
            isOnline={isOnline}
          />
        ))}
      </HorizontalScrollControls>
    </>
  );
};

const getTabLinkClassName = (...hash) => {
  const url = new URL(window.location);
  const isActive = hash.includes(url.hash || '#lobby');

  return cn(
    'nav-item nav-link text-uppercase text-center text-nowrap rounded-0 font-weight-bold p-3 border-0 w-100',
    {
      active: isActive,
    },
  );
};

const tabContentClassName = (...hash) => {
  const url = new URL(window.location);

  return cn({
    'tab-pane': true,
    fade: true,
    active: hash.includes(url.hash || '#lobby'),
    show: hash.includes(url.hash || '#lobby'),
  });
};

const getTabLinkHandler = hash => () => {
  window.location.hash = hash;
};

const navTabsClassName = cn(
  'nav nav-tabs flex-nowrap cb-overflow-x-auto cb-overflow-y-hidden',
  'rounded-top border-bottom',
);

const LobbyContainer = ({
  activeGames,
  liveTournaments,
  completedTournaments,
  currentUserId,
  isGuest = true,
  isOnline = false,
}) => {
  const handleClick = useCallback(e => {
    const { currentTarget: { dataset } } = e;
    getTabLinkHandler(dataset.hash)();
  }, []);

  useEffect(() => {
    if (!window.location.hash) {
      getTabLinkHandler(hashLinkNames.default)();
      window.scrollTo({ top: 0 });
    }
  }, []);

  return (
    <div className="p-0 shadow-sm rounded-lg">
      <nav>
        <div
          id="nav-tab"
          className={navTabsClassName}
          role="tablist"
        >
          <a
            className={getTabLinkClassName(
              hashLinkNames.lobby,
              hashLinkNames.default,
            )}
            id="lobby-tab"
            data-toggle="tab"
            data-hash={hashLinkNames.lobby}
            href="#lobby"
            role="tab"
            aria-controls="lobby"
            aria-selected="true"
            onClick={handleClick}
          >
            Lobby
          </a>
          <a
            className={getTabLinkClassName(
              hashLinkNames.tournaments,
            )}
            id="tournaments-tab"
            data-toggle="tab"
            data-hash={hashLinkNames.tournaments}
            href="#tournaments"
            role="tab"
            aria-controls="tournaments"
            aria-selected="false"
            onClick={handleClick}
          >
            Tournaments
          </a>
          <a
            className={getTabLinkClassName(
              hashLinkNames.completedGames,
            )}
            id="completedGames-tab"
            data-toggle="tab"
            data-hash={hashLinkNames.completedGames}
            href="#completedGames"
            role="tab"
            aria-controls="completedGames"
            aria-selected="false"
            onClick={handleClick}
          >
            History
          </a>
        </div>
      </nav>
      <div className="tab-content" id="nav-tabContent">
        <div
          className={tabContentClassName(
            hashLinkNames.lobby,
            hashLinkNames.default,
          )}
          id="lobby"
          role="tabpanel"
          aria-labelledby="lobby-tab"
        >
          <ActiveGames
            games={activeGames}
            currentUserId={currentUserId}
            isGuest={isGuest}
            isOnline={isOnline}
          />
        </div>
        <div
          className={tabContentClassName(hashLinkNames.tournaments)}
          id="tournaments"
          role="tabpanel"
          aria-labelledby="tournaments-tab"
        >
          <LiveTournaments tournaments={liveTournaments} />
          <CompletedTournaments tournaments={completedTournaments} />
        </div>
        <div
          className={tabContentClassName(hashLinkNames.completedGames)}
          id="completedGames"
          role="tabpanel"
          aria-labelledby="completedGames-tab"
        >
          <CompletedGames className="cb-lobby-widget-container" />
        </div>
      </div>
    </div>
  );
};

const renderModal = (show, handleCloseModal) => (
  <Modal show={show} onHide={handleCloseModal}>
    <Modal.Header closeButton>
      <Modal.Title>Create a game</Modal.Title>
    </Modal.Header>
    <Modal.Body>
      <CreateGameDialog hideModal={handleCloseModal} />
    </Modal.Body>
  </Modal>
);

const CreateGameButton = ({ onClick, isOnline, isContinue }) => (
  <button
    type="button"
    className="btn btn-success border-0 text-uppercase font-weight-bold py-3 rounded-lg"
    onClick={onClick}
    disabled={!isOnline}
  >
    {isContinue ? 'Continue' : 'Create a Game'}
  </button>
);

const LobbyWidget = () => {
  const currentOpponent = Gon.getAsset('opponent');

  const dispatch = useDispatch();

  const chatInputRef = useRef(null);

  const currentUserId = useSelector(selectors.currentUserIdSelector);
  const isGuest = useSelector(selectors.currentUserIsGuestSelector);
  const isModalShow = useSelector(selectors.isModalShow);
  const activeGame = useSelector(selectors.activeGameSelector);
  const {
    activeGames,
    liveTournaments,
    completedTournaments,
    presenceList,
    channel: { online },
  } = useSelector(selectors.lobbyDataSelector);

  const [actionModalShowing, setActionModalShowing] = useState({ opened: false });

  const handleShowModal = useCallback(() => dispatch(actions.showCreateGameModal()), [dispatch]);
  const handleCloseModal = () => dispatch(actions.closeCreateGameModal());

  const handleCreateGameBtnClick = useCallback(() => {
    if (activeGame) {
      window.location.href = makeGameUrl(activeGame.id);
    } else {
      handleShowModal();
    }
  }, [activeGame, handleShowModal]);

  useEffect(() => {
    const clearLobby = lobbyMiddlewares.fetchState(currentUserId)(dispatch);
    if (currentOpponent) {
      window.history.replaceState({}, document.title, getLobbyUrl());
      dispatch(
        actions.showCreateGameInviteModal({
          opponentInfo: { id: currentOpponent.id, name: currentOpponent.name },
        }),
      );
    }

    return clearLobby;
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);

  return (
    <div className="container-lg">
      {renderModal(isModalShow, handleCloseModal)}
      <ChatActionModal
        presenceList={presenceList}
        chatInputRef={chatInputRef}
        modalShowing={actionModalShowing}
        setModalShowing={setActionModalShowing}
      />
      <div className="row">
        <div className="d-flex flex-column col-12 col-lg-8 p-0 mb-2 pr-lg-2">
          <div className="d-none d-lg-none d-flex flex-column mb-2">
            <CreateGameButton onClick={handleCreateGameBtnClick} isOnline={online} isContinue={!!activeGame} />
          </div>
          <LobbyContainer
            activeGames={activeGames}
            liveTournaments={liveTournaments}
            completedTournaments={completedTournaments}
            currentUserId={currentUserId}
            isGuest={isGuest}
            isOnline={online}
          />
          <LobbyChat
            setOpenActionModalShowing={setActionModalShowing}
            presenceList={presenceList}
            inputRef={chatInputRef}
          />
        </div>

        <div className="d-flex flex-column col-12 col-lg-4 p-0">
          <div className="d-none d-sm-none d-md-none d-lg-block">
            <div className="d-flex flex-column">
              <CreateGameButton onClick={handleCreateGameBtnClick} isOnline={online} isContinue={!!activeGame} />
            </div>
          </div>
          <div className="mt-2">
            <Announcement />
          </div>
          <div className="mt-2">
            <Leaderboard />
          </div>
        </div>
      </div>
    </div>
  );
};

export default LobbyWidget;