Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add trial days remaining banner #72

Merged
merged 25 commits into from
Dec 17, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
794cab2
feat: add trial days remaining banner
ilee2u Dec 9, 2024
717a0a4
temp: working version w/ debug logs
ilee2u Dec 10, 2024
4d196f2
feat: better functionality for days remaining
ilee2u Dec 10, 2024
43f2c78
fix: set auditTrialLengthDays + nits
ilee2u Dec 11, 2024
5fa9f47
feat: add upgrade url to days remaining banner
ilee2u Dec 12, 2024
078db13
feat: simplify refresh chat summary call
ilee2u Dec 12, 2024
9890f24
temp: rollback point for tests
ilee2u Dec 12, 2024
77231f7
feat: only refresh chat-summary on 1st msg
ilee2u Dec 12, 2024
0947732
temp: attempting to mock useModel + factories
ilee2u Dec 12, 2024
11c0562
temp: some nits
ilee2u Dec 13, 2024
8ef838f
fix: re-syncing: mock useModel + thunk work
ilee2u Dec 13, 2024
13b9e27
temp: progressing on using new audit trial hooks
ilee2u Dec 13, 2024
2a3d1d7
temp: removing useModel mock
ilee2u Dec 13, 2024
f3496eb
temp: trying to get hook to work
ilee2u Dec 13, 2024
41bd3cd
chore: lint
ilee2u Dec 13, 2024
b49f15f
temp: attempting to test Sidebar
ilee2u Dec 13, 2024
f1ccd04
chore: lint
ilee2u Dec 13, 2024
9a48fd8
chore: trying to get around this weird lint error
ilee2u Dec 13, 2024
a8fa79d
test: completed working unit tests
ilee2u Dec 16, 2024
48e16b8
fix: add upgrade eligibility gate for refresh
ilee2u Dec 16, 2024
4d9f530
chore: lint
ilee2u Dec 16, 2024
3d2d0c1
chore: more nits :D
ilee2u Dec 16, 2024
7c97e5f
chore: make intl name more descriptive
ilee2u Dec 17, 2024
1ac0c54
fix: remove paywall placeholder + rebase main pt 2
ilee2u Dec 17, 2024
0352c85
feat: track event on upgrade link click
ilee2u Dec 17, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion src/components/MessageForm/index.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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));
}
};

Expand Down
11 changes: 10 additions & 1 deletion src/components/MessageForm/index.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down Expand Up @@ -81,6 +86,9 @@ describe('<MessageForm />', () => {
beforeEach(() => {
jest.resetAllMocks();
usePromptExperimentDecision.mockReturnValue([]);
useCourseUpgrade.mockReturnValue({
upgradeable: true,
});
});

describe('when rendered', () => {
Expand Down Expand Up @@ -137,7 +145,7 @@ describe('<MessageForm />', () => {

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

Expand Down Expand Up @@ -187,6 +195,7 @@ describe('<MessageForm />', () => {
expect(getChatResponse).toHaveBeenCalledWith(
defaultProps.courseId,
defaultProps.unitId,
true,
OPTIMIZELY_PROMPT_EXPERIMENT_VARIATION_KEYS.UPDATED_PROMPT,
);
expect(mockDispatch).toHaveBeenCalledTimes(3);
Expand Down
5 changes: 5 additions & 0 deletions src/components/Sidebar/Sidebar.scss
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,11 @@
height: 30px;
}
}

.trial-header {
ilee2u marked this conversation as resolved.
Show resolved Hide resolved
font-size: 0.9em;
background-color: #F49974;
}
}

.separator {
Expand Down
55 changes: 51 additions & 4 deletions src/components/Sidebar/index.jsx
Original file line number Diff line number Diff line change
@@ -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';

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

Expand Down Expand Up @@ -80,11 +85,53 @@ const Sidebar = ({
<MessageForm courseId={courseId} shouldAutofocus unitId={unitId} />
);

const handleUpgradeLinkClick = () => {
track('edx.ui.lms.learning_assistant.days_remaining_banner_upgrade_click');
};

const getUpgradeLink = () => (
<a
onClick={handleUpgradeLinkClick}
target="_blank"
href={upgradeUrl}
rel="noreferrer"
data-testid="days_remaining_banner_upgrade_link"
>
Upgrade
</a>
);

const getDaysRemainingMessage = () => { // eslint-disable-line consistent-return
if (auditTrialDaysRemaining > 1) {
const intlRelativeTime = new Intl.RelativeTimeFormat({ style: 'long' });
return (
<div data-testid="days-remaining-message">
Your trial ends {intlRelativeTime.format(auditTrialDaysRemaining, 'day')}. {getUpgradeLink()} for full access to Xpert.
</div>
);
} if (auditTrialDaysRemaining === 1) {
return (
<div data-testid="trial-ends-today-message">
Your trial ends today! {getUpgradeLink()} for full access to Xpert.
</div>
);
}
};

const getSidebar = () => (
<div className="h-100 d-flex flex-column justify-content-stretch" data-testid="sidebar-xpert">
<div
className="h-100 d-flex flex-column justify-content-stretch"
data-testid="sidebar-xpert"
>
<div className="p-3 sidebar-header" data-testid="sidebar-xpert-header">
<XpertLogo />
</div>
{upgradeable
&& (
<div className="p-3 trial-header" data-testid="get-days-remaining-message">
{getDaysRemainingMessage()}
</div>
)}
<span className="separator" />
<ChatBox
chatboxContainerRef={chatboxContainerRef}
Expand Down
73 changes: 60 additions & 13 deletions src/components/Sidebar/index.test.jsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,21 @@
import React from 'react';
import { screen, act } from '@testing-library/react';
import { fireEvent, screen, act } from '@testing-library/react';

import { usePromptExperimentDecision } from '../../experiments';
import {
useCourseUpgrade, useTrackEvent,
} from '../../hooks';
import { render as renderComponent } from '../../utils/utils.test';
import { initialState } from '../../data/slice';
import showSurvey from '../../utils/surveyMonkey';
import { useCourseUpgrade, useTrackEvent } from '../../hooks';

import Sidebar from '.';

jest.mock('../../hooks', () => ({
useCourseUpgrade: jest.fn(),
useTrackEvent: jest.fn(),
}));

jest.mock('../../utils/surveyMonkey', () => jest.fn());

jest.mock('@edx/frontend-platform/analytics', () => ({
Expand All @@ -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,
Expand Down Expand Up @@ -67,11 +69,12 @@ const render = async (props = {}, sliceState = {}) => {
};

describe('<Sidebar />', () => {
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', () => {
Expand All @@ -90,13 +93,57 @@ describe('<Sidebar />', () => {
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,
ilee2u marked this conversation as resolved.
Show resolved Hide resolved
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();
});
});

Expand Down
28 changes: 22 additions & 6 deletions src/data/thunks.js
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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;
Expand All @@ -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) {
Expand Down Expand Up @@ -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,
ilee2u marked this conversation as resolved.
Show resolved Hide resolved
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)); }
Expand Down
11 changes: 6 additions & 5 deletions src/data/thunks.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
Expand Down Expand Up @@ -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',
},
});

Expand Down Expand Up @@ -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',
},
});

Expand Down
2 changes: 1 addition & 1 deletion src/utils/optimizelyExperiment.js
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Was this line causing issues?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This line was causing issues on my local setup, not sure if it was due to something missing on my end. I can put this back if that's preferred.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think you probably need to add { moduleName: '@optimizely/react-sdk', dir: '../src/frontend-plugin-advertisements/src/mocks/optimizely', dist: '.' }, to your frontend-app-learning module.config.js file

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That line is in my config file so I'm not sure what's up there...

module.exports = {
  /*
  Modules you want to use from local source code.  Adding a module here means that when this app
  runs its build, it'll resolve the source from peer directories of this app.

  moduleName: the name you use to import code from the module.
  dir: The relative path to the module's source code.
  dist: The sub-directory of the source code where it puts its build artifact.  Often "dist".
  */
  localModules: [
    { moduleName: '@edx/frontend-lib-special-exams', dir: '../src/frontend-lib-special-exams', dist: 'src' },
    { moduleName: '@edx/frontend-plugin-advertisements', dir: '../src/frontend-plugin-advertisements', dist: 'src' },
    { moduleName: '@optimizely/react-sdk', dir: '../src/frontend-plugin-advertisements/src/mocks/optimizely', dist: '.' },
    { moduleName: '@edx/frontend-lib-learning-assistant', dir: '../src/frontend-lib-learning-assistant', dist: 'src' },
  ],
};

When I debugged this I found that the optomizelyInstance had a value of {} so that's why I inserted this condition.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What's the issue you're running into? I was not running into issues locally with optimizely so just trying to help narrow down what could be going wrong


optimizelyInstance.onReady().then(() => {
optimizelyInstance.track('learning_assistant_chat_message', userId, userAttributes);
Expand Down
Loading