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