Files
frontend-app-learning/src/courseware/data/thunks.js
2021-09-22 09:11:55 -04:00

320 lines
11 KiB
JavaScript

import { logError, logInfo } from '@edx/frontend-platform/logging';
import {
getBlockCompletion,
postSequencePosition,
getCourseMetadata,
getCourseBlocks,
getLearningSequencesOutline,
getSequenceMetadata,
postIntegritySignature,
} from './api';
import {
updateModel, addModel, updateModelsMap, addModelsMap, updateModels,
} from '../../generic/model-store';
import {
fetchCourseRequest,
fetchCourseSuccess,
fetchCourseFailure,
fetchCourseDenied,
fetchSequenceRequest,
fetchSequenceSuccess,
fetchSequenceFailure,
} from './slice';
/**
* Combines the models from the Course Blocks and Learning Sequences API into a
* new models obj that is returned. Does not mutate the models passed in.
*
* For performance and long term maintainability, we want to switch as much of
* the Courseware MFE to use the Learning Sequences API as possible, and
* eventually remove calls to the Course Blocks API. However, right now, certain
* data still has to come form the Course Blocks API. This function is a
* transitional step to help build out some of the data from the new API, while
* falling back to the Course Blocks API for other things.
*
* Overall performance gains will not be realized until we completely remove
* this call to the Course Blocks API (and the need for this function).
*
* @param {*} learningSequencesModels Normalized model from normalizeLearningSequencesData
* @param {*} courseBlocksModels Normalized model from normalizeBlocks
* @param {bool} isMasquerading Is Masquerading being used?
*/
function mergeLearningSequencesWithCourseBlocks(learningSequencesModels, courseBlocksModels, isMasquerading) {
// If there's no Learning Sequences API data yet (not active for this course),
// send back the course blocks model as-is. Likewise, Learning Sequences
// doesn't currently handle masquerading properly for content groups.
if (isMasquerading || learningSequencesModels === null) {
return courseBlocksModels;
}
const mergedModels = {
courses: {},
sections: {},
sequences: {},
// Units are now copied over verbatim from Course Blocks API, but they
// should eventually come just-in-time, once the Sequence Metadata API is
// made to be acceptably fast.
units: courseBlocksModels.units,
};
// Top level course information
//
// It is not at all clear to me why courses is a dict when there's only ever
// one course, but I'm not going to make that model change right now.
const lsCourse = Object.values(learningSequencesModels.courses)[0];
const [courseBlockId, courseBlock] = Object.entries(courseBlocksModels.courses)[0];
// The Learning Sequences API never exposes the usage key of the root course
// block, which is used as the key here (instead of the CourseKey). It doesn't
// look like anything actually queries for this value though, and even the
// courseBlocksModels.courses uses the CourseKey as the "id" in the value. So
// I'm imitating the form here to minimize the chance of things breaking, but
// I think we should just forget the keys and replace courses with a singular
// course. I might end up doing that before my refactoring is done here. >_<
mergedModels.courses[courseBlockId] = {
// Learning Sequences API Data
id: lsCourse.id,
title: lsCourse.title,
sectionIds: lsCourse.sectionIds,
hasScheduledContent: lsCourse.hasScheduledContent,
// Still pulling from Course Blocks API
effortActivities: courseBlock.effortActivities,
effortTime: courseBlock.effortTime,
};
// List of Sequences comes from Learning Sequences. Course Blocks will have
// extra sequences that we don't want to display to the user, like ones that
// are empty because all the enclosed units are in user partition groups that
// the user is not a part of (e.g. Verified Track).
Object.entries(learningSequencesModels.sequences).forEach(([sequenceId, sequence]) => {
const blocksSequence = courseBlocksModels.sequences[sequenceId];
mergedModels.sequences[sequenceId] = {
// Learning Sequences API Data
id: sequenceId,
title: sequence.title,
// Still pulling from Course Blocks API Data:
effortActivities: blocksSequence.effortActivities,
effortTime: blocksSequence.effortTime,
legacyWebUrl: blocksSequence.legacyWebUrl,
unitIds: blocksSequence.unitIds,
};
// Add back-references to this sequence for all child units.
blocksSequence.unitIds.forEach(childUnitId => {
mergedModels.units[childUnitId].sequenceId = sequenceId;
});
});
// List of Sections comes from Learning Sequences.
Object.entries(learningSequencesModels.sections).forEach(([sectionId, section]) => {
const blocksSection = courseBlocksModels.sections[sectionId];
mergedModels.sections[sectionId] = {
// Learning Sequences API Data
id: sectionId,
title: section.title,
sequenceIds: section.sequenceIds,
courseId: lsCourse.id,
// Still pulling from Course Blocks API Data:
effortActivities: blocksSection.effortActivities,
effortTime: blocksSection.effortTime,
};
// Add back-references to this section for all child sequences.
section.sequenceIds.forEach(childSeqId => {
mergedModels.sequences[childSeqId].sectionId = sectionId;
});
});
return mergedModels;
}
export function fetchCourse(courseId) {
return async (dispatch) => {
dispatch(fetchCourseRequest({ courseId }));
Promise.allSettled([
getCourseMetadata(courseId),
getCourseBlocks(courseId),
getLearningSequencesOutline(courseId),
]).then(([courseMetadataResult, courseBlocksResult, learningSequencesOutlineResult]) => {
if (courseMetadataResult.status === 'fulfilled') {
dispatch(addModel({
modelType: 'coursewareMeta',
model: courseMetadataResult.value,
}));
}
if (courseBlocksResult.status === 'fulfilled') {
const {
courses, sections, sequences, units,
} = mergeLearningSequencesWithCourseBlocks(
learningSequencesOutlineResult.value,
courseBlocksResult.value,
courseMetadataResult.value.isMasquerading,
);
// This updates the course with a sectionIds array from the blocks data.
dispatch(updateModelsMap({
modelType: 'coursewareMeta',
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) {
const { response } = courseBlocksResult.reason;
if (response && response.status === 403) {
// 403 responses are normal - they happen when the learner is logged out.
// We'll redirect them in a moment to the outline tab by calling fetchCourseDenied() below.
logInfo(courseBlocksResult.reason);
} else {
logError(courseBlocksResult.reason);
}
}
if (!fetchedMetadata) {
logError(courseMetadataResult.reason);
}
if (fetchedMetadata) {
if (courseMetadataResult.value.courseAccess.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);
if (sequence.blockType !== 'sequential') {
// Some other block types (particularly 'chapter') can be returned
// by this API. We want to error in that case, since downstream
// courseware code is written to render Sequences of Units.
logError(
`Requested sequence '${sequenceId}' `
+ `has block type '${sequence.blockType}'; expected block type 'sequential'.`,
);
dispatch(fetchSequenceFailure({ sequenceId }));
} else {
dispatch(updateModel({
modelType: 'sequences',
model: sequence,
}));
dispatch(updateModels({
modelType: 'units',
models: units,
}));
dispatch(fetchSequenceSuccess({ sequenceId }));
}
} catch (error) {
logError(error);
dispatch(fetchSequenceFailure({ sequenceId }));
}
};
}
export function checkBlockCompletion(courseId, sequenceId, unitId) {
return async (dispatch, getState) => {
const { models } = getState();
if (models.units[unitId].complete) {
return; // do nothing. Things don't get uncompleted after they are completed.
}
try {
const isComplete = await getBlockCompletion(courseId, sequenceId, unitId);
dispatch(updateModel({
modelType: 'units',
model: {
id: unitId,
complete: isComplete,
},
}));
} catch (error) {
logError(error);
}
};
}
export function saveSequencePosition(courseId, sequenceId, activeUnitIndex) {
return async (dispatch, getState) => {
const { models } = getState();
const initialActiveUnitIndex = models.sequences[sequenceId].activeUnitIndex;
// Optimistically update the position.
dispatch(updateModel({
modelType: 'sequences',
model: {
id: sequenceId,
activeUnitIndex,
},
}));
try {
await postSequencePosition(courseId, sequenceId, activeUnitIndex);
// Update again under the assumption that the above call succeeded, since it doesn't return a
// meaningful response.
dispatch(updateModel({
modelType: 'sequences',
model: {
id: sequenceId,
activeUnitIndex,
},
}));
} catch (error) {
logError(error);
dispatch(updateModel({
modelType: 'sequences',
model: {
id: sequenceId,
activeUnitIndex: initialActiveUnitIndex,
},
}));
}
};
}
export function saveIntegritySignature(courseId) {
return async (dispatch) => {
try {
await postIntegritySignature(courseId);
dispatch(updateModel({
modelType: 'coursewareMeta',
model: {
id: courseId,
userNeedsIntegritySignature: false,
},
}));
} catch (error) {
logError(error);
}
};
}