From 484ef9ddb7a0c880b2e1ffd8ef28d6d94e999598 Mon Sep 17 00:00:00 2001 From: Marcos Date: Tue, 10 Dec 2024 17:15:05 -0300 Subject: [PATCH 1/7] feat: Updated Disclaimer screen with trial option --- src/components/Disclosure/Disclosure.scss | 52 +++- src/components/Disclosure/Disclosure.test.jsx | 54 ++++ .../__snapshots__/Disclosure.test.jsx.snap | 239 ++++++++++++++++++ src/components/Disclosure/index.jsx | 56 ++-- src/components/MessageForm/MessageForm.scss | 11 + src/components/MessageForm/index.jsx | 5 +- src/components/Sidebar/Sidebar.scss | 9 +- 7 files changed, 397 insertions(+), 29 deletions(-) create mode 100644 src/components/Disclosure/Disclosure.test.jsx create mode 100644 src/components/Disclosure/__snapshots__/Disclosure.test.jsx.snap create mode 100644 src/components/MessageForm/MessageForm.scss diff --git a/src/components/Disclosure/Disclosure.scss b/src/components/Disclosure/Disclosure.scss index a941c574..a9a6f623 100644 --- a/src/components/Disclosure/Disclosure.scss +++ b/src/components/Disclosure/Disclosure.scss @@ -2,16 +2,29 @@ .disclosure { height: 100%; - overflow-y: auto; background-color: variables.$dark-green; + font-family: Inter, Arial, sans-serif; + padding: 2rem; h2 { - font-size: 2rem; + font-size: 1.375rem; } h3 { + font-family: "Roboto Mono", Inter, Arial, sans-serif; color: variables.$accent-yellow; + margin-bottom: 3rem; + } + + small { + font-size: 0.875rem; + } + + .bullet-icon { + width: 2rem; + height: 2rem; + margin-right: 1rem; } p { @@ -30,6 +43,39 @@ } .disclaimer { - font-size: 1rem; + font-size: 0.75rem; + margin-bottom: 7.5rem; + } + + .trial-period { + padding: 3px; + background: #2D494E; + border-radius: 0.8125rem; + background-image: + linear-gradient(#2D494E, #2D494E), + linear-gradient(to right, #E76F3F, #EBA7BC); + background-origin: border-box; + background-clip: content-box, border-box; + + &-content { + padding: 0.8125rem; + } + + .bullet-icon { + width: 1.5rem; + height: 1.5rem; + margin-right: 0.5rem; + + svg path { + fill: #E98B7E; + } + } + } + + .trial-upgrade { + background: #D74000; + border-radius: 99rem; + font-size: 0.875rem; } } + diff --git a/src/components/Disclosure/Disclosure.test.jsx b/src/components/Disclosure/Disclosure.test.jsx new file mode 100644 index 00000000..d12f8a77 --- /dev/null +++ b/src/components/Disclosure/Disclosure.test.jsx @@ -0,0 +1,54 @@ +import React from 'react'; +import { screen } from '@testing-library/react'; +import { render } from '../../utils/utils.test'; + +import TrialDisclosure from '.'; + +const PRIVACY_POLICY_URL = 'https://some.url/policy'; +jest.mock('@edx/frontend-platform/config', () => ({ + ensureConfig: jest.fn(), + getConfig: () => ({ PRIVACY_POLICY_URL }), +})); + +describe('', () => { + let container; + + describe('When trial upgrade is not being showed', () => { + beforeEach(() => { + ({ container } = render(Children)); + }); + + it('should have a link to the privacy policy url', () => { + const link = screen.queryByText('privacy policy'); + + expect(link).toBeInTheDocument(); + expect(link).toHaveAttribute('href', PRIVACY_POLICY_URL); + }); + + it('should not show the trial message', () => { + const upgrade = screen.queryByText('Free trial, then upgrade course for full access to Xpert features.'); + + expect(upgrade).not.toBeInTheDocument(); + }); + + it('should match snapshot', () => { + expect(container).toMatchSnapshot(); + }); + }); + + describe('When trial upgrade being showed', () => { + beforeEach(() => { + ({ container } = render(Children)); + }); + + it('should show the trial message', () => { + const upgrade = screen.queryByText('Free trial, then upgrade course for full access to Xpert features.'); + + expect(upgrade).toBeInTheDocument(); + }); + + it('should match snapshot', () => { + expect(container).toMatchSnapshot(); + }); + }); +}); diff --git a/src/components/Disclosure/__snapshots__/Disclosure.test.jsx.snap b/src/components/Disclosure/__snapshots__/Disclosure.test.jsx.snap new file mode 100644 index 00000000..6c06352d --- /dev/null +++ b/src/components/Disclosure/__snapshots__/Disclosure.test.jsx.snap @@ -0,0 +1,239 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` When trial upgrade being showed should match snapshot 1`] = ` +
+
+

+ Xpert Learning Assistant +

+

+ An AI-powered educational tool +

+
+
+ + + +
+ Understand a concept +
+ + “How does photosynthesis work?” + +
+
+
+ + + +
+ Summarize your learning +
+ + “Can you help me review pivot tables?” + +
+
+
+
+
+
+ + + + + Free trial, then upgrade course for full access to Xpert features. + +
+ +
+
+

+ Note: This chat is AI generated, mistakes are possible. By using it you agree that edX may create a record of this chat. Your personal data will be used as described in our   + + privacy policy + + . +

+ + Children + +
+
+`; + +exports[` When trial upgrade is not being showed should match snapshot 1`] = ` +
+
+

+ Xpert Learning Assistant +

+

+ An AI-powered educational tool +

+
+
+ + + +
+ Understand a concept +
+ + “How does photosynthesis work?” + +
+
+
+ + + +
+ Summarize your learning +
+ + “Can you help me review pivot tables?” + +
+
+
+

+ Note: This chat is AI generated, mistakes are possible. By using it you agree that edX may create a record of this chat. Your personal data will be used as described in our   + + privacy policy + + . +

+ + Children + +
+
+`; diff --git a/src/components/Disclosure/index.jsx b/src/components/Disclosure/index.jsx index 1c6edada..4be1b43a 100644 --- a/src/components/Disclosure/index.jsx +++ b/src/components/Disclosure/index.jsx @@ -1,42 +1,53 @@ import PropTypes from 'prop-types'; import React from 'react'; -import { Hyperlink, Icon } from '@openedx/paragon'; -import { Chat } from '@openedx/paragon/icons'; +import { Hyperlink, Icon, Button } from '@openedx/paragon'; +import { QuestionAnswerOutline, LightbulbCircle, AutoAwesome } from '@openedx/paragon/icons'; import { ensureConfig, getConfig } from '@edx/frontend-platform/config'; import './Disclosure.scss'; ensureConfig(['PRIVACY_POLICY_URL']); -const Disclosure = ({ children }) => ( -
+const Disclosure = ({ children, showTrial }) => ( +

- Xpert + Xpert Learning Assistant

An AI-powered educational tool

-
- +
+
- Stuck on a concept? Need more clarification on a complicated topic? -
- Ask Xpert a question! + Understand a concept
+ “How does photosynthesis work?”
-
-
    -
  • Could you explain how to multiply two numbers?
  • -
  • How should an essay be structured?
  • -
  • How does photosynthesis work?
  • -
+
+ +
+ Summarize your learning
+ “Can you help me review pivot tables?” +
-

- Note: - This chat is AI generated (powered by ChatGPT). Mistakes are possible. + {showTrial ? ( +

+
+
+ + + Free trial, then upgrade course for full access to Xpert features. + +
+ +
+
+ ) : null} +

+ Note: This chat is AI generated, mistakes are possible. By using it you agree that edX may create a record of this chat. - Your personal data will be used as described in our  + Your personal data will be used as described in our   ( ); Disclosure.propTypes = { + showTrial: PropTypes.bool, children: PropTypes.node.isRequired, }; +Disclosure.defaultProps = { + showTrial: false, +}; + export default Disclosure; diff --git a/src/components/MessageForm/MessageForm.scss b/src/components/MessageForm/MessageForm.scss new file mode 100644 index 00000000..b58e1e39 --- /dev/null +++ b/src/components/MessageForm/MessageForm.scss @@ -0,0 +1,11 @@ +.message-form { + .send-message-input { + .pgn__form-control-floating-label { + color: #ADADAD; + } + + input { + border-radius: 1rem; + } + } +} diff --git a/src/components/MessageForm/index.jsx b/src/components/MessageForm/index.jsx index 8d8d92e0..b86353bd 100644 --- a/src/components/MessageForm/index.jsx +++ b/src/components/MessageForm/index.jsx @@ -12,6 +12,8 @@ import { } from '../../data/thunks'; import { usePromptExperimentDecision } from '../../experiments'; +import './MessageForm.scss'; + const MessageForm = ({ courseId, shouldAutofocus, unitId }) => { const { apiIsLoading, currentMessage, apiError } = useSelector(state => state.learningAssistant); const dispatch = useDispatch(); @@ -55,7 +57,7 @@ const MessageForm = ({ courseId, shouldAutofocus, unitId }) => { ); return ( -

+ { trailingElement={getSubmitButton()} value={currentMessage} ref={inputRef} + className="send-message-input" />
diff --git a/src/components/Sidebar/Sidebar.scss b/src/components/Sidebar/Sidebar.scss index 4f45f55d..7dfff12e 100644 --- a/src/components/Sidebar/Sidebar.scss +++ b/src/components/Sidebar/Sidebar.scss @@ -3,13 +3,12 @@ .sidebar { box-shadow: 0 0 1rem 0 #00000026,0 0 .625rem 0 #00000026; top: 0; + right: 0; bottom: 0; - background-color: white; z-index: 9999; - - width: 30%; - right: 0; - + background-color: white; + width: 100%; + max-width: 25rem; /* Add smooth scrolling behavior */ scroll-behavior: smooth; From 4d587fff32683442c8f2354eedd37dd55661315e Mon Sep 17 00:00:00 2001 From: Marcos Rigoli Date: Fri, 13 Dec 2024 15:39:26 -0300 Subject: [PATCH 2/7] feat: Added trial url and days to the chat intro (#74) * feat: Added trial url and days to the chat intro * feat: Updated some logic for the useCourseUpgrade() hook --- src/components/Disclosure/Disclosure.scss | 13 ++- src/components/Disclosure/index.jsx | 119 ++++++++++++---------- src/components/MessageForm/index.jsx | 2 +- src/components/Sidebar/index.jsx | 4 +- src/context/course-info-context.js | 9 ++ src/context/index.js | 1 + src/data/slice.js | 5 + src/data/thunks.js | 3 + src/hooks/index.js | 3 + src/hooks/use-course-upgrade.js | 39 +++++++ src/hooks/use-track-event.js | 20 ++++ src/widgets/Xpert.jsx | 43 ++++---- 12 files changed, 187 insertions(+), 74 deletions(-) create mode 100644 src/context/course-info-context.js create mode 100644 src/context/index.js create mode 100644 src/hooks/index.js create mode 100644 src/hooks/use-course-upgrade.js create mode 100644 src/hooks/use-track-event.js diff --git a/src/components/Disclosure/Disclosure.scss b/src/components/Disclosure/Disclosure.scss index a9a6f623..d65a5f44 100644 --- a/src/components/Disclosure/Disclosure.scss +++ b/src/components/Disclosure/Disclosure.scss @@ -6,6 +6,8 @@ background-color: variables.$dark-green; font-family: Inter, Arial, sans-serif; padding: 2rem; + display: flex; + flex-direction: column; h2 { font-size: 1.375rem; @@ -44,7 +46,6 @@ .disclaimer { font-size: 0.75rem; - margin-bottom: 7.5rem; } .trial-period { @@ -75,7 +76,15 @@ .trial-upgrade { background: #D74000; border-radius: 99rem; - font-size: 0.875rem; + font-size: 0 + .875rem; + } + + .pgn__form-group { + margin: 0; + } + .pgn__form-control-decorator-group { + margin: 0; } } diff --git a/src/components/Disclosure/index.jsx b/src/components/Disclosure/index.jsx index 4be1b43a..8f5d916a 100644 --- a/src/components/Disclosure/index.jsx +++ b/src/components/Disclosure/index.jsx @@ -6,67 +6,82 @@ import { QuestionAnswerOutline, LightbulbCircle, AutoAwesome } from '@openedx/pa import { ensureConfig, getConfig } from '@edx/frontend-platform/config'; import './Disclosure.scss'; +import { useCourseUpgrade, useTrackEvent } from '../../hooks'; ensureConfig(['PRIVACY_POLICY_URL']); -const Disclosure = ({ children, showTrial }) => ( -
-

- Xpert Learning Assistant -

-

An AI-powered educational tool

-
-
- -
- Understand a concept
- “How does photosynthesis work?” -
-
-
- -
- Summarize your learning
- “Can you help me review pivot tables?” -
-
-
- {showTrial ? ( -
-
-
- - - Free trial, then upgrade course for full access to Xpert features. - +const Disclosure = ({ children }) => { + const { upgradeable, upgradeUrl, auditTrialLengthDays } = useCourseUpgrade(); + const { track } = useTrackEvent(); + + const handleClick = () => track('edx.ui.lms.learning_assistant.message'); + const freeDays = auditTrialLengthDays === 1 ? '1 day' : `${auditTrialLengthDays} days`; + + return ( +
+

+ Xpert Learning Assistant +

+
+

An AI-powered educational tool

+
+
+ +
+ Understand a concept
+ “How does photosynthesis work?” +
+
+
+ +
+ Summarize your learning
+ “Can you help me review pivot tables?” +
-
+ {upgradeable ? ( +
+
+
+ + + Free for {freeDays}, then upgrade course for full access to Xpert features. + +
+ +
+
+ ) : null} +

+ Note: This chat is AI generated, mistakes are possible. + By using it you agree that edX may create a record of this chat. + Your personal data will be used as described in our   + + privacy policy + + . +

- ) : null} -

- Note: This chat is AI generated, mistakes are possible. - By using it you agree that edX may create a record of this chat. - Your personal data will be used as described in our   - - privacy policy - - . -

- {children} -
-); +
+ {children} +
+ + ); +}; Disclosure.propTypes = { - showTrial: PropTypes.bool, children: PropTypes.node.isRequired, }; -Disclosure.defaultProps = { - showTrial: false, -}; - export default Disclosure; diff --git a/src/components/MessageForm/index.jsx b/src/components/MessageForm/index.jsx index b86353bd..69c3c23f 100644 --- a/src/components/MessageForm/index.jsx +++ b/src/components/MessageForm/index.jsx @@ -57,7 +57,7 @@ const MessageForm = ({ courseId, shouldAutofocus, unitId }) => { ); return ( -
+ - {disclosureAcknowledged ? (getSidebar()) : ({getMessageForm()})} + {disclosureAcknowledged + ? (getSidebar()) + : ({getMessageForm()})}
) ); diff --git a/src/context/course-info-context.js b/src/context/course-info-context.js new file mode 100644 index 00000000..107dd0df --- /dev/null +++ b/src/context/course-info-context.js @@ -0,0 +1,9 @@ +import { createContext } from 'react'; + +export const CourseInfoContext = createContext('course-info', { + courseId: null, + unitId: null, + isUpgradeEligible: false, +}); + +export const CourseInfoProvider = CourseInfoContext.Provider; diff --git a/src/context/index.js b/src/context/index.js new file mode 100644 index 00000000..dca05b8c --- /dev/null +++ b/src/context/index.js @@ -0,0 +1 @@ +export * from './course-info-context'; diff --git a/src/data/slice.js b/src/data/slice.js index 91982422..fc64c47e 100644 --- a/src/data/slice.js +++ b/src/data/slice.js @@ -12,6 +12,7 @@ export const initialState = { sidebarIsOpen: false, isEnabled: false, auditTrial: {}, + auditTrialLengthDays: null, }; export const learningAssistantSlice = createSlice({ @@ -48,6 +49,9 @@ export const learningAssistantSlice = createSlice({ setAuditTrial: (state, { payload }) => { state.auditTrial = payload; }, + setAuditTrialLengthDays: (state, { payload }) => { + state.auditTrialLengthDays = payload; + }, }, }); @@ -62,6 +66,7 @@ export const { setSidebarIsOpen, setIsEnabled, setAuditTrial, + setAuditTrialLengthDays, } = learningAssistantSlice.actions; export const { diff --git a/src/data/thunks.js b/src/data/thunks.js index 2bf2528a..31d71844 100644 --- a/src/data/thunks.js +++ b/src/data/thunks.js @@ -17,6 +17,7 @@ import { setSidebarIsOpen, setIsEnabled, setAuditTrial, + setAuditTrialLengthDays, } from './slice'; import { OPTIMIZELY_PROMPT_EXPERIMENT_KEY } from './optimizely'; @@ -134,6 +135,8 @@ export function getLearningAssistantChatSummary(courseId) { if (Object.keys(auditTrial).length !== 0) { dispatch(setAuditTrial(auditTrial)); } + + if (data.audit_trial_length_days) { dispatch(setAuditTrialLengthDays(data.audit_trial_length_days)); } } catch (error) { dispatch(setApiError()); } diff --git a/src/hooks/index.js b/src/hooks/index.js new file mode 100644 index 00000000..22f1a63c --- /dev/null +++ b/src/hooks/index.js @@ -0,0 +1,3 @@ +/* eslint-disable import/prefer-default-export */ +export { default as useCourseUpgrade } from './use-course-upgrade'; +export { default as useTrackEvent } from './use-track-event'; diff --git a/src/hooks/use-course-upgrade.js b/src/hooks/use-course-upgrade.js new file mode 100644 index 00000000..f5544890 --- /dev/null +++ b/src/hooks/use-course-upgrade.js @@ -0,0 +1,39 @@ +import { useContext } from 'react'; +import { useModel } from '@src/generic/model-store'; // eslint-disable-line import/no-unresolved +import { useSelector } from 'react-redux'; +import { CourseInfoContext } from '../context'; + +const millisecondsInOneDay = 24 * 60 * 60 * 1000; // hours*minutes*seconds*milliseconds + +export default function useCourseUpgrade() { + const { courseId, isUpgradeEligible } = useContext(CourseInfoContext); + const { offer } = useModel('coursewareMeta', courseId); + const { verifiedMode } = useModel('courseHomeMeta', courseId); + const { + auditTrialLengthDays, + auditTrial, + } = useSelector(state => state.learningAssistant); + + const upgradeUrl = offer?.upgradeUrl || verifiedMode?.upgradeUrl; + + if (!isUpgradeEligible || !upgradeUrl) { return { upgradeable: false }; } + + let auditTrialExpired = false; + let auditTrialDaysRemaining; + + if (auditTrial?.expirationDate) { + const auditTrialExpirationDate = new Date(auditTrial.expirationDate); + auditTrialDaysRemaining = Math.ceil((auditTrialExpirationDate - Date.now()) / millisecondsInOneDay); + + auditTrialExpired = auditTrialDaysRemaining < 0; + } + + return { + upgradeable: true, + auditTrialLengthDays, + auditTrialDaysRemaining, + auditTrialExpired, + auditTrial, + upgradeUrl, + }; +} diff --git a/src/hooks/use-track-event.js b/src/hooks/use-track-event.js new file mode 100644 index 00000000..7205326c --- /dev/null +++ b/src/hooks/use-track-event.js @@ -0,0 +1,20 @@ +import { useContext } from 'react'; +import { getAuthenticatedUser } from '@edx/frontend-platform/auth'; +import { sendTrackEvent } from '@edx/frontend-platform/analytics'; +import { CourseInfoContext } from '../context'; + +export default function useTrackEvent() { + const { courseId, moduleId } = useContext(CourseInfoContext); + const { userId } = getAuthenticatedUser(); + + const track = (event, details) => { + sendTrackEvent(event, { + course_id: courseId, + user_id: userId, + module_id: moduleId, + ...details, + }); + }; + + return { track }; +} diff --git a/src/widgets/Xpert.jsx b/src/widgets/Xpert.jsx index a8a6148a..48cc8283 100644 --- a/src/widgets/Xpert.jsx +++ b/src/widgets/Xpert.jsx @@ -1,19 +1,24 @@ import PropTypes from 'prop-types'; -import { useEffect } from 'react'; +import { useEffect, useMemo } from 'react'; import { useDispatch, useSelector } from 'react-redux'; import { updateSidebarIsOpen, getLearningAssistantChatSummary } from '../data/thunks'; import ToggleXpert from '../components/ToggleXpertButton'; import Sidebar from '../components/Sidebar'; import { ExperimentsProvider } from '../experiments'; +import { CourseInfoProvider } from '../context'; const Xpert = ({ courseId, contentToolsEnabled, unitId, - isUpgradeEligible, // eslint-disable-line no-unused-vars + isUpgradeEligible, }) => { const dispatch = useDispatch(); + const courseInfo = useMemo( + () => ({ courseId, unitId, isUpgradeEligible }), + [courseId, unitId, isUpgradeEligible], + ); const { isEnabled, @@ -40,22 +45,24 @@ const Xpert = ({ }; return isEnabled ? ( - - <> - - - - + + + <> + + + + + ) : null; }; From c61291b72f5d412407217f5a389e9c8f763c9a70 Mon Sep 17 00:00:00 2001 From: Marcos Date: Fri, 13 Dec 2024 15:49:07 -0300 Subject: [PATCH 3/7] fix: Applied suggestion from Code Review Co-authored-by: Alison Langston <46360176+alangsto@users.noreply.github.com> --- src/components/Disclosure/Disclosure.test.jsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/Disclosure/Disclosure.test.jsx b/src/components/Disclosure/Disclosure.test.jsx index d12f8a77..9bdb89b0 100644 --- a/src/components/Disclosure/Disclosure.test.jsx +++ b/src/components/Disclosure/Disclosure.test.jsx @@ -13,7 +13,7 @@ jest.mock('@edx/frontend-platform/config', () => ({ describe('', () => { let container; - describe('When trial upgrade is not being showed', () => { + describe('When trial upgrade is not being shown', () => { beforeEach(() => { ({ container } = render(Children)); }); @@ -36,7 +36,7 @@ describe('', () => { }); }); - describe('When trial upgrade being showed', () => { + describe('When trial upgrade being shown', () => { beforeEach(() => { ({ container } = render(Children)); }); From 055c6fa1ac1547dbb2c5c7b38bd7f2466e4c09c9 Mon Sep 17 00:00:00 2001 From: Marcos Date: Mon, 16 Dec 2024 13:19:04 -0300 Subject: [PATCH 4/7] chore: Added unit tests for useCourseUpgrade() and useTrackEvent() hooks --- src/context/course-info-context.js | 2 +- src/hooks/index.js | 1 - src/hooks/use-course-upgrade.js | 1 + src/hooks/use-course-upgrade.test.jsx | 159 ++++++++++++++++++++++++++ src/hooks/use-track-event.test.jsx | 54 +++++++++ 5 files changed, 215 insertions(+), 2 deletions(-) create mode 100644 src/hooks/use-course-upgrade.test.jsx create mode 100644 src/hooks/use-track-event.test.jsx diff --git a/src/context/course-info-context.js b/src/context/course-info-context.js index 107dd0df..081b173f 100644 --- a/src/context/course-info-context.js +++ b/src/context/course-info-context.js @@ -1,6 +1,6 @@ import { createContext } from 'react'; -export const CourseInfoContext = createContext('course-info', { +export const CourseInfoContext = createContext({ courseId: null, unitId: null, isUpgradeEligible: false, diff --git a/src/hooks/index.js b/src/hooks/index.js index 22f1a63c..bcc999ad 100644 --- a/src/hooks/index.js +++ b/src/hooks/index.js @@ -1,3 +1,2 @@ -/* eslint-disable import/prefer-default-export */ export { default as useCourseUpgrade } from './use-course-upgrade'; export { default as useTrackEvent } from './use-track-event'; diff --git a/src/hooks/use-course-upgrade.js b/src/hooks/use-course-upgrade.js index f5544890..8870dc5a 100644 --- a/src/hooks/use-course-upgrade.js +++ b/src/hooks/use-course-upgrade.js @@ -23,6 +23,7 @@ export default function useCourseUpgrade() { if (auditTrial?.expirationDate) { const auditTrialExpirationDate = new Date(auditTrial.expirationDate); + auditTrialDaysRemaining = Math.ceil((auditTrialExpirationDate - Date.now()) / millisecondsInOneDay); auditTrialExpired = auditTrialDaysRemaining < 0; diff --git a/src/hooks/use-course-upgrade.test.jsx b/src/hooks/use-course-upgrade.test.jsx new file mode 100644 index 00000000..2bb4aeb7 --- /dev/null +++ b/src/hooks/use-course-upgrade.test.jsx @@ -0,0 +1,159 @@ +import { renderHook as rtlRenderHook } from '@testing-library/react-hooks'; +import { useSelector } from 'react-redux'; +import { useModel } from '@src/generic/model-store'; // eslint-disable-line import/no-unresolved +import { CourseInfoProvider } from '../context'; +import useCourseUpgrade from './use-course-upgrade'; + +jest.mock('@src/generic/model-store', () => ({ useModel: jest.fn() }), { virtual: true }); +jest.mock('react-redux', () => ({ useSelector: jest.fn() })); + +const mockedUpgradeUrl = 'https://upgrade.edx/course/test'; +const mockedAuditTrialLengthDays = 7; + +const contextWrapper = ({ courseInfo }) => function Wrapper({ children }) { // eslint-disable-line react/prop-types + return ( + + {children} + + ); +}; + +const renderHook = ({ + courseInfo, offer = {}, verifiedMode = {}, state = { learningAssistant: {} }, +}) => { + useModel.mockImplementation((model) => { + switch (model) { + case 'coursewareMeta': return { offer }; + case 'courseHomeMeta': return { verifiedMode }; + default: { + throw new Error('Model not mocked'); + } + } + }); + + useSelector.mockReturnValue(state.learningAssistant); + + return rtlRenderHook( + () => useCourseUpgrade(), + { wrapper: contextWrapper({ courseInfo }) }, + ); +}; + +describe('useCourseUpgrade()', () => { + beforeAll(() => jest.useFakeTimers().setSystemTime(new Date('2024-01-10 09:00:00'))); + afterAll(() => jest.useRealTimers()); + afterEach(() => jest.resetAllMocks()); + + it('should return { upgradeable: false } if not eligible', () => { + const { result } = renderHook({ courseInfo: { isUpgradeEligible: false } }); + + expect(result.current).toEqual({ upgradeable: false }); + }); + + it('should return { upgradeable: false } if missing upgradeUrl', () => { + const { result } = renderHook({ courseInfo: { isUpgradeEligible: true } }); + + expect(result.current).toEqual({ upgradeable: false }); + }); + + it('should return { upgradeable: true } if eligible and upgradeable and no trial info for both offer and verifiedMode urls', () => { + const expected = { + upgradeable: true, + auditTrial: undefined, + auditTrialDaysRemaining: undefined, + auditTrialExpired: false, + auditTrialLengthDays: mockedAuditTrialLengthDays, + upgradeUrl: mockedUpgradeUrl, + }; + + const { result: resultOffer } = renderHook({ + courseInfo: { isUpgradeEligible: true }, + offer: { + upgradeUrl: mockedUpgradeUrl, + }, + state: { + learningAssistant: { + auditTrialLengthDays: mockedAuditTrialLengthDays, + }, + }, + }); + + expect(resultOffer.current).toEqual(expected); + + const { result: resultVerified } = renderHook({ + courseInfo: { isUpgradeEligible: true }, + verifiedMode: { + upgradeUrl: mockedUpgradeUrl, + }, + state: { + learningAssistant: { + auditTrialLengthDays: mockedAuditTrialLengthDays, + }, + }, + }); + + expect(resultVerified.current).toEqual(expected); + }); + + it('should return trial info if enabled and not expired', () => { + const { result } = renderHook({ + courseInfo: { isUpgradeEligible: true }, + offer: { + upgradeUrl: mockedUpgradeUrl, + }, + verifiedMode: { + upgradeUrl: mockedUpgradeUrl, + }, + state: { + learningAssistant: { + auditTrialLengthDays: mockedAuditTrialLengthDays, + auditTrial: { + expirationDate: '2024-01-15 09:00:00', + }, + }, + }, + }); + + expect(result.current).toEqual({ + auditTrial: { + expirationDate: '2024-01-15 09:00:00', + }, + auditTrialDaysRemaining: 5, + auditTrialExpired: false, + auditTrialLengthDays: mockedAuditTrialLengthDays, + upgradeUrl: 'https://upgrade.edx/course/test', + upgradeable: true, + }); + }); + + it('should return trial info if expired', () => { + const { result } = renderHook({ + courseInfo: { isUpgradeEligible: true }, + offer: { + upgradeUrl: mockedUpgradeUrl, + }, + verifiedMode: { + upgradeUrl: mockedUpgradeUrl, + }, + state: { + learningAssistant: { + auditTrialLengthDays: mockedAuditTrialLengthDays, + auditTrial: { + expirationDate: '2024-01-05 09:00:00', + }, + }, + }, + }); + + expect(result.current).toEqual({ + auditTrial: { + expirationDate: '2024-01-05 09:00:00', + }, + auditTrialDaysRemaining: -5, + auditTrialExpired: true, + auditTrialLengthDays: mockedAuditTrialLengthDays, + upgradeUrl: 'https://upgrade.edx/course/test', + upgradeable: true, + }); + }); +}); diff --git a/src/hooks/use-track-event.test.jsx b/src/hooks/use-track-event.test.jsx new file mode 100644 index 00000000..846f356f --- /dev/null +++ b/src/hooks/use-track-event.test.jsx @@ -0,0 +1,54 @@ +import { renderHook as rtlRenderHook } from '@testing-library/react-hooks'; +import { sendTrackEvent } from '@edx/frontend-platform/analytics'; +import { CourseInfoProvider } from '../context'; + +import useTrackEvent from './use-track-event'; + +const mockedUserId = 123; +const mockedCourseId = 'some-course-id'; +const mockedModuleId = 'some-module-id'; + +jest.mock('@edx/frontend-platform/analytics', () => ({ + sendTrackEvent: jest.fn(), +})); + +const mockedAuthenticatedUser = { userId: mockedUserId }; +jest.mock('@edx/frontend-platform/auth', () => ({ + getAuthenticatedUser: () => mockedAuthenticatedUser, +})); + +const contextWrapper = ({ courseInfo }) => function Wrapper({ children }) { // eslint-disable-line react/prop-types + return ( + + {children} + + ); +}; + +const renderHook = ({ + courseInfo, +}) => rtlRenderHook( + () => useTrackEvent(), + { wrapper: contextWrapper({ courseInfo }) }, +); + +describe('useCourseUpgrade()', () => { + afterEach(() => jest.resetAllMocks()); + + it('should return a track method that calls sendTrackEvent with the contextual information', () => { + const { result } = renderHook({ courseInfo: { courseId: mockedCourseId, moduleId: mockedModuleId } }); + + const { track } = result.current; + + const eventLabel = 'some-cool-event-to-track'; + + track(eventLabel, { some_extra_prop: 42 }); + + expect(sendTrackEvent).toHaveBeenCalledWith(eventLabel, { + course_id: mockedCourseId, + user_id: mockedUserId, + module_id: mockedModuleId, + some_extra_prop: 42, + }); + }); +}); From 4bd552215b25eb48812eda39711c4c7a867479ba Mon Sep 17 00:00:00 2001 From: Marcos Date: Mon, 16 Dec 2024 14:15:43 -0300 Subject: [PATCH 5/7] chore: Updated Disclosure unit tests --- src/components/Disclosure/Disclosure.test.jsx | 49 ++- .../__snapshots__/Disclosure.test.jsx.snap | 363 +++++++++--------- src/components/Disclosure/index.jsx | 5 +- src/components/Sidebar/index.jsx | 4 +- 4 files changed, 239 insertions(+), 182 deletions(-) diff --git a/src/components/Disclosure/Disclosure.test.jsx b/src/components/Disclosure/Disclosure.test.jsx index 9bdb89b0..abcb92d6 100644 --- a/src/components/Disclosure/Disclosure.test.jsx +++ b/src/components/Disclosure/Disclosure.test.jsx @@ -1,9 +1,18 @@ import React from 'react'; -import { screen } from '@testing-library/react'; +import { fireEvent, screen } from '@testing-library/react'; import { render } from '../../utils/utils.test'; +import { useCourseUpgrade, useTrackEvent } from '../../hooks'; import TrialDisclosure from '.'; +const mockedUpgradeUrl = 'https://upgrade.edx/course/test'; +const mockedAuditTrialDays = 7; + +jest.mock('../../hooks', () => ({ + useCourseUpgrade: jest.fn(), + useTrackEvent: jest.fn(() => ({ track: jest.fn() })), +})); + const PRIVACY_POLICY_URL = 'https://some.url/policy'; jest.mock('@edx/frontend-platform/config', () => ({ ensureConfig: jest.fn(), @@ -15,6 +24,7 @@ describe('', () => { describe('When trial upgrade is not being shown', () => { beforeEach(() => { + useCourseUpgrade.mockReturnValue({ upgradeable: false }); ({ container } = render(Children)); }); @@ -26,25 +36,56 @@ describe('', () => { }); it('should not show the trial message', () => { - const upgrade = screen.queryByText('Free trial, then upgrade course for full access to Xpert features.'); + const upgrade = screen.queryByTestId('free-days-label'); expect(upgrade).not.toBeInTheDocument(); }); + it('should not show the upgrade CTA', () => { + const upgradeCta = screen.queryByTestId('upgrade-cta'); + + expect(upgradeCta).not.toBeInTheDocument(); + }); + it('should match snapshot', () => { expect(container).toMatchSnapshot(); }); }); describe('When trial upgrade being shown', () => { + const mockedTrackEvent = jest.fn(); + beforeEach(() => { + useCourseUpgrade.mockReturnValue({ + upgradeable: true, + upgradeUrl: mockedUpgradeUrl, + auditTrialLengthDays: mockedAuditTrialDays, + }); + useTrackEvent.mockReturnValue({ track: mockedTrackEvent }); ({ container } = render(Children)); }); it('should show the trial message', () => { - const upgrade = screen.queryByText('Free trial, then upgrade course for full access to Xpert features.'); + const upgrade = screen.queryByTestId('free-days-label'); + + expect(upgrade.textContent).toBe(`Free for ${mockedAuditTrialDays} days, then upgrade course for full access to Xpert features.`); + }); + + it('should show the trial button with the proper href to upgrade', () => { + const upgradeCta = screen.queryByTestId('upgrade-cta'); + + expect(upgradeCta).toBeInTheDocument(); + expect(upgradeCta).toHaveAttribute('href', mockedUpgradeUrl); + }); + + it('should call the track event on click', () => { + const upgradeCta = screen.queryByTestId('upgrade-cta'); + + expect(mockedTrackEvent).not.toHaveBeenCalled(); + + fireEvent.click(upgradeCta); - expect(upgrade).toBeInTheDocument(); + expect(mockedTrackEvent).toHaveBeenCalledWith('edx.ui.lms.learning_assistant.message'); }); it('should match snapshot', () => { diff --git a/src/components/Disclosure/__snapshots__/Disclosure.test.jsx.snap b/src/components/Disclosure/__snapshots__/Disclosure.test.jsx.snap index 6c06352d..ee89f66e 100644 --- a/src/components/Disclosure/__snapshots__/Disclosure.test.jsx.snap +++ b/src/components/Disclosure/__snapshots__/Disclosure.test.jsx.snap @@ -1,8 +1,8 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[` When trial upgrade being showed should match snapshot 1`] = ` +exports[` When trial upgrade being shown should match snapshot 1`] = `
-

When trial upgrade being showed should match snapsh > Xpert Learning Assistant

-

- An AI-powered educational tool -

-
- - - -
- Understand a concept -
- - “How does photosynthesis work?” - -
-
+ An AI-powered educational tool +
- - - -
- Summarize your learning -
- - “Can you help me review pivot tables?” - + + +
+ Understand a concept +
+ + “How does photosynthesis work?” + +
-
-
-
-
When trial upgrade being showed should match snapsh xmlns="http://www.w3.org/2000/svg" > - - Free trial, then upgrade course for full access to Xpert features. - +
+ Summarize your learning +
+ + “Can you help me review pivot tables?” + +
- +
+ + + + + Free for + 7 days + , then upgrade course for full access to Xpert features. + +
+ + Upgrade now + +
-
-

- Note: This chat is AI generated, mistakes are possible. By using it you agree that edX may create a record of this chat. Your personal data will be used as described in our   - - privacy policy - - . -

- - Children - -
+ Note: This chat is AI generated, mistakes are possible. By using it you agree that edX may create a record of this chat. Your personal data will be used as described in our   + + privacy policy + + . +

+
+
+ + Children + +
+
`; -exports[` When trial upgrade is not being showed should match snapshot 1`] = ` +exports[` When trial upgrade is not being shown should match snapshot 1`] = `
-

When trial upgrade is not being showed should match > Xpert Learning Assistant

-

- An AI-powered educational tool -

+

+ An AI-powered educational tool +

- - - -
- Understand a concept -
- - “How does photosynthesis work?” - + + +
+ Understand a concept +
+ + “How does photosynthesis work?” + +
-
-
- - - -
- Summarize your learning -
- - “Can you help me review pivot tables?” - + + +
+ Summarize your learning +
+ + “Can you help me review pivot tables?” + +
-
-

- Note: This chat is AI generated, mistakes are possible. By using it you agree that edX may create a record of this chat. Your personal data will be used as described in our   - - privacy policy - - . -

- - Children - -
+ Note: This chat is AI generated, mistakes are possible. By using it you agree that edX may create a record of this chat. Your personal data will be used as described in our   + + privacy policy + + . +

+
+
+ + Children + +
+
`; diff --git a/src/components/Disclosure/index.jsx b/src/components/Disclosure/index.jsx index 8f5d916a..68e284de 100644 --- a/src/components/Disclosure/index.jsx +++ b/src/components/Disclosure/index.jsx @@ -4,9 +4,9 @@ import React from 'react'; import { Hyperlink, Icon, Button } from '@openedx/paragon'; import { QuestionAnswerOutline, LightbulbCircle, AutoAwesome } from '@openedx/paragon/icons'; import { ensureConfig, getConfig } from '@edx/frontend-platform/config'; +import { useCourseUpgrade, useTrackEvent } from '../../hooks'; import './Disclosure.scss'; -import { useCourseUpgrade, useTrackEvent } from '../../hooks'; ensureConfig(['PRIVACY_POLICY_URL']); @@ -45,7 +45,7 @@ const Disclosure = ({ children }) => {
- + Free for {freeDays}, then upgrade course for full access to Xpert features.
@@ -54,6 +54,7 @@ const Disclosure = ({ children }) => { href={upgradeUrl} className="trial-upgrade mt-3" block + data-testid="upgrade-cta" > Upgrade now diff --git a/src/components/Sidebar/index.jsx b/src/components/Sidebar/index.jsx index 996a6754..bb590182 100644 --- a/src/components/Sidebar/index.jsx +++ b/src/components/Sidebar/index.jsx @@ -114,9 +114,7 @@ const Sidebar = ({ invertColors data-testid="close-button" /> - {disclosureAcknowledged - ? (getSidebar()) - : ({getMessageForm()})} + {disclosureAcknowledged ? (getSidebar()) : ({getMessageForm()})}
) ); From 1e56ec315fda10c1d770356461b187c5867aa11a Mon Sep 17 00:00:00 2001 From: Marcos Date: Mon, 16 Dec 2024 15:11:14 -0300 Subject: [PATCH 6/7] chore: fixed tests --- .../{Disclosure.test.jsx.snap => index.test.jsx.snap} | 0 .../Disclosure/{Disclosure.test.jsx => index.test.jsx} | 10 +++++----- src/hooks/use-course-upgrade.test.jsx | 1 - src/hooks/use-track-event.test.jsx | 2 +- src/setupTest.js | 7 +++++++ 5 files changed, 13 insertions(+), 7 deletions(-) rename src/components/Disclosure/__snapshots__/{Disclosure.test.jsx.snap => index.test.jsx.snap} (100%) rename src/components/Disclosure/{Disclosure.test.jsx => index.test.jsx} (100%) diff --git a/src/components/Disclosure/__snapshots__/Disclosure.test.jsx.snap b/src/components/Disclosure/__snapshots__/index.test.jsx.snap similarity index 100% rename from src/components/Disclosure/__snapshots__/Disclosure.test.jsx.snap rename to src/components/Disclosure/__snapshots__/index.test.jsx.snap diff --git a/src/components/Disclosure/Disclosure.test.jsx b/src/components/Disclosure/index.test.jsx similarity index 100% rename from src/components/Disclosure/Disclosure.test.jsx rename to src/components/Disclosure/index.test.jsx index abcb92d6..a08d3f7d 100644 --- a/src/components/Disclosure/Disclosure.test.jsx +++ b/src/components/Disclosure/index.test.jsx @@ -8,17 +8,17 @@ import TrialDisclosure from '.'; const mockedUpgradeUrl = 'https://upgrade.edx/course/test'; const mockedAuditTrialDays = 7; -jest.mock('../../hooks', () => ({ - useCourseUpgrade: jest.fn(), - useTrackEvent: jest.fn(() => ({ track: jest.fn() })), -})); - const PRIVACY_POLICY_URL = 'https://some.url/policy'; jest.mock('@edx/frontend-platform/config', () => ({ ensureConfig: jest.fn(), getConfig: () => ({ PRIVACY_POLICY_URL }), })); +jest.mock('../../hooks', () => ({ + useCourseUpgrade: jest.fn(), + useTrackEvent: jest.fn(() => ({ track: jest.fn() })), +})); + describe('', () => { let container; diff --git a/src/hooks/use-course-upgrade.test.jsx b/src/hooks/use-course-upgrade.test.jsx index 2bb4aeb7..92895fda 100644 --- a/src/hooks/use-course-upgrade.test.jsx +++ b/src/hooks/use-course-upgrade.test.jsx @@ -4,7 +4,6 @@ import { useModel } from '@src/generic/model-store'; // eslint-disable-line impo import { CourseInfoProvider } from '../context'; import useCourseUpgrade from './use-course-upgrade'; -jest.mock('@src/generic/model-store', () => ({ useModel: jest.fn() }), { virtual: true }); jest.mock('react-redux', () => ({ useSelector: jest.fn() })); const mockedUpgradeUrl = 'https://upgrade.edx/course/test'; diff --git a/src/hooks/use-track-event.test.jsx b/src/hooks/use-track-event.test.jsx index 846f356f..8c0f8471 100644 --- a/src/hooks/use-track-event.test.jsx +++ b/src/hooks/use-track-event.test.jsx @@ -32,7 +32,7 @@ const renderHook = ({ { wrapper: contextWrapper({ courseInfo }) }, ); -describe('useCourseUpgrade()', () => { +describe('useTrackEvent()', () => { afterEach(() => jest.resetAllMocks()); it('should return a track method that calls sendTrackEvent with the contextual information', () => { diff --git a/src/setupTest.js b/src/setupTest.js index 9954a7a3..57647f19 100644 --- a/src/setupTest.js +++ b/src/setupTest.js @@ -1,6 +1,13 @@ import '@testing-library/jest-dom'; import { mergeConfig } from '@edx/frontend-platform'; +jest.mock('./hooks', () => ({ + useCourseUpgrade: () => ({ upgradeable: false }), + useTrackEvent: () => ({ track: () => {} }), +})); + +jest.mock('@src/generic/model-store', () => ({ useModel: jest.fn() }), { virtual: true }); + mergeConfig({ ...process.env, }); From 0659a970add9e565e7def551a0b4c9140ff5b91a Mon Sep 17 00:00:00 2001 From: Marcos Date: Mon, 16 Dec 2024 16:25:26 -0300 Subject: [PATCH 7/7] chore: Updated based on Code Review --- src/components/Disclosure/index.jsx | 2 +- src/components/Disclosure/index.test.jsx | 2 +- src/hooks/use-course-upgrade.js | 23 +++++++++++++++++++++++ src/hooks/use-track-event.js | 11 +++++++++++ 4 files changed, 36 insertions(+), 2 deletions(-) diff --git a/src/components/Disclosure/index.jsx b/src/components/Disclosure/index.jsx index 68e284de..c617627a 100644 --- a/src/components/Disclosure/index.jsx +++ b/src/components/Disclosure/index.jsx @@ -14,7 +14,7 @@ const Disclosure = ({ children }) => { const { upgradeable, upgradeUrl, auditTrialLengthDays } = useCourseUpgrade(); const { track } = useTrackEvent(); - const handleClick = () => track('edx.ui.lms.learning_assistant.message'); + const handleClick = () => track('edx.ui.lms.learning_assistant.disclosure_upgrade_click'); const freeDays = auditTrialLengthDays === 1 ? '1 day' : `${auditTrialLengthDays} days`; return ( diff --git a/src/components/Disclosure/index.test.jsx b/src/components/Disclosure/index.test.jsx index a08d3f7d..f40b4380 100644 --- a/src/components/Disclosure/index.test.jsx +++ b/src/components/Disclosure/index.test.jsx @@ -85,7 +85,7 @@ describe('', () => { fireEvent.click(upgradeCta); - expect(mockedTrackEvent).toHaveBeenCalledWith('edx.ui.lms.learning_assistant.message'); + expect(mockedTrackEvent).toHaveBeenCalledWith('edx.ui.lms.learning_assistant.disclosure_upgrade_click'); }); it('should match snapshot', () => { diff --git a/src/hooks/use-course-upgrade.js b/src/hooks/use-course-upgrade.js index 8870dc5a..41abfa99 100644 --- a/src/hooks/use-course-upgrade.js +++ b/src/hooks/use-course-upgrade.js @@ -5,6 +5,29 @@ import { CourseInfoContext } from '../context'; const millisecondsInOneDay = 24 * 60 * 60 * 1000; // hours*minutes*seconds*milliseconds +/** + * @typedef AuditTrial + * @type {object} + * @property {string} startDate Date String of when the Trial started. + * @property {string} expirationDate Date String of when the Trial ends. + */ + +/** + * @typedef CourseUpgradeInfo + * @type {object} + * @property {boolean} upgradeable Should this user see a trial/upgrade option?. + * @property {number} [auditTrialLengthDays] Audit Trial full length in days. + * @property {number} [auditTrialDaysRemaining] Remaining day for the current trial (if any). + * @property {boolean} [auditTrialExpired] True means that the audit has been taken and expired. + * @property {AuditTrial} [auditTrial] The Audit trial information. Undefined if there's no trial for this user. + * @property {string} [upgradeUrl] URL to redirect the user in case there's intention to upgrade. + */ + +/** + * This hook returns related data for the Course Upgrade. + * + * @returns {CourseUpgradeInfo} + */ export default function useCourseUpgrade() { const { courseId, isUpgradeEligible } = useContext(CourseInfoContext); const { offer } = useModel('coursewareMeta', courseId); diff --git a/src/hooks/use-track-event.js b/src/hooks/use-track-event.js index 7205326c..6993577c 100644 --- a/src/hooks/use-track-event.js +++ b/src/hooks/use-track-event.js @@ -3,6 +3,17 @@ import { getAuthenticatedUser } from '@edx/frontend-platform/auth'; import { sendTrackEvent } from '@edx/frontend-platform/analytics'; import { CourseInfoContext } from '../context'; +/** + * @typedef Tracker + * @type {object} + * @property {(eventName: string, details?: object)} track Calls sendTrackEvent with user and course context. + */ + +/** + * This hook returns a track method to track events. + * + * @returns {Tracker} + */ export default function useTrackEvent() { const { courseId, moduleId } = useContext(CourseInfoContext); const { userId } = getAuthenticatedUser();