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
This commit is contained in:
Ishan Masdekar
2025-08-01 02:56:18 +05:30
committed by GitHub
parent 90ddc5e71c
commit 00ce3d7856
9 changed files with 182 additions and 8 deletions

View File

@@ -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={<PageWrap><CustomPages courseId={courseId} /></PageWrap>}
/>
<Route
path="/subsection/:subsectionId"
element={<PageWrap><SubsectionUnitRedirect courseId={courseId} /></PageWrap>}
/>
{DECODED_ROUTES.COURSE_UNIT.map((path) => (
<Route
key={path}

View File

@@ -1,5 +1,6 @@
import { useMutation } from '@tanstack/react-query';
import { useMutation, useQuery } from '@tanstack/react-query';
import { createCourseXblock } from '@src/course-unit/data/api';
import { getCourseItem } from './api';
export const courseOutlineQueryKeys = {
all: ['courseOutline'],
@@ -7,6 +8,8 @@ export const courseOutlineQueryKeys = {
* Base key for data specific to a course in outline
*/
contentLibrary: (courseId?: string) => [...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,
})
);

View File

@@ -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(<SubsectionUnitRedirect courseId={courseId} />, {
path,
routerProps: {
initialEntries: [`/subsection/${subsectionId}`],
},
});
};
jest.mock('react-router-dom', () => {
const originalModule = jest.requireActual('react-router-dom');
return {
...originalModule,
Navigate: ({ to }: { to: string }) => <div data-testid="mock-navigate" data-to={to}>Mocked Navigate</div>,
};
});
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)}`);
});
});
});

View File

@@ -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 <LoadingSpinner />;
}
if (firstUnitId) {
firstUnitId = encodeURIComponent(firstUnitId);
return <Navigate replace to={`/course/${courseId}/container/${firstUnitId}`} />;
}
if (subsectionId) {
// if no unit then navigate to the subsection outline
subsectionId = encodeURIComponent(subsectionId);
return <Navigate replace to={`/course/${courseId}?show=${subsectionId}`} />;
}
// navigate to the course page if no subsectionId and no unitId
return <Navigate replace to={`/course/${courseId}`} />;
};
export default SubsectionUnitRedirect;

View File

@@ -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',

View File

@@ -117,6 +117,23 @@ describe('<Breadcrumbs />', () => {
});
});
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

View File

@@ -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
/>
</Dropdown.Toggle>
<Dropdown.Menu>
{children.map(({ url, displayName }) => (
{children.map(({ url, displayName, usageKey }) => (
<Dropdown.Item
as={Link}
key={url}
to={getPathToCoursePage(index < 2, url)}
to={getPathToCoursePage(index, url, usageKey)}
className="small"
data-testid={`breadcrumbs-dropdown-item-level-${index}`}
>

View File

@@ -1 +1,2 @@
export { default as CourseUnit } from './CourseUnit';
export { default as SubsectionUnitRedirect } from './SubsectionUnitRedirect';

View File

@@ -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;
};