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:
Michael Roytman
2024-01-24 12:00:17 -05:00
committed by GitHub
parent 9e06065fd3
commit 6d431e5746
8 changed files with 302 additions and 11 deletions

View File

@@ -15,4 +15,7 @@ module.exports = createConfig('jest', {
moduleNameMapper: {
'^lodash-es$': 'lodash',
},
modulePathIgnorePatterns: [
'/src/pages-and-resources/utils.test.jsx',
],
});

View File

@@ -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>

View File

@@ -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());
});
});

View File

@@ -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,

View 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);

View 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());
});
});

View 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;

View 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