From 00ce3d78568375c42804932bc6c84c43ec8dbd55 Mon Sep 17 00:00:00 2001 From: Ishan Masdekar Date: Fri, 1 Aug 2025 02:56:18 +0530 Subject: [PATCH] feat: navigates subsection breadcrumb to first unit page (#2329) - navigates the breadcrumb to the first unit under the subsection instead of the outline page. Closes #1924 --- src/CourseAuthoringRoutes.jsx | 6 +- src/course-outline/data/apiHooks.ts | 13 ++- .../SubsectionUnitRedirect.test.tsx | 79 +++++++++++++++++++ src/course-unit/SubsectionUnitRedirect.tsx | 29 +++++++ .../__mocks__/courseSectionVertical.js | 7 ++ .../breadcrumbs/Breadcrumbs.test.tsx | 17 ++++ src/course-unit/breadcrumbs/Breadcrumbs.tsx | 23 ++++-- src/course-unit/index.js | 1 + src/course-unit/utils.ts | 15 ++++ 9 files changed, 182 insertions(+), 8 deletions(-) create mode 100644 src/course-unit/SubsectionUnitRedirect.test.tsx create mode 100644 src/course-unit/SubsectionUnitRedirect.tsx diff --git a/src/CourseAuthoringRoutes.jsx b/src/CourseAuthoringRoutes.jsx index 39808ab8a..deb6f7bb7 100644 --- a/src/CourseAuthoringRoutes.jsx +++ b/src/CourseAuthoringRoutes.jsx @@ -17,7 +17,7 @@ import ScheduleAndDetails from './schedule-and-details'; import { GradingSettings } from './grading-settings'; import CourseTeam from './course-team/CourseTeam'; import { CourseUpdates } from './course-updates'; -import { CourseUnit } from './course-unit'; +import { CourseUnit, SubsectionUnitRedirect } from './course-unit'; import { Certificates } from './certificates'; import CourseExportPage from './export-page/CourseExportPage'; import CourseOptimizerPage from './optimizer-page/CourseOptimizerPage'; @@ -82,6 +82,10 @@ const CourseAuthoringRoutes = () => { path="custom-pages/*" element={} /> + } + /> {DECODED_ROUTES.COURSE_UNIT.map((path) => ( [...courseOutlineQueryKeys.all, courseId], + courseItemId: (itemId?: string) => [...courseOutlineQueryKeys.all, itemId], + }; /** @@ -22,3 +25,11 @@ export const useCreateCourseBlock = ( callback?.(data.locator, data.parent_locator); }, }); + +export const useCourseItemData = (itemId?: string, enabled: boolean = true) => ( + useQuery({ + queryKey: courseOutlineQueryKeys.courseItemId(itemId), + queryFn: () => getCourseItem(itemId!), + enabled: enabled && itemId !== undefined, + }) +); diff --git a/src/course-unit/SubsectionUnitRedirect.test.tsx b/src/course-unit/SubsectionUnitRedirect.test.tsx new file mode 100644 index 000000000..386bee489 --- /dev/null +++ b/src/course-unit/SubsectionUnitRedirect.test.tsx @@ -0,0 +1,79 @@ +import { + initializeMocks, waitFor, render, screen, +} from '../testUtils'; +import SubsectionUnitRedirect from './SubsectionUnitRedirect'; +import { getXBlockApiUrl } from '../course-outline/data/api'; + +let axiosMock; +const courseId = '123'; +const subsectionId = 'block-v1+edX+DemoX+Demo_Course+type@sequential+block@19a30717eff543078a5d94ae9d6c18a5'; +const path = '/subsection/:subsectionId'; + +const expectedCourseItemDataWithUnit = { + childInfo: { + children: [ + { + id: 'unitId', + }, + ], + }, +}; + +const expectedCourseItemDataWithoutUnit = [{ + childInfo: { + children: [], + }, +}]; + +const renderSubsectionRedirectPage = () => { + render(, { + path, + routerProps: { + initialEntries: [`/subsection/${subsectionId}`], + }, + }); +}; + +jest.mock('react-router-dom', () => { + const originalModule = jest.requireActual('react-router-dom'); + return { + ...originalModule, + Navigate: ({ to }: { to: string }) =>
Mocked Navigate
, + }; +}); +describe('SubsectionUnitRedirect', () => { + beforeEach(() => { + const mocks = initializeMocks(); + axiosMock = mocks.axiosMock; + }); + + it('navigates to first unit if available', async () => { + axiosMock + .onGet(getXBlockApiUrl(subsectionId)) + .reply(200, expectedCourseItemDataWithUnit); + + renderSubsectionRedirectPage(); + + await waitFor(() => { + // Confirm redirection by checking the final URL + const mockNavigate = screen.getByTestId('mock-navigate'); + expect(mockNavigate).toBeInTheDocument(); + expect(mockNavigate).toHaveAttribute('data-to', `/course/${courseId}/container/unitId`); + }); + }); + + it('navigates to course page with show param if no units present', async () => { + axiosMock + .onGet(getXBlockApiUrl(subsectionId)) + .reply(200, expectedCourseItemDataWithoutUnit); + + renderSubsectionRedirectPage(); + + await waitFor(() => { + // Confirm redirection by checking the final URL + const mockNavigate = screen.getByTestId('mock-navigate'); + expect(mockNavigate).toBeInTheDocument(); + expect(mockNavigate).toHaveAttribute('data-to', `/course/${courseId}?show=${encodeURIComponent(subsectionId)}`); + }); + }); +}); diff --git a/src/course-unit/SubsectionUnitRedirect.tsx b/src/course-unit/SubsectionUnitRedirect.tsx new file mode 100644 index 000000000..403656cde --- /dev/null +++ b/src/course-unit/SubsectionUnitRedirect.tsx @@ -0,0 +1,29 @@ +import { LoadingSpinner } from '@src/generic/Loading'; +import { useParams, Navigate } from 'react-router-dom'; +import { useCourseItemData } from '../course-outline/data/apiHooks'; + +const SubsectionUnitRedirect = ({ courseId }: { courseId: string }) => { + let { subsectionId } = useParams(); + // if the call is made via the click on breadcrumbs the re won't be courseId available + // in such cases the page should redirect to the 1st unit of he subsection + const { data: courseItemData, isLoading } = useCourseItemData(subsectionId); + let firstUnitId = courseItemData?.childInfo?.children?.[0]?.id; + + if (isLoading) { + return ; + } + + if (firstUnitId) { + firstUnitId = encodeURIComponent(firstUnitId); + return ; + } + if (subsectionId) { + // if no unit then navigate to the subsection outline + subsectionId = encodeURIComponent(subsectionId); + return ; + } + + // navigate to the course page if no subsectionId and no unitId + return ; +}; +export default SubsectionUnitRedirect; diff --git a/src/course-unit/__mocks__/courseSectionVertical.js b/src/course-unit/__mocks__/courseSectionVertical.js index a1647d549..6693598e4 100644 --- a/src/course-unit/__mocks__/courseSectionVertical.js +++ b/src/course-unit/__mocks__/courseSectionVertical.js @@ -19,22 +19,27 @@ module.exports = { { url: '/course/course-v1:edX+DemoX+Demo_Course?show=block-v1%3AedX%2BDemoX%2BDemo_Course%2Btype%40chapter%2Bblock%40d8a6192ade314473a78242dfeedfbf5b', display_name: 'Introduction 2', + usage_key: 'block-v1:edX+DemoX+Demo_Course+type@chapter+block@v3v57d5h5j4a8s33a78242dfeedfbf5b', }, { url: '/course/course-v1:edX+DemoX+Demo_Course?show=block-v1%3AedX%2BDemoX%2BDemo_Course%2Btype%40chapter%2Bblock%40interactive_demonstrations', display_name: 'Example Week 1: Getting Started', + usage_key: 'block-v1:edX+DemoX+Demo_Course+type@chapter+block@4bgkas5384h6f686f8ghj53feedfbf2f', }, { url: '/course/course-v1:edX+DemoX+Demo_Course?show=block-v1%3AedX%2BDemoX%2BDemo_Course%2Btype%40chapter%2Bblock%40graded_interactions', display_name: 'Example Week 2: Get Interactive', + usage_key: 'block-v1:edX+DemoX+Demo_Course+type@chapter+block@v3v57d5h5j4a8s33nsdajdsh876fbf3g', }, { url: '/course/course-v1:edX+DemoX+Demo_Course?show=block-v1%3AedX%2BDemoX%2BDemo_Course%2Btype%40chapter%2Bblock%40social_integration', display_name: 'Example Week 3: Be Social', + usage_key: 'block-v1:edX+DemoX+Demo_Course+type@chapter+block@sg8b76g7b68s7s33a78242dfeedfbf4c', }, { url: '/course/course-v1:edX+DemoX+Demo_Course?show=block-v1%3AedX%2BDemoX%2BDemo_Course%2Btype%40chapter%2Bblock%401414ffd5143b4b508f739b563ab468b7', display_name: 'About Exams and Certificates', + usage_key: 'block-v1:edX+DemoX+Demo_Course+type@chapter+block@jhk76823jh42j5kl23kjl2dfeedfbf8d', }, ], title: 'Example Week 1: Getting Started', @@ -45,10 +50,12 @@ module.exports = { { url: '/course/course-v1:edX+DemoX+Demo_Course?show=block-v1%3AedX%2BDemoX%2BDemo_Course%2Btype%40sequential%2Bblock%4019a30717eff543078a5d94ae9d6c18a5', display_name: 'Lesson 1 - Getting Started', + usage_key: 'block-v1+edX+DemoX+Demo_Course+type@sequential+block@19a30717eff543078a5d94ae9d6c18a5', }, { url: '/course/course-v1:edX+DemoX+Demo_Course?show=block-v1%3AedX%2BDemoX%2BDemo_Course%2Btype%40sequential%2Bblock%40basic_questions', display_name: 'Homework - Question Styles', + usage_key: 'block-v1+edX+DemoX+Demo_Course+type@sequential+block@basic_questions', }, ], title: 'Lesson 1 - Getting Started', diff --git a/src/course-unit/breadcrumbs/Breadcrumbs.test.tsx b/src/course-unit/breadcrumbs/Breadcrumbs.test.tsx index 792f12c57..523413ee9 100644 --- a/src/course-unit/breadcrumbs/Breadcrumbs.test.tsx +++ b/src/course-unit/breadcrumbs/Breadcrumbs.test.tsx @@ -117,6 +117,23 @@ describe('', () => { }); }); + it('navigates to the first unit in subsection via the intermediate subsection redirect page', async () => { + const user = userEvent.setup(); + // eslint-disable-next-line @typescript-eslint/naming-convention + const { ancestor_xblocks } = courseSectionVerticalMock; + const displayName = ancestor_xblocks[1].children[0].display_name; + const subsectionId = 'block-v1+edX+DemoX+Demo_Course+type@sequential+block@19a30717eff543078a5d94ae9d6c18a5'; + const expectedUrl = `/course/${courseId}/subsection/${subsectionId}`; + const { getByText, getByRole } = renderComponent(); + + const dropdownBtn = getByText(breadcrumbsExpected.subsection.displayName); + await user.click(dropdownBtn); + + const dropdownItem = getByRole('link', { name: displayName }); + await user.click(dropdownItem); + expect(dropdownItem).toHaveAttribute('href', expectedUrl); + }); + it('navigates using the new course outline page when the waffle flag is enabled', async () => { const user = userEvent.setup(); // eslint-disable-next-line @typescript-eslint/naming-convention diff --git a/src/course-unit/breadcrumbs/Breadcrumbs.tsx b/src/course-unit/breadcrumbs/Breadcrumbs.tsx index dec4ce6da..875c811b6 100644 --- a/src/course-unit/breadcrumbs/Breadcrumbs.tsx +++ b/src/course-unit/breadcrumbs/Breadcrumbs.tsx @@ -9,7 +9,7 @@ import { getConfig } from '@edx/frontend-platform'; import { useWaffleFlags } from '../../data/apiHooks'; import { getCourseSectionVertical } from '../data/selectors'; -import { adoptCourseSectionUrl } from '../utils'; +import { adoptCourseSectionUrl, subsectionFirstUnitEditUrl } from '../utils'; const Breadcrumbs = ({ courseId, parentUnitId }: { courseId: string, parentUnitId: string }) => { const { ancestorXblocks = [] } = useSelector(getCourseSectionVertical); @@ -22,9 +22,20 @@ const Breadcrumbs = ({ courseId, parentUnitId }: { courseId: string, parentUnitI ? adoptCourseSectionUrl({ url, courseId, parentUnitId }) : `${getConfig().STUDIO_BASE_URL}${url}`); - const getPathToCoursePage = (isOutlinePage, url) => ( - isOutlinePage ? getPathToCourseOutlinePage(url) : getPathToCourseUnitPage(url) - ); + // based on the level of breadcrumbs the url will differ + // at the subsection level it should navigate to the first unit if available + // if no unit then navigate to the subsection outline + function getPathToCoursePage(index, url, usageKey: string) { + let navUrl: string; + if (index === 0) { + navUrl = getPathToCourseOutlinePage(url); + } else if (index === 1) { + navUrl = subsectionFirstUnitEditUrl({ courseId, subsectionId: usageKey }); + } else { + navUrl = getPathToCourseUnitPage(url); + } + return navUrl; + } const hasChildWithUrl = (children = []) => ( !!children.filter((child : any) => child?.url).length @@ -55,11 +66,11 @@ const Breadcrumbs = ({ courseId, parentUnitId }: { courseId: string, parentUnitI /> - {children.map(({ url, displayName }) => ( + {children.map(({ url, displayName, usageKey }) => ( diff --git a/src/course-unit/index.js b/src/course-unit/index.js index f31a91ce9..5d7bbdddb 100644 --- a/src/course-unit/index.js +++ b/src/course-unit/index.js @@ -1 +1,2 @@ export { default as CourseUnit } from './CourseUnit'; +export { default as SubsectionUnitRedirect } from './SubsectionUnitRedirect'; diff --git a/src/course-unit/utils.ts b/src/course-unit/utils.ts index 08c009994..c912c94d7 100644 --- a/src/course-unit/utils.ts +++ b/src/course-unit/utils.ts @@ -28,3 +28,18 @@ export const adoptCourseSectionUrl = ( return newUrl; }; + +/** + * Generates the edit URL for the first unit of a given subsection in a course. + * + * @param {Object} params - The parameters required to build the URL. + * @param {string} params.courseId - The ID of the course. + * @param {string} params.subsectionId - The ID of the subsection. + * @returns {string} The constructed edit URL for the subsection's first unit. + */ +export const subsectionFirstUnitEditUrl = ( + { courseId, subsectionId }: { courseId: string, subsectionId: string }, +): string => { + const url = `/course/${courseId}/subsection/${subsectionId}`; + return url; +};