OperationCode/front-end

View on GitHub
components/Cards/ResourceCard/ResourceCard.tsx

Summary

Maintainability
A
0 mins
Test Coverage
A
100%
import { useState } from 'react';
import classNames from 'classnames';
import Accordion from 'components/Accordion/Accordion';
import OutboundLink from 'components/OutboundLink/OutboundLink';
import ScreenReaderOnly from 'components/ScreenReaderOnly/ScreenReaderOnly';
import {
  UPVOTE_BUTTON,
  UPVOTE_COUNT,
  DOWNVOTE_BUTTON,
  DOWNVOTE_COUNT,
  RESOURCE_CARD,
  RESOURCE_TITLE,
} from 'common/constants/testIDs';
import ThumbsUp from 'static/images/icons/FontAwesome/thumbs-up.svg';
import ThumbsDown from 'static/images/icons/FontAwesome/thumbs-down.svg';
import styles from './ResourceCard.module.css';

const DESKTOP_VOTING_BLOCK = 'desktopVotingBlock';

type VoteDirectionType = 'upvote' | 'downvote';

type HandleVoteType = (
  /**
   * Sets the vote to be up or down.
   */
  voteDirection: VoteDirectionType,
  /**
   * Sets which resource is gets the vote.
   */
  resourceID: number,
  /**
   * Function that sets the state value of "up" votes.
   */
  setUpVotes: VotingBlockPropsType['setUpVotes'],
  /**
   * Function that sets the state value of "down" votes.
   */
  setDownVotes: VotingBlockPropsType['setDownVotes'],
) => void;

type VotingBlockPropsType = {
  /**
   * Applies an id.
   */
  blockID: string;
  /**
   * Sets which resource is gets the vote.
   */
  resourceID: number;
  /**
   * Number of "up" votes.
   */
  upVotes: number;
  /**
   * Number of "down" votes.
   */
  downVotes: number;
  /**
   * Function to handle the vote.
   */
  handleVote: HandleVoteType | undefined;
  /**
   * Function that sets the state value of "up" votes.
   */
  setUpVotes: React.Dispatch<React.SetStateAction<number>>;
  /**
   * Function that sets the state value of "down" votes.
   */
  setDownVotes: React.Dispatch<React.SetStateAction<number>>;
  /**
   * Applies classes based on whether an "up" vote has occurred.
   */
  didUpvote: boolean;
  /**
   * Applies classes based on whether an "down" vote has occurred.
   */
  didDownvote: boolean;
};

function VotingBlock({
  blockID,
  resourceID,
  upVotes,
  downVotes,
  handleVote,
  setUpVotes,
  setDownVotes,
  didUpvote,
  didDownvote,
}: VotingBlockPropsType) {
  const onVote = (voteDirection: VoteDirectionType) =>
    handleVote?.(voteDirection, resourceID, setUpVotes, setDownVotes);
  const onUpvote = () => onVote('upvote');
  const onDownvote = () => onVote('downvote');

  return (
    <div className={classNames(styles.votingBlock, styles[blockID])}>
      <span className={styles.votingBlockHeader}>Useful?</span>

      <div className={styles.voteInfo}>
        <button
          className={classNames(styles.voteButton, { [styles.active]: didUpvote })}
          aria-pressed={didUpvote}
          data-testid={blockID === DESKTOP_VOTING_BLOCK ? UPVOTE_BUTTON : undefined}
          onClick={onUpvote}
          type="button"
        >
          <ScreenReaderOnly>Yes</ScreenReaderOnly>
          <ThumbsUp
            className={classNames(styles.icon, {
              [styles.active]: didUpvote,
            })}
          />

          <span
            aria-live="polite"
            className={classNames(styles.voteCount, { [styles.active]: didUpvote })}
          >
            <ScreenReaderOnly>Number of upvotes:</ScreenReaderOnly>
            <span data-testid={UPVOTE_COUNT}>{upVotes.toString()}</span>
          </span>
        </button>
      </div>

      <div className={styles.voteInfo}>
        <button
          className={classNames(styles.voteButton, { [styles.active]: didDownvote })}
          aria-pressed={didDownvote}
          data-testid={blockID === DESKTOP_VOTING_BLOCK ? DOWNVOTE_BUTTON : undefined}
          onClick={onDownvote}
          type="button"
        >
          <ScreenReaderOnly>No</ScreenReaderOnly>
          <ThumbsDown
            className={classNames(styles.icon, {
              [styles.active]: didDownvote,
            })}
          />

          <span className={classNames(styles.voteCount, { [styles.active]: didDownvote })}>
            <ScreenReaderOnly>Number of downvotes:</ScreenReaderOnly>
            <span data-testid={DOWNVOTE_COUNT}>{downVotes.toString()}</span>
          </span>
        </button>
      </div>
    </div>
  );
}

export const possibleUserVotes = {
  upvote: 'upvote',
  downvote: 'downvote',
  none: null,
};

export type ResourceCardPropType = {
  /**
   * Url path for the link.
   */
  href: string;
  /**
   * Name of the resource applied to the resource title link.
   */
  name: string;
  /**
   * Applies an id to the component.
   */
  id: number;
  /**
   * Optional description of the resource.
   */
  description?: string;
  /**
   * Number of "down" votes.
   */
  downvotes?: number;
  /**
   * Sets the category text.
   */
  category?: string;
  /**
   * Applies the resource languages.
   */
  languages?: string | string[];
  /**
   * Sets indictor that resource is free.
   */
  isFree?: boolean;
  /**
   * Function to handle the vote.
   */
  handleVote?: () => void;
  /**
   * Number of "up" votes.
   */
  upvotes?: number;
  userVote?: keyof typeof possibleUserVotes | null;
};

function ResourceCard({
  description = '',
  downvotes = 0,
  href,
  name,
  category = '',
  languages = [],
  isFree = false,
  handleVote,
  upvotes = 0,
  userVote = possibleUserVotes.none,
  id,
}: ResourceCardPropType) {
  const [upVotes, setUpVotes] = useState(upvotes);
  const [downVotes, setDownVotes] = useState(downvotes);
  const didUpvote = userVote === possibleUserVotes.upvote;
  const didDownvote = userVote === possibleUserVotes.downvote;

  return (
    <Accordion
      accessibilityId={name}
      className={styles.ResourceCard}
      content={{
        headingChildren: (
          <div
            data-testid={RESOURCE_CARD}
            data-test-category={category}
            data-test-languages={Array.isArray(languages) ? languages.join('-') : languages}
            data-test-isfree={isFree}
            className={styles.header}
          >
            <h5 data-testid={RESOURCE_TITLE} className={styles.resourceName}>
              <OutboundLink
                analyticsEventLabel={`Resource: ${name}`}
                className={styles.link}
                hasIcon={false}
                href={href}
              >
                {name}
              </OutboundLink>
            </h5>

            <VotingBlock
              blockID={DESKTOP_VOTING_BLOCK}
              resourceID={id}
              upVotes={upVotes}
              downVotes={downVotes}
              handleVote={handleVote}
              setUpVotes={setUpVotes}
              setDownVotes={setDownVotes}
              didUpvote={didUpvote}
              didDownvote={didDownvote}
            />
          </div>
        ),
        bodyChildren: (
          <div className={styles.content}>
            <p className={styles.descriptionText}>{description}</p>

            <div className={styles.metadata}>
              <p>
                <span className={styles.metadataLabel}>Languages:</span>{' '}
                {Array.isArray(languages) ? languages.join(', ') : languages}
              </p>
              <p>
                <span className={styles.metadataLabel}>Category:</span> {category}
              </p>
            </div>

            <VotingBlock
              blockID="mobileVotingBlock"
              resourceID={id}
              upVotes={upVotes}
              downVotes={downVotes}
              handleVote={handleVote}
              setUpVotes={setUpVotes}
              setDownVotes={setDownVotes}
              didUpvote={didUpvote}
              didDownvote={didDownvote}
            />
          </div>
        ),
      }}
    />
  );
}

export default ResourceCard;