Coursemology/coursemology2

View on GitHub
client/app/bundles/course/survey/pages/SurveyResults/OptionsQuestionResults.jsx

Summary

Maintainability
B
4 hrs
Test Coverage
import { Component } from 'react';
import { defineMessages, FormattedMessage } from 'react-intl';
import {
  Button,
  CardContent,
  Chip,
  Switch,
  Table,
  TableBody,
  TableCell,
  TableHead,
  TableRow,
} from '@mui/material';
import { cyan, grey } from '@mui/material/colors';
import PropTypes from 'prop-types';

import { questionTypes } from 'course/survey/constants';
import { optionShape } from 'course/survey/propTypes';
import { sorts } from 'course/survey/utils';
import Link from 'lib/components/core/Link';
import Thumbnail from 'lib/components/core/Thumbnail';

const styles = {
  percentageBarThreshold: 10,
  expandableThreshold: 10,
  image: {
    maxHeight: 80,
    maxWidth: 80,
  },
  imageContainer: {
    margin: 10,
    height: 80,
    width: 80,
  },
  bar: {
    display: 'flex',
    justifyContent: 'center',
    alignItems: 'center',
    backgroundColor: cyan[500],
    color: grey[50],
    height: 24,
    borderRadius: 5,
  },
  barContainer: {
    display: 'flex',
    justifyContent: 'flex-start',
    alignItems: 'center',
    backgroundColor: grey[300],
    width: '100%',
    height: 24,
    borderRadius: 5,
  },
  percentage: {
    marginLeft: 5,
    color: cyan[500],
    fontWeight: 'bold',
  },
  percentageHeader: {
    display: 'flex',
    justifyContent: 'space-between',
    alignItems: 'center',
  },
  sortByPercentage: {
    display: 'flex',
    justifyContent: 'space-between',
    alignItems: 'center',
  },
  expandToggleStyle: {
    display: 'flex',
    justifyContent: 'center',
  },
  wrapText: {
    whiteSpace: 'normal',
    wordWrap: 'break-word',
  },
  tableWrapper: {
    overflow: 'inherit',
  },
  optionStudentNames: {
    display: 'flex',
    flexWrap: 'wrap',
  },
  nameChip: {
    margin: 2,
  },
};

const translations = defineMessages({
  serial: {
    id: 'course.survey.OptionsQuestionResults.serial',
    defaultMessage: 'S/N',
  },
  respondents: {
    id: 'course.survey.OptionsQuestionResults.respondents',
    defaultMessage: 'Respondents',
  },
  count: {
    id: 'course.survey.OptionsQuestionResults.count',
    defaultMessage: 'Count',
  },
  percentage: {
    id: 'course.survey.OptionsQuestionResults.percentage',
    defaultMessage: 'Percentage',
  },
  sortByPercentage: {
    id: 'course.survey.OptionsQuestionResults.sortByPercentage',
    defaultMessage: 'Sort By Percentage',
  },
  sortByCount: {
    id: 'course.survey.OptionsQuestionResults.sortByCount',
    defaultMessage: 'Sort By Count',
  },
  multipleChoiceOption: {
    id: 'course.survey.OptionsQuestionResults.multipleChoiceOption',
    defaultMessage: 'Multiple Choice Option',
  },
  multipleResponseOption: {
    id: 'course.survey.OptionsQuestionResults.multipleResponseOption',
    defaultMessage: 'Multiple Response Option',
  },
  showOptions: {
    id: 'course.survey.OptionsQuestionResults.showOptions',
    defaultMessage: 'Show All {quantity} Options',
  },
  hideOptions: {
    id: 'course.survey.OptionsQuestionResults.hideOptions',
    defaultMessage: 'Hide All {quantity} Options',
  },
  phantomStudentName: {
    id: 'course.survey.OptionsQuestionResults.phantomStudentName',
    defaultMessage: '{name} (Phantom)',
  },
});

class OptionsQuestionResults extends Component {
  static renderOptionRow(breakdown, hasImage, option, index, anonymous) {
    const percentage = (100 * breakdown[option.id].count) / breakdown.length;
    const {
      id,
      option: optionText,
      image_url: imageUrl,
      image_name: imageName,
    } = option;

    return (
      <TableRow key={id}>
        <TableCell>{index + 1}</TableCell>
        {hasImage ? (
          <TableCell>
            {imageUrl ? (
              <Thumbnail
                containerStyle={styles.imageContainer}
                src={imageUrl}
                style={styles.image}
              />
            ) : (
              []
            )}
          </TableCell>
        ) : null}
        <TableCell colSpan={hasImage ? 3 : 6} style={styles.wrapText}>
          {optionText || imageName || null}
        </TableCell>
        <TableCell>{breakdown[id].count}</TableCell>
        <TableCell colSpan={6}>
          {anonymous
            ? OptionsQuestionResults.renderPercentageBar(percentage)
            : OptionsQuestionResults.renderStudentList(breakdown[id].students)}
        </TableCell>
      </TableRow>
    );
  }

  static renderPercentageBar(percentage) {
    return (
      <div style={styles.barContainer}>
        <div style={{ ...styles.bar, width: `${percentage}%` }}>
          {percentage >= styles.percentageBarThreshold
            ? `${percentage.toFixed(1)}%`
            : null}
        </div>
        {percentage < styles.percentageBarThreshold ? (
          <span style={styles.percentage}>{`${percentage.toFixed(1)}%`}</span>
        ) : null}
      </div>
    );
  }

  static renderStudentList(students) {
    return (
      <div style={styles.optionStudentNames}>
        {students.map((student) => (
          <Chip
            key={student.id}
            label={
              <Link to={student.response_path}>
                {student.phantom ? (
                  <FormattedMessage
                    {...translations.phantomStudentName}
                    values={{ name: student.name }}
                  />
                ) : (
                  student.name
                )}
              </Link>
            }
            style={styles.nameChip}
          />
        ))}
      </div>
    );
  }

  constructor(props) {
    super(props);

    const expandable = props.options.length > styles.expandableThreshold;
    this.state = {
      expandable,
      expanded: !expandable,
      sortByPercentage: false,
    };
  }

  /**
   * Computes the list and count of students that selected each option for the current question.
   */
  getOptionsBreakdown() {
    const { includePhantoms, options, answers } = this.props;
    const breakdown = { length: answers.length };
    options.forEach((option) => {
      breakdown[option.id] = { count: 0, students: [] };
    });
    answers.forEach((answer) => {
      answer.question_option_ids.forEach((selectedOption) => {
        if (!includePhantoms && answer.phantom) {
          return;
        }
        breakdown[selectedOption].count += 1;
        breakdown[selectedOption].students.push({
          id: answer.course_user_id,
          name: answer.course_user_name,
          response_path: answer.response_path,
          phantom: answer.phantom,
        });
      });
    });
    return breakdown;
  }

  renderExpandToggle() {
    if (!this.state.expandable) {
      return null;
    }

    const labelTranslation = this.state.expanded
      ? 'hideOptions'
      : 'showOptions';
    const quantity = this.props.options.length;

    return (
      <CardContent style={styles.expandToggleStyle}>
        <Button
          onClick={() =>
            this.setState((state) => ({ expanded: !state.expanded }))
          }
          variant="outlined"
        >
          <FormattedMessage
            {...translations[labelTranslation]}
            values={{ quantity }}
          />
        </Button>
      </CardContent>
    );
  }

  renderOptionsResultsTable() {
    const { anonymous, options, questionType } = this.props;
    const { MULTIPLE_CHOICE, MULTIPLE_RESPONSE } = questionTypes;
    const { byWeight } = sorts;
    const breakdown = this.getOptionsBreakdown();
    const optionsHeaderTranslation = {
      [MULTIPLE_CHOICE]: translations.multipleChoiceOption,
      [MULTIPLE_RESPONSE]: translations.multipleResponseOption,
    }[questionType];
    const hasImage = options.some((option) => option.image_url);
    const sortByCount = (a, b) => breakdown[b.id].count - breakdown[a.id].count;
    const sortMethod = this.state.sortByPercentage ? sortByCount : byWeight;

    return (
      <Table style={styles.tableWrapper}>
        <TableHead>
          <TableRow>
            <TableCell>
              <FormattedMessage {...translations.serial} />
            </TableCell>
            <TableCell colSpan={hasImage ? 4 : 6}>
              <FormattedMessage {...optionsHeaderTranslation} />
            </TableCell>
            <TableCell>
              <FormattedMessage {...translations.count} />
            </TableCell>
            <TableCell colSpan={6}>
              <div style={styles.percentageHeader}>
                <FormattedMessage
                  {...translations[anonymous ? 'percentage' : 'respondents']}
                />
                <div style={styles.sortByPercentage}>
                  <FormattedMessage
                    {...translations[
                      anonymous ? 'sortByPercentage' : 'sortByCount'
                    ]}
                  />
                  <Switch
                    color="primary"
                    onChange={(_, value) =>
                      this.setState({ sortByPercentage: value })
                    }
                  />
                </div>
              </div>
            </TableCell>
          </TableRow>
        </TableHead>
        <TableBody>
          {options
            .sort(sortMethod)
            .map((option, index) =>
              OptionsQuestionResults.renderOptionRow(
                breakdown,
                hasImage,
                option,
                index,
                anonymous,
              ),
            )}
        </TableBody>
      </Table>
    );
  }

  render() {
    const toggle = this.renderExpandToggle();
    return (
      <>
        {toggle}
        {this.state.expanded && this.renderOptionsResultsTable()}
        {this.state.expanded && toggle}
      </>
    );
  }
}

OptionsQuestionResults.propTypes = {
  options: PropTypes.arrayOf(optionShape),
  includePhantoms: PropTypes.bool,
  anonymous: PropTypes.bool,
  questionType: PropTypes.string,
  answers: PropTypes.arrayOf(
    PropTypes.shape({
      id: PropTypes.number,
      course_user_id: PropTypes.number,
      course_user_name: PropTypes.string,
      phantom: PropTypes.bool,
      question_option_ids: PropTypes.arrayOf(PropTypes.number),
    }),
  ),
};

export default OptionsQuestionResults;