Courseware and courseHome both provide tabs to the mfe. This PR unifies the calls so that tab descriptions are only fetched from courseHome metadata Remove jest-chain dependencies to make test errors more usable.
438 lines
17 KiB
JavaScript
438 lines
17 KiB
JavaScript
import { camelCaseObject, getConfig } from '@edx/frontend-platform';
|
|
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
|
import { logInfo } from '@edx/frontend-platform/logging';
|
|
import { appendBrowserTimezoneToUrl } from '../../utils';
|
|
|
|
const calculateAssignmentTypeGrades = (points, assignmentWeight, numDroppable) => {
|
|
let dropCount = numDroppable;
|
|
// Drop the lowest grades
|
|
while (dropCount && points.length >= dropCount) {
|
|
const lowestScore = Math.min(...points);
|
|
const lowestScoreIndex = points.indexOf(lowestScore);
|
|
points.splice(lowestScoreIndex, 1);
|
|
dropCount--;
|
|
}
|
|
let averageGrade = 0;
|
|
let weightedGrade = 0;
|
|
if (points.length) {
|
|
// Calculate the average grade for the assignment and round it. This rounding is not ideal and does not accurately
|
|
// reflect what a learner's grade would be, however, we must have parity with the current grading behavior that
|
|
// exists in edx-platform.
|
|
averageGrade = (points.reduce((a, b) => a + b, 0) / points.length).toFixed(2);
|
|
weightedGrade = averageGrade * assignmentWeight;
|
|
}
|
|
return { averageGrade, weightedGrade };
|
|
};
|
|
|
|
function normalizeAssignmentPolicies(assignmentPolicies, sectionScores) {
|
|
const gradeByAssignmentType = {};
|
|
assignmentPolicies.forEach(assignment => {
|
|
// Create an array with the number of total assignments and set the scores to 0
|
|
// as placeholders for assignments that have not yet been released
|
|
gradeByAssignmentType[assignment.type] = {
|
|
grades: Array(assignment.numTotal).fill(0),
|
|
numAssignmentsCreated: 0,
|
|
numTotalExpectedAssignments: assignment.numTotal,
|
|
};
|
|
});
|
|
|
|
sectionScores.forEach((chapter) => {
|
|
chapter.subsections.forEach((subsection) => {
|
|
if (!(subsection.hasGradedAssignment && subsection.showGrades && subsection.numPointsPossible)) {
|
|
return;
|
|
}
|
|
const {
|
|
assignmentType,
|
|
numPointsEarned,
|
|
numPointsPossible,
|
|
} = subsection;
|
|
|
|
// If a subsection's assignment type does not match an assignment policy in Studio,
|
|
// we won't be able to include it in this accumulation of grades by assignment type.
|
|
// This may happen if a course author has removed/renamed an assignment policy in Studio and
|
|
// neglected to update the subsection's of that assignment type
|
|
if (!gradeByAssignmentType[assignmentType]) {
|
|
return;
|
|
}
|
|
|
|
let {
|
|
numAssignmentsCreated,
|
|
} = gradeByAssignmentType[assignmentType];
|
|
|
|
numAssignmentsCreated++;
|
|
if (numAssignmentsCreated <= gradeByAssignmentType[assignmentType].numTotalExpectedAssignments) {
|
|
// Remove a placeholder grade so long as the number of recorded created assignments is less than the number
|
|
// of expected assignments
|
|
gradeByAssignmentType[assignmentType].grades.shift();
|
|
}
|
|
// Add the graded assignment to the list
|
|
gradeByAssignmentType[assignmentType].grades.push(numPointsEarned ? numPointsEarned / numPointsPossible : 0);
|
|
// Record the created assignment
|
|
gradeByAssignmentType[assignmentType].numAssignmentsCreated = numAssignmentsCreated;
|
|
});
|
|
});
|
|
|
|
return assignmentPolicies.map((assignment) => {
|
|
const { averageGrade, weightedGrade } = calculateAssignmentTypeGrades(
|
|
gradeByAssignmentType[assignment.type].grades,
|
|
assignment.weight,
|
|
assignment.numDroppable,
|
|
);
|
|
|
|
return {
|
|
averageGrade,
|
|
numDroppable: assignment.numDroppable,
|
|
shortLabel: assignment.shortLabel,
|
|
type: assignment.type,
|
|
weight: assignment.weight,
|
|
weightedGrade,
|
|
};
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Tweak the metadata for consistency
|
|
* @param metadata the data to normalize
|
|
* @param rootSlug either 'courseware' or 'outline' depending on the context
|
|
* @returns {Object} The normalized metadata
|
|
*/
|
|
function normalizeCourseHomeCourseMetadata(metadata, rootSlug) {
|
|
const data = camelCaseObject(metadata);
|
|
return {
|
|
...data,
|
|
tabs: data.tabs.map(tab => ({
|
|
// The API uses "courseware" as a slug for both courseware and the outline tab.
|
|
// If needed, we switch it to "outline" here for
|
|
// use within the MFE to differentiate between course home and courseware.
|
|
slug: tab.tabId === 'courseware' ? rootSlug : tab.tabId,
|
|
title: tab.title,
|
|
url: tab.url,
|
|
})),
|
|
isMasquerading: data.originalUserIsStaff && !data.isStaff,
|
|
};
|
|
}
|
|
|
|
export function normalizeOutlineBlocks(courseId, blocks) {
|
|
const models = {
|
|
courses: {},
|
|
sections: {},
|
|
sequences: {},
|
|
};
|
|
Object.values(blocks).forEach(block => {
|
|
switch (block.type) {
|
|
case 'course':
|
|
models.courses[block.id] = {
|
|
id: courseId,
|
|
title: block.display_name,
|
|
sectionIds: block.children || [],
|
|
hasScheduledContent: block.has_scheduled_content,
|
|
};
|
|
break;
|
|
|
|
case 'chapter':
|
|
models.sections[block.id] = {
|
|
complete: block.complete,
|
|
id: block.id,
|
|
title: block.display_name,
|
|
resumeBlock: block.resume_block,
|
|
sequenceIds: block.children || [],
|
|
};
|
|
break;
|
|
|
|
case 'sequential':
|
|
models.sequences[block.id] = {
|
|
complete: block.complete,
|
|
description: block.description,
|
|
due: block.due,
|
|
effortActivities: block.effort_activities,
|
|
effortTime: block.effort_time,
|
|
icon: block.icon,
|
|
id: block.id,
|
|
legacyWebUrl: block.legacy_web_url,
|
|
// The presence of an legacy URL for the sequence indicates that we want this
|
|
// sequence to be a clickable link in the outline (even though, if the new
|
|
// courseware experience is active, we will ignore `legacyWebUrl` and build a
|
|
// link to the MFE ourselves).
|
|
showLink: !!block.legacy_web_url,
|
|
title: block.display_name,
|
|
};
|
|
break;
|
|
|
|
default:
|
|
logInfo(`Unexpected course block type: ${block.type} with ID ${block.id}. Expected block types are course, chapter, and sequential.`);
|
|
}
|
|
});
|
|
|
|
// 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 => {
|
|
if (sequenceId in models.sequences) {
|
|
models.sequences[sequenceId].sectionId = section.id;
|
|
} else {
|
|
logInfo(`Section ${section.id} has child block ${sequenceId}, but that block is not in the list of sequences.`);
|
|
}
|
|
});
|
|
}
|
|
});
|
|
|
|
return models;
|
|
}
|
|
|
|
export async function getCourseHomeCourseMetadata(courseId, rootSlug) {
|
|
let url = `${getConfig().LMS_BASE_URL}/api/course_home/course_metadata/${courseId}`;
|
|
url = appendBrowserTimezoneToUrl(url);
|
|
const { data } = await getAuthenticatedHttpClient().get(url);
|
|
return normalizeCourseHomeCourseMetadata(data, rootSlug);
|
|
}
|
|
|
|
// For debugging purposes, you might like to see a fully loaded dates tab.
|
|
// Just uncomment the next few lines and the immediate 'return' in the function below
|
|
// import { Factory } from 'rosie';
|
|
// import './__factories__';
|
|
export async function getDatesTabData(courseId) {
|
|
// return camelCaseObject(Factory.build('datesTabData'));
|
|
const url = `${getConfig().LMS_BASE_URL}/api/course_home/dates/${courseId}`;
|
|
try {
|
|
const { data } = await getAuthenticatedHttpClient().get(url);
|
|
return camelCaseObject(data);
|
|
} catch (error) {
|
|
const { httpErrorStatus } = error && error.customAttributes;
|
|
if (httpErrorStatus === 404) {
|
|
global.location.replace(`${getConfig().LMS_BASE_URL}/courses/${courseId}/dates`);
|
|
return {};
|
|
}
|
|
if (httpErrorStatus === 401) {
|
|
// The backend sends this for unenrolled and unauthenticated learners, but we handle those cases by examining
|
|
// courseAccess in the metadata call, so just ignore this status for now.
|
|
return {};
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
export async function getProgressTabData(courseId, targetUserId) {
|
|
let url = `${getConfig().LMS_BASE_URL}/api/course_home/progress/${courseId}`;
|
|
|
|
// If targetUserId is passed in, we will get the progress page data
|
|
// for the user with the provided id, rather than the requesting user.
|
|
if (targetUserId) {
|
|
url += `/${targetUserId}/`;
|
|
}
|
|
|
|
try {
|
|
const { data } = await getAuthenticatedHttpClient().get(url);
|
|
const camelCasedData = camelCaseObject(data);
|
|
|
|
camelCasedData.gradingPolicy.assignmentPolicies = normalizeAssignmentPolicies(
|
|
camelCasedData.gradingPolicy.assignmentPolicies,
|
|
camelCasedData.sectionScores,
|
|
);
|
|
|
|
// Accumulate the weighted grades by assignment type to calculate the learner facing grade. The grades within
|
|
// assignmentPolicies have been filtered by what's visible to the learner.
|
|
camelCasedData.courseGrade.visiblePercent = camelCasedData.gradingPolicy.assignmentPolicies
|
|
? camelCasedData.gradingPolicy.assignmentPolicies.reduce(
|
|
(accumulator, assignment) => accumulator + assignment.weightedGrade, 0,
|
|
) : camelCasedData.courseGrade.percent;
|
|
|
|
camelCasedData.courseGrade.isPassing = camelCasedData.courseGrade.visiblePercent
|
|
>= Math.min(...Object.values(data.grading_policy.grade_range));
|
|
|
|
// We replace gradingPolicy.gradeRange with the original data to preserve the intended casing for the grade.
|
|
// For example, if a grade range key is "A", we do not want it to be camel cased (i.e. "A" would become "a")
|
|
// in order to preserve a course team's desired grade formatting.
|
|
camelCasedData.gradingPolicy.gradeRange = data.grading_policy.grade_range;
|
|
|
|
camelCasedData.gradesFeatureIsFullyLocked = camelCasedData.completionSummary.lockedCount > 0;
|
|
|
|
camelCasedData.gradesFeatureIsPartiallyLocked = false;
|
|
if (camelCasedData.gradesFeatureIsFullyLocked) {
|
|
camelCasedData.sectionScores.forEach((chapter) => {
|
|
chapter.subsections.forEach((subsection) => {
|
|
// If something is eligible to be gated by content type gating and would show up on the progress page
|
|
if (subsection.assignmentType !== null && subsection.hasGradedAssignment && subsection.showGrades
|
|
&& (subsection.numPointsPossible > 0 || subsection.numPointsEarned > 0)) {
|
|
// but the learner still has access to it, then we are in a partially locked, rather than fully locked state
|
|
// since the learner has access to some (but not all) content that would normally be locked
|
|
if (subsection.learnerHasAccess) {
|
|
camelCasedData.gradesFeatureIsPartiallyLocked = true;
|
|
camelCasedData.gradesFeatureIsFullyLocked = false;
|
|
}
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
return camelCasedData;
|
|
} catch (error) {
|
|
const { httpErrorStatus } = error && error.customAttributes;
|
|
if (httpErrorStatus === 404) {
|
|
global.location.replace(`${getConfig().LMS_BASE_URL}/courses/${courseId}/progress`);
|
|
return {};
|
|
}
|
|
if (httpErrorStatus === 401) {
|
|
// The backend sends this for unenrolled and unauthenticated learners, but we handle those cases by examining
|
|
// courseAccess in the metadata call, so just ignore this status for now.
|
|
return {};
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
export async function getProctoringInfoData(courseId, username) {
|
|
let url = `${getConfig().LMS_BASE_URL}/api/edx_proctoring/v1/user_onboarding/status?is_learning_mfe=true&course_id=${encodeURIComponent(courseId)}`;
|
|
if (username) {
|
|
url += `&username=${encodeURIComponent(username)}`;
|
|
}
|
|
try {
|
|
const { data } = await getAuthenticatedHttpClient().get(url);
|
|
return data;
|
|
} catch (error) {
|
|
const { httpErrorStatus } = error && error.customAttributes;
|
|
if (httpErrorStatus === 404) {
|
|
return {};
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
export function getTimeOffsetMillis(headerDate, requestTime, responseTime) {
|
|
// Time offset computation should move down into the HttpClient wrapper to maintain a global time correction reference
|
|
// Requires 'Access-Control-Expose-Headers: Date' on the server response per https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS#access-control-expose-headers
|
|
|
|
let timeOffsetMillis = 0;
|
|
if (headerDate !== undefined) {
|
|
const headerTime = Date.parse(headerDate);
|
|
const roundTripMillis = requestTime - responseTime;
|
|
const localTime = responseTime - (roundTripMillis / 2); // Roughly compensate for transit time
|
|
timeOffsetMillis = headerTime - localTime;
|
|
}
|
|
|
|
return timeOffsetMillis;
|
|
}
|
|
|
|
export async function getOutlineTabData(courseId) {
|
|
const url = `${getConfig().LMS_BASE_URL}/api/course_home/outline/${courseId}`;
|
|
let { tabData } = {};
|
|
let requestTime = Date.now();
|
|
let responseTime = requestTime;
|
|
try {
|
|
requestTime = Date.now();
|
|
tabData = await getAuthenticatedHttpClient().get(url);
|
|
responseTime = Date.now();
|
|
} catch (error) {
|
|
const { httpErrorStatus } = error && error.customAttributes;
|
|
if (httpErrorStatus === 404) {
|
|
global.location.replace(`${getConfig().LMS_BASE_URL}/courses/${courseId}/course/`);
|
|
return {};
|
|
}
|
|
throw error;
|
|
}
|
|
|
|
const {
|
|
data,
|
|
headers,
|
|
} = tabData;
|
|
|
|
const accessExpiration = camelCaseObject(data.access_expiration);
|
|
const canShowUpgradeSock = data.can_show_upgrade_sock;
|
|
const certData = camelCaseObject(data.cert_data);
|
|
const courseBlocks = data.course_blocks ? normalizeOutlineBlocks(courseId, data.course_blocks.blocks) : {};
|
|
const courseGoals = camelCaseObject(data.course_goals);
|
|
const courseTools = camelCaseObject(data.course_tools);
|
|
const datesBannerInfo = camelCaseObject(data.dates_banner_info);
|
|
const datesWidget = camelCaseObject(data.dates_widget);
|
|
const enableProctoredExams = data.enable_proctored_exams;
|
|
const enrollAlert = camelCaseObject(data.enroll_alert);
|
|
const enrollmentMode = data.enrollment_mode;
|
|
const handoutsHtml = data.handouts_html;
|
|
const hasScheduledContent = data.has_scheduled_content;
|
|
const hasEnded = data.has_ended;
|
|
const offer = camelCaseObject(data.offer);
|
|
const resumeCourse = camelCaseObject(data.resume_course);
|
|
const timeOffsetMillis = getTimeOffsetMillis(headers && headers.date, requestTime, responseTime);
|
|
const userHasPassingGrade = data.user_has_passing_grade;
|
|
const verifiedMode = camelCaseObject(data.verified_mode);
|
|
const welcomeMessageHtml = data.welcome_message_html;
|
|
|
|
return {
|
|
accessExpiration,
|
|
canShowUpgradeSock,
|
|
certData,
|
|
courseBlocks,
|
|
courseGoals,
|
|
courseTools,
|
|
datesBannerInfo,
|
|
datesWidget,
|
|
enrollAlert,
|
|
enrollmentMode,
|
|
enableProctoredExams,
|
|
handoutsHtml,
|
|
hasScheduledContent,
|
|
hasEnded,
|
|
offer,
|
|
resumeCourse,
|
|
timeOffsetMillis, // This should move to a global time correction reference
|
|
userHasPassingGrade,
|
|
verifiedMode,
|
|
welcomeMessageHtml,
|
|
};
|
|
}
|
|
|
|
export async function postCourseDeadlines(courseId, model) {
|
|
const url = new URL(`${getConfig().LMS_BASE_URL}/api/course_experience/v1/reset_course_deadlines`);
|
|
return getAuthenticatedHttpClient().post(url.href, {
|
|
course_key: courseId,
|
|
research_event_data: { location: `${model}-tab` },
|
|
});
|
|
}
|
|
|
|
export async function deprecatedPostCourseGoals(courseId, goalKey) {
|
|
const url = new URL(`${getConfig().LMS_BASE_URL}/api/course_home/save_course_goal`);
|
|
return getAuthenticatedHttpClient().post(url.href, { course_id: courseId, goal_key: goalKey });
|
|
}
|
|
|
|
export async function postWeeklyLearningGoal(courseId, daysPerWeek, subscribedToReminders) {
|
|
const url = new URL(`${getConfig().LMS_BASE_URL}/api/course_home/save_course_goal`);
|
|
return getAuthenticatedHttpClient().post(url.href, {
|
|
course_id: courseId,
|
|
days_per_week: daysPerWeek,
|
|
subscribed_to_reminders: subscribedToReminders,
|
|
});
|
|
}
|
|
|
|
export async function postDismissWelcomeMessage(courseId) {
|
|
const url = new URL(`${getConfig().LMS_BASE_URL}/api/course_home/dismiss_welcome_message`);
|
|
await getAuthenticatedHttpClient().post(url.href, { course_id: courseId });
|
|
}
|
|
|
|
export async function postRequestCert(courseId) {
|
|
const url = new URL(`${getConfig().LMS_BASE_URL}/courses/${courseId}/generate_user_cert`);
|
|
await getAuthenticatedHttpClient().post(url.href);
|
|
}
|
|
|
|
export async function executePostFromPostEvent(postData, researchEventData) {
|
|
const url = new URL(postData.url);
|
|
return getAuthenticatedHttpClient().post(url.href, {
|
|
course_key: postData.bodyParams.courseId,
|
|
research_event_data: researchEventData,
|
|
});
|
|
}
|
|
|
|
export async function unsubscribeFromCourseGoal(token) {
|
|
const url = new URL(`${getConfig().LMS_BASE_URL}/api/course_home/unsubscribe_from_course_goal/${token}`);
|
|
return getAuthenticatedHttpClient().post(url.href)
|
|
.then(res => camelCaseObject(res));
|
|
}
|