Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: added ReviewExamAttemptButton #11

Merged
merged 8 commits into from
Aug 7, 2023
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions src/data/constants.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,38 @@ export const RequestKeys = {
fetchCourseExams: 'fetchCourseExams',
fetchExamAttempts: 'fetchExamAttempts',
deleteExamAttempt: 'deleteExamAttempt',
modifyExamAttempt: 'modifyExamAttempt',
};

export const ExamAttemptActions = {
// NOTE: These are the "staff-only" actions, which are meant to be performed from this dashboard.
verify: 'verify',
reject: 'reject',
};

export const ExamAttemptStatus = {
Copy link
Member

@Zacharis278 Zacharis278 Aug 4, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sorry to keep nit picking on these but I think there's two different types of constants that belong in different places. One is just the statuses we get from the backend (so lower/snake case values) and the other is UI labels for those statuses. I think the former makes sense here like the ExamAttemptActions you have above but the UI labels should probably go nearer to the component that's using them and likely named accordingly. I think someone looking at this file the first time would assume these are all the backend values since it's in the data folder.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've moved the UI label mapping to AttemptsList, and kept the backend string mapping in constants.js

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',
};

export const ErrorStatuses = {
Expand Down
88 changes: 32 additions & 56 deletions src/pages/ExamsPage/components/AttemptList.jsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
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';

const capitalizeFirstLetter = (string) => string.charAt(0).toUpperCase() + string.slice(1);

// 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) => (
<ResetExamAttemptButton
username={row.original.username}
Expand All @@ -14,12 +15,23 @@ const ResetButton = (row) => (
/>
);

const ReviewButton = (row) => (
<ReviewExamAttemptButton
username={row.original.username}
examName={row.original.exam_name}
attemptId={row.original.attempt_id}
severity={row.original.severity}
submissionReason={row.original.submission_reason}
/>
);

const AttemptList = ({ attempts }) => {
const { formatMessage, formatDate } = useIntl();

return (
<div data-testid="attempt_list">
<DataTable
isLoading={attempts == null}
isPaginated
initialState={{
pageSize: 20,
Expand All @@ -29,58 +41,35 @@ const AttemptList = ({ attempts }) => {
additionalColumns={[
{
id: 'action',
Header: formatMessage({
id: 'AttemptsList.action',
defaultMessage: 'Action',
description: 'Table header for the table column listing action to reset the exam attempt',
}),
Header: formatMessage(messages.examAttemptsTableHeaderAction),
Cell: ({ row }) => ResetButton(row),
},
{
id: 'review',
Header: formatMessage(messages.examAttemptsTableHeaderReview),
Cell: ({ row }) => (row.original.status === 'second_review_required' ? ReviewButton(row) : null),
},
]}
data={attempts}
columns={[
{
Header: formatMessage({
id: 'AttemptsList.exam_name',
defaultMessage: 'Exam Name',
description: 'Table header for the table column listing the exam name',
}),
Header: formatMessage(messages.examAttemptsTableHeaderExamName),
accessor: 'exam_name',
},
{
Header: formatMessage({
id: 'AttemptsList.username',
defaultMessage: 'Username',
description: 'Table header for the table column listing the username',
}),
Header: formatMessage(messages.examAttemptsTableHeaderUsername),
accessor: 'username',
},
{
Header: formatMessage({
id: 'AttemptsList.time_limit',
defaultMessage: 'Time Limit',
description: 'Table header for the table column listing the time limit to complete the exam',
}),
Cell: ({ row }) => formatMessage({
id: 'AttemptsList.time_limit',
defaultMessage: `${row.original.time_limit} minutes`,
description: 'Data cell for the time limit to complete the exam',
}),
Header: formatMessage(messages.examAttemptsTableHeaderTimeLimit),
Cell: ({ row }) => (row.original.time_limit),
},
{
Header: formatMessage({
id: 'AttemptsList.exam_type',
defaultMessage: 'Exam Type',
description: 'Table header for the type of the exam',
}),
Cell: ({ row }) => (capitalizeFirstLetter(row.original.exam_type)),
Header: formatMessage(messages.examAttemptsTableHeaderExamType),
Cell: ({ row }) => constants.ExamTypes[row.original.exam_type],
},
{
Header: formatMessage({
id: 'AttemptsList.started_at',
defaultMessage: 'Started At',
description: 'Table header for the time the exam attempt was started',
}),
Header: formatMessage(messages.examAttemptsTableHeaderStartedAt),
Cell: ({ row }) => (formatDate(row.original.started_at, {
year: 'numeric',
month: 'numeric',
Expand All @@ -90,11 +79,7 @@ const AttemptList = ({ attempts }) => {
})),
},
{
Header: formatMessage({
id: 'AttemptsList.completed_at',
defaultMessage: 'Completed At',
description: 'Table header for the time the exam attempt was completed',
}),
Header: formatMessage(messages.examAttemptsTableHeaderCompletedAt),
Cell: ({ row }) => (formatDate(row.original.completed_at, {
year: 'numeric',
month: 'numeric',
Expand All @@ -104,23 +89,14 @@ const AttemptList = ({ attempts }) => {
})),
},
{
Header: formatMessage({
id: 'AttemptsList.status',
defaultMessage: 'Status',
description: 'Table header for the current status of the exam attempt',
}),
Cell: ({ row }) => (capitalizeFirstLetter(row.original.status)),
Header: formatMessage(messages.examAttemptsTableHeaderStatus),
Cell: ({ row }) => constants.ExamAttemptStatus[row.original.status],
},
]}
>
<DataTable.TableControlBar />
<DataTable.Table />
<DataTable.EmptyTable content={formatMessage({
id: 'AttemptsList.DataTable.EmptyTable',
defaultMessage: 'No results found.',
description: 'Message that appears in the table if no data is found',
})}
/>
<DataTable.EmptyTable content={formatMessage(messages.examAttemptsTableHeaderEmptyTable)} />
<DataTable.TableFooter />
</DataTable>
</div>
Expand Down
40 changes: 12 additions & 28 deletions src/pages/ExamsPage/components/ResetExamAttemptButton.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -15,11 +16,8 @@ const ResetExamAttemptButton = ({ username, examName, attemptId }) => {
<>
<div className="d-flex">
<Button variant="link" size="sm" onClick={open}>
{formatMessage({
id: 'ResetExamAttemptButton.exam_name',
defaultMessage: 'Reset',
description: 'Table header for the table column with buttons to reset exam attempts',
})}
{/* TODO: Figure out why this has an extra semicolon on it by default */}
{formatMessage(messages.ResetExamAttemptButtonTitle)};
</Button>
</div>
<ModalDialog
Expand All @@ -33,45 +31,31 @@ const ResetExamAttemptButton = ({ username, examName, attemptId }) => {
>
<ModalDialog.Header>
<ModalDialog.Title>
{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)}
</ModalDialog.Title>
</ModalDialog.Header>

<ModalDialog.Body>
{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 */}
<p>{formatMessage(messages.ResetExamAttemptButtonModalBody)}</p>
<ul>
<li>{formatMessage(messages.Username)}{username}</li>
<li>{formatMessage(messages.ExamName)}{examName}</li>
</ul>
</ModalDialog.Body>

<ModalDialog.Footer>
<ActionRow>
<ModalDialog.CloseButton variant="tertiary">
{formatMessage({
id: 'ResetExamAttemptButton.cancel_button',
defaultMessage: 'No (Cancel)',
description: 'Text for the button to cancel resetting an exam attempt',
})}
{formatMessage(messages.ResetExamAttemptButtonCancel)}
</ModalDialog.CloseButton>
<Button
variant="primary"
onClick={e => { // eslint-disable-line no-unused-vars
resetExamAttempt(attemptId);
}}
>
{formatMessage({
id: 'ResetExamAttemptButton.confirm_button',
defaultMessage: 'Yes, I\'m Sure',
description: 'Text for the button to confirm the reset of an exam attempt',
})}
{formatMessage(messages.ResetExamAttemptButtonConfirm)}
</Button>
</ActionRow>
</ModalDialog.Footer>
Expand Down
88 changes: 88 additions & 0 deletions src/pages/ExamsPage/components/ReviewExamAttemptButton.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import PropTypes from 'prop-types';

import {
Button, useToggle, ModalDialog, ActionRow,
} from '@edx/paragon';
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, severity, submissionReason,
}) => {
const [isOpen, open, close] = useToggle(false);
const modifyExamAttempt = useModifyExamAttempt();
const { formatMessage } = useIntl();

return (
<>
<div className="d-flex text-danger">
<Button variant="link" size="sm" className="text-danger" onClick={open}>
<Warning />
{formatMessage(messages.ReviewExamAttemptButtonTitle)}
</Button>
</div>
<ModalDialog
title="my dialog"
isOpen={isOpen}
onClose={close}
size="md"
variant="default"
hasCloseButton
isFullscreenOnMobile
>
<ModalDialog.Header>
<ModalDialog.Title>
{formatMessage(messages.ReviewExamAttemptButtonModalTitle)}
</ModalDialog.Title>
</ModalDialog.Header>

<ModalDialog.Body>
{/* TODO: Figure out how to move this formatMessage with the variables. */}
<p>{formatMessage(messages.ReviewExamAttemptButtonModalBody)}</p>
<ul>
<li>{formatMessage(messages.Username)}{username}</li>
<li>{formatMessage(messages.ExamName)}{examName}</li>
<li>{formatMessage(messages.SuspicionLevel)}{severity}</li>
<li>{formatMessage(messages.SubmissionReason)}{submissionReason}</li>
</ul>
</ModalDialog.Body>

<ModalDialog.Footer>
<ActionRow>
<ModalDialog.CloseButton variant="tertiary">
{formatMessage(messages.ReviewExamAttemptButtonCancel)}
</ModalDialog.CloseButton>
<Button
variant="primary"
onClick={e => { // eslint-disable-line no-unused-vars
modifyExamAttempt(attemptId, 'verify');
}}
>
{formatMessage(messages.ReviewExamAttemptButtonVerify)}
</Button>
<Button
variant="primary"
onClick={e => { // eslint-disable-line no-unused-vars
modifyExamAttempt(attemptId, 'reject');
}}
>
{formatMessage(messages.ReviewExamAttemptButtonReject)}
</Button>
</ActionRow>
</ModalDialog.Footer>
</ModalDialog>
</>
);
};

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;
7 changes: 7 additions & 0 deletions src/pages/ExamsPage/data/api.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
const response = await getAuthenticatedHttpClient().put(url, payload);
return response.data;
}
Loading
Loading