From 52fc1a462df28f689d0ade00d84124dd3795e267 Mon Sep 17 00:00:00 2001 From: ilee2u Date: Thu, 3 Aug 2023 09:52:05 -0400 Subject: [PATCH 1/8] feat: added ReviewExamAttemptButton - added call to backend api - developing change to redux state --- src/data/constants.js | 1 + .../ExamsPage/components/AttemptList.jsx | 35 +++++- .../components/ReviewExamAttemptButton.jsx | 107 ++++++++++++++++++ src/pages/ExamsPage/data/api.js | 7 ++ src/pages/ExamsPage/data/reducer.js | 22 ++++ src/pages/ExamsPage/hooks.js | 12 ++ 6 files changed, 183 insertions(+), 1 deletion(-) create mode 100644 src/pages/ExamsPage/components/ReviewExamAttemptButton.jsx diff --git a/src/data/constants.js b/src/data/constants.js index ad50467..156cdfb 100644 --- a/src/data/constants.js +++ b/src/data/constants.js @@ -9,6 +9,7 @@ export const RequestKeys = { fetchCourseExams: 'fetchCourseExams', fetchExamAttempts: 'fetchExamAttempts', deleteExamAttempt: 'deleteExamAttempt', + modifyExamAttempt: 'modifyExamAttempt', }; export const ErrorStatuses = { diff --git a/src/pages/ExamsPage/components/AttemptList.jsx b/src/pages/ExamsPage/components/AttemptList.jsx index 3b5a6ab..4e312f2 100644 --- a/src/pages/ExamsPage/components/AttemptList.jsx +++ b/src/pages/ExamsPage/components/AttemptList.jsx @@ -2,6 +2,7 @@ import PropTypes from 'prop-types'; import { DataTable } from '@edx/paragon'; import { useIntl } from '@edx/frontend-platform/i18n'; import ResetExamAttemptButton from './ResetExamAttemptButton'; +import ReviewExamAttemptButton from './ReviewExamAttemptButton'; const capitalizeFirstLetter = (string) => string.charAt(0).toUpperCase() + string.slice(1); @@ -14,12 +15,35 @@ const ResetButton = (row) => ( /> ); +const test = attempt => row.original.attempt_id === attempt.attempt_id + +const ReviewButton = (row, attempts) => { + // console.log("\n\nindex:", attempts.findIndex(attempt => row.original.attempt_id === attempt.attempt_id)) + return ( + row.original.attempt_id === attempt.attempt_id)} + /> + ); +}; + +const getAndReadAttempts = ( attempts ) => { + console.log("\n\n\nattempts:", attempts); + if (attempts !== undefined) { + return attempts; + } + return []; +} + const AttemptList = ({ attempts }) => { const { formatMessage, formatDate } = useIntl(); return (
{ }), Cell: ({ row }) => ResetButton(row), }, + { + id: 'review', + Header: formatMessage({ + id: 'AttemptsList.review', + defaultMessage: 'Review', + description: 'Table header for the table column listing review to reset the exam attempt', + }), + Cell: ({ row }) => ReviewButton(row, attempts), + }, ]} - data={attempts} + data={getAndReadAttempts(attempts)} columns={[ { Header: formatMessage({ diff --git a/src/pages/ExamsPage/components/ReviewExamAttemptButton.jsx b/src/pages/ExamsPage/components/ReviewExamAttemptButton.jsx new file mode 100644 index 0000000..badf513 --- /dev/null +++ b/src/pages/ExamsPage/components/ReviewExamAttemptButton.jsx @@ -0,0 +1,107 @@ +import PropTypes from 'prop-types'; + +import { + Button, useToggle, ModalDialog, ActionRow, +} from '@edx/paragon'; +import { useIntl } from '@edx/frontend-platform/i18n'; +import { useModifyExamAttempt } from '../hooks'; +import { Warning } from '@edx/paragon/icons'; + +const ReviewExamAttemptButton = ({ username, examName, attemptId, attemptIndex, suspicionLevel }) => { + const [isOpen, open, close] = useToggle(false); + const modifyExamAttempt = useModifyExamAttempt(); + const { formatMessage } = useIntl(); + + return ( + <> +
+ +
+ + + + {formatMessage({ + id: 'ReviewExamAttemptButton.confirmation_modal_title', + defaultMessage: 'Please confirm your choice.', + description: 'Title header of the modal that appears to confirm the review of an exam attempt', + })} + + + + + {formatMessage( + { + id: 'ReviewExamAttemptButton.confirmation_modal_body', + defaultMessage: `Are you sure you want to review the exam attempt for learner with username "${username} + for the exam "${examName}"?. Suspicion Level: ${suspicionLevel}.`, + description: 'Body text of the modal that appears to confirm the review of an exam attempt', + }, + { username, examName }, + )} + + + + + + {formatMessage({ + id: 'ReviewExamAttemptButton.cancel_button', + defaultMessage: 'No (Cancel)', + description: 'Text for the button to cancel reviewing an exam attempt', + })} + + + + + + + + ); +}; + +ReviewExamAttemptButton.propTypes = { + username: PropTypes.string.isRequired, + examName: PropTypes.string.isRequired, + attemptId: PropTypes.number.isRequired, +}; + +export default ReviewExamAttemptButton; diff --git a/src/pages/ExamsPage/data/api.js b/src/pages/ExamsPage/data/api.js index 759720f..55e53fe 100644 --- a/src/pages/ExamsPage/data/api.js +++ b/src/pages/ExamsPage/data/api.js @@ -25,3 +25,10 @@ export async function deleteExamAttempt(attemptId) { const response = await getAuthenticatedHttpClient().delete(url); return response.data; } + +export async function modifyExamAttempt(attemptId, action){ + const url = `${getExamsBaseUrl()}/api/v1/exams/attempt/${attemptId}`; + const payload = { 'action': action } + const response = await getAuthenticatedHttpClient().put(url, payload); + return response.data; +} diff --git a/src/pages/ExamsPage/data/reducer.js b/src/pages/ExamsPage/data/reducer.js index a31db33..7aa13b9 100644 --- a/src/pages/ExamsPage/data/reducer.js +++ b/src/pages/ExamsPage/data/reducer.js @@ -36,6 +36,27 @@ const slice = createSlice({ ...state, attemptsList: state.attemptsList.filter(attempt => attempt.attempt_id !== attemptId.payload), }), + modifyExamAttempt: (state, attemptId, action, attemptIndex) => ({ + ...state, + // TODO: Make the status of the attempt modified change in the UI + attemptsList: state.attemptsList.map((attempt, index) => { + // Set the status of the modified attempt to verified or rejected + debugger; + if (index === attemptIndex) { + return { + ...attempt, + status: (() => { + if (action === 'verify') { + return 'Verified' + } + return 'Rejected' + }), + } + } + // Keep all other attempts as is + return attempt; + }) + }), setCurrentExam: (state, examId) => ({ ...state, currentExamIndex: Math.max(0, state.examsList.findIndex(exam => exam.id === examId.payload)), @@ -47,6 +68,7 @@ export const { loadExams, loadExamAttempts, deleteExamAttempt, + modifyExamAttempt, setCurrentExam, } = slice.actions; diff --git a/src/pages/ExamsPage/hooks.js b/src/pages/ExamsPage/hooks.js index 8fbd2a1..8deabad 100644 --- a/src/pages/ExamsPage/hooks.js +++ b/src/pages/ExamsPage/hooks.js @@ -52,6 +52,18 @@ export const useDeleteExamAttempt = () => { ); }; +export const useModifyExamAttempt = () => { + const makeNetworkRequest = reduxHooks.useMakeNetworkRequest(); + const dispatch = useDispatch(); + return (attemptId, action, attemptIndex) => ( + makeNetworkRequest({ + requestKey: RequestKeys.modifyExamAttempt, + promise: api.modifyExamAttempt(attemptId, action), + onSuccess: () => dispatch(reducer.modifyExamAttempt(attemptId, action, attemptIndex)), + }) + ); +}; + export const useInitializeExamsPage = (courseId) => { const fetchCourseExams = module.useFetchCourseExams(); React.useEffect(() => { fetchCourseExams(courseId); }, []); // eslint-disable-line react-hooks/exhaustive-deps From 980ccc4f10c98f51300a2b3dab6f7eb1f6c4cd93 Mon Sep 17 00:00:00 2001 From: ilee2u Date: Thu, 3 Aug 2023 11:05:36 -0400 Subject: [PATCH 2/8] feat: completed reducer, added status filter --- .../ExamsPage/components/AttemptList.jsx | 40 +++++++++---------- .../components/ReviewExamAttemptButton.jsx | 15 ++++--- src/pages/ExamsPage/data/api.js | 4 +- src/pages/ExamsPage/data/reducer.js | 17 +++----- src/pages/ExamsPage/hooks.js | 4 +- 5 files changed, 35 insertions(+), 45 deletions(-) diff --git a/src/pages/ExamsPage/components/AttemptList.jsx b/src/pages/ExamsPage/components/AttemptList.jsx index 4e312f2..b9cf52f 100644 --- a/src/pages/ExamsPage/components/AttemptList.jsx +++ b/src/pages/ExamsPage/components/AttemptList.jsx @@ -4,9 +4,14 @@ import { useIntl } from '@edx/frontend-platform/i18n'; import ResetExamAttemptButton from './ResetExamAttemptButton'; import ReviewExamAttemptButton from './ReviewExamAttemptButton'; -const capitalizeFirstLetter = (string) => string.charAt(0).toUpperCase() + string.slice(1); +const cleanString = (string) => { + // Remove underscores, capitalize the first letter of each word, and return as a single string. + return string.split("_").map(string => string.charAt(0).toUpperCase() + string.slice(1)).join(" "); +}; + +// (string.charAt(0).toUpperCase() + string.slice(1)).replace(/_/g, " "); -// This component has to be compartmentalized here otherwise npm lint throws an unstable-nested-component error. +// The button components must be compartmentalized here otherwise npm lint throws an unstable-nested-component error. const ResetButton = (row) => ( ( /> ); -const test = attempt => row.original.attempt_id === attempt.attempt_id - -const ReviewButton = (row, attempts) => { - // console.log("\n\nindex:", attempts.findIndex(attempt => row.original.attempt_id === attempt.attempt_id)) - return ( - row.original.attempt_id === attempt.attempt_id)} - /> - ); -}; +const ReviewButton = (row, attempts) => ( + +); -const getAndReadAttempts = ( attempts ) => { - console.log("\n\n\nattempts:", attempts); +const getAndReadAttempts = (attempts) => { if (attempts !== undefined) { return attempts; } return []; -} +}; const AttemptList = ({ attempts }) => { const { formatMessage, formatDate } = useIntl(); @@ -67,7 +65,7 @@ const AttemptList = ({ attempts }) => { defaultMessage: 'Review', description: 'Table header for the table column listing review to reset the exam attempt', }), - Cell: ({ row }) => ReviewButton(row, attempts), + Cell: ({ row }) => (row.original.status === "second_review_required" ? ReviewButton(row, attempts) : null), }, ]} data={getAndReadAttempts(attempts)} @@ -106,7 +104,7 @@ const AttemptList = ({ attempts }) => { defaultMessage: 'Exam Type', description: 'Table header for the type of the exam', }), - Cell: ({ row }) => (capitalizeFirstLetter(row.original.exam_type)), + Cell: ({ row }) => (cleanString(row.original.exam_type)), }, { Header: formatMessage({ @@ -142,7 +140,7 @@ const AttemptList = ({ attempts }) => { defaultMessage: 'Status', description: 'Table header for the current status of the exam attempt', }), - Cell: ({ row }) => (capitalizeFirstLetter(row.original.status)), + Cell: ({ row }) => cleanString(row.original.status), }, ]} > diff --git a/src/pages/ExamsPage/components/ReviewExamAttemptButton.jsx b/src/pages/ExamsPage/components/ReviewExamAttemptButton.jsx index badf513..38d026a 100644 --- a/src/pages/ExamsPage/components/ReviewExamAttemptButton.jsx +++ b/src/pages/ExamsPage/components/ReviewExamAttemptButton.jsx @@ -4,10 +4,12 @@ import { Button, useToggle, ModalDialog, ActionRow, } from '@edx/paragon'; import { useIntl } from '@edx/frontend-platform/i18n'; -import { useModifyExamAttempt } from '../hooks'; import { Warning } from '@edx/paragon/icons'; +import { useModifyExamAttempt } from '../hooks'; -const ReviewExamAttemptButton = ({ username, examName, attemptId, attemptIndex, suspicionLevel }) => { +const ReviewExamAttemptButton = ({ + username, examName, attemptId, suspicionLevel, +}) => { const [isOpen, open, close] = useToggle(false); const modifyExamAttempt = useModifyExamAttempt(); const { formatMessage } = useIntl(); @@ -19,7 +21,7 @@ const ReviewExamAttemptButton = ({ username, examName, attemptId, attemptIndex, {formatMessage({ id: 'ReviewExamAttemptButton.exam_name', - defaultMessage: 'Review', + defaultMessage: 'Second Review Required', description: 'Table header for the table column with buttons to review exam attempts', })} @@ -67,9 +69,7 @@ const ReviewExamAttemptButton = ({ username, examName, attemptId, attemptIndex,
diff --git a/src/pages/ExamsPage/components/ResetExamAttemptButton.jsx b/src/pages/ExamsPage/components/ResetExamAttemptButton.jsx index bc6f25d..74557da 100644 --- a/src/pages/ExamsPage/components/ResetExamAttemptButton.jsx +++ b/src/pages/ExamsPage/components/ResetExamAttemptButton.jsx @@ -5,6 +5,7 @@ import { } from '@edx/paragon'; import { useIntl } from '@edx/frontend-platform/i18n'; import { useDeleteExamAttempt } from '../hooks'; +import messages from '../messages'; const ResetExamAttemptButton = ({ username, examName, attemptId }) => { const [isOpen, open, close] = useToggle(false); @@ -15,11 +16,8 @@ const ResetExamAttemptButton = ({ username, examName, attemptId }) => { <>
{ > - {formatMessage({ - id: 'ResetExamAttemptButton.confirmation_modal_title', - defaultMessage: 'Please confirm your choice.', - description: 'Title header of the modal that appears to confirm the reset of an exam attempt', - })} + {formatMessage(messages.ResetExamAttemptButtonModalTitle)} - {formatMessage( - { - id: 'ResetExamAttemptButton.confirmation_modal_body', - defaultMessage: 'Are you sure you want to remove the exam attempt for learner with username "{username}" for the exam "{examName}"?.', - description: 'Body text of the modal that appears to confirm the reset of an exam attempt', - }, - { username, examName }, - )} + {/* TODO: Figure out how to move this while keeping the vars passed in */} +

{formatMessage(messages.ResetExamAttemptButtonModalBody)}

+
    +
  • {formatMessage(messages.Username)}{username}
  • +
  • {formatMessage(messages.ExamName)}{examName}
  • +
- {formatMessage({ - id: 'ResetExamAttemptButton.cancel_button', - defaultMessage: 'No (Cancel)', - description: 'Text for the button to cancel resetting an exam attempt', - })} + {formatMessage(messages.ResetExamAttemptButtonCancel)} diff --git a/src/pages/ExamsPage/components/ReviewExamAttemptButton.jsx b/src/pages/ExamsPage/components/ReviewExamAttemptButton.jsx index 38d026a..98b2dde 100644 --- a/src/pages/ExamsPage/components/ReviewExamAttemptButton.jsx +++ b/src/pages/ExamsPage/components/ReviewExamAttemptButton.jsx @@ -6,9 +6,10 @@ import { import { useIntl } from '@edx/frontend-platform/i18n'; import { Warning } from '@edx/paragon/icons'; import { useModifyExamAttempt } from '../hooks'; +import messages from '../messages'; const ReviewExamAttemptButton = ({ - username, examName, attemptId, suspicionLevel, + username, examName, attemptId, severity, submissionReason, }) => { const [isOpen, open, close] = useToggle(false); const modifyExamAttempt = useModifyExamAttempt(); @@ -19,11 +20,7 @@ const ReviewExamAttemptButton = ({
- {formatMessage({ - id: 'ReviewExamAttemptButton.confirmation_modal_title', - defaultMessage: 'Please confirm your choice.', - description: 'Title header of the modal that appears to confirm the review of an exam attempt', - })} + {formatMessage(messages.ReviewExamAttemptButtonModalTitle)} - {formatMessage( - { - id: 'ReviewExamAttemptButton.confirmation_modal_body', - defaultMessage: `Are you sure you want to review the exam attempt for learner with username "${username} - for the exam "${examName}"?. Suspicion Level: ${suspicionLevel}.`, - description: 'Body text of the modal that appears to confirm the review of an exam attempt', - }, - { username, examName }, - )} + {/* TODO: Figure out how to move this formatMessage with the variables. */} +

{formatMessage(messages.ReviewExamAttemptButtonModalBody)}

+
    +
  • {formatMessage(messages.Username)}{username}
  • +
  • {formatMessage(messages.ExamName)}{examName}
  • +
  • {formatMessage(messages.SuspicionLevel)}{severity}
  • +
  • {formatMessage(messages.SubmissionReason)}{submissionReason}
  • +
- {formatMessage({ - id: 'ReviewExamAttemptButton.cancel_button', - defaultMessage: 'No (Cancel)', - description: 'Text for the button to cancel reviewing an exam attempt', - })} + {formatMessage(messages.ReviewExamAttemptButtonCancel)} @@ -101,6 +81,8 @@ ReviewExamAttemptButton.propTypes = { username: PropTypes.string.isRequired, examName: PropTypes.string.isRequired, attemptId: PropTypes.number.isRequired, + severity: PropTypes.number.isRequired, + submissionReason: PropTypes.string.isRequired, }; export default ReviewExamAttemptButton; diff --git a/src/pages/ExamsPage/data/reducer.js b/src/pages/ExamsPage/data/reducer.js index eb670c4..890306c 100644 --- a/src/pages/ExamsPage/data/reducer.js +++ b/src/pages/ExamsPage/data/reducer.js @@ -1,4 +1,5 @@ import { createSlice } from '@reduxjs/toolkit'; +import * as constants from 'data/constants'; export const initialState = { currentExamIndex: 0, @@ -6,6 +7,18 @@ export const initialState = { attemptsList: [], }; +const getStatusFromAction = (action, status) => { + switch (action) { + case constants.ExamAttemptActions.verify: + return constants.ExamAttemptStatus.verified; + case constants.ExamAttemptActions.reject: + return constants.ExamAttemptStatus.rejected; + default: + // If invalid action, return the same status as before. + return status; + } +}; + const slice = createSlice({ name: 'exams', initialState, @@ -19,8 +32,8 @@ const slice = createSlice({ }), loadExamAttempts: (state, { payload }) => ({ ...state, - attemptsList: payload?.results.map((attempt) => ( - { + attemptsList: payload?.results.map((attempt) => { + const data = { attempt_id: attempt.attempt_id, exam_name: attempt.exam_display_name, username: attempt.username, @@ -29,21 +42,30 @@ const slice = createSlice({ started_at: attempt.start_time, completed_at: attempt.end_time, status: attempt.attempt_status, + }; + // Only add ACS review values if they exist + if (attempt.proctored_review) { + Object.assign( + data, + { severity: attempt.proctored_review.severity }, + { submission_reason: attempt.proctored_review.submission_reason }, + ); } - )), + return data; + }), }), deleteExamAttempt: (state, attemptId) => ({ ...state, attemptsList: state.attemptsList.filter(attempt => attempt.attempt_id !== attemptId.payload), }), - modifyExamAttempt: (state, { payload }) => ({ + modifyExamAttemptStatus: (state, { payload }) => ({ ...state, - attemptsList: state.attemptsList.map((attempt, index) => { + attemptsList: state.attemptsList.map((attempt) => { // Set the status of the modified attempt to verified or rejected if (attempt.attempt_id === payload.attemptId) { return { ...attempt, - status: payload.action === 'verify' ? 'Verified' : 'Rejected', + status: getStatusFromAction(payload.action, attempt.status), }; } // Keep all other attempts as is @@ -61,7 +83,7 @@ export const { loadExams, loadExamAttempts, deleteExamAttempt, - modifyExamAttempt, + modifyExamAttemptStatus, setCurrentExam, } = slice.actions; diff --git a/src/pages/ExamsPage/hooks.js b/src/pages/ExamsPage/hooks.js index 9b789c5..15e32c0 100644 --- a/src/pages/ExamsPage/hooks.js +++ b/src/pages/ExamsPage/hooks.js @@ -59,7 +59,7 @@ export const useModifyExamAttempt = () => { makeNetworkRequest({ requestKey: RequestKeys.modifyExamAttempt, promise: api.modifyExamAttempt(attemptId, action), - onSuccess: () => dispatch(reducer.modifyExamAttempt({ attemptId, action })), + onSuccess: () => dispatch(reducer.modifyExamAttemptStatus({ attemptId, action })), }) ); }; diff --git a/src/pages/ExamsPage/messages.js b/src/pages/ExamsPage/messages.js index 0a2f774..7933943 100644 --- a/src/pages/ExamsPage/messages.js +++ b/src/pages/ExamsPage/messages.js @@ -21,6 +21,139 @@ const messages = defineMessages({ defaultMessage: 'Select an exam', description: 'Default message for the exam selection dropdown', }, + + // Exam attempts data table headers + examAttemptsTableHeaderAction: { + id: 'AttemptsList.action', + defaultMessage: 'Action', + description: 'Table header for the table column listing action to reset the exam attempt', + }, + examAttemptsTableHeaderReview: { + id: 'AttemptsList.review', + defaultMessage: 'Review', + description: 'Table header for the table column listing review to reset the exam attempt', + }, + examAttemptsTableHeaderExamName: { + id: 'AttemptsList.exam_name', + defaultMessage: 'Exam Name', + description: 'Table header for the table column listing the exam name', + }, + examAttemptsTableHeaderUsername: { + id: 'AttemptsList.username', + defaultMessage: 'Username', + description: 'Table header for the table column listing the username', + }, + examAttemptsTableHeaderTimeLimit: { + id: 'AttemptsList.time_limit', + defaultMessage: 'Time Limit', + description: 'Table header for the table column listing the time limit to complete the exam', + }, + examAttemptsTableHeaderExamType: { + id: 'AttemptsList.exam_type', + defaultMessage: 'Exam Type', + description: 'Table header for the type of the exam', + }, + examAttemptsTableHeaderStartedAt: { + id: 'AttemptsList.started_at', + defaultMessage: 'Started At', + description: 'Table header for the time the exam attempt was started', + }, + examAttemptsTableHeaderCompletedAt: { + id: 'AttemptsList.completed_at', + defaultMessage: 'Completed At', + description: 'Table header for the time the exam attempt was completed', + }, + examAttemptsTableHeaderStatus: { + id: 'AttemptsList.status', + defaultMessage: 'Status', + description: 'Table header for the current status of the exam attempt', + }, + examAttemptsTableHeaderEmptyTable: { + id: 'AttemptsList.DataTable.EmptyTable', + defaultMessage: 'No results found.', + description: 'Message that appears in the table if no data is found', + }, + + // ResetExamAttemptButton + ResetExamAttemptButtonTitle: { + id: 'ResetExamAttemptButton.exam_name', + defaultMessage: 'Reset', + description: 'Title for the button to reset exam attempts', + }, + ResetExamAttemptButtonModalTitle: { + id: 'ResetExamAttemptButton.confirmation_modal_title', + defaultMessage: 'Please confirm your choice.', + description: 'Title header of the modal that appears to confirm the reset of an exam attempt', + }, + ResetExamAttemptButtonModalBody: { + id: 'ResetExamAttemptButton.confirmation_modal_body', + defaultMessage: 'Are you sure you want to remove the exam attempt with the following data?:', + description: 'Body text of the modal that appears to confirm the reset of an exam attempt', + }, + ResetExamAttemptButtonCancel: { + id: 'ResetExamAttemptButton.cancel_button', + defaultMessage: 'No (Cancel)', + description: 'Text for the button to cancel resetting an exam attempt', + }, + ResetExamAttemptButtonConfirm: { + id: 'ResetExamAttemptButton.confirm_button', + defaultMessage: 'Yes, I\'m Sure', + description: 'Text for the button to confirm the reset of an exam attempt', + }, + + // ReviewExamAttemptButton + ReviewExamAttemptButtonTitle: { + id: 'ReviewExamAttemptButton.exam_name', + defaultMessage: 'Second Review Required', + description: 'Title for the button to review exam attempts', + }, + ReviewExamAttemptButtonModalTitle: { + id: 'ReviewExamAttemptButton.confirmation_modal_title', + defaultMessage: 'Please confirm your choice.', + description: 'Title header of the modal that appears to confirm the review of an exam attempt', + }, + ReviewExamAttemptButtonModalBody: { + id: 'ReviewExamAttemptButton.confirmation_modal_body', + defaultMessage: 'Please "Verify" or "Reject" the exam attempt with the following data:', + description: 'Body text of the modal that appears to confirm the review of an exam attempt', + }, + ReviewExamAttemptButtonCancel: { + id: 'ReviewExamAttemptButton.cancel_button', + defaultMessage: 'No (Cancel)', + description: 'Text for the button to cancel reviewing an exam attempt', + }, + ReviewExamAttemptButtonVerify: { + id: 'ReviewExamAttemptButton.verify_button', + defaultMessage: 'Verify', + description: 'Text for the button to verify an exam attempt', + }, + ReviewExamAttemptButtonReject: { + id: 'ReviewExamAttemptButton.reject_button', + defaultMessage: 'Reject', + description: 'Text for the button to reject an exam attempt', + }, + + // NOTE: Wasn't sure how to title and id these since the first are used by both the Review & Reset buttons + Username: { + id: 'ExamAttemptButton.username', + defaultMessage: 'Username: ', + description: 'Username label for exam attempt data in the review/reset modal', + }, + ExamName: { + id: 'ExamAttemptButton.exam_name', + defaultMessage: 'Exam Name: ', + description: 'Exam name label for exam attempt data in the review/reset modal', + }, + SuspicionLevel: { + id: 'ExamAttemptButton.suspicion_level', + defaultMessage: 'Suspicion Level: ', + description: 'Suspicion level label for exam attempt data in the review/reset modal', + }, + SubmissionReason: { + id: 'ExamAttemptButton.submission_reason', + defaultMessage: 'Submission Reason: ', + description: 'Submission reason label for exam attempt data in the review/reset modal', + }, }); export default messages; From df5c73faf278c577d7a70b1bcb793e92f81becbb Mon Sep 17 00:00:00 2001 From: ilee2u Date: Thu, 3 Aug 2023 16:41:08 -0400 Subject: [PATCH 4/8] fix: temp workaround for status capitalization --- src/data/constants.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/data/constants.js b/src/data/constants.js index 79ea719..f6ecf9b 100644 --- a/src/data/constants.js +++ b/src/data/constants.js @@ -28,6 +28,9 @@ export const ExamAttemptStatus = { submitted: 'Submitted', verified: 'Verified', rejected: 'Rejected', + // NOTE: This is a temporary workaround since the reducer sets the status to "Verified", not "verified". + Verified: 'Verified', + Rejected: 'Rejected', expired: 'Expired', second_review_required: 'Second Review Required', error: 'Error', From ff3e65952a4a3bd3aea5f09742cc084ead42fe0a Mon Sep 17 00:00:00 2001 From: ilee2u Date: Fri, 4 Aug 2023 10:05:52 -0400 Subject: [PATCH 5/8] fix: separated ui and backend constants --- src/data/constants.js | 34 +++++++------------ .../ExamsPage/components/AttemptList.jsx | 27 +++++++++++++-- .../components/ResetExamAttemptButton.jsx | 1 + src/pages/ExamsPage/messages.js | 4 +-- 4 files changed, 39 insertions(+), 27 deletions(-) diff --git a/src/data/constants.js b/src/data/constants.js index f6ecf9b..99b93ed 100644 --- a/src/data/constants.js +++ b/src/data/constants.js @@ -19,28 +19,18 @@ export const ExamAttemptActions = { }; export const ExamAttemptStatus = { - created: 'Created', - download_software_clicked: 'Download Software Clicked', - ready_to_start: 'Ready To Start', - started: 'Started', - ready_to_submit: 'Ready To Submit', - timed_out: 'Timed Out', - submitted: 'Submitted', - verified: 'Verified', - rejected: 'Rejected', - // NOTE: This is a temporary workaround since the reducer sets the status to "Verified", not "verified". - Verified: 'Verified', - Rejected: 'Rejected', - expired: 'Expired', - second_review_required: 'Second Review Required', - error: 'Error', -}; - -export const ExamTypes = { - proctored: 'Proctored', - timed: 'Timed', - practice: 'Practice', - onboarding: 'Onboarding', + created: 'created', + download_software_clicked: 'download_software_clicked', + ready_to_start: 'ready_to_start', + started: 'started', + ready_to_submit: 'ready_to_submit', + timed_out: 'timed_out', + submitted: 'submitted', + verified: 'verified', + rejected: 'rejected', + expired: 'expired', + second_review_required: 'second_review_required', + error: 'error', }; export const ErrorStatuses = { diff --git a/src/pages/ExamsPage/components/AttemptList.jsx b/src/pages/ExamsPage/components/AttemptList.jsx index 2520c27..530e9b8 100644 --- a/src/pages/ExamsPage/components/AttemptList.jsx +++ b/src/pages/ExamsPage/components/AttemptList.jsx @@ -1,11 +1,32 @@ import PropTypes from 'prop-types'; import { DataTable } from '@edx/paragon'; import { useIntl } from '@edx/frontend-platform/i18n'; -import * as constants from 'data/constants'; import ResetExamAttemptButton from './ResetExamAttemptButton'; import ReviewExamAttemptButton from './ReviewExamAttemptButton'; import messages from '../messages'; +export const ExamTypes = { + proctored: 'Proctored', + timed: 'Timed', + practice: 'Practice', + onboarding: 'Onboarding', +}; + +const ExamAttemptStatusUILabels = { + created: 'Created', + download_software_clicked: 'Download Software Clicked', + ready_to_start: 'Ready To Start', + started: 'Started', + ready_to_submit: 'Ready To Submit', + timed_out: 'Timed Out', + submitted: 'Submitted', + verified: 'Verified', + rejected: 'Rejected', + expired: 'Expired', + second_review_required: 'Second Review Required', + error: 'Error', +}; + // The button components must be compartmentalized here otherwise npm lint throws an unstable-nested-component error. const ResetButton = (row) => ( { }, { Header: formatMessage(messages.examAttemptsTableHeaderExamType), - Cell: ({ row }) => constants.ExamTypes[row.original.exam_type], + Cell: ({ row }) => ExamTypes[row.original.exam_type], }, { Header: formatMessage(messages.examAttemptsTableHeaderStartedAt), @@ -90,7 +111,7 @@ const AttemptList = ({ attempts }) => { }, { Header: formatMessage(messages.examAttemptsTableHeaderStatus), - Cell: ({ row }) => constants.ExamAttemptStatus[row.original.status], + Cell: ({ row }) => ExamAttemptStatusUILabels[row.original.status], }, ]} > diff --git a/src/pages/ExamsPage/components/ResetExamAttemptButton.jsx b/src/pages/ExamsPage/components/ResetExamAttemptButton.jsx index 74557da..3022063 100644 --- a/src/pages/ExamsPage/components/ResetExamAttemptButton.jsx +++ b/src/pages/ExamsPage/components/ResetExamAttemptButton.jsx @@ -15,6 +15,7 @@ const ResetExamAttemptButton = ({ username, examName, attemptId }) => { return ( <>
+ {messages.ResetExamAttemptButtonTitle.defaultMessage};
Date: Fri, 4 Aug 2023 12:42:45 -0400 Subject: [PATCH 7/8] test: completed tests for this feat - Fixed attempt data values to match backend constants --- .../__snapshots__/index.test.jsx.snap | 84 +++++++- .../ExamsPage/components/AttemptList.jsx | 5 +- .../ExamsPage/components/AttemptList.test.jsx | 49 +++-- .../components/ResetExamAttemptButton.jsx | 1 - .../ResetExamAttemptButton.test.jsx | 10 +- .../components/ReviewExamAttemptButton.jsx | 6 +- .../ReviewExamAttemptButton.test.jsx | 63 ++++++ .../__snapshots__/AttemptList.test.jsx.snap | 192 +++++++++++++++++- .../ReviewExamAttemptButton.test.jsx.snap | 108 ++++++++++ src/pages/ExamsPage/data/api.test.js | 10 + src/pages/ExamsPage/data/reducer.test.js | 153 ++++++++++++-- src/pages/ExamsPage/data/selectors.test.jsx | 8 +- src/pages/ExamsPage/hooks.test.js | 36 ++++ src/pages/ExamsPage/index.test.jsx | 4 +- src/pages/ExamsPage/messages.js | 2 +- 15 files changed, 673 insertions(+), 58 deletions(-) create mode 100644 src/pages/ExamsPage/components/ReviewExamAttemptButton.test.jsx create mode 100644 src/pages/ExamsPage/components/__snapshots__/ReviewExamAttemptButton.test.jsx.snap diff --git a/src/pages/ExamsPage/__snapshots__/index.test.jsx.snap b/src/pages/ExamsPage/__snapshots__/index.test.jsx.snap index d32e7e7..9f06fc2 100644 --- a/src/pages/ExamsPage/__snapshots__/index.test.jsx.snap +++ b/src/pages/ExamsPage/__snapshots__/index.test.jsx.snap @@ -241,6 +241,8 @@ Object { Time Limit + + + + + + + Review + + + - 60 minutes + 60 - Completed + Submitted + @@ -721,6 +759,8 @@ Object { Time Limit + + + + + + + Review + + + - 60 minutes + 60 - Completed + Submitted + diff --git a/src/pages/ExamsPage/components/AttemptList.jsx b/src/pages/ExamsPage/components/AttemptList.jsx index 530e9b8..72233c2 100644 --- a/src/pages/ExamsPage/components/AttemptList.jsx +++ b/src/pages/ExamsPage/components/AttemptList.jsx @@ -5,7 +5,7 @@ import ResetExamAttemptButton from './ResetExamAttemptButton'; import ReviewExamAttemptButton from './ReviewExamAttemptButton'; import messages from '../messages'; -export const ExamTypes = { +const ExamTypes = { proctored: 'Proctored', timed: 'Timed', practice: 'Practice', @@ -51,6 +51,7 @@ const AttemptList = ({ attempts }) => { return (
+ {console.log('attempts:', attempts)} { }, { Header: formatMessage(messages.examAttemptsTableHeaderTimeLimit), - Cell: ({ row }) => (row.original.time_limit), + accessor: 'time_limit', }, { Header: formatMessage(messages.examAttemptsTableHeaderExamType), diff --git a/src/pages/ExamsPage/components/AttemptList.test.jsx b/src/pages/ExamsPage/components/AttemptList.test.jsx index df7592d..cfe8d4c 100644 --- a/src/pages/ExamsPage/components/AttemptList.test.jsx +++ b/src/pages/ExamsPage/components/AttemptList.test.jsx @@ -9,31 +9,35 @@ jest.unmock('react'); jest.mock('../hooks', () => ({ useDeleteExamAttempt: jest.fn(), + useModifyExamAttempt: jest.fn(), })); describe('AttemptList', () => { beforeEach(() => { hooks.useDeleteExamAttempt.mockReturnValue(jest.fn()); + hooks.useModifyExamAttempt.mockReturnValue(jest.fn()); }); const defaultAttemptsData = [ { exam_name: 'Exam 1', username: 'username', time_limit: 60, - exam_type: 'Timed', + exam_type: 'timed', started_at: '2023-04-05T19:27:16.000000Z', completed_at: '2023-04-05T19:27:17.000000Z', - status: 'completed', + status: 'second_review_required', attempt_id: 0, + severity: 1.0, + submission_reason: 'Submitted by user', }, { exam_name: 'Exam 2', username: 'username', time_limit: 60, - exam_type: 'Proctored', + exam_type: 'proctored', started_at: '2023-04-05T19:37:16.000000Z', completed_at: '2023-04-05T19:37:17.000000Z', - status: 'completed', + status: 'second_review_required', attempt_id: 1, }, ]; @@ -43,17 +47,32 @@ describe('AttemptList', () => { it('Data appears in data table as expected', () => { render(); defaultAttemptsData.forEach((attempt, index) => { - // Expect a row to be in the table for each attempt in the data - expect(screen.getAllByRole('row', { - attempt_id: attempt.attempt_id, - exam_name: attempt.exam_name, - username: attempt.username, - time_limit: attempt.time_limit, - exam_type: attempt.exam_type, - started_at: attempt.started_at, - completed_at: attempt.completed_at, - status: attempt.status, - })[index]).toBeInTheDocument(); + // Expect a row to be in the table for each attempt in the data (with respect to key/values present) + if (attempt.severity && attempt.submission_reason) { + expect(screen.getAllByRole('row', { + attempt_id: attempt.attempt_id, + exam_name: attempt.exam_name, + username: attempt.username, + time_limit: attempt.time_limit, + exam_type: attempt.exam_type, + started_at: attempt.started_at, + completed_at: attempt.completed_at, + status: attempt.status, + severity: attempt.severity, + submission_reason: attempt.submission_reason, + })[index]).toBeInTheDocument(); + } else { + expect(screen.getAllByRole('row', { + attempt_id: attempt.attempt_id, + exam_name: attempt.exam_name, + username: attempt.username, + time_limit: attempt.time_limit, + exam_type: attempt.exam_type, + started_at: attempt.started_at, + completed_at: attempt.completed_at, + status: attempt.status, + })[index]).toBeInTheDocument(); + } }); }); }); diff --git a/src/pages/ExamsPage/components/ResetExamAttemptButton.jsx b/src/pages/ExamsPage/components/ResetExamAttemptButton.jsx index 7e08b36..34b78c0 100644 --- a/src/pages/ExamsPage/components/ResetExamAttemptButton.jsx +++ b/src/pages/ExamsPage/components/ResetExamAttemptButton.jsx @@ -35,7 +35,6 @@ const ResetExamAttemptButton = ({ username, examName, attemptId }) => { - {/* TODO: Figure out how to move this while keeping the vars passed in */}

{formatMessage(messages.ResetExamAttemptButtonModalBody)}

  • {formatMessage(messages.Username)}{username}
  • diff --git a/src/pages/ExamsPage/components/ResetExamAttemptButton.test.jsx b/src/pages/ExamsPage/components/ResetExamAttemptButton.test.jsx index 378bf48..349c08e 100644 --- a/src/pages/ExamsPage/components/ResetExamAttemptButton.test.jsx +++ b/src/pages/ExamsPage/components/ResetExamAttemptButton.test.jsx @@ -13,21 +13,23 @@ const mockMakeNetworkRequest = jest.fn(); // nomally mocked for unit tests but required for rendering/snapshots jest.unmock('react'); +const resetButton = ; + describe('ResetExamAttemptButton', () => { beforeEach(() => { jest.restoreAllMocks(); hooks.useDeleteExamAttempt.mockReturnValue(mockMakeNetworkRequest); }); it('Test that the ResetExamAttemptButton matches snapshot', () => { - expect(render()).toMatchSnapshot(); + expect(render(resetButton)).toMatchSnapshot(); }); it('Modal appears upon clicking button', () => { - render(); + render(resetButton); screen.getByText('Reset').click(); expect(screen.getByText('Please confirm your choice.')).toBeInTheDocument(); }); it('Clicking the No button closes the modal', () => { - render(); + render(resetButton); screen.getByText('Reset').click(); screen.getByText('No (Cancel)').click(); // Using queryByText here allows the function to throw @@ -36,7 +38,7 @@ describe('ResetExamAttemptButton', () => { it('Clicking the Yes button calls the deletion hook', () => { const mockDeleteExamAttempt = jest.fn(); jest.spyOn(hooks, 'useDeleteExamAttempt').mockImplementation(() => mockDeleteExamAttempt); - render(); + render(resetButton); screen.getByText('Reset').click(); screen.getByText('Yes, I\'m Sure').click(); expect(mockDeleteExamAttempt).toHaveBeenCalledWith(0); diff --git a/src/pages/ExamsPage/components/ReviewExamAttemptButton.jsx b/src/pages/ExamsPage/components/ReviewExamAttemptButton.jsx index 98b2dde..edb81e6 100644 --- a/src/pages/ExamsPage/components/ReviewExamAttemptButton.jsx +++ b/src/pages/ExamsPage/components/ReviewExamAttemptButton.jsx @@ -5,6 +5,7 @@ import { } from '@edx/paragon'; import { useIntl } from '@edx/frontend-platform/i18n'; import { Warning } from '@edx/paragon/icons'; +import * as constants from 'data/constants'; import { useModifyExamAttempt } from '../hooks'; import messages from '../messages'; @@ -39,7 +40,6 @@ const ReviewExamAttemptButton = ({ - {/* TODO: Figure out how to move this formatMessage with the variables. */}

    {formatMessage(messages.ReviewExamAttemptButtonModalBody)}

    • {formatMessage(messages.Username)}{username}
    • @@ -57,7 +57,7 @@ const ReviewExamAttemptButton = ({
+ +
+ +
+ - 60 minutes + 60 - Completed + Second Review Required + +
+ +
+ @@ -545,6 +633,8 @@ Object { Time Limit + + + + + + + Review + + + - 60 minutes + 60 - Completed + Second Review Required + +
+ +
+ - 60 minutes + 60 - Completed + Second Review Required + +
+ +
+ diff --git a/src/pages/ExamsPage/components/__snapshots__/ReviewExamAttemptButton.test.jsx.snap b/src/pages/ExamsPage/components/__snapshots__/ReviewExamAttemptButton.test.jsx.snap new file mode 100644 index 0000000..e274ab1 --- /dev/null +++ b/src/pages/ExamsPage/components/__snapshots__/ReviewExamAttemptButton.test.jsx.snap @@ -0,0 +1,108 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`ReviewExamAttemptButton Test that the ReviewExamAttemptButton matches snapshot 1`] = ` +Object { + "asFragment": [Function], + "baseElement": +
+
+ +
+
+ , + "container":
+
+ +
+
, + "debug": [Function], + "findAllByAltText": [Function], + "findAllByDisplayValue": [Function], + "findAllByLabelText": [Function], + "findAllByPlaceholderText": [Function], + "findAllByRole": [Function], + "findAllByTestId": [Function], + "findAllByText": [Function], + "findAllByTitle": [Function], + "findByAltText": [Function], + "findByDisplayValue": [Function], + "findByLabelText": [Function], + "findByPlaceholderText": [Function], + "findByRole": [Function], + "findByTestId": [Function], + "findByText": [Function], + "findByTitle": [Function], + "getAllByAltText": [Function], + "getAllByDisplayValue": [Function], + "getAllByLabelText": [Function], + "getAllByPlaceholderText": [Function], + "getAllByRole": [Function], + "getAllByTestId": [Function], + "getAllByText": [Function], + "getAllByTitle": [Function], + "getByAltText": [Function], + "getByDisplayValue": [Function], + "getByLabelText": [Function], + "getByPlaceholderText": [Function], + "getByRole": [Function], + "getByTestId": [Function], + "getByText": [Function], + "getByTitle": [Function], + "queryAllByAltText": [Function], + "queryAllByDisplayValue": [Function], + "queryAllByLabelText": [Function], + "queryAllByPlaceholderText": [Function], + "queryAllByRole": [Function], + "queryAllByTestId": [Function], + "queryAllByText": [Function], + "queryAllByTitle": [Function], + "queryByAltText": [Function], + "queryByDisplayValue": [Function], + "queryByLabelText": [Function], + "queryByPlaceholderText": [Function], + "queryByRole": [Function], + "queryByTestId": [Function], + "queryByText": [Function], + "queryByTitle": [Function], + "rerender": [Function], + "unmount": [Function], +} +`; diff --git a/src/pages/ExamsPage/data/api.test.js b/src/pages/ExamsPage/data/api.test.js index 2226958..b7f6a69 100644 --- a/src/pages/ExamsPage/data/api.test.js +++ b/src/pages/ExamsPage/data/api.test.js @@ -17,6 +17,8 @@ jest.mock('@edx/frontend-platform', () => ({ const courseId = 'course-v1:edX+DemoX+Demo_Course'; const examId = 0; +const attemptId = 0; +const action = 'verify'; describe('ExamsPage data api', () => { describe('getCourseExams', () => { it('calls get on exams url with course id', async () => { @@ -40,5 +42,13 @@ describe('ExamsPage data api', () => { await api.deleteExamAttempt(examId); expect(axiosMock.history.delete[0].url).toBe('test-exams-url/api/v1/exams/attempt/0'); }); + describe('modifyExamAttempt', () => { + it('calls put on exams attempts url with attempt id', async () => { + axiosMock.onPut().reply(200, []); + const data = await api.modifyExamAttempt(attemptId, action); + expect(axiosMock.history.put[0].url).toBe('test-exams-url/api/v1/exams/attempt/0'); + expect(data).toEqual([]); + }); + }); }); }); diff --git a/src/pages/ExamsPage/data/reducer.test.js b/src/pages/ExamsPage/data/reducer.test.js index febf25a..667c3b3 100644 --- a/src/pages/ExamsPage/data/reducer.test.js +++ b/src/pages/ExamsPage/data/reducer.test.js @@ -1,3 +1,4 @@ +import * as constants from 'data/constants'; import { initialState, reducer, @@ -51,20 +52,20 @@ describe('ExamsPage reducer', () => { exam_display_name: 'Exam 1', username: 'username', allowed_time_limit_mins: 60, - exam_type: 'Timed', + exam_type: 'timed', start_time: '2023-04-05T19:27:16.000000Z', end_time: '2023-04-05T19:27:17.000000Z', - attempt_status: 'completed', + attempt_status: 'submitted', attempt_id: 0, }, { exam_display_name: 'Exam 2', username: 'username', allowed_time_limit_mins: 60, - exam_type: 'Proctored', + exam_type: 'proctored', start_time: '2023-04-05T19:37:16.000000Z', end_time: '2023-04-05T19:37:17.000000Z', - attempt_status: 'completed', + attempt_status: 'submitted', attempt_id: 1, }, ], @@ -78,20 +79,20 @@ describe('ExamsPage reducer', () => { exam_name: 'Exam 1', username: 'username', time_limit: 60, - exam_type: 'Timed', + exam_type: 'timed', started_at: '2023-04-05T19:27:16.000000Z', completed_at: '2023-04-05T19:27:17.000000Z', - status: 'completed', + status: 'submitted', attempt_id: 0, }, { exam_name: 'Exam 2', username: 'username', time_limit: 60, - exam_type: 'Proctored', + exam_type: 'proctored', started_at: '2023-04-05T19:37:16.000000Z', completed_at: '2023-04-05T19:37:17.000000Z', - status: 'completed', + status: 'submitted', attempt_id: 1, }, ], @@ -108,20 +109,20 @@ describe('ExamsPage reducer', () => { exam_name: 'Exam 1', username: 'username', time_limit: 60, - exam_type: 'Timed', + exam_type: 'timed', started_at: '2023-04-05T19:27:16.000000Z', completed_at: '2023-04-05T19:27:17.000000Z', - status: 'completed', + status: 'submitted', attempt_id: 0, }, { exam_name: 'Exam 2', username: 'username', time_limit: 60, - exam_type: 'Proctored', + exam_type: 'proctored', started_at: '2023-04-05T19:37:16.000000Z', completed_at: '2023-04-05T19:37:17.000000Z', - status: 'completed', + status: 'submitted', attempt_id: 1, }, ], @@ -138,10 +139,134 @@ describe('ExamsPage reducer', () => { exam_name: 'Exam 2', username: 'username', time_limit: 60, - exam_type: 'Proctored', + exam_type: 'proctored', + started_at: '2023-04-05T19:37:16.000000Z', + completed_at: '2023-04-05T19:37:17.000000Z', + status: 'submitted', + attempt_id: 1, + }, + ], + }); + }); + }); + describe('modifyExamAttemptStatus', () => { + it('changes status of one attempt to verified when passed verify action', () => { + const state = { + currentExamIndex: 0, + examsList: [], + attemptsList: [ + { + exam_name: 'Exam 1', + username: 'username', + time_limit: 60, + exam_type: 'timed', + started_at: '2023-04-05T19:27:16.000000Z', + completed_at: '2023-04-05T19:27:17.000000Z', + status: 'submitted', + attempt_id: 0, + }, + { + exam_name: 'Exam 2', + username: 'username', + time_limit: 60, + exam_type: 'proctored', + started_at: '2023-04-05T19:37:16.000000Z', + completed_at: '2023-04-05T19:37:17.000000Z', + status: 'submitted', + attempt_id: 1, + }, + ], + }; + const action = { + type: 'exams/modifyExamAttemptStatus', + payload: { + attemptId: 0, + action: constants.ExamAttemptActions.verify, + }, + }; + expect(reducer(state, action)).toEqual({ + currentExamIndex: 0, + examsList: [], + attemptsList: [ + { + exam_name: 'Exam 1', + username: 'username', + time_limit: 60, + exam_type: 'timed', + started_at: '2023-04-05T19:27:16.000000Z', + completed_at: '2023-04-05T19:27:17.000000Z', + status: constants.ExamAttemptStatus.verified, + attempt_id: 0, + }, + { + exam_name: 'Exam 2', + username: 'username', + time_limit: 60, + exam_type: 'proctored', + started_at: '2023-04-05T19:37:16.000000Z', + completed_at: '2023-04-05T19:37:17.000000Z', + status: 'submitted', + attempt_id: 1, + }, + ], + }); + }); + it('changes status of one attempt to rejected when passed reject action', () => { + const state = { + currentExamIndex: 0, + examsList: [], + attemptsList: [ + { + exam_name: 'Exam 1', + username: 'username', + time_limit: 60, + exam_type: 'timed', + started_at: '2023-04-05T19:27:16.000000Z', + completed_at: '2023-04-05T19:27:17.000000Z', + status: 'submitted', + attempt_id: 0, + }, + { + exam_name: 'Exam 2', + username: 'username', + time_limit: 60, + exam_type: 'proctored', + started_at: '2023-04-05T19:37:16.000000Z', + completed_at: '2023-04-05T19:37:17.000000Z', + status: 'submitted', + attempt_id: 1, + }, + ], + }; + const action = { + type: 'exams/modifyExamAttemptStatus', + payload: { + attemptId: 0, + action: constants.ExamAttemptActions.reject, + }, + }; + expect(reducer(state, action)).toEqual({ + currentExamIndex: 0, + examsList: [], + attemptsList: [ + { + exam_name: 'Exam 1', + username: 'username', + time_limit: 60, + exam_type: 'timed', + started_at: '2023-04-05T19:27:16.000000Z', + completed_at: '2023-04-05T19:27:17.000000Z', + status: constants.ExamAttemptStatus.rejected, + attempt_id: 0, + }, + { + exam_name: 'Exam 2', + username: 'username', + time_limit: 60, + exam_type: 'proctored', started_at: '2023-04-05T19:37:16.000000Z', completed_at: '2023-04-05T19:37:17.000000Z', - status: 'completed', + status: 'submitted', attempt_id: 1, }, ], diff --git a/src/pages/ExamsPage/data/selectors.test.jsx b/src/pages/ExamsPage/data/selectors.test.jsx index 3655588..42da1bb 100644 --- a/src/pages/ExamsPage/data/selectors.test.jsx +++ b/src/pages/ExamsPage/data/selectors.test.jsx @@ -12,19 +12,19 @@ const testAttempts = [{ exam_name: 'Exam 1', username: 'username', time_limit: 60, - exam_type: 'Timed', + exam_type: 'timed', started_at: '2023-04-05T19:27:16.000000Z', completed_at: '2023-04-05T19:27:17.000000Z', - status: 'completed', + status: 'submitted', }, { exam_name: 'Exam 2', username: 'username', time_limit: 60, - exam_type: 'Proctored', + exam_type: 'proctored', started_at: '2023-04-05T19:37:16.000000Z', completed_at: '2023-04-05T19:37:17.000000Z', - status: 'completed', + status: 'submitted', }]; const testState = { diff --git a/src/pages/ExamsPage/hooks.test.js b/src/pages/ExamsPage/hooks.test.js index 07968a8..8f41524 100644 --- a/src/pages/ExamsPage/hooks.test.js +++ b/src/pages/ExamsPage/hooks.test.js @@ -2,6 +2,7 @@ import React from 'react'; import * as reduxHooks from 'data/redux/hooks'; +import * as constants from 'data/constants'; import * as api from './data/api'; import * as hooks from './hooks'; @@ -112,6 +113,41 @@ describe('ExamsPage hooks', () => { }); }); }); + + describe('useModifyExamAttempt', () => { + const mockMakeNetworkRequest = jest.fn(); + beforeEach(() => { + mockMakeNetworkRequest.mockClear(); + reduxHooks.useMakeNetworkRequest.mockReturnValue(mockMakeNetworkRequest); + api.modifyExamAttempt.mockReturnValue(Promise.resolve({ data: 'data' })); + }); + it('calls makeNetworkRequest to modify an exam attempt status', () => { + hooks.useModifyExamAttempt()(0, constants.ExamAttemptActions.verify); + expect(mockMakeNetworkRequest).toHaveBeenCalledWith({ + requestKey: 'modifyExamAttempt', + promise: expect.any(Promise), + onSuccess: expect.any(Function), + }); + }); + it('dispatches modifyExamAttemptStatus on success', async () => { + const attemptId = 0; + const action = constants.ExamAttemptActions.verify; + await hooks.useModifyExamAttempt()(attemptId, action); + const { onSuccess } = mockMakeNetworkRequest.mock.calls[0][0]; + onSuccess({ + attemptId, + action, + }); + expect(mockDispatch).toHaveBeenCalledWith({ + payload: { + attemptId, + action, + }, + type: 'exams/modifyExamAttemptStatus', + }); + }); + }); + describe('useSetCurrentExam', () => { it('dispatches setCurrentExam with the new exam id', () => { hooks.useSetCurrentExam()(1); diff --git a/src/pages/ExamsPage/index.test.jsx b/src/pages/ExamsPage/index.test.jsx index 32b42f8..958ca58 100644 --- a/src/pages/ExamsPage/index.test.jsx +++ b/src/pages/ExamsPage/index.test.jsx @@ -27,10 +27,10 @@ describe('ExamsPage', () => { exam_name: 'Exam 1', username: 'username', time_limit: 60, - exam_type: 'Timed', + exam_type: 'timed', started_at: '2023-04-05T19:27:16.000000Z', completed_at: '2023-04-05T19:27:17.000000Z', - status: 'completed', + status: 'submitted', attempt_id: 0, }], }; diff --git a/src/pages/ExamsPage/messages.js b/src/pages/ExamsPage/messages.js index c6909a4..530e096 100644 --- a/src/pages/ExamsPage/messages.js +++ b/src/pages/ExamsPage/messages.js @@ -133,7 +133,7 @@ const messages = defineMessages({ description: 'Text for the button to reject an exam attempt', }, - // NOTE: Wasn't sure how to title and id these since the first are used by both the Review & Reset buttons + // Labels for exam attempt info for review/reset modals Username: { id: 'ExamAttemptButton.username', defaultMessage: 'Username: ', From 760c48189dfed26d549a016d829caa06b016c510 Mon Sep 17 00:00:00 2001 From: ilee2u Date: Mon, 7 Aug 2023 09:40:41 -0400 Subject: [PATCH 8/8] fix: remove extra console log --- src/pages/ExamsPage/components/AttemptList.jsx | 1 - 1 file changed, 1 deletion(-) diff --git a/src/pages/ExamsPage/components/AttemptList.jsx b/src/pages/ExamsPage/components/AttemptList.jsx index 72233c2..006c249 100644 --- a/src/pages/ExamsPage/components/AttemptList.jsx +++ b/src/pages/ExamsPage/components/AttemptList.jsx @@ -51,7 +51,6 @@ const AttemptList = ({ attempts }) => { return (
- {console.log('attempts:', attempts)}