Coursemology/coursemology2

View on GitHub
client/app/bundles/course/assessment/pages/AssessmentStatistics/LiveFeedbackStatisticsTable.tsx

Summary

Maintainability
C
1 day
Test Coverage
import { FC, ReactNode, useEffect, useState } from 'react';
import { useParams } from 'react-router-dom';
import { Box, Chip, Typography } from '@mui/material';
import palette from 'theme/palette';
import { AssessmentLiveFeedbackStatistics } from 'types/course/statistics/assessmentStatistics';

import { workflowStates } from 'course/assessment/submission/constants';
import Prompt from 'lib/components/core/dialogs/Prompt';
import Link from 'lib/components/core/Link';
import GhostIcon from 'lib/components/icons/GhostIcon';
import Table, { ColumnTemplate } from 'lib/components/table';
import { DEFAULT_TABLE_ROWS_PER_PAGE } from 'lib/constants/sharedConstants';
import { useAppSelector } from 'lib/hooks/store';
import useTranslation from 'lib/hooks/useTranslation';

import { getClassnameForLiveFeedbackCell } from './classNameUtils';
import LiveFeedbackHistoryIndex from './LiveFeedbackHistory';
import { getAssessmentStatistics } from './selectors';
import translations from './translations';
import { getJointGroupsName, translateStatus } from './utils';

interface Props {
  includePhantom: boolean;
  liveFeedbackStatistics: AssessmentLiveFeedbackStatistics[];
}

const LiveFeedbackStatisticsTable: FC<Props> = (props) => {
  const { t } = useTranslation();
  const { courseId } = useParams();
  const { includePhantom, liveFeedbackStatistics } = props;

  const statistics = useAppSelector(getAssessmentStatistics);
  const assessment = statistics.assessment;

  const [parsedStatistics, setParsedStatistics] = useState<
    AssessmentLiveFeedbackStatistics[]
  >([]);
  const [upperQuartileFeedbackCount, setUpperQuartileFeedbackCount] =
    useState<number>(0);

  const [openLiveFeedbackHistory, setOpenLiveFeedbackHistory] = useState(false);
  const [liveFeedbackInfo, setLiveFeedbackInfo] = useState({
    courseUserId: 0,
    questionId: 0,
    questionNumber: 0,
  });

  useEffect(() => {
    const feedbackCounts = liveFeedbackStatistics
      .flatMap((s) => s.liveFeedbackCount ?? [])
      .map((c) => c ?? 0)
      .filter((c) => c !== 0)
      .sort((a, b) => a - b);
    const upperQuartilePercentileIndex = Math.floor(
      0.75 * (feedbackCounts.length - 1),
    );
    const upperQuartilePercentileValue =
      feedbackCounts[upperQuartilePercentileIndex];
    setUpperQuartileFeedbackCount(upperQuartilePercentileValue);

    const filteredStats = includePhantom
      ? liveFeedbackStatistics
      : liveFeedbackStatistics.filter((s) => !s.courseUser.isPhantom);

    filteredStats.forEach((stat) => {
      stat.totalFeedbackCount =
        stat.liveFeedbackCount?.reduce((sum, count) => sum + (count || 0), 0) ??
        0;
    });

    setParsedStatistics(
      filteredStats.sort((a, b) => {
        const phantomDiff =
          Number(a.courseUser.isPhantom) - Number(b.courseUser.isPhantom);
        if (phantomDiff !== 0) return phantomDiff;

        const feedbackDiff =
          (b.totalFeedbackCount ?? 0) - (a.totalFeedbackCount ?? 0);
        if (feedbackDiff !== 0) return feedbackDiff;

        return a.courseUser.name.localeCompare(b.courseUser.name);
      }),
    );
  }, [liveFeedbackStatistics, includePhantom]);

  // the case where the live feedback count is null is handled separately inside the column
  // (refer to the definition of statColumns below)
  const renderNonNullClickableLiveFeedbackCountCell = (
    count: number,
    courseUserId: number,
    questionId: number,
    questionNumber: number,
  ): ReactNode => {
    const classname = getClassnameForLiveFeedbackCell(
      count,
      upperQuartileFeedbackCount,
    );
    if (count === 0) {
      return <Box>{count}</Box>;
    }
    return (
      <div
        className={`cursor-pointer ${classname}`}
        onClick={(): void => {
          setOpenLiveFeedbackHistory(true);
          setLiveFeedbackInfo({ courseUserId, questionId, questionNumber });
        }}
      >
        <Box>{count}</Box>
      </div>
    );
  };

  const statColumns: ColumnTemplate<AssessmentLiveFeedbackStatistics>[] =
    Array.from({ length: assessment?.questionCount ?? 0 }, (_, index) => {
      return {
        searchProps: {
          getValue: (datum) =>
            datum.liveFeedbackCount?.[index]?.toString() ?? '',
        },
        title: t(translations.questionIndex, { index: index + 1 }),
        cell: (datum): ReactNode => {
          return typeof datum.liveFeedbackCount?.[index] === 'number'
            ? renderNonNullClickableLiveFeedbackCountCell(
                datum.liveFeedbackCount?.[index],
                datum.courseUser.id,
                datum.questionIds[index],
                index + 1,
              )
            : null;
        },
        sortable: true,
        csvDownloadable: true,
        className: 'text-right',
        sortProps: {
          sort: (a, b): number => {
            const aValue =
              a.liveFeedbackCount?.[index] ?? Number.MIN_SAFE_INTEGER;
            const bValue =
              b.liveFeedbackCount?.[index] ?? Number.MIN_SAFE_INTEGER;

            return aValue - bValue;
          },
        },
      };
    });

  const columns: ColumnTemplate<AssessmentLiveFeedbackStatistics>[] = [
    {
      searchProps: {
        getValue: (datum) => datum.courseUser.name,
      },
      title: t(translations.name),
      sortable: true,
      searchable: true,
      cell: (datum) => (
        <div className="flex grow items-center">
          <Link to={`/courses/${courseId}/users/${datum.courseUser.id}`}>
            {datum.courseUser.name}
          </Link>
          {datum.courseUser.isPhantom && (
            <GhostIcon className="ml-2" fontSize="small" />
          )}
        </div>
      ),
      csvDownloadable: true,
    },
    {
      searchProps: {
        getValue: (datum) => datum.courseUser.email,
      },
      title: t(translations.email),
      hidden: true,
      cell: (datum) => (
        <div className="flex grow items-center">{datum.courseUser.email}</div>
      ),
      csvDownloadable: true,
    },
    {
      of: 'groups',
      title: t(translations.group),
      sortable: true,
      searchable: true,
      searchProps: {
        getValue: (datum) => getJointGroupsName(datum.groups),
      },
      cell: (datum) => getJointGroupsName(datum.groups),
      csvDownloadable: true,
    },
    {
      of: 'workflowState',
      title: t(translations.workflowState),
      sortable: true,
      cell: (datum) => (
        <Chip
          className="w-100"
          label={translateStatus(
            datum.workflowState ?? workflowStates.Unstarted,
          )}
          style={{
            backgroundColor:
              palette.submissionStatus[
                datum.workflowState ?? workflowStates.Unstarted
              ],
          }}
          variant="filled"
        />
      ),
      className: 'center',
    },
    ...statColumns,
    {
      searchProps: {
        getValue: (datum) =>
          datum.liveFeedbackCount
            ? datum.liveFeedbackCount
                .reduce((sum, count) => sum + (count || 0), 0)
                .toString()
            : '',
      },
      title: t(translations.total),
      cell: (datum): ReactNode => {
        const totalFeedbackCount = datum.liveFeedbackCount
          ? datum.liveFeedbackCount.reduce(
              (sum, count) => sum + (count || 0),
              0,
            )
          : null;
        return (
          <div className="p-[1rem]">
            <Box>{totalFeedbackCount}</Box>
          </div>
        );
      },
      sortable: true,
      csvDownloadable: true,
      className: 'text-right',
      sortProps: {
        sort: (a, b): number => {
          const totalA = a.totalFeedbackCount ?? 0;
          const totalB = b.totalFeedbackCount ?? 0;
          return totalA - totalB;
        },
      },
    },
  ];

  return (
    <>
      <div className="flex items-center">
        <Typography variant="caption">
          {t(translations.legendLowerUsage)}
        </Typography>
        {
          // The gradient color bar
          <div className="h-5 w-1/4 mx-2 bg-gradient-to-r from-red-100 to-red-500" />
        }
        <Typography variant="caption">
          {t(translations.legendHigherusage)}
        </Typography>
      </div>

      <Table
        columns={columns}
        csvDownload={{
          filename: t(translations.liveFeedbackFilename, {
            assessment: assessment?.title ?? '',
          }),
        }}
        data={parsedStatistics}
        getRowClassName={(datum): string =>
          `data_${datum.courseUser.id} bg-slot-1 hover?:bg-slot-2 slot-1-white slot-2-neutral-100`
        }
        getRowEqualityData={(datum): AssessmentLiveFeedbackStatistics => datum}
        getRowId={(datum): string => datum.courseUser.id.toString()}
        indexing={{ indices: true }}
        pagination={{
          rowsPerPage: [DEFAULT_TABLE_ROWS_PER_PAGE],
          showAllRows: true,
        }}
        search={{ searchPlaceholder: t(translations.nameGroupsSearchText) }}
        toolbar={{ show: true }}
      />
      <Prompt
        cancelLabel={t(translations.closePrompt)}
        maxWidth="lg"
        onClose={(): void => setOpenLiveFeedbackHistory(false)}
        open={openLiveFeedbackHistory}
        title={t(translations.liveFeedbackHistoryPromptTitle)}
      >
        <LiveFeedbackHistoryIndex
          courseUserId={liveFeedbackInfo.courseUserId}
          questionId={liveFeedbackInfo.questionId}
          questionNumber={liveFeedbackInfo.questionNumber}
        />
      </Prompt>
    </>
  );
};

export default LiveFeedbackStatisticsTable;