Skip to content

Commit

Permalink
feat: install Xpert chatbot from frontend-lib-learning-assistant
Browse files Browse the repository at this point in the history
This commit installs the Xpert chatbot feature from the frontend-lib-learning-assistant repository into the frontend-lib-learning application.

This component is rendered by the Course component. The component is only rendered when a few conditions are satisfied.
  • Loading branch information
MichaelRoytman committed Aug 22, 2023
1 parent 2e90e21 commit c88cd44
Show file tree
Hide file tree
Showing 17 changed files with 332 additions and 257 deletions.
84 changes: 84 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
"@edx/frontend-component-footer": "12.0.0",
"@edx/frontend-component-header": "4.0.0",
"@edx/frontend-lib-special-exams": "2.20.1",
"@edx/frontend-lib-learning-assistant": "^1.4.0",
"@edx/frontend-platform": "4.3.0",
"@edx/paragon": "20.46.0",
"@edx/react-unit-test-utils": "npm:@edx/[email protected]",
Expand Down
21 changes: 21 additions & 0 deletions src/course-home/data/__snapshots__/redux.test.js.snap
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,13 @@ Object {
"sequenceMightBeUnit": false,
"sequenceStatus": "loading",
},
"learningAssistant": Object {
"apiError": false,
"apiIsLoading": false,
"conversationId": "bef3fa5c-b8c2-464b-9c5b-9dbbde61a72c",
"currentMessage": "",
"messageList": Array [],
},
"models": Object {
"courseHomeMeta": Object {
"course-v1:edX+DemoX+Demo_Course": Object {
Expand Down Expand Up @@ -336,6 +343,13 @@ Object {
"sequenceMightBeUnit": false,
"sequenceStatus": "loading",
},
"learningAssistant": Object {
"apiError": false,
"apiIsLoading": false,
"conversationId": "bef3fa5c-b8c2-464b-9c5b-9dbbde61a72c",
"currentMessage": "",
"messageList": Array [],
},
"models": Object {
"courseHomeMeta": Object {
"course-v1:edX+DemoX+Demo_Course": Object {
Expand Down Expand Up @@ -532,6 +546,13 @@ Object {
"sequenceMightBeUnit": false,
"sequenceStatus": "loading",
},
"learningAssistant": Object {
"apiError": false,
"apiIsLoading": false,
"conversationId": "bef3fa5c-b8c2-464b-9c5b-9dbbde61a72c",
"currentMessage": "",
"messageList": Array [],
},
"models": Object {
"courseHomeMeta": Object {
"course-v1:edX+DemoX+Demo_Course": Object {
Expand Down
6 changes: 3 additions & 3 deletions src/courseware/course/Course.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,11 @@ import { AlertList } from '../../generic/user-messages';
import Sequence from './sequence';

import { CelebrationModal, shouldCelebrateOnSectionLoad, WeeklyGoalCelebrationModal } from './celebration';
import Chat from './chat/Chat';
import ContentTools from './content-tools';
import CourseBreadcrumbs from './CourseBreadcrumbs';
import SidebarProvider from './sidebar/SidebarContextProvider';
import SidebarTriggers from './sidebar/SidebarTriggers';
import ChatTrigger from './lti-modal/ChatTrigger';

import { useModel } from '../../generic/model-store';
import { getSessionStorage, setSessionStorage } from '../../data/sessionStorage';
Expand Down Expand Up @@ -93,10 +93,10 @@ const Course = ({
/>
{shouldDisplayTriggers && (
<>
<ChatTrigger
<Chat
enabled={course.learningAssistantEnabled}
enrollmentMode={course.enrollmentMode}
isStaff={isStaff}
launchUrl={course.learningAssistantLaunchUrl}
courseId={courseId}
/>
<SidebarTriggers />
Expand Down
55 changes: 55 additions & 0 deletions src/courseware/course/chat/Chat.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { createPortal } from 'react-dom';
import PropTypes from 'prop-types';

import { Xpert } from '@edx/frontend-lib-learning-assistant';
import { injectIntl } from '@edx/frontend-platform/i18n';

const Chat = ({
enabled,
enrollmentMode,
isStaff,
courseId,
}) => {
const VERIFIED_MODES = [
'professional',
'verified',
'no-id-professional',
'credit',
'masters',
'executive-education',
];

const isVerifiedEnrollmentMode = (
enrollmentMode !== null
&& enrollmentMode !== undefined
&& VERIFIED_MODES.some(mode => mode === enrollmentMode)
);

const shouldDisplayChat = (
enabled
&& (isVerifiedEnrollmentMode || isStaff) // display only to non-audit or staff
);

return (
<>
{/* Use a portal to ensure that component overlay does not compete with learning MFE styles. */}
{shouldDisplayChat && (createPortal(
<Xpert courseId={courseId} />,
document.body,
))}
</>
);
};

Chat.propTypes = {
isStaff: PropTypes.bool.isRequired,
enabled: PropTypes.bool.isRequired,
enrollmentMode: PropTypes.string,
courseId: PropTypes.string.isRequired,
};

Chat.defaultProps = {
enrollmentMode: null,
};

export default injectIntl(Chat);
157 changes: 157 additions & 0 deletions src/courseware/course/chat/Chat.test.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
import { BrowserRouter } from 'react-router-dom';
import { configureStore } from '@reduxjs/toolkit';
import React from 'react';

import { reducer as learningAssistantReducer } from '@edx/frontend-lib-learning-assistant';

import { initializeMockApp, render, screen } from '../../../setupTest';

import Chat from './Chat';

jest.mock('@edx/frontend-platform/analytics');

initializeMockApp();

const courseId = 'course-v1:edX+DemoX+Demo_Course';
let testCases = [];
let enabledTestCases = [];
let disabledTestCases = [];
const enabledModes = ['professional', 'verified', 'no-id-professional', 'credit', 'masters', 'executive-education'];
const disabledModes = [null, undefined, 'xyz', 'audit'];

describe('Chat', () => {
// Generate test cases.
enabledTestCases = enabledModes.map((mode) => ({ enrollmentMode: mode, isVisible: true }));
disabledTestCases = disabledModes.map((mode) => ({ enrollmentMode: mode, isVisible: false }));
testCases = enabledTestCases.concat(disabledTestCases);

testCases.forEach(test => {
it(
`visibility determined by ${test.enrollmentMode} enrollment mode when enabled and not isStaff`,
async () => {
const store = configureStore({
reducer: {
learningAssistant: learningAssistantReducer,
},
});

render(
<BrowserRouter>
<Chat
enrollmentMode={test.enrollmentMode}
isStaff={false}
enabled
courseId={courseId}
/>
</BrowserRouter>,
{ store },
);

// This is rather brittle and depends on the implementation of the Xpert component, but there
// isn't another way to validate that the Xpert is visible.
const chat = screen.queryByRole('button', { name: 'Have a question?' });
if (test.isVisible) {
expect(chat).toBeInTheDocument();
} else {
expect(chat).not.toBeInTheDocument();
}
},
);
});

// Generate test cases.
testCases = enabledModes.concat(disabledModes).map((mode) => ({ enrollmentMode: mode, isVisible: true }));
testCases.forEach(test => {
it('visibility determined by isStaff when enabled and any enrollment mode', async () => {
const store = configureStore({
reducer: {
learningAssistant: learningAssistantReducer,
},
});

render(
<BrowserRouter>
<Chat
enrollmentMode={test.enrollmentMode}
isStaff
enabled
courseId={courseId}
/>
</BrowserRouter>,
{ store },
);

// This is rather brittle and depends on the implementation of the Xpert component, but there
// isn't another way to validate that the Xpert is visible.
const chat = screen.queryByRole('button', { name: 'Have a question?' });
if (test.isVisible) {
expect(chat).toBeInTheDocument();
} else {
expect(chat).not.toBeInTheDocument();
}
});
});

// Generate the map function used for generating test cases by currying the map function.
// In this test suite, visibility depends on whether the enrollment mode is a valid or invalid
// enrollment mode for enabling the Chat when the user is not a staff member and the Chat is enabled. Instead of
// defining two separate map functions that differ in only one case, curry the function.
const generateMapFunction = (areEnabledModes) => (
(mode) => (
[
{
enrollmentMode: mode, isStaff: true, enabled: true, isVisible: true,
},
{
enrollmentMode: mode, isStaff: true, enabled: false, isVisible: false,
},
{
enrollmentMode: mode, isStaff: false, enabled: true, isVisible: areEnabledModes,
},
{
enrollmentMode: mode, isStaff: false, enabled: false, isVisible: false,
},
]
)
);

// Generate test cases.
enabledTestCases = enabledModes.map(generateMapFunction(true));
disabledTestCases = disabledModes.map(generateMapFunction(false));
testCases = enabledTestCases.concat(disabledTestCases);
testCases = testCases.flat();
testCases.forEach(test => {
it(
`visibility determined by ${test.enabled} enabled when ${test.isStaff} isStaff
and ${test.enrollmentMode} enrollment mode`,
async () => {
const store = configureStore({
reducer: {
learningAssistant: learningAssistantReducer,
},
});

render(
<BrowserRouter>
<Chat
enrollmentMode={test.enrollmentMode}
isStaff={test.isStaff}
enabled={test.enabled}
courseId={courseId}
/>
</BrowserRouter>,
{ store },
);

// This is rather brittle and depends on the implementation of the Xpert component, but there
// isn't another way to validate that the Xpert is visible.
const chat = screen.queryByRole('button', { name: 'Have a question?' });
if (test.isVisible) {
expect(chat).toBeInTheDocument();
} else {
expect(chat).not.toBeInTheDocument();
}
},
);
});
});
1 change: 1 addition & 0 deletions src/courseware/course/chat/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { default } from './Chat';
Loading

0 comments on commit c88cd44

Please sign in to comment.