Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Implemented product recommendations experiment #174

Merged
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