fix: replaced the LMS endpoint for navigating the course unit page

fix: [AXIMST-424] Course unit - Fixed network connection behavior (#138)

* fix: [AXIMST-424] fixed network connetcion behavior

* fix: added placeholder for unsuccessful loading for the page

* refactor: code refactoring
This commit is contained in:
Peter Kulko
2024-01-29 15:10:58 +02:00
committed by Adolfo R. Brandes
parent d76aaa73a4
commit 8acd27d7bf
10 changed files with 31 additions and 391 deletions

View File

@@ -13,6 +13,7 @@ import getPageHeadTitle from '../generic/utils';
import AlertMessage from '../generic/alert-message';
import ProcessingNotification from '../generic/processing-notification';
import InternetConnectionAlert from '../generic/internet-connection-alert';
import ConnectionErrorAlert from '../generic/ConnectionErrorAlert';
import Loading from '../generic/Loading';
import AddComponent from './add-component/AddComponent';
import CourseXBlock from './course-xblock/CourseXBlock';
@@ -32,6 +33,7 @@ const CourseUnit = ({ courseId }) => {
sequenceId,
unitTitle,
isQueryPending,
sequenceStatus,
savingStatus,
isTitleEditFormOpen,
isErrorAlert,
@@ -57,6 +59,14 @@ const CourseUnit = ({ courseId }) => {
return <Loading />;
}
if (sequenceStatus === RequestStatus.FAILED) {
return (
<Container size="xl" className="course-unit px-4 mt-4">
<ConnectionErrorAlert />
</Container>
);
}
return (
<>
<Container size="xl" className="course-unit px-4">

View File

@@ -289,6 +289,10 @@ module.exports = {
},
},
],
course_sequence_ids: [
'block-v1:edx+876+2030+type@sequential+block@297321078a0f4c26a50d671ed87642a6',
'block-v1:edx+876+2030+type@sequential+block@4e91bdfefd8e4173a03d19c4d91e1936',
],
xblock_info: {
id: 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@867dddb6f55d410caaa9c1eb9c6743ec',
display_name: 'Getting Started',

View File

@@ -3,20 +3,14 @@ import { useLayoutEffect, useRef, useState } from 'react';
import { useWindowSize } from '@openedx/paragon';
import { useModel } from '../../generic/model-store';
import {
getCourseSectionVertical,
getCourseUnit,
sequenceIdsSelector,
} from '../data/selectors';
import { getCourseSectionVertical, getSequenceIds } from '../data/selectors';
export function useSequenceNavigationMetadata(currentSequenceId, currentUnitId) {
const sequenceIds = useSelector(sequenceIdsSelector);
export function useSequenceNavigationMetadata(courseId, currentSequenceId, currentUnitId) {
const { nextUrl, prevUrl } = useSelector(getCourseSectionVertical);
const sequence = useModel('sequences', currentSequenceId);
const { courseId } = useSelector(getCourseUnit);
const isFirstUnit = !prevUrl;
const isLastUnit = !nextUrl;
const sequenceIds = useSelector(getSequenceIds);
const sequenceIndex = sequenceIds.indexOf(currentSequenceId);
const unitIndex = sequence.unitIds.indexOf(currentUnitId);

View File

@@ -20,6 +20,7 @@ import SequenceNavigationTabs from './SequenceNavigationTabs';
const SequenceNavigation = ({
intl,
courseId,
unitId,
sequenceId,
className,
@@ -28,7 +29,7 @@ const SequenceNavigation = ({
const sequenceStatus = useSelector(getSequenceStatus);
const {
isFirstUnit, isLastUnit, nextLink, previousLink,
} = useSequenceNavigationMetadata(sequenceId, unitId);
} = useSequenceNavigationMetadata(courseId, sequenceId, unitId);
const sequence = useModel('sequences', sequenceId);
const shouldDisplayNotificationTriggerInSequence = useWindowSize().width < breakpoints.small.minWidth;
@@ -104,6 +105,7 @@ const SequenceNavigation = ({
SequenceNavigation.propTypes = {
intl: intlShape.isRequired,
courseId: PropTypes.string.isRequired,
unitId: PropTypes.string,
className: PropTypes.string,
sequenceId: PropTypes.string,

View File

@@ -3,23 +3,13 @@ import { camelCaseObject, getConfig } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { PUBLISH_TYPES } from '../constants';
import {
normalizeLearningSequencesData,
normalizeMetadata,
normalizeCourseHomeCourseMetadata,
appendBrowserTimezoneToUrl,
normalizeCourseSectionVerticalData,
} from './utils';
import { normalizeCourseSectionVerticalData } from './utils';
const getStudioBaseUrl = () => getConfig().STUDIO_BASE_URL;
const getLmsBaseUrl = () => getConfig().LMS_BASE_URL;
export const getCourseUnitApiUrl = (itemId) => `${getStudioBaseUrl()}/xblock/container/${itemId}`;
export const getXBlockBaseApiUrl = (itemId) => `${getStudioBaseUrl()}/xblock/${itemId}`;
export const getCourseSectionVerticalApiUrl = (itemId) => `${getStudioBaseUrl()}/api/contentstore/v1/container_handler/${itemId}`;
export const getLearningSequencesOutlineApiUrl = (courseId) => `${getLmsBaseUrl()}/api/learning_sequences/v1/course_outline/${courseId}`;
export const getCourseMetadataApiUrl = (courseId) => `${getLmsBaseUrl()}/api/courseware/course/${courseId}`;
export const getCourseHomeCourseMetadataApiUrl = (courseId) => `${getLmsBaseUrl()}/api/course_home/course_metadata/${courseId}`;
export const getCourseVerticalChildrenApiUrl = (itemId) => `${getStudioBaseUrl()}/api/contentstore/v1/container/vertical/${itemId}/children`;
export const postXBlockBaseApiUrl = () => `${getStudioBaseUrl()}/xblock/`;
@@ -65,45 +55,6 @@ export async function getCourseSectionVerticalData(unitId) {
return normalizeCourseSectionVerticalData(data);
}
/**
* Retrieves the outline of learning sequences for a specific course.
* @param {string} courseId - The ID of the course.
* @returns {Promise<Object>} A Promise that resolves to the normalized learning sequences outline data.
*/
export async function getLearningSequencesOutline(courseId) {
const { href } = new URL(getLearningSequencesOutlineApiUrl(courseId));
const { data } = await getAuthenticatedHttpClient().get(href, {});
return normalizeLearningSequencesData(data);
}
/**
* Retrieves metadata for a specific course.
* @param {string} courseId - The ID of the course.
* @returns {Promise<Object>} A Promise that resolves to the normalized course metadata.
*/
export async function getCourseMetadata(courseId) {
let courseMetadataApiUrl = getCourseMetadataApiUrl(courseId);
courseMetadataApiUrl = appendBrowserTimezoneToUrl(courseMetadataApiUrl);
const metadata = await getAuthenticatedHttpClient().get(courseMetadataApiUrl);
return normalizeMetadata(metadata);
}
/**
* Retrieves metadata for a course's home page.
* @param {string} courseId - The ID of the course.
* @param {string} rootSlug - The root slug for the course.
* @returns {Promise<Object>} A Promise that resolves to the normalized course home page metadata.
*/
export async function getCourseHomeCourseMetadata(courseId, rootSlug) {
let courseHomeCourseMetadataApiUrl = getCourseHomeCourseMetadataApiUrl(courseId);
courseHomeCourseMetadataApiUrl = appendBrowserTimezoneToUrl(courseHomeCourseMetadataApiUrl);
const { data } = await getAuthenticatedHttpClient().get(courseHomeCourseMetadataApiUrl);
return normalizeCourseHomeCourseMetadata(data, rootSlug);
}
/**
* Creates a new course XBlock.
* @param {Object} options - The options for creating the XBlock.

View File

@@ -1,30 +1,9 @@
import { createSelector } from '@reduxjs/toolkit';
import { RequestStatus } from '../../data/constants';
export const getCourseUnitData = (state) => state.courseUnit.unit;
export const getCourseUnit = (state) => state.courseUnit;
export const getSavingStatus = (state) => state.courseUnit.savingStatus;
export const getLoadingStatus = (state) => state.courseUnit.loadingStatus;
export const getSequenceStatus = (state) => state.courseUnit.sequenceStatus;
export const getSequenceIds = (state) => state.courseUnit.courseSectionVertical.courseSequenceIds;
export const getCourseSectionVertical = (state) => state.courseUnit.courseSectionVertical;
export const getCourseUnitComponentTemplates = (state) => state.courseUnit.courseSectionVertical.componentTemplates;
export const getCourseSectionVerticalLoadingStatus = (state) => state
.courseUnit.loadingStatus.courseSectionVerticalLoadingStatus;
export const getCourseStatus = state => state.courseUnit.courseStatus;
export const getCoursewareMeta = state => state.models.coursewareMeta;
export const getSections = state => state.models.sections;
export const getCourseId = state => state.courseDetail.courseId;
export const getSequenceId = state => state.courseUnit.sequenceId;
export const getCourseVerticalChildren = state => state.courseUnit.courseVerticalChildren;
export const sequenceIdsSelector = createSelector(
[getCourseStatus, getCoursewareMeta, getSections, getCourseId],
(courseStatus, coursewareMeta, sections, courseId) => {
if (courseStatus !== RequestStatus.SUCCESSFUL) {
return [];
}
const sectionIds = coursewareMeta[courseId].sectionIds || [];
return sectionIds.flatMap(sectionId => sections[sectionId].sequenceIds);
},
);
export const getCourseId = (state) => state.courseDetail.courseId;
export const getSequenceId = (state) => state.courseUnit.sequenceId;
export const getCourseVerticalChildren = (state) => state.courseUnit.courseVerticalChildren;

View File

@@ -52,22 +52,6 @@ const slice = createSlice({
state.sequenceStatus = RequestStatus.FAILED;
state.sequenceMightBeUnit = payload.sequenceMightBeUnit || false;
},
fetchCourseRequest: (state, { payload }) => {
state.courseId = payload.courseId;
state.courseStatus = RequestStatus.IN_PROGRESS;
},
fetchCourseSuccess: (state, { payload }) => {
state.courseId = payload.courseId;
state.courseStatus = RequestStatus.SUCCESSFUL;
},
fetchCourseFailure: (state, { payload }) => {
state.courseId = payload.courseId;
state.courseStatus = RequestStatus.FAILED;
},
fetchCourseDenied: (state, { payload }) => {
state.courseId = payload.courseId;
state.courseStatus = RequestStatus.DENIED;
},
fetchCourseSectionVerticalDataSuccess: (state, { payload }) => {
state.courseSectionVertical = payload;
},
@@ -122,10 +106,6 @@ export const {
fetchSequenceRequest,
fetchSequenceSuccess,
fetchSequenceFailure,
fetchCourseRequest,
fetchCourseSuccess,
fetchCourseFailure,
fetchCourseDenied,
fetchCourseSectionVerticalDataSuccess,
updateLoadingCourseSectionVerticalDataStatus,
changeEditTitleFormOpen,

View File

@@ -1,4 +1,3 @@
import { logError, logInfo } from '@edx/frontend-platform/logging';
import { camelCaseObject } from '@edx/frontend-platform';
import {
@@ -7,15 +6,10 @@ import {
} from '../../generic/processing-notification/data/slice';
import { RequestStatus } from '../../data/constants';
import { NOTIFICATION_MESSAGES } from '../../constants';
import {
addModel, updateModel, updateModels, updateModelsMap, addModelsMap,
} from '../../generic/model-store';
import { updateModel, updateModels } from '../../generic/model-store';
import {
getCourseUnitData,
editUnitDisplayName,
getCourseMetadata,
getLearningSequencesOutline,
getCourseHomeCourseMetadata,
getCourseSectionVerticalData,
createCourseXblock,
getCourseVerticalChildren,
@@ -30,10 +24,6 @@ import {
fetchSequenceRequest,
fetchSequenceFailure,
fetchSequenceSuccess,
fetchCourseRequest,
fetchCourseSuccess,
fetchCourseDenied,
fetchCourseFailure,
fetchCourseSectionVerticalDataSuccess,
updateLoadingCourseSectionVerticalDataStatus,
updateLoadingCourseXblockStatus,
@@ -146,88 +136,6 @@ export function editCourseUnitVisibilityAndData(itemId, type, isVisible) {
};
}
export function fetchCourse(courseId) {
return async (dispatch) => {
dispatch(fetchCourseRequest({ courseId }));
Promise.allSettled([
getCourseMetadata(courseId),
getLearningSequencesOutline(courseId),
getCourseHomeCourseMetadata(courseId, 'courseware'),
]).then(([
courseMetadataResult,
learningSequencesOutlineResult,
courseHomeMetadataResult]) => {
if (courseMetadataResult.status === 'fulfilled') {
dispatch(addModel({
modelType: 'coursewareMeta',
model: courseMetadataResult.value,
}));
}
if (courseHomeMetadataResult.status === 'fulfilled') {
dispatch(addModel({
modelType: 'courseHomeMeta',
model: {
id: courseId,
...courseHomeMetadataResult.value,
},
}));
}
if (learningSequencesOutlineResult.status === 'fulfilled') {
const { courses, sections } = learningSequencesOutlineResult.value;
// This updates the course with a sectionIds array from the Learning Sequence data.
dispatch(updateModelsMap({
modelType: 'coursewareMeta',
modelsMap: courses,
}));
dispatch(addModelsMap({
modelType: 'sections',
modelsMap: sections,
}));
}
const fetchedMetadata = courseMetadataResult.status === 'fulfilled';
const fetchedCourseHomeMetadata = courseHomeMetadataResult.status === 'fulfilled';
const fetchedOutline = learningSequencesOutlineResult.status === 'fulfilled';
// Log errors for each request if needed. Outline failures may occur
// even if the course metadata request is successful
if (!fetchedOutline) {
const { response } = learningSequencesOutlineResult.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(learningSequencesOutlineResult.reason);
} else {
logError(learningSequencesOutlineResult.reason);
}
}
if (!fetchedMetadata) {
logError(courseMetadataResult.reason);
}
if (!fetchedCourseHomeMetadata) {
logError(courseHomeMetadataResult.reason);
}
if (fetchedMetadata && fetchedCourseHomeMetadata) {
if (courseHomeMetadataResult.value.courseAccess.hasAccess && fetchedOutline) {
// 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 createNewCourseXBlock(body, callback, blockId) {
return async (dispatch) => {
dispatch(updateLoadingCourseXblockStatus({ status: RequestStatus.IN_PROGRESS }));

View File

@@ -3,196 +3,7 @@ import { camelCaseObject } from '@edx/frontend-platform';
import { NOTIFICATION_MESSAGES } from '../../constants';
import { PUBLISH_TYPES } from '../constants';
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 function normalizeMetadata(metadata) {
const requestTime = Date.now();
const responseTime = requestTime;
const { data, headers } = metadata;
return {
accessExpiration: camelCaseObject(data.access_expiration),
canShowUpgradeSock: data.can_show_upgrade_sock,
contentTypeGatingEnabled: data.content_type_gating_enabled,
courseGoals: camelCaseObject(data.course_goals),
id: data.id,
title: data.name,
offer: camelCaseObject(data.offer),
enrollmentStart: data.enrollment_start,
enrollmentEnd: data.enrollment_end,
end: data.end,
start: data.start,
enrollmentMode: data.enrollment.mode,
isEnrolled: data.enrollment.is_active,
license: data.license,
userTimezone: data.user_timezone,
showCalculator: data.show_calculator,
notes: camelCaseObject(data.notes),
marketingUrl: data.marketing_url,
celebrations: camelCaseObject(data.celebrations),
userHasPassingGrade: data.user_has_passing_grade,
courseExitPageIsActive: data.course_exit_page_is_active,
certificateData: camelCaseObject(data.certificate_data),
entranceExamData: camelCaseObject(data.entrance_exam_data),
timeOffsetMillis: getTimeOffsetMillis(headers && headers.date, requestTime, responseTime),
verifyIdentityUrl: data.verify_identity_url,
verificationStatus: data.verification_status,
linkedinAddToProfileUrl: data.linkedin_add_to_profile_url,
relatedPrograms: camelCaseObject(data.related_programs),
userNeedsIntegritySignature: data.user_needs_integrity_signature,
canAccessProctoredExams: data.can_access_proctored_exams,
learningAssistantEnabled: data.learning_assistant_enabled,
};
}
export const appendBrowserTimezoneToUrl = (url) => {
const browserTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
const urlObject = new URL(url);
if (browserTimezone) {
urlObject.searchParams.append('browser_timezone', browserTimezone);
}
return urlObject.href;
};
export function normalizeSequenceMetadata(sequence) {
return {
sequence: {
id: sequence.item_id,
blockType: sequence.tag,
unitIds: sequence.items.map(unit => unit.id),
bannerText: sequence.banner_text,
format: sequence.format,
title: sequence.display_name,
/*
Example structure of gated_content when prerequisites exist:
{
prereq_id: 'id of the prereq section',
prereq_url: 'unused by this frontend',
prereq_section_name: 'Name of the prerequisite section',
gated: true,
gated_section_name: 'Name of this gated section',
*/
gatedContent: camelCaseObject(sequence.gated_content),
isTimeLimited: sequence.is_time_limited,
isProctored: sequence.is_proctored,
isHiddenAfterDue: sequence.is_hidden_after_due,
// 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,
allowProctoringOptOut: sequence.allow_proctoring_opt_out,
},
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,
graded: unit.graded,
containsContentTypeGatedContent: unit.contains_content_type_gated_content,
})),
};
}
export function normalizeLearningSequencesData(learningSequencesData) {
const models = {
courses: {},
sections: {},
sequences: {},
};
const now = new Date();
function isReleased(block) {
// We check whether the backend marks this as accessible because staff users are granted access anyway.
// Note that sections don't have the `accessible` field and will just be checking `effective_start`.
return block.accessible || !block.effective_start || now >= Date.parse(block.effective_start);
}
// Sequences
Object.entries(learningSequencesData.outline.sequences).forEach(([seqId, sequence]) => {
if (!isReleased(sequence)) {
return; // Don't let the learner see unreleased sequences
}
models.sequences[seqId] = {
id: seqId,
title: sequence.title,
};
});
// Sections
learningSequencesData.outline.sections.forEach(section => {
// Filter out any ignored sequences (e.g. unreleased sequences)
const availableSequenceIds = section.sequence_ids.filter(seqId => seqId in models.sequences);
// If we are unreleased and already stripped out all our children, just don't show us at all.
// (We check both release date and children because children will exist for an unreleased section even for staff,
// so we still want to show this section.)
if (!isReleased(section) && !availableSequenceIds.length) {
return;
}
models.sections[section.id] = {
id: section.id,
title: section.title,
sequenceIds: availableSequenceIds,
courseId: learningSequencesData.course_key,
};
// Add back-references to this section for all child sequences.
availableSequenceIds.forEach(childSeqId => {
models.sequences[childSeqId].sectionId = section.id;
});
});
// Course
models.courses[learningSequencesData.course_key] = {
id: learningSequencesData.course_key,
title: learningSequencesData.title,
sectionIds: Object.entries(models.sections).map(([sectionId]) => sectionId),
// Scan through all the sequences and look for ones that aren't released yet.
hasScheduledContent: Object.values(learningSequencesData.outline.sequences).some(seq => !isReleased(seq)),
};
return models;
}
/**
* 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
*/
export 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,
};
}
// eslint-disable-next-line import/prefer-default-export
export function normalizeCourseSectionVerticalData(metadata) {
const data = camelCaseObject(metadata);
return {

View File

@@ -7,7 +7,6 @@ import {
createNewCourseXBlock,
fetchCourseUnitQuery,
editCourseItemQuery,
fetchCourse,
fetchCourseSectionVerticalData,
fetchCourseVerticalChildrenData,
deleteUnitItemQuery,
@@ -19,6 +18,7 @@ import {
getCourseUnitData,
getLoadingStatus,
getSavingStatus,
getSequenceStatus,
} from './data/selectors';
import { changeEditTitleFormOpen, updateQueryPendingStatus } from './data/slice';
@@ -31,6 +31,7 @@ export const useCourseUnit = ({ courseId, blockId }) => {
const courseUnit = useSelector(getCourseUnitData);
const savingStatus = useSelector(getSavingStatus);
const loadingStatus = useSelector(getLoadingStatus);
const sequenceStatus = useSelector(getSequenceStatus);
const { draftPreviewLink, publishedPreviewLink } = useSelector(getCourseSectionVertical);
const courseVerticalChildren = useSelector(getCourseVerticalChildren);
const navigate = useNavigate();
@@ -87,7 +88,7 @@ export const useCourseUnit = ({ courseId, blockId }) => {
useEffect(() => {
if (savingStatus === RequestStatus.SUCCESSFUL) {
dispatch(updateQueryPendingStatus(false));
dispatch(updateQueryPendingStatus(true));
} else if (savingStatus === RequestStatus.FAILED && !hasInternetConnectionError) {
toggleErrorAlert(true);
}
@@ -97,7 +98,6 @@ export const useCourseUnit = ({ courseId, blockId }) => {
dispatch(fetchCourseUnitQuery(blockId));
dispatch(fetchCourseSectionVerticalData(blockId, sequenceId));
dispatch(fetchCourseVerticalChildrenData(blockId));
dispatch(fetchCourse(courseId));
handleNavigate(sequenceId);
}, [courseId, blockId, sequenceId]);
@@ -106,6 +106,7 @@ export const useCourseUnit = ({ courseId, blockId }) => {
sequenceId,
courseUnit,
unitTitle,
sequenceStatus,
savingStatus,
isQueryPending,
isErrorAlert,