Skip to content

Commit

Permalink
feat: Implemented product recommendations experiment (#174)
Browse files Browse the repository at this point in the history
  • Loading branch information
JodyBaileyy authored Jul 11, 2023
1 parent 58c3720 commit 103a676
Show file tree
Hide file tree
Showing 32 changed files with 845 additions and 154 deletions.
7 changes: 6 additions & 1 deletion src/App.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
import { reduxHooks } from 'hooks';
import Dashboard from 'containers/Dashboard';
import ZendeskFab from 'components/ZendeskFab';
import { ExperimentProvider } from 'ExperimentContext';

import track from 'tracking';

Expand Down Expand Up @@ -84,7 +85,11 @@ export const App = () => {
<Alert variant="danger">
<ErrorPage message={formatMessage(messages.errorMessage, { supportEmail })} />
</Alert>
) : (<Dashboard />)}
) : (
<ExperimentProvider>
<Dashboard />
</ExperimentProvider>
)}
</main>
<Footer logo={process.env.LOGO_POWERED_BY_OPEN_EDX_URL_SVG} />
<ZendeskFab />
Expand Down
6 changes: 5 additions & 1 deletion src/App.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { RequestKeys } from 'data/constants/requests';
import { reduxHooks } from 'hooks';
import Dashboard from 'containers/Dashboard';
import LearnerDashboardHeader from 'containers/LearnerDashboardHeader';
import { ExperimentProvider } from 'ExperimentContext';
import { App } from './App';
import messages from './messages';

Expand All @@ -21,6 +22,9 @@ jest.mock('@edx/frontend-component-footer', () => 'Footer');
jest.mock('containers/Dashboard', () => 'Dashboard');
jest.mock('containers/LearnerDashboardHeader', () => 'LearnerDashboardHeader');
jest.mock('components/ZendeskFab', () => 'ZendeskFab');
jest.mock('ExperimentContext', () => ({
ExperimentProvider: 'ExperimentProvider',
}));
jest.mock('data/redux', () => ({
selectors: 'redux.selectors',
actions: 'redux.actions',
Expand Down Expand Up @@ -71,7 +75,7 @@ describe('App router component', () => {
runBasicTests();
it('loads dashboard', () => {
expect(el.find('main')).toMatchObject(shallow(
<main><Dashboard /></main>,
<main><ExperimentProvider><Dashboard /></ExperimentProvider></main>,
));
});
});
Expand Down
64 changes: 64 additions & 0 deletions src/ExperimentContext.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import React from 'react';
import PropTypes from 'prop-types';
import { useWindowSize, breakpoints } from '@edx/paragon';
import { StrictDict } from 'utils';
import api from 'widgets/ProductRecommendations/api';
import * as module from './ExperimentContext';

export const state = StrictDict({
experiment: (val) => React.useState(val), // eslint-disable-line
countryCode: (val) => React.useState(val), // eslint-disable-line
});

export const useCountryCode = (setCountryCode) => {
React.useEffect(() => {
api
.fetchRecommendationsContext()
.then((response) => {
setCountryCode(response.data.countryCode);
})
.catch(() => {
setCountryCode('');
});
/* eslint-disable */
}, []);
};

export const ExperimentContext = React.createContext();

export const ExperimentProvider = ({ children }) => {
const [countryCode, setCountryCode] = module.state.countryCode(null);
const [experiment, setExperiment] = module.state.experiment({
isExperimentActive: false,
inRecommendationsVariant: true,
});

module.useCountryCode(setCountryCode);
const { width } = useWindowSize();
const isMobile = width < breakpoints.small.minWidth;

const contextValue = React.useMemo(
() => ({
experiment,
countryCode,
setExperiment,
setCountryCode,
isMobile,
}),
[experiment, countryCode, setExperiment, setCountryCode, isMobile]
);

return (
<ExperimentContext.Provider value={contextValue}>
{children}
</ExperimentContext.Provider>
);
};

export const useExperimentContext = () => React.useContext(ExperimentContext);

ExperimentProvider.propTypes = {
children: PropTypes.node.isRequired,
};

export default { useCountryCode, useExperimentContext };
123 changes: 123 additions & 0 deletions src/ExperimentContext.test.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import React from 'react';
import { mount } from 'enzyme';
import { waitFor } from '@testing-library/react';
import { useWindowSize } from '@edx/paragon';

import api from 'widgets/ProductRecommendations/api';
import { MockUseState } from 'testUtils';

import * as experiment from 'ExperimentContext';

const state = new MockUseState(experiment);

jest.unmock('react');
jest.spyOn(React, 'useEffect').mockImplementation((cb, prereqs) => ({ useEffect: { cb, prereqs } }));

jest.mock('widgets/ProductRecommendations/api', () => ({
fetchRecommendationsContext: jest.fn(),
}));

describe('experiments context', () => {
beforeEach(() => {
jest.resetAllMocks();
});

describe('useCountryCode', () => {
describe('behaviour', () => {
describe('useEffect call', () => {
let calls;
let cb;
const setCountryCode = jest.fn();
const successfulFetch = { data: { countryCode: 'ZA' } };

beforeEach(() => {
experiment.useCountryCode(setCountryCode);

({ calls } = React.useEffect.mock);
[[cb]] = calls;
});

it('calls useEffect once', () => {
expect(calls.length).toEqual(1);
});
describe('successfull fetch', () => {
it('sets the country code', async () => {
let resolveFn;
api.fetchRecommendationsContext.mockReturnValueOnce(
new Promise((resolve) => {
resolveFn = resolve;
}),
);

cb();
expect(api.fetchRecommendationsContext).toHaveBeenCalled();
expect(setCountryCode).not.toHaveBeenCalled();
resolveFn(successfulFetch);
await waitFor(() => {
expect(setCountryCode).toHaveBeenCalledWith(successfulFetch.data.countryCode);
});
});
});
describe('unsuccessfull fetch', () => {
it('sets the country code to an empty string', async () => {
let rejectFn;
api.fetchRecommendationsContext.mockReturnValueOnce(
new Promise((resolve, reject) => {
rejectFn = reject;
}),
);
cb();
expect(api.fetchRecommendationsContext).toHaveBeenCalled();
expect(setCountryCode).not.toHaveBeenCalled();
rejectFn();
await waitFor(() => {
expect(setCountryCode).toHaveBeenCalledWith('');
});
});
});
});
});
});

describe('ExperimentProvider', () => {
const { ExperimentProvider } = experiment;

const TestComponent = () => {
const {
experiment: exp,
setExperiment,
countryCode,
setCountryCode,
isMobile,
} = experiment.useExperimentContext();

expect(exp.isExperimentActive).toBeFalsy();
expect(exp.inRecommendationsVariant).toBeTruthy();
expect(countryCode).toBeNull();
expect(isMobile).toBe(false);
expect(setExperiment).toBeDefined();
expect(setCountryCode).toBeDefined();

return (
<div />
);
};

it('allows access to child components with the context stateful values', () => {
const countryCodeSpy = jest.spyOn(experiment, 'useCountryCode').mockImplementationOnce(() => {});
useWindowSize.mockImplementationOnce(() => ({ width: 577, height: 943 }));

state.mock();

mount(
<ExperimentProvider>
<TestComponent />
</ExperimentProvider>,
);

expect(countryCodeSpy).toHaveBeenCalledWith(state.setState.countryCode);
state.expectInitializedWith(state.keys.countryCode, null);
state.expectInitializedWith(state.keys.experiment, { isExperimentActive: false, inRecommendationsVariant: true });
});
});
});
4 changes: 3 additions & 1 deletion src/__snapshots__/App.test.jsx.snap
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,9 @@ exports[`App router component component no network failure snapshot 1`] = `
<div>
<LearnerDashboardHeader />
<main>
<Dashboard />
<ExperimentProvider>
<Dashboard />
</ExperimentProvider>
</main>
<Footer
logo="fakeLogo.png"
Expand Down
4 changes: 2 additions & 2 deletions src/containers/WidgetContainers/LoadedSidebar/index.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,9 @@ import RecommendationsPanel from 'widgets/RecommendationsPanel';
import hooks from 'widgets/ProductRecommendations/hooks';

export const WidgetSidebar = ({ setSidebarShowing }) => {
const { shouldShowFooter } = hooks.useShowRecommendationsFooter();
const { inRecommendationsVariant, isExperimentActive } = hooks.useShowRecommendationsFooter();

if (!shouldShowFooter) {
if (!inRecommendationsVariant && isExperimentActive) {
setSidebarShowing(true);

return (
Expand Down
18 changes: 15 additions & 3 deletions src/containers/WidgetContainers/LoadedSidebar/index.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,19 +18,31 @@ describe('WidgetSidebar', () => {
describe('snapshots', () => {
test('default', () => {
hooks.useShowRecommendationsFooter.mockReturnValueOnce(
mockFooterRecommendationsHook.dontShowOrLoad,
mockFooterRecommendationsHook.activeControl,
);
const wrapper = shallow(<WidgetSidebar {...props} />);

expect(props.setSidebarShowing).toHaveBeenCalledWith(true);
expect(wrapper).toMatchSnapshot();
});
});

test('is hidden if footer is shown', () => {
test('is hidden when the has the default values', () => {
hooks.useShowRecommendationsFooter.mockReturnValueOnce(
mockFooterRecommendationsHook.default,
);
const wrapper = shallow(<WidgetSidebar {...props} />);

expect(props.setSidebarShowing).not.toHaveBeenCalled();
expect(wrapper.type()).toBeNull();
});

test('is hidden when the has the treatment values', () => {
hooks.useShowRecommendationsFooter.mockReturnValueOnce(
mockFooterRecommendationsHook.showDontLoad,
mockFooterRecommendationsHook.activeTreatment,
);
const wrapper = shallow(<WidgetSidebar {...props} />);

expect(props.setSidebarShowing).not.toHaveBeenCalled();
expect(wrapper.type()).toBeNull();
});
Expand Down
4 changes: 2 additions & 2 deletions src/containers/WidgetContainers/NoCoursesSidebar/index.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,9 @@ import RecommendationsPanel from 'widgets/RecommendationsPanel';
import hooks from 'widgets/ProductRecommendations/hooks';

export const WidgetSidebar = ({ setSidebarShowing }) => {
const { shouldShowFooter } = hooks.useShowRecommendationsFooter();
const { inRecommendationsVariant, isExperimentActive } = hooks.useShowRecommendationsFooter();

if (!shouldShowFooter) {
if (!inRecommendationsVariant && isExperimentActive) {
setSidebarShowing(true);

return (
Expand Down
18 changes: 15 additions & 3 deletions src/containers/WidgetContainers/NoCoursesSidebar/index.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,19 +18,31 @@ describe('WidgetSidebar', () => {
describe('snapshots', () => {
test('default', () => {
hooks.useShowRecommendationsFooter.mockReturnValueOnce(
mockFooterRecommendationsHook.dontShowOrLoad,
mockFooterRecommendationsHook.activeControl,
);
const wrapper = shallow(<WidgetSidebar {...props} />);

expect(props.setSidebarShowing).toHaveBeenCalledWith(true);
expect(wrapper).toMatchSnapshot();
});
});

test('is hidden if footer is shown', () => {
test('is hidden when the has the default values', () => {
hooks.useShowRecommendationsFooter.mockReturnValueOnce(
mockFooterRecommendationsHook.default,
);
const wrapper = shallow(<WidgetSidebar {...props} />);

expect(props.setSidebarShowing).not.toHaveBeenCalled();
expect(wrapper.type()).toBeNull();
});

test('is hidden when the has the treatment values', () => {
hooks.useShowRecommendationsFooter.mockReturnValueOnce(
mockFooterRecommendationsHook.showDontLoad,
mockFooterRecommendationsHook.activeTreatment,
);
const wrapper = shallow(<WidgetSidebar {...props} />);

expect(props.setSidebarShowing).not.toHaveBeenCalled();
expect(wrapper.type()).toBeNull();
});
Expand Down
5 changes: 3 additions & 2 deletions src/containers/WidgetContainers/WidgetFooter/index.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,10 @@ import ProductRecommendations from 'widgets/ProductRecommendations';
import hooks from 'widgets/ProductRecommendations/hooks';

export const WidgetFooter = () => {
const { shouldShowFooter, shouldLoadFooter } = hooks.useShowRecommendationsFooter();
hooks.useActivateRecommendationsExperiment();
const { inRecommendationsVariant, isExperimentActive } = hooks.useShowRecommendationsFooter();

if (shouldShowFooter && shouldLoadFooter) {
if (inRecommendationsVariant && isExperimentActive) {
return (
<div className="widget-footer">
<ProductRecommendations />
Expand Down
Loading

0 comments on commit 103a676

Please sign in to comment.