From 1b28cf54d6e35e5d95adfadd72f294b60379a08e Mon Sep 17 00:00:00 2001 From: Marcos Date: Wed, 17 Jul 2024 13:27:03 -0300 Subject: [PATCH] fix: Optimizely refactor --- .env.test | 1 + module.config.js.example | 1 + src/components/MessageForm/index.jsx | 17 +- src/components/MessageForm/index.test.jsx | 195 ++++++++++++++++++ src/components/Sidebar/index.jsx | 18 +- src/components/Sidebar/index.test.jsx | 37 +++- src/components/ToggleXpertButton/index.jsx | 19 +- .../ToggleXpertButton/index.test.jsx | 40 ++-- src/constants/experiments.js | 7 - src/data/optimizely.js | 39 +++- src/data/optimizely.test.js | 23 +++ src/data/slice.js | 13 -- src/data/thunks.js | 22 +- src/hooks/useOptimizelyExperiments.js | 22 -- src/hooks/useOptimizelyExperiments.test.js | 64 ------ src/mocks/optimizely/README.rst | 46 +++++ src/mocks/optimizely/index.jsx | 19 ++ src/mocks/optimizely/package.json | 11 + src/optimizely/ExperimentsProvider.jsx | 28 +++ src/optimizely/index.jsx | 3 + src/setupTest.js | 5 + src/utils/optimizelyExperiment.js | 16 +- src/widgets/Xpert.jsx | 34 +-- src/widgets/Xpert.test.jsx | 38 ++-- 24 files changed, 494 insertions(+), 224 deletions(-) create mode 100644 src/components/MessageForm/index.test.jsx delete mode 100644 src/constants/experiments.js create mode 100644 src/data/optimizely.test.js delete mode 100644 src/hooks/useOptimizelyExperiments.js delete mode 100644 src/hooks/useOptimizelyExperiments.test.js create mode 100644 src/mocks/optimizely/README.rst create mode 100644 src/mocks/optimizely/index.jsx create mode 100644 src/mocks/optimizely/package.json create mode 100644 src/optimizely/ExperimentsProvider.jsx create mode 100644 src/optimizely/index.jsx diff --git a/.env.test b/.env.test index 420a16d8..bc876bcf 100644 --- a/.env.test +++ b/.env.test @@ -19,3 +19,4 @@ SITE_NAME=localhost USER_INFO_COOKIE_NAME='edx-user-info' APP_ID='' MFE_CONFIG_API_URL='' +OPTIMIZELY_FULL_STACK_SDK_KEY='test-optimizely-sdk-full-stack-key' diff --git a/module.config.js.example b/module.config.js.example index c16d1186..00143e7f 100644 --- a/module.config.js.example +++ b/module.config.js.example @@ -27,5 +27,6 @@ module.exports = { // { moduleName: '@openedx/paragon/icons', dir: '../paragon', dist: 'icons' }, // { moduleName: '@openedx/paragon', dir: '../paragon', dist: 'dist' }, // { moduleName: '@edx/frontend-platform', dir: '../frontend-platform', dist: 'dist' }, + // { moduleName: '@optimizely/react-sdk', dir: '../src/frontend-lib-learning-assistant/src/mocks/optimizely', dist: '.' }, ], }; diff --git a/src/components/MessageForm/index.jsx b/src/components/MessageForm/index.jsx index 4f2cb163..b1ec12c6 100644 --- a/src/components/MessageForm/index.jsx +++ b/src/components/MessageForm/index.jsx @@ -1,10 +1,12 @@ import PropTypes from 'prop-types'; import React, { useEffect, useRef } from 'react'; import { useDispatch, useSelector } from 'react-redux'; - +import { useDecision } from '@optimizely/react-sdk'; import { Button, Form, Icon } from '@openedx/paragon'; import { Send } from '@openedx/paragon/icons'; +import { getAuthenticatedUser } from '@edx/frontend-platform/auth'; +import { OPTIMIZELY_PROMPT_EXPERIMENT_KEY } from '../../data/optimizely'; import { acknowledgeDisclosure, addChatMessage, @@ -17,6 +19,11 @@ const MessageForm = ({ courseId, shouldAutofocus, unitId }) => { const dispatch = useDispatch(); const inputRef = useRef(); + const { userId } = getAuthenticatedUser(); + const [decision] = useDecision(OPTIMIZELY_PROMPT_EXPERIMENT_KEY, { autoUpdate: true }, { id: userId.toString() }); + const { active, variationKey } = decision || {}; + const promptExperimentVariationKey = active ? variationKey : undefined; + useEffect(() => { if (inputRef.current && !apiError && !apiIsLoading && shouldAutofocus) { inputRef.current.focus(); @@ -25,10 +32,11 @@ const MessageForm = ({ courseId, shouldAutofocus, unitId }) => { const handleSubmitMessage = (event) => { event.preventDefault(); + if (currentMessage) { dispatch(acknowledgeDisclosure(true)); - dispatch(addChatMessage('user', currentMessage, courseId)); - dispatch(getChatResponse(courseId, unitId)); + dispatch(addChatMessage('user', currentMessage, courseId, promptExperimentVariationKey)); + dispatch(getChatResponse(courseId, unitId, promptExperimentVariationKey)); } }; @@ -43,13 +51,14 @@ const MessageForm = ({ courseId, shouldAutofocus, unitId }) => { onClick={handleSubmitMessage} size="inline" variant="tertiary" + data-testid="message-form-submit" > ); return ( -
+ ({ + showControlSurvey: jest.fn(), + showVariationSurvey: jest.fn(), +})); + +jest.mock('@edx/frontend-platform/analytics', () => ({ + sendTrackEvent: jest.fn(), +})); + +const mockedAuthenticatedUser = { userId: 123 }; +jest.mock('@edx/frontend-platform/auth', () => ({ + getAuthenticatedUser: () => mockedAuthenticatedUser, +})); + +jest.mock('@edx/frontend-platform/analytics', () => ({ + sendTrackEvent: jest.fn(), +})); + +const mockDispatch = jest.fn(); +jest.mock('react-redux', () => ({ + ...jest.requireActual('react-redux'), + useDispatch: () => mockDispatch, +})); + +jest.mock('@optimizely/react-sdk', () => ({ + useDecision: jest.fn(), +})); + +jest.mock('../../data/thunks', () => ({ + acknowledgeDisclosure: jest.fn(), + addChatMessage: jest.fn(), + getChatResponse: jest.fn(), + updateCurrentMessage: jest.fn(), +})); + +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(); + useDecision.mockReturnValue([]); + }); + + describe('when rendered', () => { + it('should focus if shouldAutofocus is enabled', () => { + const currentMessage = 'How much wood'; + const sliceState = { + apiIsLoading: false, + currentMessage, + apiError: false, + }; + + render({ shouldAutofocus: true }, sliceState); + + waitFor(() => { + expect(screen.getByDisplayValue(currentMessage)).toHaveFocus(); + }); + + expect(screen.queryByTestId('message-form')).toBeInTheDocument(); + }); + + it('should dispatch updateCurrentMessage() when updating the form control', () => { + const currentMessage = 'How much wood'; + const updatedMessage = 'How much wood coud a woodchuck chuck'; + const sliceState = { + apiIsLoading: false, + currentMessage, + apiError: false, + }; + + render(undefined, sliceState); + + act(() => { + const input = screen.getByDisplayValue(currentMessage); + fireEvent.change(input, { target: { value: updatedMessage } }); + }); + + expect(updateCurrentMessage).toHaveBeenCalledWith(updatedMessage); + expect(mockDispatch).toHaveBeenCalledTimes(1); + }); + + it('should dispatch message on submit as expected', () => { + const currentMessage = 'How much wood could a woodchuck chuck if a woodchuck could chuck wood?'; + const sliceState = { + apiIsLoading: false, + currentMessage, + apiError: false, + }; + + render(undefined, sliceState); + + act(() => { + screen.queryByTestId('message-form-submit').click(); + }); + + expect(acknowledgeDisclosure).toHaveBeenCalledWith(true); + expect(addChatMessage).toHaveBeenCalledWith('user', currentMessage, defaultProps.courseId, undefined); + expect(getChatResponse).toHaveBeenCalledWith(defaultProps.courseId, defaultProps.unitId, undefined); + expect(mockDispatch).toHaveBeenCalledTimes(3); + }); + + it('should not dispatch on submit if there\'s no message', () => { + render(); + + act(() => { + screen.queryByTestId('message-form-submit').click(); + }); + + expect(acknowledgeDisclosure).not.toHaveBeenCalled(); + expect(addChatMessage).not.toHaveBeenCalled(); + expect(getChatResponse).not.toHaveBeenCalled(); + expect(mockDispatch).not.toHaveBeenCalled(); + }); + }); + + describe('prmpt experiment', () => { + beforeEach(() => { + useDecision.mockReturnValue([{ + active: true, + variationKey: OPTIMIZELY_PROMPT_EXPERIMENT_VARIATION_KEYS.UPDATED_PROMPT, + }]); + }); + + it('should include experiment data on submit', () => { + const currentMessage = 'How much wood could a woodchuck chuck if a woodchuck could chuck wood?'; + const sliceState = { + apiIsLoading: false, + currentMessage, + apiError: false, + }; + + render(undefined, sliceState); + + act(() => { + screen.queryByTestId('message-form-submit').click(); + }); + + expect(acknowledgeDisclosure).toHaveBeenCalledWith(true); + expect(addChatMessage).toHaveBeenCalledWith( + 'user', + currentMessage, + defaultProps.courseId, + OPTIMIZELY_PROMPT_EXPERIMENT_VARIATION_KEYS.UPDATED_PROMPT, + ); + expect(getChatResponse).toHaveBeenCalledWith( + defaultProps.courseId, + defaultProps.unitId, + OPTIMIZELY_PROMPT_EXPERIMENT_VARIATION_KEYS.UPDATED_PROMPT, + ); + expect(mockDispatch).toHaveBeenCalledTimes(3); + }); + }); +}); diff --git a/src/components/Sidebar/index.jsx b/src/components/Sidebar/index.jsx index 8d0b7312..f888156a 100644 --- a/src/components/Sidebar/index.jsx +++ b/src/components/Sidebar/index.jsx @@ -1,7 +1,9 @@ import React, { useRef, useEffect } from 'react'; import { useDispatch, useSelector } from 'react-redux'; import PropTypes from 'prop-types'; +import { useDecision } from '@optimizely/react-sdk'; import { sendTrackEvent } from '@edx/frontend-platform/analytics'; +import { getAuthenticatedUser } from '@edx/frontend-platform/auth'; import { Button, Icon, @@ -10,7 +12,7 @@ import { import { Close } from '@openedx/paragon/icons'; import { clearMessages } from '../../data/thunks'; -import { PROMPT_EXPERIMENT_FLAG, PROMPT_EXPERIMENT_KEY } from '../../constants/experiments'; +import { OPTIMIZELY_PROMPT_EXPERIMENT_KEY, OPTIMIZELY_PROMPT_EXPERIMENT_VARIATION_KEYS } from '../../data/optimizely'; import { showControlSurvey, showVariationSurvey } from '../../utils/surveyMonkey'; import APIError from '../APIError'; @@ -29,12 +31,18 @@ const Sidebar = ({ apiError, disclosureAcknowledged, messageList, - experiments, } = useSelector(state => state.learningAssistant); - const { variationKey } = experiments?.[PROMPT_EXPERIMENT_FLAG] || {}; const chatboxContainerRef = useRef(null); const dispatch = useDispatch(); + const { userId } = getAuthenticatedUser(); + const [decision] = useDecision(OPTIMIZELY_PROMPT_EXPERIMENT_KEY, { autoUpdate: true }, { id: userId.toString() }); + const { active: activeExperiment, variationKey } = decision || {}; + const experimentPayload = activeExperiment ? { + experiment_name: OPTIMIZELY_PROMPT_EXPERIMENT_KEY, + variation_key: variationKey, + } : {}; + // this use effect is intended to scroll to the bottom of the chat window, in the case // that a message is larger than the chat window height. useEffect(() => { @@ -73,7 +81,7 @@ const Sidebar = ({ setIsOpen(false); if (messageList.length >= 2) { - if (variationKey === PROMPT_EXPERIMENT_KEY) { + if (activeExperiment && variationKey === OPTIMIZELY_PROMPT_EXPERIMENT_VARIATION_KEYS.UPDATED_PROMPT) { showVariationSurvey(); } else { showControlSurvey(); @@ -85,7 +93,7 @@ const Sidebar = ({ dispatch(clearMessages()); sendTrackEvent('edx.ui.lms.learning_assistant.clear', { course_id: courseId, - ...(variationKey ? { experiment_name: PROMPT_EXPERIMENT_FLAG, variation_key: variationKey } : {}), + ...experimentPayload, }); }; diff --git a/src/components/Sidebar/index.test.jsx b/src/components/Sidebar/index.test.jsx index fab97f9f..95933f5c 100644 --- a/src/components/Sidebar/index.test.jsx +++ b/src/components/Sidebar/index.test.jsx @@ -1,9 +1,10 @@ import React from 'react'; import { screen, act } from '@testing-library/react'; +import { useDecision } from '@optimizely/react-sdk'; 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 { OPTIMIZELY_PROMPT_EXPERIMENT_KEY, OPTIMIZELY_PROMPT_EXPERIMENT_VARIATION_KEYS } from '../../data/optimizely'; import { showControlSurvey, showVariationSurvey } from '../../utils/surveyMonkey'; import Sidebar from '.'; @@ -17,12 +18,25 @@ jest.mock('@edx/frontend-platform/analytics', () => ({ sendTrackEvent: jest.fn(), })); +const mockedAuthenticatedUser = { userId: 123 }; +jest.mock('@edx/frontend-platform/auth', () => ({ + getAuthenticatedUser: () => mockedAuthenticatedUser, +})); + +jest.mock('@edx/frontend-platform/analytics', () => ({ + sendTrackEvent: jest.fn(), +})); + const mockDispatch = jest.fn(); jest.mock('react-redux', () => ({ ...jest.requireActual('react-redux'), useDispatch: () => mockDispatch, })); +jest.mock('@optimizely/react-sdk', () => ({ + useDecision: jest.fn(), +})); + const clearMessagesAction = 'clear-messages-action'; jest.mock('../../data/thunks', () => ({ clearMessages: () => 'clear-messages-action', @@ -58,6 +72,7 @@ const render = async (props = {}, sliceState = {}) => { describe('', () => { beforeEach(() => { jest.resetAllMocks(); + useDecision.mockReturnValue([]); }); describe('when it\'s open', () => { @@ -106,15 +121,14 @@ describe('', () => { 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', () => { + useDecision.mockReturnValue([{ + active: true, + variationKey: OPTIMIZELY_PROMPT_EXPERIMENT_VARIATION_KEYS.UPDATED_PROMPT, + }]); + render(undefined, defaultState); act(() => { @@ -140,6 +154,11 @@ describe('', () => { }); it('should dispatch clearMessages() and call sendTrackEvent() with the expected props on clear', () => { + useDecision.mockReturnValue([{ + active: true, + variationKey: OPTIMIZELY_PROMPT_EXPERIMENT_VARIATION_KEYS.UPDATED_PROMPT, + }]); + render(undefined, { ...defaultState, disclosureAcknowledged: true, @@ -152,8 +171,8 @@ describe('', () => { 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, + experiment_name: OPTIMIZELY_PROMPT_EXPERIMENT_KEY, + variation_key: OPTIMIZELY_PROMPT_EXPERIMENT_VARIATION_KEYS.UPDATED_PROMPT, }); }); }); diff --git a/src/components/ToggleXpertButton/index.jsx b/src/components/ToggleXpertButton/index.jsx index c49ad5c3..d165af1e 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 { useDecision } from '@optimizely/react-sdk'; import { sendTrackEvent } from '@edx/frontend-platform/analytics'; import { getAuthenticatedUser } from '@edx/frontend-platform/auth'; import { @@ -12,9 +12,9 @@ import { } from '@openedx/paragon'; import { Close } from '@openedx/paragon/icons'; +import { OPTIMIZELY_PROMPT_EXPERIMENT_KEY } from '../../data/optimizely'; import { ReactComponent as XpertLogo } from '../../assets/xpert-logo.svg'; import './index.scss'; -import { PROMPT_EXPERIMENT_FLAG } from '../../constants/experiments'; const ToggleXpert = ({ isOpen, @@ -22,13 +22,18 @@ 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); const { userId } = getAuthenticatedUser(); + const [decision] = useDecision(OPTIMIZELY_PROMPT_EXPERIMENT_KEY, { autoUpdate: true }, { id: userId.toString() }); + const { active, variationKey } = decision || {}; + const experimentPayload = active ? { + experiment_name: OPTIMIZELY_PROMPT_EXPERIMENT_KEY, + variation_key: variationKey, + } : {}; + const handleClick = (event) => { // log event if the tool is opened if (!isOpen) { @@ -38,7 +43,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 } : {}), + ...experimentPayload, }, ); } @@ -55,7 +60,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 } : {}), + ...experimentPayload, }); }; @@ -68,7 +73,7 @@ const ToggleXpert = ({ course_id: courseId, user_id: userId, source: 'product-tour', - ...(variationKey ? { experiment_name: PROMPT_EXPERIMENT_FLAG, variation_key: variationKey } : {}), + ...experimentPayload, }, ); }; diff --git a/src/components/ToggleXpertButton/index.test.jsx b/src/components/ToggleXpertButton/index.test.jsx index 96ee6e1f..d3e3d515 100644 --- a/src/components/ToggleXpertButton/index.test.jsx +++ b/src/components/ToggleXpertButton/index.test.jsx @@ -1,9 +1,10 @@ import React from 'react'; import { screen, waitFor } from '@testing-library/react'; +import { useDecision } from '@optimizely/react-sdk'; 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 { OPTIMIZELY_PROMPT_EXPERIMENT_KEY, OPTIMIZELY_PROMPT_EXPERIMENT_VARIATION_KEYS } from '../../data/optimizely'; import ToggleXpert from '.'; @@ -16,6 +17,10 @@ jest.mock('@edx/frontend-platform/auth', () => ({ getAuthenticatedUser: () => mockedAuthenticatedUser, })); +jest.mock('@optimizely/react-sdk', () => ({ + useDecision: jest.fn(), +})); + const defaultProps = { isOpen: false, setIsOpen: jest.fn(), @@ -47,6 +52,7 @@ describe('', () => { beforeEach(() => { window.localStorage.clear(); jest.clearAllMocks(); + useDecision.mockReturnValue([]); }); describe('when it\'s closed', () => { @@ -113,14 +119,12 @@ describe('', () => { }); describe('prompt experiment', () => { - const defaultState = { - experiments: { - [PROMPT_EXPERIMENT_FLAG]: { - enabled: true, - variationKey: PROMPT_EXPERIMENT_KEY, - }, - }, - }; + beforeEach(() => { + useDecision.mockReturnValue([{ + active: true, + variationKey: OPTIMIZELY_PROMPT_EXPERIMENT_VARIATION_KEYS.UPDATED_PROMPT, + }]); + }); it('should render the component', () => { render(); @@ -128,7 +132,7 @@ describe('', () => { }); it('should track the "Check it out" action', () => { - render(undefined, defaultState); + render(); screen.queryByTestId('check-button').click(); @@ -138,14 +142,14 @@ describe('', () => { course_id: defaultProps.courseId, user_id: mockedAuthenticatedUser.userId, source: 'cta', - experiment_name: PROMPT_EXPERIMENT_FLAG, - variation_key: PROMPT_EXPERIMENT_KEY, + experiment_name: OPTIMIZELY_PROMPT_EXPERIMENT_KEY, + variation_key: OPTIMIZELY_PROMPT_EXPERIMENT_VARIATION_KEYS.UPDATED_PROMPT, }, ); }); it('should track the toggle action', () => { - render(undefined, defaultState); + render(); screen.queryByTestId('toggle-button').click(); @@ -155,14 +159,14 @@ describe('', () => { course_id: defaultProps.courseId, user_id: mockedAuthenticatedUser.userId, source: 'toggle', - experiment_name: PROMPT_EXPERIMENT_FLAG, - variation_key: PROMPT_EXPERIMENT_KEY, + experiment_name: OPTIMIZELY_PROMPT_EXPERIMENT_KEY, + variation_key: OPTIMIZELY_PROMPT_EXPERIMENT_VARIATION_KEYS.UPDATED_PROMPT, }, ); }); it('should track the dismiss action', async () => { - render(undefined, defaultState); + render(); // Show CTA await screen.queryByTestId('check-button').click(); @@ -176,8 +180,8 @@ describe('', () => { 'edx.ui.lms.learning_assistant.dismiss_action_message', { course_id: defaultProps.courseId, - experiment_name: PROMPT_EXPERIMENT_FLAG, - variation_key: PROMPT_EXPERIMENT_KEY, + experiment_name: OPTIMIZELY_PROMPT_EXPERIMENT_KEY, + variation_key: OPTIMIZELY_PROMPT_EXPERIMENT_VARIATION_KEYS.UPDATED_PROMPT, }, ); }); diff --git a/src/constants/experiments.js b/src/constants/experiments.js deleted file mode 100644 index f18efefd..00000000 --- a/src/constants/experiments.js +++ /dev/null @@ -1,7 +0,0 @@ -const PROMPT_EXPERIMENT_FLAG = '_cosmo__xpert_gpt_4_0_prompt'; -const PROMPT_EXPERIMENT_KEY = 'updated_prompt'; - -export { - PROMPT_EXPERIMENT_FLAG, - PROMPT_EXPERIMENT_KEY, -}; diff --git a/src/data/optimizely.js b/src/data/optimizely.js index e9e6e7ba..5b1ad264 100644 --- a/src/data/optimizely.js +++ b/src/data/optimizely.js @@ -1,11 +1,34 @@ -import { - createInstance, -} from '@optimizely/react-sdk'; +import { createInstance } from '@optimizely/react-sdk'; +import { ensureConfig, getConfig } from '@edx/frontend-platform'; -const OPTIMIZELY_SDK_KEY = process.env.OPTIMIZELY_FULL_STACK_SDK_KEY; +ensureConfig([ + 'OPTIMIZELY_FULL_STACK_SDK_KEY', +], 'Frontend Lib Learning Assistant Optimizely module'); -const optimizely = createInstance({ - sdkKey: OPTIMIZELY_SDK_KEY, -}); +/** + * Initializing the Optimizely instance at the top-level will not work, becaused it may be initialized before + * the OPTIMIZELY_FULL_STACK_SDK_KEY is available and will not be reinitialized afterward. Wrapping the initialization + * in a function allows components to request the instance as-needed. + */ +const getOptimizely = () => { + const OPTIMIZELY_SDK_KEY = getConfig().OPTIMIZELY_FULL_STACK_SDK_KEY; -export default optimizely; + if (OPTIMIZELY_SDK_KEY) { + return createInstance({ + sdkKey: OPTIMIZELY_SDK_KEY, + }); + } + + return null; +}; + +const OPTIMIZELY_PROMPT_EXPERIMENT_KEY = '_cosmo__xpert_gpt_4_0_prompt'; +const OPTIMIZELY_PROMPT_EXPERIMENT_VARIATION_KEYS = { + UPDATED_PROMPT: 'updated_prompt', +}; + +export { + getOptimizely, + OPTIMIZELY_PROMPT_EXPERIMENT_KEY, + OPTIMIZELY_PROMPT_EXPERIMENT_VARIATION_KEYS, +}; diff --git a/src/data/optimizely.test.js b/src/data/optimizely.test.js new file mode 100644 index 00000000..4393b1c2 --- /dev/null +++ b/src/data/optimizely.test.js @@ -0,0 +1,23 @@ +import { getConfig } from '@edx/frontend-platform'; + +import { getOptimizely } from './optimizely'; + +const originalConfig = jest.requireActual('@edx/frontend-platform').getConfig(); +jest.mock('@edx/frontend-platform', () => ({ + ...jest.requireActual('@edx/frontend-platform'), + getConfig: jest.fn(), +})); + +getConfig.mockImplementation(() => originalConfig); + +describe('Optimizely', () => { + test('getOptimizely returns null when OPTIMIZELY_FULL_STACK_SDK_KEY config variable is missing', () => { + getConfig.mockImplementation(() => ({})); + expect(getOptimizely()).toEqual(null); + }); + + test('getOptimizely returns null when OPTIMIZELY_FULL_STACK_SDK_KEY config variable is available', () => { + getConfig.mockImplementation(() => originalConfig); + expect(getOptimizely()).not.toEqual(null); + }); +}); diff --git a/src/data/slice.js b/src/data/slice.js index fb8348e9..29dbd402 100644 --- a/src/data/slice.js +++ b/src/data/slice.js @@ -11,7 +11,6 @@ export const initialState = { disclosureAcknowledged: false, sidebarIsOpen: false, isEnabled: false, - experiments: {}, }; export const learningAssistantSlice = createSlice({ @@ -50,16 +49,6 @@ export const learningAssistantSlice = createSlice({ setIsEnabled: (state, { payload }) => { state.isEnabled = payload; }, - setExperiments: (state, { payload }) => { - const { decisions } = payload; - const experiments = {}; - decisions - .filter(({ flagKey, enabled, variationKey }) => flagKey && enabled && variationKey) - .foreach(({ flagKey, enabled, variationKey }) => { - experiments[flagKey] = { enabled, variationKey }; - }); - state.experiments = experiments; - }, }, }); @@ -74,8 +63,6 @@ export const { setDisclosureAcknowledged, setSidebarIsOpen, setIsEnabled, - setExperiments, - clearExperiment, } = learningAssistantSlice.actions; export const { diff --git a/src/data/thunks.js b/src/data/thunks.js index 03f1635d..08cc25fa 100644 --- a/src/data/thunks.js +++ b/src/data/thunks.js @@ -15,12 +15,11 @@ import { setSidebarIsOpen, setIsEnabled, } from './slice'; -import { PROMPT_EXPERIMENT_FLAG } from '../constants/experiments'; +import { OPTIMIZELY_PROMPT_EXPERIMENT_KEY } from './optimizely'; -export function addChatMessage(role, content, courseId) { +export function addChatMessage(role, content, courseId, promptExperimentVariationKey = undefined) { return (dispatch, getState) => { - const { messageList, conversationId, experiments } = getState().learningAssistant; - const { variationKey } = experiments?.[PROMPT_EXPERIMENT_FLAG] || {}; + const { messageList, conversationId } = getState().learningAssistant; // 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. @@ -45,28 +44,29 @@ export function addChatMessage(role, content, courseId) { timestamp: message.timestamp, role: message.role, content: message.content, - ...(variationKey ? { experiment_name: PROMPT_EXPERIMENT_FLAG, variation_key: variationKey } : {}), + ...(promptExperimentVariationKey ? { + experiment_name: OPTIMIZELY_PROMPT_EXPERIMENT_KEY, + variation_key: promptExperimentVariationKey, + } : {}), }); }; } -export function getChatResponse(courseId, unitId) { +export function getChatResponse(courseId, unitId, promptExperimentVariationKey = undefined) { return async (dispatch, getState) => { const { userId } = getAuthenticatedUser(); const { messageList } = getState().learningAssistant; - const { enabled, variationKey } = getState().experiments?.[PROMPT_EXPERIMENT_FLAG] || {}; - dispatch(setApiIsLoading(true)); try { - if (enabled) { + if (promptExperimentVariationKey) { trackChatBotMessageOptimizely(userId); } - const customQueryParams = variationKey ? { responseVariation: variationKey } : {}; + const customQueryParams = promptExperimentVariationKey ? { responseVariation: promptExperimentVariationKey } : {}; const message = await fetchChatResponse(courseId, messageList, unitId, customQueryParams); dispatch(setApiIsLoading(false)); - dispatch(addChatMessage(message.role, message.content, courseId)); + dispatch(addChatMessage(message.role, message.content, courseId, promptExperimentVariationKey)); } catch (error) { dispatch(setApiError()); dispatch(setApiIsLoading(false)); diff --git a/src/hooks/useOptimizelyExperiments.js b/src/hooks/useOptimizelyExperiments.js deleted file mode 100644 index 23601ff2..00000000 --- a/src/hooks/useOptimizelyExperiments.js +++ /dev/null @@ -1,22 +0,0 @@ -import { useEffect } from 'react'; -import { useDispatch } from 'react-redux'; -import { OptimizelyDecideOption } from '@optimizely/react-sdk'; -import { getAuthenticatedUser } from '@edx/frontend-platform/auth'; -import { setExperiments } from '../data/slice'; -import optimizely from '../data/optimizely'; // eslint-disable-line no-unused-vars - -const useOptimizelyExperiments = async () => { - const dispatch = useDispatch(); - const { userId } = getAuthenticatedUser(); - - await optimizely.onReady(); - - useEffect(() => { - const user = optimizely.createUserContext(userId); - const decisions = user.decideAll([OptimizelyDecideOption.ENABLED_FLAGS_ONLY]); - - dispatch(setExperiments({ decisions })); - }, [dispatch, userId]); -}; - -export default useOptimizelyExperiments; diff --git a/src/hooks/useOptimizelyExperiments.test.js b/src/hooks/useOptimizelyExperiments.test.js deleted file mode 100644 index 3f18d4c1..00000000 --- a/src/hooks/useOptimizelyExperiments.test.js +++ /dev/null @@ -1,64 +0,0 @@ -import { renderHook } from '@testing-library/react-hooks'; -// import { useDispatch } from 'react-redux'; -import { useDecision } from '@optimizely/react-sdk'; -import { setExperiments } from '../data/slice'; - -import useOptimizelyExperiment from './useOptimizelyExperiments'; - -const optimizelyFlag = 'some-optimizely-flag'; -const userId = 123; -const mockedDecision = { active: true, variationKey: 'test-key' }; - -jest.mock('../data/optimizely', () => ({})); - -const mockDispatch = jest.fn(); -jest.mock('react-redux', () => ({ - useDispatch: () => mockDispatch, -})); - -jest.mock('@optimizely/react-sdk', () => ({ - useDecision: jest.fn(() => ([mockedDecision])), -})); - -jest.mock('../data/slice', () => ({ - setExperiment: jest.fn(), -})); - -jest.mock( - '@edx/frontend-platform/auth', - () => ({ - getAuthenticatedUser: jest.fn(() => ({ - userId, - })), - }), - { virtual: true }, -); - -describe('useOptimizelyExperiment()', () => { - describe('on normal behavior', () => { - beforeEach(() => { - renderHook(() => useOptimizelyExperiment(optimizelyFlag)); - }); - - it('should call useDecision() with the expected parameters', () => { - expect(useDecision).toHaveBeenCalledWith(optimizelyFlag, { autoUpdate: true }, { id: userId.toString() }); - }); - - it('should call setExperiment() with the expected parameters', () => { - renderHook(() => useOptimizelyExperiment(optimizelyFlag)); - expect(setExperiments).toHaveBeenCalledWith({ flag: optimizelyFlag, ...mockedDecision }); - }); - }); - - describe('if useDecision returns nothing', () => { - beforeEach(() => { - useDecision.mockImplementation(() => []); - renderHook(() => useOptimizelyExperiment(optimizelyFlag)); - }); - - it('should call setExperiment() with undefined flag and variationKey', () => { - renderHook(() => useOptimizelyExperiment(optimizelyFlag)); - expect(setExperiments).toHaveBeenCalledWith({ flag: optimizelyFlag, active: undefined, variationKey: undefined }); - }); - }); -}); diff --git a/src/mocks/optimizely/README.rst b/src/mocks/optimizely/README.rst new file mode 100644 index 00000000..52ef28a3 --- /dev/null +++ b/src/mocks/optimizely/README.rst @@ -0,0 +1,46 @@ +Optimizely Mock Implementation +############################## + +Purpose +======= + +This repository uses the `Optimizely SDK`_ to implement frontend experiments. Using Optimizely requires the use of an +SDK key or datafile. This poses a problem for development locally, because it requires that an `Optimizely environment`_ +exists for local environments. When developing locally, it may be preferable not to rely explicitly on Optimizely and +to reserve testing the Optimizely flow in a staging environment. + +This module contains a mock Optimizely implementation module that allows engineers to hardcode the return +values of the functions of the `Optimizely SDK`_, namely the `useDecision hook`_. + +Usage +===== + +The implementations of the `createInstance function`_ and the `OptimizelyProvider component`_ are no-op pass through +functions. They do not do anything. + +In order to modify the experiment and how your user is bucketed, you will need to modify the `useDecision hook`_ +implementation. Use the React SDK documentation to determine how to modify this function to suit your needs. + +In addition, you will need to update your ``module.config.js`` file in your local checkout of the Learning MFE to +map the ``@optimizely/react-sdk`` module to this mock module. + +A sample ``module.config.js`` file is shown below, but please refer to the documentation for `local module development`_ +in the `Learning MFE`_ for more information. + + +.. code-block:: + + module.exports = { + localModules: [ + { moduleName: '@edx/frontend-lib-learning-assistant', dir: '../src/frontend-lib-learning-assistant', dist: 'src' }, + { moduleName: '@optimizely/react-sdk', dir: '../src/frontend-lib-learning-assistant/src/mocks/optimizely', dist: '.' }, + ], + }; + +.. _createInstance function: https://docs.developers.optimizely.com/feature-experimentation/docs/initialize-sdk-react +.. _Learning MFE: https://github.com/openedx/frontend-app-learning +.. _local module development: https://github.com/openedx/frontend-app-learning#local-module-development +.. _Optimizely environment: https://docs.developers.optimizely.com/feature-experimentation/docs/manage-environments +.. _OptimizelyProvider component: https://docs.developers.optimizely.com/feature-experimentation/docs/optimizelyprovider +.. _Optimizely SDK: https://docs.developers.optimizely.com/feature-experimentation/docs/javascript-react-sdk +.. _useDecision hook: https://docs.developers.optimizely.com/feature-experimentation/docs/usedecision-react diff --git a/src/mocks/optimizely/index.jsx b/src/mocks/optimizely/index.jsx new file mode 100644 index 00000000..468b03d4 --- /dev/null +++ b/src/mocks/optimizely/index.jsx @@ -0,0 +1,19 @@ +/* eslint-disable no-unused-vars */ +/* eslint-disable react/prop-types */ + +const useDecision = (experimentKey) => ( + // To mock optimizely for local smoke testing, set the value of "enabled" to "true" and + // replace "replace_me" with the desired value from OPTIMIZELY_VTR_EXPERIMENT_VARIATION_KEYS + // which can be found in src/data/optimizely.js + [{ enabled: true, variationKey: 'replace_me' }] +); + +const OptimizelyProvider = ({ optimizely, user, children = null }) => children; + +const createInstance = (args) => ({}); + +export { + createInstance, + useDecision, + OptimizelyProvider, +}; diff --git a/src/mocks/optimizely/package.json b/src/mocks/optimizely/package.json new file mode 100644 index 00000000..c345e862 --- /dev/null +++ b/src/mocks/optimizely/package.json @@ -0,0 +1,11 @@ +{ + "name": "optimizely", + "version": "1.0.0", + "description": "Optimizely mock application for local development", + "main": "index.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "author": "edX", + "license": "AGPL-3.0" +} diff --git a/src/optimizely/ExperimentsProvider.jsx b/src/optimizely/ExperimentsProvider.jsx new file mode 100644 index 00000000..9e3dfd87 --- /dev/null +++ b/src/optimizely/ExperimentsProvider.jsx @@ -0,0 +1,28 @@ +import PropTypes from 'prop-types'; +import { getAuthenticatedUser } from '@edx/frontend-platform/auth'; +import { OptimizelyProvider } from '@optimizely/react-sdk'; + +import { getLocale } from '@edx/frontend-platform/i18n'; +import { getOptimizely } from '../data/optimizely'; + +const ExperimentsProvider = ({ children }) => { + const { userId } = getAuthenticatedUser(); + const optimizely = getOptimizely(); + + return ( + {children} + + ); +}; + +ExperimentsProvider.propTypes = { + children: PropTypes.node.isRequired, +}; + +export default ExperimentsProvider; diff --git a/src/optimizely/index.jsx b/src/optimizely/index.jsx new file mode 100644 index 00000000..a94cb61d --- /dev/null +++ b/src/optimizely/index.jsx @@ -0,0 +1,3 @@ +import ExperimentsProvider from './ExperimentsProvider'; + +export default ExperimentsProvider; diff --git a/src/setupTest.js b/src/setupTest.js index 95b3726d..0d1754a5 100644 --- a/src/setupTest.js +++ b/src/setupTest.js @@ -1,3 +1,8 @@ import 'core-js/stable'; import 'regenerator-runtime/runtime'; import '@testing-library/jest-dom'; +import { mergeConfig } from '@edx/frontend-platform'; + +mergeConfig({ + ...process.env, +}); diff --git a/src/utils/optimizelyExperiment.js b/src/utils/optimizelyExperiment.js index 1d192303..e792aef0 100644 --- a/src/utils/optimizelyExperiment.js +++ b/src/utils/optimizelyExperiment.js @@ -1,26 +1,16 @@ -import optimizelyInstance from '../data/optimizely'; - -const PRODUCT_TOUR_EXP_KEY = 'la_product_tour'; -const PRODUCT_TOUR_EXP_VARIATION = 'learning_assistant_product_tour'; - -const activateProductTourExperiment = (userId) => { - const variant = optimizelyInstance.activate( - PRODUCT_TOUR_EXP_KEY, - userId, - ); - return variant === PRODUCT_TOUR_EXP_VARIATION; -}; +import { getOptimizely } from '../data/optimizely'; const trackChatBotLaunchOptimizely = (userId, userAttributes = {}) => { + const optimizelyInstance = getOptimizely(); optimizelyInstance.track('learning_assistant_chat_click', userId, userAttributes); }; const trackChatBotMessageOptimizely = (userId, userAttributes = {}) => { + const optimizelyInstance = getOptimizely(); optimizelyInstance.track('learning_assistant_chat_message', userId, userAttributes); }; export { - activateProductTourExperiment, trackChatBotLaunchOptimizely, trackChatBotMessageOptimizely, }; diff --git a/src/widgets/Xpert.jsx b/src/widgets/Xpert.jsx index a53dcd0e..45fb6d23 100644 --- a/src/widgets/Xpert.jsx +++ b/src/widgets/Xpert.jsx @@ -4,7 +4,7 @@ import { useDispatch, useSelector } from 'react-redux'; import { updateSidebarIsOpen, getIsEnabled } from '../data/thunks'; import ToggleXpert from '../components/ToggleXpertButton'; import Sidebar from '../components/Sidebar'; -import useOptimizelyExperiments from '../hooks/useOptimizelyExperiments'; +import ExperimentsProvider from '../optimizely'; const Xpert = ({ courseId, contentToolsEnabled, unitId }) => { const dispatch = useDispatch(); @@ -14,8 +14,6 @@ const Xpert = ({ courseId, contentToolsEnabled, unitId }) => { sidebarIsOpen, } = useSelector(state => state.learningAssistant); - useOptimizelyExperiments(); - const setSidebarIsOpen = (isOpen) => { dispatch(updateSidebarIsOpen(isOpen)); }; @@ -25,20 +23,22 @@ const Xpert = ({ courseId, contentToolsEnabled, unitId }) => { }, [dispatch, courseId]); return isEnabled ? ( -
- - -
+ + <> + + + + ) : null; }; diff --git a/src/widgets/Xpert.test.jsx b/src/widgets/Xpert.test.jsx index 61690a54..1fe46460 100644 --- a/src/widgets/Xpert.test.jsx +++ b/src/widgets/Xpert.test.jsx @@ -8,37 +8,18 @@ import Xpert from './Xpert'; import * as surveyMonkey from '../utils/surveyMonkey'; import { render, createRandomResponseForTesting } from '../utils/utils.test'; +import { OPTIMIZELY_PROMPT_EXPERIMENT_VARIATION_KEYS } from '../data/optimizely'; jest.mock('@edx/frontend-platform/analytics'); 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 }, -); +jest.mock('@optimizely/react-sdk', () => ({ + useDecision: jest.fn(), +})); + +jest.mock('../optimizely', () => ({ children }) => children); // import useDecision here, after mocking, so that it can be used in tests import { useDecision } from '@optimizely/react-sdk'; // eslint-disable-line @@ -72,6 +53,7 @@ beforeEach(() => { const responseMessage = createRandomResponseForTesting(); jest.spyOn(api, 'default').mockResolvedValue(responseMessage); jest.spyOn(api, 'fetchLearningAssistantEnabled').mockResolvedValue({ enabled: true }); + useDecision.mockReturnValue([]); window.localStorage.clear(); // Popup modal should be ignored for all tests unless explicitly enabled. This is because @@ -428,11 +410,15 @@ test('survey monkey survey should appear after closing sidebar', async () => { 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' }]); + useDecision.mockReturnValue([{ + active: true, + variationKey: OPTIMIZELY_PROMPT_EXPERIMENT_VARIATION_KEYS.UPDATED_PROMPT, + }]); const surveyState = { learningAssistant: {