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 { useIntl, FormattedMessage } from '@edx/frontend-platform/i18n'; import { ActionRow, Alert, Badge, Form, Hyperlink, ModalDialog, StatefulButton, } from '@openedx/paragon'; import ExamsApiService from 'CourseAuthoring/data/services/ExamsApiService'; import StudioApiService from 'CourseAuthoring/data/services/StudioApiService'; import Loading from 'CourseAuthoring/generic/Loading'; import ConnectionErrorAlert from 'CourseAuthoring/generic/ConnectionErrorAlert'; import FormSwitchGroup from 'CourseAuthoring/generic/FormSwitchGroup'; import { useModel } from 'CourseAuthoring/generic/model-store'; import PermissionDeniedAlert from 'CourseAuthoring/generic/PermissionDeniedAlert'; import { useIsMobile } from 'CourseAuthoring/utils'; import { PagesAndResourcesContext } from 'CourseAuthoring/pages-and-resources/PagesAndResourcesProvider'; import { useCourseAuthoringContext } from 'CourseAuthoring/CourseAuthoringContext'; import messages from './messages'; const ProctoringSettings = ({ onClose }) => { const intl = useIntl(); const initialFormValues = { enableProctoredExams: false, proctoringProvider: false, escalationEmail: '', allowOptingOut: 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 [allowLtiProviders, setAllowLtiProviders] = useState(false); const [availableProctoringProviders, setAvailableProctoringProviders] = useState([]); const [requiresEscalationEmailProviders, setRequiresEscalationEmailProviders] = useState([]); const [ltiProctoringProviders, setLtiProctoringProviders] = useState([]); const [courseStartDate, setCourseStartDate] = useState(''); const [saveSuccess, setSaveSuccess] = useState(false); const [saveError, setSaveError] = useState(false); const [submissionInProgress, setSubmissionInProgress] = useState(false); const [showEscalationEmail, setShowEscalationEmail] = useState(false); const isEdxStaff = getAuthenticatedUser().administrator; const [formStatus, setFormStatus] = useState({ isValid: true, errors: {}, }); const isMobile = useIsMobile(); const modalVariant = isMobile ? 'dark' : 'default'; const isLtiProvider = (provider) => ( ltiProctoringProviders.some(p => p.name === provider) ); function getProviderDisplayLabel(provider) { // if a display label exists for this provider return it return ltiProctoringProviders.find(p => p.name === provider)?.verbose_name || provider; } const { courseId } = useContext(PagesAndResourcesContext); const { courseDetails } = useCourseAuthoringContext(); const org = courseDetails?.org; const appInfo = useModel('courseApps', 'proctoring'); const alertRef = React.createRef(); const saveStatusAlertRef = React.createRef(); const proctoringEscalationEmailInputRef = useRef(null); const submitButtonState = submissionInProgress ? 'pending' : 'default'; const handleChange = (event) => { const { target } = event; const value = target.type === 'checkbox' ? target.checked : target.value; const { name } = target; if (['allowOptingOut'].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 (requiresEscalationEmailProviders.includes(value)) { setFormValues({ ...newFormValues }); setShowEscalationEmail(true); } else if (isLtiProvider(value)) { setFormValues(newFormValues); setShowEscalationEmail(true); } else { setFormValues(newFormValues); setShowEscalationEmail(false); } } else { setFormValues({ ...formValues, [name]: value }); } }; const setFocusToEscalationEmailInput = () => { if (proctoringEscalationEmailInputRef && proctoringEscalationEmailInputRef.current) { proctoringEscalationEmailInputRef.current.focus(); } }; function postSettingsBackToServer() { const selectedProvider = formValues.proctoringProvider; const isLtiProviderSelected = isLtiProvider(selectedProvider); const studioDataToPostBack = { proctored_exam_settings: { enable_proctored_exams: formValues.enableProctoredExams, // lti providers are managed outside edx-platform, lti_external indicates this proctoring_provider: isLtiProviderSelected ? 'lti_external' : selectedProvider, }, }; if (isEdxStaff) { studioDataToPostBack.proctored_exam_settings.allow_proctoring_opt_out = formValues.allowOptingOut; } if (requiresEscalationEmailProviders.includes(formValues.proctoringProvider)) { studioDataToPostBack.proctored_exam_settings.proctoring_escalation_email = formValues.escalationEmail === '' ? null : formValues.escalationEmail; } // only save back to exam service if necessary setSubmissionInProgress(true); const saveOperations = [StudioApiService.saveProctoredExamSettingsData(courseId, studioDataToPostBack)]; if (allowLtiProviders && ExamsApiService.isAvailable()) { const selectedEscalationEmail = formValues.escalationEmail; saveOperations.push( ExamsApiService.saveCourseExamConfiguration( courseId, { provider: isLtiProviderSelected ? formValues.proctoringProvider : null, escalationEmail: (isLtiProviderSelected && selectedEscalationEmail !== '') ? selectedEscalationEmail : null, }, ), ); } Promise.all(saveOperations) .then(() => { setSaveSuccess(true); setSaveError(false); setSubmissionInProgress(false); }).catch((error) => { setSaveSuccess(false); setSaveError(error); setSubmissionInProgress(false); }); } const handleSubmit = (event) => { event.preventDefault(); const isLtiProviderSelected = isLtiProvider(formValues.proctoringProvider); if ( (requiresEscalationEmailProviders.includes(formValues.proctoringProvider) || isLtiProviderSelected) && !EmailValidator.validate(formValues.escalationEmail) && !(formValues.escalationEmail === '' && !formValues.enableProctoredExams) ) { if (formValues.escalationEmail === '') { const errorMessage = intl.formatMessage(messages['authoring.proctoring.escalationemail.error.blank'], { proctoringProviderName: getProviderDisplayLabel(formValues.proctoringProvider) }); setFormStatus({ isValid: false, errors: { formEscalationEmail: { dialogErrorMessage: ( {errorMessage} ), inputErrorMessage: errorMessage, }, }, }); } else { const errorMessage = intl.formatMessage(messages['authoring.proctoring.escalationemail.error.invalid']); setFormStatus({ isValid: false, errors: { formEscalationEmail: { dialogErrorMessage: ({errorMessage}), inputErrorMessage: errorMessage, }, }, }); } } else { postSettingsBackToServer(); const errors = { ...formStatus.errors }; delete errors.formEscalationEmail; 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() { const isLtiProviderSelected = isLtiProvider(formValues.proctoringProvider); return ( <> {!formStatus.isValid && formStatus.errors.formEscalationEmail && ( // 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']) } )} {/* ESCALATION EMAIL */} {showEscalationEmail && formValues.enableProctoredExams && ( {intl.formatMessage(messages['authoring.proctoring.escalationemail.label'])} {intl.formatMessage(messages['authoring.proctoring.escalationemail.help'])} {Object.prototype.hasOwnProperty.call(formStatus.errors, 'formEscalationEmail') && ( { formStatus.errors.formEscalationEmail && formStatus.errors.formEscalationEmail.inputErrorMessage } )} )} {/* ALLOW OPTING OUT OF PROCTORED EXAMS */} { isEdxStaff && formValues.enableProctoredExams && !isLtiProviderSelected && (
    {intl.formatMessage(messages['authoring.proctoring.allowoptout.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() { let errorMessage = ( {intl.formatMessage(messages['authoring.proctoring.support.text'])} ), }} /> ); if (saveError?.response.status === 403) { errorMessage = ( {intl.formatMessage(messages['authoring.proctoring.support.text'])} ), }} /> ); } return ( setSaveError(false)} dismissible > {errorMessage} ); } useEffect(() => { Promise.all([ StudioApiService.getProctoredExamSettingsData(courseId), ExamsApiService.isAvailable() ? ExamsApiService.getCourseExamConfiguration(courseId) : Promise.resolve(), ExamsApiService.isAvailable() ? ExamsApiService.getAvailableProviders(org) : 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); setAvailableProctoringProviders(settingsResponse.data.available_proctoring_providers); setRequiresEscalationEmailProviders(settingsResponse.data.requires_escalation_email_providers); // 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); let selectedProvider; if (proctoredExamSettings.proctoring_provider === 'lti_external') { selectedProvider = examConfigResponse.data.provider; } else { selectedProvider = proctoredExamSettings.proctoring_provider; } const requiresEscalationEmailProvidersList = settingsResponse.data.requires_escalation_email_providers; const isEscalationEmailRequired = requiresEscalationEmailProvidersList.includes(selectedProvider); const ltiProviderSelected = proctoringProvidersLti.some(p => p.name === selectedProvider); if (isEscalationEmailRequired || ltiProviderSelected) { setShowEscalationEmail(true); } const proctoringEscalationEmail = ltiProviderSelected ? examConfigResponse.data.escalation_email : proctoredExamSettings.proctoring_escalation_email; setFormValues({ ...formValues, proctoringProvider: selectedProvider, enableProctoredExams: proctoredExamSettings.enable_proctored_exams, allowOptingOut: proctoredExamSettings.allow_proctoring_opt_out, // 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. escalationEmail: 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 = { onClose: PropTypes.func.isRequired, }; ProctoringSettings.defaultProps = {}; export default ProctoringSettings;