From d13e7520f07096e8ce1e5a34ccba12219141f058 Mon Sep 17 00:00:00 2001 From: Michael Roytman Date: Fri, 21 Nov 2025 06:48:18 -0500 Subject: [PATCH] feat: fetch exams data on the progress page This commit adds changes to fetch the exams data associated with all subsections relevant to the progress page. Exams data is relevant to the progress page because the status of a learner's exam attempt may influence the state of their grade. This allows children of the root ProgressPage or downstream plugin slots to access this data from the Redux store. --- src/course-home/data/api.js | 21 ++++++++++++++++++++ src/course-home/data/slice.js | 5 +++++ src/course-home/data/thunks.js | 18 +++++++++++++++++ src/course-home/progress-tab/ProgressTab.jsx | 13 +++++++++--- src/course-home/progress-tab/hooks.jsx | 12 +++++++++++ 5 files changed, 66 insertions(+), 3 deletions(-) create mode 100644 src/course-home/progress-tab/hooks.jsx diff --git a/src/course-home/data/api.js b/src/course-home/data/api.js index 8254d4ef1e..b2ec2d3a9f 100644 --- a/src/course-home/data/api.js +++ b/src/course-home/data/api.js @@ -379,3 +379,24 @@ export async function searchCourseContentFromAPI(courseId, searchKeyword, option return camelCaseObject(response); } + +export async function getExamsData(courseId, sequenceId) { + let url; + + if (!getConfig().EXAMS_BASE_URL) { + url = `${getConfig().LMS_BASE_URL}/api/edx_proctoring/v1/proctored_exam/attempt/course_id/${encodeURIComponent(courseId)}?is_learning_mfe=true&content_id=${encodeURIComponent(sequenceId)}`; + } else { + url = `${getConfig().EXAMS_BASE_URL}/api/v1/student/exam/attempt/course_id/${encodeURIComponent(courseId)}/content_id/${encodeURIComponent(sequenceId)}`; + } + + try { + const { data } = await getAuthenticatedHttpClient().get(url); + return camelCaseObject(data); + } catch (error) { + const { httpErrorStatus } = error && error.customAttributes; + if (httpErrorStatus === 404) { + return {}; + } + throw error; + } +} diff --git a/src/course-home/data/slice.js b/src/course-home/data/slice.js index 21c804d3f3..86179f8aa7 100644 --- a/src/course-home/data/slice.js +++ b/src/course-home/data/slice.js @@ -18,6 +18,7 @@ const slice = createSlice({ toastBodyLink: null, toastHeader: '', showSearch: false, + examsData: null, }, reducers: { fetchProctoringInfoResolved: (state) => { @@ -53,6 +54,9 @@ const slice = createSlice({ setShowSearch: (state, { payload }) => { state.showSearch = payload; }, + setExamsData: (state, { payload }) => { + state.examsData = payload; + }, }, }); @@ -64,6 +68,7 @@ export const { fetchTabSuccess, setCallToActionToast, setShowSearch, + setExamsData, } = slice.actions; export const { diff --git a/src/course-home/data/thunks.js b/src/course-home/data/thunks.js index 4dd3658e53..44b60fdc26 100644 --- a/src/course-home/data/thunks.js +++ b/src/course-home/data/thunks.js @@ -4,6 +4,7 @@ import { executePostFromPostEvent, getCourseHomeCourseMetadata, getDatesTabData, + getExamsData, getOutlineTabData, getProgressTabData, postCourseDeadlines, @@ -26,6 +27,7 @@ import { fetchTabRequest, fetchTabSuccess, setCallToActionToast, + setExamsData, } from './slice'; import mapSearchResponse from '../courseware-search/map-search-response'; @@ -223,3 +225,19 @@ export function searchCourseContent(courseId, searchKeyword) { }); }; } + +export function fetchExamAttemptsData(courseId, sequenceIds) { + return async (dispatch) => { + const results = await Promise.all(sequenceIds.map(async (sequenceId) => { + try { + const response = await getExamsData(courseId, sequenceId); + return response.exam || {}; + } catch (e) { + logError(e); + return [sequenceId, {}]; + } + })); + + dispatch(setExamsData(results)); + }; +} diff --git a/src/course-home/progress-tab/ProgressTab.jsx b/src/course-home/progress-tab/ProgressTab.jsx index a0d86a288b..32506930bf 100644 --- a/src/course-home/progress-tab/ProgressTab.jsx +++ b/src/course-home/progress-tab/ProgressTab.jsx @@ -1,6 +1,7 @@ -import React from 'react'; +import React, { useMemo } from 'react'; import { useWindowSize } from '@openedx/paragon'; import { useContextId } from '../../data/hooks'; +import { useModel } from '../../generic/model-store'; import ProgressTabCertificateStatusSidePanelSlot from '../../plugin-slots/ProgressTabCertificateStatusSidePanelSlot'; import CourseCompletion from './course-completion/CourseCompletion'; @@ -10,11 +11,17 @@ import ProgressTabCertificateStatusMainBodySlot from '../../plugin-slots/Progres import ProgressTabCourseGradeSlot from '../../plugin-slots/ProgressTabCourseGradeSlot'; import ProgressTabGradeBreakdownSlot from '../../plugin-slots/ProgressTabGradeBreakdownSlot'; import ProgressTabRelatedLinksSlot from '../../plugin-slots/ProgressTabRelatedLinksSlot'; -import { useModel } from '../../generic/model-store'; +import { useGetExamsData } from './hooks'; const ProgressTab = () => { const courseId = useContextId(); - const { disableProgressGraph } = useModel('progress', courseId); + const { disableProgressGraph, sectionScores } = useModel('progress', courseId); + + const sequenceIds = useMemo(() => ( + sectionScores.flatMap((section) => (section.subsections)).map((subsection) => subsection.blockKey) + ), [sectionScores]); + + useGetExamsData(courseId, sequenceIds); const windowWidth = useWindowSize().width; if (windowWidth === undefined) { diff --git a/src/course-home/progress-tab/hooks.jsx b/src/course-home/progress-tab/hooks.jsx new file mode 100644 index 0000000000..d1b707bc80 --- /dev/null +++ b/src/course-home/progress-tab/hooks.jsx @@ -0,0 +1,12 @@ +import { useEffect } from 'react'; +import { useDispatch } from 'react-redux'; + +import { fetchExamAttemptsData } from '../data/thunks'; + +export function useGetExamsData(courseId, sequenceIds) { + const dispatch = useDispatch(); + + useEffect(() => { + dispatch(fetchExamAttemptsData(courseId, sequenceIds)); + }, [dispatch, courseId, sequenceIds]); +}