From 6d431e57463881ce98e0b5eeb41a6a3545332bcb Mon Sep 17 00:00:00 2001 From: Michael Roytman Date: Wed, 24 Jan 2024 12:00:17 -0500 Subject: [PATCH] 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. --- jest.config.js | 3 + src/pages-and-resources/PagesAndResources.jsx | 35 ++++++--- .../PagesAndResources.test.jsx | 71 ++++++++++++++++++- .../app-settings-modal/AppSettingsModal.jsx | 7 +- .../learning_assistant/Settings.jsx | 62 ++++++++++++++++ .../learning_assistant/Settings.test.jsx | 59 +++++++++++++++ .../learning_assistant/messages.js | 28 ++++++++ src/pages-and-resources/utils.test.jsx | 48 +++++++++++++ 8 files changed, 302 insertions(+), 11 deletions(-) create mode 100644 src/pages-and-resources/learning_assistant/Settings.jsx create mode 100644 src/pages-and-resources/learning_assistant/Settings.test.jsx create mode 100644 src/pages-and-resources/learning_assistant/messages.js create mode 100644 src/pages-and-resources/utils.test.jsx 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