feat: configuration for xpert unit summaries (#540)

Adds setting modal for Xpert unit summaries

Includes hiding the config section for xpert summary - 
this is done based on a flag from 3d113d267c
This commit is contained in:
David Nuon
2023-08-01 06:07:08 -07:00
committed by GitHub
parent 9f4422d1b9
commit 1dba6208a5
11 changed files with 740 additions and 1 deletions

View File

@@ -9,21 +9,24 @@ import { useDispatch, useSelector } from 'react-redux';
import { Button, Hyperlink } from '@edx/paragon';
import messages from './messages';
import DiscussionsSettings from './discussions';
import { XpertUnitSummarySettings, fetchXpertPluginConfigurable, appInfo } from './xpert-unit-summary';
import PageGrid from './pages/PageGrid';
import { fetchCourseApps } from './data/thunks';
import { useModels } from '../generic/model-store';
import { useModels, useModel } from '../generic/model-store';
import { getCourseAppsApiStatus, getLoadingStatus } from './data/selectors';
import PagesAndResourcesProvider from './PagesAndResourcesProvider';
import { RequestStatus } from '../data/constants';
import PermissionDeniedAlert from '../generic/PermissionDeniedAlert';
const permissonPages = [appInfo];
const PagesAndResources = ({ courseId, intl }) => {
const { path, url } = useRouteMatch();
const dispatch = useDispatch();
useEffect(() => {
dispatch(fetchCourseApps(courseId));
dispatch(fetchXpertPluginConfigurable(courseId));
}, [courseId]);
const courseAppIds = useSelector(state => state.pagesAndResources.courseAppIds);
@@ -35,6 +38,8 @@ const PagesAndResources = ({ courseId, intl }) => {
// Each page here is driven by a course app
const pages = useModels('courseApps', courseAppIds);
const xpertPluginConfigurable = useModel('XpertSettings.enabled', 'xpert-unit-summary');
if (loadingStatus === RequestStatus.IN_PROGRESS) {
// eslint-disable-next-line react/jsx-no-useless-fragment
return <></>;
@@ -62,6 +67,18 @@ const PagesAndResources = ({ courseId, intl }) => {
</div>
<PageGrid pages={pages} />
{
xpertPluginConfigurable?.enabled ? (
<>
<div className="d-flex justify-content-between my-4 my-md-5 align-items-center">
<h3 className="m-0">{intl.formatMessage(messages.contentPermissions)}</h3>
</div>
<PageGrid pages={permissonPages} />
</>
) : ''
}
<Switch>
<PageRoute
path={[
@@ -71,6 +88,15 @@ const PagesAndResources = ({ courseId, intl }) => {
>
<DiscussionsSettings courseId={courseId} />
</PageRoute>
<PageRoute
path={[
`${path}/xpert-unit-summary/settings`,
]}
>
<XpertUnitSummarySettings courseId={courseId} />
</PageRoute>
<PageRoute path={`${path}/:appId/settings`}>
{
({ match, history }) => {

View File

@@ -17,6 +17,10 @@ const messages = defineMessages({
id: 'course-authoring.badge.enabled',
defaultMessage: 'Enabled',
},
contentPermissions: {
id: 'course-authoring.pages-resources.content-permissions.heading',
defaultMessage: 'Content permissions',
},
});
export default messages;

View File

@@ -0,0 +1,41 @@
import React, { useCallback, useContext, useEffect } from 'react';
import { history } from '@edx/frontend-platform';
import { useDispatch } from 'react-redux';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { PagesAndResourcesContext } from '../PagesAndResourcesProvider';
import SettingsModal from './settings-modal/SettingsModal';
import messages from './messages';
import { fetchXpertSettings } from './data/thunks';
const XpertUnitSummarySettings = ({ intl }) => {
const { path: pagesAndResourcesPath, courseId } = useContext(PagesAndResourcesContext);
const dispatch = useDispatch();
useEffect(() => {
dispatch(fetchXpertSettings(courseId));
}, [courseId]);
const handleClose = useCallback(() => {
history.push(pagesAndResourcesPath);
}, [pagesAndResourcesPath]);
return (
<SettingsModal
appId="xpert-unit-summary"
title={intl.formatMessage(messages.heading)}
enableAppHelp={intl.formatMessage(messages.enableXpertUnitSummaryHelp)}
enableAppLabel={intl.formatMessage(messages.enableXpertUnitSummaryLabel)}
learnMoreText={intl.formatMessage(messages.enableXpertUnitSummaryLink)}
onClose={handleClose}
/>
);
};
XpertUnitSummarySettings.propTypes = {
intl: intlShape.isRequired,
};
export default injectIntl(XpertUnitSummarySettings);

View File

@@ -0,0 +1,189 @@
import ReactDOM from 'react-dom';
import React from 'react';
import { Switch } from 'react-router';
import {
getConfig, history, initializeMockApp, setConfig,
} from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { AppProvider, PageRoute } from '@edx/frontend-platform/react';
import {
queryByTestId, render, waitFor, getByText, fireEvent,
} from '@testing-library/react';
import MockAdapter from 'axios-mock-adapter';
import PagesAndResourcesProvider from '../PagesAndResourcesProvider';
import { XpertUnitSummarySettings } from './index';
import initializeStore from '../../store';
import * as API from './data/api';
import * as Thunks from './data/thunks';
import { executeThunk } from '../../utils';
const courseId = 'course-v1:edX+TestX+Test_Course';
let axiosMock;
let store;
let container;
// Modal creates a portal. Overriding ReactDOM.createPortal allows portals to be tested in jest.
ReactDOM.createPortal = jest.fn(node => node);
function renderComponent() {
const wrapper = render(
<AppProvider store={store}>
<PagesAndResourcesProvider courseId={courseId}>
<Switch>
<PageRoute
path={[
'/xpert-unit-summary/settings',
]}
>
<XpertUnitSummarySettings courseId={courseId} />
</PageRoute>
<PageRoute
path={[
'/',
]}
>
<div />
</PageRoute>
</Switch>
</PagesAndResourcesProvider>
</AppProvider>,
);
container = wrapper.container;
}
function generateCourseLevelAPIRepsonse({
success, enabled,
}) {
return {
response: {
success, enabled,
},
};
}
describe('XpertUnitSummarySettings', () => {
beforeEach(() => {
setConfig({
...getConfig(),
BASE_URL: 'http://test.edx.org',
LMS_BASE_URL: 'http://lmstest.edx.org',
CMS_BASE_URL: 'http://cmstest.edx.org',
LOGIN_URL: 'http://support.edx.org/login',
LOGOUT_URL: 'http://support.edx.org/logout',
REFRESH_ACCESS_TOKEN_ENDPOINT: 'http://support.edx.org/access_token',
ACCESS_TOKEN_COOKIE_NAME: 'cookie',
CSRF_TOKEN_API_PATH: '/',
SUPPORT_URL: 'http://support.edx.org',
});
initializeMockApp({
authenticatedUser: {
userId: 3,
username: 'abc123',
administrator: true,
roles: [],
},
});
store = initializeStore({
models: {
courseDetails: {
[courseId]: {
start: Date(),
},
},
},
});
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
// Go back to settings route
history.push('/xpert-unit-summary/settings');
});
describe('with successful network connections', () => {
beforeEach(() => {
axiosMock.onGet(API.getXpertSettingsUrl(courseId))
.reply(200, generateCourseLevelAPIRepsonse({
success: true,
enabled: true,
}));
renderComponent();
});
test('Shows enabled if enabled from backend', async () => {
expect(container.querySelector('#enable-xpert-unit-summary-toggle').checked).toBeTruthy();
expect(queryByTestId(container, 'enable-badge')).toBeTruthy();
});
test('Does not show enabled if disabled from backend', async () => {
axiosMock.onGet(API.getXpertSettingsUrl(courseId))
.reply(200, generateCourseLevelAPIRepsonse({
success: true,
enabled: false,
}));
renderComponent();
await waitFor(() => expect(container.querySelector('#enable-xpert-unit-summary-toggle')).toBeTruthy());
expect(container.querySelector('#enable-xpert-unit-summary-toggle').checked).not.toBeTruthy();
expect(queryByTestId(container, 'enable-badge')).not.toBeTruthy();
});
});
describe('first time course configuration', () => {
beforeEach(() => {
axiosMock.onGet(API.getXpertSettingsUrl(courseId))
.reply(400, generateCourseLevelAPIRepsonse({
success: false,
enabled: false,
}));
renderComponent();
});
test('Does not show as enabled if configuation does not exist', async () => {
await waitFor(() => expect(container.querySelector('#enable-xpert-unit-summary-toggle')).toBeTruthy());
expect(container.querySelector('#enable-xpert-unit-summary-toggle').checked).not.toBeTruthy();
expect(queryByTestId(container, 'enable-badge')).not.toBeTruthy();
});
});
describe('saving configuration changes', () => {
beforeEach(() => {
axiosMock.onPost(API.getXpertSettingsUrl(courseId))
.reply(200, generateCourseLevelAPIRepsonse({
success: true,
enabled: true,
}));
renderComponent();
});
test('Saving configuration changes', async () => {
jest.spyOn(API, 'postXpertSettings');
await waitFor(() => expect(container.querySelector('#enable-xpert-unit-summary-toggle')).toBeTruthy());
fireEvent.click(getByText(container, 'Save'));
await waitFor(() => expect(container.querySelector('#enable-xpert-unit-summary-toggle')).not.toBeTruthy());
expect(API.postXpertSettings).toBeCalled();
});
});
describe('testing configurable gating', () => {
beforeEach(async () => {
axiosMock.onGet(API.getXpertConfigurationStatusUrl(courseId))
.reply(200, generateCourseLevelAPIRepsonse({
success: true,
enabled: true,
}));
jest.spyOn(API, 'getXpertPluginConfigurable');
await executeThunk(Thunks.fetchXpertPluginConfigurable(courseId), store.dispatch);
renderComponent();
});
test('getting Xpert Plugin configurable status', () => {
expect(API.getXpertPluginConfigurable).toBeCalled();
});
});
});

View File

@@ -0,0 +1,13 @@
export default {
id: 'xpert-unit-summary',
enabled: false,
name: 'Xpert unit summaries',
description: 'Harness ChatGPT for quick, focused summaries of text and video content.',
allowedOperations: {
enable: true,
configure: true,
},
documentationLinks: {
learnMoreConfiguration: '',
},
};

View File

@@ -0,0 +1,33 @@
import { getConfig } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
export function getXpertSettingsUrl(courseId) {
return `${getConfig().STUDIO_BASE_URL}/ai_aside/v1/${courseId}`;
}
export function getXpertConfigurationStatusUrl(courseId) {
return `${getConfig().STUDIO_BASE_URL}/ai_aside/v1/${courseId}/configurable`;
}
export async function getXpertSettings(courseId) {
const { data } = await getAuthenticatedHttpClient()
.get(getXpertSettingsUrl(courseId));
return data;
}
export async function postXpertSettings(courseId, state) {
const { data } = await getAuthenticatedHttpClient()
.post(getXpertSettingsUrl(courseId), {
enabled: state.enabled,
});
return data;
}
export async function getXpertPluginConfigurable(courseId) {
const { data } = await getAuthenticatedHttpClient()
.get(getXpertConfigurationStatusUrl(courseId));
return data;
}

View File

@@ -0,0 +1,71 @@
import { getXpertSettings, postXpertSettings, getXpertPluginConfigurable } from './api';
import { updateSavingStatus, updateLoadingStatus } from '../../data/slice';
import { RequestStatus } from '../../../data/constants';
import { addModel, updateModel } from '../../../generic/model-store';
export function updateXpertSettings(courseId, state) {
return async (dispatch) => {
dispatch(updateSavingStatus({ status: RequestStatus.IN_PROGRESS }));
try {
const { response } = await postXpertSettings(courseId, state);
const { success, enabled } = response;
if (success) {
dispatch(updateModel({ modelType: 'XpertSettings', model: { id: 'xpert-unit-summary', enabled } }));
dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL }));
return true;
}
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
return false;
} catch (error) {
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
return false;
}
};
}
export function fetchXpertPluginConfigurable(courseId) {
return async (dispatch) => {
let enabled = false;
dispatch(updateLoadingStatus({ status: RequestStatus.PENDING }));
try {
const { response } = await getXpertPluginConfigurable(courseId);
enabled = response?.enabled;
} catch (e) {
enabled = false;
}
dispatch(addModel({
modelType: 'XpertSettings.enabled',
model: {
id: 'xpert-unit-summary',
enabled,
},
}));
};
}
export function fetchXpertSettings(courseId) {
return async (dispatch) => {
let enabled = false;
dispatch(updateLoadingStatus({ status: RequestStatus.PENDING }));
try {
const { response } = await getXpertSettings(courseId);
enabled = response?.enabled;
} catch (e) {
enabled = false;
}
dispatch(addModel({
modelType: 'XpertSettings',
model: {
id: 'xpert-unit-summary',
enabled,
},
}));
dispatch(updateLoadingStatus({ status: RequestStatus.SUCCESSFUL }));
};
}

View File

@@ -0,0 +1,9 @@
import XpertUnitSummarySettings from './XpertUnitSummarySettings';
import appInfo from './appInfo';
import { fetchXpertPluginConfigurable } from './data/thunks';
export {
XpertUnitSummarySettings,
appInfo,
fetchXpertPluginConfigurable,
};

View File

@@ -0,0 +1,30 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
heading: {
id: 'course-authoring.pages-resources.xpert-unit-summary.heading',
defaultMessage: 'Configure Xpert unit summaries',
},
enableXpertUnitSummaryLabel: {
id: 'course-authoring.pages-resources.xpert-unit-summary.enable-xpert-unit-summary.label',
defaultMessage: 'Xpert unit summaries',
},
enableXpertUnitSummaryHelp: {
id: 'course-authoring.pages-resources.xpert-unit-summary.enable-xpert-unit-summary.help',
defaultMessage: 'Enable concise summaries of text and video content.',
},
enableXpertUnitSummaryLink: {
id: 'course-authoring.pages-resources.xpert-unit-summary.enable-xpert-unit-summary.link',
defaultMessage: 'Learn more about the Xpert unit summaries',
},
allUnitsEnabledByDefault: {
id: 'course-authoring.pages-resources.xpert-unit-summary.all-units-enabled-by-default',
defaultMessage: 'All units enabled by default',
},
noUnitsEnabledByDefault: {
id: 'course-authoring.pages-resources.xpert-unit-summary.no-units-enabled-by-default',
defaultMessage: 'No units enabled by default',
},
});
export default messages;

View File

@@ -0,0 +1,281 @@
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import {
ActionRow,
Alert,
Badge,
Form,
ModalDialog,
StatefulButton,
TransitionReplace,
} from '@edx/paragon';
import { Info } from '@edx/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 '../../../data/constants';
import ConnectionErrorAlert from '../../../generic/ConnectionErrorAlert';
import FormSwitchGroup from '../../../generic/FormSwitchGroup';
import Loading from '../../../generic/Loading';
import { useModel } from '../../../generic/model-store';
import PermissionDeniedAlert from '../../../generic/PermissionDeniedAlert';
import { useIsMobile } from '../../../utils';
import { getLoadingStatus, getSavingStatus } from '../../data/selectors';
import { updateSavingStatus } from '../../data/slice';
import { updateXpertSettings } from '../data/thunks';
import AppConfigFormDivider from '../../discussions/app-config-form/apps/shared/AppConfigFormDivider';
import { PagesAndResourcesContext } from '../../PagesAndResourcesProvider';
import messages from './messages';
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 = ({
intl, title, onClose, variant, isMobile, children, footer,
}) => (
<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 = {
intl: intlShape.isRequired,
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 SettingsModal = ({
intl,
appId,
title,
children,
configureBeforeEnable,
initialValues,
validationSchema,
onClose,
onSettingsSave,
enableAppLabel,
enableAppHelp,
enableReinitialize,
}) => {
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 (values) => {
let success = true;
success = await dispatch(updateXpertSettings(courseId, values));
if (onSettingsSave) {
success = success && await onSettingsSave(values);
}
setSaveError(!success);
!success && alertRef?.current.scrollIntoView(); // eslint-disable-line 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
}
};
if (loadingStatus === RequestStatus.SUCCESSFUL) {
return (
<Formik
initialValues={{
enabled: !!xpertSettings?.enabled,
...initialValues,
}}
validationSchema={
Yup.object()
.shape({
enabled: Yup.boolean(),
...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)}
/>
)}
>
{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={(event) => formikProps.handleChange(event)}
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>
</div>
)}
/>
{(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 = {
intl: intlShape.isRequired,
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,
configureBeforeEnable: PropTypes.bool,
enableReinitialize: PropTypes.bool,
};
SettingsModal.defaultProps = {
children: null,
onSettingsSave: null,
initialValues: {},
validationSchema: {},
configureBeforeEnable: false,
enableReinitialize: false,
};
export default injectIntl(SettingsModal);

View File

@@ -0,0 +1,42 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
cancel: {
id: 'course-authoring.pages-resources.app-settings-modal.button.cancel',
defaultMessage: 'Cancel',
},
save: {
id: 'course-authoring.pages-resources.app-settings-modal.button.save',
defaultMessage: 'Save',
},
saving: {
id: 'course-authoring.pages-resources.app-settings-modal.button.saving',
defaultMessage: 'Saving',
},
saved: {
id: 'course-authoring.pages-resources.app-settings-modal.button.saved',
defaultMessage: 'Saved',
},
retry: {
id: 'course-authoring.pages-resources.app-settings-modal.button.retry',
defaultMessage: 'Retry',
},
enabled: {
id: 'course-authoring.pages-resources.app-settings-modal.badge.enabled',
defaultMessage: 'Enabled',
},
disabled: {
id: 'course-authoring.pages-resources.app-settings-modal.badge.disabled',
defaultMessage: 'Disabled',
},
errorSavingTitle: {
id: 'course-authoring.pages-resources.app-settings-modal.save-error.title',
defaultMessage: 'We couldn\'t apply your changes.',
},
errorSavingMessage: {
id: 'course-authoring.pages-resources.app-settings-modal.save-error.message',
defaultMessage: 'Please check your entries and try again.',
},
});
export default messages;