-
- {`${pageTitleBreadCrumbs.join(' | ')} | ${getConfig().SITE_NAME}`}
-
+ {minimalCourseOutlineQuery.isSuccess && (
+
+ {`${pageTitleBreadCrumbs.join(' | ')} | ${getConfig().SITE_NAME}`}
+
+ )}
[
+ useModels('sections', course.sectionIds ?? [])?.map((section) => [
section.id,
{
default: section.id === sectionId,
diff --git a/src/courseware/course/course-exit/CourseExit.jsx b/src/courseware/course/course-exit/CourseExit.jsx
index ea0c41805d..101fed7f22 100644
--- a/src/courseware/course/course-exit/CourseExit.jsx
+++ b/src/courseware/course/course-exit/CourseExit.jsx
@@ -72,13 +72,17 @@ const CourseExit = () => {
const { courseId } = useParams();
const metadataQuery = useCoursewareMetadata(courseId);
const courseHomeMetaQuery = useCourseHomeMeta(courseId);
- useMinimalCourseOutline(courseId);
+ const outlineQuery = useMinimalCourseOutline(courseId);
+
+ if (outlineQuery.isError && courseHomeMetaQuery.data?.courseAccess?.hasAccess) {
+ return ();
+ }
return (
diff --git a/src/courseware/course/course-exit/CourseExit.test.jsx b/src/courseware/course/course-exit/CourseExit.test.jsx
index ee6eeae797..c532865fed 100644
--- a/src/courseware/course/course-exit/CourseExit.test.jsx
+++ b/src/courseware/course/course-exit/CourseExit.test.jsx
@@ -3,12 +3,14 @@ import MockAdapter from 'axios-mock-adapter';
import { Factory } from 'rosie';
import { getConfig, history } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
-import { waitFor, waitForElementToBeRemoved } from '@testing-library/react';
+import { act, waitFor, waitForElementToBeRemoved } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { BrowserRouter, Route, Routes } from 'react-router-dom';
import { QueryClientProvider } from '@tanstack/react-query';
import { getCourseMetadata } from '../../data/api';
+import { coursewareQueryKeys } from '../../data/queryKeys';
+import { courseHomeQueryKeys } from '../../../course-home/data/queryKeys';
import { getCourseHomeCourseMetadata } from '../../../course-home/data/api';
import { addModel } from '../../../generic/model-store';
import { buildSimpleCourseBlocks } from '../../../shared/data/__factories__/courseBlocks.factory';
@@ -23,6 +25,7 @@ import CourseCelebration from './CourseCelebration';
import CourseExit from './CourseExit';
import CourseInProgress from './CourseInProgress';
import CourseNonPassing from './CourseNonPassing';
+import messages from './messages';
initializeMockApp();
jest.mock('@edx/frontend-platform/analytics');
@@ -103,6 +106,112 @@ describe('Course Exit Pages', () => {
logUnhandledRequests(axiosMock);
});
+ describe('when the outline is loading', () => {
+ const courseEndPath = `/course/${courseId}/course-end`;
+
+ // Holds the outline request, as a slow outline would; the page's mode depends on
+ // `hasScheduledContent`, which only the outline carries.
+ const holdOutline = () => {
+ axiosMock.onGet(learningSequencesUrlRegExp).reply(() => new Promise(() => {}));
+ };
+
+ const renderCourseEnd = () => {
+ const queryClient = createTestQueryClient(store);
+ history.push(courseEndPath);
+ render(
+
+
+
+ } />
+ Courseware
} />
+
+
+ ,
+ { store, wrapWithRouter: false },
+ );
+ return queryClient;
+ };
+
+ const waitForMetadataWithOutlinePending = async (queryClient) => {
+ await waitFor(() => {
+ expect(queryClient.getQueryState(coursewareQueryKeys.metadata(courseId))?.status).toBe('success');
+ expect(queryClient.getQueryState(courseHomeQueryKeys.metadata(courseId))?.status).toBe('success');
+ });
+ await act(async () => { await new Promise((resolve) => { setTimeout(resolve, 0); }); });
+ };
+
+ const itWaitsForTheOutline = () => {
+ it('does not redirect', async () => {
+ holdOutline();
+ await waitForMetadataWithOutlinePending(renderCourseEnd());
+
+ expect(global.location.pathname).toBe(courseEndPath);
+ });
+
+ it('does not render any course-end content', async () => {
+ holdOutline();
+ await waitForMetadataWithOutlinePending(renderCourseEnd());
+
+ expect(screen.queryByText(messages.congratulationsHeader.defaultMessage)).not.toBeInTheDocument();
+ expect(screen.queryByText(messages.endOfCourseHeader.defaultMessage)).not.toBeInTheDocument();
+ expect(screen.queryByText(messages.courseInProgressHeader.defaultMessage)).not.toBeInTheDocument();
+ });
+ };
+
+ describe('for an audit learner who is not passing and cannot view a certificate', () => {
+ beforeEach(() => {
+ setMetadata(
+ { enrollment: { is_active: true }, user_has_passing_grade: false, certificate_data: { cert_status: 'audit_notpassing' } },
+ { can_view_certificate: false },
+ );
+ });
+
+ itWaitsForTheOutline();
+ });
+
+ describe('for an audit learner who is not passing and can view a certificate', () => {
+ beforeEach(() => {
+ setMetadata(
+ { enrollment: { is_active: true }, user_has_passing_grade: false, certificate_data: { cert_status: 'audit_notpassing' } },
+ { can_view_certificate: true },
+ );
+ });
+
+ itWaitsForTheOutline();
+ });
+
+ describe('for a learner who is not passing, has no certificate data and cannot view a certificate', () => {
+ beforeEach(() => {
+ setMetadata(
+ { enrollment: { is_active: true }, user_has_passing_grade: false, certificate_data: null },
+ { can_view_certificate: false },
+ );
+ });
+
+ itWaitsForTheOutline();
+ });
+ });
+
+ describe('when the outline fails', () => {
+ it('redirects to the course home', async () => {
+ axiosMock.onGet(learningSequencesUrlRegExp).reply(500);
+ history.push(`/course/${courseId}/course-end`);
+ render(
+
+
+
+ } />
+ Course home} />
+
+
+ ,
+ { store, wrapWithRouter: false },
+ );
+
+ await waitFor(() => expect(global.location.pathname).toBe(`/course/${courseId}/home`));
+ });
+ });
+
describe('Course Exit routing', () => {
it('Routes to celebration for a celebration status', async () => {
setMetadata({
diff --git a/src/courseware/course/sequence/sequence-navigation/SequenceNavigation.jsx b/src/courseware/course/sequence/sequence-navigation/SequenceNavigation.jsx
index 12bd3ce8e8..fdf45b9d35 100644
--- a/src/courseware/course/sequence/sequence-navigation/SequenceNavigation.jsx
+++ b/src/courseware/course/sequence/sequence-navigation/SequenceNavigation.jsx
@@ -75,7 +75,7 @@ const SequenceNavigation = ({
const renderNextButton = () => {
let buttonText;
- const disabled = isLastUnit && !exitActive;
+ const disabled = !nextLink || (isLastUnit && !exitActive);
if (isLastUnit && exitText) {
buttonText = exitText;
diff --git a/src/courseware/course/sequence/sequence-navigation/UnitNavigation.jsx b/src/courseware/course/sequence/sequence-navigation/UnitNavigation.jsx
index e0c9ad0188..8ba4184492 100644
--- a/src/courseware/course/sequence/sequence-navigation/UnitNavigation.jsx
+++ b/src/courseware/course/sequence/sequence-navigation/UnitNavigation.jsx
@@ -41,7 +41,7 @@ const UnitNavigation = ({
const renderNextButton = () => {
const { exitActive, exitText } = GetCourseExitNavigation(courseId, intl);
const buttonText = (isLastUnit && exitText) ? exitText : intl.formatMessage(messages.nextButton);
- const disabled = isLastUnit && !exitActive;
+ const disabled = !nextLink || (isLastUnit && !exitActive);
const variant = 'outline-primary';
const buttonStyle = `next-button ${isAtTop ? 'text-dark' : 'justify-content-center'}`;
diff --git a/src/courseware/course/sequence/sequence-navigation/UnitNavigation.test.jsx b/src/courseware/course/sequence/sequence-navigation/UnitNavigation.test.jsx
index 278180f8a3..b742714039 100644
--- a/src/courseware/course/sequence/sequence-navigation/UnitNavigation.test.jsx
+++ b/src/courseware/course/sequence/sequence-navigation/UnitNavigation.test.jsx
@@ -64,8 +64,10 @@ describe('Unit Navigation', () => {
onClickNext: () => {},
});
- // Only "Previous" and "Next" buttons should be rendered.
- expect(screen.getAllByRole('link')).toHaveLength(2);
+ // Only "Previous" and "Next" buttons should be rendered, disabled: there is no sequence to navigate in.
+ expect(screen.getAllByRole('button')).toHaveLength(2);
+ expect(screen.getByRole('button', { name: 'Previous' })).toBeDisabled();
+ expect(screen.getByRole('button', { name: 'Next' })).toBeDisabled();
});
it('handles the clicks', async () => {
diff --git a/src/courseware/course/sequence/sequence-navigation/generic/PreviousButton.jsx b/src/courseware/course/sequence/sequence-navigation/generic/PreviousButton.jsx
index 1a2d25a3a4..a9ea9ac9b1 100644
--- a/src/courseware/course/sequence/sequence-navigation/generic/PreviousButton.jsx
+++ b/src/courseware/course/sequence/sequence-navigation/generic/PreviousButton.jsx
@@ -19,7 +19,7 @@ const PreviousButton = ({
isAtTop,
}) => {
const navigate = useNavigate();
- const disabled = isFirstUnit;
+ const disabled = isFirstUnit || !previousLink;
const { pathname } = useLocation();
const navLink = pathname.startsWith('/preview') ? `/preview${previousLink}` : previousLink;
diff --git a/src/plugin-slots/CourseBreadcrumbsSlot/index.test.tsx b/src/plugin-slots/CourseBreadcrumbsSlot/index.test.tsx
new file mode 100644
index 0000000000..a300e90b76
--- /dev/null
+++ b/src/plugin-slots/CourseBreadcrumbsSlot/index.test.tsx
@@ -0,0 +1,139 @@
+import { getConfig, setConfig } from '@edx/frontend-platform';
+import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
+import { IntlProvider } from '@edx/frontend-platform/i18n';
+import { AppProvider } from '@edx/frontend-platform/react';
+import { DIRECT_PLUGIN, PLUGIN_OPERATIONS } from '@openedx/frontend-plugin-framework';
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import {
+ act, render, screen, waitFor,
+} from '@testing-library/react';
+import MockAdapter from 'axios-mock-adapter';
+import type { ReactNode } from 'react';
+import { MemoryRouter } from 'react-router-dom';
+import { Factory } from 'rosie';
+
+import { useCourseHomeMeta } from '@src/course-home/data/apiHooks';
+import { courseHomeQueryKeys } from '@src/course-home/data/queryKeys';
+import CourseBreadcrumbs from '@src/courseware/course/breadcrumbs';
+import { buildOutlineFromBlocks } from '@src/courseware/data/__factories__/learningSequencesOutline.factory';
+import { useCoursewareMetadata, useMinimalCourseOutline } from '@src/courseware/data/apiHooks';
+import { coursewareQueryKeys } from '@src/courseware/data/queryKeys';
+import { createTestQueryClient, initializeMockApp } from '@src/setupTest';
+import { buildSimpleCourseBlocks } from '@src/shared/data/__factories__/courseBlocks.factory';
+import initializeStore from '@src/store';
+import MountCourseQueryHooks from '@src/tests/MountCourseQueryHooks';
+import { appendBrowserTimezoneToUrl } from '@src/utils';
+
+import { CourseBreadcrumbsSlot } from './index';
+
+jest.unmock('@openedx/frontend-plugin-framework');
+
+initializeMockApp();
+
+describe('CourseBreadcrumbsSlot', () => {
+ const courseMetadata = Factory.build('courseMetadata');
+ const courseHomeMetadata = Factory.build('courseHomeMetadata');
+ const courseId = courseMetadata.id;
+ const {
+ courseBlocks, sectionBlocks: [sectionBlock], sequenceBlocks: [sequenceBlock], unitBlocks,
+ } = buildSimpleCourseBlocks(courseId, courseHomeMetadata.title);
+ const sequenceMetadata = Factory.build('sequenceMetadata', {}, { courseId, sequenceBlock, unitBlocks });
+ const learningSequencesUrlRegExp = new RegExp(`${getConfig().LMS_BASE_URL}/api/learning_sequences/v1/course_outline/*`);
+
+ let axiosMock: MockAdapter;
+ let originalConfig: ReturnType;
+ let queryClient: QueryClient;
+ let store: ReturnType;
+
+ beforeEach(() => {
+ axiosMock = new MockAdapter(getAuthenticatedHttpClient());
+ axiosMock.onGet(appendBrowserTimezoneToUrl(`${getConfig().LMS_BASE_URL}/api/courseware/course/${courseId}`))
+ .reply(200, courseMetadata);
+ axiosMock.onGet(appendBrowserTimezoneToUrl(`${getConfig().LMS_BASE_URL}/api/course_home/course_metadata/${courseId}`))
+ .reply(200, courseHomeMetadata);
+ axiosMock.onGet(`${getConfig().LMS_BASE_URL}/api/courseware/sequence/${sequenceBlock.id}`).reply(200, sequenceMetadata);
+ store = initializeStore();
+ queryClient = createTestQueryClient(store);
+
+ // The default breadcrumbs inserted as the slot's README shows.
+ originalConfig = getConfig();
+ setConfig({
+ ...originalConfig,
+ pluginSlots: {
+ 'org.openedx.frontend.learning.course_breadcrumbs.v1': {
+ keepDefault: false,
+ plugins: [{
+ op: PLUGIN_OPERATIONS.Insert,
+ widget: {
+ id: 'default_breadcrumbs_component',
+ priority: 50,
+ type: DIRECT_PLUGIN,
+ RenderWidget: CourseBreadcrumbs,
+ },
+ }],
+ },
+ },
+ });
+ });
+
+ afterEach(() => {
+ setConfig(originalConfig);
+ });
+
+ // Holds the outline request, as a slow outline would.
+ const holdOutline = () => {
+ axiosMock.onGet(learningSequencesUrlRegExp).reply(() => new Promise(() => {}));
+ };
+
+ // Renders its children once both metadata queries have succeeded, as the courseware page
+ // renders the slot, and, unless `waitForOutline` is false, once the outline has too.
+ const LoadedGate = ({ waitForOutline = true, children }: { waitForOutline?: boolean, children: ReactNode }) => {
+ const metadataQuery = useCoursewareMetadata(courseId, { enabled: false });
+ const courseHomeMetaQuery = useCourseHomeMeta(courseId, { enabled: false });
+ const outlineQuery = useMinimalCourseOutline(courseId, { enabled: false });
+ const isLoaded = metadataQuery.isSuccess && courseHomeMetaQuery.isSuccess
+ && (!waitForOutline || outlineQuery.isSuccess);
+ return isLoaded ? <>{children}> : null;
+ };
+
+ const renderSlot = ({ waitForOutline = true }: { waitForOutline?: boolean } = {}) => render(
+
+
+
+
+
+
+
+
+
+
+
+ ,
+ );
+
+ it('renders inserted breadcrumbs', async () => {
+ axiosMock.onGet(learningSequencesUrlRegExp).reply(200, buildOutlineFromBlocks(courseBlocks));
+ renderSlot();
+
+ expect(await screen.findAllByTestId('breadcrumb-item')).toHaveLength(2);
+ expect(screen.queryByTestId('error-page')).not.toBeInTheDocument();
+ });
+
+ it('inserted breadcrumbs do not error while the outline is loading', async () => {
+ holdOutline();
+ renderSlot({ waitForOutline: false });
+ await waitFor(() => {
+ expect(queryClient.getQueryState(coursewareQueryKeys.metadata(courseId))?.status).toBe('success');
+ expect(queryClient.getQueryState(courseHomeQueryKeys.metadata(courseId))?.status).toBe('success');
+ });
+ await act(async () => { await new Promise((resolve) => { setTimeout(resolve, 0); }); });
+
+ expect(screen.queryByTestId('error-page')).not.toBeInTheDocument();
+ });
+});
diff --git a/src/tab-page/TabPage.test.jsx b/src/tab-page/TabPage.test.jsx
index bcab51083c..6f34ef65d3 100644
--- a/src/tab-page/TabPage.test.jsx
+++ b/src/tab-page/TabPage.test.jsx
@@ -107,6 +107,61 @@ describe('Tab Page', () => {
expect(screen.getByText('There was an error loading this course.')).toBeInTheDocument();
});
+ describe('with several tab-data queries', () => {
+ const forbidden = (detail) => ({
+ isError: true,
+ error: { response: { status: 403, data: { detail } } },
+ });
+
+ it('renders the Loaded Tab Page when every tab-data query resolves', () => {
+ render(
+ ,
+ { wrapWithRouter: true },
+ );
+ expect(screen.getByTestId('LoadedTabPage')).toBeInTheDocument();
+ });
+
+ it('displays loading while any tab-data query is loading', () => {
+ render(
+ ,
+ { wrapWithRouter: true },
+ );
+ expect(screen.getByText('Loading course pageā¦')).toBeInTheDocument();
+ });
+
+ it('displays the 403 detail of every failed tab-data query', () => {
+ render(
+ ,
+ { wrapWithRouter: true },
+ );
+ expect(screen.getByText('The first detail.')).toBeInTheDocument();
+ expect(screen.getByText('The second detail.')).toBeInTheDocument();
+ });
+
+ it('displays a detail shared by several failed tab-data queries once', () => {
+ render(
+ ,
+ { wrapWithRouter: true },
+ );
+ expect(screen.getAllByText('The same detail.')).toHaveLength(1);
+ });
+ });
+
it('renders no tab content when courseId is missing', () => {
render(
;
- tabDataQuery?: UseQueryResult;
-};
+} & (
+ | { tabDataQuery?: UseQueryResult; tabDataQueries?: never }
+ | { tabDataQuery?: never; tabDataQueries: UseQueryResult[] }
+);
export interface TabPageProps {
activeTabSlug: string;
@@ -36,22 +39,32 @@ interface TabView {
isLoading: boolean;
isError: boolean;
isDenied: boolean;
- errorDetail?: string;
+ errorDetails?: string[];
}
+const getErrorDetails = (queries: { error: unknown }[]): string[] => {
+ const details = queries.flatMap((query) => getErrorDetail(query.error) || []);
+ return [...new Set(details)];
+};
+
const deriveView = (courseStatus: CourseStatus): TabView => {
const view = { isLoading: false, isError: false, isDenied: false };
// Access is read from the metadata query, resolved before tabData is considered.
- const { metadataQuery, tabDataQuery } = courseStatus;
+ const {
+ metadataQuery,
+ tabDataQuery,
+ tabDataQueries = tabDataQuery ? [tabDataQuery] : [],
+ } = courseStatus;
if (metadataQuery.isError) {
- return { ...view, isError: true, errorDetail: getErrorDetail(metadataQuery.error) };
+ return { ...view, isError: true, errorDetails: getErrorDetails([metadataQuery]) };
}
if (metadataQuery.isPending) { return { ...view, isLoading: true }; }
- if (tabDataQuery?.isPending) { return { ...view, isLoading: true }; }
+ if (tabDataQueries.some((query) => query.isPending)) { return { ...view, isLoading: true }; }
if (!metadataQuery.data?.courseAccess?.hasAccess) { return { ...view, isDenied: true }; }
- if (tabDataQuery?.isError) {
- return { ...view, isError: true, errorDetail: getErrorDetail(tabDataQuery.error) };
+ const failedQueries = tabDataQueries.filter((query) => query.isError);
+ if (failedQueries.length > 0) {
+ return { ...view, isError: true, errorDetails: getErrorDetails(failedQueries) };
}
return view;
};
@@ -74,7 +87,7 @@ const TabPage = ({
}: Partial = courseStatus.metadataQuery.data ?? {};
const {
- isLoading, isError, isDenied, errorDetail,
+ isLoading, isError, isDenied, errorDetails,
} = deriveView(courseStatus);
if (isDenied) {
@@ -125,11 +138,14 @@ const TabPage = ({
);
};
- const renderError = () => (
-
- {errorDetail || intl.formatMessage(messages.failure)}
-
- );
+ const renderError = () => {
+ const errorMessages = errorDetails?.length ? errorDetails : [intl.formatMessage(messages.failure)];
+ return (
+
+ {errorMessages.map((errorMessage) =>
{errorMessage}
)}
+
+ );
+ };
return (