Organizationing (#102)
* Moving model-store into “generic” sub-directory. Also adding a README.md to explain what belongs in “generic” * Moving user-messages into “generic” sub-directory. * Moving PageLoading into “generic” sub-directory. * Moving “tabs” module into “generic” sub-directory. * Moving InstructorToolbar and MasqueradeWidget up to instructor-toolbar. The masquerade widget is a sub-module of instructor-toolbar. * Co-locating celebration APIs with celebration utils. Also adding an ADR about thunk/API naming conventions and making some other areas of the code adhere to it. * Moving courseware data (thunks, api) into the courseware module. Note that cousre-home/data/api still uses normalizeBlocks - this should be fixed so it’s not reaching across. Maybe we pull that particular API up top. This PR includes a few TODOs for things I saw, as well as a tiny bit of whitespace cleanup.
This commit is contained in:
194
src/data/api.js
194
src/data/api.js
@@ -1,194 +0,0 @@
|
||||
/* 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 normalizeTabUrls(id, tabs) {
|
||||
// If api doesn't return the mfe base url, change tab url to point back to LMS
|
||||
return tabs.map((tab) => {
|
||||
let { url } = tab;
|
||||
if (url[0] === '/') {
|
||||
url = `${getConfig().LMS_BASE_URL}${tab.url}`;
|
||||
}
|
||||
return { ...tab, url };
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeMetadata(metadata) {
|
||||
return {
|
||||
canShowUpgradeSock: metadata.can_show_upgrade_sock,
|
||||
contentTypeGatingEnabled: metadata.content_type_gating_enabled,
|
||||
// TODO: TNL-7185: return course expired _date_, instead of _message_
|
||||
courseExpiredMessage: metadata.course_expired_message,
|
||||
id: metadata.id,
|
||||
title: metadata.name,
|
||||
number: metadata.number,
|
||||
offerHtml: metadata.offer_html,
|
||||
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,
|
||||
canLoadCourseware: camelCaseObject(metadata.can_load_courseware),
|
||||
isStaff: metadata.is_staff,
|
||||
verifiedMode: camelCaseObject(metadata.verified_mode),
|
||||
tabs: normalizeTabUrls(metadata.id, camelCaseObject(metadata.tabs)),
|
||||
showCalculator: metadata.show_calculator,
|
||||
notes: camelCaseObject(metadata.notes),
|
||||
marketingUrl: metadata.marketing_url,
|
||||
celebrations: camelCaseObject(metadata.celebrations),
|
||||
};
|
||||
}
|
||||
|
||||
export async function getCourseMetadata(courseId) {
|
||||
const url = `${getConfig().LMS_BASE_URL}/api/courseware/course/${courseId}`;
|
||||
const { data } = await getAuthenticatedHttpClient().get(url);
|
||||
return normalizeMetadata(data);
|
||||
}
|
||||
|
||||
export function normalizeBlocks(courseId, blocks) {
|
||||
const models = {
|
||||
courses: {},
|
||||
sections: {},
|
||||
sequences: {},
|
||||
units: {},
|
||||
};
|
||||
Object.values(blocks).forEach(block => {
|
||||
switch (block.type) {
|
||||
case 'course':
|
||||
models.courses[block.id] = {
|
||||
id: courseId,
|
||||
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] = {
|
||||
graded: block.graded,
|
||||
id: block.id,
|
||||
title: block.display_name,
|
||||
lmsWebUrl: block.lms_web_url,
|
||||
};
|
||||
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(courseId) {
|
||||
const { username } = getAuthenticatedUser();
|
||||
const url = new URL(`${getConfig().LMS_BASE_URL}/api/courses/v2/blocks/`);
|
||||
url.searchParams.append('course_id', courseId);
|
||||
url.searchParams.append('username', username);
|
||||
url.searchParams.append('depth', 3);
|
||||
url.searchParams.append('requested_fields', 'children,show_gated_sections,graded');
|
||||
|
||||
const { data } = await getAuthenticatedHttpClient().get(url.href, {});
|
||||
return normalizeBlocks(courseId, 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);
|
||||
}
|
||||
|
||||
export async function getResumeBlock(courseId) {
|
||||
const url = new URL(`${getConfig().LMS_BASE_URL}/api/courseware/resume/${courseId}`);
|
||||
const { data } = await getAuthenticatedHttpClient().get(url.href, {});
|
||||
return camelCaseObject(data);
|
||||
}
|
||||
|
||||
export async function getMasqueradeOptions(courseId) {
|
||||
const url = new URL(`${getConfig().LMS_BASE_URL}/courses/${courseId}/masquerade`);
|
||||
const { data } = await getAuthenticatedHttpClient().get(url.href, {});
|
||||
return camelCaseObject(data);
|
||||
}
|
||||
|
||||
export async function postMasqueradeOptions(courseId, data) {
|
||||
const url = new URL(`${getConfig().LMS_BASE_URL}/courses/${courseId}/masquerade`);
|
||||
const { response } = await getAuthenticatedHttpClient().post(url.href, data);
|
||||
return camelCaseObject(response);
|
||||
}
|
||||
|
||||
// Does not block on answer
|
||||
export function setFirstSectionCelebrationComplete(courseId) {
|
||||
const url = new URL(`${getConfig().LMS_BASE_URL}/api/courseware/celebration/${courseId}`);
|
||||
getAuthenticatedHttpClient().post(url.href, {
|
||||
first_section: false,
|
||||
});
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
export {
|
||||
fetchCourse,
|
||||
fetchSequence,
|
||||
} from './thunks';
|
||||
|
||||
export { getResumeBlock, normalizeBlocks } from './api';
|
||||
export { reducer } from './slice';
|
||||
@@ -1,61 +0,0 @@
|
||||
/* eslint-disable no-param-reassign */
|
||||
import { createSlice } from '@reduxjs/toolkit';
|
||||
|
||||
export const LOADING = 'loading';
|
||||
export const LOADED = 'loaded';
|
||||
export const FAILED = 'failed';
|
||||
export const DENIED = 'denied';
|
||||
|
||||
const slice = createSlice({
|
||||
name: 'courseware',
|
||||
initialState: {
|
||||
courseStatus: 'loading',
|
||||
courseId: null,
|
||||
sequenceStatus: 'loading',
|
||||
sequenceId: null,
|
||||
},
|
||||
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 }) => {
|
||||
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,
|
||||
fetchCourseDenied,
|
||||
fetchSequenceRequest,
|
||||
fetchSequenceSuccess,
|
||||
fetchSequenceFailure,
|
||||
} = slice.actions;
|
||||
|
||||
export const {
|
||||
reducer,
|
||||
} = slice;
|
||||
@@ -1,108 +0,0 @@
|
||||
import { logError } from '@edx/frontend-platform/logging';
|
||||
import {
|
||||
getCourseMetadata,
|
||||
getCourseBlocks,
|
||||
getSequenceMetadata,
|
||||
} from './api';
|
||||
import {
|
||||
addModelsMap, updateModel, updateModels, updateModelsMap, addModel,
|
||||
} from '../model-store';
|
||||
import {
|
||||
fetchCourseRequest,
|
||||
fetchCourseSuccess,
|
||||
fetchCourseFailure,
|
||||
fetchCourseDenied,
|
||||
fetchSequenceRequest,
|
||||
fetchSequenceSuccess,
|
||||
fetchSequenceFailure,
|
||||
} from './slice';
|
||||
|
||||
export function fetchCourse(courseId) {
|
||||
return async (dispatch) => {
|
||||
dispatch(fetchCourseRequest({ courseId }));
|
||||
Promise.allSettled([
|
||||
getCourseMetadata(courseId),
|
||||
getCourseBlocks(courseId),
|
||||
]).then(([courseMetadataResult, courseBlocksResult]) => {
|
||||
if (courseMetadataResult.status === 'fulfilled') {
|
||||
dispatch(addModel({
|
||||
modelType: 'courses',
|
||||
model: courseMetadataResult.value,
|
||||
}));
|
||||
}
|
||||
|
||||
if (courseBlocksResult.status === 'fulfilled') {
|
||||
const {
|
||||
courses, sections, sequences, units,
|
||||
} = courseBlocksResult.value;
|
||||
|
||||
// This updates the course with a sectionIds array from the blocks data.
|
||||
dispatch(updateModelsMap({
|
||||
modelType: 'courses',
|
||||
modelsMap: courses,
|
||||
}));
|
||||
dispatch(addModelsMap({
|
||||
modelType: 'sections',
|
||||
modelsMap: sections,
|
||||
}));
|
||||
// We update for sequences and units because the sequence metadata may have come back first.
|
||||
dispatch(updateModelsMap({
|
||||
modelType: 'sequences',
|
||||
modelsMap: sequences,
|
||||
}));
|
||||
dispatch(updateModelsMap({
|
||||
modelType: 'units',
|
||||
modelsMap: units,
|
||||
}));
|
||||
}
|
||||
|
||||
const fetchedMetadata = courseMetadataResult.status === 'fulfilled';
|
||||
const fetchedBlocks = courseBlocksResult.status === 'fulfilled';
|
||||
|
||||
// Log errors for each request if needed. Course block failures may occur
|
||||
// even if the course metadata request is successful
|
||||
if (!fetchedBlocks) {
|
||||
logError(courseBlocksResult.reason);
|
||||
}
|
||||
if (!fetchedMetadata) {
|
||||
logError(courseMetadataResult.reason);
|
||||
}
|
||||
|
||||
if (fetchedMetadata) {
|
||||
if (courseMetadataResult.value.canLoadCourseware.hasAccess && fetchedBlocks) {
|
||||
// User has access
|
||||
dispatch(fetchCourseSuccess({ courseId }));
|
||||
return;
|
||||
}
|
||||
// User either doesn't have access or only has partial access
|
||||
// (can't access course blocks)
|
||||
dispatch(fetchCourseDenied({ courseId }));
|
||||
return;
|
||||
}
|
||||
|
||||
// Definitely an error happening
|
||||
dispatch(fetchCourseFailure({ courseId }));
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
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 }));
|
||||
}
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user