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