Coursemology/coursemology2

View on GitHub
client/app/bundles/course/assessment/submission/pages/SubmissionEditIndex/SubmissionEditStepForm.jsx

Summary

Maintainability
F
6 days
Test Coverage
import { useEffect, useState } from 'react';
import { FormProvider, useForm } from 'react-hook-form';
import Hotkeys from 'react-hot-keys';
import { FormattedMessage, injectIntl } from 'react-intl';
import { connect } from 'react-redux';
import {
  Button,
  Card,
  CardContent,
  CardHeader,
  Paper,
  Step,
  StepButton,
  StepLabel,
  Stepper,
  SvgIcon,
  Tooltip,
  Typography,
} from '@mui/material';
import { blue, green, lightBlue, red } from '@mui/material/colors';
import PropTypes from 'prop-types';

import ConfirmationDialog from 'lib/components/core/dialogs/ConfirmationDialog';
import ErrorText from 'lib/components/core/ErrorText';
import LoadingIndicator from 'lib/components/core/LoadingIndicator';
import usePrompt from 'lib/hooks/router/usePrompt';

import SubmissionAnswer from '../../components/answers';
import EvaluatorErrorPanel from '../../components/EvaluatorErrorPanel';
import { formNames, questionTypes } from '../../constants';
import Comments from '../../containers/Comments';
import GradingPanel from '../../containers/GradingPanel';
import QuestionGrade from '../../containers/QuestionGrade';
import {
  attachmentShape,
  explanationShape,
  historyQuestionShape,
  questionFlagsShape,
  questionShape,
  topicShape,
} from '../../propTypes';
import translations from '../../translations';

import { errorResolver } from './ErrorHelper';

const styles = {
  questionContainer: {
    marginTop: 20,
  },
  questionCardContainer: {
    padding: 40,
  },
  explanationContainer: {
    marginTop: 30,
    marginBottom: 30,
    borderRadius: 5,
  },
  explanationHeader: {
    borderRadius: '5px 5px 0 0',
    padding: 12,
  },
  formButton: {
    marginBottom: 10,
    marginRight: 10,
  },
  contineButton: {
    backgroundColor: green[500],
    color: 'white',
    marginBottom: 10,
    marginRight: 10,
  },
  stepButton: {
    margin: '-24px -16px',
    padding: '24px 16px',
  },
};

const isLastQuestion = (questionIds, stepIndex) =>
  stepIndex + 1 === questionIds.length;

const SubmissionEditStepForm = (props) => {
  const {
    attachments,
    allConsideredCorrect,
    allowPartialSubmission,
    attempting,
    codaveriFeedbackStatus,
    explanations,
    graderView,
    isCodaveriEnabled,
    onReset,
    onSaveDraft,
    onSubmit,
    onSubmitAnswer,
    onGenerateFeedback,
    onReevaluateAnswer,
    handleSaveAllGrades,
    handleSaveGrade,
    handleUnsubmit,
    historyQuestions,
    initialValues,
    intl,
    isSaving,
    maxStep: maxInitialStep,
    published,
    questionIds,
    questions,
    questionsFlags,
    showMcqAnswer,
    showMcqMrqSolution,
    skippable,
    step,
    topics,
  } = props;

  const initialStep = Math.min(maxInitialStep, Math.max(0, step || 0));

  const [submitConfirmation, setSubmitConfirmation] = useState(false);
  const [unsubmitConfirmation, setUnsubmitConfirmation] = useState(false);
  const [resetConfirmation, setResetConfirmation] = useState(false);
  const [resetAnswerId, setResetAnswerId] = useState(null);
  const [maxStep, setMaxStep] = useState(maxInitialStep);
  const [stepIndex, setStepIndex] = useState(initialStep);

  const methods = useForm({
    defaultValues: initialValues,
    resolver: errorResolver(questions, attachments),
  });

  const {
    getValues,
    handleSubmit,
    reset,
    resetField,
    formState: { errors, isDirty },
  } = methods;
  usePrompt(isDirty);

  useEffect(() => {
    reset(initialValues);
  }, [initialValues]);

  const handleNext = () => {
    setMaxStep(Math.max(maxStep, stepIndex + 1));
    setStepIndex(stepIndex + 1);
  };

  const handleStepClick = (index) => {
    if (published || skippable || graderView || index <= maxStep) {
      setStepIndex(index);
    }
  };

  const shouldDisableContinueButton = () => {
    const questionId = questionIds[stepIndex];

    if (isSaving) {
      return true;
    }

    if (explanations[questionId] && explanations[questionId].correct) {
      return false;
    }

    return showMcqAnswer;
  };

  const shouldRenderContinueButton = () =>
    !isLastQuestion(questionIds, stepIndex);

  const renderAutogradingErrorPanel = (id) => {
    const { jobError, jobErrorMessage } = questionsFlags[id] || {};
    const { isCodaveri, type } = questions[id];

    if (type === questionTypes.Programming && jobError) {
      return (
        <EvaluatorErrorPanel className="mb-8">
          {isCodaveri
            ? intl.formatMessage(translations.codaveriAutogradeFailure)
            : jobErrorMessage}
        </EvaluatorErrorPanel>
      );
    }

    return null;
  };

  const renderContinueButton = () => {
    const disabled = shouldDisableContinueButton();
    if (!shouldRenderContinueButton()) {
      return null;
    }
    return (
      <Button
        disabled={disabled}
        onClick={() => handleNext()}
        style={{
          ...styles.formButton,
          ...(!disabled && styles.contineButton),
        }}
        variant="contained"
      >
        {intl.formatMessage(translations.continue)}
      </Button>
    );
  };

  const renderExplanationPanel = (question) => {
    const explanation = explanations[question.id];

    if (explanation && explanation.correct !== null) {
      if (question.type === questionTypes.Programming && explanation.correct) {
        return null;
      }

      let title = '';
      if (explanation.correct) {
        if (question.autogradable) {
          title = intl.formatMessage(translations.correct);
        } else {
          title = intl.formatMessage(translations.answerSubmitted);
        }
      } else if (explanation.failureType === 'public_test') {
        title = intl.formatMessage(translations.publicTestCaseFailure);
      } else if (explanation.failureType === 'private_test') {
        title = intl.formatMessage(translations.privateTestCaseFailure);
      } else {
        title = intl.formatMessage(translations.wrong);
      }

      /* eslint-disable react/no-array-index-key */
      return (
        <Card style={styles.explanationContainer}>
          <CardHeader
            style={{
              ...styles.explanationHeader,
              color: explanation.correct ? green[900] : red[900],
              backgroundColor: explanation.correct ? green[200] : red[200],
            }}
            title={title}
            titleTypographyProps={{ variant: 'body2' }}
          />
          {explanation.explanations.every(
            (exp) => exp.trim().length === 0,
          ) ? null : (
            <CardContent>
              {explanation.explanations.map((exp, idx) => (
                <Typography
                  key={idx}
                  dangerouslySetInnerHTML={{ __html: exp }}
                  variant="body2"
                />
              ))}
            </CardContent>
          )}
        </Card>
      );
      /* eslint-enable react/no-array-index-key */
    }
    return null;
  };

  const renderFinaliseButton = () => {
    if (attempting && (allowPartialSubmission || allConsideredCorrect)) {
      return (
        <Button
          color="secondary"
          disabled={isSaving}
          onClick={() => setSubmitConfirmation(true)}
          style={styles.formButton}
          variant="contained"
        >
          {intl.formatMessage(translations.finalise)}
        </Button>
      );
    }
    return null;
  };

  const renderGradingPanel = () => {
    if (attempting) {
      return null;
    }
    return <GradingPanel />;
  };

  const renderQuestionGrading = (id) => {
    const editable = !attempting && graderView;
    const visible = editable || published;

    return visible ? (
      <QuestionGrade
        editable={editable}
        handleSaveGrade={handleSaveGrade}
        isSaving={isSaving}
        questionId={id}
      />
    ) : null;
  };

  const renderReevaluateButton = () => {
    const id = questionIds[stepIndex];
    const question = questions[id];
    const { answerId } = question;
    const { isAutograding } = questionsFlags[id] || {};
    if (question.type !== questionTypes.Programming) {
      return null;
    }

    return (
      <>
        {isCodaveriEnabled && question.isCodaveri && (
          <Button
            color="secondary"
            disabled={
              codaveriFeedbackStatus?.answers[answerId]?.jobStatus ===
                'submitted' || isSaving
            }
            id="retrieve-code-feedback"
            onClick={() => onGenerateFeedback(answerId, question.id)}
            style={styles.formButton}
            variant="contained"
          >
            {intl.formatMessage(translations.generateCodaveriFeedback)}
          </Button>
        )}
        <Button
          color="secondary"
          disabled={isAutograding || isSaving}
          endIcon={isAutograding && <LoadingIndicator bare size={20} />}
          id="re-evaluate-code"
          onClick={() => onReevaluateAnswer(answerId, question.id)}
          style={styles.formButton}
          variant="contained"
        >
          {intl.formatMessage(translations.reevaluate)}
        </Button>
      </>
    );
  };

  const renderResetButton = () => {
    const id = questionIds[stepIndex];
    const question = questions[id];
    const { answerId } = question;
    const { isAutograding, isResetting } = questionsFlags[id] || {};

    if (question.type === questionTypes.Programming) {
      return (
        <Button
          color="info"
          disabled={isAutograding || isResetting || isSaving}
          endIcon={isResetting && <LoadingIndicator bare size={20} />}
          onClick={() => {
            setResetConfirmation(true);
            setResetAnswerId(answerId);
          }}
          style={styles.formButton}
          variant="outlined"
        >
          {intl.formatMessage(translations.reset)}
        </Button>
      );
    }
    return null;
  };

  const renderResetDialog = () => (
    <ConfirmationDialog
      message={intl.formatMessage(translations.resetConfirmation)}
      onCancel={() => {
        setResetConfirmation(false);
        setResetAnswerId(null);
      }}
      onConfirm={() => {
        setResetConfirmation(false);
        setResetAnswerId(null);
        onReset(resetAnswerId, resetField);
      }}
      open={resetConfirmation}
    />
  );

  const renderSaveDraftButton = () => {
    if (!attempting) {
      return null;
    }
    return (
      <Button
        color="primary"
        disabled={!isDirty || isSaving}
        onClick={handleSubmit((data) => onSaveDraft({ ...data }, resetField))}
        style={styles.formButton}
        variant="contained"
      >
        {intl.formatMessage(translations.saveDraft)}
      </Button>
    );
  };

  const renderSaveGradeButton = () => {
    const shouldRenderSaveGradeButton = graderView && !attempting;
    if (!shouldRenderSaveGradeButton) {
      return null;
    }
    return (
      <Button
        color="primary"
        disabled={isSaving}
        onClick={handleSaveAllGrades}
        style={styles.formButton}
        variant="contained"
      >
        {intl.formatMessage(translations.saveGrade)}
      </Button>
    );
  };

  const renderSubmitButton = () => {
    const id = questionIds[stepIndex];
    const question = questions[id];
    const { answerId } = question;
    const { isAutograding, isResetting } = questionsFlags[id] || {};
    if (
      [questionTypes.MultipleChoice, questionTypes.MultipleResponse].includes(
        question.type,
      ) &&
      question.autogradable &&
      !showMcqAnswer
    ) {
      return null;
    }
    return (
      <>
        <Hotkeys
          disabled={isAutograding || isResetting || isSaving}
          filter={() => true}
          keyName="command+enter,control+enter"
          onKeyDown={() =>
            onSubmitAnswer(answerId, getValues(`${answerId}`), resetField)
          }
        />
        <Tooltip title={<FormattedMessage {...translations.submitTooltip} />}>
          <Button
            color="secondary"
            disabled={isAutograding || isResetting || isSaving}
            endIcon={isAutograding && <LoadingIndicator bare size={20} />}
            onClick={() =>
              onSubmitAnswer(answerId, getValues(`${answerId}`), resetField)
            }
            style={styles.formButton}
            variant="contained"
          >
            {intl.formatMessage(translations.submit)}
          </Button>
        </Tooltip>
      </>
    );
  };

  const renderSubmitDialog = () => (
    <ConfirmationDialog
      form={formNames.SUBMISSION}
      message={intl.formatMessage(translations.submitConfirmation)}
      onCancel={() => setSubmitConfirmation(false)}
      onConfirm={() => setSubmitConfirmation(false)}
      open={submitConfirmation}
    />
  );

  const renderUnsubmitButton = () => {
    const shouldRenderUnsubmitButton = graderView && !attempting;
    if (!shouldRenderUnsubmitButton) {
      return null;
    }
    return (
      <Button
        color="secondary"
        disabled={isSaving}
        onClick={() => setUnsubmitConfirmation(true)}
        style={styles.formButton}
        variant="contained"
      >
        {intl.formatMessage(translations.unsubmit)}
      </Button>
    );
  };

  const renderUnsubmitDialog = () => (
    <ConfirmationDialog
      message={intl.formatMessage(translations.unsubmitConfirmation)}
      onCancel={() => setUnsubmitConfirmation(false)}
      onConfirm={() => {
        setUnsubmitConfirmation(false);
        handleUnsubmit();
      }}
      open={unsubmitConfirmation}
    />
  );

  const renderStepQuestion = () => {
    const id = questionIds[stepIndex];
    const question = questions[id];
    const { answerId, topicId } = question;
    const topic = topics[topicId];
    const allErrors = errors[answerId]?.errorTypes ?? [];

    return (
      <>
        <SubmissionAnswer
          {...{
            readOnly: !attempting,
            answerId,
            allErrors,
            question,
            questionType: question.type,
            historyQuestions,
            graderView,
            showMcqMrqSolution,
          }}
        />
        {renderAutogradingErrorPanel(id)}
        {renderExplanationPanel(question)}
        {!attempting && graderView ? renderReevaluateButton() : null}
        {renderQuestionGrading(id)}

        {attempting && (
          <div>
            {renderResetButton()}
            {renderSubmitButton()}
            {renderContinueButton()}
          </div>
        )}

        <Comments topic={topic} />
      </>
    );
  };

  const renderStepperIcon = (questionId, questionIndex) => {
    let stepButtonColor = '';
    const isCurrentQuestion = questionIndex === stepIndex;
    if (explanations[questionId]?.correct) {
      stepButtonColor = isCurrentQuestion ? green[700] : green[300];
    } else if (explanations[questionId]?.correct === false) {
      stepButtonColor = isCurrentQuestion ? red[700] : red[300];
    } else {
      stepButtonColor = isCurrentQuestion ? blue[800] : lightBlue[400];
    }
    return (
      <SvgIcon htmlColor={stepButtonColor}>
        <circle cx="12" cy="12" r="12" />
        <text fill="#fff" fontSize="12" textAnchor="middle" x="12" y="16">
          {questionIndex + 1}
        </text>
      </SvgIcon>
    );
  };

  const renderStepper = () => {
    if (!questionIds || questionIds.length <= 1) {
      return null;
    }

    return (
      <Stepper
        activeStep={stepIndex}
        connector={<div />}
        nonLinear
        style={{ justifyContent: 'center', flexWrap: 'wrap', padding: 24 }}
      >
        {questionIds.map((questionId, index) => {
          if (published || skippable || graderView || index <= maxStep) {
            return (
              <Step key={questionId} active={index <= maxStep}>
                <StepButton
                  icon={renderStepperIcon(questionId, index)}
                  onClick={() => handleStepClick(index)}
                  style={styles.stepButton}
                />
              </Step>
            );
          }
          return (
            <Step key={questionId}>
              <StepLabel />
            </Step>
          );
        })}
      </Stepper>
    );
  };

  const renderErrorMessages = () => (
    <div className="flex flex-col text-right">
      <ErrorText
        errors={
          Object.keys(errors).length > 0 &&
          intl.formatMessage(translations.submissionError, {
            questions: Object.values(errors)
              .map((error) => error.questionNumber)
              .join(', '),
          })
        }
      />
    </div>
  );

  return (
    <div style={styles.questionContainer}>
      {renderStepper()}

      <FormProvider {...methods}>
        <form
          encType="multipart/form-data"
          id={formNames.SUBMISSION}
          noValidate
          onSubmit={handleSubmit((data) => onSubmit({ ...data }))}
        >
          <Paper className="mb-5 p-6" variant="outlined">
            {renderStepQuestion()}
          </Paper>
          {renderErrorMessages()}
          {renderSubmitDialog()}
        </form>
      </FormProvider>

      {renderGradingPanel()}

      <div>
        {renderSaveGradeButton()}
        {renderSaveDraftButton()}

        <div style={{ display: 'inline', float: 'right' }}>
          {renderFinaliseButton()}
        </div>

        {renderUnsubmitButton()}
      </div>

      {renderUnsubmitDialog()}
      {renderResetDialog()}
    </div>
  );
};

SubmissionEditStepForm.propTypes = {
  initialValues: PropTypes.object.isRequired,
  intl: PropTypes.object.isRequired,

  attachments: PropTypes.arrayOf(attachmentShape),
  graderView: PropTypes.bool.isRequired,
  maxStep: PropTypes.number.isRequired,
  step: PropTypes.number,
  skippable: PropTypes.bool.isRequired,

  attempting: PropTypes.bool.isRequired,
  published: PropTypes.bool.isRequired,

  codaveriFeedbackStatus: PropTypes.object,
  explanations: PropTypes.objectOf(explanationShape),
  allConsideredCorrect: PropTypes.bool.isRequired,
  allowPartialSubmission: PropTypes.bool.isRequired,
  showMcqAnswer: PropTypes.bool.isRequired,
  showMcqMrqSolution: PropTypes.bool.isRequired,
  isCodaveriEnabled: PropTypes.bool.isRequired,

  questionIds: PropTypes.arrayOf(PropTypes.number),
  questions: PropTypes.objectOf(questionShape),
  historyQuestions: PropTypes.objectOf(historyQuestionShape),
  questionsFlags: PropTypes.objectOf(questionFlagsShape),
  topics: PropTypes.objectOf(topicShape),
  isSaving: PropTypes.bool.isRequired,

  onReset: PropTypes.func,
  onSaveDraft: PropTypes.func,
  onSubmit: PropTypes.func,
  onSubmitAnswer: PropTypes.func,
  onReevaluateAnswer: PropTypes.func,
  onGenerateFeedback: PropTypes.func,
  handleUnsubmit: PropTypes.func,
  handleSaveAllGrades: PropTypes.func,
  handleSaveGrade: PropTypes.func,
};

function mapStateToProps(state) {
  return {
    attachments: state.assessments.submission.attachments,
  };
}

export default connect(mapStateToProps)(injectIntl(SubmissionEditStepForm));