diff --git a/src/pages-and-resources/proctoring/Settings.jsx b/src/pages-and-resources/proctoring/Settings.jsx
new file mode 100644
index 000000000..bb909dacb
--- /dev/null
+++ b/src/pages-and-resources/proctoring/Settings.jsx
@@ -0,0 +1,557 @@
+import React, {
+ useContext, useEffect, useRef, useState,
+} from 'react';
+import classNames from 'classnames';
+import EmailValidator from 'email-validator';
+import moment from 'moment';
+import PropTypes from 'prop-types';
+
+import { getConfig } from '@edx/frontend-platform';
+import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
+import { injectIntl, intlShape, FormattedMessage } from '@edx/frontend-platform/i18n';
+import {
+ ActionRow, Alert, Badge, Form, Hyperlink, ModalDialog, StatefulButton,
+} from '@edx/paragon';
+
+import StudioApiService from '../../data/services/StudioApiService';
+import Loading from '../../generic/Loading';
+import ConnectionErrorAlert from '../../generic/ConnectionErrorAlert';
+import FormSwitchGroup from '../../generic/FormSwitchGroup';
+import { useModel } from '../../generic/model-store';
+import PermissionDeniedAlert from '../../generic/PermissionDeniedAlert';
+import { useIsMobile } from '../../utils';
+import { PagesAndResourcesContext } from '../PagesAndResourcesProvider';
+import messages from './messages';
+
+function ProctoringSettings({ intl, onClose }) {
+ const initialFormValues = {
+ enableProctoredExams: false,
+ proctoringProvider: false,
+ proctortrackEscalationEmail: '',
+ allowOptingOut: false,
+ createZendeskTickets: false,
+ };
+ const [formValues, setFormValues] = useState(initialFormValues);
+ const [loading, setLoading] = useState(true);
+ const [loaded, setLoaded] = useState(false);
+ const [loadingConnectionError, setLoadingConnectionError] = useState(false);
+ const [loadingPermissionError, setLoadingPermissionError] = useState(false);
+ const [availableProctoringProviders, setAvailableProctoringProviders] = useState([]);
+ 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 isMobile = useIsMobile();
+ const modalVariant = isMobile ? 'dark' : 'default';
+
+ const { courseId } = useContext(PagesAndResourcesContext);
+ const appInfo = useModel('courseApps', 'proctoring');
+ const alertRef = React.createRef();
+ const saveStatusAlertRef = React.createRef();
+ const proctoringEscalationEmailInputRef = useRef(null);
+ const submitButtonState = submissionInProgress ? 'pending' : 'default';
+
+ function handleChange(event) {
+ const { target } = event;
+ const value = target.type === 'checkbox' ? target.checked : target.value;
+ const { name } = target;
+
+ if (['allowOptingOut', 'createZendeskTickets'].includes(name)) {
+ // Form.Radio expects string values, so convert back to a boolean here
+ setFormValues({ ...formValues, [name]: value === 'true' });
+ } else if (name === 'proctoringProvider') {
+ const newFormValues = { ...formValues, proctoringProvider: value };
+
+ if (value === 'proctortrack') {
+ setFormValues({ ...newFormValues, createZendeskTickets: false });
+ setShowProctortrackEscalationEmail(true);
+ } else {
+ if (value === 'software_secure') {
+ setFormValues({ ...newFormValues, createZendeskTickets: true });
+ } else {
+ setFormValues(newFormValues);
+ }
+
+ setShowProctortrackEscalationEmail(false);
+ }
+ } else {
+ setFormValues({ ...formValues, [name]: value });
+ }
+ }
+
+ function setFocusToProctortrackEscalationEmailInput() {
+ if (proctoringEscalationEmailInputRef && proctoringEscalationEmailInputRef.current) {
+ proctoringEscalationEmailInputRef.current.focus();
+ }
+ }
+
+ function postSettingsBackToServer() {
+ const dataToPostBack = {
+ proctored_exam_settings: {
+ enable_proctored_exams: formValues.enableProctoredExams,
+ proctoring_provider: formValues.proctoringProvider,
+ create_zendesk_tickets: formValues.createZendeskTickets,
+ },
+ };
+ if (isEdxStaff) {
+ dataToPostBack.proctored_exam_settings.allow_proctoring_opt_out = formValues.allowOptingOut;
+ }
+
+ if (formValues.proctoringProvider === 'proctortrack') {
+ dataToPostBack.proctored_exam_settings.proctoring_escalation_email = formValues.proctortrackEscalationEmail === '' ? null : formValues.proctortrackEscalationEmail;
+ }
+
+ setSubmissionInProgress(true);
+ StudioApiService.saveProctoredExamSettingsData(courseId, dataToPostBack).then(() => {
+ setSaveSuccess(true);
+ setSaveError(false);
+ setSubmissionInProgress(false);
+ }).catch(() => {
+ setSaveSuccess(false);
+ setSaveError(true);
+ setSubmissionInProgress(false);
+ });
+ }
+
+ function handleSubmit(event) {
+ event.preventDefault();
+ if (
+ formValues.proctoringProvider === 'proctortrack'
+ && !EmailValidator.validate(formValues.proctortrackEscalationEmail)
+ && !(formValues.proctortrackEscalationEmail === '' && !formValues.enableProctoredExams)
+ ) {
+ if (formValues.proctortrackEscalationEmail === '') {
+ const errorMessage = intl.formatMessage(messages['authoring.proctoring.escalationemail.error.blank']);
+
+ setFormStatus({
+ isValid: false,
+ errors: {
+ formProctortrackEscalationEmail: {
+ dialogErrorMessage: ({errorMessage}),
+ inputErrorMessage: errorMessage,
+ },
+ },
+ });
+ } else {
+ const errorMessage = intl.formatMessage(messages['authoring.proctoring.escalationemail.error.invalid']);
+
+ setFormStatus({
+ isValid: false,
+ errors: {
+ formProctortrackEscalationEmail: {
+ dialogErrorMessage: ({errorMessage}),
+ 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 !== formValues.proctoringProvider;
+ }
+ return markDisabled;
+ }
+
+ function getProctoringProviderOptions(providers) {
+ return providers.map(provider => (
+
+ ));
+ }
+
+ function getFormErrorMessage() {
+ const numOfErrors = Object.keys(formStatus.errors).length;
+ const errors = Object.entries(formStatus.errors).map(([id, error]) =>
{error.dialogErrorMessage});
+ const messageId = numOfErrors > 1 ? 'authoring.proctoring.error.multiple' : 'authoring.proctoring.error.single';
+
+ return (
+ <>
+ {intl.formatMessage(messages[messageId], { numOfErrors })}
+
+ >
+ );
+ }
+
+ const learnMoreLink = appInfo.documentationLinks?.learnMoreConfiguration && (
+
+ {intl.formatMessage(messages['authoring.proctoring.learn.more'])}
+
+ );
+
+ function renderContent() {
+ return (
+ <>
+ {!formStatus.isValid && formStatus.errors.formProctortrackEscalationEmail
+ && (
+ // tabIndex="-1" to make non-focusable element focusable
+
+ {getFormErrorMessage()}
+
+ )}
+
+ {/* ENABLE PROCTORED EXAMS */}
+
+ {intl.formatMessage(messages['authoring.proctoring.enableproctoredexams.label'])}
+ {
+ formValues.enableProctoredExams && (
+
+ {intl.formatMessage(messages['authoring.proctoring.enabled'])}
+
+ )
+ }
+
+ )}
+ helpText={(
+
+
+ {intl.formatMessage(messages['authoring.proctoring.enableproctoredexams.help'])}
+
+
{learnMoreLink}
+
+ )}
+ />
+
+ {/* PROCTORING PROVIDER */}
+ { formValues.enableProctoredExams && (
+ <>
+
+
+
+ {intl.formatMessage(messages['authoring.proctoring.provider.label'])}
+
+
+ {getProctoringProviderOptions(availableProctoringProviders)}
+
+
+ {
+ cannotEditProctoringProvider()
+ ? intl.formatMessage(messages['authoring.proctoring.provider.help.aftercoursestart'])
+ : intl.formatMessage(messages['authoring.proctoring.provider.help'])
+ }
+
+
+ >
+ )}
+
+ {/* PROCTORTRACK ESCALATION EMAIL */}
+ {showProctortrackEscalationEmail && formValues.enableProctoredExams && (
+
+
+ {intl.formatMessage(messages['authoring.proctoring.escalationemail.label'])}
+
+
+
+ {intl.formatMessage(messages['authoring.proctoring.escalationemail.help'])}
+
+ {Object.prototype.hasOwnProperty.call(formStatus.errors, 'formProctortrackEscalationEmail') && (
+
+ {
+ formStatus.errors.formProctortrackEscalationEmail
+ && formStatus.errors.formProctortrackEscalationEmail.inputErrorMessage
+ }
+
+ )}
+
+ )}
+
+ {/* ALLOW OPTING OUT OF PROCTORED EXAMS */}
+ { isEdxStaff && formValues.enableProctoredExams && (
+
+ )}
+
+ {/* CREATE ZENDESK TICKETS */}
+ { isEdxStaff && formValues.enableProctoredExams && (
+
+ )}
+ >
+ );
+ }
+
+ function renderLoading() {
+ return (
+
+ );
+ }
+
+ function renderConnectionError() {
+ return (
+
+ );
+ }
+
+ function renderPermissionError() {
+ return (
+
+ );
+ }
+
+ function renderSaveSuccess() {
+ const studioCourseRunURL = StudioApiService.getStudioCourseRunUrl(courseId);
+ return (
+ setSaveSuccess(false)}
+ dismissible
+ >
+
+ {intl.formatMessage(messages['authoring.proctoring.studio.link.text'])}
+
+ ),
+ }}
+ />
+
+ );
+ }
+
+ function renderSaveError() {
+ return (
+ setSaveError(false)}
+ dismissible
+ >
+
+ {intl.formatMessage(messages['authoring.proctoring.support.text'])}
+
+ ),
+ }}
+ />
+
+ );
+ }
+
+ useEffect(
+ () => {
+ StudioApiService.getProctoredExamSettingsData(courseId)
+ .then(
+ response => {
+ const proctoredExamSettings = response.data.proctored_exam_settings;
+ setLoaded(true);
+ setLoading(false);
+ setSubmissionInProgress(false);
+ setCourseStartDate(response.data.course_start_date);
+ const isProctortrack = proctoredExamSettings.proctoring_provider === 'proctortrack';
+ setShowProctortrackEscalationEmail(isProctortrack);
+ setAvailableProctoringProviders(response.data.available_proctoring_providers);
+ const proctoringEscalationEmail = proctoredExamSettings.proctoring_escalation_email;
+
+ setFormValues({
+ ...formValues,
+ enableProctoredExams: proctoredExamSettings.enable_proctored_exams,
+ proctoringProvider: proctoredExamSettings.proctoring_provider,
+ allowOptingOut: proctoredExamSettings.allow_proctoring_opt_out,
+ createZendeskTickets: proctoredExamSettings.create_zendesk_tickets,
+ // 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.
+ proctortrackEscalationEmail: proctoringEscalationEmail === null ? '' : proctoringEscalationEmail,
+ });
+ },
+ ).catch(
+ error => {
+ if (error.response.status === 403) {
+ setLoadingPermissionError(true);
+ } else {
+ setLoadingConnectionError(true);
+ }
+ setLoading(false);
+ setLoaded(false);
+ setSubmissionInProgress(false);
+ },
+ );
+ }, [],
+ );
+
+ useEffect(() => {
+ if ((saveSuccess || saveError) && !!saveStatusAlertRef.current) {
+ saveStatusAlertRef.current.focus();
+ }
+ if (!formStatus.isValid && !!alertRef.current) {
+ alertRef.current.focus();
+ }
+ }, [formStatus, saveSuccess, saveError]);
+
+ return (
+
+
+
+ );
+}
+
+ProctoringSettings.propTypes = {
+ intl: intlShape.isRequired,
+ onClose: PropTypes.func.isRequired,
+};
+
+ProctoringSettings.defaultProps = {};
+
+export default injectIntl(ProctoringSettings);
diff --git a/src/pages-and-resources/proctoring/Settings.test.jsx b/src/pages-and-resources/proctoring/Settings.test.jsx
new file mode 100644
index 000000000..7ff4d8c29
--- /dev/null
+++ b/src/pages-and-resources/proctoring/Settings.test.jsx
@@ -0,0 +1,740 @@
+import React from 'react';
+import {
+ render, screen, cleanup, waitFor, fireEvent, act,
+} from '@testing-library/react';
+import MockAdapter from 'axios-mock-adapter';
+
+import { initializeMockApp } from '@edx/frontend-platform';
+import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
+import { IntlProvider, injectIntl } from '@edx/frontend-platform/i18n';
+import { AppProvider } from '@edx/frontend-platform/react';
+
+import StudioApiService from '../../data/services/StudioApiService';
+import initializeStore from '../../store';
+import PagesAndResourcesProvider from '../PagesAndResourcesProvider';
+import ProctoredExamSettings from './Settings';
+
+const defaultProps = {
+ courseId: 'course-v1%3AedX%2BDemoX%2BDemo_Course',
+ onClose: () => {},
+};
+const IntlProctoredExamSettings = injectIntl(ProctoredExamSettings);
+let store;
+
+const intlWrapper = children => (
+
+
+
+ {children}
+
+
+
+);
+let axiosMock;
+
+describe('ProctoredExamSettings', () => {
+ beforeEach(() => {
+ store = initializeStore({
+ models: {
+ courseApps: {
+ proctoring: {},
+ },
+ },
+ });
+ });
+
+ afterEach(() => {
+ cleanup();
+ });
+
+ describe('Field dependencies', () => {
+ beforeEach(async () => {
+ initializeMockApp({
+ authenticatedUser: {
+ userId: 3,
+ username: 'abc123',
+ administrator: true,
+ roles: [],
+ },
+ });
+
+ axiosMock = new MockAdapter(getAuthenticatedHttpClient());
+
+ 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()));
+ });
+
+ 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()));
+ await waitFor(() => {
+ screen.getByText('Proctored exams');
+ });
+ const enabledProctoredExamCheck = screen.getByLabelText('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.getByText('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 enabledProctoredExamCheck = screen.getAllByLabelText('Proctored exams', { exact: false })[0];
+ expect(enabledProctoredExamCheck.checked).toEqual(true);
+ await act(async () => {
+ fireEvent.click(enabledProctoredExamCheck, { target: { value: false } });
+ });
+ enabledProctoredExamCheck = screen.getByLabelText('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();
+ });
+ });
+
+ describe('Validation with invalid escalation email', () => {
+ beforeEach(async () => {
+ initializeMockApp({
+ authenticatedUser: {
+ userId: 3,
+ username: 'abc123',
+ administrator: false,
+ roles: [],
+ },
+ });
+
+ axiosMock = new MockAdapter(getAuthenticatedHttpClient());
+
+ 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',
+ });
+
+ axiosMock.onPost(
+ StudioApiService.getProctoredExamSettingsUrl(defaultProps.courseId),
+ ).reply(200, {});
+
+ await act(async () => render(intlWrapper()));
+ });
+
+ 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.getByText('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 () => {
+ await waitFor(() => {
+ screen.getByDisplayValue('proctortrack');
+ });
+ const selectEscalationEmailElement = screen.getByDisplayValue('test@example.com');
+ await act(async () => {
+ fireEvent.change(selectEscalationEmailElement, { target: { value: '' } });
+ });
+ const enableProctoringElement = screen.getByText('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 () => {
+ 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 setup(data, isAdmin) {
+ initializeMockApp({
+ authenticatedUser: {
+ userId: 3,
+ username: 'abc123',
+ administrator: isAdmin,
+ roles: [],
+ },
+ });
+
+ axiosMock = new MockAdapter(getAuthenticatedHttpClient());
+ 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 () => {
+ setup(mockGetPastCourseData, false);
+ await act(async () => render(intlWrapper()));
+ 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 () => {
+ setup(mockGetFutureCourseData, false);
+ await act(async () => render(intlWrapper()));
+ 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 () => {
+ setup(mockGetPastCourseData, true);
+ await act(async () => render(intlWrapper()));
+ 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 () => {
+ setup(mockGetFutureCourseData, true);
+ await act(async () => render(intlWrapper()));
+ const providerOption = screen.getByTestId('proctortrack');
+ expect(providerOption.hasAttribute('disabled')).toEqual(false);
+ });
+ });
+
+ describe('Toggles field visibility based on user permissions', () => {
+ function setup(isAdmin) {
+ initializeMockApp({
+ authenticatedUser: {
+ userId: 3,
+ username: 'abc123',
+ administrator: isAdmin,
+ roles: [],
+ },
+ });
+
+ axiosMock = new MockAdapter(getAuthenticatedHttpClient());
+ 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',
+ });
+ }
+
+ it('Hides opting out and zendesk tickets for non edX staff', async () => {
+ setup(false);
+ await act(async () => render(intlWrapper()));
+ expect(screen.queryByTestId('allowOptingOutYes')).toBeNull();
+ expect(screen.queryByTestId('createZendeskTicketsYes')).toBeNull();
+ });
+
+ it('Shows opting out and zendesk tickets for edX staff', async () => {
+ setup(true);
+ await act(async () => render(intlWrapper()));
+ expect(screen.queryByTestId('allowOptingOutYes')).not.toBeNull();
+ expect(screen.queryByTestId('createZendeskTicketsYes')).not.toBeNull();
+ });
+ });
+
+ describe('Connection states', () => {
+ beforeEach(() => {
+ initializeMockApp({
+ authenticatedUser: {
+ userId: 3,
+ username: 'abc123',
+ administrator: true,
+ roles: [],
+ },
+ });
+ axiosMock = new MockAdapter(getAuthenticatedHttpClient());
+ });
+
+ it('Shows the spinner before the connection is complete', async () => {
+ await act(async () => {
+ render(intlWrapper());
+ // 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 server side error', async () => {
+ axiosMock.onGet(
+ StudioApiService.getProctoredExamSettingsUrl(defaultProps.courseId),
+ ).reply(500);
+
+ await act(async () => render(intlWrapper()));
+ 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()));
+ const permissionError = screen.getByTestId('permissionDeniedAlert');
+ expect(permissionError.textContent).toEqual(
+ expect.stringContaining('You are not authorized to view this page'),
+ );
+ });
+ });
+
+ describe('Save settings', () => {
+ beforeEach(() => {
+ initializeMockApp({
+ authenticatedUser: {
+ userId: 3,
+ username: 'abc123',
+ administrator: true,
+ roles: [],
+ },
+ });
+
+ axiosMock = new MockAdapter(getAuthenticatedHttpClient(), { onNoMatch: 'throwException' });
+ 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'],
+ });
+ });
+
+ it('Disable button while submitting', async () => {
+ axiosMock.onPost(
+ StudioApiService.getProctoredExamSettingsUrl(defaultProps.courseId),
+ ).reply(200, 'success');
+
+ await act(async () => render(intlWrapper()));
+ let submitButton = screen.getByTestId('submissionButton');
+ expect(screen.queryByTestId('saveInProgress')).toBeFalsy();
+ act(() => {
+ fireEvent.click(submitButton);
+ });
+
+ submitButton = screen.getByTestId('submissionButton');
+ expect(submitButton).toHaveAttribute('disabled');
+ });
+
+ it('Makes API call successfully with proctoring_escalation_email if proctortrack', async () => {
+ axiosMock.onPost(
+ StudioApiService.getProctoredExamSettingsUrl(defaultProps.courseId),
+ ).reply(200, 'success');
+
+ await act(async () => render(intlWrapper()));
+ // 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 () => {
+ axiosMock.onPost(
+ StudioApiService.getProctoredExamSettingsUrl(defaultProps.courseId),
+ ).reply(200, 'success');
+
+ await act(async () => render(intlWrapper()));
+
+ // 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('Makes API call generated error', async () => {
+ axiosMock.onPost(
+ StudioApiService.getProctoredExamSettingsUrl(defaultProps.courseId),
+ ).reply(500);
+
+ await act(async () => render(intlWrapper()));
+ 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),
+ ).replyOnce(500);
+
+ await act(async () => render(intlWrapper()));
+ 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),
+ ).replyOnce(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
+ initializeMockApp({
+ authenticatedUser: {
+ userId: 4,
+ username: 'abc1234',
+ administrator: false,
+ roles: [],
+ },
+ });
+ axiosMock = new MockAdapter(getAuthenticatedHttpClient(), { onNoMatch: 'throwException' });
+ 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'],
+ });
+ axiosMock.onPost(
+ StudioApiService.getProctoredExamSettingsUrl(defaultProps.courseId),
+ ).reply(200, 'success');
+
+ await act(async () => render(intlWrapper()));
+ // Make a change to the proctoring provider
+ 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,
+ },
+ });
+ });
+ });
+});
diff --git a/src/pages-and-resources/proctoring/messages.js b/src/pages-and-resources/proctoring/messages.js
new file mode 100644
index 000000000..1f1969306
--- /dev/null
+++ b/src/pages-and-resources/proctoring/messages.js
@@ -0,0 +1,116 @@
+import { defineMessages } from '@edx/frontend-platform/i18n';
+
+const messages = defineMessages({
+ 'authoring.proctoring.no': {
+ id: 'authoring.proctoring.no',
+ defaultMessage: 'No',
+ description: '"No" option for yes/no radio button set',
+ },
+ 'authoring.proctoring.yes': {
+ id: 'authoring.proctoring.yes',
+ defaultMessage: 'Yes',
+ description: '"Yes" option for proctored exam settings',
+ },
+ 'authoring.proctoring.support.text': {
+ id: 'authoring.proctoring.support.text',
+ defaultMessage: 'Support Page',
+ description: 'Text linking to the support page.',
+ },
+ 'authoring.proctoring.enableproctoredexams.label': {
+ id: 'authoring.proctoring.enableproctoredexams.label',
+ defaultMessage: 'Proctored exams',
+ description: 'Label for checkbox to enable proctored exams.',
+ },
+ 'authoring.proctoring.enableproctoredexams.help': {
+ id: 'authoring.proctoring.enableproctoredexams.help',
+ defaultMessage: 'Enable and configure proctored exams in your course.',
+ description: 'Help text for checkbox to enable proctored exams.',
+ },
+ 'authoring.proctoring.enabled': {
+ id: 'authoring.proctoring.enabled',
+ defaultMessage: 'Enabled',
+ description: 'Text describing that the feature is enabled.',
+ },
+ 'authoring.proctoring.learn.more': {
+ id: 'authoring.proctoring.learn.more',
+ defaultMessage: 'Learn more about proctoring',
+ description: 'Link to learn more about the proctoring feature.',
+ },
+ 'authoring.proctoring.provider.label': {
+ id: 'authoring.proctoring.provider.label',
+ defaultMessage: 'Proctoring provider',
+ description: 'Label for proctoring provider dropdown selection.',
+ },
+ 'authoring.proctoring.provider.help': {
+ id: 'authoring.proctoring.provider.help',
+ defaultMessage: 'Select the proctoring provider you want to use for this course run.',
+ description: 'Help text for selecting a proctoring provider.',
+ },
+ 'authoring.proctoring.provider.help.aftercoursestart': {
+ id: 'authoring.proctoring.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.',
+ },
+ 'authoring.proctoring.escalationemail.label': {
+ id: 'authoring.proctoring.escalationemail.label',
+ defaultMessage: 'Proctortrack escalation email',
+ description: 'Label for escalation email text field',
+ },
+ 'authoring.proctoring.escalationemail.help': {
+ id: 'authoring.proctoring.escalationemail.help',
+ defaultMessage: 'Provide an email address to be contacted by the support team for escalations (e.g. appeals, delayed reviews).',
+ description: 'Help text explaining escalation email field.',
+ },
+ 'authoring.proctoring.escalationemail.error.blank': {
+ id: 'authoring.proctoring.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.proctoring.escalationemail.error.invalid': {
+ id: 'authoring.proctoring.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.proctoring.allowoptout.label': {
+ id: 'authoring.proctoring.allowoptout.label',
+ defaultMessage: 'Allow learners to opt out of proctoring on proctored exams',
+ description: 'Label for radio selection allowing proctored exam opt out',
+ },
+ 'authoring.proctoring.createzendesk.label': {
+ id: 'authoring.proctoring.createzendesk.label',
+ defaultMessage: 'Create Zendesk tickets for suspicious attempts',
+ description: 'Label for Zendesk ticket creation radio select.',
+ },
+ 'authoring.proctoring.error.single': {
+ id: 'authoring.proctoring.error.single',
+ defaultMessage: 'There is 1 error in this form.',
+ description: 'Error alert for one and only one error in the form.',
+ },
+ 'authoring.proctoring.error.multiple': {
+ id: 'authoring.proctoring.escalationemail.error.multiple',
+ defaultMessage: 'There are {numOfErrors} errors in this form.',
+ description: 'Error alert for multiple errors in the form.',
+ },
+ 'authoring.proctoring.save': {
+ id: 'authoring.proctoring.save',
+ defaultMessage: 'Save',
+ description: 'Button to save proctoring settings.',
+ },
+ 'authoring.proctoring.saving': {
+ id: 'authoring.proctoring.saving',
+ defaultMessage: 'Saving...',
+ description: 'Proctoring settings are in the process of saving.',
+ },
+ 'authoring.proctoring.cancel': {
+ id: 'authoring.proctoring.cancel',
+ defaultMessage: 'Cancel',
+ description: 'Button to cancel edits to proctoring settings.',
+ },
+ 'authoring.proctoring.studio.link.text': {
+ id: 'authoring.proctoring.studio.link.text',
+ defaultMessage: 'Go back to your course in Studio',
+ description: 'Link to go back to the course Studio page.',
+ },
+});
+
+export default messages;