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:
David Joy
2020-07-02 13:11:50 -04:00
committed by GitHub
parent a6edc9132f
commit 73c74119f0
68 changed files with 427 additions and 408 deletions

View File

@@ -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,
});
}

View File

@@ -1,7 +0,0 @@
export {
fetchCourse,
fetchSequence,
} from './thunks';
export { getResumeBlock, normalizeBlocks } from './api';
export { reducer } from './slice';

View File

@@ -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;

View File

@@ -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 }));
}
};
}