Compare commits
5 Commits
master
...
djoy/id_re
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d997431382 | ||
|
|
aca3519a7d | ||
|
|
1b38b02b9c | ||
|
|
8967edee8d | ||
|
|
2c9fcce01e |
@@ -6,4 +6,4 @@ https://redux.js.org/faq/organizing-state#how-do-i-organize-nested-or-duplicate-
|
|||||||
|
|
||||||
Different modules of the application maintain individual/lists of IDs that reference data stored in the model store. These are akin to indices in a database, in that they allow you to quickly extract data from the model store without iteration or filtering.
|
Different modules of the application maintain individual/lists of IDs that reference data stored in the model store. These are akin to indices in a database, in that they allow you to quickly extract data from the model store without iteration or filtering.
|
||||||
|
|
||||||
A common pattern when loading data from an API endpoint is to use the model-store's redux actions (addModel, updateModel, etc.) to load the "models" themselves into the model store by ID, and then dispatch another action to save references elsewhere in the redux store to the data that was just added. When adding courses, sequences, etc., to model-store, we also save the courseId and sequenceId in the 'courseware' part of redux. This means the courseware React Components can extract the data from the model-store quickly by using the courseId as a key: `state.models.courses[state.courseware.courseId]`. For an array, it iterates once over the ID list in order to extract the models from model-store. This iteration is done when React components' re-render, and can be done less often through memoization as necessary.
|
A common pattern when loading data from an API endpoint is to use the model-store's redux actions (addModel, updateModel, etc.) to load the "models" themselves into the model store by ID, and then dispatch another action to save references elsewhere in the redux store to the data that was just added. When adding courses, sequences, etc., to model-store, we also save the courseId and sequenceId in the 'courseware' part of redux. This means the courseware React Components can extract the data from the model-store quickly by using the courseId as a key: `state.models.courses[state.activeCourse.courseId]`. For an array, it iterates once over the ID list in order to extract the models from model-store. This iteration is done when React components' re-render, and can be done less often through memoization as necessary.
|
||||||
|
|||||||
8
src/active-course/data/selectors.js
Normal file
8
src/active-course/data/selectors.js
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
/* eslint-disable import/prefer-default-export */
|
||||||
|
import { createSelector } from 'reselect';
|
||||||
|
|
||||||
|
export const activeCourseSelector = createSelector(
|
||||||
|
(state) => state.models.courses || {},
|
||||||
|
(state) => state.activeCourse.courseId,
|
||||||
|
(coursesById, courseId) => (coursesById[courseId] ? coursesById[courseId] : null),
|
||||||
|
);
|
||||||
44
src/active-course/data/slice.js
Normal file
44
src/active-course/data/slice.js
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
/* eslint-disable no-param-reassign */
|
||||||
|
import { createSlice } from '@reduxjs/toolkit';
|
||||||
|
|
||||||
|
export const COURSE_LOADING = 'loading';
|
||||||
|
export const COURSE_LOADED = 'loaded';
|
||||||
|
export const COURSE_FAILED = 'failed';
|
||||||
|
export const COURSE_DENIED = 'denied';
|
||||||
|
|
||||||
|
const slice = createSlice({
|
||||||
|
name: 'activeCourse',
|
||||||
|
initialState: {
|
||||||
|
courseStatus: COURSE_LOADING,
|
||||||
|
courseId: null,
|
||||||
|
},
|
||||||
|
reducers: {
|
||||||
|
fetchCourseRequest: (state, { payload }) => {
|
||||||
|
state.courseId = payload.courseId;
|
||||||
|
state.courseStatus = COURSE_LOADING;
|
||||||
|
},
|
||||||
|
fetchCourseSuccess: (state, { payload }) => {
|
||||||
|
state.courseId = payload.courseId;
|
||||||
|
state.courseStatus = COURSE_LOADED;
|
||||||
|
},
|
||||||
|
fetchCourseFailure: (state, { payload }) => {
|
||||||
|
state.courseId = payload.courseId;
|
||||||
|
state.courseStatus = COURSE_FAILED;
|
||||||
|
},
|
||||||
|
fetchCourseDenied: (state, { payload }) => {
|
||||||
|
state.courseId = payload.courseId;
|
||||||
|
state.courseStatus = COURSE_DENIED;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const {
|
||||||
|
fetchCourseRequest,
|
||||||
|
fetchCourseSuccess,
|
||||||
|
fetchCourseFailure,
|
||||||
|
fetchCourseDenied,
|
||||||
|
} = slice.actions;
|
||||||
|
|
||||||
|
export const {
|
||||||
|
reducer,
|
||||||
|
} = slice;
|
||||||
12
src/active-course/index.js
Normal file
12
src/active-course/index.js
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
export { activeCourseSelector } from './data/selectors';
|
||||||
|
export {
|
||||||
|
reducer,
|
||||||
|
COURSE_LOADING,
|
||||||
|
COURSE_LOADED,
|
||||||
|
COURSE_FAILED,
|
||||||
|
COURSE_DENIED,
|
||||||
|
fetchCourseRequest,
|
||||||
|
fetchCourseSuccess,
|
||||||
|
fetchCourseFailure,
|
||||||
|
fetchCourseDenied,
|
||||||
|
} from './data/slice';
|
||||||
@@ -1,219 +0,0 @@
|
|||||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
|
||||||
|
|
||||||
exports[`Data layer integration tests Should initialize store 1`] = `
|
|
||||||
Object {
|
|
||||||
"courseHome": Object {
|
|
||||||
"courseId": null,
|
|
||||||
"courseStatus": "loading",
|
|
||||||
"displayResetDatesToast": false,
|
|
||||||
},
|
|
||||||
"courseware": Object {
|
|
||||||
"courseId": null,
|
|
||||||
"courseStatus": "loading",
|
|
||||||
"sequenceId": null,
|
|
||||||
"sequenceStatus": "loading",
|
|
||||||
},
|
|
||||||
"models": Object {},
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
exports[`Data layer integration tests Test fetchDatesTab Should fetch, normalize, and save metadata 1`] = `
|
|
||||||
Object {
|
|
||||||
"courseHome": Object {
|
|
||||||
"courseId": "course-v1:edX+DemoX+Demo_Course_1",
|
|
||||||
"courseStatus": "loaded",
|
|
||||||
"displayResetDatesToast": false,
|
|
||||||
},
|
|
||||||
"courseware": Object {
|
|
||||||
"courseId": null,
|
|
||||||
"courseStatus": "loading",
|
|
||||||
"sequenceId": null,
|
|
||||||
"sequenceStatus": "loading",
|
|
||||||
},
|
|
||||||
"models": Object {
|
|
||||||
"courses": Object {
|
|
||||||
"course-v1:edX+DemoX+Demo_Course_1": Object {
|
|
||||||
"courseId": "course-v1:edX+DemoX+Demo_Course_1",
|
|
||||||
"id": "course-v1:edX+DemoX+Demo_Course_1",
|
|
||||||
"isSelfPaced": false,
|
|
||||||
"isStaff": false,
|
|
||||||
"number": "DemoX",
|
|
||||||
"org": "edX",
|
|
||||||
"originalUserIsStaff": false,
|
|
||||||
"tabs": Array [
|
|
||||||
Object {
|
|
||||||
"slug": "courseware",
|
|
||||||
"title": "Course",
|
|
||||||
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course_1/course/",
|
|
||||||
},
|
|
||||||
Object {
|
|
||||||
"slug": "discussion",
|
|
||||||
"title": "Discussion",
|
|
||||||
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course_1/discussion/forum/",
|
|
||||||
},
|
|
||||||
Object {
|
|
||||||
"slug": "wiki",
|
|
||||||
"title": "Wiki",
|
|
||||||
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course_1/course_wiki",
|
|
||||||
},
|
|
||||||
Object {
|
|
||||||
"slug": "progress",
|
|
||||||
"title": "Progress",
|
|
||||||
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course_1/progress",
|
|
||||||
},
|
|
||||||
Object {
|
|
||||||
"slug": "instructor",
|
|
||||||
"title": "Instructor",
|
|
||||||
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course_1/instructor",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
"title": "Demonstration Course",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"dates": Object {
|
|
||||||
"course-v1:edX+DemoX+Demo_Course_1": Object {
|
|
||||||
"courseDateBlocks": Array [
|
|
||||||
Object {
|
|
||||||
"assigmentType": "Homework",
|
|
||||||
"date": "2013-02-05T05:00:00Z",
|
|
||||||
"dateType": "course-start-date",
|
|
||||||
"description": "",
|
|
||||||
"extraInfo": "",
|
|
||||||
"learnerHasAccess": true,
|
|
||||||
"link": "",
|
|
||||||
"title": "Course Starts",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
"datesBannerInfo": Object {
|
|
||||||
"contentTypeGatingEnabled": false,
|
|
||||||
"missedDeadlines": false,
|
|
||||||
"missedGatedContent": false,
|
|
||||||
},
|
|
||||||
"id": "course-v1:edX+DemoX+Demo_Course_1",
|
|
||||||
"learnerIsFullAccess": true,
|
|
||||||
"missedDeadlines": false,
|
|
||||||
"missedGatedContent": false,
|
|
||||||
"userTimezone": null,
|
|
||||||
"verifiedUpgradeLink": "http://localhost:18130/basket/add/?sku=8CF08E5",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
exports[`Data layer integration tests Test fetchOutlineTab Should fetch, normalize, and save metadata 1`] = `
|
|
||||||
Object {
|
|
||||||
"courseHome": Object {
|
|
||||||
"courseId": "course-v1:edX+DemoX+Demo_Course_1",
|
|
||||||
"courseStatus": "loaded",
|
|
||||||
"displayResetDatesToast": false,
|
|
||||||
},
|
|
||||||
"courseware": Object {
|
|
||||||
"courseId": null,
|
|
||||||
"courseStatus": "loading",
|
|
||||||
"sequenceId": null,
|
|
||||||
"sequenceStatus": "loading",
|
|
||||||
},
|
|
||||||
"models": Object {
|
|
||||||
"courses": Object {
|
|
||||||
"course-v1:edX+DemoX+Demo_Course_1": Object {
|
|
||||||
"courseId": "course-v1:edX+DemoX+Demo_Course_1",
|
|
||||||
"id": "course-v1:edX+DemoX+Demo_Course_1",
|
|
||||||
"isSelfPaced": false,
|
|
||||||
"isStaff": false,
|
|
||||||
"number": "DemoX",
|
|
||||||
"org": "edX",
|
|
||||||
"originalUserIsStaff": false,
|
|
||||||
"tabs": Array [
|
|
||||||
Object {
|
|
||||||
"slug": "courseware",
|
|
||||||
"title": "Course",
|
|
||||||
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course_1/course/",
|
|
||||||
},
|
|
||||||
Object {
|
|
||||||
"slug": "discussion",
|
|
||||||
"title": "Discussion",
|
|
||||||
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course_1/discussion/forum/",
|
|
||||||
},
|
|
||||||
Object {
|
|
||||||
"slug": "wiki",
|
|
||||||
"title": "Wiki",
|
|
||||||
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course_1/course_wiki",
|
|
||||||
},
|
|
||||||
Object {
|
|
||||||
"slug": "progress",
|
|
||||||
"title": "Progress",
|
|
||||||
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course_1/progress",
|
|
||||||
},
|
|
||||||
Object {
|
|
||||||
"slug": "instructor",
|
|
||||||
"title": "Instructor",
|
|
||||||
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course_1/instructor",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
"title": "Demonstration Course",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"outline": Object {
|
|
||||||
"course-v1:edX+DemoX+Demo_Course_1": Object {
|
|
||||||
"courseBlocks": Object {
|
|
||||||
"courses": Object {
|
|
||||||
"block-v1:edX+DemoX+Demo_Course+type@course+block@bcdabcdabcdabcdabcdabcdabcdabcd4": Object {
|
|
||||||
"id": "course-v1:edX+DemoX+Demo_Course_1",
|
|
||||||
"sectionIds": Array [
|
|
||||||
"block-v1:edX+DemoX+Demo_Course+type@chapter+block@bcdabcdabcdabcdabcdabcdabcdabcd3",
|
|
||||||
],
|
|
||||||
"title": "bcdabcdabcdabcdabcdabcdabcdabcd4",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"sections": Object {
|
|
||||||
"block-v1:edX+DemoX+Demo_Course+type@chapter+block@bcdabcdabcdabcdabcdabcdabcdabcd3": Object {
|
|
||||||
"courseId": "course-v1:edX+DemoX+Demo_Course_1",
|
|
||||||
"id": "block-v1:edX+DemoX+Demo_Course+type@chapter+block@bcdabcdabcdabcdabcdabcdabcdabcd3",
|
|
||||||
"sequenceIds": Array [
|
|
||||||
"block-v1:edX+DemoX+Demo_Course+type@sequential+block@bcdabcdabcdabcdabcdabcdabcdabcd2",
|
|
||||||
],
|
|
||||||
"title": "bcdabcdabcdabcdabcdabcdabcdabcd3",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"sequences": Object {
|
|
||||||
"block-v1:edX+DemoX+Demo_Course+type@sequential+block@bcdabcdabcdabcdabcdabcdabcdabcd2": Object {
|
|
||||||
"id": "block-v1:edX+DemoX+Demo_Course+type@sequential+block@bcdabcdabcdabcdabcdabcdabcdabcd2",
|
|
||||||
"lmsWebUrl": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@sequential+block@bcdabcdabcdabcdabcdabcdabcdabcd2",
|
|
||||||
"sectionId": "block-v1:edX+DemoX+Demo_Course+type@chapter+block@bcdabcdabcdabcdabcdabcdabcdabcd3",
|
|
||||||
"title": "bcdabcdabcdabcdabcdabcdabcdabcd2",
|
|
||||||
"unitIds": Array [
|
|
||||||
"block-v1:edX+DemoX+Demo_Course+type@vertical+block@bcdabcdabcdabcdabcdabcdabcdabcd1",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"units": Object {
|
|
||||||
"block-v1:edX+DemoX+Demo_Course+type@vertical+block@bcdabcdabcdabcdabcdabcdabcdabcd1": Object {
|
|
||||||
"graded": false,
|
|
||||||
"id": "block-v1:edX+DemoX+Demo_Course+type@vertical+block@bcdabcdabcdabcdabcdabcdabcdabcd1",
|
|
||||||
"lmsWebUrl": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@vertical+block@bcdabcdabcdabcdabcdabcdabcdabcd1",
|
|
||||||
"sequenceId": "block-v1:edX+DemoX+Demo_Course+type@sequential+block@bcdabcdabcdabcdabcdabcdabcdabcd2",
|
|
||||||
"title": "bcdabcdabcdabcdabcdabcdabcdabcd1",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"courseExpiredHtml": "<div>Course expired</div>",
|
|
||||||
"courseTools": Object {
|
|
||||||
"analyticsId": "edx.bookmarks",
|
|
||||||
"title": "Bookmarks",
|
|
||||||
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/bookmarks/",
|
|
||||||
},
|
|
||||||
"datesWidget": undefined,
|
|
||||||
"enrollAlert": Object {
|
|
||||||
"canEnroll": true,
|
|
||||||
"extraText": "Contact the administrator.",
|
|
||||||
},
|
|
||||||
"handoutsHtml": "<ul><li>Handout 1</li></ul>",
|
|
||||||
"id": "course-v1:edX+DemoX+Demo_Course_1",
|
|
||||||
"offerHtml": "<div>Great offer here</div>",
|
|
||||||
"welcomeMessageHtml": undefined,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
@@ -1,15 +1,14 @@
|
|||||||
import { Factory } from 'rosie';
|
import { Factory } from 'rosie';
|
||||||
import MockAdapter from 'axios-mock-adapter';
|
import MockAdapter from 'axios-mock-adapter';
|
||||||
|
|
||||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||||
import { getConfig } from '@edx/frontend-platform';
|
import { getConfig } from '@edx/frontend-platform';
|
||||||
|
|
||||||
import * as thunks from './thunks';
|
import { COURSE_LOADED, COURSE_LOADING, COURSE_FAILED } from '../../active-course';
|
||||||
|
|
||||||
import executeThunk from '../../utils';
|
|
||||||
|
|
||||||
import initializeMockApp from '../../setupTest';
|
import initializeMockApp from '../../setupTest';
|
||||||
import initializeStore from '../../store';
|
import initializeStore from '../../store';
|
||||||
|
import executeThunk from '../../utils';
|
||||||
|
|
||||||
|
import * as thunks from './thunks';
|
||||||
|
|
||||||
const { loggingService } = initializeMockApp();
|
const { loggingService } = initializeMockApp();
|
||||||
|
|
||||||
@@ -41,7 +40,8 @@ describe('Data layer integration tests', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('Should initialize store', () => {
|
it('Should initialize store', () => {
|
||||||
expect(store.getState()).toMatchSnapshot();
|
expect(store.getState().activeCourse.courseId).toBeNull();
|
||||||
|
expect(store.getState().activeCourse.courseStatus).toEqual(COURSE_LOADING);
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Test fetchDatesTab', () => {
|
describe('Test fetchDatesTab', () => {
|
||||||
@@ -55,7 +55,7 @@ describe('Data layer integration tests', () => {
|
|||||||
await executeThunk(thunks.fetchDatesTab(courseId), store.dispatch);
|
await executeThunk(thunks.fetchDatesTab(courseId), store.dispatch);
|
||||||
|
|
||||||
expect(loggingService.logError).toHaveBeenCalled();
|
expect(loggingService.logError).toHaveBeenCalled();
|
||||||
expect(store.getState().courseHome.courseStatus).toEqual('failed');
|
expect(store.getState().activeCourse.courseStatus).toEqual(COURSE_FAILED);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('Should fetch, normalize, and save metadata', async () => {
|
it('Should fetch, normalize, and save metadata', async () => {
|
||||||
@@ -70,8 +70,31 @@ describe('Data layer integration tests', () => {
|
|||||||
await executeThunk(thunks.fetchDatesTab(courseId), store.dispatch);
|
await executeThunk(thunks.fetchDatesTab(courseId), store.dispatch);
|
||||||
|
|
||||||
const state = store.getState();
|
const state = store.getState();
|
||||||
expect(state.courseHome.courseStatus).toEqual('loaded');
|
expect(state.activeCourse.courseStatus).toEqual(COURSE_LOADED);
|
||||||
expect(state).toMatchSnapshot();
|
expect(state.activeCourse.courseId).toEqual(courseId);
|
||||||
|
expect(state.courseHome.displayResetDatesToast).toBe(false);
|
||||||
|
|
||||||
|
// Validate course
|
||||||
|
const course = state.models.courses[courseId];
|
||||||
|
const expectedFieldCount = Object.keys(course).length;
|
||||||
|
// If this breaks, you should consider adding assertions below for the new data. If it's not
|
||||||
|
// an "interesting" addition, just bump the number anyway.
|
||||||
|
expect(expectedFieldCount).toBe(9);
|
||||||
|
expect(course.title).toEqual(courseHomeMetadata.title);
|
||||||
|
|
||||||
|
// Representative sample of data that proves data normalization and ingestion happened.
|
||||||
|
expect(course.id).toEqual(courseId);
|
||||||
|
expect(course.isStaff).toBe(courseHomeMetadata.is_staff);
|
||||||
|
expect(course.number).toEqual(courseHomeMetadata.number);
|
||||||
|
expect(Array.isArray(course.tabs)).toBe(true);
|
||||||
|
expect(course.tabs.length).toBe(5); // Weak assertion, but proves the array made it through.
|
||||||
|
|
||||||
|
// This proves the tab type came through as a modelType. We don't need to assert much else
|
||||||
|
// here because the shape of this data is not passed through any sort of normalization scheme,
|
||||||
|
// it just gets camelCased.
|
||||||
|
const dates = state.models.dates[courseId];
|
||||||
|
expect(dates.id).toEqual(courseId);
|
||||||
|
expect(dates.verifiedUpgradeLink).toBe(datesTabData.verified_upgrade_link);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -86,7 +109,7 @@ describe('Data layer integration tests', () => {
|
|||||||
await executeThunk(thunks.fetchOutlineTab(courseId), store.dispatch);
|
await executeThunk(thunks.fetchOutlineTab(courseId), store.dispatch);
|
||||||
|
|
||||||
expect(loggingService.logError).toHaveBeenCalled();
|
expect(loggingService.logError).toHaveBeenCalled();
|
||||||
expect(store.getState().courseHome.courseStatus).toEqual('failed');
|
expect(store.getState().activeCourse.courseStatus).toEqual(COURSE_FAILED);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('Should fetch, normalize, and save metadata', async () => {
|
it('Should fetch, normalize, and save metadata', async () => {
|
||||||
@@ -101,8 +124,30 @@ describe('Data layer integration tests', () => {
|
|||||||
await executeThunk(thunks.fetchOutlineTab(courseId), store.dispatch);
|
await executeThunk(thunks.fetchOutlineTab(courseId), store.dispatch);
|
||||||
|
|
||||||
const state = store.getState();
|
const state = store.getState();
|
||||||
expect(state.courseHome.courseStatus).toEqual('loaded');
|
expect(state.activeCourse.courseStatus).toEqual(COURSE_LOADED);
|
||||||
expect(state).toMatchSnapshot();
|
expect(state.courseHome.displayResetDatesToast).toBe(false);
|
||||||
|
|
||||||
|
// Validate course
|
||||||
|
const course = state.models.courses[courseId];
|
||||||
|
const expectedFieldCount = Object.keys(course).length;
|
||||||
|
// If this breaks, you should consider adding assertions below for the new data. If it's not
|
||||||
|
// an "interesting" addition, just bump the number anyway.
|
||||||
|
expect(expectedFieldCount).toBe(9);
|
||||||
|
expect(course.title).toEqual(courseHomeMetadata.title);
|
||||||
|
|
||||||
|
// Representative sample of data that proves data normalization and ingestion happened.
|
||||||
|
expect(course.id).toEqual(courseId);
|
||||||
|
expect(course.isStaff).toBe(courseHomeMetadata.is_staff);
|
||||||
|
expect(course.number).toEqual(courseHomeMetadata.number);
|
||||||
|
expect(Array.isArray(course.tabs)).toBe(true);
|
||||||
|
expect(course.tabs.length).toBe(5); // Weak assertion, but proves the array made it through.
|
||||||
|
|
||||||
|
// This proves the tab type came through as a modelType. We don't need to assert much else
|
||||||
|
// here because the shape of this data is not passed through any sort of normalization scheme,
|
||||||
|
// it just gets camelCased.
|
||||||
|
const outline = state.models.outline[courseId];
|
||||||
|
expect(outline.id).toEqual(courseId);
|
||||||
|
expect(outline.handoutsHtml).toBe(outlineTabData.handouts_html);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,30 +1,12 @@
|
|||||||
/* eslint-disable no-param-reassign */
|
/* eslint-disable no-param-reassign */
|
||||||
import { createSlice } from '@reduxjs/toolkit';
|
import { createSlice } from '@reduxjs/toolkit';
|
||||||
|
|
||||||
export const LOADING = 'loading';
|
|
||||||
export const LOADED = 'loaded';
|
|
||||||
export const FAILED = 'failed';
|
|
||||||
|
|
||||||
const slice = createSlice({
|
const slice = createSlice({
|
||||||
name: 'course-home',
|
name: 'course-home',
|
||||||
initialState: {
|
initialState: {
|
||||||
courseStatus: 'loading',
|
|
||||||
courseId: null,
|
|
||||||
displayResetDatesToast: false,
|
displayResetDatesToast: false,
|
||||||
},
|
},
|
||||||
reducers: {
|
reducers: {
|
||||||
fetchTabRequest: (state, { payload }) => {
|
|
||||||
state.courseId = payload.courseId;
|
|
||||||
state.courseStatus = LOADING;
|
|
||||||
},
|
|
||||||
fetchTabSuccess: (state, { payload }) => {
|
|
||||||
state.courseId = payload.courseId;
|
|
||||||
state.courseStatus = LOADED;
|
|
||||||
},
|
|
||||||
fetchTabFailure: (state, { payload }) => {
|
|
||||||
state.courseId = payload.courseId;
|
|
||||||
state.courseStatus = FAILED;
|
|
||||||
},
|
|
||||||
toggleResetDatesToast: (state, { payload }) => {
|
toggleResetDatesToast: (state, { payload }) => {
|
||||||
state.displayResetDatesToast = payload.displayResetDatesToast;
|
state.displayResetDatesToast = payload.displayResetDatesToast;
|
||||||
},
|
},
|
||||||
@@ -32,9 +14,6 @@ const slice = createSlice({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const {
|
export const {
|
||||||
fetchTabRequest,
|
|
||||||
fetchTabSuccess,
|
|
||||||
fetchTabFailure,
|
|
||||||
toggleResetDatesToast,
|
toggleResetDatesToast,
|
||||||
} = slice.actions;
|
} = slice.actions;
|
||||||
|
|
||||||
|
|||||||
@@ -14,15 +14,19 @@ import {
|
|||||||
} from '../../generic/model-store';
|
} from '../../generic/model-store';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
fetchTabFailure,
|
fetchCourseRequest,
|
||||||
fetchTabRequest,
|
fetchCourseSuccess,
|
||||||
fetchTabSuccess,
|
fetchCourseFailure,
|
||||||
|
} from '../../active-course';
|
||||||
|
|
||||||
|
import {
|
||||||
toggleResetDatesToast,
|
toggleResetDatesToast,
|
||||||
} from './slice';
|
} from './slice';
|
||||||
|
|
||||||
export function fetchTab(courseId, tab, getTabData) {
|
export function fetchTab(courseId, tab, getTabData) {
|
||||||
return async (dispatch) => {
|
return async (dispatch) => {
|
||||||
dispatch(fetchTabRequest({ courseId }));
|
dispatch(fetchCourseRequest({ courseId }));
|
||||||
|
|
||||||
Promise.allSettled([
|
Promise.allSettled([
|
||||||
getCourseHomeCourseMetadata(courseId),
|
getCourseHomeCourseMetadata(courseId),
|
||||||
getTabData(courseId),
|
getTabData(courseId),
|
||||||
@@ -55,9 +59,9 @@ export function fetchTab(courseId, tab, getTabData) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (fetchedCourseHomeCourseMetadata && fetchedTabData) {
|
if (fetchedCourseHomeCourseMetadata && fetchedTabData) {
|
||||||
dispatch(fetchTabSuccess({ courseId }));
|
dispatch(fetchCourseSuccess({ courseId }));
|
||||||
} else {
|
} else {
|
||||||
dispatch(fetchTabFailure({ courseId }));
|
dispatch(fetchCourseFailure({ courseId }));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ function DatesBannerContainer(props) {
|
|||||||
|
|
||||||
const {
|
const {
|
||||||
courseId,
|
courseId,
|
||||||
} = useSelector(state => state.courseHome);
|
} = useSelector(state => state.activeCourse);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
courseDateBlocks,
|
courseDateBlocks,
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ function Day({
|
|||||||
}) {
|
}) {
|
||||||
const {
|
const {
|
||||||
courseId,
|
courseId,
|
||||||
} = useSelector(state => state.courseHome);
|
} = useSelector(state => state.activeCourse);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
userTimezone,
|
userTimezone,
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { daycmp, isLearnerAssignment } from './utils';
|
|||||||
export default function Timeline() {
|
export default function Timeline() {
|
||||||
const {
|
const {
|
||||||
courseId,
|
courseId,
|
||||||
} = useSelector(state => state.courseHome);
|
} = useSelector(state => state.activeCourse);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
courseDateBlocks,
|
courseDateBlocks,
|
||||||
|
|||||||
2
src/course-home/index.js
Normal file
2
src/course-home/index.js
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
/* eslint-disable import/prefer-default-export */
|
||||||
|
export { reducer } from './data';
|
||||||
@@ -23,7 +23,7 @@ import WelcomeMessage from './widgets/WelcomeMessage';
|
|||||||
function OutlineTab({ intl }) {
|
function OutlineTab({ intl }) {
|
||||||
const {
|
const {
|
||||||
courseId,
|
courseId,
|
||||||
} = useSelector(state => state.courseHome);
|
} = useSelector(state => state.activeCourse);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
title,
|
title,
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ export default function DueDateTime({
|
|||||||
}) {
|
}) {
|
||||||
const {
|
const {
|
||||||
courseId,
|
courseId,
|
||||||
} = useSelector(state => state.courseHome);
|
} = useSelector(state => state.activeCourse);
|
||||||
const {
|
const {
|
||||||
userTimezone,
|
userTimezone,
|
||||||
} = useModel('progress', courseId);
|
} = useModel('progress', courseId);
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import messages from './messages';
|
|||||||
function ProgressTab({ intl }) {
|
function ProgressTab({ intl }) {
|
||||||
const {
|
const {
|
||||||
courseId,
|
courseId,
|
||||||
} = useSelector(state => state.courseHome);
|
} = useSelector(state => state.activeCourse);
|
||||||
|
|
||||||
const { administrator } = getAuthenticatedUser();
|
const { administrator } = getAuthenticatedUser();
|
||||||
|
|
||||||
|
|||||||
@@ -13,14 +13,20 @@ import {
|
|||||||
fetchSequence,
|
fetchSequence,
|
||||||
getResumeBlock,
|
getResumeBlock,
|
||||||
saveSequencePosition,
|
saveSequencePosition,
|
||||||
|
SEQUENCE_LOADED,
|
||||||
|
SEQUENCE_LOADING,
|
||||||
|
SEQUENCE_FAILED,
|
||||||
} from './data';
|
} from './data';
|
||||||
import { TabPage } from '../tab-page';
|
import { TabPage } from '../tab-page';
|
||||||
|
|
||||||
|
import {
|
||||||
|
activeCourseSelector, COURSE_LOADED, COURSE_LOADING, COURSE_FAILED, COURSE_DENIED,
|
||||||
|
} from '../active-course';
|
||||||
import Course from './course';
|
import Course from './course';
|
||||||
import { handleNextSectionCelebration } from './course/celebration';
|
import { handleNextSectionCelebration } from './course/celebration';
|
||||||
|
|
||||||
const checkExamRedirect = memoize((sequenceStatus, sequence) => {
|
const checkExamRedirect = memoize((sequenceStatus, sequence) => {
|
||||||
if (sequenceStatus === 'loaded') {
|
if (sequenceStatus === SEQUENCE_LOADED) {
|
||||||
if (sequence.isTimeLimited && sequence.lmsWebUrl !== undefined) {
|
if (sequence.isTimeLimited && sequence.lmsWebUrl !== undefined) {
|
||||||
global.location.assign(sequence.lmsWebUrl);
|
global.location.assign(sequence.lmsWebUrl);
|
||||||
}
|
}
|
||||||
@@ -28,7 +34,7 @@ const checkExamRedirect = memoize((sequenceStatus, sequence) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const checkResumeRedirect = memoize((courseStatus, courseId, sequenceId, firstSequenceId) => {
|
const checkResumeRedirect = memoize((courseStatus, courseId, sequenceId, firstSequenceId) => {
|
||||||
if (courseStatus === 'loaded' && !sequenceId) {
|
if (courseStatus === COURSE_LOADED && !sequenceId) {
|
||||||
// Note that getResumeBlock is just an API call, not a redux thunk.
|
// Note that getResumeBlock is just an API call, not a redux thunk.
|
||||||
getResumeBlock(courseId).then((data) => {
|
getResumeBlock(courseId).then((data) => {
|
||||||
// This is a replace because we don't want this change saved in the browser's history.
|
// This is a replace because we don't want this change saved in the browser's history.
|
||||||
@@ -42,7 +48,7 @@ const checkResumeRedirect = memoize((courseStatus, courseId, sequenceId, firstSe
|
|||||||
});
|
});
|
||||||
|
|
||||||
const checkContentRedirect = memoize((courseId, sequenceStatus, sequenceId, sequence, unitId) => {
|
const checkContentRedirect = memoize((courseId, sequenceStatus, sequenceId, sequence, unitId) => {
|
||||||
if (sequenceStatus === 'loaded' && sequenceId && !unitId) {
|
if (sequenceStatus === SEQUENCE_LOADED && sequenceId && !unitId) {
|
||||||
if (sequence.unitIds !== undefined && sequence.unitIds.length > 0) {
|
if (sequence.unitIds !== undefined && sequence.unitIds.length > 0) {
|
||||||
const nextUnitId = sequence.unitIds[sequence.activeUnitIndex];
|
const nextUnitId = sequence.unitIds[sequence.activeUnitIndex];
|
||||||
// This is a replace because we don't want this change saved in the browser's history.
|
// This is a replace because we don't want this change saved in the browser's history.
|
||||||
@@ -59,7 +65,7 @@ class CoursewareContainer extends Component {
|
|||||||
sequenceStatus,
|
sequenceStatus,
|
||||||
sequence,
|
sequence,
|
||||||
} = this.props;
|
} = this.props;
|
||||||
if (sequenceStatus === 'loaded' && sequence.saveUnitPosition && unitId) {
|
if (sequenceStatus === SEQUENCE_LOADED && sequence.saveUnitPosition && unitId) {
|
||||||
const activeUnitIndex = sequence.unitIds.indexOf(unitId);
|
const activeUnitIndex = sequence.unitIds.indexOf(unitId);
|
||||||
this.props.saveSequencePosition(courseId, sequenceId, activeUnitIndex);
|
this.props.saveSequencePosition(courseId, sequenceId, activeUnitIndex);
|
||||||
}
|
}
|
||||||
@@ -207,7 +213,7 @@ class CoursewareContainer extends Component {
|
|||||||
},
|
},
|
||||||
} = this.props;
|
} = this.props;
|
||||||
|
|
||||||
if (courseStatus === 'denied') {
|
if (courseStatus === COURSE_DENIED) {
|
||||||
return this.renderDenied();
|
return this.renderDenied();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -257,8 +263,8 @@ CoursewareContainer.propTypes = {
|
|||||||
sequenceId: PropTypes.string,
|
sequenceId: PropTypes.string,
|
||||||
firstSequenceId: PropTypes.string,
|
firstSequenceId: PropTypes.string,
|
||||||
unitId: PropTypes.string,
|
unitId: PropTypes.string,
|
||||||
courseStatus: PropTypes.oneOf(['loaded', 'loading', 'failed', 'denied']).isRequired,
|
courseStatus: PropTypes.oneOf([COURSE_LOADED, COURSE_LOADING, COURSE_FAILED, COURSE_DENIED]).isRequired,
|
||||||
sequenceStatus: PropTypes.oneOf(['loaded', 'loading', 'failed']).isRequired,
|
sequenceStatus: PropTypes.oneOf([SEQUENCE_LOADED, SEQUENCE_LOADING, SEQUENCE_FAILED]).isRequired,
|
||||||
nextSequence: sequenceShape,
|
nextSequence: sequenceShape,
|
||||||
previousSequence: sequenceShape,
|
previousSequence: sequenceShape,
|
||||||
course: courseShape,
|
course: courseShape,
|
||||||
@@ -280,12 +286,6 @@ CoursewareContainer.defaultProps = {
|
|||||||
sequence: null,
|
sequence: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
const currentCourseSelector = createSelector(
|
|
||||||
(state) => state.models.courses || {},
|
|
||||||
(state) => state.courseware.courseId,
|
|
||||||
(coursesById, courseId) => (coursesById[courseId] ? coursesById[courseId] : null),
|
|
||||||
);
|
|
||||||
|
|
||||||
const currentSequenceSelector = createSelector(
|
const currentSequenceSelector = createSelector(
|
||||||
(state) => state.models.sequences || {},
|
(state) => state.models.sequences || {},
|
||||||
(state) => state.courseware.sequenceId,
|
(state) => state.courseware.sequenceId,
|
||||||
@@ -293,11 +293,11 @@ const currentSequenceSelector = createSelector(
|
|||||||
);
|
);
|
||||||
|
|
||||||
const sequenceIdsSelector = createSelector(
|
const sequenceIdsSelector = createSelector(
|
||||||
(state) => state.courseware.courseStatus,
|
(state) => state.activeCourse.courseStatus,
|
||||||
currentCourseSelector,
|
activeCourseSelector,
|
||||||
(state) => state.models.sections,
|
(state) => state.models.sections,
|
||||||
(courseStatus, course, sectionsById) => {
|
(courseStatus, course, sectionsById) => {
|
||||||
if (courseStatus !== 'loaded') {
|
if (courseStatus !== COURSE_LOADED) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
const { sectionIds = [] } = course;
|
const { sectionIds = [] } = course;
|
||||||
@@ -334,11 +334,11 @@ const nextSequenceSelector = createSelector(
|
|||||||
);
|
);
|
||||||
|
|
||||||
const firstSequenceIdSelector = createSelector(
|
const firstSequenceIdSelector = createSelector(
|
||||||
(state) => state.courseware.courseStatus,
|
(state) => state.activeCourse.courseStatus,
|
||||||
currentCourseSelector,
|
activeCourseSelector,
|
||||||
(state) => state.models.sections || {},
|
(state) => state.models.sections || {},
|
||||||
(courseStatus, course, sectionsById) => {
|
(courseStatus, course, sectionsById) => {
|
||||||
if (courseStatus !== 'loaded') {
|
if (courseStatus !== COURSE_LOADED) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const { sectionIds = [] } = course;
|
const { sectionIds = [] } = course;
|
||||||
@@ -353,8 +353,11 @@ const firstSequenceIdSelector = createSelector(
|
|||||||
|
|
||||||
const mapStateToProps = (state) => {
|
const mapStateToProps = (state) => {
|
||||||
const {
|
const {
|
||||||
courseId, sequenceId, unitId, courseStatus, sequenceStatus,
|
sequenceId, sequenceStatus, unitId,
|
||||||
} = state.courseware;
|
} = state.courseware;
|
||||||
|
const {
|
||||||
|
courseId, courseStatus,
|
||||||
|
} = state.activeCourse;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
courseId,
|
courseId,
|
||||||
@@ -362,7 +365,7 @@ const mapStateToProps = (state) => {
|
|||||||
unitId,
|
unitId,
|
||||||
courseStatus,
|
courseStatus,
|
||||||
sequenceStatus,
|
sequenceStatus,
|
||||||
course: currentCourseSelector(state),
|
course: activeCourseSelector(state),
|
||||||
sequence: currentSequenceSelector(state),
|
sequence: currentSequenceSelector(state),
|
||||||
previousSequence: previousSequenceSelector(state),
|
previousSequence: previousSequenceSelector(state),
|
||||||
nextSequence: nextSequenceSelector(state),
|
nextSequence: nextSequenceSelector(state),
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import React from 'react';
|
|||||||
import { Switch, Route, useRouteMatch } from 'react-router';
|
import { Switch, Route, useRouteMatch } from 'react-router';
|
||||||
import { getConfig } from '@edx/frontend-platform';
|
import { getConfig } from '@edx/frontend-platform';
|
||||||
import { FormattedMessage } from '@edx/frontend-platform/i18n';
|
import { FormattedMessage } from '@edx/frontend-platform/i18n';
|
||||||
import PageLoading from './generic/PageLoading';
|
import PageLoading from '../generic/PageLoading';
|
||||||
|
|
||||||
export default () => {
|
export default () => {
|
||||||
const { path } = useRouteMatch();
|
const { path } = useRouteMatch();
|
||||||
@@ -6,6 +6,8 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
|||||||
import { faHome } from '@fortawesome/free-solid-svg-icons';
|
import { faHome } from '@fortawesome/free-solid-svg-icons';
|
||||||
import { useSelector } from 'react-redux';
|
import { useSelector } from 'react-redux';
|
||||||
import { useModel } from '../../generic/model-store';
|
import { useModel } from '../../generic/model-store';
|
||||||
|
import { COURSE_LOADED } from '../../active-course';
|
||||||
|
import { SEQUENCE_LOADED } from '../data';
|
||||||
|
|
||||||
function CourseBreadcrumb({
|
function CourseBreadcrumb({
|
||||||
url, children, withSeparator, ...attrs
|
url, children, withSeparator, ...attrs
|
||||||
@@ -40,11 +42,11 @@ export default function CourseBreadcrumbs({
|
|||||||
const course = useModel('courses', courseId);
|
const course = useModel('courses', courseId);
|
||||||
const sequence = useModel('sequences', sequenceId);
|
const sequence = useModel('sequences', sequenceId);
|
||||||
const section = useModel('sections', sectionId);
|
const section = useModel('sections', sectionId);
|
||||||
const courseStatus = useSelector(state => state.courseware.courseStatus);
|
const courseStatus = useSelector(state => state.activeCourse.courseStatus);
|
||||||
const sequenceStatus = useSelector(state => state.courseware.sequenceStatus);
|
const sequenceStatus = useSelector(state => state.courseware.sequenceStatus);
|
||||||
|
|
||||||
const links = useMemo(() => {
|
const links = useMemo(() => {
|
||||||
if (courseStatus === 'loaded' && sequenceStatus === 'loaded') {
|
if (courseStatus === COURSE_LOADED && sequenceStatus === SEQUENCE_LOADED) {
|
||||||
return [section, sequence].filter(node => !!node).map((node) => ({
|
return [section, sequence].filter(node => !!node).map((node) => ({
|
||||||
id: node.id,
|
id: node.id,
|
||||||
label: node.title,
|
label: node.title,
|
||||||
|
|||||||
@@ -1,15 +1,18 @@
|
|||||||
import MockAdapter from 'axios-mock-adapter';
|
import MockAdapter from 'axios-mock-adapter';
|
||||||
|
|
||||||
import { getAuthenticatedHttpClient, getAuthenticatedUser } from '@edx/frontend-platform/auth';
|
import { getAuthenticatedHttpClient, getAuthenticatedUser } from '@edx/frontend-platform/auth';
|
||||||
import { getConfig } from '@edx/frontend-platform';
|
import { getConfig } from '@edx/frontend-platform';
|
||||||
|
|
||||||
import * as thunks from './thunks';
|
|
||||||
|
|
||||||
import executeThunk from '../../../../utils';
|
import executeThunk from '../../../../utils';
|
||||||
|
|
||||||
import initializeMockApp from '../../../../setupTest';
|
import initializeMockApp from '../../../../setupTest';
|
||||||
import initializeStore from '../../../../store';
|
import initializeStore from '../../../../store';
|
||||||
|
|
||||||
|
import {
|
||||||
|
addBookmark,
|
||||||
|
removeBookmark,
|
||||||
|
BOOKMARK_FAILED,
|
||||||
|
BOOKMARK_LOADED,
|
||||||
|
} from './thunks';
|
||||||
|
|
||||||
const { loggingService } = initializeMockApp();
|
const { loggingService } = initializeMockApp();
|
||||||
|
|
||||||
const axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
const axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||||
@@ -32,24 +35,24 @@ describe('Data layer integration tests', () => {
|
|||||||
it('Should fail to create bookmark in case of error', async () => {
|
it('Should fail to create bookmark in case of error', async () => {
|
||||||
axiosMock.onPost(createBookmarkURL).networkError();
|
axiosMock.onPost(createBookmarkURL).networkError();
|
||||||
|
|
||||||
await executeThunk(thunks.addBookmark(unitId), store.dispatch);
|
await executeThunk(addBookmark(unitId), store.dispatch);
|
||||||
|
|
||||||
expect(loggingService.logError).toHaveBeenCalled();
|
expect(loggingService.logError).toHaveBeenCalled();
|
||||||
expect(axiosMock.history.post[0].url).toEqual(createBookmarkURL);
|
expect(axiosMock.history.post[0].url).toEqual(createBookmarkURL);
|
||||||
expect(store.getState().models.units[unitId]).toEqual(expect.objectContaining({
|
expect(store.getState().models.units[unitId]).toEqual(expect.objectContaining({
|
||||||
bookmarked: false,
|
bookmarked: false,
|
||||||
bookmarkedUpdateState: 'failed',
|
bookmarkedUpdateState: BOOKMARK_FAILED,
|
||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
|
|
||||||
it('Should create bookmark and update model state', async () => {
|
it('Should create bookmark and update model state', async () => {
|
||||||
axiosMock.onPost(createBookmarkURL).reply(201);
|
axiosMock.onPost(createBookmarkURL).reply(201);
|
||||||
|
|
||||||
await executeThunk(thunks.addBookmark(unitId), store.dispatch);
|
await executeThunk(addBookmark(unitId), store.dispatch);
|
||||||
|
|
||||||
expect(store.getState().models.units[unitId]).toEqual(expect.objectContaining({
|
expect(store.getState().models.units[unitId]).toEqual(expect.objectContaining({
|
||||||
bookmarked: true,
|
bookmarked: true,
|
||||||
bookmarkedUpdateState: 'loaded',
|
bookmarkedUpdateState: BOOKMARK_LOADED,
|
||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -60,24 +63,24 @@ describe('Data layer integration tests', () => {
|
|||||||
it('Should fail to remove bookmark in case of error', async () => {
|
it('Should fail to remove bookmark in case of error', async () => {
|
||||||
axiosMock.onDelete(deleteBookmarkURL).networkError();
|
axiosMock.onDelete(deleteBookmarkURL).networkError();
|
||||||
|
|
||||||
await executeThunk(thunks.removeBookmark(unitId), store.dispatch);
|
await executeThunk(removeBookmark(unitId), store.dispatch);
|
||||||
|
|
||||||
expect(loggingService.logError).toHaveBeenCalled();
|
expect(loggingService.logError).toHaveBeenCalled();
|
||||||
expect(axiosMock.history.delete[0].url).toEqual(deleteBookmarkURL);
|
expect(axiosMock.history.delete[0].url).toEqual(deleteBookmarkURL);
|
||||||
expect(store.getState().models.units[unitId]).toEqual(expect.objectContaining({
|
expect(store.getState().models.units[unitId]).toEqual(expect.objectContaining({
|
||||||
bookmarked: true,
|
bookmarked: true,
|
||||||
bookmarkedUpdateState: 'failed',
|
bookmarkedUpdateState: BOOKMARK_FAILED,
|
||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
|
|
||||||
it('Should delete bookmark and update model state', async () => {
|
it('Should delete bookmark and update model state', async () => {
|
||||||
axiosMock.onDelete(deleteBookmarkURL).reply(201);
|
axiosMock.onDelete(deleteBookmarkURL).reply(201);
|
||||||
|
|
||||||
await executeThunk(thunks.removeBookmark(unitId), store.dispatch);
|
await executeThunk(removeBookmark(unitId), store.dispatch);
|
||||||
|
|
||||||
expect(store.getState().models.units[unitId]).toEqual(expect.objectContaining({
|
expect(store.getState().models.units[unitId]).toEqual(expect.objectContaining({
|
||||||
bookmarked: false,
|
bookmarked: false,
|
||||||
bookmarkedUpdateState: 'loaded',
|
bookmarkedUpdateState: BOOKMARK_LOADED,
|
||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,6 +5,10 @@ import {
|
|||||||
} from './api';
|
} from './api';
|
||||||
import { updateModel } from '../../../../generic/model-store';
|
import { updateModel } from '../../../../generic/model-store';
|
||||||
|
|
||||||
|
export const BOOKMARK_LOADING = 'loading';
|
||||||
|
export const BOOKMARK_LOADED = 'loaded';
|
||||||
|
export const BOOKMARK_FAILED = 'failed';
|
||||||
|
|
||||||
export function addBookmark(unitId) {
|
export function addBookmark(unitId) {
|
||||||
return async (dispatch) => {
|
return async (dispatch) => {
|
||||||
// Optimistically update the bookmarked flag.
|
// Optimistically update the bookmarked flag.
|
||||||
@@ -13,7 +17,7 @@ export function addBookmark(unitId) {
|
|||||||
model: {
|
model: {
|
||||||
id: unitId,
|
id: unitId,
|
||||||
bookmarked: true,
|
bookmarked: true,
|
||||||
bookmarkedUpdateState: 'loading',
|
bookmarkedUpdateState: BOOKMARK_LOADING,
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -24,7 +28,7 @@ export function addBookmark(unitId) {
|
|||||||
model: {
|
model: {
|
||||||
id: unitId,
|
id: unitId,
|
||||||
bookmarked: true,
|
bookmarked: true,
|
||||||
bookmarkedUpdateState: 'loaded',
|
bookmarkedUpdateState: BOOKMARK_LOADED,
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -34,7 +38,7 @@ export function addBookmark(unitId) {
|
|||||||
model: {
|
model: {
|
||||||
id: unitId,
|
id: unitId,
|
||||||
bookmarked: false,
|
bookmarked: false,
|
||||||
bookmarkedUpdateState: 'failed',
|
bookmarkedUpdateState: BOOKMARK_FAILED,
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
@@ -49,7 +53,7 @@ export function removeBookmark(unitId) {
|
|||||||
model: {
|
model: {
|
||||||
id: unitId,
|
id: unitId,
|
||||||
bookmarked: false,
|
bookmarked: false,
|
||||||
bookmarkedUpdateState: 'loading',
|
bookmarkedUpdateState: BOOKMARK_LOADING,
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
try {
|
try {
|
||||||
@@ -59,7 +63,7 @@ export function removeBookmark(unitId) {
|
|||||||
model: {
|
model: {
|
||||||
id: unitId,
|
id: unitId,
|
||||||
bookmarked: false,
|
bookmarked: false,
|
||||||
bookmarkedUpdateState: 'loaded',
|
bookmarkedUpdateState: BOOKMARK_LOADED,
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -69,7 +73,7 @@ export function removeBookmark(unitId) {
|
|||||||
model: {
|
model: {
|
||||||
id: unitId,
|
id: unitId,
|
||||||
bookmarked: true,
|
bookmarked: true,
|
||||||
bookmarkedUpdateState: 'failed',
|
bookmarkedUpdateState: BOOKMARK_FAILED,
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,9 @@
|
|||||||
export { default as BookmarkButton } from './BookmarkButton';
|
export { default as BookmarkButton } from './BookmarkButton';
|
||||||
export { default as BookmarkFilledIcon } from './BookmarkFilledIcon';
|
export { default as BookmarkFilledIcon } from './BookmarkFilledIcon';
|
||||||
export { default as BookmarkOutlineIcon } from './BookmarkFilledIcon';
|
export { default as BookmarkOutlineIcon } from './BookmarkFilledIcon';
|
||||||
|
|
||||||
|
export {
|
||||||
|
BOOKMARK_LOADING,
|
||||||
|
BOOKMARK_LOADED,
|
||||||
|
BOOKMARK_FAILED,
|
||||||
|
} from './data/thunks';
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import CourseLicense from '../course-license';
|
|||||||
import messages from './messages';
|
import messages from './messages';
|
||||||
import { SequenceNavigation, UnitNavigation } from './sequence-navigation';
|
import { SequenceNavigation, UnitNavigation } from './sequence-navigation';
|
||||||
import SequenceContent from './SequenceContent';
|
import SequenceContent from './SequenceContent';
|
||||||
|
import { SEQUENCE_LOADED, SEQUENCE_LOADING } from '../../data';
|
||||||
|
|
||||||
function Sequence({
|
function Sequence({
|
||||||
unitId,
|
unitId,
|
||||||
@@ -73,7 +74,7 @@ function Sequence({
|
|||||||
const { add, remove } = useContext(UserMessagesContext);
|
const { add, remove } = useContext(UserMessagesContext);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let id = null;
|
let id = null;
|
||||||
if (sequenceStatus === 'loaded') {
|
if (sequenceStatus === SEQUENCE_LOADED) {
|
||||||
if (sequence.bannerText) {
|
if (sequence.bannerText) {
|
||||||
id = add({
|
id = add({
|
||||||
code: null,
|
code: null,
|
||||||
@@ -101,7 +102,7 @@ function Sequence({
|
|||||||
}
|
}
|
||||||
}, [unit]);
|
}, [unit]);
|
||||||
|
|
||||||
if (sequenceStatus === 'loading') {
|
if (sequenceStatus === SEQUENCE_LOADING) {
|
||||||
if (!sequenceId) {
|
if (!sequenceId) {
|
||||||
return (<div> {intl.formatMessage(messages['learn.sequence.no.content'])} </div>);
|
return (<div> {intl.formatMessage(messages['learn.sequence.no.content'])} </div>);
|
||||||
}
|
}
|
||||||
@@ -114,7 +115,7 @@ function Sequence({
|
|||||||
|
|
||||||
const gated = sequence && sequence.gatedContent !== undefined && sequence.gatedContent.gated;
|
const gated = sequence && sequence.gatedContent !== undefined && sequence.gatedContent.gated;
|
||||||
|
|
||||||
if (sequenceStatus === 'loaded') {
|
if (sequenceStatus === SEQUENCE_LOADED) {
|
||||||
return (
|
return (
|
||||||
<div className="sequence-container">
|
<div className="sequence-container">
|
||||||
<div className="sequence">
|
<div className="sequence">
|
||||||
|
|||||||
@@ -20,11 +20,11 @@ describe('Sequence', () => {
|
|||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
const store = await initializeTestStore({ courseMetadata, unitBlocks });
|
const store = await initializeTestStore({ courseMetadata, unitBlocks });
|
||||||
const { courseware } = store.getState();
|
const { courseware, activeCourse } = store.getState();
|
||||||
mockData = {
|
mockData = {
|
||||||
unitId: unitBlocks[0].id,
|
unitId: unitBlocks[0].id,
|
||||||
sequenceId: courseware.sequenceId,
|
sequenceId: courseware.sequenceId,
|
||||||
courseId: courseware.courseId,
|
courseId: activeCourse.courseId,
|
||||||
unitNavigationHandler: () => {},
|
unitNavigationHandler: () => {},
|
||||||
nextSequenceHandler: () => {},
|
nextSequenceHandler: () => {},
|
||||||
previousSequenceHandler: () => {},
|
previousSequenceHandler: () => {},
|
||||||
|
|||||||
@@ -8,10 +8,10 @@ describe('Sequence Content', () => {
|
|||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
store = await initializeTestStore();
|
store = await initializeTestStore();
|
||||||
const { models, courseware } = store.getState();
|
const { models, courseware, activeCourse } = store.getState();
|
||||||
mockData = {
|
mockData = {
|
||||||
gated: false,
|
gated: false,
|
||||||
courseId: courseware.courseId,
|
courseId: activeCourse.courseId,
|
||||||
sequenceId: courseware.sequenceId,
|
sequenceId: courseware.sequenceId,
|
||||||
unitId: models.sequences[courseware.sequenceId].unitIds[0],
|
unitId: models.sequences[courseware.sequenceId].unitIds[0],
|
||||||
unitLoadedHandler: () => {},
|
unitLoadedHandler: () => {},
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { useModel } from '../../../generic/model-store';
|
|||||||
import PageLoading from '../../../generic/PageLoading';
|
import PageLoading from '../../../generic/PageLoading';
|
||||||
import { resetDeadlines } from '../../../course-home/data/thunks';
|
import { resetDeadlines } from '../../../course-home/data/thunks';
|
||||||
import { fetchCourse } from '../../data/thunks';
|
import { fetchCourse } from '../../data/thunks';
|
||||||
|
import { BOOKMARK_LOADING } from '../bookmark';
|
||||||
|
|
||||||
const LockPaywall = React.lazy(() => import('./lock-paywall'));
|
const LockPaywall = React.lazy(() => import('./lock-paywall'));
|
||||||
|
|
||||||
@@ -106,7 +107,7 @@ function Unit({
|
|||||||
<BookmarkButton
|
<BookmarkButton
|
||||||
unitId={unit.id}
|
unitId={unit.id}
|
||||||
isBookmarked={unit.bookmarked}
|
isBookmarked={unit.bookmarked}
|
||||||
isProcessing={unit.bookmarkedUpdateState === 'loading'}
|
isProcessing={unit.bookmarkedUpdateState === BOOKMARK_LOADING}
|
||||||
/>
|
/>
|
||||||
{ contentTypeGatingEnabled && unit.graded && (
|
{ contentTypeGatingEnabled && unit.graded && (
|
||||||
<Suspense
|
<Suspense
|
||||||
|
|||||||
@@ -5,13 +5,14 @@ import classNames from 'classnames';
|
|||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
import { faChevronLeft, faChevronRight } from '@fortawesome/free-solid-svg-icons';
|
import { faChevronLeft, faChevronRight } from '@fortawesome/free-solid-svg-icons';
|
||||||
import { FormattedMessage } from '@edx/frontend-platform/i18n';
|
import { FormattedMessage } from '@edx/frontend-platform/i18n';
|
||||||
|
|
||||||
import { useSelector } from 'react-redux';
|
import { useSelector } from 'react-redux';
|
||||||
|
|
||||||
|
import { useModel } from '../../../../generic/model-store';
|
||||||
|
|
||||||
import UnitButton from './UnitButton';
|
import UnitButton from './UnitButton';
|
||||||
import SequenceNavigationTabs from './SequenceNavigationTabs';
|
import SequenceNavigationTabs from './SequenceNavigationTabs';
|
||||||
import { useSequenceNavigationMetadata } from './hooks';
|
import { useSequenceNavigationMetadata } from './hooks';
|
||||||
import { useModel } from '../../../../generic/model-store';
|
import { SEQUENCE_LOADED } from '../../../data';
|
||||||
import { LOADED } from '../../../data/slice';
|
|
||||||
|
|
||||||
export default function SequenceNavigation({
|
export default function SequenceNavigation({
|
||||||
unitId,
|
unitId,
|
||||||
@@ -24,7 +25,7 @@ export default function SequenceNavigation({
|
|||||||
const sequence = useModel('sequences', sequenceId);
|
const sequence = useModel('sequences', sequenceId);
|
||||||
const { isFirstUnit, isLastUnit } = useSequenceNavigationMetadata(sequenceId, unitId);
|
const { isFirstUnit, isLastUnit } = useSequenceNavigationMetadata(sequenceId, unitId);
|
||||||
const sequenceStatus = useSelector(state => state.courseware.sequenceStatus);
|
const sequenceStatus = useSelector(state => state.courseware.sequenceStatus);
|
||||||
const isLocked = sequenceStatus === LOADED ? (
|
const isLocked = sequenceStatus === SEQUENCE_LOADED ? (
|
||||||
sequence.gatedContent !== undefined && sequence.gatedContent.gated
|
sequence.gatedContent !== undefined && sequence.gatedContent.gated
|
||||||
) : undefined;
|
) : undefined;
|
||||||
|
|
||||||
@@ -49,7 +50,7 @@ export default function SequenceNavigation({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
return sequenceStatus === LOADED && (
|
return sequenceStatus === SEQUENCE_LOADED && (
|
||||||
<nav className={classNames('sequence-navigation', className)}>
|
<nav className={classNames('sequence-navigation', className)}>
|
||||||
<Button className="previous-btn" onClick={previousSequenceHandler} disabled={isFirstUnit}>
|
<Button className="previous-btn" onClick={previousSequenceHandler} disabled={isFirstUnit}>
|
||||||
<FontAwesomeIcon icon={faChevronLeft} className="mr-2" size="sm" />
|
<FontAwesomeIcon icon={faChevronLeft} className="mr-2" size="sm" />
|
||||||
|
|||||||
@@ -1,16 +1,18 @@
|
|||||||
/* eslint-disable import/prefer-default-export */
|
/* eslint-disable import/prefer-default-export */
|
||||||
|
|
||||||
import { useSelector } from 'react-redux';
|
import { useSelector } from 'react-redux';
|
||||||
|
|
||||||
import { useModel } from '../../../../generic/model-store';
|
import { useModel } from '../../../../generic/model-store';
|
||||||
|
import { COURSE_LOADED } from '../../../../active-course';
|
||||||
|
|
||||||
import { sequenceIdsSelector } from '../../../data/selectors';
|
import { sequenceIdsSelector } from '../../../data/selectors';
|
||||||
|
|
||||||
export function useSequenceNavigationMetadata(currentSequenceId, currentUnitId) {
|
export function useSequenceNavigationMetadata(currentSequenceId, currentUnitId) {
|
||||||
const sequenceIds = useSelector(sequenceIdsSelector);
|
const sequenceIds = useSelector(sequenceIdsSelector);
|
||||||
const sequence = useModel('sequences', currentSequenceId);
|
const sequence = useModel('sequences', currentSequenceId);
|
||||||
const courseStatus = useSelector(state => state.courseware.courseStatus);
|
const courseStatus = useSelector(state => state.activeCourse.courseStatus);
|
||||||
|
|
||||||
// If we don't know the sequence and unit yet, then assume no.
|
// If we don't know the sequence and unit yet, then assume no.
|
||||||
if (courseStatus !== 'loaded' || !currentSequenceId || !currentUnitId) {
|
if (courseStatus !== COURSE_LOADED || !currentSequenceId || !currentUnitId) {
|
||||||
return { isFirstUnit: false, isLastUnit: false };
|
return { isFirstUnit: false, isLastUnit: false };
|
||||||
}
|
}
|
||||||
const isFirstSequence = sequenceIds.indexOf(currentSequenceId) === 0;
|
const isFirstSequence = sequenceIds.indexOf(currentSequenceId) === 0;
|
||||||
|
|||||||
@@ -10,4 +10,9 @@ export {
|
|||||||
export {
|
export {
|
||||||
sequenceIdsSelector,
|
sequenceIdsSelector,
|
||||||
} from './selectors';
|
} from './selectors';
|
||||||
export { reducer } from './slice';
|
export {
|
||||||
|
reducer,
|
||||||
|
SEQUENCE_LOADING,
|
||||||
|
SEQUENCE_LOADED,
|
||||||
|
SEQUENCE_FAILED,
|
||||||
|
} from './slice';
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ import executeThunk from '../../utils';
|
|||||||
import buildSimpleCourseBlocks from './__factories__/courseBlocks.factory';
|
import buildSimpleCourseBlocks from './__factories__/courseBlocks.factory';
|
||||||
import initializeMockApp from '../../setupTest';
|
import initializeMockApp from '../../setupTest';
|
||||||
import initializeStore from '../../store';
|
import initializeStore from '../../store';
|
||||||
|
import { SEQUENCE_LOADING, SEQUENCE_LOADED, SEQUENCE_FAILED } from './slice';
|
||||||
|
import { COURSE_LOADED, COURSE_FAILED, COURSE_DENIED } from '../../active-course';
|
||||||
|
|
||||||
const { loggingService } = initializeMockApp();
|
const { loggingService } = initializeMockApp();
|
||||||
|
|
||||||
@@ -53,9 +55,9 @@ describe('Data layer integration tests', () => {
|
|||||||
await executeThunk(thunks.fetchCourse(courseId), store.dispatch);
|
await executeThunk(thunks.fetchCourse(courseId), store.dispatch);
|
||||||
|
|
||||||
expect(loggingService.logError).toHaveBeenCalled();
|
expect(loggingService.logError).toHaveBeenCalled();
|
||||||
expect(store.getState().courseware).toEqual(expect.objectContaining({
|
expect(store.getState().activeCourse).toEqual(expect.objectContaining({
|
||||||
courseId,
|
courseId,
|
||||||
courseStatus: 'failed',
|
courseStatus: COURSE_FAILED,
|
||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -78,7 +80,7 @@ describe('Data layer integration tests', () => {
|
|||||||
|
|
||||||
const state = store.getState();
|
const state = store.getState();
|
||||||
|
|
||||||
expect(state.courseware.courseStatus).toEqual('denied');
|
expect(state.activeCourse.courseStatus).toEqual(COURSE_DENIED);
|
||||||
|
|
||||||
// check that at least one key camel cased, thus course data normalized
|
// check that at least one key camel cased, thus course data normalized
|
||||||
expect(state.models.courses[forbiddenCourseMetadata.id].canLoadCourseware).not.toBeUndefined();
|
expect(state.models.courses[forbiddenCourseMetadata.id].canLoadCourseware).not.toBeUndefined();
|
||||||
@@ -92,9 +94,9 @@ describe('Data layer integration tests', () => {
|
|||||||
|
|
||||||
const state = store.getState();
|
const state = store.getState();
|
||||||
|
|
||||||
expect(state.courseware.courseStatus).toEqual('loaded');
|
expect(state.activeCourse.courseStatus).toEqual(COURSE_LOADED);
|
||||||
expect(state.courseware.courseId).toEqual(courseId);
|
expect(state.activeCourse.courseId).toEqual(courseId);
|
||||||
expect(state.courseware.sequenceStatus).toEqual('loading');
|
expect(state.courseware.sequenceStatus).toEqual(SEQUENCE_LOADING);
|
||||||
expect(state.courseware.sequenceId).toEqual(null);
|
expect(state.courseware.sequenceId).toEqual(null);
|
||||||
|
|
||||||
// check that at least one key camel cased, thus course data normalized
|
// check that at least one key camel cased, thus course data normalized
|
||||||
@@ -109,7 +111,7 @@ describe('Data layer integration tests', () => {
|
|||||||
await executeThunk(thunks.fetchSequence(sequenceId), store.dispatch);
|
await executeThunk(thunks.fetchSequence(sequenceId), store.dispatch);
|
||||||
|
|
||||||
expect(loggingService.logError).toHaveBeenCalled();
|
expect(loggingService.logError).toHaveBeenCalled();
|
||||||
expect(store.getState().courseware.sequenceStatus).toEqual('failed');
|
expect(store.getState().courseware.sequenceStatus).toEqual(SEQUENCE_FAILED);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('Should fetch and normalize metadata, and then update existing models with sequence metadata', async () => {
|
it('Should fetch and normalize metadata, and then update existing models with sequence metadata', async () => {
|
||||||
@@ -139,9 +141,9 @@ describe('Data layer integration tests', () => {
|
|||||||
// Update our state variable again.
|
// Update our state variable again.
|
||||||
state = store.getState();
|
state = store.getState();
|
||||||
|
|
||||||
expect(state.courseware.courseStatus).toEqual('loaded');
|
expect(state.activeCourse.courseStatus).toEqual(COURSE_LOADED);
|
||||||
expect(state.courseware.courseId).toEqual(courseId);
|
expect(state.activeCourse.courseId).toEqual(courseId);
|
||||||
expect(state.courseware.sequenceStatus).toEqual('loading');
|
expect(state.courseware.sequenceStatus).toEqual(SEQUENCE_LOADING);
|
||||||
expect(state.courseware.sequenceId).toEqual(null);
|
expect(state.courseware.sequenceId).toEqual(null);
|
||||||
|
|
||||||
await executeThunk(thunks.fetchSequence(sequenceId), store.dispatch);
|
await executeThunk(thunks.fetchSequence(sequenceId), store.dispatch);
|
||||||
@@ -163,9 +165,9 @@ describe('Data layer integration tests', () => {
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(state.courseware.courseStatus).toEqual('loaded');
|
expect(state.activeCourse.courseStatus).toEqual(COURSE_LOADED);
|
||||||
expect(state.courseware.courseId).toEqual(courseId);
|
expect(state.activeCourse.courseId).toEqual(courseId);
|
||||||
expect(state.courseware.sequenceStatus).toEqual('loaded');
|
expect(state.courseware.sequenceStatus).toEqual(SEQUENCE_LOADED);
|
||||||
expect(state.courseware.sequenceId).toEqual(sequenceId);
|
expect(state.courseware.sequenceId).toEqual(sequenceId);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
/* eslint-disable import/prefer-default-export */
|
/* eslint-disable import/prefer-default-export */
|
||||||
|
import { COURSE_LOADED } from '../../active-course';
|
||||||
|
|
||||||
export function sequenceIdsSelector(state) {
|
export function sequenceIdsSelector(state) {
|
||||||
if (state.courseware.courseStatus !== 'loaded') {
|
if (state.activeCourse.courseStatus !== COURSE_LOADED) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
const { sectionIds = [] } = state.models.courses[state.courseware.courseId];
|
const { sectionIds = [] } = state.models.courses[state.activeCourse.courseId];
|
||||||
|
|
||||||
const sequenceIds = sectionIds
|
const sequenceIds = sectionIds
|
||||||
.flatMap(sectionId => state.models.sections[sectionId].sequenceIds);
|
.flatMap(sectionId => state.models.sections[sectionId].sequenceIds);
|
||||||
|
|||||||
@@ -1,56 +1,33 @@
|
|||||||
/* eslint-disable no-param-reassign */
|
/* eslint-disable no-param-reassign */
|
||||||
import { createSlice } from '@reduxjs/toolkit';
|
import { createSlice } from '@reduxjs/toolkit';
|
||||||
|
|
||||||
export const LOADING = 'loading';
|
export const SEQUENCE_LOADING = 'loading';
|
||||||
export const LOADED = 'loaded';
|
export const SEQUENCE_LOADED = 'loaded';
|
||||||
export const FAILED = 'failed';
|
export const SEQUENCE_FAILED = 'failed';
|
||||||
export const DENIED = 'denied';
|
|
||||||
|
|
||||||
const slice = createSlice({
|
const slice = createSlice({
|
||||||
name: 'courseware',
|
name: 'courseware',
|
||||||
initialState: {
|
initialState: {
|
||||||
courseStatus: 'loading',
|
sequenceStatus: SEQUENCE_LOADING,
|
||||||
courseId: null,
|
|
||||||
sequenceStatus: 'loading',
|
|
||||||
sequenceId: null,
|
sequenceId: null,
|
||||||
},
|
},
|
||||||
reducers: {
|
reducers: {
|
||||||
fetchCourseRequest: (state, { payload }) => {
|
|
||||||
state.courseId = payload.courseId;
|
|
||||||
state.courseStatus = LOADING;
|
|
||||||
},
|
|
||||||
fetchCourseSuccess: (state, { payload }) => {
|
|
||||||
state.courseId = payload.courseId;
|
|
||||||
state.courseStatus = LOADED;
|
|
||||||
},
|
|
||||||
fetchCourseFailure: (state, { payload }) => {
|
|
||||||
state.courseId = payload.courseId;
|
|
||||||
state.courseStatus = FAILED;
|
|
||||||
},
|
|
||||||
fetchCourseDenied: (state, { payload }) => {
|
|
||||||
state.courseId = payload.courseId;
|
|
||||||
state.courseStatus = DENIED;
|
|
||||||
},
|
|
||||||
fetchSequenceRequest: (state, { payload }) => {
|
fetchSequenceRequest: (state, { payload }) => {
|
||||||
state.sequenceId = payload.sequenceId;
|
state.sequenceId = payload.sequenceId;
|
||||||
state.sequenceStatus = LOADING;
|
state.sequenceStatus = SEQUENCE_LOADING;
|
||||||
},
|
},
|
||||||
fetchSequenceSuccess: (state, { payload }) => {
|
fetchSequenceSuccess: (state, { payload }) => {
|
||||||
state.sequenceId = payload.sequenceId;
|
state.sequenceId = payload.sequenceId;
|
||||||
state.sequenceStatus = LOADED;
|
state.sequenceStatus = SEQUENCE_LOADED;
|
||||||
},
|
},
|
||||||
fetchSequenceFailure: (state, { payload }) => {
|
fetchSequenceFailure: (state, { payload }) => {
|
||||||
state.sequenceId = payload.sequenceId;
|
state.sequenceId = payload.sequenceId;
|
||||||
state.sequenceStatus = FAILED;
|
state.sequenceStatus = SEQUENCE_FAILED;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export const {
|
export const {
|
||||||
fetchCourseRequest,
|
|
||||||
fetchCourseSuccess,
|
|
||||||
fetchCourseFailure,
|
|
||||||
fetchCourseDenied,
|
|
||||||
fetchSequenceRequest,
|
fetchSequenceRequest,
|
||||||
fetchSequenceSuccess,
|
fetchSequenceSuccess,
|
||||||
fetchSequenceFailure,
|
fetchSequenceFailure,
|
||||||
|
|||||||
@@ -14,11 +14,15 @@ import {
|
|||||||
fetchCourseSuccess,
|
fetchCourseSuccess,
|
||||||
fetchCourseFailure,
|
fetchCourseFailure,
|
||||||
fetchCourseDenied,
|
fetchCourseDenied,
|
||||||
|
} from '../../active-course';
|
||||||
|
import {
|
||||||
fetchSequenceRequest,
|
fetchSequenceRequest,
|
||||||
fetchSequenceSuccess,
|
fetchSequenceSuccess,
|
||||||
fetchSequenceFailure,
|
fetchSequenceFailure,
|
||||||
} from './slice';
|
} from './slice';
|
||||||
|
|
||||||
|
const FULFILLED = 'fulfilled';
|
||||||
|
|
||||||
export function fetchCourse(courseId) {
|
export function fetchCourse(courseId) {
|
||||||
return async (dispatch) => {
|
return async (dispatch) => {
|
||||||
dispatch(fetchCourseRequest({ courseId }));
|
dispatch(fetchCourseRequest({ courseId }));
|
||||||
@@ -26,14 +30,17 @@ export function fetchCourse(courseId) {
|
|||||||
getCourseMetadata(courseId),
|
getCourseMetadata(courseId),
|
||||||
getCourseBlocks(courseId),
|
getCourseBlocks(courseId),
|
||||||
]).then(([courseMetadataResult, courseBlocksResult]) => {
|
]).then(([courseMetadataResult, courseBlocksResult]) => {
|
||||||
if (courseMetadataResult.status === 'fulfilled') {
|
const fetchedMetadata = courseMetadataResult.status === FULFILLED;
|
||||||
|
const fetchedBlocks = courseBlocksResult.status === FULFILLED;
|
||||||
|
|
||||||
|
if (fetchedMetadata) {
|
||||||
dispatch(addModel({
|
dispatch(addModel({
|
||||||
modelType: 'courses',
|
modelType: 'courses',
|
||||||
model: courseMetadataResult.value,
|
model: courseMetadataResult.value,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (courseBlocksResult.status === 'fulfilled') {
|
if (fetchedBlocks) {
|
||||||
const {
|
const {
|
||||||
courses, sections, sequences, units,
|
courses, sections, sequences, units,
|
||||||
} = courseBlocksResult.value;
|
} = courseBlocksResult.value;
|
||||||
@@ -58,9 +65,6 @@ export function fetchCourse(courseId) {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
const fetchedMetadata = courseMetadataResult.status === 'fulfilled';
|
|
||||||
const fetchedBlocks = courseBlocksResult.status === 'fulfilled';
|
|
||||||
|
|
||||||
// Log errors for each request if needed. Course block failures may occur
|
// Log errors for each request if needed. Course block failures may occur
|
||||||
// even if the course metadata request is successful
|
// even if the course metadata request is successful
|
||||||
if (!fetchedBlocks) {
|
if (!fetchedBlocks) {
|
||||||
@@ -72,7 +76,7 @@ export function fetchCourse(courseId) {
|
|||||||
|
|
||||||
if (fetchedMetadata) {
|
if (fetchedMetadata) {
|
||||||
if (courseMetadataResult.value.canLoadCourseware.hasAccess && fetchedBlocks) {
|
if (courseMetadataResult.value.canLoadCourseware.hasAccess && fetchedBlocks) {
|
||||||
// User has access
|
// User has access - we dispatch this at the end now that all the data is loaded.
|
||||||
dispatch(fetchCourseSuccess({ courseId }));
|
dispatch(fetchCourseSuccess({ courseId }));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1 +1,3 @@
|
|||||||
export { default } from './CoursewareContainer';
|
export { default } from './CoursewareContainer';
|
||||||
|
export { default as CoursewareRedirect } from './CoursewareRedirect';
|
||||||
|
export { reducer } from './data/slice';
|
||||||
|
|||||||
@@ -19,8 +19,7 @@ import { UserMessagesProvider } from './generic/user-messages';
|
|||||||
import './index.scss';
|
import './index.scss';
|
||||||
import './assets/favicon.ico';
|
import './assets/favicon.ico';
|
||||||
import OutlineTab from './course-home/outline-tab';
|
import OutlineTab from './course-home/outline-tab';
|
||||||
import CoursewareContainer from './courseware';
|
import CoursewareContainer, { CoursewareRedirect } from './courseware';
|
||||||
import CoursewareRedirect from './CoursewareRedirect';
|
|
||||||
import DatesTab from './course-home/dates-tab';
|
import DatesTab from './course-home/dates-tab';
|
||||||
import ProgressTab from './course-home/progress-tab/ProgressTab';
|
import ProgressTab from './course-home/progress-tab/ProgressTab';
|
||||||
import { TabContainer } from './tab-page';
|
import { TabContainer } from './tab-page';
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import { configureStore } from '@reduxjs/toolkit';
|
|||||||
import { IntlProvider } from 'react-intl';
|
import { IntlProvider } from 'react-intl';
|
||||||
import MockAdapter from 'axios-mock-adapter';
|
import MockAdapter from 'axios-mock-adapter';
|
||||||
import AppProvider from '@edx/frontend-platform/react/AppProvider';
|
import AppProvider from '@edx/frontend-platform/react/AppProvider';
|
||||||
import { reducer as courseHomeReducer } from './course-home/data';
|
import { reducer as activeCourseReducer } from './active-course';
|
||||||
import { reducer as coursewareReducer } from './courseware/data/slice';
|
import { reducer as coursewareReducer } from './courseware/data/slice';
|
||||||
import { reducer as modelsReducer } from './generic/model-store';
|
import { reducer as modelsReducer } from './generic/model-store';
|
||||||
import { UserMessagesProvider } from './generic/user-messages';
|
import { UserMessagesProvider } from './generic/user-messages';
|
||||||
@@ -81,8 +81,8 @@ export async function initializeTestStore(options = {}, overrideStore = true) {
|
|||||||
const store = configureStore({
|
const store = configureStore({
|
||||||
reducer: {
|
reducer: {
|
||||||
models: modelsReducer,
|
models: modelsReducer,
|
||||||
|
activeCourse: activeCourseReducer,
|
||||||
courseware: coursewareReducer,
|
courseware: coursewareReducer,
|
||||||
courseHome: courseHomeReducer,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (overrideStore) {
|
if (overrideStore) {
|
||||||
|
|||||||
11
src/store.js
11
src/store.js
@@ -1,14 +1,17 @@
|
|||||||
import { configureStore } from '@reduxjs/toolkit';
|
import { configureStore } from '@reduxjs/toolkit';
|
||||||
import { reducer as courseHomeReducer } from './course-home/data';
|
|
||||||
import { reducer as coursewareReducer } from './courseware/data/slice';
|
import { reducer as activeCourseReducer } from './active-course';
|
||||||
|
import { reducer as courseHomeReducer } from './course-home';
|
||||||
|
import { reducer as coursewareReducer } from './courseware';
|
||||||
import { reducer as modelsReducer } from './generic/model-store';
|
import { reducer as modelsReducer } from './generic/model-store';
|
||||||
|
|
||||||
export default function initializeStore() {
|
export default function initializeStore() {
|
||||||
return configureStore({
|
return configureStore({
|
||||||
reducer: {
|
reducer: {
|
||||||
models: modelsReducer,
|
activeCourse: activeCourseReducer,
|
||||||
courseware: coursewareReducer,
|
|
||||||
courseHome: courseHomeReducer,
|
courseHome: courseHomeReducer,
|
||||||
|
courseware: coursewareReducer,
|
||||||
|
models: modelsReducer,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ export default function TabContainer(props) {
|
|||||||
const {
|
const {
|
||||||
courseId,
|
courseId,
|
||||||
courseStatus,
|
courseStatus,
|
||||||
} = useSelector(state => state.courseHome);
|
} = useSelector(state => state.activeCourse);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TabPage
|
<TabPage
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import messages from './messages';
|
|||||||
import LoadedTabPage from './LoadedTabPage';
|
import LoadedTabPage from './LoadedTabPage';
|
||||||
import LearningToast from '../toast/LearningToast';
|
import LearningToast from '../toast/LearningToast';
|
||||||
import { toggleResetDatesToast } from '../course-home/data/slice';
|
import { toggleResetDatesToast } from '../course-home/data/slice';
|
||||||
|
import { COURSE_LOADED, COURSE_LOADING } from '../active-course';
|
||||||
|
|
||||||
function TabPage({
|
function TabPage({
|
||||||
intl,
|
intl,
|
||||||
@@ -22,7 +23,7 @@ function TabPage({
|
|||||||
} = useSelector(state => state.courseHome);
|
} = useSelector(state => state.courseHome);
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
|
|
||||||
if (courseStatus === 'loading') {
|
if (courseStatus === COURSE_LOADING) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Header />
|
<Header />
|
||||||
@@ -33,7 +34,7 @@ function TabPage({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (courseStatus === 'loaded') {
|
if (courseStatus === COURSE_LOADED) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<LearningToast
|
<LearningToast
|
||||||
|
|||||||
Reference in New Issue
Block a user