From 2d29827e6b1db07bc81b00b8dcc72293ef283e54 Mon Sep 17 00:00:00 2001 From: Michael Roytman Date: Wed, 23 Aug 2023 09:14:14 -0400 Subject: [PATCH] feat: install Xpert chatbot from frontend-lib-learning-assistant (#1166) 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. --- package-lock.json | 84 ++++++++++ package.json | 1 + .../data/__snapshots__/redux.test.js.snap | 9 ++ src/course-home/data/redux.test.js | 27 +++- src/courseware/course/Course.jsx | 6 +- src/courseware/course/chat/Chat.jsx | 55 +++++++ src/courseware/course/chat/Chat.test.jsx | 151 ++++++++++++++++++ src/courseware/course/chat/index.js | 1 + .../course/lti-modal/ChatTrigger.jsx | 133 --------------- .../course/lti-modal/ChatTrigger.test.jsx | 88 ---------- src/courseware/course/lti-modal/index.js | 1 - src/courseware/course/lti-modal/messages.js | 26 --- .../__factories__/courseMetadata.factory.js | 2 +- src/courseware/data/api.js | 2 +- .../data/pact-tests/lmsPact.test.jsx | 4 +- src/pacts/frontend-app-learning-lms.json | 4 +- src/setupTest.js | 2 + src/store.js | 2 + 18 files changed, 338 insertions(+), 260 deletions(-) create mode 100644 src/courseware/course/chat/Chat.jsx create mode 100644 src/courseware/course/chat/Chat.test.jsx create mode 100644 src/courseware/course/chat/index.js delete mode 100644 src/courseware/course/lti-modal/ChatTrigger.jsx delete mode 100644 src/courseware/course/lti-modal/ChatTrigger.test.jsx delete mode 100644 src/courseware/course/lti-modal/index.js delete mode 100644 src/courseware/course/lti-modal/messages.js diff --git a/package-lock.json b/package-lock.json index 3a570e70a1..3f6104249f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "@edx/brand": "npm:@edx/brand-openedx@1.2.0", "@edx/frontend-component-footer": "12.0.0", "@edx/frontend-component-header": "4.0.0", + "@edx/frontend-lib-learning-assistant": "^1.4.0", "@edx/frontend-lib-special-exams": "2.20.1", "@edx/frontend-platform": "4.3.0", "@edx/paragon": "20.46.0", @@ -3431,6 +3432,78 @@ "node": ">=10" } }, + "node_modules/@edx/frontend-lib-learning-assistant": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@edx/frontend-lib-learning-assistant/-/frontend-lib-learning-assistant-1.4.0.tgz", + "integrity": "sha512-yTwN4Qqqx2atIX941IFEJ2HeH/nGj4pPdkpUhluce31IHjIB2oUyk1Vi/iBKASZvN/22qvrtpjCQClhFvfopEg==", + "dependencies": { + "@edx/brand": "npm:@edx/brand-openedx@1.2.0", + "@fortawesome/fontawesome-svg-core": "1.2.36", + "@fortawesome/free-brands-svg-icons": "5.15.4", + "@fortawesome/free-regular-svg-icons": "5.15.4", + "@fortawesome/free-solid-svg-icons": "5.15.4", + "@fortawesome/react-fontawesome": "0.2.0", + "core-js": "3.31.1", + "prop-types": "15.8.1", + "react-feather": "^2.0.10" + }, + "peerDependencies": { + "@edx/frontend-platform": "4.3.0", + "@edx/paragon": "20.46.0", + "@reduxjs/toolkit": "1.8.1", + "react": "16.14.0", + "react-dom": "16.14.0", + "react-redux": "7.2.9", + "react-router": "5.2.1", + "react-router-dom": "5.3.0", + "redux": "4.1.2", + "regenerator-runtime": "0.13.11", + "uuid": "9.0.0" + } + }, + "node_modules/@edx/frontend-lib-learning-assistant/node_modules/@fortawesome/fontawesome-common-types": { + "version": "0.2.36", + "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-0.2.36.tgz", + "integrity": "sha512-a/7BiSgobHAgBWeN7N0w+lAhInrGxksn13uK7231n2m8EDPE3BMCl9NZLTGrj9ZXfCmC6LM0QLqXidIizVQ6yg==", + "hasInstallScript": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/@edx/frontend-lib-learning-assistant/node_modules/@fortawesome/fontawesome-svg-core": { + "version": "1.2.36", + "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-svg-core/-/fontawesome-svg-core-1.2.36.tgz", + "integrity": "sha512-YUcsLQKYb6DmaJjIHdDWpBIGCcyE/W+p/LMGvjQem55Mm2XWVAP5kWTMKWLv9lwpCVjpLxPyOMOyUocP1GxrtA==", + "hasInstallScript": true, + "dependencies": { + "@fortawesome/fontawesome-common-types": "^0.2.36" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@edx/frontend-lib-learning-assistant/node_modules/@fortawesome/react-fontawesome": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/@fortawesome/react-fontawesome/-/react-fontawesome-0.2.0.tgz", + "integrity": "sha512-uHg75Rb/XORTtVt7OS9WoK8uM276Ufi7gCzshVWkUJbHhh3svsUUeqXerrM96Wm7fRiDzfKRwSoahhMIkGAYHw==", + "dependencies": { + "prop-types": "^15.8.1" + }, + "peerDependencies": { + "@fortawesome/fontawesome-svg-core": "~1 || ~6", + "react": ">=16.3" + } + }, + "node_modules/@edx/frontend-lib-learning-assistant/node_modules/core-js": { + "version": "3.31.1", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.31.1.tgz", + "integrity": "sha512-2sKLtfq1eFST7l7v62zaqXacPc7uG8ZAya8ogijLhTtaKNcpzpB4TMoTw2Si+8GYKRwFPMMtUT0263QFWFfqyQ==", + "hasInstallScript": true, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/core-js" + } + }, "node_modules/@edx/frontend-lib-special-exams": { "version": "2.20.1", "resolved": "https://registry.npmjs.org/@edx/frontend-lib-special-exams/-/frontend-lib-special-exams-2.20.1.tgz", @@ -19385,6 +19458,17 @@ "resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-3.2.2.tgz", "integrity": "sha512-nsO+KSNgo1SbJqJEYRE9ERzo7YtYbou/OqjSQKxV7jcKox7+usiUVZOAC+XnDOABXggQTno0Y1CpVnuWEc1boQ==" }, + "node_modules/react-feather": { + "version": "2.0.10", + "resolved": "https://registry.npmjs.org/react-feather/-/react-feather-2.0.10.tgz", + "integrity": "sha512-BLhukwJ+Z92Nmdcs+EMw6dy1Z/VLiJTzEQACDUEnWMClhYnFykJCGWQx+NmwP/qQHGX/5CzQ+TGi8ofg2+HzVQ==", + "dependencies": { + "prop-types": "^15.7.2" + }, + "peerDependencies": { + "react": ">=16.8.6" + } + }, "node_modules/react-focus-lock": { "version": "2.9.5", "resolved": "https://registry.npmjs.org/react-focus-lock/-/react-focus-lock-2.9.5.tgz", diff --git a/package.json b/package.json index 37d3729a73..283a05ffef 100644 --- a/package.json +++ b/package.json @@ -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/react-unit-test-utils@1.5.3", diff --git a/src/course-home/data/__snapshots__/redux.test.js.snap b/src/course-home/data/__snapshots__/redux.test.js.snap index 4644d7b142..be8367b7ed 100644 --- a/src/course-home/data/__snapshots__/redux.test.js.snap +++ b/src/course-home/data/__snapshots__/redux.test.js.snap @@ -18,6 +18,9 @@ Object { "sequenceMightBeUnit": false, "sequenceStatus": "loading", }, + "learningAssistant": ObjectContaining { + "conversationId": Any, + }, "models": Object { "courseHomeMeta": Object { "course-v1:edX+DemoX+Demo_Course": Object { @@ -336,6 +339,9 @@ Object { "sequenceMightBeUnit": false, "sequenceStatus": "loading", }, + "learningAssistant": ObjectContaining { + "conversationId": Any, + }, "models": Object { "courseHomeMeta": Object { "course-v1:edX+DemoX+Demo_Course": Object { @@ -532,6 +538,9 @@ Object { "sequenceMightBeUnit": false, "sequenceStatus": "loading", }, + "learningAssistant": ObjectContaining { + "conversationId": Any, + }, "models": Object { "courseHomeMeta": Object { "course-v1:edX+DemoX+Demo_Course": Object { diff --git a/src/course-home/data/redux.test.js b/src/course-home/data/redux.test.js index 8382dcf016..f96c830b83 100644 --- a/src/course-home/data/redux.test.js +++ b/src/course-home/data/redux.test.js @@ -67,7 +67,14 @@ describe('Data layer integration tests', () => { const state = store.getState(); expect(state.courseHome.courseStatus).toEqual('loaded'); - expect(state).toMatchSnapshot(); + expect(state).toMatchSnapshot({ + // The Xpert chatbot (frontend-lib-learning-assistant) generates a unique UUID + // to keep track of conversations. This causes snapshots to fail, because this UUID + // is generated on each run of the snapshot. Instead, we use an asymmetric matcher here. + learningAssistant: expect.objectContaining({ + conversationId: expect.any(String), + }), + }); }); it.each([401, 403, 404])( @@ -111,7 +118,14 @@ describe('Data layer integration tests', () => { const state = store.getState(); expect(state.courseHome.courseStatus).toEqual('loaded'); - expect(state).toMatchSnapshot(); + expect(state).toMatchSnapshot({ + // The Xpert chatbot (frontend-lib-learning-assistant) generates a unique UUID + // to keep track of conversations. This causes snapshots to fail, because this UUID + // is generated on each run of the snapshot. Instead, we use an asymmetric matcher here. + learningAssistant: expect.objectContaining({ + conversationId: expect.any(String), + }), + }); }); it.each([401, 403, 404])( @@ -156,7 +170,14 @@ describe('Data layer integration tests', () => { const state = store.getState(); expect(state.courseHome.courseStatus).toEqual('loaded'); - expect(state).toMatchSnapshot(); + expect(state).toMatchSnapshot({ + // The Xpert chatbot (frontend-lib-learning-assistant) generates a unique UUID + // to keep track of conversations. This causes snapshots to fail, because this UUID + // is generated on each run of the snapshot. Instead, we use an asymmetric matcher here. + learningAssistant: expect.objectContaining({ + conversationId: expect.any(String), + }), + }); }); it('Should handle the url including a targetUserId', async () => { diff --git a/src/courseware/course/Course.jsx b/src/courseware/course/Course.jsx index 99dbbb46ad..b3cd0ab788 100644 --- a/src/courseware/course/Course.jsx +++ b/src/courseware/course/Course.jsx @@ -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'; @@ -93,10 +93,10 @@ const Course = ({ /> {shouldDisplayTriggers && ( <> - diff --git a/src/courseware/course/chat/Chat.jsx b/src/courseware/course/chat/Chat.jsx new file mode 100644 index 0000000000..e1858fa748 --- /dev/null +++ b/src/courseware/course/chat/Chat.jsx @@ -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( + , + 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); diff --git a/src/courseware/course/chat/Chat.test.jsx b/src/courseware/course/chat/Chat.test.jsx new file mode 100644 index 0000000000..d59b3e8cf7 --- /dev/null +++ b/src/courseware/course/chat/Chat.test.jsx @@ -0,0 +1,151 @@ +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( + + + , + { store }, + ); + + const chat = screen.queryByRole('button'); + 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( + + + , + { store }, + ); + + const chat = screen.queryByRole('button'); + 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( + + + , + { store }, + ); + + const chat = screen.queryByRole('button'); + if (test.isVisible) { + expect(chat).toBeInTheDocument(); + } else { + expect(chat).not.toBeInTheDocument(); + } + }, + ); + }); +}); diff --git a/src/courseware/course/chat/index.js b/src/courseware/course/chat/index.js new file mode 100644 index 0000000000..654497b6a3 --- /dev/null +++ b/src/courseware/course/chat/index.js @@ -0,0 +1 @@ +export { default } from './Chat'; diff --git a/src/courseware/course/lti-modal/ChatTrigger.jsx b/src/courseware/course/lti-modal/ChatTrigger.jsx deleted file mode 100644 index 97784829e0..0000000000 --- a/src/courseware/course/lti-modal/ChatTrigger.jsx +++ /dev/null @@ -1,133 +0,0 @@ -import React, { useState } from 'react'; -import { getAuthenticatedUser } from '@edx/frontend-platform/auth'; -import { sendTrackEvent } from '@edx/frontend-platform/analytics'; -import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; -import { - ModalDialog, - Icon, - useToggle, - OverlayTrigger, - Popover, -} from '@edx/paragon'; -import { ChatBubbleOutline } from '@edx/paragon/icons'; -import classNames from 'classnames'; -import PropTypes from 'prop-types'; -import messages from './messages'; - -const ChatTrigger = ({ - intl, - enrollmentMode, - isStaff, - launchUrl, - courseId, -}) => { - const [isOpen, open, close] = useToggle(false); - const [hasOpenedChat, setHasOpenedChat] = useState(false); - const { userId } = getAuthenticatedUser(); - - 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 = ( - launchUrl - && (isVerifiedEnrollmentMode || isStaff) // display only to non-audit or staff - ); - - const handleOpen = () => { - if (!hasOpenedChat) { - setHasOpenedChat(true); - } - open(); - sendTrackEvent('edx.ui.lms.lti_modal.opened', { - course_id: courseId, - user_id: userId, - is_staff: isStaff, - }); - }; - - return ( - <> - {shouldDisplayChat && ( -
- - {intl.formatMessage(messages.popoverTitle)} - - {intl.formatMessage(messages.popoverContent)} - - - )} - > - - - - - - {intl.formatMessage(messages.modalTitle)} - - - -