diff --git a/jest.config.js b/jest.config.js
index dc0163477..54732260c 100644
--- a/jest.config.js
+++ b/jest.config.js
@@ -15,4 +15,7 @@ module.exports = createConfig('jest', {
moduleNameMapper: {
'^lodash-es$': 'lodash',
},
+ modulePathIgnorePatterns: [
+ '/src/pages-and-resources/utils.test.jsx',
+ ],
});
diff --git a/src/pages-and-resources/PagesAndResources.jsx b/src/pages-and-resources/PagesAndResources.jsx
index 6784572f1..cc3a15dd0 100644
--- a/src/pages-and-resources/PagesAndResources.jsx
+++ b/src/pages-and-resources/PagesAndResources.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 }) => {
{
- xpertPluginConfigurable?.enabled ? (
+ !_.isEmpty(contentPermissionsPages) && (
<>
{intl.formatMessage(messages.contentPermissions)}
-
+
>
- ) : ''
+ )
}
diff --git a/src/pages-and-resources/PagesAndResources.test.jsx b/src/pages-and-resources/PagesAndResources.test.jsx
index e68d14ebe..b30aedaaf 100644
--- a/src/pages-and-resources/PagesAndResources.test.jsx
+++ b/src/pages-and-resources/PagesAndResources.test.jsx
@@ -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(
+ ,
+ );
+
+ 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(
+ ,
+ );
+
+ 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(
+ ,
+ );
+
+ await waitFor(() => expect(screen.getByRole('heading', { name: 'Content permissions' })).toBeInTheDocument());
+ await waitFor(() => expect(screen.getByText('Xpert unit summaries')).toBeInTheDocument());
});
});
diff --git a/src/pages-and-resources/app-settings-modal/AppSettingsModal.jsx b/src/pages-and-resources/app-settings-modal/AppSettingsModal.jsx
index 6ac63122b..cbeb10f7f 100644
--- a/src/pages-and-resources/app-settings-modal/AppSettingsModal.jsx
+++ b/src/pages-and-resources/app-settings-modal/AppSettingsModal.jsx
@@ -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
&& }
@@ -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,
diff --git a/src/pages-and-resources/learning_assistant/Settings.jsx b/src/pages-and-resources/learning_assistant/Settings.jsx
new file mode 100644
index 000000000..ee2e17f5b
--- /dev/null
+++ b/src/pages-and-resources/learning_assistant/Settings.jsx
@@ -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
+ ? (
+
+ {appInfo.documentationLinks?.learnMoreOpenaiDataPrivacy && (
+
+ {intl.formatMessage(messages.learningAssistantOpenAIDataPrivacyLink)}
+
+ )}
+ {appInfo.documentationLinks?.learnMoreOpenai && (
+
+ {intl.formatMessage(messages.learningAssistantOpenAILink)}
+
+ )}
+
+ )
+ : null
+ );
+
+ return (
+
+ );
+};
+
+LearningAssistantSettings.propTypes = {
+ intl: intlShape.isRequired,
+ onClose: PropTypes.func.isRequired,
+};
+
+export default injectIntl(LearningAssistantSettings);
diff --git a/src/pages-and-resources/learning_assistant/Settings.test.jsx b/src/pages-and-resources/learning_assistant/Settings.test.jsx
new file mode 100644
index 000000000..087434857
--- /dev/null
+++ b/src/pages-and-resources/learning_assistant/Settings.test.jsx
@@ -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(
+ ,
+ {
+ 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());
+ });
+});
diff --git a/src/pages-and-resources/learning_assistant/messages.js b/src/pages-and-resources/learning_assistant/messages.js
new file mode 100644
index 000000000..78060bbc7
--- /dev/null
+++ b/src/pages-and-resources/learning_assistant/messages.js
@@ -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;
diff --git a/src/pages-and-resources/utils.test.jsx b/src/pages-and-resources/utils.test.jsx
new file mode 100644
index 000000000..078519864
--- /dev/null
+++ b/src/pages-and-resources/utils.test.jsx
@@ -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 }) => (
+
+ {/* */}
+
+
+ {children}
+
+
+ {/* */}
+
+ );
+
+ 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