diff --git a/src/components/MessageForm/index.jsx b/src/components/MessageForm/index.jsx
index 69c3c23f..cae5616f 100644
--- a/src/components/MessageForm/index.jsx
+++ b/src/components/MessageForm/index.jsx
@@ -10,11 +10,14 @@ import {
getChatResponse,
updateCurrentMessage,
} from '../../data/thunks';
+import { useCourseUpgrade } from '../../hooks';
import { usePromptExperimentDecision } from '../../experiments';
import './MessageForm.scss';
const MessageForm = ({ courseId, shouldAutofocus, unitId }) => {
+ const { upgradeable } = useCourseUpgrade();
+
const { apiIsLoading, currentMessage, apiError } = useSelector(state => state.learningAssistant);
const dispatch = useDispatch();
const inputRef = useRef();
@@ -35,7 +38,7 @@ const MessageForm = ({ courseId, shouldAutofocus, unitId }) => {
if (currentMessage) {
dispatch(acknowledgeDisclosure(true));
dispatch(addChatMessage('user', currentMessage, courseId, promptExperimentVariationKey));
- dispatch(getChatResponse(courseId, unitId, promptExperimentVariationKey));
+ dispatch(getChatResponse(courseId, unitId, upgradeable, promptExperimentVariationKey));
}
};
diff --git a/src/components/MessageForm/index.test.jsx b/src/components/MessageForm/index.test.jsx
index 6c8dba2b..220bfbdf 100644
--- a/src/components/MessageForm/index.test.jsx
+++ b/src/components/MessageForm/index.test.jsx
@@ -12,9 +12,14 @@ import {
getChatResponse,
updateCurrentMessage,
} from '../../data/thunks';
+import { useCourseUpgrade } from '../../hooks';
import MessageForm from '.';
+jest.mock('../../hooks', () => ({
+ useCourseUpgrade: jest.fn(),
+}));
+
jest.mock('../../utils/surveyMonkey', () => ({
showControlSurvey: jest.fn(),
showVariationSurvey: jest.fn(),
@@ -81,6 +86,9 @@ describe('', () => {
beforeEach(() => {
jest.resetAllMocks();
usePromptExperimentDecision.mockReturnValue([]);
+ useCourseUpgrade.mockReturnValue({
+ upgradeable: true,
+ });
});
describe('when rendered', () => {
@@ -137,7 +145,7 @@ describe('', () => {
expect(acknowledgeDisclosure).toHaveBeenCalledWith(true);
expect(addChatMessage).toHaveBeenCalledWith('user', currentMessage, defaultProps.courseId, undefined);
- expect(getChatResponse).toHaveBeenCalledWith(defaultProps.courseId, defaultProps.unitId, undefined);
+ expect(getChatResponse).toHaveBeenCalledWith(defaultProps.courseId, defaultProps.unitId, true, undefined);
expect(mockDispatch).toHaveBeenCalledTimes(3);
});
@@ -187,6 +195,7 @@ describe('', () => {
expect(getChatResponse).toHaveBeenCalledWith(
defaultProps.courseId,
defaultProps.unitId,
+ true,
OPTIMIZELY_PROMPT_EXPERIMENT_VARIATION_KEYS.UPDATED_PROMPT,
);
expect(mockDispatch).toHaveBeenCalledTimes(3);
diff --git a/src/components/Sidebar/Sidebar.scss b/src/components/Sidebar/Sidebar.scss
index 7dfff12e..8d385146 100644
--- a/src/components/Sidebar/Sidebar.scss
+++ b/src/components/Sidebar/Sidebar.scss
@@ -31,6 +31,11 @@
height: 30px;
}
}
+
+ .trial-header {
+ font-size: 0.9em;
+ background-color: #F49974;
+ }
}
.separator {
diff --git a/src/components/Sidebar/index.jsx b/src/components/Sidebar/index.jsx
index d9bc6497..5f6425d8 100644
--- a/src/components/Sidebar/index.jsx
+++ b/src/components/Sidebar/index.jsx
@@ -1,20 +1,21 @@
import React, { useRef, useEffect } from 'react';
import { useSelector } from 'react-redux';
import PropTypes from 'prop-types';
+
import {
Icon,
IconButton,
} from '@openedx/paragon';
import { Close } from '@openedx/paragon/icons';
-import { useCourseUpgrade } from '../../hooks';
import showSurvey from '../../utils/surveyMonkey';
import APIError from '../APIError';
import ChatBox from '../ChatBox';
import Disclosure from '../Disclosure';
-import UpgradePanel from '../UpgradePanel';
import MessageForm from '../MessageForm';
+import UpgradePanel from '../UpgradePanel';
+import { useCourseUpgrade, useTrackEvent } from '../../hooks';
import { ReactComponent as XpertLogo } from '../../assets/xpert-logo.svg';
import './Sidebar.scss';
@@ -30,7 +31,11 @@ const Sidebar = ({
messageList,
} = useSelector(state => state.learningAssistant);
- const { upgradeable, auditTrialExpired } = useCourseUpgrade();
+ const {
+ upgradeable, upgradeUrl, auditTrialExpired, auditTrialDaysRemaining,
+ } = useCourseUpgrade();
+
+ const { track } = useTrackEvent();
const chatboxContainerRef = useRef(null);
@@ -80,11 +85,53 @@ const Sidebar = ({
);
+ const handleUpgradeLinkClick = () => {
+ track('edx.ui.lms.learning_assistant.days_remaining_banner_upgrade_click');
+ };
+
+ const getUpgradeLink = () => (
+
+ Upgrade
+
+ );
+
+ const getDaysRemainingMessage = () => { // eslint-disable-line consistent-return
+ if (auditTrialDaysRemaining > 1) {
+ const intlRelativeTime = new Intl.RelativeTimeFormat({ style: 'long' });
+ return (
+
+ Your trial ends {intlRelativeTime.format(auditTrialDaysRemaining, 'day')}. {getUpgradeLink()} for full access to Xpert.
+
+ );
+ } if (auditTrialDaysRemaining === 1) {
+ return (
+
+ Your trial ends today! {getUpgradeLink()} for full access to Xpert.
+
+ );
+ }
+ };
+
const getSidebar = () => (
-
+
+ {upgradeable
+ && (
+
+ {getDaysRemainingMessage()}
+
+ )}
({
+ useCourseUpgrade: jest.fn(),
+ useTrackEvent: jest.fn(),
+}));
+
jest.mock('../../utils/surveyMonkey', () => jest.fn());
jest.mock('@edx/frontend-platform/analytics', () => ({
@@ -34,11 +41,6 @@ jest.mock('../../experiments', () => ({
usePromptExperimentDecision: jest.fn(),
}));
-jest.mock('../../hooks', () => ({
- useCourseUpgrade: jest.fn(),
- useTrackEvent: jest.fn(),
-}));
-
const defaultProps = {
courseId: 'some-course-id',
isOpen: true,
@@ -67,11 +69,12 @@ const render = async (props = {}, sliceState = {}) => {
};
describe('', () => {
- beforeEach(() => {
+ beforeEach(async () => {
jest.resetAllMocks();
useCourseUpgrade.mockReturnValue({ upgradeable: false });
- useTrackEvent.mockReturnValue({ track: jest.fn() });
usePromptExperimentDecision.mockReturnValue([]);
+ useCourseUpgrade.mockReturnValue([]);
+ useTrackEvent.mockReturnValue({ track: jest.fn() });
});
describe('when it\'s open', () => {
@@ -90,13 +93,57 @@ describe('', () => {
expect(screen.queryByTestId('sidebar-xpert')).toBeInTheDocument();
});
- it('should not render xpert if audit trial is expired', () => {
+ it('If auditTrialDaysRemaining > 1, show days remaining', () => {
useCourseUpgrade.mockReturnValue({
upgradeable: true,
- auditTrialExpired: true,
+ upgradeUrl: 'https://mockurl.com',
+ auditTrialDaysRemaining: 2,
});
- render();
- expect(screen.queryByTestId('sidebar-xpert')).not.toBeInTheDocument();
+ render(undefined, { disclosureAcknowledged: true });
+ expect(screen.queryByTestId('sidebar-xpert')).toBeInTheDocument();
+ expect(screen.queryByTestId('get-days-remaining-message')).toBeInTheDocument();
+ expect(screen.queryByTestId('days-remaining-message')).toBeInTheDocument();
+ });
+
+ it('If auditTrialDaysRemaining === 1, say final day', () => {
+ useCourseUpgrade.mockReturnValue({
+ upgradeable: true,
+ upgradeUrl: 'https://mockurl.com',
+ auditTrialDaysRemaining: 1,
+ });
+ render(undefined, { disclosureAcknowledged: true });
+ expect(screen.queryByTestId('sidebar-xpert')).toBeInTheDocument();
+ expect(screen.queryByTestId('get-days-remaining-message')).toBeInTheDocument();
+ expect(screen.queryByTestId('trial-ends-today')).not.toBeInTheDocument();
+ });
+
+ it('should call track event on click', () => {
+ useCourseUpgrade.mockReturnValue({
+ upgradeable: true,
+ upgradeUrl: 'https://mockurl.com',
+ auditTrialDaysRemaining: 1,
+ });
+ const mockedTrackEvent = jest.fn();
+ useTrackEvent.mockReturnValue({ track: mockedTrackEvent });
+
+ render(undefined, { disclosureAcknowledged: true });
+ const upgradeLink = screen.queryByTestId('days_remaining_banner_upgrade_link');
+ expect(mockedTrackEvent).not.toHaveBeenCalled();
+ fireEvent.click(upgradeLink);
+ expect(mockedTrackEvent).toHaveBeenCalledWith('edx.ui.lms.learning_assistant.days_remaining_banner_upgrade_click');
+ });
+
+ it('If auditTrialDaysRemaining < 1, do not show either of those', () => {
+ useCourseUpgrade.mockReturnValue({
+ upgradeable: true,
+ upgradeUrl: 'https://mockurl.com',
+ auditTrialDaysRemaining: 0,
+ });
+ render(undefined, { disclosureAcknowledged: true });
+ expect(screen.queryByTestId('sidebar-xpert')).toBeInTheDocument();
+ expect(screen.queryByTestId('get-days-remaining-message')).toBeInTheDocument();
+ expect(screen.queryByTestId('days-remaining-message')).not.toBeInTheDocument();
+ expect(screen.queryByTestId('trial-ends-today')).not.toBeInTheDocument();
});
});
diff --git a/src/data/thunks.js b/src/data/thunks.js
index 31d71844..ac069283 100644
--- a/src/data/thunks.js
+++ b/src/data/thunks.js
@@ -1,6 +1,7 @@
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
+import { camelCaseObject } from '@edx/frontend-platform';
import trackChatBotMessageOptimizely from '../utils/optimizelyExperiment';
import {
fetchChatResponse,
@@ -55,7 +56,7 @@ export function addChatMessage(role, content, courseId, promptExperimentVariatio
};
}
-export function getChatResponse(courseId, unitId, promptExperimentVariationKey = undefined) {
+export function getChatResponse(courseId, unitId, upgradeable, promptExperimentVariationKey = undefined) {
return async (dispatch, getState) => {
const { userId } = getAuthenticatedUser();
const { messageList } = getState().learningAssistant;
@@ -68,6 +69,13 @@ export function getChatResponse(courseId, unitId, promptExperimentVariationKey =
const customQueryParams = promptExperimentVariationKey ? { responseVariation: promptExperimentVariationKey } : {};
const message = await fetchChatResponse(courseId, messageList, unitId, customQueryParams);
+ // Refresh chat summary only on the first message for an upgrade eligible user
+ // so we can tell if the user has just initiated an audit trial
+ if (messageList.length === 1 && upgradeable) {
+ // eslint-disable-next-line no-use-before-define
+ dispatch(getLearningAssistantChatSummary(courseId));
+ }
+
dispatch(setApiIsLoading(false));
dispatch(addChatMessage(message.role, message.content, courseId, promptExperimentVariationKey));
} catch (error) {
@@ -129,11 +137,19 @@ export function getLearningAssistantChatSummary(courseId) {
}
// Audit Trial
- const auditTrial = data.audit_trial;
-
- // If returned audit trial data is not empty
- if (Object.keys(auditTrial).length !== 0) {
- dispatch(setAuditTrial(auditTrial));
+ const auditTrial = {
+ startDate: data.audit_trial.start_date,
+ expirationDate: data.audit_trial.expiration_date,
+ };
+
+ // Validate audit trial data & dates
+ const auditTrialDatesValid = !(
+ Number.isNaN(Date.parse(auditTrial.startDate))
+ || Number.isNaN(Date.parse(auditTrial.expirationDate))
+ );
+
+ if (Object.keys(auditTrial).length !== 0 && auditTrialDatesValid) {
+ dispatch(setAuditTrial(camelCaseObject(auditTrial)));
}
if (data.audit_trial_length_days) { dispatch(setAuditTrialLengthDays(data.audit_trial_length_days)); }
diff --git a/src/data/thunks.test.js b/src/data/thunks.test.js
index 6214cc3f..8692bd95 100644
--- a/src/data/thunks.test.js
+++ b/src/data/thunks.test.js
@@ -26,7 +26,8 @@ describe('Thunks unit tests', () => {
describe('addChatMessage()', () => {
const mockDate = new Date(2024, 1, 1);
- beforeAll(() => {
+
+ beforeEach(async () => {
jest.useFakeTimers('modern');
jest.setSystemTime(mockDate);
});
@@ -110,8 +111,8 @@ describe('Thunks unit tests', () => {
expect(dispatch).toHaveBeenNthCalledWith(5, {
type: 'learning-assistant/setAuditTrial',
payload: {
- start_date: '2024-12-02T14:59:16.148236Z',
- expiration_date: '9999-12-16T14:59:16.148236Z',
+ startDate: '2024-12-02T14:59:16.148236Z',
+ expirationDate: '9999-12-16T14:59:16.148236Z',
},
});
@@ -158,8 +159,8 @@ describe('Thunks unit tests', () => {
expect(dispatch).toHaveBeenNthCalledWith(3, {
type: 'learning-assistant/setAuditTrial',
payload: {
- start_date: '2024-12-02T14:59:16.148236Z',
- expiration_date: '9999-12-16T14:59:16.148236Z',
+ startDate: '2024-12-02T14:59:16.148236Z',
+ expirationDate: '9999-12-16T14:59:16.148236Z',
},
});
diff --git a/src/utils/optimizelyExperiment.js b/src/utils/optimizelyExperiment.js
index 1ca3e241..c9772a82 100644
--- a/src/utils/optimizelyExperiment.js
+++ b/src/utils/optimizelyExperiment.js
@@ -3,7 +3,7 @@ import { getOptimizely } from '../data/optimizely';
const trackChatBotMessageOptimizely = (userId, userAttributes = {}) => {
const optimizelyInstance = getOptimizely();
- if (!optimizelyInstance) { return; }
+ if (!optimizelyInstance || Object.keys(optimizelyInstance).length === 0) { return; }
optimizelyInstance.onReady().then(() => {
optimizelyInstance.track('learning_assistant_chat_message', userId, userAttributes);