diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index b4fba46..a2b0443 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -1,5 +1,5 @@
name: Default CI
-on:
+on:
push:
branches:
- 'main'
@@ -28,7 +28,7 @@ jobs:
- name: Lint
run: npm run lint
- name: Test
- run: npm run test
+ run: npm run test:ci
- name: Build
run: npm run build
- name: i18n_extract
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index a2a1e7e..3d3719c 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -22,7 +22,7 @@ jobs:
- name: Install dependencies
run: npm ci
- name: Test
- run: npm run test
+ run: npm run test:ci
- name: Release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
diff --git a/package-lock.json b/package-lock.json
index 0f5c8c2..030d3cd 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -30,6 +30,7 @@
"@reduxjs/toolkit": "1.8.1",
"@testing-library/jest-dom": "6.4.5",
"@testing-library/react": "^12.1.5",
+ "@testing-library/react-hooks": "^8.0.1",
"@testing-library/user-event": "^14.4.3",
"glob": "7.2.3",
"husky": "7.0.4",
@@ -5050,6 +5051,36 @@
"react-dom": "<18.0.0"
}
},
+ "node_modules/@testing-library/react-hooks": {
+ "version": "8.0.1",
+ "resolved": "https://registry.npmjs.org/@testing-library/react-hooks/-/react-hooks-8.0.1.tgz",
+ "integrity": "sha512-Aqhl2IVmLt8IovEVarNDFuJDVWVvhnr9/GCU6UUnrYXwgDFF9h2L2o2P9KBni1AST5sT6riAyoukFLyjQUgD/g==",
+ "dev": true,
+ "dependencies": {
+ "@babel/runtime": "^7.12.5",
+ "react-error-boundary": "^3.1.0"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "peerDependencies": {
+ "@types/react": "^16.9.0 || ^17.0.0",
+ "react": "^16.9.0 || ^17.0.0",
+ "react-dom": "^16.9.0 || ^17.0.0",
+ "react-test-renderer": "^16.9.0 || ^17.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "react-dom": {
+ "optional": true
+ },
+ "react-test-renderer": {
+ "optional": true
+ }
+ }
+ },
"node_modules/@testing-library/react/node_modules/@testing-library/dom": {
"version": "8.20.1",
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-8.20.1.tgz",
@@ -5511,11 +5542,12 @@
"dev": true
},
"node_modules/@types/react": {
- "version": "18.3.2",
- "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.2.tgz",
- "integrity": "sha512-Btgg89dAnqD4vV7R3hlwOxgqobUQKgx3MmrQRi0yYbs/P0ym8XozIAlkqVilPqHQwXs4e9Tf63rrCgl58BcO4w==",
+ "version": "17.0.80",
+ "resolved": "https://registry.npmjs.org/@types/react/-/react-17.0.80.tgz",
+ "integrity": "sha512-LrgHIu2lEtIo8M7d1FcI3BdwXWoRQwMoXOZ7+dPTW0lYREjmlHl3P0U1VD0i/9tppOuv8/sam7sOjx34TxSFbA==",
"dependencies": {
"@types/prop-types": "*",
+ "@types/scheduler": "^0.16",
"csstype": "^3.0.2"
}
},
@@ -5528,17 +5560,6 @@
"@types/react": "^17"
}
},
- "node_modules/@types/react-dom/node_modules/@types/react": {
- "version": "17.0.80",
- "resolved": "https://registry.npmjs.org/@types/react/-/react-17.0.80.tgz",
- "integrity": "sha512-LrgHIu2lEtIo8M7d1FcI3BdwXWoRQwMoXOZ7+dPTW0lYREjmlHl3P0U1VD0i/9tppOuv8/sam7sOjx34TxSFbA==",
- "dev": true,
- "dependencies": {
- "@types/prop-types": "*",
- "@types/scheduler": "^0.16",
- "csstype": "^3.0.2"
- }
- },
"node_modules/@types/react-redux": {
"version": "7.1.33",
"resolved": "https://registry.npmjs.org/@types/react-redux/-/react-redux-7.1.33.tgz",
@@ -5569,8 +5590,7 @@
"node_modules/@types/scheduler": {
"version": "0.16.8",
"resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.8.tgz",
- "integrity": "sha512-WZLiwShhwLRmeV6zH+GkbOFT6Z6VklCItrDioxUnv+u4Ll+8vKeFySoFyK/0ctcRpOmwAicELfmys1sDc/Rw+A==",
- "dev": true
+ "integrity": "sha512-WZLiwShhwLRmeV6zH+GkbOFT6Z6VklCItrDioxUnv+u4Ll+8vKeFySoFyK/0ctcRpOmwAicELfmys1sDc/Rw+A=="
},
"node_modules/@types/semver": {
"version": "7.5.8",
@@ -21293,6 +21313,22 @@
"react": ">= 16.8 || 18.0.0"
}
},
+ "node_modules/react-error-boundary": {
+ "version": "3.1.4",
+ "resolved": "https://registry.npmjs.org/react-error-boundary/-/react-error-boundary-3.1.4.tgz",
+ "integrity": "sha512-uM9uPzZJTF6wRQORmSrvOIgt4lJ9MC1sNgEOj2XGsDTRE4kmpWxg7ENK9EWNKJRMAOY9z0MuF4yIfl6gp4sotA==",
+ "dev": true,
+ "dependencies": {
+ "@babel/runtime": "^7.12.5"
+ },
+ "engines": {
+ "node": ">=10",
+ "npm": ">=6"
+ },
+ "peerDependencies": {
+ "react": ">=16.13.1"
+ }
+ },
"node_modules/react-error-overlay": {
"version": "6.0.11",
"resolved": "https://registry.npmjs.org/react-error-overlay/-/react-error-overlay-6.0.11.tgz",
diff --git a/package.json b/package.json
index 5d42ea5..ee4be1d 100644
--- a/package.json
+++ b/package.json
@@ -19,7 +19,8 @@
"lint:fix": "fedx-scripts eslint --fix --ext .js --ext .jsx .",
"snapshot": "fedx-scripts jest --updateSnapshot",
"start": "fedx-scripts webpack-dev-server --progress",
- "test": "fedx-scripts jest --coverage --passWithNoTests"
+ "test": "fedx-scripts jest --coverage --passWithNoTests",
+ "test:ci": "fedx-scripts jest --silent --coverage --passWithNoTests"
},
"files": [
"/dist"
@@ -72,6 +73,7 @@
"@reduxjs/toolkit": "1.8.1",
"@testing-library/jest-dom": "6.4.5",
"@testing-library/react": "^12.1.5",
+ "@testing-library/react-hooks": "^8.0.1",
"@testing-library/user-event": "^14.4.3",
"glob": "7.2.3",
"husky": "7.0.4",
diff --git a/src/components/Disclosure/index.jsx b/src/components/Disclosure/index.jsx
index cdb383b..1c6edad 100644
--- a/src/components/Disclosure/index.jsx
+++ b/src/components/Disclosure/index.jsx
@@ -3,10 +3,12 @@ import React from 'react';
import { Hyperlink, Icon } from '@openedx/paragon';
import { Chat } from '@openedx/paragon/icons';
-import { getConfig } from '@edx/frontend-platform/config';
+import { ensureConfig, getConfig } from '@edx/frontend-platform/config';
import './Disclosure.scss';
+ensureConfig(['PRIVACY_POLICY_URL']);
+
const Disclosure = ({ children }) => (
diff --git a/src/components/Sidebar/index.jsx b/src/components/Sidebar/index.jsx
index 2d47f29..8d0b731 100644
--- a/src/components/Sidebar/index.jsx
+++ b/src/components/Sidebar/index.jsx
@@ -9,14 +9,15 @@ import {
} from '@openedx/paragon';
import { Close } from '@openedx/paragon/icons';
+import { clearMessages } from '../../data/thunks';
+import { PROMPT_EXPERIMENT_FLAG, PROMPT_EXPERIMENT_KEY } from '../../constants/experiments';
+import { showControlSurvey, showVariationSurvey } from '../../utils/surveyMonkey';
+
import APIError from '../APIError';
import ChatBox from '../ChatBox';
import Disclosure from '../Disclosure';
import MessageForm from '../MessageForm';
import './Sidebar.scss';
-import {
- clearMessages,
-} from '../../data/thunks';
const Sidebar = ({
courseId,
@@ -28,7 +29,9 @@ const Sidebar = ({
apiError,
disclosureAcknowledged,
messageList,
+ experiments,
} = useSelector(state => state.learningAssistant);
+ const { variationKey } = experiments?.[PROMPT_EXPERIMENT_FLAG] || {};
const chatboxContainerRef = useRef(null);
const dispatch = useDispatch();
@@ -69,10 +72,12 @@ const Sidebar = ({
const handleClick = () => {
setIsOpen(false);
- // check to see if hotjar is available, then trigger hotjar event if user has sent and received a message
- const hasWindow = typeof window !== 'undefined';
- if (hasWindow && window.hj && messageList.length >= 2) {
- window.hj('event', 'ocm_learning_assistant_chat_closed');
+ if (messageList.length >= 2) {
+ if (variationKey === PROMPT_EXPERIMENT_KEY) {
+ showVariationSurvey();
+ } else {
+ showControlSurvey();
+ }
}
};
@@ -80,6 +85,7 @@ const Sidebar = ({
dispatch(clearMessages());
sendTrackEvent('edx.ui.lms.learning_assistant.clear', {
course_id: courseId,
+ ...(variationKey ? { experiment_name: PROMPT_EXPERIMENT_FLAG, variation_key: variationKey } : {}),
});
};
@@ -88,7 +94,7 @@ const Sidebar = ({
);
const getSidebar = () => (
-
+
Hi, I'm Xpert!
@@ -119,6 +125,7 @@ const Sidebar = ({
aria-label="clear"
variant="primary"
type="button"
+ data-testid="sidebar-clear-btn"
>
Clear
@@ -130,16 +137,18 @@ const Sidebar = ({
isOpen && (
{disclosureAcknowledged ? (getSidebar()) : ({getMessageForm()})}
diff --git a/src/components/Sidebar/index.test.jsx b/src/components/Sidebar/index.test.jsx
new file mode 100644
index 0000000..fab97f9
--- /dev/null
+++ b/src/components/Sidebar/index.test.jsx
@@ -0,0 +1,160 @@
+import React from 'react';
+import { screen, act } from '@testing-library/react';
+import { sendTrackEvent } from '@edx/frontend-platform/analytics';
+import { render as renderComponent } from '../../utils/utils.test';
+import { initialState } from '../../data/slice';
+import { PROMPT_EXPERIMENT_FLAG, PROMPT_EXPERIMENT_KEY } from '../../constants/experiments';
+import { showControlSurvey, showVariationSurvey } from '../../utils/surveyMonkey';
+
+import Sidebar from '.';
+
+jest.mock('../../utils/surveyMonkey', () => ({
+ showControlSurvey: jest.fn(),
+ showVariationSurvey: jest.fn(),
+}));
+
+jest.mock('@edx/frontend-platform/analytics', () => ({
+ sendTrackEvent: jest.fn(),
+}));
+
+const mockDispatch = jest.fn();
+jest.mock('react-redux', () => ({
+ ...jest.requireActual('react-redux'),
+ useDispatch: () => mockDispatch,
+}));
+
+const clearMessagesAction = 'clear-messages-action';
+jest.mock('../../data/thunks', () => ({
+ clearMessages: () => 'clear-messages-action',
+}));
+
+const defaultProps = {
+ courseId: 'some-course-id',
+ isOpen: true,
+ setIsOpen: jest.fn(),
+ unitId: 'some-unit-id',
+};
+
+const render = async (props = {}, sliceState = {}) => {
+ const componentProps = {
+ ...defaultProps,
+ ...props,
+ };
+
+ const initState = {
+ preloadedState: {
+ learningAssistant: {
+ ...initialState,
+ ...sliceState,
+ },
+ },
+ };
+ return act(async () => renderComponent(
+ ,
+ initState,
+ ));
+};
+
+describe('', () => {
+ beforeEach(() => {
+ jest.resetAllMocks();
+ });
+
+ describe('when it\'s open', () => {
+ it('should render normally', () => {
+ render();
+ expect(screen.queryByTestId('sidebar')).toBeInTheDocument();
+ });
+
+ it('should not render xpert if no disclosureAcknowledged', () => {
+ render();
+ expect(screen.queryByTestId('sidebar-xpert')).not.toBeInTheDocument();
+ });
+
+ it('should render xpert if disclosureAcknowledged', () => {
+ render(undefined, { disclosureAcknowledged: true });
+ expect(screen.queryByTestId('sidebar-xpert')).toBeInTheDocument();
+ });
+
+ it('should dispatch clearMessages() and call sendTrackEvent() with the expected props on clear', () => {
+ render(undefined, { disclosureAcknowledged: true });
+
+ act(() => {
+ screen.queryByTestId('sidebar-clear-btn').click();
+ });
+
+ expect(mockDispatch).toHaveBeenCalledWith(clearMessagesAction);
+ expect(sendTrackEvent).toHaveBeenCalledWith('edx.ui.lms.learning_assistant.clear', { course_id: defaultProps.courseId });
+ });
+ });
+
+ describe('when it\'s not open', () => {
+ it('should not render', () => {
+ render({ isOpen: false });
+ expect(screen.queryByTestId('sidebar')).not.toBeInTheDocument();
+ });
+ });
+
+ describe('prompt experiment', () => {
+ const defaultState = {
+ messageList: [{
+ role: 'user',
+ content: 'Testing message 1',
+ timestamp: +Date.now(),
+ }, {
+ role: 'user',
+ content: 'Testing message 2',
+ timestamp: +Date.now() + 1,
+ }],
+ experiments: {
+ [PROMPT_EXPERIMENT_FLAG]: {
+ enabled: true,
+ variationKey: PROMPT_EXPERIMENT_KEY,
+ },
+ },
+ };
+
+ it('should call showVariationSurvey if experiment is active', () => {
+ render(undefined, defaultState);
+
+ act(() => {
+ screen.queryByTestId('close-button').click();
+ });
+
+ expect(showVariationSurvey).toHaveBeenCalled();
+ expect(showControlSurvey).not.toHaveBeenCalled();
+ });
+
+ it('should call showControlSurvey if experiment is not active', () => {
+ render(undefined, {
+ ...defaultState,
+ experiments: {},
+ });
+
+ act(() => {
+ screen.queryByTestId('close-button').click();
+ });
+
+ expect(showControlSurvey).toHaveBeenCalled();
+ expect(showVariationSurvey).not.toHaveBeenCalled();
+ });
+
+ it('should dispatch clearMessages() and call sendTrackEvent() with the expected props on clear', () => {
+ render(undefined, {
+ ...defaultState,
+ disclosureAcknowledged: true,
+ });
+
+ act(() => {
+ screen.queryByTestId('sidebar-clear-btn').click();
+ });
+
+ expect(mockDispatch).toHaveBeenCalledWith(clearMessagesAction);
+ expect(sendTrackEvent).toHaveBeenCalledWith('edx.ui.lms.learning_assistant.clear', {
+ course_id: defaultProps.courseId,
+ experiment_name: PROMPT_EXPERIMENT_FLAG,
+ variation_key: PROMPT_EXPERIMENT_KEY,
+ });
+ });
+ });
+});
diff --git a/src/components/ToggleXpertButton/index.jsx b/src/components/ToggleXpertButton/index.jsx
index 6041c9b..c49ad5c 100644
--- a/src/components/ToggleXpertButton/index.jsx
+++ b/src/components/ToggleXpertButton/index.jsx
@@ -1,6 +1,6 @@
import PropTypes from 'prop-types';
import React, { useState } from 'react';
-
+import { useSelector } from 'react-redux';
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
import {
@@ -14,6 +14,7 @@ import { Close } from '@openedx/paragon/icons';
import { ReactComponent as XpertLogo } from '../../assets/xpert-logo.svg';
import './index.scss';
+import { PROMPT_EXPERIMENT_FLAG } from '../../constants/experiments';
const ToggleXpert = ({
isOpen,
@@ -21,6 +22,8 @@ const ToggleXpert = ({
courseId,
contentToolsEnabled,
}) => {
+ const { experiments } = useSelector(state => state.learningAssistant);
+ const { variationKey } = experiments?.[PROMPT_EXPERIMENT_FLAG] || {};
const [hasDismissedCTA, setHasDismissedCTA] = useState(false);
const [isModalOpen, setIsModalOpen] = useState(true);
const [target, setTarget] = useState(null);
@@ -35,6 +38,7 @@ const ToggleXpert = ({
course_id: courseId,
user_id: userId,
source: event.target.id === 'toggle-button' ? 'toggle' : 'cta',
+ ...(variationKey ? { experiment_name: PROMPT_EXPERIMENT_FLAG, variation_key: variationKey } : {}),
},
);
}
@@ -51,6 +55,7 @@ const ToggleXpert = ({
localStorage.setItem('dismissedLearningAssistantCallToAction', 'true');
sendTrackEvent('edx.ui.lms.learning_assistant.dismiss_action_message', {
course_id: courseId,
+ ...(variationKey ? { experiment_name: PROMPT_EXPERIMENT_FLAG, variation_key: variationKey } : {}),
});
};
@@ -63,6 +68,7 @@ const ToggleXpert = ({
course_id: courseId,
user_id: userId,
source: 'product-tour',
+ ...(variationKey ? { experiment_name: PROMPT_EXPERIMENT_FLAG, variation_key: variationKey } : {}),
},
);
};
@@ -78,9 +84,10 @@ const ToggleXpert = ({
(!isOpen && (