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 && ( +
    + + + {intl.formatMessage(messages['authoring.proctoring.allowoptout.label'])} + + + + {intl.formatMessage(messages['authoring.proctoring.yes'])} + + + {intl.formatMessage(messages['authoring.proctoring.no'])} + + + +
    + )} + + {/* CREATE ZENDESK TICKETS */} + { isEdxStaff && formValues.enableProctoredExams && ( +
    + + + {intl.formatMessage(messages['authoring.proctoring.createzendesk.label'])} + + + + {intl.formatMessage(messages['authoring.proctoring.yes'])} + + + {intl.formatMessage(messages['authoring.proctoring.no'])} + + + +
    + )} + + ); + } + + 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 ( + +
    + + + Proctored Exam Settings + + + + {loading ? renderLoading() : null} + {saveSuccess ? renderSaveSuccess() : null} + {saveError ? renderSaveError() : null} + {loaded ? renderContent() : null} + {loadingConnectionError ? renderConnectionError() : null} + {loadingPermissionError ? renderPermissionError() : null} + + + + + {intl.formatMessage(messages['authoring.proctoring.cancel'])} + + + + +
    +
    + ); +} + +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;