feat: remove old/duplicate proctoring component (#671)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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/*"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 "Yes", 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);
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 }));
|
||||
};
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
// legend styling should match label styling
|
||||
legend {
|
||||
font-size: $spacer;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
// override conflicting browser styles
|
||||
select {
|
||||
appearance: none;
|
||||
}
|
||||
Reference in New Issue
Block a user