client/app/bundles/course/assessment/submission/pages/SubmissionEditIndex/SubmissionForm.tsx
import { FC, useEffect, useRef, useState } from 'react';
import { FormProvider, useForm } from 'react-hook-form';
import { AnswerData } from 'types/course/assessment/submission/answer';
import { getSubmissionId } from 'lib/helpers/url-helpers';
import usePrompt from 'lib/hooks/router/usePrompt';
import { useAppDispatch, useAppSelector } from 'lib/hooks/store';
import useTranslation from 'lib/hooks/useTranslation';
import { finalise, getEvaluationResult, getJobStatus } from '../../actions';
import { fetchLiveFeedback } from '../../actions/answers';
import WarningDialog from '../../components/WarningDialog';
import actionTypes, {
EVALUATE_POLL_INTERVAL_MILLISECONDS,
FEEDBACK_POLL_INTERVAL_MILLISECONDS,
formNames,
workflowStates,
} from '../../constants';
import GradingPanel from '../../containers/GradingPanel';
import { getInitialAnswer } from '../../selectors/answers';
import { getAssessment } from '../../selectors/assessments';
import { getAttachments } from '../../selectors/attachments';
import { getLiveFeedbacks } from '../../selectors/liveFeedbacks';
import { getQuestionFlags } from '../../selectors/questionFlags';
import { getQuestions } from '../../selectors/questions';
import { getSubmission } from '../../selectors/submissions';
import translations from '../../translations';
import { setTimerForForceSubmission } from '../../utils/timer';
import AutogradeSubmissionButton from './components/button/AutogradeSubmissionButton';
import FinaliseButton from './components/button/FinaliseButton';
import MarkButton from './components/button/MarkButton';
import PublishButton from './components/button/PublishButton';
import SaveDraftButton from './components/button/SaveDraftButton';
import SaveGradeButton from './components/button/SaveGradeButton';
import UnmarkButton from './components/button/UnmarkButton';
import UnsubmitButton from './components/button/UnsubmitButton';
import ErrorMessages from './components/ErrorMessages';
import SinglePageQuestions from './components/SinglePageQuestions';
import TabbedViewQuestions from './components/TabbedViewQuestions';
import { errorResolver } from './ErrorHelper';
interface Props {
step: number;
}
const SubmissionForm: FC<Props> = (props) => {
const { step } = props;
const { t } = useTranslation();
const dispatch = useAppDispatch();
const assessment = useAppSelector(getAssessment);
const submission = useAppSelector(getSubmission);
const questions = useAppSelector(getQuestions);
const questionFlags = useAppSelector(getQuestionFlags);
const attachments = useAppSelector(getAttachments);
const liveFeedbacks = useAppSelector(getLiveFeedbacks);
const initialValues = useAppSelector(getInitialAnswer);
const { autograded, timeLimit, tabbedView, questionIds } = assessment;
const { workflowState, attemptedAt } = submission;
const maxInitialStep = submission.maxStep ?? questionIds.length - 1;
const submissionId = getSubmissionId();
const hasSubmissionTimeLimit =
workflowState === workflowStates.Attempting && timeLimit;
const submissionTimeLimitAt = hasSubmissionTimeLimit
? new Date(attemptedAt).getTime() + timeLimit * 60 * 1000
: null;
const initialStep = Math.min(maxInitialStep, Math.max(0, step || 0));
const [maxStep, setMaxStep] = useState(maxInitialStep);
const [stepIndex, setStepIndex] = useState(initialStep);
const methods = useForm({
defaultValues: initialValues,
resolver: errorResolver(questions, attachments),
});
const onFetchLiveFeedback = (answerId: number, questionId: number): void => {
const liveFeedbackId =
liveFeedbacks?.feedbackByQuestion?.[questionId].liveFeedbackId;
const feedbackToken =
liveFeedbacks?.feedbackByQuestion?.[questionId].pendingFeedbackToken;
const questionIndex = questionIds.findIndex((id) => id === questionId) + 1;
const successMessage = t(translations.liveFeedbackSuccess, {
questionIndex,
});
const noFeedbackMessage = t(translations.liveFeedbackNoneGenerated, {
questionIndex,
});
dispatch(
fetchLiveFeedback({
answerId,
questionId,
feedbackUrl: liveFeedbacks?.feedbackUrl,
liveFeedbackId,
feedbackToken,
successMessage,
noFeedbackMessage,
}),
);
};
const onSubmit = (data: Record<number, AnswerData>): void => {
dispatch(finalise(submissionId, data));
};
const onContinueToNextQuestion = (): void => {
setMaxStep(Math.max(maxStep, stepIndex + 1));
setStepIndex(stepIndex + 1);
};
const {
handleSubmit,
reset,
formState: { isDirty },
} = methods;
usePrompt(isDirty);
useEffect(() => {
reset(initialValues);
}, [initialValues]);
useEffect(() => {
if (submissionTimeLimitAt) {
setTimerForForceSubmission(
submissionTimeLimitAt,
handleSubmit((data) => onSubmit({ ...data })),
);
}
}, [submissionTimeLimitAt]);
const scrollToRef = useRef(null);
useEffect(() => {
if (step !== null && !tabbedView) {
const assignedStep = Math.min(questionIds.length - 1, Math.max(step, 0));
setStepIndex(assignedStep);
if (scrollToRef.current) {
setImmediate(() =>
(scrollToRef.current! as HTMLElement).scrollIntoView(),
);
}
}
});
const feedbackPollerRef = useRef<NodeJS.Timeout | null>(null);
const evaluatePollerRef = useRef<NodeJS.Timeout | null>(null);
const pollAllFeedback = (): void => {
questionIds.forEach((id) => {
const question = questions[id];
const feedbackRequestToken =
liveFeedbacks?.feedbackByQuestion?.[question.id]?.pendingFeedbackToken;
if (feedbackRequestToken) {
onFetchLiveFeedback(question.answerId!, id);
}
});
};
const handleEvaluationPolling = (): void => {
Object.values(questions).forEach((question) => {
if (
questionFlags[question.id]?.isAutograding &&
questionFlags[question.id]?.jobUrl
) {
getJobStatus(questionFlags[question.id].jobUrl).then((response) => {
switch (response.data.status) {
case 'submitted':
break;
case 'completed':
dispatch(
getEvaluationResult(
submissionId,
question.answerId,
question.id,
),
);
break;
case 'errored':
dispatch({
type: actionTypes.AUTOGRADE_FAILURE,
answerId: question.answerId,
questionId: question.id,
});
break;
default:
throw new Error('Unknown job status');
}
});
}
});
};
useEffect(() => {
// check for feedback from Codaveri on page load for each question
feedbackPollerRef.current = setInterval(
pollAllFeedback,
FEEDBACK_POLL_INTERVAL_MILLISECONDS,
);
evaluatePollerRef.current = setInterval(
handleEvaluationPolling,
EVALUATE_POLL_INTERVAL_MILLISECONDS,
);
// clean up poller on unmount
return () => {
if (feedbackPollerRef.current) {
clearInterval(feedbackPollerRef.current);
}
if (evaluatePollerRef.current) {
clearInterval(evaluatePollerRef.current);
}
};
});
return (
<div className="mt-4">
<FormProvider {...methods}>
<form
encType="multipart/form-data"
id={formNames.SUBMISSION}
noValidate
onSubmit={handleSubmit((data) => onSubmit({ ...data }))}
>
{tabbedView ? (
<TabbedViewQuestions
handleNext={onContinueToNextQuestion}
maxStep={maxStep}
setStepIndex={setStepIndex}
stepIndex={stepIndex}
/>
) : (
<SinglePageQuestions
scrollToRef={scrollToRef}
stepIndex={stepIndex}
/>
)}
<GradingPanel />
<SaveDraftButton />
<SaveGradeButton />
{!autograded && <AutogradeSubmissionButton />}
<div style={{ display: 'inline', float: 'right' }}>
<FinaliseButton />
</div>
<UnsubmitButton />
{!autograded && (
<>
<MarkButton />
<UnmarkButton />
<PublishButton />
</>
)}
<ErrorMessages />
</form>
</FormProvider>
<WarningDialog />
</div>
);
};
export default SubmissionForm;