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.
This commit is contained in:
Michael Roytman
2023-08-23 09:14:14 -04:00
committed by GitHub
parent 2b9b3db5d3
commit 2d29827e6b
18 changed files with 338 additions and 260 deletions

View File

@@ -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 && (
<>
<ChatTrigger
<Chat
enabled={course.learningAssistantEnabled}
enrollmentMode={course.enrollmentMode}
isStaff={isStaff}
launchUrl={course.learningAssistantLaunchUrl}
courseId={courseId}
/>
<SidebarTriggers />

View File

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

View File

@@ -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(
<BrowserRouter>
<Chat
enrollmentMode={test.enrollmentMode}
isStaff={false}
enabled
courseId={courseId}
/>
</BrowserRouter>,
{ 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(
<BrowserRouter>
<Chat
enrollmentMode={test.enrollmentMode}
isStaff
enabled
courseId={courseId}
/>
</BrowserRouter>,
{ 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(
<BrowserRouter>
<Chat
enrollmentMode={test.enrollmentMode}
isStaff={test.isStaff}
enabled={test.enabled}
courseId={courseId}
/>
</BrowserRouter>,
{ store },
);
const chat = screen.queryByRole('button');
if (test.isVisible) {
expect(chat).toBeInTheDocument();
} else {
expect(chat).not.toBeInTheDocument();
}
},
);
});
});

View File

@@ -0,0 +1 @@
export { default } from './Chat';

View File

@@ -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 && (
<div
className={classNames('mt-3', 'd-flex', 'ml-auto')}
>
<OverlayTrigger
trigger="click"
key="top"
show={!hasOpenedChat}
overlay={(
<Popover id="popover-chat-information">
<Popover.Title as="h3">{intl.formatMessage(messages.popoverTitle)}</Popover.Title>
<Popover.Content>
{intl.formatMessage(messages.popoverContent)}
</Popover.Content>
</Popover>
)}
>
<button
className="border border-light-400 bg-transparent align-items-center align-content-center d-flex"
type="button"
onClick={handleOpen}
aria-label={intl.formatMessage(messages.openChatModalTrigger)}
>
<div className="icon-container d-flex position-relative align-items-center">
<Icon src={ChatBubbleOutline} className="m-0 m-auto" />
</div>
</button>
</OverlayTrigger>
<ModalDialog
onClose={close}
isOpen={isOpen}
title={intl.formatMessage(messages.modalTitle)}
size="xl"
hasCloseButton
>
<ModalDialog.Header>
<ModalDialog.Title>
{intl.formatMessage(messages.modalTitle)}
</ModalDialog.Title>
</ModalDialog.Header>
<ModalDialog.Body>
<iframe
src={launchUrl}
allowFullScreen
style={{
width: '100%',
height: '60vh',
}}
title={intl.formatMessage(messages.modalTitle)}
/>
</ModalDialog.Body>
</ModalDialog>
</div>
)}
</>
);
};
ChatTrigger.propTypes = {
intl: intlShape.isRequired,
isStaff: PropTypes.bool.isRequired,
enrollmentMode: PropTypes.string,
launchUrl: PropTypes.string,
courseId: PropTypes.string.isRequired,
};
ChatTrigger.defaultProps = {
launchUrl: null,
enrollmentMode: null,
};
export default injectIntl(ChatTrigger);

View File

@@ -1,88 +0,0 @@
import { render } from '@testing-library/react';
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import { BrowserRouter } from 'react-router-dom';
import React from 'react';
import ChatTrigger from './ChatTrigger';
import { act, fireEvent, screen } from '../../../setupTest';
jest.mock('@edx/frontend-platform/auth', () => ({
getAuthenticatedUser: jest.fn(() => ({ userId: 1 })),
}));
jest.mock('@edx/frontend-platform/analytics');
describe('ChatTrigger', () => {
it('handles click to open/close chat modal', async () => {
sendTrackEvent.mockClear();
render(
<IntlProvider>
<BrowserRouter>
<ChatTrigger
enrollmentMode={null}
isStaff
launchUrl="https://testurl.org"
courseId="course-edX"
/>
</BrowserRouter>,
</IntlProvider>,
);
const chatTrigger = screen.getByRole('button', { name: /Show chat modal/i });
expect(chatTrigger).toBeInTheDocument();
expect(screen.queryByText('Need help understanding course content?')).toBeInTheDocument();
await act(async () => {
fireEvent.click(chatTrigger);
});
const modalCloseButton = screen.getByRole('button', { name: /Close/i });
await expect(modalCloseButton).toBeInTheDocument();
expect(sendTrackEvent).toHaveBeenCalledTimes(1);
expect(sendTrackEvent).toHaveBeenCalledWith('edx.ui.lms.lti_modal.opened', {
course_id: 'course-edX',
user_id: 1,
is_staff: true,
});
await act(async () => {
fireEvent.click(modalCloseButton);
});
await expect(modalCloseButton).not.toBeInTheDocument();
expect(screen.queryByText('Need help understanding course content?')).not.toBeInTheDocument();
});
const testCases = [
{ enrollmentMode: null, isVisible: false },
{ enrollmentMode: undefined, isVisible: false },
{ enrollmentMode: 'audit', isVisible: false },
{ enrollmentMode: 'xyz', isVisible: false },
{ enrollmentMode: 'professional', isVisible: true },
{ enrollmentMode: 'verified', isVisible: true },
{ enrollmentMode: 'no-id-professional', isVisible: true },
{ enrollmentMode: 'credit', isVisible: true },
{ enrollmentMode: 'masters', isVisible: true },
{ enrollmentMode: 'executive-education', isVisible: true },
];
testCases.forEach(test => {
it(`does chat to be visible based on enrollment mode of ${test.enrollmentMode}`, async () => {
render(
<IntlProvider>
<BrowserRouter>
<ChatTrigger
enrollmentMode={test.enrollmentMode}
isStaff={false}
launchUrl="https://testurl.org"
/>
</BrowserRouter>,
</IntlProvider>,
);
const chatTrigger = screen.queryByRole('button', { name: /Show chat modal/i });
if (test.isVisible) {
expect(chatTrigger).toBeInTheDocument();
} else {
expect(chatTrigger).not.toBeInTheDocument();
}
});
});
});

View File

@@ -1 +0,0 @@
export { default } from './ChatTrigger';

View File

@@ -1,26 +0,0 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
popoverTitle: {
id: 'popover.title',
defaultMessage: 'Need help understanding course content?',
description: 'Title for popover alerting user of chat modal',
},
popoverContent: {
id: 'popover.content',
defaultMessage: 'Click here for your Xpert Learning Assistant.',
description: 'Content of the popover message',
},
openChatModalTrigger: {
id: 'chat.model.trigger',
defaultMessage: 'Show chat modal',
description: 'Alt text for button that opens the chat modal',
},
modalTitle: {
id: 'chat.model.title',
defaultMessage: 'Xpert Learning Assistant',
description: 'Title for chat modal header',
},
});
export default messages;

View File

@@ -57,5 +57,5 @@ Factory.define('courseMetadata')
related_programs: null,
user_needs_integrity_signature: false,
recommendations: null,
learning_assistant_launch_url: null,
learning_assistant_enabled: null,
});

View File

@@ -122,7 +122,7 @@ function normalizeMetadata(metadata) {
relatedPrograms: camelCaseObject(data.related_programs),
userNeedsIntegritySignature: data.user_needs_integrity_signature,
canAccessProctoredExams: data.can_access_proctored_exams,
learningAssistantLaunchUrl: data.learning_assistant_launch_url,
learningAssistantEnabled: data.learning_assistant_enabled,
};
}

View File

@@ -228,7 +228,7 @@ describe('Courseware Service', () => {
linkedinAddToProfileUrl: null,
relatedPrograms: null,
userNeedsIntegritySignature: false,
learningAssistantLaunchUrl: null,
learningAssistantEnabled: false,
};
setTimeout(() => {
provider.addInteraction({
@@ -334,7 +334,7 @@ describe('Courseware Service', () => {
verification_status: string('none'),
linkedin_add_to_profile_url: null,
user_needs_integrity_signature: boolean(false),
learning_assistant_launch_url: null,
learning_assistant_enabled: boolean(false),
},
},
});