Add settings modal for Xpert Learning Assistant feature. (#794)
* feat: modify AppSettingsModal to add bodyChildren prop and to make the learnMoreText prop optional This commit adds a new bodyChildren prop to the AppSettingsModal component. This prop is meant to be used by a parent to pass through React components that should be rendered between the enable toggle and the form. This allows parents to specify additional UI that doesn't belong in the form. For example, additional documentation about the feature or additional links are examples of additional UI that can be rendered this way. This commit modifies the learnMoreText prop to the AppSettingsModal component optional. The learnMoreText prop is used as the text for the "learn more configuration" link. This link is rendered only if the corresponding documentationLink is provided, and this link is optional. Therefore, the corresponding learnMoreText prop should also be optional. * feat: modify PagesAndResources to support additional pages in the "content permissions" section This commit modifies the way that the PagesAndResources component renders pages in the "content permissions" section to enable additional pages in this section beyond just the Xpert unit summaries feature. * feat: add settings modal for Xpert Learning Assistant feature This commit adds a settings modal for the Xpert Learning Assistant feature.
This commit is contained in:
@@ -15,4 +15,7 @@ module.exports = createConfig('jest', {
|
||||
moduleNameMapper: {
|
||||
'^lodash-es$': 'lodash',
|
||||
},
|
||||
modulePathIgnorePatterns: [
|
||||
'/src/pages-and-resources/utils.test.jsx',
|
||||
],
|
||||
});
|
||||
|
||||
@@ -5,6 +5,7 @@ import { PageWrap, AppContext } from '@edx/frontend-platform/react';
|
||||
|
||||
import { Routes, Route } from 'react-router-dom';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import _ from 'lodash';
|
||||
|
||||
import { Button, Hyperlink } from '@edx/paragon';
|
||||
import messages from './messages';
|
||||
@@ -45,14 +46,32 @@ const PagesAndResources = ({ courseId, intl }) => {
|
||||
const learningCourseURL = `${config.LEARNING_BASE_URL}/course/${courseId}`;
|
||||
const redirectUrl = `/course/${courseId}/pages-and-resources`;
|
||||
|
||||
// Each page here is driven by a course app
|
||||
// Most pages here are driven by a course app. The one exception is the Xpert unit summaries page.
|
||||
const pages = useModels('courseApps', courseAppIds);
|
||||
const xpertPluginConfigurable = useModel('XpertSettings.enabled', 'xpert-unit-summary');
|
||||
const xpertSettings = useModel('XpertSettings', 'xpert-unit-summary');
|
||||
const permissonPages = [{
|
||||
...XpertAppInfo,
|
||||
enabled: xpertSettings?.enabled !== undefined,
|
||||
}];
|
||||
|
||||
// These pages appear in a separate "Content Permissions" section at the bottom of the page.
|
||||
// If there are no content permission pages, this section will not appear.
|
||||
const contentPermissionsPages = [];
|
||||
|
||||
// Xpert unit summaries
|
||||
if (xpertPluginConfigurable?.enabled) {
|
||||
contentPermissionsPages.push({
|
||||
...XpertAppInfo,
|
||||
enabled: xpertSettings?.enabled !== undefined,
|
||||
});
|
||||
}
|
||||
|
||||
// Xpert learning assistant
|
||||
if (_.some(pages, (page) => page.id === 'learning_assistant')) {
|
||||
const index = pages.findIndex(app => app.id === 'learning_assistant');
|
||||
|
||||
// We want the Xpert learning assistant page to appear in the "Content Permissions" section instead,
|
||||
// so we remove it from pages and add it to contentPermissionsPages.
|
||||
const [page] = pages.splice(index, 1);
|
||||
contentPermissionsPages.push(page);
|
||||
}
|
||||
|
||||
if (loadingStatus === RequestStatus.IN_PROGRESS) {
|
||||
// eslint-disable-next-line react/jsx-no-useless-fragment
|
||||
@@ -83,14 +102,14 @@ const PagesAndResources = ({ courseId, intl }) => {
|
||||
<PageGrid pages={pages} />
|
||||
|
||||
{
|
||||
xpertPluginConfigurable?.enabled ? (
|
||||
!_.isEmpty(contentPermissionsPages) && (
|
||||
<>
|
||||
<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} />
|
||||
<PageGrid pages={contentPermissionsPages} />
|
||||
</>
|
||||
) : ''
|
||||
)
|
||||
}
|
||||
|
||||
<Routes>
|
||||
|
||||
@@ -1,5 +1,72 @@
|
||||
describe('PagesAndResources', () => {
|
||||
it('will pass because it is an example', () => {
|
||||
import { camelCaseObject } from '@edx/frontend-platform';
|
||||
import { screen, waitFor } from '@testing-library/react';
|
||||
|
||||
import { PagesAndResources } from '.';
|
||||
import * as pagesAndResourcesApi from './data/api';
|
||||
import { render } from './utils.test';
|
||||
import * as xpertUnitSummaryApi from './xpert-unit-summary/data/api';
|
||||
|
||||
const courseId = 'course-v1:edX+TestX+Test_Course';
|
||||
|
||||
describe('PagesAndResources', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('doesn\'t show content permissions section if relevant apps are not enabled', () => {
|
||||
jest.spyOn(pagesAndResourcesApi, 'getCourseApps').mockResolvedValue(camelCaseObject([]));
|
||||
|
||||
const apiResponse = { response: { enabled: true } };
|
||||
jest.spyOn(xpertUnitSummaryApi, 'getXpertSettings').mockResolvedValue(apiResponse);
|
||||
jest.spyOn(xpertUnitSummaryApi, 'getXpertPluginConfigurable').mockResolvedValue(apiResponse);
|
||||
|
||||
render(
|
||||
<PagesAndResources
|
||||
courseId={courseId}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.queryByRole('heading', { name: 'Content permissions' })).not.toBeInTheDocument();
|
||||
});
|
||||
it('show content permissions section if Learning Assistant app is enabled', async () => {
|
||||
const apiResponse = [
|
||||
{
|
||||
id: 'learning_assistant',
|
||||
enabled: true,
|
||||
name: 'Learning Assistant',
|
||||
description: 'Learning Assistant description',
|
||||
allowed_operations: {
|
||||
configure: false,
|
||||
enable: true,
|
||||
},
|
||||
documentation_links: {},
|
||||
},
|
||||
];
|
||||
|
||||
jest.spyOn(pagesAndResourcesApi, 'getCourseApps').mockResolvedValue(camelCaseObject(apiResponse));
|
||||
|
||||
render(
|
||||
<PagesAndResources
|
||||
courseId={courseId}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => expect(screen.getByRole('heading', { name: 'Content permissions' })).toBeInTheDocument());
|
||||
await waitFor(() => expect(screen.getByText('Learning Assistant')).toBeInTheDocument());
|
||||
});
|
||||
it('show content permissions section if Xpert learning summaries app is enabled', async () => {
|
||||
const apiResponse = { response: { enabled: true } };
|
||||
|
||||
jest.spyOn(xpertUnitSummaryApi, 'getXpertSettings').mockResolvedValue(apiResponse);
|
||||
jest.spyOn(xpertUnitSummaryApi, 'getXpertPluginConfigurable').mockResolvedValue(apiResponse);
|
||||
|
||||
render(
|
||||
<PagesAndResources
|
||||
courseId={courseId}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => expect(screen.getByRole('heading', { name: 'Content permissions' })).toBeInTheDocument());
|
||||
await waitFor(() => expect(screen.getByText('Xpert unit summaries')).toBeInTheDocument());
|
||||
});
|
||||
});
|
||||
|
||||
@@ -109,6 +109,7 @@ const AppSettingsModal = ({
|
||||
appId,
|
||||
title,
|
||||
children,
|
||||
bodyChildren,
|
||||
configureBeforeEnable,
|
||||
initialValues,
|
||||
validationSchema,
|
||||
@@ -243,6 +244,7 @@ const AppSettingsModal = ({
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{bodyChildren}
|
||||
{(formikProps.values.enabled || configureBeforeEnable) && children
|
||||
&& <AppConfigFormDivider marginAdj={{ default: 0, sm: 0 }} />}
|
||||
<AppSettingsForm formikProps={formikProps} showForm={formikProps.values.enabled || configureBeforeEnable}>
|
||||
@@ -277,13 +279,14 @@ AppSettingsModal.propTypes = {
|
||||
title: PropTypes.string.isRequired,
|
||||
appId: PropTypes.string.isRequired,
|
||||
children: PropTypes.func,
|
||||
bodyChildren: PropTypes.node,
|
||||
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,
|
||||
learnMoreText: PropTypes.string,
|
||||
configureBeforeEnable: PropTypes.bool,
|
||||
enableReinitialize: PropTypes.bool,
|
||||
hideAppToggle: PropTypes.bool,
|
||||
@@ -291,9 +294,11 @@ AppSettingsModal.propTypes = {
|
||||
|
||||
AppSettingsModal.defaultProps = {
|
||||
children: null,
|
||||
bodyChildren: null,
|
||||
onSettingsSave: null,
|
||||
initialValues: {},
|
||||
validationSchema: {},
|
||||
learnMoreText: null,
|
||||
configureBeforeEnable: false,
|
||||
enableReinitialize: false,
|
||||
hideAppToggle: false,
|
||||
|
||||
62
src/pages-and-resources/learning_assistant/Settings.jsx
Normal file
62
src/pages-and-resources/learning_assistant/Settings.jsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { Hyperlink } from '@edx/paragon';
|
||||
|
||||
import AppSettingsModal from '../app-settings-modal/AppSettingsModal';
|
||||
import messages from './messages';
|
||||
import { useModel } from '../../generic/model-store';
|
||||
|
||||
const LearningAssistantSettings = ({ intl, onClose }) => {
|
||||
const appId = 'learning_assistant';
|
||||
const appInfo = useModel('courseApps', appId);
|
||||
|
||||
// We need to render more than one link, so we use the bodyChildren prop.
|
||||
const bodyChildren = (
|
||||
appInfo?.documentationLinks?.learnMoreOpenaiDataPrivacy && appInfo?.documentationLinks?.learnMoreOpenai
|
||||
? (
|
||||
<div className="d-flex flex-column">
|
||||
{appInfo.documentationLinks?.learnMoreOpenaiDataPrivacy && (
|
||||
<Hyperlink
|
||||
className="text-primary-500"
|
||||
destination={appInfo.documentationLinks.learnMoreOpenaiDataPrivacy}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
>
|
||||
{intl.formatMessage(messages.learningAssistantOpenAIDataPrivacyLink)}
|
||||
</Hyperlink>
|
||||
)}
|
||||
{appInfo.documentationLinks?.learnMoreOpenai && (
|
||||
<Hyperlink
|
||||
className="text-primary-500"
|
||||
destination={appInfo.documentationLinks.learnMoreOpenai}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
>
|
||||
{intl.formatMessage(messages.learningAssistantOpenAILink)}
|
||||
</Hyperlink>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
: null
|
||||
);
|
||||
|
||||
return (
|
||||
<AppSettingsModal
|
||||
appId={appId}
|
||||
title={intl.formatMessage(messages.heading)}
|
||||
enableAppHelp={intl.formatMessage(messages.enableLearningAssistantHelp)}
|
||||
enableAppLabel={intl.formatMessage(messages.enableLearningAssistantLabel)}
|
||||
bodyChildren={bodyChildren}
|
||||
onClose={onClose}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
LearningAssistantSettings.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
onClose: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(LearningAssistantSettings);
|
||||
59
src/pages-and-resources/learning_assistant/Settings.test.jsx
Normal file
59
src/pages-and-resources/learning_assistant/Settings.test.jsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import React from 'react';
|
||||
import { screen, waitFor } from '@testing-library/react';
|
||||
|
||||
import LearningAssistantSettings from './Settings';
|
||||
import { render } from '../utils.test';
|
||||
import { RequestStatus } from '../../data/constants';
|
||||
|
||||
const onClose = () => { };
|
||||
|
||||
describe('Learning Assistant Settings', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('renders', async () => {
|
||||
const initialState = {
|
||||
models: {
|
||||
courseApps: {
|
||||
learning_assistant:
|
||||
{
|
||||
id: 'learning_assistant',
|
||||
enabled: true,
|
||||
name: 'Learning Assistant',
|
||||
description: 'Learning Assistant description',
|
||||
allowedOperations: {
|
||||
configure: false,
|
||||
enable: true,
|
||||
},
|
||||
documentationLinks: {
|
||||
learnMoreOpenaiDataPrivacy: 'www.example.com/learn-more-data-privacy',
|
||||
learnMoreOpenai: 'www.example.com/learn-more',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
pagesAndResources: {
|
||||
loadingStatus: RequestStatus.SUCCESSFUL,
|
||||
},
|
||||
};
|
||||
|
||||
render(
|
||||
<LearningAssistantSettings
|
||||
onClose={onClose}
|
||||
/>,
|
||||
{
|
||||
preloadedState: initialState,
|
||||
},
|
||||
);
|
||||
|
||||
const toggleDescription = 'Reinforce learning concepts by sharing text-based course content '
|
||||
+ 'with OpenAI (via API) to power an in-course Learning Assistant. Learners can leave feedback about the quality '
|
||||
+ 'of the AI-powered experience for use by edX to improve the performance of the tool.';
|
||||
|
||||
await waitFor(() => expect(screen.getByRole('heading', { name: 'Configure Learning Assistant' })).toBeInTheDocument());
|
||||
await waitFor(() => expect(screen.getByText(toggleDescription)).toBeInTheDocument());
|
||||
await waitFor(() => expect(screen.getByText('Learn more about how OpenAI handles data')).toBeInTheDocument());
|
||||
await waitFor(() => expect(screen.getByText('Learn more about OpenAI API data privacy')).toBeInTheDocument());
|
||||
});
|
||||
});
|
||||
28
src/pages-and-resources/learning_assistant/messages.js
Normal file
28
src/pages-and-resources/learning_assistant/messages.js
Normal file
@@ -0,0 +1,28 @@
|
||||
import { defineMessages } from '@edx/frontend-platform/i18n';
|
||||
|
||||
const messages = defineMessages({
|
||||
heading: {
|
||||
id: 'course-authoring.pages-resources.learning-assistant.heading',
|
||||
defaultMessage: 'Configure Learning Assistant',
|
||||
},
|
||||
enableLearningAssistantLabel: {
|
||||
id: 'course-authoring.pages-resources.learning_assistant.enable-learning-assistant.label',
|
||||
defaultMessage: 'Learning Assistant',
|
||||
},
|
||||
enableLearningAssistantHelp: {
|
||||
id: 'course-authoring.pages-resources.learning_assistant.enable-learning-assistant.help',
|
||||
defaultMessage: `Reinforce learning concepts by sharing text-based course content with OpenAI (via API) to power
|
||||
an in-course Learning Assistant. Learners can leave feedback about the quality of the AI-powered experience for
|
||||
use by edX to improve the performance of the tool.`,
|
||||
},
|
||||
learningAssistantOpenAILink: {
|
||||
id: 'course-authoring.pages-resources.learning_assistant.open-ai.link',
|
||||
defaultMessage: 'Learn more about how OpenAI handles data',
|
||||
},
|
||||
learningAssistantOpenAIDataPrivacyLink: {
|
||||
id: 'course-authoring.pages-resources.learning_assistant.open-ai.data-privacy.link',
|
||||
defaultMessage: 'Learn more about OpenAI API data privacy',
|
||||
},
|
||||
});
|
||||
|
||||
export default messages;
|
||||
48
src/pages-and-resources/utils.test.jsx
Normal file
48
src/pages-and-resources/utils.test.jsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { render } from '@testing-library/react';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import { configureStore } from '@reduxjs/toolkit';
|
||||
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||
import { Provider } from 'react-redux';
|
||||
|
||||
import { reducer as modelsReducer } from '../generic/model-store';
|
||||
import { reducer as pagesAndResourcesReducer } from './data/slice';
|
||||
import PagesAndResourcesProvider from './PagesAndResourcesProvider';
|
||||
|
||||
function renderWithProviders(
|
||||
ui,
|
||||
{
|
||||
preloadedState = {},
|
||||
// Automatically create a store instance if no store was passed in
|
||||
store = configureStore(
|
||||
{
|
||||
reducer: { pagesAndResources: pagesAndResourcesReducer, models: modelsReducer }, preloadedState,
|
||||
},
|
||||
),
|
||||
...renderOptions
|
||||
} = {},
|
||||
) {
|
||||
const Wrapper = ({ children }) => (
|
||||
<IntlProvider locale="en">
|
||||
{/* <MemoryRouter initialEntries={['/pages-and-resources']}> */}
|
||||
<PagesAndResourcesProvider courseId="course-v1:edX+TestX+Test_Course">
|
||||
<BrowserRouter>
|
||||
<Provider store={store}>{children}</Provider>
|
||||
</BrowserRouter>
|
||||
</PagesAndResourcesProvider>
|
||||
{/* </MemoryRouter> */}
|
||||
</IntlProvider>
|
||||
);
|
||||
|
||||
Wrapper.propTypes = {
|
||||
children: PropTypes.node.isRequired,
|
||||
};
|
||||
|
||||
// Return an object with the store and all of RTL's query functions
|
||||
return { store, ...render(ui, { wrapper: Wrapper, ...renderOptions }) };
|
||||
}
|
||||
|
||||
// We may add additional exports to this file over time, so we will not export render as the default.
|
||||
export { renderWithProviders as render }; // eslint-disable-line import/prefer-default-export
|
||||
Reference in New Issue
Block a user