feat: remove old/duplicate proctoring component (#671)

This commit is contained in:
Zachary Hancock
2023-11-09 08:55:25 -05:00
committed by GitHub
parent 78eb512836
commit 7c7b3cdc07
10 changed files with 5 additions and 1619 deletions

View File

@@ -259,7 +259,6 @@ Developing
If your devstack includes the default Demo course, you can visit the following URLs to see content:
- `Proctored Exam Settings <http://localhost:2001/course/course-v1:edX+DemoX+Demo_Course/proctored-exam-settings>`_
- `Pages and Resources <http://localhost:2001/course/course-v1:edX+DemoX+Demo_Course/pages-and-resources>`_
Troubleshooting

View File

@@ -1,10 +1,11 @@
import React from 'react';
import { Routes, Route, useParams } from 'react-router-dom';
import {
Navigate, Routes, Route, useParams,
} from 'react-router-dom';
import { PageWrap } from '@edx/frontend-platform/react';
import Placeholder from '@edx/frontend-lib-content-components';
import CourseAuthoringPage from './CourseAuthoringPage';
import { PagesAndResources } from './pages-and-resources';
import ProctoredExamSettings from './proctored-exam-settings/ProctoredExamSettings';
import EditorContainer from './editors/EditorContainer';
import VideoSelectorContainer from './selectors/VideoSelectorContainer';
import CustomPages from './custom-pages';
@@ -61,7 +62,7 @@ const CourseAuthoringRoutes = () => {
/>
<Route
path="proctored-exam-settings"
element={<PageWrap><ProctoredExamSettings courseId={courseId} /></PageWrap>}
element={<Navigate replace to={`/course/${courseId}/pages-and-resources`} />}
/>
<Route
path="custom-pages/*"

View File

@@ -8,7 +8,6 @@ import initializeStore from './store';
const courseId = 'course-v1:edX+TestX+Test_Course';
const pagesAndResourcesMockText = 'Pages And Resources';
const proctoredExamSeetingsMockText = 'Proctored Exam Settings';
const editorContainerMockText = 'Editor Container';
const videoSelectorContainerMockText = 'Video Selector Container';
const customPagesMockText = 'Custom Pages';
@@ -36,10 +35,6 @@ jest.mock('./pages-and-resources/PagesAndResources', () => (props) => {
mockComponentFn(props);
return pagesAndResourcesMockText;
});
jest.mock('./proctored-exam-settings/ProctoredExamSettings', () => (props) => {
mockComponentFn(props);
return proctoredExamSeetingsMockText;
});
jest.mock('./editors/EditorContainer', () => (props) => {
mockComponentFn(props);
return editorContainerMockText;
@@ -76,25 +71,6 @@ describe('<CourseAuthoringRoutes>', () => {
);
expect(screen.getByText(pagesAndResourcesMockText)).toBeVisible();
expect(screen.queryByText(proctoredExamSeetingsMockText)).not.toBeInTheDocument();
expect(mockComponentFn).toHaveBeenCalledWith(
expect.objectContaining({
courseId,
}),
);
});
it('renders the ProctoredExamSettings component when the proctored exam settings route is active', () => {
render(
<AppProvider store={store} wrapWithRouter={false}>
<MemoryRouter initialEntries={['/proctored-exam-settings']}>
<CourseAuthoringRoutes />
</MemoryRouter>
</AppProvider>,
);
expect(screen.queryByText(proctoredExamSeetingsMockText)).toBeInTheDocument();
expect(screen.queryByText(pagesAndResourcesMockText)).not.toBeInTheDocument();
expect(mockComponentFn).toHaveBeenCalledWith(
expect.objectContaining({
courseId,

View File

@@ -7,7 +7,6 @@
@import "assets/scss/form";
@import "assets/scss/utilities";
@import "assets/scss/animations";
@import "proctored-exam-settings/proctoredExamSettings";
@import "pages-and-resources/discussions/app-list/AppList";
@import "advanced-settings/scss/AdvancedSettings";
@import "grading-settings/scss/GradingSettings";

View File

@@ -235,7 +235,7 @@ const ProctoringSettings = ({ intl, onClose }) => {
);
}
const learnMoreLink = appInfo.documentationLinks?.learnMoreConfiguration && (
const learnMoreLink = appInfo?.documentationLinks?.learnMoreConfiguration && (
<Hyperlink
className="text-primary-500"
destination={appInfo.documentationLinks.learnMoreConfiguration}

View File

@@ -1,601 +0,0 @@
import React, { useState, useEffect, useRef } from 'react';
import { useDispatch } from 'react-redux';
import PropTypes from 'prop-types';
import EmailValidator from 'email-validator';
import moment from 'moment';
import {
Alert, Button, Form, Spinner,
} from '@edx/paragon';
import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
import {
injectIntl,
intlShape,
FormattedMessage,
} from '@edx/frontend-platform/i18n';
import { getConfig } from '@edx/frontend-platform';
import { useModel } from '../generic/model-store';
import messages from './ProctoredExamSettings.messages';
import ExamsApiService from '../data/services/ExamsApiService';
import StudioApiService from '../data/services/StudioApiService';
import Loading from '../generic/Loading';
import ConnectionErrorAlert from '../generic/ConnectionErrorAlert';
import PermissionDeniedAlert from '../generic/PermissionDeniedAlert';
import {
fetchExamSettingsFailure,
fetchExamSettingsPending,
fetchExamSettingsSuccess,
} from './data/thunks';
import getPageHeadTitle from '../generic/utils';
const ProctoredExamSettings = ({ courseId, intl }) => {
const dispatch = useDispatch();
const [loading, setLoading] = useState(true);
const [loaded, setLoaded] = useState(false);
const [loadingConnectionError, setLoadingConnectionError] = useState(false);
const [loadingPermissionError, setLoadingPermissionError] = useState(false);
const [enableProctoredExams, setEnableProctoredExams] = useState(true);
const [allowOptingOut, setAllowOptingOut] = useState(false);
const [allowLtiProviders, setAllowLtiProviders] = useState(false);
const [proctoringProvider, setProctoringProvider] = useState('');
const [availableProctoringProviders, setAvailableProctoringProviders] = useState([]);
const [ltiProctoringProviders, setLtiProctoringProviders] = useState([]);
const [proctortrackEscalationEmail, setProctortrackEscalationEmail] = useState('');
const [createZendeskTickets, setCreateZendeskTickets] = useState(false);
const [courseStartDate, setCourseStartDate] = useState('');
const [saveSuccess, setSaveSuccess] = useState(false);
const [saveError, setSaveError] = useState(false);
const [submissionInProgress, setSubmissionInProgress] = useState(false);
const [showProctortrackEscalationEmail, setShowProctortrackEscalationEmail] = useState(false);
const isEdxStaff = getAuthenticatedUser().administrator;
const [formStatus, setFormStatus] = useState({
isValid: true,
errors: {},
});
const courseDetails = useModel('courseDetails', courseId);
document.title = getPageHeadTitle(courseDetails?.name, 'Proctored Exam Settings');
const alertRef = React.createRef();
const saveStatusAlertRef = React.createRef();
const proctoringEscalationEmailInputRef = useRef(null);
const onEnableProctoredExamsChange = (event) => {
setEnableProctoredExams(event.target.checked);
};
function onAllowOptingOutChange(value) {
setAllowOptingOut(value);
}
function onCreateZendeskTicketsChange(value) {
setCreateZendeskTickets(value);
}
const onProctoringProviderChange = (event) => {
const provider = event.target.value;
setProctoringProvider(provider);
if (provider === 'proctortrack') {
setCreateZendeskTickets(false);
setShowProctortrackEscalationEmail(true);
} else {
if (provider === 'software_secure') {
setCreateZendeskTickets(true);
}
setShowProctortrackEscalationEmail(false);
}
};
const onProctortrackEscalationEmailChange = (event) => {
setProctortrackEscalationEmail(event.target.value);
};
const setFocusToProctortrackEscalationEmailInput = () => {
if (proctoringEscalationEmailInputRef && proctoringEscalationEmailInputRef.current) {
proctoringEscalationEmailInputRef.current.focus();
}
};
function isLtiProvider(provider) {
return ltiProctoringProviders.some(p => p.name === provider);
}
function postSettingsBackToServer() {
const providerIsLti = isLtiProvider(proctoringProvider);
const studioDataToPostBack = {
proctored_exam_settings: {
enable_proctored_exams: enableProctoredExams,
// lti providers are managed outside edx-platform, lti_external indicates this
proctoring_provider: providerIsLti ? 'lti_external' : proctoringProvider,
create_zendesk_tickets: createZendeskTickets,
},
};
if (isEdxStaff) {
studioDataToPostBack.proctored_exam_settings.allow_proctoring_opt_out = allowOptingOut;
}
if (proctoringProvider === 'proctortrack') {
studioDataToPostBack.proctored_exam_settings.proctoring_escalation_email = proctortrackEscalationEmail === '' ? null : proctortrackEscalationEmail;
}
setSubmissionInProgress(true);
// only save back to exam service if necessary
const saveOperations = [StudioApiService.saveProctoredExamSettingsData(courseId, studioDataToPostBack)];
if (allowLtiProviders && ExamsApiService.isAvailable()) {
saveOperations.push(
ExamsApiService.saveCourseExamConfiguration(courseId, { provider: providerIsLti ? proctoringProvider : null }),
);
}
Promise.all(saveOperations)
.then(() => {
setSaveSuccess(true);
setSaveError(false);
setSubmissionInProgress(false);
}).catch(() => {
setSaveSuccess(false);
setSaveError(true);
setSubmissionInProgress(false);
});
}
const handleSubmit = (event) => {
event.preventDefault();
if (proctoringProvider === 'proctortrack' && !EmailValidator.validate(proctortrackEscalationEmail) && !(proctortrackEscalationEmail === '' && !enableProctoredExams)) {
if (proctortrackEscalationEmail === '') {
const errorMessage = intl.formatMessage(messages['authoring.examsettings.escalationemail.error.blank']);
setFormStatus({
isValid: false,
errors: {
formProctortrackEscalationEmail: {
dialogErrorMessage: (<Alert.Link onClick={setFocusToProctortrackEscalationEmailInput} href="#formProctortrackEscalationEmail" data-testid="proctorTrackEscalationEmailErrorLink">{errorMessage}</Alert.Link>),
inputErrorMessage: errorMessage,
},
},
});
} else {
const errorMessage = intl.formatMessage(messages['authoring.examsettings.escalationemail.error.invalid']);
setFormStatus({
isValid: false,
errors: {
formProctortrackEscalationEmail: {
dialogErrorMessage: (<Alert.Link onClick={setFocusToProctortrackEscalationEmailInput} href="#formProctortrackEscalationEmail" data-testid="proctorTrackEscalationEmailErrorLink">{errorMessage}</Alert.Link>),
inputErrorMessage: errorMessage,
},
},
});
}
} else {
postSettingsBackToServer();
const errors = { ...formStatus.errors };
delete errors.formProctortrackEscalationEmail;
setFormStatus({
isValid: true,
errors,
});
}
};
function cannotEditProctoringProvider() {
const currentDate = moment(moment()).format('YYYY-MM-DD[T]hh:mm:ss[Z]');
const isAfterCourseStart = currentDate > courseStartDate;
// if the user is not edX staff and it is after the course start date, user cannot edit proctoring provider
return !isEdxStaff && isAfterCourseStart;
}
function isDisabledOption(provider) {
let markDisabled = false;
if (cannotEditProctoringProvider()) {
markDisabled = provider !== proctoringProvider;
}
return markDisabled;
}
function getProviderDisplayLabel(provider) {
// if a display label exists for this provider return it
return ltiProctoringProviders.find(p => p.name === provider)?.verbose_name || provider;
}
function getProctoringProviderOptions(providers) {
return providers.map(provider => (
<option
key={provider}
value={provider}
disabled={isDisabledOption(provider)}
data-testid={provider}
>
{getProviderDisplayLabel(provider)}
</option>
));
}
function getFormErrorMessage() {
const numOfErrors = Object.keys(formStatus.errors).length;
const errors = Object.entries(formStatus.errors).map(([id, error]) => <li key={id}>{error.dialogErrorMessage}</li>);
const messageId = numOfErrors > 1 ? 'authoring.examsettings.error.multiple' : 'authoring.examsettings.error.single';
return (
<>
<div>{intl.formatMessage(messages[messageId], { numOfErrors })}</div>
<ul>
{errors}
</ul>
</>
);
}
function renderContent() {
return (
<Form onSubmit={handleSubmit} data-testid="proctoringForm">
{!formStatus.isValid && formStatus.errors.formProctortrackEscalationEmail
&& (
// tabIndex="-1" to make non-focusable element focusable
<Alert
id="proctortrackEscalationEmailError"
variant="danger"
tabIndex="-1"
data-testid="proctortrackEscalationEmailError"
ref={alertRef}
>
{getFormErrorMessage()}
</Alert>
)}
{/* ENABLE PROCTORED EXAMS */}
<Form.Group controlId="formEnableProctoredExam">
<Form.Check
type="checkbox"
id="enableProctoredExams"
label={intl.formatMessage(messages['authoring.examsettings.enableproctoredexams.label'])}
aria-describedby="enableProctoredExamsHelpText"
onChange={onEnableProctoredExamsChange}
checked={enableProctoredExams}
inline
/>
<Form.Text id="enableProctoredExamsHelpText">
<FormattedMessage
id="authoring.examsettings.enableproctoredexams.help"
defaultMessage="If checked, proctored exams are enabled in your course."
description="Help text for checkbox to enable proctored exams."
/>
</Form.Text>
</Form.Group>
{/* ALLOW OPTING OUT OF PROCTORED EXAMS */}
{ isEdxStaff && enableProctoredExams && (
<fieldset aria-describedby="allowOptingOutHelpText">
<Form.Group controlId="formAllowingOptingOut">
<Form.Label as="legend">
<FormattedMessage
id="authoring.examsettings.allowoptout.label"
defaultMessage="Allow Opting Out of Proctored Exams"
description="Label for radio selection allowing proctored exam opt out"
/>
</Form.Label>
<Form.Check
type="radio"
id="allowOptingOutYes"
name="allowOptingOut"
label={intl.formatMessage(messages['authoring.examsettings.allowoptout.yes'])}
inline
checked={allowOptingOut}
onChange={() => onAllowOptingOutChange(true)}
data-testid="allowOptingOutYes"
/>
<Form.Check
type="radio"
id="allowOptingOutNo"
name="allowOptingOut"
label={intl.formatMessage(messages['authoring.examsettings.allowoptout.no'])}
inline
checked={!allowOptingOut}
onChange={() => onAllowOptingOutChange(false)}
data-testid="allowOptingOutNo"
/>
<Form.Text id="allowOptingOutHelpText">
<FormattedMessage
id="authoring.examsettings.allowoptout.help"
defaultMessage={`
If this value is "Yes", learners can choose to take proctored exams without proctoring.
If this value is "No", all learners must take the exam with proctoring.
`}
description="Help text for proctored exam opt out radio selection"
/>
</Form.Text>
</Form.Group>
</fieldset>
)}
{/* PROCTORING PROVIDER */}
{ enableProctoredExams && (
<Form.Group controlId="formProctoringProvider">
<Form.Label as="legend">
<FormattedMessage
id="authoring.examsettings.provider.label"
defaultMessage="Proctoring Provider"
description="Label for proctoring provider dropdown selection"
/>
</Form.Label>
<Form.Control
as="select"
value={proctoringProvider}
onChange={onProctoringProviderChange}
aria-describedby="proctoringProviderHelpText"
>
{getProctoringProviderOptions(availableProctoringProviders)}
</Form.Control>
<Form.Text id="proctoringProviderHelpText">
{cannotEditProctoringProvider() ? intl.formatMessage(messages['authoring.examsettings.provider.help.aftercoursestart']) : intl.formatMessage(messages['authoring.examsettings.provider.help'])}
</Form.Text>
</Form.Group>
)}
{/* PROCTORTRACK ESCALATION EMAIL */}
{showProctortrackEscalationEmail && enableProctoredExams && (
<Form.Group controlId="formProctortrackEscalationEmail">
<Form.Label>
<FormattedMessage
id="authoring.examsettings.escalationemail.label"
defaultMessage="Proctortrack Escalation Email"
description="Label for escalation email text field"
/>
</Form.Label>
<Form.Control
ref={proctoringEscalationEmailInputRef}
type="email"
data-testid="escalationEmail"
onChange={onProctortrackEscalationEmailChange}
value={proctortrackEscalationEmail}
isInvalid={Object.prototype.hasOwnProperty.call(formStatus.errors, 'formProctortrackEscalationEmail')}
aria-describedby="proctortrackEscalationEmailHelpText"
/>
<Form.Control.Feedback type="invalid">{formStatus.errors.formProctortrackEscalationEmail && formStatus.errors.formProctortrackEscalationEmail.inputErrorMessage} </Form.Control.Feedback>
<Form.Text id="proctortrackEscalationEmailHelpText">
<FormattedMessage
id="authoring.examsettings.escalationemail.help"
defaultMessage={`
Required if "proctortrack" is selected as your proctoring provider. Enter an email address to be
contacted by the support team whenever there are escalations (e.g. appeals, delayed reviews, etc.).
`}
description="Help text explaining escalation email field."
/>
</Form.Text>
</Form.Group>
)}
{/* CREATE ZENDESK TICKETS */}
{ isEdxStaff && enableProctoredExams && !isLtiProvider(proctoringProvider) && (
<fieldset aria-describedby="createZendeskTicketsText">
<Form.Group controlId="formCreateZendeskTickets">
<Form.Label as="legend">
<FormattedMessage
id="authoring.examsettings.createzendesk.label"
defaultMessage="Create Zendesk Tickets for Suspicious Proctored Exam Attempts"
description="Label for Zendesk ticket creation radio select."
/>
</Form.Label>
<Form.Check
type="radio"
id="createZendeskTicketsYes"
label={intl.formatMessage(messages['authoring.examsettings.createzendesk.yes'])}
inline
name="createZendeskTickets"
checked={createZendeskTickets}
onChange={() => onCreateZendeskTicketsChange(true)}
data-testid="createZendeskTicketsYes"
/>
<Form.Check
type="radio"
id="createZendeskTicketsNo"
label={intl.formatMessage(messages['authoring.examsettings.createzendesk.no'])}
inline
name="createZendeskTickets"
checked={!createZendeskTickets}
onChange={() => onCreateZendeskTicketsChange(false)}
data-testid="createZendeskTicketsNo"
/>
<Form.Text id="createZendeskTicketsText">
<FormattedMessage
id="authoring.examsettings.createzendesk.help"
defaultMessage="If this value is &quot;Yes&quot;, a Zendesk ticket will be created for suspicious proctored exam attempts."
description="Help text for Zendesk ticket creation radio select"
/>
</Form.Text>
</Form.Group>
</fieldset>
)}
<Button
variant="primary"
className="mb-3"
data-testid="submissionButton"
type="submit"
disabled={submissionInProgress}
>
<FormattedMessage
id="authoring.examsettings.submit"
defaultMessage="Submit"
description="Form submit button"
/>
</Button> {' '}
{submissionInProgress && <Spinner animation="border" variant="primary" data-testid="saveInProgress" aria-label="Save in progress" />}
</Form>
);
}
function renderLoading() {
return (
<Loading />
);
}
function renderConnectionError() {
return (
<ConnectionErrorAlert />
);
}
function renderPermissionError() {
return (
<PermissionDeniedAlert />
);
}
function renderSaveSuccess() {
const studioCourseRunURL = StudioApiService.getStudioCourseRunUrl(courseId);
return (
<Alert
variant="success"
dismissible
data-testid="saveSuccess"
tabIndex="-1"
ref={saveStatusAlertRef}
onClose={() => setSaveSuccess(false)}
>
<FormattedMessage
id="authoring.examsettings.alert.success"
defaultMessage={`
Proctored exam settings saved successfully.
You can go back to your course in Studio {studioCourseRunURL}.
`}
values={{ studioCourseRunURL: <Alert.Link href={studioCourseRunURL}>here</Alert.Link> }}
/>
</Alert>
);
}
function renderSaveError() {
return (
<Alert
variant="danger"
dismissible
data-testid="saveError"
tabIndex="-1"
ref={saveStatusAlertRef}
onClose={() => setSaveError(false)}
>
<FormattedMessage
id="authoring.examsettings.alert.error"
defaultMessage={`
We encountered a technical error while trying to save proctored exam settings.
This might be a temporary issue, so please try again in a few minutes.
If the problem persists,
please go to the {support_link} for help.
`}
values={{
support_link: (
<Alert.Link href={getConfig().SUPPORT_URL}>
{intl.formatMessage(messages['authoring.examsettings.support.text'])}
</Alert.Link>
),
}}
/>
</Alert>
);
}
useEffect(() => {
dispatch(fetchExamSettingsPending(courseId));
Promise.all([
StudioApiService.getProctoredExamSettingsData(courseId),
ExamsApiService.isAvailable() ? ExamsApiService.getCourseExamConfiguration(courseId) : Promise.resolve(),
ExamsApiService.isAvailable() ? ExamsApiService.getAvailableProviders() : Promise.resolve(),
])
.then(
([settingsResponse, examConfigResponse, ltiProvidersResponse]) => {
const proctoredExamSettings = settingsResponse.data.proctored_exam_settings;
setLoaded(true);
setLoading(false);
setSubmissionInProgress(false);
setCourseStartDate(settingsResponse.data.course_start_date);
setEnableProctoredExams(proctoredExamSettings.enable_proctored_exams);
setAllowOptingOut(proctoredExamSettings.allow_proctoring_opt_out);
const isProctortrack = proctoredExamSettings.proctoring_provider === 'proctortrack';
setShowProctortrackEscalationEmail(isProctortrack);
// The list of providers returned by studio settings are the default behavior. If lti_external
// is available as an option display the list of LTI providers returned by the exam service.
// Setting 'lti_external' in studio indicates an LTI provider configured outside of edx-platform.
// This option is not directly selectable.
const proctoringProvidersStudio = settingsResponse.data.available_proctoring_providers;
const proctoringProvidersLti = ltiProvidersResponse?.data || [];
const enableLtiProviders = proctoringProvidersStudio.includes('lti_external');
setAllowLtiProviders(enableLtiProviders);
setLtiProctoringProviders(proctoringProvidersLti);
// flatten provider objects and coalesce values to just the provider key
let availableProviders = proctoringProvidersStudio.filter(value => value !== 'lti_external');
if (enableLtiProviders) {
availableProviders = proctoringProvidersLti.reduce(
(result, provider) => [...result, provider.name],
availableProviders,
);
}
setAvailableProctoringProviders(availableProviders);
if (proctoredExamSettings.proctoring_provider === 'lti_external') {
setProctoringProvider(examConfigResponse.data.provider);
} else {
setProctoringProvider(proctoredExamSettings.proctoring_provider);
}
// The backend API may return null for the proctoringEscalationEmail value, which is the default.
// In order to keep our email input component controlled, we use the empty string as the default
// and perform this conversion during GETs and POSTs.
const proctoringEscalationEmail = proctoredExamSettings.proctoring_escalation_email;
setProctortrackEscalationEmail(proctoringEscalationEmail === null ? '' : proctoringEscalationEmail);
setCreateZendeskTickets(proctoredExamSettings.create_zendesk_tickets);
dispatch(fetchExamSettingsSuccess(courseId));
},
)
.catch(
error => {
if (error.response?.status === 403) {
setLoadingPermissionError(true);
} else {
setLoadingConnectionError(true);
}
setLoading(false);
setLoaded(false);
setSubmissionInProgress(false);
dispatch(fetchExamSettingsFailure(courseId));
},
);
}, []);
useEffect(() => {
if ((saveSuccess || saveError) && !!saveStatusAlertRef.current) {
saveStatusAlertRef.current.focus();
}
if (!formStatus.isValid && !!alertRef.current) {
alertRef.current.focus();
}
}, [formStatus, saveSuccess, saveError]);
return (
<div className="container">
<h2 className="mt-3 mb-2">
Proctored Exam Settings
</h2>
<div>
{loading ? renderLoading() : null}
{saveSuccess ? renderSaveSuccess() : null}
{saveError ? renderSaveError() : null}
{loaded ? renderContent() : null}
{loadingConnectionError ? renderConnectionError() : null}
{loadingPermissionError ? renderPermissionError() : null}
</div>
</div>
);
};
ProctoredExamSettings.propTypes = {
courseId: PropTypes.string.isRequired,
intl: intlShape.isRequired,
};
ProctoredExamSettings.defaultProps = {};
export default injectIntl(ProctoredExamSettings);

View File

@@ -1,71 +0,0 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
'authoring.examsettings.allowoptout.no': {
id: 'authoring.examsettings.allowoptout.no',
defaultMessage: 'No',
description: '"No" option for yes/no radio button set',
},
'authoring.examsettings.allowoptout.yes': {
id: 'authoring.examsettings.allowoptout.yes',
defaultMessage: 'Yes',
description: '"Yes" option for yes/no radio button set',
},
'authoring.examsettings.createzendesk.no': {
id: 'authoring.examsettings.createzendesk.no',
defaultMessage: 'No',
description: '"No" option for yes/no radio button set.',
},
'authoring.examsettings.createzendesk.yes': {
id: 'authoring.examsettings.createzendesk.yes',
defaultMessage: 'Yes',
description: '"Yes" option for yes/no radio button set.',
},
'authoring.examsettings.support.text': {
id: 'authoring.examsettings.support.text',
defaultMessage: 'Support Page',
description: 'Text linking to the support page.',
},
'authoring.examsettings.enableproctoredexams.label': {
id: 'authoring.examsettings.escalationemail.enableproctoredexams.label',
defaultMessage: 'Enable Proctored Exams',
description: 'Label for checkbox to enable proctored exams.',
},
'authoring.examsettings.escalationemail.error.blank': {
id: 'authoring.examsettings.escalationemail.error.blank',
defaultMessage: 'The Proctortrack Escalation Email field cannot be empty if proctortrack is the selected provider.',
description: 'Error message for missing required email field.',
},
'authoring.examsettings.escalationemail.error.invalid': {
id: 'authoring.examsettings.escalationemail.error.invalid',
defaultMessage: 'The Proctortrack Escalation Email field is in the wrong format and is not valid.',
description: 'Error message for a invalid email format.',
},
'authoring.examsettings.error.single': {
id: 'authoring.examsettings.error.single',
defaultMessage: 'There is 1 error in this form.',
description: 'Error alert for one and only one error in the form.',
},
'authoring.examsettings.error.multiple': {
id: 'authoring.examsettings.escalationemail.error.multiple',
defaultMessage: 'There are {numOfErrors} errors in this form.',
description: 'Error alert for multiple errors in the form.',
},
'authoring.examsettings.provider.label': {
id: 'authoring.examsettings.provider.label',
defaultMessage: 'Proctoring Provider',
description: 'Label for provider dropdown selection.',
},
'authoring.examsettings.provider.help': {
id: 'authoring.examsettings.provider.help',
defaultMessage: 'Select the proctoring provider you want to use for this course run.',
description: 'Help text for selecting a proctoring provider.',
},
'authoring.examsettings.provider.help.aftercoursestart': {
id: 'authoring.examsettings.provider.help.aftercoursestart',
defaultMessage: 'Proctoring provider cannot be modified after course start date.',
description: 'Help text notifying the user that the provider cannot be changed for a course that has already begun.',
},
});
export default messages;

View File

@@ -1,886 +0,0 @@
import React from 'react';
import {
render, screen, cleanup, waitFor, waitForElementToBeRemoved, fireEvent, act,
} from '@testing-library/react';
import { IntlProvider, injectIntl } from '@edx/frontend-platform/i18n';
// import * as auth from '@edx/frontend-platform/auth';
import MockAdapter from 'axios-mock-adapter';
import { initializeMockApp, mergeConfig } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { AppProvider } from '@edx/frontend-platform/react';
import ProctoredExamSettings from './ProctoredExamSettings';
import StudioApiService from '../data/services/StudioApiService';
import ExamsApiService from '../data/services/ExamsApiService';
import initializeStore from '../store';
const defaultProps = {
courseId: 'course-v1%3AedX%2BDemoX%2BDemo_Course',
};
const IntlProctoredExamSettings = injectIntl(ProctoredExamSettings);
let axiosMock;
let store;
const intlWrapper = children => (
<AppProvider store={store}>
<IntlProvider locale="en">
{children}
</IntlProvider>
</AppProvider>
);
describe('ProctoredExamSettings', () => {
function setupApp(isAdmin = true) {
mergeConfig({
EXAMS_BASE_URL: 'http://exams.testing.co',
}, 'CourseAuthoringConfig');
initializeMockApp({
authenticatedUser: {
userId: 3,
username: 'abc123',
administrator: isAdmin,
roles: [],
},
});
store = initializeStore();
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
axiosMock.onGet(
`${ExamsApiService.getExamsBaseUrl()}/api/v1/providers`,
).reply(200, [
{
name: 'test_lti',
verbose_name: 'LTI Provider',
},
]);
axiosMock.onGet(
`${ExamsApiService.getExamsBaseUrl()}/api/v1/configs/course_id/${defaultProps.courseId}`,
).reply(200, {
provider: null,
});
axiosMock.onGet(
StudioApiService.getProctoredExamSettingsUrl(defaultProps.courseId),
).reply(200, {
proctored_exam_settings: {
enable_proctored_exams: true,
allow_proctoring_opt_out: false,
proctoring_provider: 'mockproc',
proctoring_escalation_email: 'test@example.com',
create_zendesk_tickets: true,
},
available_proctoring_providers: ['software_secure', 'proctortrack', 'mockproc', 'lti_external'],
course_start_date: '2070-01-01T00:00:00Z',
});
}
afterEach(() => {
cleanup();
axiosMock.reset();
});
beforeEach(async () => {
setupApp();
});
describe('Field dependencies', () => {
beforeEach(async () => {
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
});
it('Updates Zendesk ticket field if proctortrack is provider', async () => {
await waitFor(() => {
screen.getByDisplayValue('mockproc');
});
const selectElement = screen.getByDisplayValue('mockproc');
await act(async () => {
fireEvent.change(selectElement, { target: { value: 'proctortrack' } });
});
const zendeskTicketInput = screen.getByTestId('createZendeskTicketsNo');
expect(zendeskTicketInput.checked).toEqual(true);
});
it('Updates Zendesk ticket field if software_secure is provider', async () => {
await waitFor(() => {
screen.getByDisplayValue('mockproc');
});
const selectElement = screen.getByDisplayValue('mockproc');
await act(async () => {
fireEvent.change(selectElement, { target: { value: 'software_secure' } });
});
const zendeskTicketInput = screen.getByTestId('createZendeskTicketsYes');
expect(zendeskTicketInput.checked).toEqual(true);
});
it('Does not update zendesk ticket field for any other provider', async () => {
await waitFor(() => {
screen.getByDisplayValue('mockproc');
});
const selectElement = screen.getByDisplayValue('mockproc');
await act(async () => {
fireEvent.change(selectElement, { target: { value: 'mockproc' } });
});
const zendeskTicketInput = screen.getByTestId('createZendeskTicketsYes');
expect(zendeskTicketInput.checked).toEqual(true);
});
it('Hides all other fields when enabledProctorExam is false when first loaded', async () => {
cleanup();
// Overrides the handler defined in beforeEach.
axiosMock.onGet(
StudioApiService.getProctoredExamSettingsUrl(defaultProps.courseId),
).reply(200, {
proctored_exam_settings: {
enable_proctored_exams: false,
allow_proctoring_opt_out: false,
proctoring_provider: 'mockproc',
proctoring_escalation_email: 'test@example.com',
create_zendesk_tickets: true,
},
available_proctoring_providers: ['software_secure', 'proctortrack', 'mockproc'],
course_start_date: '2070-01-01T00:00:00Z',
});
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
await waitFor(() => {
screen.getByLabelText('Enable Proctored Exams');
});
const enabledProctoredExamCheck = screen.getByLabelText('Enable Proctored Exams');
expect(enabledProctoredExamCheck.checked).toEqual(false);
expect(screen.queryByText('Allow Opting Out of Proctored Exams')).toBeNull();
expect(screen.queryByDisplayValue('mockproc')).toBeNull();
expect(screen.queryByTestId('escalationEmail')).toBeNull();
expect(screen.queryByTestId('createZendeskTicketsYes')).toBeNull();
expect(screen.queryByTestId('createZendeskTicketsNo')).toBeNull();
});
it('Hides all other fields when enableProctoredExams toggled to false', async () => {
await waitFor(() => {
screen.getByLabelText('Enable Proctored Exams');
});
expect(screen.queryByText('Allow Opting Out of Proctored Exams')).toBeDefined();
expect(screen.queryByDisplayValue('mockproc')).toBeDefined();
expect(screen.queryByTestId('escalationEmail')).toBeDefined();
expect(screen.queryByTestId('createZendeskTicketsYes')).toBeDefined();
expect(screen.queryByTestId('createZendeskTicketsNo')).toBeDefined();
let enabledProctorExamCheck = screen.getByLabelText('Enable Proctored Exams');
expect(enabledProctorExamCheck.checked).toEqual(true);
await act(async () => {
fireEvent.click(enabledProctorExamCheck, { target: { value: false } });
});
enabledProctorExamCheck = screen.getByLabelText('Enable Proctored Exams');
expect(enabledProctorExamCheck.checked).toEqual(false);
expect(screen.queryByText('Allow Opting Out of Proctored Exams')).toBeNull();
expect(screen.queryByDisplayValue('mockproc')).toBeNull();
expect(screen.queryByTestId('escalationEmail')).toBeNull();
expect(screen.queryByTestId('createZendeskTicketsYes')).toBeNull();
expect(screen.queryByTestId('createZendeskTicketsNo')).toBeNull();
});
it('Hides unsupported fields when lti provider is selected', async () => {
await waitFor(() => {
screen.getByDisplayValue('mockproc');
});
const selectElement = screen.getByDisplayValue('mockproc');
await act(async () => {
fireEvent.change(selectElement, { target: { value: 'test_lti' } });
});
expect(screen.queryByTestId('escalationEmail')).toBeNull();
expect(screen.queryByTestId('createZendeskTicketsYes')).toBeNull();
expect(screen.queryByTestId('createZendeskTicketsNo')).toBeNull();
});
});
describe('Validation with invalid escalation email', () => {
beforeEach(async () => {
axiosMock.onGet(
StudioApiService.getProctoredExamSettingsUrl(defaultProps.courseId),
).reply(200, {
proctored_exam_settings: {
enable_proctored_exams: true,
allow_proctoring_opt_out: false,
proctoring_provider: 'proctortrack',
proctoring_escalation_email: 'test@example.com',
create_zendesk_tickets: true,
},
available_proctoring_providers: ['software_secure', 'proctortrack', 'mockproc'],
course_start_date: '2070-01-01T00:00:00Z',
});
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
});
it('Creates an alert when no proctoring escalation email is provided with proctortrack selected', async () => {
await waitFor(() => {
screen.getByDisplayValue('proctortrack');
});
const selectEscalationEmailElement = screen.getByDisplayValue('test@example.com');
await act(async () => {
fireEvent.change(selectEscalationEmailElement, { target: { value: '' } });
});
const selectButton = screen.getByTestId('submissionButton');
await act(async () => {
fireEvent.click(selectButton);
});
// verify alert content and focus management
const escalationEmailError = screen.getByTestId('proctortrackEscalationEmailError');
expect(escalationEmailError.textContent).not.toBeNull();
expect(document.activeElement).toEqual(escalationEmailError);
// verify alert link links to offending input
const errorLink = screen.getByTestId('proctorTrackEscalationEmailErrorLink');
await act(async () => {
fireEvent.click(errorLink);
});
const escalationEmailInput = screen.getByTestId('escalationEmail');
expect(document.activeElement).toEqual(escalationEmailInput);
});
it('Creates an alert when invalid proctoring escalation email is provided with proctortrack selected', async () => {
await waitFor(() => {
screen.getByDisplayValue('proctortrack');
});
const selectEscalationEmailElement = screen.getByDisplayValue('test@example.com');
await act(async () => {
fireEvent.change(selectEscalationEmailElement, { target: { value: 'foo.bar' } });
});
const selectButton = screen.getByTestId('submissionButton');
await act(async () => {
fireEvent.click(selectButton);
});
// verify alert content and focus management
const escalationEmailError = screen.getByTestId('proctortrackEscalationEmailError');
expect(document.activeElement).toEqual(escalationEmailError);
expect(escalationEmailError.textContent).not.toBeNull();
expect(document.activeElement).toEqual(escalationEmailError);
// verify alert link links to offending input
const errorLink = screen.getByTestId('proctorTrackEscalationEmailErrorLink');
await act(async () => {
fireEvent.click(errorLink);
});
const escalationEmailInput = screen.getByTestId('escalationEmail');
expect(document.activeElement).toEqual(escalationEmailInput);
});
it('Creates an alert when invalid proctoring escalation email is provided with proctoring disabled', async () => {
await waitFor(() => {
screen.getByDisplayValue('proctortrack');
});
const selectEscalationEmailElement = screen.getByDisplayValue('test@example.com');
await act(async () => {
fireEvent.change(selectEscalationEmailElement, { target: { value: 'foo.bar' } });
});
const enableProctoringElement = screen.getByLabelText('Enable Proctored Exams');
await act(async () => fireEvent.click(enableProctoringElement));
const selectButton = screen.getByTestId('submissionButton');
await act(async () => {
fireEvent.click(selectButton);
});
// verify alert content and focus management
const escalationEmailError = screen.getByTestId('proctortrackEscalationEmailError');
expect(document.activeElement).toEqual(escalationEmailError);
expect(escalationEmailError.textContent).not.toBeNull();
expect(document.activeElement).toEqual(escalationEmailError);
});
it('Has no error when invalid proctoring escalation email is provided with proctoring disabled', async () => {
axiosMock.onPost(
StudioApiService.getProctoredExamSettingsUrl(defaultProps.courseId),
).reply(200, 'success');
axiosMock.onPatch(
`${ExamsApiService.getExamsBaseUrl()}/api/v1/configs/course_id/${defaultProps.courseId}`,
).reply(200, 'success');
await waitFor(() => {
screen.getByDisplayValue('proctortrack');
});
const selectEscalationEmailElement = screen.getByDisplayValue('test@example.com');
await act(async () => {
fireEvent.change(selectEscalationEmailElement, { target: { value: '' } });
});
const enableProctoringElement = screen.getByLabelText('Enable Proctored Exams');
await act(async () => fireEvent.click(enableProctoringElement));
const selectButton = screen.getByTestId('submissionButton');
await act(async () => {
fireEvent.click(selectButton);
});
// verify there is no escalation email alert, and focus has been set on save success alert
expect(screen.queryByTestId('proctortrackEscalationEmailError')).toBeNull();
const errorAlert = screen.getByTestId('saveSuccess');
expect(errorAlert.textContent).toEqual(
expect.stringContaining('Proctored exam settings saved successfully.'),
);
expect(document.activeElement).toEqual(errorAlert);
});
it('Has no error when valid proctoring escalation email is provided with proctortrack selected', async () => {
axiosMock.onPost(
StudioApiService.getProctoredExamSettingsUrl(defaultProps.courseId),
).reply(200, 'success');
axiosMock.onPatch(
`${ExamsApiService.getExamsBaseUrl()}/api/v1/configs/course_id/${defaultProps.courseId}`,
).reply(200, 'success');
await waitFor(() => {
screen.getByDisplayValue('proctortrack');
});
const selectEscalationEmailElement = screen.getByDisplayValue('test@example.com');
await act(async () => {
fireEvent.change(selectEscalationEmailElement, { target: { value: 'foo@bar.com' } });
});
const selectButton = screen.getByTestId('submissionButton');
await act(async () => {
fireEvent.click(selectButton);
});
// verify there is no escalation email alert, and focus has been set on save success alert
expect(screen.queryByTestId('proctortrackEscalationEmailError')).toBeNull();
const errorAlert = screen.getByTestId('saveSuccess');
expect(errorAlert.textContent).toEqual(
expect.stringContaining('Proctored exam settings saved successfully.'),
);
expect(document.activeElement).toEqual(errorAlert);
});
it('Escalation email field hidden when proctoring backend is not Proctortrack', async () => {
await waitFor(() => {
screen.getByDisplayValue('proctortrack');
});
const proctoringBackendSelect = screen.getByDisplayValue('proctortrack');
const selectEscalationEmailElement = screen.getByTestId('escalationEmail');
expect(selectEscalationEmailElement.value).toEqual('test@example.com');
await act(async () => {
fireEvent.change(proctoringBackendSelect, { target: { value: 'software_secure' } });
});
expect(screen.queryByTestId('escalationEmail')).toBeNull();
});
it('Escalation email Field Show when proctoring backend is switched back to Proctortrack', async () => {
await waitFor(() => {
screen.getByDisplayValue('proctortrack');
});
const proctoringBackendSelect = screen.getByDisplayValue('proctortrack');
let selectEscalationEmailElement = screen.getByTestId('escalationEmail');
await act(async () => {
fireEvent.change(proctoringBackendSelect, { target: { value: 'software_secure' } });
});
expect(screen.queryByTestId('escalationEmail')).toBeNull();
await act(async () => {
fireEvent.change(proctoringBackendSelect, { target: { value: 'proctortrack' } });
});
expect(screen.queryByTestId('escalationEmail')).toBeDefined();
selectEscalationEmailElement = screen.getByTestId('escalationEmail');
expect(selectEscalationEmailElement.value).toEqual('test@example.com');
});
it('Submits form when "Enter" key is hit in the escalation email field', async () => {
await waitFor(() => {
screen.getByDisplayValue('proctortrack');
});
const selectEscalationEmailElement = screen.getByDisplayValue('test@example.com');
await act(async () => {
fireEvent.change(selectEscalationEmailElement, { target: { value: '' } });
});
await act(async () => {
fireEvent.submit(selectEscalationEmailElement);
});
// if the error appears, the form has been submitted
expect(screen.getByTestId('proctortrackEscalationEmailError')).toBeDefined();
});
});
describe('Proctoring provider options', () => {
const mockGetFutureCourseData = {
proctored_exam_settings: {
enable_proctored_exams: true,
allow_proctoring_opt_out: false,
proctoring_provider: 'mockproc',
proctoring_escalation_email: 'test@example.com',
create_zendesk_tickets: true,
},
available_proctoring_providers: ['software_secure', 'proctortrack', 'mockproc'],
course_start_date: '2099-01-01T00:00:00Z',
};
const mockGetPastCourseData = {
proctored_exam_settings: {
enable_proctored_exams: true,
allow_proctoring_opt_out: false,
proctoring_provider: 'mockproc',
proctoring_escalation_email: 'test@example.com',
create_zendesk_tickets: true,
},
available_proctoring_providers: ['software_secure', 'proctortrack', 'mockproc'],
course_start_date: '2013-01-01T00:00:00Z',
};
function mockCourseData(data) {
axiosMock.onGet(StudioApiService.getProctoredExamSettingsUrl(defaultProps.courseId)).reply(200, data);
}
it('Disables irrelevant proctoring provider fields when user is not an administrator and it is after start date', async () => {
const isAdmin = false;
setupApp(isAdmin);
mockCourseData(mockGetPastCourseData);
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
const providerOption = screen.getByTestId('proctortrack');
expect(providerOption.hasAttribute('disabled')).toEqual(true);
});
it('Enables all proctoring provider options if user is not an administrator and it is before start date', async () => {
const isAdmin = false;
setupApp(isAdmin);
mockCourseData(mockGetFutureCourseData);
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
const providerOption = screen.getByTestId('proctortrack');
expect(providerOption.hasAttribute('disabled')).toEqual(false);
});
it('Enables all proctoring provider options if user administrator and it is after start date', async () => {
const isAdmin = true;
setupApp(isAdmin);
mockCourseData(mockGetPastCourseData);
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
const providerOption = screen.getByTestId('proctortrack');
expect(providerOption.hasAttribute('disabled')).toEqual(false);
});
it('Enables all proctoring provider options if user administrator and it is before start date', async () => {
const isAdmin = true;
setupApp(isAdmin);
mockCourseData(mockGetFutureCourseData);
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
const providerOption = screen.getByTestId('proctortrack');
expect(providerOption.hasAttribute('disabled')).toEqual(false);
});
it('Does not include lti_external as a selectable option', async () => {
const courseData = mockGetFutureCourseData;
courseData.available_proctoring_providers = ['lti_external', 'proctortrack', 'mockproc'];
mockCourseData(courseData);
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
await waitFor(() => {
screen.getByDisplayValue('mockproc');
});
expect(screen.queryByTestId('lti_external')).toBeNull();
});
it('Includes lti proctoring provider options when lti_external is allowed by studio', async () => {
const courseData = mockGetFutureCourseData;
courseData.available_proctoring_providers = ['lti_external', 'proctortrack', 'mockproc'];
mockCourseData(courseData);
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
await waitFor(() => {
screen.getByDisplayValue('mockproc');
});
const providerOption = screen.getByTestId('test_lti');
// as as admin the provider should not be disabled
expect(providerOption.hasAttribute('disabled')).toEqual(false);
});
it('Does not request lti provider options if there is no exam service url configuration', async () => {
mergeConfig({
EXAMS_BASE_URL: null,
}, 'CourseAuthoringConfig');
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
await waitFor(() => {
screen.getByDisplayValue('mockproc');
});
// only outgoing request should be for studio settings
expect(axiosMock.history.get.length).toBe(1);
expect(axiosMock.history.get[0].url.includes('proctored_exam_settings')).toEqual(true);
});
it('Selected LTI proctoring provider is shown on page load', async () => {
const courseData = { ...mockGetFutureCourseData };
courseData.available_proctoring_providers = ['lti_external', 'proctortrack', 'mockproc'];
courseData.proctored_exam_settings.proctoring_provider = 'lti_external';
mockCourseData(courseData);
axiosMock.onGet(
`${ExamsApiService.getExamsBaseUrl()}/api/v1/configs/course_id/${defaultProps.courseId}`,
).reply(200, {
provider: 'test_lti',
});
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
await waitFor(() => {
screen.getByText('Proctoring Provider');
});
// make sure test_lti is the selected provider
expect(screen.getByDisplayValue('LTI Provider')).toBeInTheDocument();
});
});
describe('Toggles field visibility based on user permissions', () => {
it('Hides opting out and zendesk tickets for non edX staff', async () => {
setupApp(false);
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
expect(screen.queryByTestId('allowOptingOutYes')).toBeNull();
expect(screen.queryByTestId('createZendeskTicketsYes')).toBeNull();
});
it('Shows opting out and zendesk tickets for edX staff', async () => {
setupApp(true);
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
expect(screen.queryByTestId('allowOptingOutYes')).not.toBeNull();
expect(screen.queryByTestId('createZendeskTicketsYes')).not.toBeNull();
});
});
describe('Connection states', () => {
it('Shows the spinner before the connection is complete', async () => {
await act(async () => {
render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />));
// This expectation is _inside_ the `act` intentionally, so that it executes immediately.
const spinner = screen.getByRole('status');
expect(spinner.textContent).toEqual('Loading...');
});
});
it('Show connection error message when we suffer studio server side error', async () => {
axiosMock.onGet(
StudioApiService.getProctoredExamSettingsUrl(defaultProps.courseId),
).reply(500);
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
const connectionError = screen.getByTestId('connectionErrorAlert');
expect(connectionError.textContent).toEqual(
expect.stringContaining('We encountered a technical error when loading this page.'),
);
});
it('Show connection error message when we suffer edx-exams server side error', async () => {
axiosMock.onGet(
`${ExamsApiService.getExamsBaseUrl()}/api/v1/providers`,
).reply(500);
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
const connectionError = screen.getByTestId('connectionErrorAlert');
expect(connectionError.textContent).toEqual(
expect.stringContaining('We encountered a technical error when loading this page.'),
);
});
it('Show permission error message when user do not have enough permission', async () => {
axiosMock.onGet(
StudioApiService.getProctoredExamSettingsUrl(defaultProps.courseId),
).reply(403);
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
const permissionError = screen.getByTestId('permissionDeniedAlert');
expect(permissionError.textContent).toEqual(
expect.stringContaining('You are not authorized to view this page'),
);
});
});
describe('Save settings', () => {
beforeEach(async () => {
axiosMock.onPost(
StudioApiService.getProctoredExamSettingsUrl(defaultProps.courseId),
).reply(200, 'success');
axiosMock.onPatch(
`${ExamsApiService.getExamsBaseUrl()}/api/v1/configs/course_id/${defaultProps.courseId}`,
).reply(200, 'success');
});
it('Show spinner while saving', async () => {
axiosMock.onPost(
StudioApiService.getProctoredExamSettingsUrl(defaultProps.courseId),
).reply(200, 'success');
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
const submitButton = screen.getByTestId('submissionButton');
expect(screen.queryByTestId('saveInProgress')).toBeFalsy();
act(() => {
fireEvent.click(submitButton);
});
const submitSpinner = screen.getByTestId('saveInProgress');
expect(submitSpinner).toBeDefined();
await waitForElementToBeRemoved(submitSpinner);
// request studio settings, exam config, and exam service providers
expect(axiosMock.history.get.length).toBe(3);
expect(axiosMock.history.post.length).toBe(1); // studio
expect(axiosMock.history.patch.length).toBe(1); // edx-exams
expect(screen.queryByTestId('saveInProgress')).toBeFalsy();
});
it('Makes API call successfully with proctoring_escalation_email if proctortrack', async () => {
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
// Make a change to the provider to proctortrack and set the email
const selectElement = screen.getByDisplayValue('mockproc');
await act(async () => {
fireEvent.change(selectElement, { target: { value: 'proctortrack' } });
});
const escalationEmail = screen.getByTestId('escalationEmail');
expect(escalationEmail.value).toEqual('test@example.com');
await act(async () => {
fireEvent.change(escalationEmail, { target: { value: 'proctortrack@example.com' } });
});
expect(escalationEmail.value).toEqual('proctortrack@example.com');
const submitButton = screen.getByTestId('submissionButton');
await act(async () => {
fireEvent.click(submitButton);
});
expect(axiosMock.history.post.length).toBe(1);
expect(JSON.parse(axiosMock.history.post[0].data)).toEqual({
proctored_exam_settings: {
enable_proctored_exams: true,
allow_proctoring_opt_out: false,
proctoring_provider: 'proctortrack',
proctoring_escalation_email: 'proctortrack@example.com',
create_zendesk_tickets: false,
},
});
const errorAlert = screen.getByTestId('saveSuccess');
expect(errorAlert.textContent).toEqual(
expect.stringContaining('Proctored exam settings saved successfully.'),
);
expect(document.activeElement).toEqual(errorAlert);
});
it('Makes API call successfully without proctoring_escalation_email if not proctortrack', async () => {
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
// make sure we have not selected proctortrack as the proctoring provider
expect(screen.getByDisplayValue('mockproc')).toBeDefined();
const submitButton = screen.getByTestId('submissionButton');
await act(async () => {
fireEvent.click(submitButton);
});
expect(axiosMock.history.post.length).toBe(1);
expect(JSON.parse(axiosMock.history.post[0].data)).toEqual({
proctored_exam_settings: {
enable_proctored_exams: true,
allow_proctoring_opt_out: false,
proctoring_provider: 'mockproc',
create_zendesk_tickets: true,
},
});
const errorAlert = screen.getByTestId('saveSuccess');
expect(errorAlert.textContent).toEqual(
expect.stringContaining('Proctored exam settings saved successfully.'),
);
expect(document.activeElement).toEqual(errorAlert);
});
it('Successfully updates exam configuration and studio provider is set to "lti_external" for lti providers', async () => {
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
// Make a change to the provider to proctortrack and set the email
const selectElement = screen.getByDisplayValue('mockproc');
await act(async () => {
fireEvent.change(selectElement, { target: { value: 'test_lti' } });
});
const submitButton = screen.getByTestId('submissionButton');
await act(async () => {
fireEvent.click(submitButton);
});
// update exam service config
expect(axiosMock.history.patch.length).toBe(1);
expect(JSON.parse(axiosMock.history.patch[0].data)).toEqual({
provider: 'test_lti',
});
// update studio settings
expect(axiosMock.history.post.length).toBe(1);
expect(JSON.parse(axiosMock.history.post[0].data)).toEqual({
proctored_exam_settings: {
enable_proctored_exams: true,
allow_proctoring_opt_out: false,
proctoring_provider: 'lti_external',
create_zendesk_tickets: true,
},
});
const errorAlert = screen.getByTestId('saveSuccess');
expect(errorAlert.textContent).toEqual(
expect.stringContaining('Proctored exam settings saved successfully.'),
);
expect(document.activeElement).toEqual(errorAlert);
});
it('Sets exam service provider to null if a non-lti provider is selected', async () => {
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
const submitButton = screen.getByTestId('submissionButton');
await act(async () => {
fireEvent.click(submitButton);
});
// update exam service config
expect(axiosMock.history.patch.length).toBe(1);
expect(JSON.parse(axiosMock.history.patch[0].data)).toEqual({
provider: null,
});
expect(axiosMock.history.patch.length).toBe(1);
expect(axiosMock.history.post.length).toBe(1);
expect(JSON.parse(axiosMock.history.post[0].data)).toEqual({
proctored_exam_settings: {
enable_proctored_exams: true,
allow_proctoring_opt_out: false,
proctoring_provider: 'mockproc',
create_zendesk_tickets: true,
},
});
const errorAlert = screen.getByTestId('saveSuccess');
expect(errorAlert.textContent).toEqual(
expect.stringContaining('Proctored exam settings saved successfully.'),
);
expect(document.activeElement).toEqual(errorAlert);
});
it('Does not update exam service if lti is not enabled in studio', async () => {
axiosMock.onGet(
StudioApiService.getProctoredExamSettingsUrl(defaultProps.courseId),
).reply(200, {
proctored_exam_settings: {
enable_proctored_exams: true,
allow_proctoring_opt_out: false,
proctoring_provider: 'mockproc',
proctoring_escalation_email: 'test@example.com',
create_zendesk_tickets: true,
},
available_proctoring_providers: ['software_secure', 'proctortrack', 'mockproc'],
course_start_date: '2070-01-01T00:00:00Z',
});
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
const submitButton = screen.getByTestId('submissionButton');
await act(async () => {
fireEvent.click(submitButton);
});
// does not update exam service config
expect(axiosMock.history.patch.length).toBe(0);
// does update studio
expect(axiosMock.history.post.length).toBe(1);
expect(JSON.parse(axiosMock.history.post[0].data)).toEqual({
proctored_exam_settings: {
enable_proctored_exams: true,
allow_proctoring_opt_out: false,
proctoring_provider: 'mockproc',
create_zendesk_tickets: true,
},
});
const errorAlert = screen.getByTestId('saveSuccess');
expect(errorAlert.textContent).toEqual(
expect.stringContaining('Proctored exam settings saved successfully.'),
);
expect(document.activeElement).toEqual(errorAlert);
});
it('Makes studio API call generated error', async () => {
axiosMock.onPost(
StudioApiService.getProctoredExamSettingsUrl(defaultProps.courseId),
).reply(500);
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
const submitButton = screen.getByTestId('submissionButton');
await act(async () => {
fireEvent.click(submitButton);
});
expect(axiosMock.history.post.length).toBe(1);
const errorAlert = screen.getByTestId('saveError');
expect(errorAlert.textContent).toEqual(
expect.stringContaining('We encountered a technical error while trying to save proctored exam settings'),
);
expect(document.activeElement).toEqual(errorAlert);
});
it('Makes exams API call generated error', async () => {
axiosMock.onPatch(
`${ExamsApiService.getExamsBaseUrl()}/api/v1/configs/course_id/${defaultProps.courseId}`,
).reply(500, 'error');
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
const submitButton = screen.getByTestId('submissionButton');
await act(async () => {
fireEvent.click(submitButton);
});
expect(axiosMock.history.post.length).toBe(1);
const errorAlert = screen.getByTestId('saveError');
expect(errorAlert.textContent).toEqual(
expect.stringContaining('We encountered a technical error while trying to save proctored exam settings'),
);
expect(document.activeElement).toEqual(errorAlert);
});
it('Manages focus correctly after different save statuses', async () => {
// first make a call that will cause a save error
axiosMock.onPost(
StudioApiService.getProctoredExamSettingsUrl(defaultProps.courseId),
).reply(500);
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
const submitButton = screen.getByTestId('submissionButton');
await act(async () => {
fireEvent.click(submitButton);
});
expect(axiosMock.history.post.length).toBe(1);
const errorAlert = screen.getByTestId('saveError');
expect(errorAlert.textContent).toEqual(
expect.stringContaining('We encountered a technical error while trying to save proctored exam settings'),
);
expect(document.activeElement).toEqual(errorAlert);
// now make a call that will allow for a successful save
axiosMock.onPost(
StudioApiService.getProctoredExamSettingsUrl(defaultProps.courseId),
).reply(200, 'success');
await act(async () => {
fireEvent.click(submitButton);
});
expect(axiosMock.history.post.length).toBe(2);
const successAlert = screen.getByTestId('saveSuccess');
expect(successAlert.textContent).toEqual(
expect.stringContaining('Proctored exam settings saved successfully.'),
);
expect(document.activeElement).toEqual(successAlert);
});
it('Include Zendesk ticket in post request if user is not an admin', async () => {
// use non-admin user for test
const isAdmin = false;
setupApp(isAdmin);
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
// Make a change to the proctoring provider
await waitFor(() => {
screen.getByDisplayValue('mockproc');
});
const selectElement = screen.getByDisplayValue('mockproc');
await act(async () => {
fireEvent.change(selectElement, { target: { value: 'proctortrack' } });
});
const submitButton = screen.getByTestId('submissionButton');
await act(async () => {
fireEvent.click(submitButton);
});
expect(axiosMock.history.post.length).toBe(1);
expect(JSON.parse(axiosMock.history.post[0].data)).toEqual({
proctored_exam_settings: {
enable_proctored_exams: true,
proctoring_provider: 'proctortrack',
proctoring_escalation_email: 'test@example.com',
create_zendesk_tickets: false,
},
});
});
});
});

View File

@@ -1,21 +0,0 @@
import { RequestStatus } from '../../data/constants';
import { updateLoadingStatus } from '../../pages-and-resources/data/slice';
/* eslint-disable import/prefer-default-export */
export function fetchExamSettingsPending(courseId) {
return async (dispatch) => {
dispatch(updateLoadingStatus({ courseId, status: RequestStatus.IN_PROGRESS }));
};
}
export function fetchExamSettingsSuccess(courseId) {
return async (dispatch) => {
dispatch(updateLoadingStatus({ courseId, status: RequestStatus.SUCCESSFUL }));
};
}
export function fetchExamSettingsFailure(courseId) {
return async (dispatch) => {
dispatch(updateLoadingStatus({ courseId, status: RequestStatus.FAILED }));
};
}

View File

@@ -1,10 +0,0 @@
// legend styling should match label styling
legend {
font-size: $spacer;
font-weight: 700;
}
// override conflicting browser styles
select {
appearance: none;
}