Skip to content

Commit 00ce3d7

Browse files
authored
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
1 parent 90ddc5e commit 00ce3d7

File tree

9 files changed

+182
-8
lines changed

9 files changed

+182
-8
lines changed

src/CourseAuthoringRoutes.jsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ import ScheduleAndDetails from './schedule-and-details';
1717
import { GradingSettings } from './grading-settings';
1818
import CourseTeam from './course-team/CourseTeam';
1919
import { CourseUpdates } from './course-updates';
20-
import { CourseUnit } from './course-unit';
20+
import { CourseUnit, SubsectionUnitRedirect } from './course-unit';
2121
import { Certificates } from './certificates';
2222
import CourseExportPage from './export-page/CourseExportPage';
2323
import CourseOptimizerPage from './optimizer-page/CourseOptimizerPage';
@@ -82,6 +82,10 @@ const CourseAuthoringRoutes = () => {
8282
path="custom-pages/*"
8383
element={<PageWrap><CustomPages courseId={courseId} /></PageWrap>}
8484
/>
85+
<Route
86+
path="/subsection/:subsectionId"
87+
element={<PageWrap><SubsectionUnitRedirect courseId={courseId} /></PageWrap>}
88+
/>
8589
{DECODED_ROUTES.COURSE_UNIT.map((path) => (
8690
<Route
8791
key={path}

src/course-outline/data/apiHooks.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,15 @@
1-
import { useMutation } from '@tanstack/react-query';
1+
import { useMutation, useQuery } from '@tanstack/react-query';
22
import { createCourseXblock } from '@src/course-unit/data/api';
3+
import { getCourseItem } from './api';
34

45
export const courseOutlineQueryKeys = {
56
all: ['courseOutline'],
67
/**
78
* Base key for data specific to a course in outline
89
*/
910
contentLibrary: (courseId?: string) => [...courseOutlineQueryKeys.all, courseId],
11+
courseItemId: (itemId?: string) => [...courseOutlineQueryKeys.all, itemId],
12+
1013
};
1114

1215
/**
@@ -22,3 +25,11 @@ export const useCreateCourseBlock = (
2225
callback?.(data.locator, data.parent_locator);
2326
},
2427
});
28+
29+
export const useCourseItemData = (itemId?: string, enabled: boolean = true) => (
30+
useQuery({
31+
queryKey: courseOutlineQueryKeys.courseItemId(itemId),
32+
queryFn: () => getCourseItem(itemId!),
33+
enabled: enabled && itemId !== undefined,
34+
})
35+
);
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import {
2+
initializeMocks, waitFor, render, screen,
3+
} from '../testUtils';
4+
import SubsectionUnitRedirect from './SubsectionUnitRedirect';
5+
import { getXBlockApiUrl } from '../course-outline/data/api';
6+
7+
let axiosMock;
8+
const courseId = '123';
9+
const subsectionId = 'block-v1+edX+DemoX+Demo_Course+type@sequential+block@19a30717eff543078a5d94ae9d6c18a5';
10+
const path = '/subsection/:subsectionId';
11+
12+
const expectedCourseItemDataWithUnit = {
13+
childInfo: {
14+
children: [
15+
{
16+
id: 'unitId',
17+
},
18+
],
19+
},
20+
};
21+
22+
const expectedCourseItemDataWithoutUnit = [{
23+
childInfo: {
24+
children: [],
25+
},
26+
}];
27+
28+
const renderSubsectionRedirectPage = () => {
29+
render(<SubsectionUnitRedirect courseId={courseId} />, {
30+
path,
31+
routerProps: {
32+
initialEntries: [`/subsection/${subsectionId}`],
33+
},
34+
});
35+
};
36+
37+
jest.mock('react-router-dom', () => {
38+
const originalModule = jest.requireActual('react-router-dom');
39+
return {
40+
...originalModule,
41+
Navigate: ({ to }: { to: string }) => <div data-testid="mock-navigate" data-to={to}>Mocked Navigate</div>,
42+
};
43+
});
44+
describe('SubsectionUnitRedirect', () => {
45+
beforeEach(() => {
46+
const mocks = initializeMocks();
47+
axiosMock = mocks.axiosMock;
48+
});
49+
50+
it('navigates to first unit if available', async () => {
51+
axiosMock
52+
.onGet(getXBlockApiUrl(subsectionId))
53+
.reply(200, expectedCourseItemDataWithUnit);
54+
55+
renderSubsectionRedirectPage();
56+
57+
await waitFor(() => {
58+
// Confirm redirection by checking the final URL
59+
const mockNavigate = screen.getByTestId('mock-navigate');
60+
expect(mockNavigate).toBeInTheDocument();
61+
expect(mockNavigate).toHaveAttribute('data-to', `/course/${courseId}/container/unitId`);
62+
});
63+
});
64+
65+
it('navigates to course page with show param if no units present', async () => {
66+
axiosMock
67+
.onGet(getXBlockApiUrl(subsectionId))
68+
.reply(200, expectedCourseItemDataWithoutUnit);
69+
70+
renderSubsectionRedirectPage();
71+
72+
await waitFor(() => {
73+
// Confirm redirection by checking the final URL
74+
const mockNavigate = screen.getByTestId('mock-navigate');
75+
expect(mockNavigate).toBeInTheDocument();
76+
expect(mockNavigate).toHaveAttribute('data-to', `/course/${courseId}?show=${encodeURIComponent(subsectionId)}`);
77+
});
78+
});
79+
});
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import { LoadingSpinner } from '@src/generic/Loading';
2+
import { useParams, Navigate } from 'react-router-dom';
3+
import { useCourseItemData } from '../course-outline/data/apiHooks';
4+
5+
const SubsectionUnitRedirect = ({ courseId }: { courseId: string }) => {
6+
let { subsectionId } = useParams();
7+
// if the call is made via the click on breadcrumbs the re won't be courseId available
8+
// in such cases the page should redirect to the 1st unit of he subsection
9+
const { data: courseItemData, isLoading } = useCourseItemData(subsectionId);
10+
let firstUnitId = courseItemData?.childInfo?.children?.[0]?.id;
11+
12+
if (isLoading) {
13+
return <LoadingSpinner />;
14+
}
15+
16+
if (firstUnitId) {
17+
firstUnitId = encodeURIComponent(firstUnitId);
18+
return <Navigate replace to={`/course/${courseId}/container/${firstUnitId}`} />;
19+
}
20+
if (subsectionId) {
21+
// if no unit then navigate to the subsection outline
22+
subsectionId = encodeURIComponent(subsectionId);
23+
return <Navigate replace to={`/course/${courseId}?show=${subsectionId}`} />;
24+
}
25+
26+
// navigate to the course page if no subsectionId and no unitId
27+
return <Navigate replace to={`/course/${courseId}`} />;
28+
};
29+
export default SubsectionUnitRedirect;

src/course-unit/__mocks__/courseSectionVertical.js

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,22 +19,27 @@ module.exports = {
1919
{
2020
url: '/course/course-v1:edX+DemoX+Demo_Course?show=block-v1%3AedX%2BDemoX%2BDemo_Course%2Btype%40chapter%2Bblock%40d8a6192ade314473a78242dfeedfbf5b',
2121
display_name: 'Introduction 2',
22+
usage_key: 'block-v1:edX+DemoX+Demo_Course+type@chapter+block@v3v57d5h5j4a8s33a78242dfeedfbf5b',
2223
},
2324
{
2425
url: '/course/course-v1:edX+DemoX+Demo_Course?show=block-v1%3AedX%2BDemoX%2BDemo_Course%2Btype%40chapter%2Bblock%40interactive_demonstrations',
2526
display_name: 'Example Week 1: Getting Started',
27+
usage_key: 'block-v1:edX+DemoX+Demo_Course+type@chapter+block@4bgkas5384h6f686f8ghj53feedfbf2f',
2628
},
2729
{
2830
url: '/course/course-v1:edX+DemoX+Demo_Course?show=block-v1%3AedX%2BDemoX%2BDemo_Course%2Btype%40chapter%2Bblock%40graded_interactions',
2931
display_name: 'Example Week 2: Get Interactive',
32+
usage_key: 'block-v1:edX+DemoX+Demo_Course+type@chapter+block@v3v57d5h5j4a8s33nsdajdsh876fbf3g',
3033
},
3134
{
3235
url: '/course/course-v1:edX+DemoX+Demo_Course?show=block-v1%3AedX%2BDemoX%2BDemo_Course%2Btype%40chapter%2Bblock%40social_integration',
3336
display_name: 'Example Week 3: Be Social',
37+
usage_key: 'block-v1:edX+DemoX+Demo_Course+type@chapter+block@sg8b76g7b68s7s33a78242dfeedfbf4c',
3438
},
3539
{
3640
url: '/course/course-v1:edX+DemoX+Demo_Course?show=block-v1%3AedX%2BDemoX%2BDemo_Course%2Btype%40chapter%2Bblock%401414ffd5143b4b508f739b563ab468b7',
3741
display_name: 'About Exams and Certificates',
42+
usage_key: 'block-v1:edX+DemoX+Demo_Course+type@chapter+block@jhk76823jh42j5kl23kjl2dfeedfbf8d',
3843
},
3944
],
4045
title: 'Example Week 1: Getting Started',
@@ -45,10 +50,12 @@ module.exports = {
4550
{
4651
url: '/course/course-v1:edX+DemoX+Demo_Course?show=block-v1%3AedX%2BDemoX%2BDemo_Course%2Btype%40sequential%2Bblock%4019a30717eff543078a5d94ae9d6c18a5',
4752
display_name: 'Lesson 1 - Getting Started',
53+
usage_key: 'block-v1+edX+DemoX+Demo_Course+type@sequential+block@19a30717eff543078a5d94ae9d6c18a5',
4854
},
4955
{
5056
url: '/course/course-v1:edX+DemoX+Demo_Course?show=block-v1%3AedX%2BDemoX%2BDemo_Course%2Btype%40sequential%2Bblock%40basic_questions',
5157
display_name: 'Homework - Question Styles',
58+
usage_key: 'block-v1+edX+DemoX+Demo_Course+type@sequential+block@basic_questions',
5259
},
5360
],
5461
title: 'Lesson 1 - Getting Started',

src/course-unit/breadcrumbs/Breadcrumbs.test.tsx

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,23 @@ describe('<Breadcrumbs />', () => {
117117
});
118118
});
119119

120+
it('navigates to the first unit in subsection via the intermediate subsection redirect page', async () => {
121+
const user = userEvent.setup();
122+
// eslint-disable-next-line @typescript-eslint/naming-convention
123+
const { ancestor_xblocks } = courseSectionVerticalMock;
124+
const displayName = ancestor_xblocks[1].children[0].display_name;
125+
const subsectionId = 'block-v1+edX+DemoX+Demo_Course+type@sequential+block@19a30717eff543078a5d94ae9d6c18a5';
126+
const expectedUrl = `/course/${courseId}/subsection/${subsectionId}`;
127+
const { getByText, getByRole } = renderComponent();
128+
129+
const dropdownBtn = getByText(breadcrumbsExpected.subsection.displayName);
130+
await user.click(dropdownBtn);
131+
132+
const dropdownItem = getByRole('link', { name: displayName });
133+
await user.click(dropdownItem);
134+
expect(dropdownItem).toHaveAttribute('href', expectedUrl);
135+
});
136+
120137
it('navigates using the new course outline page when the waffle flag is enabled', async () => {
121138
const user = userEvent.setup();
122139
// eslint-disable-next-line @typescript-eslint/naming-convention

src/course-unit/breadcrumbs/Breadcrumbs.tsx

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import { getConfig } from '@edx/frontend-platform';
99

1010
import { useWaffleFlags } from '../../data/apiHooks';
1111
import { getCourseSectionVertical } from '../data/selectors';
12-
import { adoptCourseSectionUrl } from '../utils';
12+
import { adoptCourseSectionUrl, subsectionFirstUnitEditUrl } from '../utils';
1313

1414
const Breadcrumbs = ({ courseId, parentUnitId }: { courseId: string, parentUnitId: string }) => {
1515
const { ancestorXblocks = [] } = useSelector(getCourseSectionVertical);
@@ -22,9 +22,20 @@ const Breadcrumbs = ({ courseId, parentUnitId }: { courseId: string, parentUnitI
2222
? adoptCourseSectionUrl({ url, courseId, parentUnitId })
2323
: `${getConfig().STUDIO_BASE_URL}${url}`);
2424

25-
const getPathToCoursePage = (isOutlinePage, url) => (
26-
isOutlinePage ? getPathToCourseOutlinePage(url) : getPathToCourseUnitPage(url)
27-
);
25+
// based on the level of breadcrumbs the url will differ
26+
// at the subsection level it should navigate to the first unit if available
27+
// if no unit then navigate to the subsection outline
28+
function getPathToCoursePage(index, url, usageKey: string) {
29+
let navUrl: string;
30+
if (index === 0) {
31+
navUrl = getPathToCourseOutlinePage(url);
32+
} else if (index === 1) {
33+
navUrl = subsectionFirstUnitEditUrl({ courseId, subsectionId: usageKey });
34+
} else {
35+
navUrl = getPathToCourseUnitPage(url);
36+
}
37+
return navUrl;
38+
}
2839

2940
const hasChildWithUrl = (children = []) => (
3041
!!children.filter((child : any) => child?.url).length
@@ -55,11 +66,11 @@ const Breadcrumbs = ({ courseId, parentUnitId }: { courseId: string, parentUnitI
5566
/>
5667
</Dropdown.Toggle>
5768
<Dropdown.Menu>
58-
{children.map(({ url, displayName }) => (
69+
{children.map(({ url, displayName, usageKey }) => (
5970
<Dropdown.Item
6071
as={Link}
6172
key={url}
62-
to={getPathToCoursePage(index < 2, url)}
73+
to={getPathToCoursePage(index, url, usageKey)}
6374
className="small"
6475
data-testid={`breadcrumbs-dropdown-item-level-${index}`}
6576
>

src/course-unit/index.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
11
export { default as CourseUnit } from './CourseUnit';
2+
export { default as SubsectionUnitRedirect } from './SubsectionUnitRedirect';

src/course-unit/utils.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,3 +28,18 @@ export const adoptCourseSectionUrl = (
2828

2929
return newUrl;
3030
};
31+
32+
/**
33+
* Generates the edit URL for the first unit of a given subsection in a course.
34+
*
35+
* @param {Object} params - The parameters required to build the URL.
36+
* @param {string} params.courseId - The ID of the course.
37+
* @param {string} params.subsectionId - The ID of the subsection.
38+
* @returns {string} The constructed edit URL for the subsection's first unit.
39+
*/
40+
export const subsectionFirstUnitEditUrl = (
41+
{ courseId, subsectionId }: { courseId: string, subsectionId: string },
42+
): string => {
43+
const url = `/course/${courseId}/subsection/${subsectionId}`;
44+
return url;
45+
};

0 commit comments

Comments
 (0)