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 && (