* feat: add external links override support * fix: add note, fix 404 url and fix unrelated typo * test: fix
453 lines
14 KiB
JavaScript
453 lines
14 KiB
JavaScript
import { useIntl } from '@edx/frontend-platform/i18n';
|
|
import { getExternalLinkUrl } from '@edx/frontend-platform';
|
|
import {
|
|
ActionRow,
|
|
Alert,
|
|
Badge,
|
|
Form,
|
|
Icon,
|
|
ModalDialog,
|
|
OverlayTrigger,
|
|
StatefulButton,
|
|
Tooltip,
|
|
TransitionReplace,
|
|
Hyperlink,
|
|
} from '@openedx/paragon';
|
|
import {
|
|
Info, CheckCircleOutline, SpinnerSimple,
|
|
} from '@openedx/paragon/icons';
|
|
|
|
import { Formik } from 'formik';
|
|
import PropTypes from 'prop-types';
|
|
import React, {
|
|
useContext, useEffect, useRef, useState,
|
|
} from 'react';
|
|
import { useDispatch, useSelector } from 'react-redux';
|
|
import * as Yup from 'yup';
|
|
|
|
import { RequestStatus } from 'CourseAuthoring/data/constants';
|
|
import ConnectionErrorAlert from 'CourseAuthoring/generic/ConnectionErrorAlert';
|
|
import FormSwitchGroup from 'CourseAuthoring/generic/FormSwitchGroup';
|
|
import Loading from 'CourseAuthoring/generic/Loading';
|
|
import { useModel } from 'CourseAuthoring/generic/model-store';
|
|
import PermissionDeniedAlert from 'CourseAuthoring/generic/PermissionDeniedAlert';
|
|
import { useIsMobile } from 'CourseAuthoring/utils';
|
|
import { getLoadingStatus, getSavingStatus, getResetStatus } from 'CourseAuthoring/pages-and-resources/data/selectors';
|
|
import { updateSavingStatus, updateResetStatus } from 'CourseAuthoring/pages-and-resources/data/slice';
|
|
import AppConfigFormDivider from 'CourseAuthoring/pages-and-resources/discussions/app-config-form/apps/shared/AppConfigFormDivider';
|
|
import { PagesAndResourcesContext } from 'CourseAuthoring/pages-and-resources/PagesAndResourcesProvider';
|
|
|
|
import { updateXpertSettings, resetXpertSettings, removeXpertSettings } from '../data/thunks';
|
|
import messages from './messages';
|
|
import appInfo from '../appInfo';
|
|
import ResetIcon from './ResetIcon';
|
|
|
|
import './SettingsModal.scss';
|
|
|
|
const AppSettingsForm = ({
|
|
formikProps, children, showForm,
|
|
}) => children && (
|
|
<TransitionReplace>
|
|
{showForm ? (
|
|
<React.Fragment key="app-enabled">
|
|
{children(formikProps)}
|
|
</React.Fragment>
|
|
) : (
|
|
<React.Fragment key="app-disabled" />
|
|
)}
|
|
</TransitionReplace>
|
|
);
|
|
|
|
AppSettingsForm.propTypes = {
|
|
// Ignore the warning here since we're just passing along the props as-is and the child component should validate
|
|
// eslint-disable-next-line react/forbid-prop-types
|
|
formikProps: PropTypes.object.isRequired,
|
|
showForm: PropTypes.bool.isRequired,
|
|
children: PropTypes.func,
|
|
};
|
|
|
|
AppSettingsForm.defaultProps = {
|
|
children: null,
|
|
};
|
|
|
|
const SettingsModalBase = ({
|
|
title, onClose, variant, isMobile, children, footer,
|
|
}) => {
|
|
const intl = useIntl();
|
|
return (
|
|
<ModalDialog
|
|
title={title}
|
|
isOpen
|
|
onClose={onClose}
|
|
size="lg"
|
|
variant={variant}
|
|
hasCloseButton={isMobile}
|
|
isFullscreenOnMobile
|
|
>
|
|
<ModalDialog.Header>
|
|
<ModalDialog.Title data-testid="modal-title">
|
|
{title}
|
|
</ModalDialog.Title>
|
|
</ModalDialog.Header>
|
|
<ModalDialog.Body>
|
|
{children}
|
|
</ModalDialog.Body>
|
|
<ModalDialog.Footer className="p-4">
|
|
<ActionRow>
|
|
<ModalDialog.CloseButton variant="tertiary">
|
|
{intl.formatMessage(messages.cancel)}
|
|
</ModalDialog.CloseButton>
|
|
{footer}
|
|
</ActionRow>
|
|
</ModalDialog.Footer>
|
|
</ModalDialog>
|
|
);
|
|
};
|
|
|
|
SettingsModalBase.propTypes = {
|
|
title: PropTypes.string.isRequired,
|
|
onClose: PropTypes.func.isRequired,
|
|
variant: PropTypes.oneOf(['default', 'dark']).isRequired,
|
|
isMobile: PropTypes.bool.isRequired,
|
|
children: PropTypes.node.isRequired,
|
|
footer: PropTypes.node,
|
|
};
|
|
|
|
SettingsModalBase.defaultProps = {
|
|
footer: null,
|
|
};
|
|
|
|
const ResetUnitsButton = ({
|
|
courseId,
|
|
checked,
|
|
visible,
|
|
}) => {
|
|
const intl = useIntl();
|
|
const resetStatusRequestStatus = useSelector(getResetStatus);
|
|
const dispatch = useDispatch();
|
|
|
|
useEffect(() => {
|
|
if (resetStatusRequestStatus === RequestStatus.SUCCESSFUL) {
|
|
setTimeout(() => {
|
|
dispatch(updateResetStatus({ status: '' }));
|
|
}, 2000);
|
|
}
|
|
}, [resetStatusRequestStatus]);
|
|
|
|
const handleResetUnits = () => {
|
|
dispatch(resetXpertSettings(courseId, { enabled: checked === 'true', reset: true }));
|
|
};
|
|
|
|
const getResetButtonState = () => {
|
|
switch (resetStatusRequestStatus) {
|
|
case RequestStatus.PENDING:
|
|
return 'pending';
|
|
case RequestStatus.SUCCESSFUL:
|
|
return 'finish';
|
|
default:
|
|
return 'default';
|
|
}
|
|
};
|
|
|
|
if (!visible) { return null; }
|
|
|
|
const messageKey = checked === 'true' ? 'resetAllUnitsTooltipChecked' : 'resetAllUnitsTooltipUnchecked';
|
|
|
|
return (
|
|
<OverlayTrigger
|
|
placement="right"
|
|
overlay={(
|
|
<Tooltip
|
|
id={`tooltip-reset-${checked}`}
|
|
className="reset-tooltip"
|
|
>
|
|
{intl.formatMessage(messages[messageKey])}
|
|
</Tooltip>
|
|
)}
|
|
>
|
|
<StatefulButton
|
|
className="reset-units-button"
|
|
labels={{
|
|
default: intl.formatMessage(messages.resetAllUnits),
|
|
pending: '',
|
|
finish: intl.formatMessage(messages.reset),
|
|
}}
|
|
icons={{
|
|
default: <Icon src={ResetIcon} />,
|
|
pending: <Icon src={SpinnerSimple} className="icon-spin" />,
|
|
finish: <Icon src={CheckCircleOutline} />,
|
|
}}
|
|
state={getResetButtonState()}
|
|
onClick={handleResetUnits}
|
|
disabledStates={['pending', 'finish']}
|
|
variant="outline"
|
|
data-testid="reset-units"
|
|
/>
|
|
</OverlayTrigger>
|
|
);
|
|
};
|
|
|
|
ResetUnitsButton.propTypes = {
|
|
courseId: PropTypes.string.isRequired,
|
|
checked: PropTypes.oneOf(['true', 'false']).isRequired,
|
|
visible: PropTypes.bool,
|
|
};
|
|
|
|
ResetUnitsButton.defaultProps = {
|
|
visible: false,
|
|
};
|
|
|
|
const SettingsModal = ({
|
|
appId,
|
|
title,
|
|
children,
|
|
configureBeforeEnable,
|
|
initialValues,
|
|
validationSchema,
|
|
onClose,
|
|
onSettingsSave,
|
|
enableAppLabel,
|
|
enableAppHelp,
|
|
learnMoreText,
|
|
helpPrivacyText,
|
|
enableReinitialize,
|
|
allUnitsEnabledText,
|
|
noUnitsEnabledText,
|
|
}) => {
|
|
const intl = useIntl();
|
|
const { courseId } = useContext(PagesAndResourcesContext);
|
|
const loadingStatus = useSelector(getLoadingStatus);
|
|
const updateSettingsRequestStatus = useSelector(getSavingStatus);
|
|
const alertRef = useRef(null);
|
|
const [saveError, setSaveError] = useState(false);
|
|
const dispatch = useDispatch();
|
|
const submitButtonState = updateSettingsRequestStatus === RequestStatus.IN_PROGRESS ? 'pending' : 'default';
|
|
const isMobile = useIsMobile();
|
|
const modalVariant = isMobile ? 'dark' : 'default';
|
|
|
|
const xpertSettings = useModel('XpertSettings', appId);
|
|
|
|
useEffect(() => {
|
|
if (updateSettingsRequestStatus === RequestStatus.SUCCESSFUL) {
|
|
dispatch(updateSavingStatus({ status: '' }));
|
|
onClose();
|
|
}
|
|
}, [updateSettingsRequestStatus]);
|
|
|
|
const handleFormSubmit = async ({ enabled, checked, ...rest }) => {
|
|
let success;
|
|
const values = { ...rest, enabled: enabled ? checked === 'true' : undefined };
|
|
|
|
if (enabled) {
|
|
success = await dispatch(updateXpertSettings(courseId, values));
|
|
} else {
|
|
success = await dispatch(removeXpertSettings(courseId));
|
|
}
|
|
|
|
if (onSettingsSave) {
|
|
success = success && await onSettingsSave(values);
|
|
}
|
|
setSaveError(!success);
|
|
!success && alertRef?.current.scrollIntoView(); // eslint-disable-line @typescript-eslint/no-unused-expressions
|
|
};
|
|
|
|
const handleFormikSubmit = ({ handleSubmit, errors }) => async (event) => {
|
|
// If submitting the form with errors, show the alert and scroll to it.
|
|
await handleSubmit(event);
|
|
if (Object.keys(errors).length > 0) {
|
|
setSaveError(true);
|
|
alertRef?.current.scrollIntoView?.(); // eslint-disable-line no-unused-expressions
|
|
}
|
|
};
|
|
|
|
const learnMoreLink = appInfo.documentationLinks?.learnMoreConfiguration && (
|
|
<div className="py-1">
|
|
<Hyperlink
|
|
className="text-primary-500"
|
|
destination={appInfo.documentationLinks.learnMoreConfiguration}
|
|
target="_blank"
|
|
rel="noreferrer noopener"
|
|
>
|
|
{learnMoreText}
|
|
</Hyperlink>
|
|
</div>
|
|
);
|
|
|
|
const helpPrivacyLink = (
|
|
<div className="py-1">
|
|
<Hyperlink
|
|
className="text-primary-500"
|
|
destination={getExternalLinkUrl('https://openai.com/api-data-privacy')}
|
|
target="_blank"
|
|
rel="noreferrer noopener"
|
|
>
|
|
{helpPrivacyText}
|
|
</Hyperlink>
|
|
</div>
|
|
);
|
|
|
|
if (loadingStatus === RequestStatus.SUCCESSFUL) {
|
|
return (
|
|
<Formik
|
|
initialValues={{
|
|
enabled: xpertSettings?.enabled !== undefined,
|
|
checked: xpertSettings?.enabled?.toString() || 'true',
|
|
...initialValues,
|
|
}}
|
|
validationSchema={
|
|
Yup.object()
|
|
.shape({
|
|
enabled: Yup.boolean(),
|
|
checked: Yup.string().oneOf(['true', 'false']),
|
|
...validationSchema,
|
|
})
|
|
}
|
|
onSubmit={handleFormSubmit}
|
|
enableReinitialize={enableReinitialize}
|
|
>
|
|
{(formikProps) => (
|
|
<Form onSubmit={handleFormikSubmit(formikProps)}>
|
|
<SettingsModalBase
|
|
title={title}
|
|
isOpen
|
|
onClose={onClose}
|
|
variant={modalVariant}
|
|
isMobile={isMobile}
|
|
isFullscreenOnMobile
|
|
intl={intl}
|
|
footer={(
|
|
<StatefulButton
|
|
labels={{
|
|
default: intl.formatMessage(messages.save),
|
|
pending: intl.formatMessage(messages.saving),
|
|
complete: intl.formatMessage(messages.saved),
|
|
}}
|
|
state={submitButtonState}
|
|
onClick={handleFormikSubmit(formikProps)}
|
|
disabled={!formikProps.dirty}
|
|
/>
|
|
)}
|
|
>
|
|
{saveError && (
|
|
<Alert variant="danger" icon={Info} ref={alertRef}>
|
|
<Alert.Heading>
|
|
{formikProps.errors.enabled?.title || intl.formatMessage(messages.errorSavingTitle)}
|
|
</Alert.Heading>
|
|
{formikProps.errors.enabled?.message || intl.formatMessage(messages.errorSavingMessage)}
|
|
</Alert>
|
|
)}
|
|
<FormSwitchGroup
|
|
id={`enable-${appId}-toggle`}
|
|
name="enabled"
|
|
onChange={formikProps.handleChange}
|
|
onBlur={formikProps.handleBlur}
|
|
checked={formikProps.values.enabled}
|
|
label={(
|
|
<div className="d-flex align-items-center">
|
|
{enableAppLabel}
|
|
{formikProps.values.enabled && (
|
|
<Badge className="ml-2" variant="success" data-testid="enable-badge">
|
|
{intl.formatMessage(messages.enabled)}
|
|
</Badge>
|
|
)}
|
|
</div>
|
|
)}
|
|
helpText={(
|
|
<div>
|
|
<p>{enableAppHelp}</p>
|
|
{helpPrivacyLink}
|
|
{learnMoreLink}
|
|
</div>
|
|
)}
|
|
/>
|
|
{(formikProps.values.enabled || configureBeforeEnable) && (
|
|
<Form.RadioSet
|
|
name="checked"
|
|
onChange={formikProps.handleChange}
|
|
onBlur={formikProps.handleBlur}
|
|
value={formikProps.values.checked}
|
|
>
|
|
<Form.Radio
|
|
className="summary-radio m-2 px-3"
|
|
data-testid="enable-radio"
|
|
value="true"
|
|
>
|
|
{allUnitsEnabledText}
|
|
<ResetUnitsButton
|
|
courseId={courseId}
|
|
checked={formikProps.values.checked}
|
|
visible={formikProps.values.checked === 'true'}
|
|
/>
|
|
</Form.Radio>
|
|
<Form.Radio
|
|
className="summary-radio m-2 px-3"
|
|
data-testid="disable-radio"
|
|
value="false"
|
|
>
|
|
{noUnitsEnabledText}
|
|
<ResetUnitsButton
|
|
courseId={courseId}
|
|
checked={formikProps.values.checked}
|
|
visible={formikProps.values.checked === 'false'}
|
|
/>
|
|
</Form.Radio>
|
|
</Form.RadioSet>
|
|
)}
|
|
{(formikProps.values.enabled || configureBeforeEnable) && children
|
|
&& <AppConfigFormDivider marginAdj={{ default: 0, sm: 0 }} />}
|
|
<AppSettingsForm formikProps={formikProps} showForm={formikProps.values.enabled || configureBeforeEnable}>
|
|
{children}
|
|
</AppSettingsForm>
|
|
</SettingsModalBase>
|
|
</Form>
|
|
)}
|
|
</Formik>
|
|
);
|
|
}
|
|
return (
|
|
<SettingsModalBase
|
|
intl={intl}
|
|
title={title}
|
|
isOpen
|
|
onClose={onClose}
|
|
size="sm"
|
|
variant={modalVariant}
|
|
isMobile={isMobile}
|
|
isFullscreenOnMobile
|
|
>
|
|
{loadingStatus === RequestStatus.IN_PROGRESS && <Loading />}
|
|
{loadingStatus === RequestStatus.FAILED && <ConnectionErrorAlert />}
|
|
{loadingStatus === RequestStatus.DENIED && <PermissionDeniedAlert />}
|
|
</SettingsModalBase>
|
|
);
|
|
};
|
|
|
|
SettingsModal.propTypes = {
|
|
title: PropTypes.string.isRequired,
|
|
appId: PropTypes.string.isRequired,
|
|
children: PropTypes.func,
|
|
onSettingsSave: PropTypes.func,
|
|
initialValues: PropTypes.shape({}),
|
|
validationSchema: PropTypes.shape({}),
|
|
onClose: PropTypes.func.isRequired,
|
|
enableAppLabel: PropTypes.string.isRequired,
|
|
enableAppHelp: PropTypes.string.isRequired,
|
|
learnMoreText: PropTypes.string.isRequired,
|
|
helpPrivacyText: PropTypes.string.isRequired,
|
|
allUnitsEnabledText: PropTypes.string.isRequired,
|
|
noUnitsEnabledText: PropTypes.string.isRequired,
|
|
configureBeforeEnable: PropTypes.bool,
|
|
enableReinitialize: PropTypes.bool,
|
|
};
|
|
|
|
SettingsModal.defaultProps = {
|
|
children: null,
|
|
onSettingsSave: null,
|
|
initialValues: {},
|
|
validationSchema: {},
|
|
configureBeforeEnable: false,
|
|
enableReinitialize: false,
|
|
};
|
|
|
|
export default SettingsModal;
|