Extensive refactor of application data management. (#32)

* Extensive refactor of application data management.

- “course-blocks” and “course-meta” are replaced with “courseware” module.  This obscures the difference between the two from the application itself.
- a generic “model-store” module is used to store all course, section, sequence, and unit data in a normalized way, agnostic to the metadata vs. blocks APIs.
- SequenceContainer has been removed, and it’s work is just done in CourseContainer instead.
- UI components are - in general - more responsible for deciding their own behavior during data loading.  If they want to show a spinner or nothing, it’s up to their discretion.
- The API layer is responsible for normalizing data into a form the app will want to use, prior to putting it into the model store.

* Organizing into some more sub-modules.

- Bookmarks becomes it’s own module.
- SequenceNavigation becomes another one.

* More modularization of data directories.

- Moving model-store up to the top.
- Moving fetchCourse and fetchSequence up to the top-level data directory, since they’re used by both courseware and outline.
- Moving getBlockCompletion and updateSequencePosition into the courseware/data directory, since they pertain to that page.

* Normalizing on using the word “title”

* Using history.replace instead of history.push

This fixes TNL-7125

* Allowing sub-components to use hooks and redux

This reduces the amount of data we need to pass around, and lets us move some complexity to more natural modules.

* Fixing bug where enrollment alert is shown for undefined isEnrolled

The enrollment alert would inadvertently be shown if a user navigated from the outline to the course.  This was because it interpreted an undefined “isEnrolled” flag as false.  Instead, we should wait for the isEnrolled flag to be explicitly true or false.

* Organizing modules.

- Renaming “outline” to “course-home”.
- Moving sequence and sequence-navigation modules under the course module.

* Some final application organization and ADR write-ups.

* Final refactoring

- Favoring passing data by ID and looking it up in the store with useModel.
- Moving headers into course-header directory.

* Updating ADRs.  Splitting model-store information out into its own ADR.
This commit is contained in:
David Joy
2020-03-23 11:31:09 -04:00
committed by GitHub
parent 720594a7cf
commit 9cbb765f8a
78 changed files with 1544 additions and 1625 deletions

146
src/data/api.js Normal file
View File

@@ -0,0 +1,146 @@
/* eslint-disable import/prefer-default-export */
import { getConfig, camelCaseObject } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient, getAuthenticatedUser } from '@edx/frontend-platform/auth';
import { logError } from '@edx/frontend-platform/logging';
function normalizeMetadata(metadata) {
return {
id: metadata.id,
title: metadata.name,
number: metadata.number,
org: metadata.org,
enrollmentStart: metadata.enrollment_start,
enrollmentEnd: metadata.enrollment_end,
end: metadata.end,
start: metadata.start,
enrollmentMode: metadata.enrollment.mode,
isEnrolled: metadata.enrollment.is_active,
userHasAccess: metadata.user_has_access,
isStaff: metadata.user_has_staff_access,
verifiedMode: camelCaseObject(metadata.verified_mode),
tabs: camelCaseObject(metadata.tabs),
};
}
export async function getCourseMetadata(courseUsageKey) {
const url = `${getConfig().LMS_BASE_URL}/api/courseware/course/${courseUsageKey}`;
const { data } = await getAuthenticatedHttpClient().get(url);
return normalizeMetadata(data);
}
function normalizeBlocks(courseUsageKey, blocks) {
const models = {
courses: {},
sections: {},
sequences: {},
units: {},
};
Object.values(blocks).forEach(block => {
switch (block.type) {
case 'course':
models.courses[block.id] = {
id: courseUsageKey,
title: block.display_name,
sectionIds: block.children,
};
break;
case 'chapter':
models.sections[block.id] = {
id: block.id,
title: block.display_name,
sequenceIds: block.children,
};
break;
case 'sequential':
models.sequences[block.id] = {
id: block.id,
title: block.display_name,
lmsWebUrl: block.lms_web_url,
unitIds: block.children,
};
break;
case 'vertical':
models.units[block.id] = {
id: block.id,
title: block.display_name,
};
break;
default:
logError(`Unexpected course block type: ${block.type} with ID ${block.id}. Expected block types are course, chapter, sequential, and vertical.`);
}
});
// Next go through each list and use their child lists to decorate those children with a
// reference back to their parent.
Object.values(models.courses).forEach(course => {
if (Array.isArray(course.sectionIds)) {
course.sectionIds.forEach(sectionId => {
const section = models.sections[sectionId];
section.courseId = course.id;
});
}
});
Object.values(models.sections).forEach(section => {
if (Array.isArray(section.sequenceIds)) {
section.sequenceIds.forEach(sequenceId => {
models.sequences[sequenceId].sectionId = section.id;
});
}
});
Object.values(models.sequences).forEach(sequence => {
if (Array.isArray(sequence.unitIds)) {
sequence.unitIds.forEach(unitId => {
models.units[unitId].sequenceId = sequence.id;
});
}
});
return models;
}
export async function getCourseBlocks(courseUsageKey) {
const { username } = getAuthenticatedUser();
const url = new URL(`${getConfig().LMS_BASE_URL}/api/courses/v2/blocks/`);
url.searchParams.append('course_id', courseUsageKey);
url.searchParams.append('username', username);
url.searchParams.append('depth', 3);
url.searchParams.append('requested_fields', 'children,show_gated_sections');
const { data } = await getAuthenticatedHttpClient().get(url.href, {});
return normalizeBlocks(courseUsageKey, data.blocks);
}
function normalizeSequenceMetadata(sequence) {
return {
sequence: {
id: sequence.item_id,
unitIds: sequence.items.map(unit => unit.id),
bannerText: sequence.banner_text,
title: sequence.display_name,
gatedContent: camelCaseObject(sequence.gated_content),
isTimeLimited: sequence.is_time_limited,
// Position comes back from the server 1-indexed. Adjust here.
activeUnitIndex: sequence.position ? sequence.position - 1 : 0,
saveUnitPosition: sequence.save_position,
showCompletion: sequence.show_completion,
},
units: sequence.items.map(unit => ({
id: unit.id,
sequenceId: sequence.item_id,
bookmarked: unit.bookmarked,
complete: unit.complete,
title: unit.page_title,
contentType: unit.type,
})),
};
}
export async function getSequenceMetadata(sequenceId) {
const { data } = await getAuthenticatedHttpClient()
.get(`${getConfig().LMS_BASE_URL}/api/courseware/sequence/${sequenceId}`, {});
return normalizeSequenceMetadata(data);
}

View File

@@ -1,109 +0,0 @@
import { getConfig, camelCaseObject } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient, getAuthenticatedUser } from '@edx/frontend-platform/auth';
export async function getCourseBlocks(courseUsageKey) {
const { username } = getAuthenticatedUser();
const url = new URL(`${getConfig().LMS_BASE_URL}/api/courses/v2/blocks/`);
url.searchParams.append('course_id', courseUsageKey);
url.searchParams.append('username', username);
url.searchParams.append('depth', 3);
url.searchParams.append('requested_fields', 'children,show_gated_sections');
const { data } = await getAuthenticatedHttpClient().get(url.href, {});
// Camelcase block objects (leave blockId keys alone)
const blocks = Object.entries(data.blocks).reduce((acc, [key, value]) => {
acc[key] = camelCaseObject(value);
return acc;
}, {});
// Next go through the blocksList again - now that we've added them all to the blocks map - and
// append a parent ID to every child found in every `children` list, using the blocks map to find
// them.
Object.values(blocks).forEach((block) => {
if (Array.isArray(block.children)) {
const parentId = block.id;
block.children.forEach((childBlockId) => {
blocks[childBlockId].parentId = parentId;
});
}
});
const processedData = camelCaseObject(data);
processedData.blocks = blocks;
return processedData;
}
export async function getSequenceMetadata(sequenceId) {
const { data } = await getAuthenticatedHttpClient()
.get(`${getConfig().LMS_BASE_URL}/api/courseware/sequence/${sequenceId}`, {});
const camelCasedData = camelCaseObject(data);
camelCasedData.items = camelCasedData.items.map((item) => {
const processedItem = camelCaseObject(item);
processedItem.contentType = processedItem.type;
delete processedItem.type;
return processedItem;
});
// Position comes back from the server 1-indexed. Adjust here.
camelCasedData.position = camelCasedData.position ? camelCasedData.position - 1 : 0;
return camelCasedData;
}
const getSequenceXModuleHandlerUrl = (courseUsageKey, sequenceId) => `${getConfig().LMS_BASE_URL}/courses/${courseUsageKey}/xblock/${sequenceId}/handler/xmodule_handler`;
export async function updateSequencePosition(courseUsageKey, sequenceId, position) {
// Post data sent to this endpoint must be url encoded
// TODO: Remove the need for this to be the case.
// TODO: Ensure this usage of URLSearchParams is working in Internet Explorer
const urlEncoded = new URLSearchParams();
// Position is 1-indexed on the server and 0-indexed in this app. Adjust here.
urlEncoded.append('position', position + 1);
const requestConfig = {
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
};
const { data } = await getAuthenticatedHttpClient().post(
`${getSequenceXModuleHandlerUrl(courseUsageKey, sequenceId)}/goto_position`,
urlEncoded.toString(),
requestConfig,
);
return data;
}
export async function getBlockCompletion(courseUsageKey, sequenceId, usageKey) {
// Post data sent to this endpoint must be url encoded
// TODO: Remove the need for this to be the case.
// TODO: Ensure this usage of URLSearchParams is working in Internet Explorer
const urlEncoded = new URLSearchParams();
urlEncoded.append('usage_key', usageKey);
const requestConfig = {
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
};
const { data } = await getAuthenticatedHttpClient().post(
`${getSequenceXModuleHandlerUrl(courseUsageKey, sequenceId)}/get_completion`,
urlEncoded.toString(),
requestConfig,
);
if (data.complete) {
return true;
}
return false;
}
const bookmarksBaseUrl = `${getConfig().LMS_BASE_URL}/api/bookmarks/v1/bookmarks/`;
export async function createBookmark(usageId) {
return getAuthenticatedHttpClient().post(bookmarksBaseUrl, { usage_id: usageId });
}
export async function deleteBookmark(usageId) {
const { username } = getAuthenticatedUser();
return getAuthenticatedHttpClient().delete(`${bookmarksBaseUrl}${username},${usageId}/`);
}

View File

@@ -1,20 +0,0 @@
export {
getCourseBlocks,
getSequenceMetadata,
updateSequencePosition,
getBlockCompletion,
createBookmark,
deleteBookmark,
} from './api';
export {
reducer,
courseBlocksShape,
} from './slice';
export {
fetchCourseBlocks,
fetchSequenceMetadata,
checkBlockCompletion,
saveSequencePosition,
addBookmark,
removeBookmark,
} from './thunks';

View File

@@ -1,142 +0,0 @@
/* eslint-disable no-param-reassign */
import { createSlice } from '@reduxjs/toolkit';
import PropTypes from 'prop-types';
const blocksSlice = createSlice({
name: 'blocks',
initialState: {
fetchState: null,
root: null,
blocks: {},
},
reducers: {
/**
* fetchCourseBlocks
* This routine is responsible for fetching all blocks in a course.
*/
fetchCourseBlocksRequest: (draftState) => {
draftState.fetchState = 'loading';
},
fetchCourseBlocksSuccess: (draftState, { payload }) => ({
...payload,
fetchState: 'loaded',
loaded: true,
}),
fetchCourseBlocksFailure: (draftState) => {
draftState.fetchState = 'failed';
},
/**
* fetchBlockMetadata
* This routine is responsible for fetching metadata for any kind of
* block (sequential, vertical or any other block) and merging that
* data with what is in the store. Currently used for:
*
* - fetchSequenceMetadata
* - checkBlockCompletion (Vertical blocks)
*/
fetchBlockMetadataRequest: (draftState, action) => {
const { blockId } = action.payload;
if (!draftState.blocks[blockId]) {
draftState.blocks[blockId] = {};
}
draftState.blocks[blockId].fetchState = 'loading';
},
fetchBlockMetadataSuccess: (draftState, action) => {
const { blockId, metadata, relatedBlocksMetadata } = action.payload;
if (!draftState.blocks[blockId]) {
draftState.blocks[blockId] = {};
}
draftState.blocks[blockId] = {
...draftState.blocks[blockId],
...metadata,
fetchState: 'loaded',
loaded: true,
};
if (relatedBlocksMetadata) {
relatedBlocksMetadata.forEach((blockMetadata) => {
if (draftState.blocks[blockMetadata.id] === undefined) {
draftState.blocks[blockMetadata.id] = {};
}
draftState.blocks[blockMetadata.id] = {
...draftState.blocks[blockMetadata.id],
...blockMetadata,
};
});
}
},
fetchBlockMetadataFailure: (draftState, action) => {
const { blockId } = action.payload;
if (!draftState.blocks[blockId]) {
draftState.blocks[blockId] = {};
}
draftState.blocks[blockId].fetchState = 'failure';
},
/**
* updateBlock
* This routine is responsible for CRUD operations on block properties.
* Updates to blocks are handled in an optimistic way applying the update
* to the store at request time and then reverting it if the update fails.
*
* TODO: It may be helpful to add a flag to be optimistic or not.
*
* The update state of a property is added to the block in the store with
* a dynamic property name: ${propertyToUpdate}UpdateState.
* (e.g. bookmarkedUpdateState)
*
* Used in:
* - saveSequencePosition
* - addBookmark
* - removeBookmark
*/
updateBlockRequest: (draftState, action) => {
const { blockId, propertyToUpdate, updateValue } = action.payload;
const updateStateKey = `${propertyToUpdate}UpdateState`;
if (!draftState.blocks[blockId]) {
draftState.blocks[blockId] = {};
}
draftState.blocks[blockId][updateStateKey] = 'loading';
draftState.blocks[blockId][propertyToUpdate] = updateValue;
},
updateBlockSuccess: (draftState, action) => {
const { blockId, propertyToUpdate, updateValue } = action.payload;
const updateStateKey = `${propertyToUpdate}UpdateState`;
if (!draftState.blocks[blockId]) {
draftState.blocks[blockId] = {};
}
draftState.blocks[blockId][updateStateKey] = 'updated';
draftState.blocks[blockId][propertyToUpdate] = updateValue;
},
updateBlockFailure: (draftState, action) => {
const { blockId, propertyToUpdate, initialValue } = action.payload;
const updateStateKey = `${propertyToUpdate}UpdateState`;
if (!draftState.blocks[blockId]) {
draftState.blocks[blockId] = {};
}
draftState.blocks[blockId][updateStateKey] = 'failed';
draftState.blocks[blockId][propertyToUpdate] = initialValue;
},
},
});
export const {
fetchCourseBlocksRequest,
fetchCourseBlocksSuccess,
fetchCourseBlocksFailure,
fetchBlockMetadataRequest,
fetchBlockMetadataSuccess,
fetchBlockMetadataFailure,
updateBlockRequest,
updateBlockSuccess,
updateBlockFailure,
} = blocksSlice.actions;
export const { reducer } = blocksSlice;
export const courseBlocksShape = PropTypes.objectOf(PropTypes.shape({
id: PropTypes.string.isRequired,
displayName: PropTypes.string.isRequired,
children: PropTypes.arrayOf(PropTypes.string),
parentId: PropTypes.string,
}));

View File

@@ -1,131 +0,0 @@
import { logError } from '@edx/frontend-platform/logging';
import {
fetchCourseBlocksRequest,
fetchCourseBlocksSuccess,
fetchCourseBlocksFailure,
fetchBlockMetadataRequest,
fetchBlockMetadataSuccess,
fetchBlockMetadataFailure,
updateBlockRequest,
updateBlockSuccess,
updateBlockFailure,
} from './slice';
import {
getCourseBlocks,
getSequenceMetadata,
getBlockCompletion,
updateSequencePosition,
createBookmark,
deleteBookmark,
} from './api';
export function fetchCourseBlocks(courseUsageKey) {
return async (dispatch) => {
dispatch(fetchCourseBlocksRequest(courseUsageKey));
try {
const courseBlocks = await getCourseBlocks(courseUsageKey);
dispatch(fetchCourseBlocksSuccess(courseBlocks));
} catch (error) {
logError(error);
dispatch(fetchCourseBlocksFailure(courseUsageKey));
}
};
}
export function fetchSequenceMetadata(sequenceBlockId) {
return async (dispatch) => {
dispatch(fetchBlockMetadataRequest({ blockId: sequenceBlockId }));
try {
const sequenceMetadata = await getSequenceMetadata(sequenceBlockId);
dispatch(fetchBlockMetadataSuccess({
blockId: sequenceBlockId,
metadata: sequenceMetadata,
relatedBlocksMetadata: sequenceMetadata.items,
}));
} catch (error) {
logError(error);
dispatch(fetchBlockMetadataFailure({ blockId: sequenceBlockId }));
}
};
}
export function checkBlockCompletion(courseUsageKey, sequenceId, unitId) {
return async (dispatch, getState) => {
const { courseBlocks } = getState();
if (courseBlocks.blocks[unitId].complete) {
return; // do nothing. Things don't get uncompleted after they are completed.
}
const commonPayload = { blockId: unitId, fetchType: 'completion' };
dispatch(fetchBlockMetadataRequest(commonPayload));
try {
const isComplete = await getBlockCompletion(courseUsageKey, sequenceId, unitId);
dispatch(fetchBlockMetadataSuccess({
...commonPayload,
metadata: {
complete: isComplete,
},
}));
} catch (error) {
logError(error);
dispatch(fetchBlockMetadataFailure(commonPayload));
}
};
}
export function saveSequencePosition(courseUsageKey, sequenceId, position) {
return async (dispatch, getState) => {
const { courseBlocks } = getState();
const actionPayload = {
blockId: sequenceId,
propertyToUpdate: 'position',
updateValue: position,
initialValue: courseBlocks.blocks[sequenceId].position,
};
dispatch(updateBlockRequest(actionPayload));
try {
await updateSequencePosition(courseUsageKey, sequenceId, position);
dispatch(updateBlockSuccess(actionPayload));
} catch (error) {
logError(error);
dispatch(updateBlockFailure(actionPayload));
}
};
}
export function addBookmark(unitId) {
return async (dispatch) => {
const actionPayload = {
blockId: unitId,
propertyToUpdate: 'bookmarked',
updateValue: true,
initialValue: false,
};
dispatch(updateBlockRequest(actionPayload));
try {
await createBookmark(unitId);
dispatch(updateBlockSuccess(actionPayload));
} catch (error) {
logError(error);
dispatch(updateBlockFailure(actionPayload));
}
};
}
export function removeBookmark(unitId) {
return async (dispatch) => {
const actionPayload = {
blockId: unitId,
propertyToUpdate: 'bookmarked',
updateValue: false,
initialValue: true,
};
dispatch(updateBlockRequest(actionPayload));
try {
await deleteBookmark(unitId);
dispatch(updateBlockSuccess(actionPayload));
} catch (error) {
logError(error);
dispatch(updateBlockFailure(actionPayload));
}
};
}

View File

@@ -1,10 +0,0 @@
/* eslint-disable import/prefer-default-export */
import { getConfig, camelCaseObject } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
export async function getCourseMetadata(courseUsageKey) {
const url = `${getConfig().LMS_BASE_URL}/api/courseware/course/${courseUsageKey}`;
const { data } = await getAuthenticatedHttpClient().get(url);
const processedData = camelCaseObject(data);
return processedData;
}

View File

@@ -1,6 +0,0 @@
export { getCourseMetadata } from './api';
export {
reducer,
courseMetadataShape,
} from './slice';
export { fetchCourseMetadata } from './thunks';

View File

@@ -1,95 +0,0 @@
/* eslint-disable no-param-reassign */
import { createSlice } from '@reduxjs/toolkit';
import PropTypes from 'prop-types';
const courseMetaSlice = createSlice({
name: 'course-meta',
initialState: {
fetchState: null,
},
reducers: {
fetchCourseMetadataRequest: (draftState) => {
draftState.fetchState = 'loading';
},
fetchCourseMetadataSuccess: (draftState, { payload }) => ({
fetchState: 'loaded',
/*
* NOTE: If you change the data saved here,
* update the courseMetadataShape below!
*/
// Course identifiers
name: payload.name,
number: payload.number,
org: payload.org,
// Enrollment dates
enrollmentStart: payload.enrollmentStart,
enrollmentEnd: payload.enrollmentEnd,
// Course dates
end: payload.end,
start: payload.start,
// User access/enrollment status
enrollmentMode: payload.enrollment.mode,
isEnrolled: payload.enrollment.isActive,
userHasAccess: payload.userHasAccess,
isStaff: payload.userHasStaffAccess,
verifiedMode: payload.verifiedMode,
// Misc
tabs: payload.tabs,
}),
fetchCourseMetadataFailure: (draftState) => {
draftState.fetchState = 'failed';
},
},
});
export const {
fetchCourseMetadataRequest,
fetchCourseMetadataSuccess,
fetchCourseMetadataFailure,
} = courseMetaSlice.actions;
export const { reducer } = courseMetaSlice;
export const courseMetadataShape = PropTypes.shape({
fetchState: PropTypes.string,
// Course identifiers
name: PropTypes.string,
number: PropTypes.string,
org: PropTypes.string,
// Enrollment dates
enrollmentStart: PropTypes.string,
enrollmentEnd: PropTypes.string,
// User access/enrollment status
enrollmentMode: PropTypes.string,
isEnrolled: PropTypes.bool,
userHasAccess: PropTypes.bool,
isStaff: PropTypes.bool,
verifiedMode: PropTypes.shape({
price: PropTypes.number.isRequired,
currency: PropTypes.string.isRequired,
currencySymbol: PropTypes.string.isRequired,
sku: PropTypes.string.isRequired,
upgradeUrl: PropTypes.string.isRequired,
}),
// Course dates
start: PropTypes.string,
end: PropTypes.string,
// Misc
tabs: PropTypes.arrayOf(PropTypes.shape({
priority: PropTypes.number,
slug: PropTypes.string,
title: PropTypes.string,
type: PropTypes.string,
url: PropTypes.string,
})),
});

View File

@@ -1,23 +0,0 @@
/* eslint-disable import/prefer-default-export */
import { logError } from '@edx/frontend-platform/logging';
import {
fetchCourseMetadataRequest,
fetchCourseMetadataSuccess,
fetchCourseMetadataFailure,
} from './slice';
import {
getCourseMetadata,
} from './api';
export function fetchCourseMetadata(courseUsageKey) {
return async (dispatch) => {
dispatch(fetchCourseMetadataRequest({ courseUsageKey }));
try {
const courseMetadata = await getCourseMetadata(courseUsageKey);
dispatch(fetchCourseMetadataSuccess(courseMetadata));
} catch (error) {
logError(error);
dispatch(fetchCourseMetadataFailure({ courseUsageKey }));
}
};
}

6
src/data/index.js Normal file
View File

@@ -0,0 +1,6 @@
export {
fetchCourse,
fetchSequence,
} from './thunks';
export { reducer } from './slice';

55
src/data/slice.js Normal file
View File

@@ -0,0 +1,55 @@
/* eslint-disable no-param-reassign */
import { createSlice } from '@reduxjs/toolkit';
export const LOADING = 'loading';
export const LOADED = 'loaded';
export const FAILED = 'failed';
const slice = createSlice({
name: 'courseware',
initialState: {
courseStatus: 'loading',
courseUsageKey: null,
sequenceStatus: 'loading',
sequenceId: null,
},
reducers: {
fetchCourseRequest: (state, { payload }) => {
state.courseUsageKey = payload.courseUsageKey;
state.courseStatus = LOADING;
},
fetchCourseSuccess: (state, { payload }) => {
state.courseUsageKey = payload.courseUsageKey;
state.courseStatus = LOADED;
},
fetchCourseFailure: (state, { payload }) => {
state.courseUsageKey = payload.courseUsageKey;
state.courseStatus = FAILED;
},
fetchSequenceRequest: (state, { payload }) => {
state.sequenceId = payload.sequenceId;
state.sequenceStatus = LOADING;
},
fetchSequenceSuccess: (state, { payload }) => {
state.sequenceId = payload.sequenceId;
state.sequenceStatus = LOADED;
},
fetchSequenceFailure: (state, { payload }) => {
state.sequenceId = payload.sequenceId;
state.sequenceStatus = FAILED;
},
},
});
export const {
fetchCourseRequest,
fetchCourseSuccess,
fetchCourseFailure,
fetchSequenceRequest,
fetchSequenceSuccess,
fetchSequenceFailure,
} = slice.actions;
export const {
reducer,
} = slice;

78
src/data/thunks.js Normal file
View File

@@ -0,0 +1,78 @@
import { logError } from '@edx/frontend-platform/logging';
import {
getCourseMetadata,
getCourseBlocks,
getSequenceMetadata,
} from './api';
import {
addModelsMap, updateModel, updateModels,
} from '../model-store';
import {
fetchCourseRequest,
fetchCourseSuccess,
fetchCourseFailure,
fetchSequenceRequest,
fetchSequenceSuccess,
fetchSequenceFailure,
} from './slice';
export function fetchCourse(courseUsageKey) {
return async (dispatch) => {
dispatch(fetchCourseRequest({ courseUsageKey }));
Promise.all([
getCourseBlocks(courseUsageKey),
getCourseMetadata(courseUsageKey),
]).then(([
{
courses, sections, sequences, units,
},
course,
]) => {
dispatch(addModelsMap({
modelType: 'courses',
modelsMap: courses,
}));
dispatch(updateModel({
modelType: 'courses',
model: course,
}));
dispatch(addModelsMap({
modelType: 'sections',
modelsMap: sections,
}));
dispatch(addModelsMap({
modelType: 'sequences',
modelsMap: sequences,
}));
dispatch(addModelsMap({
modelType: 'units',
modelsMap: units,
}));
dispatch(fetchCourseSuccess({ courseUsageKey }));
}).catch((error) => {
logError(error);
dispatch(fetchCourseFailure({ courseUsageKey }));
});
};
}
export function fetchSequence(sequenceId) {
return async (dispatch) => {
dispatch(fetchSequenceRequest({ sequenceId }));
try {
const { sequence, units } = await getSequenceMetadata(sequenceId);
dispatch(updateModel({
modelType: 'sequences',
model: sequence,
}));
dispatch(updateModels({
modelType: 'units',
models: units,
}));
dispatch(fetchSequenceSuccess({ sequenceId }));
} catch (error) {
logError(error);
dispatch(fetchSequenceFailure({ sequenceId }));
}
};
}