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:
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
);
|
||||
|
||||
79
src/course-unit/SubsectionUnitRedirect.test.tsx
Normal file
79
src/course-unit/SubsectionUnitRedirect.test.tsx
Normal 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)}`);
|
||||
});
|
||||
});
|
||||
});
|
||||
29
src/course-unit/SubsectionUnitRedirect.tsx
Normal file
29
src/course-unit/SubsectionUnitRedirect.tsx
Normal 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;
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}`}
|
||||
>
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
export { default as CourseUnit } from './CourseUnit';
|
||||
export { default as SubsectionUnitRedirect } from './SubsectionUnitRedirect';
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user