fix: course list styling for hidden panel and new endpoint integration (#165)

Co-authored-by: Ben Warzeski <bwarzesk@gmail.com>
This commit is contained in:
Jody Bailey
2023-06-27 16:28:07 +02:00
committed by GitHub
parent 86b1f5df1a
commit 43aa5b088e
23 changed files with 611 additions and 190 deletions

View File

@@ -1,10 +1,15 @@
import { get, stringifyUrl } from 'data/services/lms/utils';
import urls from 'data/services/lms/urls';
export const productRecommendationsUrl = (courseId) => `${urls.api}/learner_recommendations/product_recommendations/${courseId}/`;
export const crossProductAndAmplitudeRecommendationsUrl = (courseId) => `${urls.getApiUrl()}/learner_recommendations/product_recommendations/${courseId}/`;
export const amplitudeRecommendationsUrl = () => `${urls.getApiUrl()}/learner_recommendations/product_recommendations/`;
const fetchProductRecommendations = (courseId) => get(stringifyUrl(productRecommendationsUrl(courseId)));
const fetchCrossProductRecommendations = (courseId) => (
get(stringifyUrl(crossProductAndAmplitudeRecommendationsUrl(courseId)))
);
const fetchAmplitudeRecommendations = () => get(stringifyUrl(amplitudeRecommendationsUrl()));
export default {
fetchProductRecommendations,
fetchCrossProductRecommendations,
fetchAmplitudeRecommendations,
};

View File

@@ -1,5 +1,6 @@
import { get, stringifyUrl } from 'data/services/lms/utils';
import api, { productRecommendationsUrl } from './api';
import api, { crossProductAndAmplitudeRecommendationsUrl, amplitudeRecommendationsUrl } from './api';
jest.mock('data/services/lms/utils', () => ({
stringifyUrl: (...args) => ({ stringifyUrl: args }),
@@ -7,10 +8,18 @@ jest.mock('data/services/lms/utils', () => ({
}));
describe('productRecommendationCourses api', () => {
describe('fetchProductRecommendations', () => {
describe('fetchCrossProductRecommendations', () => {
it('calls get with the correct recommendation courses URL', () => {
expect(api.fetchProductRecommendations('CourseRunKey')).toEqual(
get(stringifyUrl(productRecommendationsUrl('CourseRunKey'))),
expect(api.fetchCrossProductRecommendations('CourseRunKey')).toEqual(
get(stringifyUrl(crossProductAndAmplitudeRecommendationsUrl('CourseRunKey'))),
);
});
});
describe('fetchAmplitudeRecommendations', () => {
it('calls get with the correct recommendation courses URL', () => {
expect(api.fetchAmplitudeRecommendations()).toEqual(
get(stringifyUrl(amplitudeRecommendationsUrl())),
);
});
});

View File

@@ -13,42 +13,56 @@ export const state = StrictDict({
});
export const useShowRecommendationsFooter = () => {
const hasCourses = reduxHooks.useHasCourses();
const hasAvailableDashboards = reduxHooks.useHasAvailableDashboards();
const initIsPending = reduxHooks.useRequestIsPending(RequestKeys.initialize);
const hasRequestCompleted = reduxHooks.useRequestIsCompleted(RequestKeys.initialize);
// Hardcoded to not show until experiment related code is implemented
return !initIsPending && hasCourses && !hasAvailableDashboards && false;
return {
shouldShowFooter: false,
shouldLoadFooter: hasRequestCompleted && !hasAvailableDashboards,
};
};
export const useMostRecentCourseRunKey = () => {
const mostRecentCourse = reduxHooks.useCurrentCourseList({
const mostRecentCourseRunKey = reduxHooks.useCurrentCourseList({
sortBy: SortKeys.enrolled,
filters: [],
pageSize: 0,
}).visible[0].courseRun.courseId;
}).visible[0]?.courseRun?.courseId;
return mostRecentCourse;
return mostRecentCourseRunKey;
};
export const useFetchProductRecommendations = (setRequestState, setData) => {
export const useFetchRecommendations = (setRequestState, setData) => {
const courseRunKey = module.useMostRecentCourseRunKey();
useEffect(() => {
let isMounted = true;
api
.fetchProductRecommendations(courseRunKey)
.then((response) => {
if (isMounted) {
setData(response.data);
setRequestState(RequestStates.completed);
}
})
.catch(() => {
if (isMounted) {
setRequestState(RequestStates.failed);
}
});
const handleSuccess = (response) => {
if (isMounted) {
setData(response.data);
setRequestState(RequestStates.completed);
}
};
const handleError = () => {
if (isMounted) {
setRequestState(RequestStates.failed);
}
};
if (courseRunKey) {
api
.fetchCrossProductRecommendations(courseRunKey)
.then(handleSuccess)
.catch(handleError);
} else {
api
.fetchAmplitudeRecommendations()
.then(handleSuccess)
.catch(handleError);
}
return () => {
isMounted = false;
};
@@ -59,7 +73,7 @@ export const useFetchProductRecommendations = (setRequestState, setData) => {
export const useProductRecommendationsData = () => {
const [requestState, setRequestState] = module.state.requestState(RequestStates.pending);
const [data, setData] = module.state.data({});
module.useFetchProductRecommendations(setRequestState, setData);
module.useFetchRecommendations(setRequestState, setData);
return {
productRecommendations: data,

View File

@@ -10,15 +10,15 @@ import api from './api';
import * as hooks from './hooks';
jest.mock('./api', () => ({
fetchProductRecommendations: jest.fn(),
fetchCrossProductRecommendations: jest.fn(),
fetchAmplitudeRecommendations: jest.fn(),
}));
jest.mock('hooks', () => ({
reduxHooks: {
useCurrentCourseList: jest.fn(),
useHasCourses: jest.fn(),
useHasAvailableDashboards: jest.fn(),
useRequestIsPending: jest.fn(),
useRequestIsCompleted: jest.fn(),
},
}));
@@ -38,11 +38,16 @@ const courses = [
},
];
const courseListData = {
const populatedCourseListData = {
visible: courses,
numPages: 0,
};
const emptyCourseListData = {
visible: [],
numPages: 0,
};
let output;
describe('ProductRecommendations hooks', () => {
beforeEach(() => {
@@ -56,7 +61,7 @@ describe('ProductRecommendations hooks', () => {
describe('useMostRecentCourseRunKey', () => {
it('returns the courseId of the first course in the sorted visible array', () => {
reduxHooks.useCurrentCourseList.mockReturnValueOnce(courseListData);
reduxHooks.useCurrentCourseList.mockReturnValueOnce(populatedCourseListData);
expect(hooks.useMostRecentCourseRunKey()).toBe(mostRecentCourseRunKey);
});
@@ -64,16 +69,17 @@ describe('ProductRecommendations hooks', () => {
describe('useShowRecommendationsFooter', () => {
// TODO: Update when hardcoded value is removed
it('returns whether the footer widget should show', () => {
reduxHooks.useHasCourses.mockReturnValueOnce(true);
it('returns whether the footer widget should show and should load', () => {
reduxHooks.useHasAvailableDashboards.mockReturnValueOnce(false);
reduxHooks.useRequestIsPending.mockReturnValueOnce(false);
reduxHooks.useRequestIsCompleted.mockReturnValueOnce(true);
const { shouldShowFooter, shouldLoadFooter } = hooks.useShowRecommendationsFooter();
expect(hooks.useShowRecommendationsFooter()).toBeFalsy();
expect(shouldShowFooter).toBeFalsy();
expect(shouldLoadFooter).toBeTruthy();
});
});
describe('useFetchProductRecommendations', () => {
describe('useFetchRecommendations', () => {
describe('behavior', () => {
describe('useEffect call', () => {
let calls;
@@ -81,86 +87,176 @@ describe('ProductRecommendations hooks', () => {
const response = { data: 'response data' };
const setRequestState = jest.fn();
const setData = jest.fn();
beforeEach(() => {
reduxHooks.useCurrentCourseList.mockReturnValue(courseListData);
hooks.useFetchProductRecommendations(setRequestState, setData);
const setUp = (mockCourseListData) => {
reduxHooks.useCurrentCourseList.mockReturnValue(mockCourseListData);
hooks.useFetchRecommendations(setRequestState, setData);
({ calls } = React.useEffect.mock);
([[cb]] = calls);
});
};
it('calls useEffect once', () => {
setUp(populatedCourseListData);
expect(calls.length).toEqual(1);
});
it('calls fetchProductRecommendations with the most recently enrolled courseId', () => {
api.fetchProductRecommendations.mockReturnValueOnce(Promise.resolve(response));
cb();
expect(api.fetchProductRecommendations).toHaveBeenCalledWith(mostRecentCourseRunKey);
});
describe('successful fetch on mounted component', () => {
it('sets the request state to completed and loads response', async () => {
let resolveFn;
api.fetchProductRecommendations.mockReturnValueOnce(new Promise(resolve => {
resolveFn = resolve;
}));
describe('without no courseId due to no enrolled courses', () => {
it('calls fetchAmplitudeRecommendations', () => {
setUp(emptyCourseListData);
api.fetchAmplitudeRecommendations.mockReturnValueOnce(Promise.resolve(response));
cb();
expect(api.fetchProductRecommendations).toHaveBeenCalledWith(mostRecentCourseRunKey);
expect(setRequestState).not.toHaveBeenCalled();
expect(setData).not.toHaveBeenCalled();
resolveFn(response);
await waitFor(() => {
expect(setRequestState).toHaveBeenCalledWith(RequestStates.completed);
expect(setData).toHaveBeenCalledWith(response.data);
expect(api.fetchAmplitudeRecommendations).toHaveBeenCalled();
});
});
describe('with most recently enrolled courseId', () => {
it('calls fetchCrossProductRecommendations with the most recently enrolled courseId', () => {
setUp(populatedCourseListData);
api.fetchCrossProductRecommendations.mockReturnValueOnce(Promise.resolve(response));
cb();
expect(api.fetchCrossProductRecommendations).toHaveBeenCalledWith(mostRecentCourseRunKey);
});
});
describe('fetching cross product recommendations', () => {
beforeEach(() => setUp(populatedCourseListData));
describe('successful fetch on mounted component', () => {
it('sets the request state to completed and loads response', async () => {
let resolveFn;
api.fetchCrossProductRecommendations.mockReturnValueOnce(new Promise(resolve => {
resolveFn = resolve;
}));
cb();
expect(api.fetchCrossProductRecommendations).toHaveBeenCalledWith(mostRecentCourseRunKey);
expect(setRequestState).not.toHaveBeenCalled();
expect(setData).not.toHaveBeenCalled();
resolveFn(response);
await waitFor(() => {
expect(setRequestState).toHaveBeenCalledWith(RequestStates.completed);
expect(setData).toHaveBeenCalledWith(response.data);
});
});
});
});
describe('successful fetch on unmounted component', () => {
it('does not set the state', async () => {
let resolveFn;
api.fetchProductRecommendations.mockReturnValueOnce(new Promise(resolve => {
resolveFn = resolve;
}));
const unMount = cb();
expect(api.fetchProductRecommendations).toHaveBeenCalledWith(mostRecentCourseRunKey);
expect(setRequestState).not.toHaveBeenCalled();
expect(setData).not.toHaveBeenCalled();
unMount();
resolveFn(response);
await wait(10);
expect(setRequestState).not.toHaveBeenCalled();
expect(setData).not.toHaveBeenCalled();
describe('successful fetch on unmounted component', () => {
it('does not set the state', async () => {
let resolveFn;
api.fetchCrossProductRecommendations.mockReturnValueOnce(new Promise(resolve => {
resolveFn = resolve;
}));
const unMount = cb();
expect(api.fetchCrossProductRecommendations).toHaveBeenCalledWith(mostRecentCourseRunKey);
expect(setRequestState).not.toHaveBeenCalled();
expect(setData).not.toHaveBeenCalled();
unMount();
resolveFn(response);
await wait(10);
expect(setRequestState).not.toHaveBeenCalled();
expect(setData).not.toHaveBeenCalled();
});
});
});
describe('unsuccessful fetch on mounted component', () => {
it('sets the request state to failed and does not set the data state', async () => {
let rejectFn;
api.fetchProductRecommendations.mockReturnValueOnce(new Promise((resolve, reject) => {
rejectFn = reject;
}));
cb();
expect(api.fetchProductRecommendations).toHaveBeenCalledWith(mostRecentCourseRunKey);
expect(setRequestState).not.toHaveBeenCalled();
expect(setData).not.toHaveBeenCalled();
rejectFn();
await waitFor(() => {
expect(setRequestState).toHaveBeenCalledWith(RequestStates.failed);
describe('unsuccessful fetch on mounted component', () => {
it('sets the request state to failed and does not set the data state', async () => {
let rejectFn;
api.fetchCrossProductRecommendations.mockReturnValueOnce(new Promise((resolve, reject) => {
rejectFn = reject;
}));
cb();
expect(api.fetchCrossProductRecommendations).toHaveBeenCalledWith(mostRecentCourseRunKey);
expect(setRequestState).not.toHaveBeenCalled();
expect(setData).not.toHaveBeenCalled();
rejectFn();
await waitFor(() => {
expect(setRequestState).toHaveBeenCalledWith(RequestStates.failed);
expect(setData).not.toHaveBeenCalled();
});
});
});
describe('unsuccessful fetch on unmounted component', () => {
it('does not set the state', async () => {
let rejectFn;
api.fetchCrossProductRecommendations.mockReturnValueOnce(new Promise((resolve, reject) => {
rejectFn = reject;
}));
const unMount = cb();
expect(api.fetchCrossProductRecommendations).toHaveBeenCalledWith(mostRecentCourseRunKey);
expect(setRequestState).not.toHaveBeenCalled();
expect(setData).not.toHaveBeenCalled();
unMount();
rejectFn();
await wait(10);
expect(setRequestState).not.toHaveBeenCalled();
expect(setData).not.toHaveBeenCalled();
});
});
});
describe('unsuccessful fetch on unmounted component', () => {
it('does not set the state', async () => {
let rejectFn;
api.fetchProductRecommendations.mockReturnValueOnce(new Promise((resolve, reject) => {
rejectFn = reject;
}));
const unMount = cb();
expect(api.fetchProductRecommendations).toHaveBeenCalledWith(mostRecentCourseRunKey);
expect(setRequestState).not.toHaveBeenCalled();
expect(setData).not.toHaveBeenCalled();
unMount();
rejectFn();
await wait(10);
expect(setRequestState).not.toHaveBeenCalled();
expect(setData).not.toHaveBeenCalled();
describe('fetching Amplitude recommendations', () => {
beforeEach(() => setUp(emptyCourseListData));
describe('successful fetch on mounted component', () => {
it('sets the request state to completed and loads response', async () => {
let resolveFn;
api.fetchAmplitudeRecommendations.mockReturnValueOnce(new Promise(resolve => {
resolveFn = resolve;
}));
cb();
expect(api.fetchAmplitudeRecommendations).toHaveBeenCalled();
expect(setRequestState).not.toHaveBeenCalled();
expect(setData).not.toHaveBeenCalled();
resolveFn(response);
await waitFor(() => {
expect(setRequestState).toHaveBeenCalledWith(RequestStates.completed);
expect(setData).toHaveBeenCalledWith(response.data);
});
});
});
describe('successful fetch on unmounted component', () => {
it('does not set the state', async () => {
let resolveFn;
api.fetchAmplitudeRecommendations.mockReturnValueOnce(new Promise(resolve => {
resolveFn = resolve;
}));
const unMount = cb();
expect(api.fetchAmplitudeRecommendations).toHaveBeenCalled();
expect(setRequestState).not.toHaveBeenCalled();
expect(setData).not.toHaveBeenCalled();
unMount();
resolveFn(response);
await wait(10);
expect(setRequestState).not.toHaveBeenCalled();
expect(setData).not.toHaveBeenCalled();
});
});
describe('unsuccessful fetch on mounted component', () => {
it('sets the request state to failed and does not set the data state', async () => {
let rejectFn;
api.fetchAmplitudeRecommendations.mockReturnValueOnce(new Promise((resolve, reject) => {
rejectFn = reject;
}));
cb();
expect(api.fetchAmplitudeRecommendations).toHaveBeenCalled();
expect(setRequestState).not.toHaveBeenCalled();
expect(setData).not.toHaveBeenCalled();
rejectFn();
await waitFor(() => {
expect(setRequestState).toHaveBeenCalledWith(RequestStates.failed);
expect(setData).not.toHaveBeenCalled();
});
});
});
describe('unsuccessful fetch on unmounted component', () => {
it('does not set the state', async () => {
let rejectFn;
api.fetchAmplitudeRecommendations.mockReturnValueOnce(new Promise((resolve, reject) => {
rejectFn = reject;
}));
const unMount = cb();
expect(api.fetchAmplitudeRecommendations).toHaveBeenCalled();
expect(setRequestState).not.toHaveBeenCalled();
expect(setData).not.toHaveBeenCalled();
unMount();
rejectFn();
await wait(10);
expect(setRequestState).not.toHaveBeenCalled();
expect(setData).not.toHaveBeenCalled();
});
});
});
});
@@ -170,10 +266,10 @@ describe('ProductRecommendations hooks', () => {
let fetchSpy;
beforeEach(() => {
state.mock();
fetchSpy = jest.spyOn(hooks, 'useFetchProductRecommendations').mockImplementationOnce(() => {});
fetchSpy = jest.spyOn(hooks, 'useFetchRecommendations').mockImplementationOnce(() => {});
output = hooks.useProductRecommendationsData();
});
it('calls useFetchProductRecommendations with setRequestState and setData', () => {
it('calls useFetchRecommendations with setRequestState and setData', () => {
expect(fetchSpy).toHaveBeenCalledWith(state.setState.requestState, state.setState.data);
});
it('initializes requestState as RequestStates.pending', () => {

View File

@@ -18,7 +18,7 @@ const ProductRecommendations = () => {
return (
<LoadedView
openCourses={productRecommendations.amplitudeCourses}
crossProductCourses={productRecommendations.crossProductCourses}
crossProductCourses={productRecommendations.crossProductCourses || []}
/>
);
}

View File

@@ -6,7 +6,7 @@ import hooks from './hooks';
import ProductRecommendations from './index';
import LoadingView from './components/LoadingView';
import LoadedView from './components/LoadedView';
import { mockResponse } from './testData';
import { mockCrossProductResponse, mockAmplitudeResponse } from './testData';
jest.mock('./hooks', () => ({
useProductRecommendationsData: jest.fn(),
@@ -25,7 +25,7 @@ describe('ProductRecommendations', () => {
const successfullLoadValues = {
...defaultValues,
isLoaded: true,
productRecommendations: mockResponse,
productRecommendations: mockCrossProductResponse,
};
const desktopWindowSize = {
@@ -41,7 +41,7 @@ describe('ProductRecommendations', () => {
expect(shallow(<ProductRecommendations />)).toMatchSnapshot();
});
it('renders the LoadedView with course data if the request completed', () => {
it('renders the LoadedView with cross product data if the request completed', () => {
useWindowSize.mockReturnValueOnce(desktopWindowSize);
hooks.useProductRecommendationsData.mockReturnValueOnce({
...successfullLoadValues,
@@ -50,8 +50,24 @@ describe('ProductRecommendations', () => {
expect(shallow(<ProductRecommendations />)).toMatchObject(
shallow(
<LoadedView
openCourses={mockResponse.amplitudeCourses}
crossProductCourses={mockResponse.crossProductCourses}
openCourses={mockCrossProductResponse.amplitudeCourses}
crossProductCourses={mockCrossProductResponse.crossProductCourses}
/>,
),
);
});
it('renders the LoadedView with Amplitude course data if the request completed', () => {
useWindowSize.mockReturnValueOnce(desktopWindowSize);
hooks.useProductRecommendationsData.mockReturnValueOnce({
...successfullLoadValues,
productRecommendations: mockAmplitudeResponse,
});
expect(shallow(<ProductRecommendations />)).toMatchObject(
shallow(
<LoadedView
openCourses={mockCrossProductResponse.amplitudeCourses}
crossProductCourses={[]}
/>,
),
);

View File

@@ -22,10 +22,21 @@ export const getCoursesWithType = (courseTypes) => {
return courses;
};
export const mockFooterRecommendationsHook = {
showAndLoad: { shouldShowFooter: true, shouldLoadFooter: true },
showDontLoad: { shouldShowFooter: true, shouldLoadFooter: false },
loadDontShow: { shouldShowFooter: false, shouldLoadFooter: true },
dontShowOrLoad: { shouldShowFooter: false, shouldLoadFooter: false },
};
export const mockCrossProductCourses = getCoursesWithType(['executive-education-2u', 'bootcamp-2u']);
export const mockOpenCourses = getCoursesWithType(['verified-audit', 'audit', 'verified', 'course']);
export const mockResponse = {
export const mockCrossProductResponse = {
crossProductCourses: mockCrossProductCourses,
amplitudeCourses: mockOpenCourses,
};
export const mockAmplitudeResponse = {
amplitudeCourses: mockOpenCourses,
};