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: {