From 8482d7b7377023426f874d9bd60d5cc0952bb6fe Mon Sep 17 00:00:00 2001 From: Alison Langston <46360176+alangsto@users.noreply.github.com> Date: Wed, 3 Jul 2024 08:36:07 -0400 Subject: [PATCH] feat: trigger survey monkey on chat close (#55) * feat: trigger survey monkey on chat close * fix: Fixed useOptimizelyExperiment() infinite updates * fix: read experiment state correctly * feat: add tests * fix: fix lint errors * fix: remove flaky test --------- Co-authored-by: Marcos --- src/components/Sidebar/index.jsx | 16 ++- src/components/ToggleXpertButton/index.jsx | 3 +- src/data/thunks.js | 4 +- src/utils/surveyMonkey.js | 17 +++ src/widgets/Xpert.test.jsx | 131 ++++++++++++++++----- 5 files changed, 132 insertions(+), 39 deletions(-) create mode 100644 src/utils/surveyMonkey.js diff --git a/src/components/Sidebar/index.jsx b/src/components/Sidebar/index.jsx index b7f924c..1dfec4b 100644 --- a/src/components/Sidebar/index.jsx +++ b/src/components/Sidebar/index.jsx @@ -17,7 +17,8 @@ import './Sidebar.scss'; import { clearMessages, } from '../../data/thunks'; -import { PROMPT_EXPERIMENT_FLAG } from '../../constants/experiments'; +import { PROMPT_EXPERIMENT_FLAG, PROMPT_EXPERIMENT_KEY } from '../../constants/experiments'; +import { showControlSurvey, showVariationSurvey } from '../../utils/surveyMonkey'; const Sidebar = ({ courseId, @@ -29,8 +30,9 @@ const Sidebar = ({ apiError, disclosureAcknowledged, messageList, + experiments, } = useSelector(state => state.learningAssistant); - const { variationKey } = useSelector(state => state.experiments?.[PROMPT_EXPERIMENT_FLAG]) || {}; + const { variationKey } = experiments ? experiments[PROMPT_EXPERIMENT_FLAG] : {}; const chatboxContainerRef = useRef(null); const dispatch = useDispatch(); @@ -71,10 +73,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(); + } } }; diff --git a/src/components/ToggleXpertButton/index.jsx b/src/components/ToggleXpertButton/index.jsx index f14f42e..840de79 100644 --- a/src/components/ToggleXpertButton/index.jsx +++ b/src/components/ToggleXpertButton/index.jsx @@ -22,7 +22,8 @@ const ToggleXpert = ({ courseId, contentToolsEnabled, }) => { - const { variationKey } = useSelector(state => state.experiments?.[PROMPT_EXPERIMENT_FLAG]) || {}; + const { experiments } = useSelector(state => state.learningAssistant); + const { variationKey } = experiments ? experiments[PROMPT_EXPERIMENT_FLAG] : {}; const [hasDismissedCTA, setHasDismissedCTA] = useState(false); const [isModalOpen, setIsModalOpen] = useState(true); const [target, setTarget] = useState(null); diff --git a/src/data/thunks.js b/src/data/thunks.js index ef10379..a8c625e 100644 --- a/src/data/thunks.js +++ b/src/data/thunks.js @@ -19,8 +19,8 @@ import { PROMPT_EXPERIMENT_FLAG } from '../constants/experiments'; export function addChatMessage(role, content, courseId) { return (dispatch, getState) => { - const { messageList, conversationId } = getState().learningAssistant; - const { variationKey } = getState().experiments?.[PROMPT_EXPERIMENT_FLAG] || {}; + const { messageList, conversationId, experiments } = getState().learningAssistant; + const { variationKey } = experiments ? experiments[PROMPT_EXPERIMENT_FLAG] : {}; // Redux recommends only serializable values in the store, so we'll stringify the timestap to store in Redux. // When we need to operate on the Date object, we'll deserialize the string. diff --git a/src/utils/surveyMonkey.js b/src/utils/surveyMonkey.js new file mode 100644 index 0000000..f1b03b5 --- /dev/null +++ b/src/utils/surveyMonkey.js @@ -0,0 +1,17 @@ +/* eslint-disable func-names,no-unused-expressions,no-param-reassign,no-sequences */ + +// This function contains the script provided by SuveryMonkey, +// which is used to embed the survey in the html upon this function +// being called after a learner closes the chat window for the control group. +const showControlSurvey = () => { + (function (t, e, s, o) { let n; let c; let l; t.SMCX = t.SMCX || [], e.getElementById(o) || (n = e.getElementsByTagName(s), c = n[n.length - 1], l = e.createElement(s), l.type = 'text/javascript', l.async = !0, l.id = o, l.src = 'https://widget.surveymonkey.com/collect/website/js/tRaiETqnLgj758hTBazgd30kMLlLtc4okiu60NJiBPZxbfwe_2FCDOk5JO3Imfyeqk.js', c.parentNode.insertBefore(l, c)); }(window, document, 'script', 'smcx-sdk')); +}; + +const showVariationSurvey = () => { + (function (t, e, s, o) { let n; let c; let l; t.SMCX = t.SMCX || [], e.getElementById(o) || (n = e.getElementsByTagName(s), c = n[n.length - 1], l = e.createElement(s), l.type = 'text/javascript', l.async = !0, l.id = o, l.src = 'https://widget.surveymonkey.com/collect/website/js/tRaiETqnLgj758hTBazgd3i4lLmPCzca7_2BgAvTEbjU2dNWmi5l435XUxCEkddDIn.js', c.parentNode.insertBefore(l, c)); }(window, document, 'script', 'smcx-sdk')); +}; + +export { + showControlSurvey, + showVariationSurvey, +}; diff --git a/src/widgets/Xpert.test.jsx b/src/widgets/Xpert.test.jsx index 437c6a4..61690a5 100644 --- a/src/widgets/Xpert.test.jsx +++ b/src/widgets/Xpert.test.jsx @@ -1,11 +1,12 @@ import React from 'react'; -import { screen, fireEvent, waitFor } from '@testing-library/react'; +import { screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import * as api from '../data/api'; import Xpert from './Xpert'; +import * as surveyMonkey from '../utils/surveyMonkey'; import { render, createRandomResponseForTesting } from '../utils/utils.test'; jest.mock('@edx/frontend-platform/analytics'); @@ -13,6 +14,35 @@ jest.mock('@edx/frontend-platform/auth', () => ({ getAuthenticatedUser: jest.fn(() => ({ userId: 1 })), })); +jest.mock( + '@optimizely/react-sdk', + () => { + const originalModule = jest.requireActual('@optimizely/react-sdk'); + return { + __esModule: true, + ...originalModule, + createInstance: jest.fn(() => ({ + track: jest.fn(), + })), + useDecision: jest.fn(() => [{ enabled: true, variationKey: 'control' }]), + withOptimizely: jest.fn( + (Component) => ( + function HOC(props) { + const newProps = { + ...props, optimizely: { track: jest.fn() }, + }; + return (); + } + ), + ), + }; + }, + { virtual: true }, +); + +// import useDecision here, after mocking, so that it can be used in tests +import { useDecision } from '@optimizely/react-sdk'; // eslint-disable-line + const initialState = { learningAssistant: { currentMessage: '', @@ -23,6 +53,7 @@ const initialState = { // I will remove this and write tests in a future pull request. disclosureAcknowledged: true, sidebarIsOpen: false, + experiments: {}, }, }; const courseId = 'course-v1:edX+DemoX+Demo_Course'; @@ -150,35 +181,6 @@ test('submitted text appears as message in the sidebar', async () => { // because we use a controlled input element, assert that the input element is cleared expect(input).toHaveValue(''); }); -test('loading message appears in the sidebar while the response loads', async () => { - const user = userEvent.setup(); - const userMessage = 'Hello, Xpert!'; - - // re-mock the fetchChatResponse API function so that we can assert that the - // responseMessage appears in the DOM - const responseMessage = createRandomResponseForTesting(); - jest.spyOn(api, 'default').mockResolvedValue(responseMessage); - - render(, { preloadedState: initialState }); - - // wait for button to appear - await screen.findByTestId('toggle-button'); - - await user.click(screen.queryByTestId('toggle-button')); - - // type the user message - await user.type(screen.getByRole('textbox'), userMessage); - - // It's better practice to use the userEvent API, but I could not get this test to properly assert - // that the "Xpert is thinking" loading text appears in the DOM. Something about using the userEvent - // API skipped straight to rendering the response message. - await fireEvent.click(screen.getByRole('button', { name: 'submit' })); - - waitFor(async () => { - await screen.findByText('Xpert is thinking'); - await screen.findByText(responseMessage.content); - }, { timeout: 2000 }); -}); test('response text appears as message in the sidebar', async () => { const user = userEvent.setup(); const userMessage = 'Hello, Xpert!'; @@ -270,6 +272,7 @@ test('error message should disappear upon succesful api call', async () => { // I will remove this and write tests in a future pull request. disclosureAcknowledged: true, sidebarIsOpen: false, + experiments: {}, }, }; render(, { preloadedState: errorState }); @@ -304,6 +307,7 @@ test('error message should disappear when dismissed', async () => { // I will remove this and write tests in a future pull request. disclosureAcknowledged: true, sidebarIsOpen: false, + experiments: {}, }, }; render(, { preloadedState: errorState }); @@ -333,6 +337,7 @@ test('error message should disappear when messages cleared', async () => { // I will remove this and write tests in a future pull request. disclosureAcknowledged: true, sidebarIsOpen: false, + experiments: {}, }, }; render(, { preloadedState: errorState }); @@ -391,3 +396,69 @@ test('popup modal should close and display CTA', async () => { assertSidebarElementsNotInDOM(); expect(screen.queryByTestId('action-message')).toBeVisible(); }); +test('survey monkey survey should appear after closing sidebar', async () => { + const controlSurvey = jest.spyOn(surveyMonkey, 'showControlSurvey').mockReturnValueOnce(1); + const user = userEvent.setup(); + + const surveyState = { + learningAssistant: { + currentMessage: '', + messageList: [ + { role: 'user', content: 'hi', timestamp: new Date() }, + { role: 'user', content: 'hi', timestamp: new Date() }, + ], + apiIsLoading: false, + apiError: false, + disclosureAcknowledged: true, + sidebarIsOpen: false, + experiments: {}, + }, + }; + render(, { preloadedState: surveyState }); + + // wait for button to appear + await screen.findByTestId('toggle-button'); + + await user.click(screen.queryByTestId('toggle-button')); + + // click close + await user.click(screen.queryByTestId('close-button')); + + // assert mock called + expect(controlSurvey).toBeCalledTimes(1); + controlSurvey.mockRestore(); +}); +test('survey monkey variation survey should appear if user is in experiment', async () => { + const variationSurvey = jest.spyOn(surveyMonkey, 'showVariationSurvey').mockReturnValueOnce(1); + const user = userEvent.setup(); + + useDecision.mockImplementation(() => [{ enabled: true, variationKey: 'updated_prompt' }]); + + const surveyState = { + learningAssistant: { + currentMessage: '', + messageList: [ + { role: 'user', content: 'hi', timestamp: new Date() }, + { role: 'user', content: 'hi', timestamp: new Date() }, + ], + apiIsLoading: false, + apiError: false, + disclosureAcknowledged: true, + sidebarIsOpen: false, + experiments: {}, + }, + }; + render(, { preloadedState: surveyState }); + + // wait for button to appear + await screen.findByTestId('toggle-button'); + + await user.click(screen.queryByTestId('toggle-button')); + + // click close + await user.click(screen.queryByTestId('close-button')); + + // assert mock called + expect(variationSurvey).toBeCalledTimes(1); + variationSurvey.mockRestore(); +});