From 4800512435b29268e3cf5bdbfdf89d980fb9336b Mon Sep 17 00:00:00 2001 From: Brian Smith Date: Tue, 29 Sep 2026 08:57:35 -0400 Subject: [PATCH 1/2] test: reproduce the courseware and course-end pages rendering before the outline has loaded Adds failing cases for #2138. Both pages gate `TabPage` on the course-home metadata and the courseware metadata only, so when the learning-sequences outline lands after both, they render without it. Each case holds the outline request on a promise it releases later, the test form of the manual repro's delayed outline, and waits for both metadata queries by their query state rather than for the spinner, so the cases hold whether the fix gates the pages on the outline again or makes each reader handle it pending. - `CourseBreadcrumbsSlot/index.test.tsx` (new): the default `CourseBreadcrumbs` inserted as the slot's README shows, through the real FPF (`jest.unmock`, since `setupTest.js` mocks it for every suite), and rendered behind a test gate that waits, as the courseware page does, for both metadata queries. With the outline held the inserted breadcrumbs throw in the `useModels` read; with the outline also in the gate they render (a baseline that passes on this commit). - `CoursewareContainer.test`: the Next control is enabled before it can navigate, a link to the current unit; the page title has an empty segment. - `CourseExit.test`, when the outline is loading: the page does not redirect, and does not render any course-end content. The page's mode depends on `hasScheduledContent`, which only the outline carries, so the two cases run for three learners who are not passing, each of whom gets a different wrong outcome on this commit: an audit learner who cannot view a certificate is redirected, one who can is shown the not-passing page, and a learner with no certificate data who cannot view one is shown the celebration page. Six of these cases fail on this commit (the slot case, the two courseware ones, and one per course-end learner); the next commit on this layer fixes them. Part of #1946. Part of #2138. Co-Authored-By: Claude Opus 5.5 --- src/courseware/CoursewareContainer.test.jsx | 49 +++++- .../course/course-exit/CourseExit.test.jsx | 111 +++++++++++++- .../CourseBreadcrumbsSlot/index.test.tsx | 139 ++++++++++++++++++ 3 files changed, 297 insertions(+), 2 deletions(-) create mode 100644 src/plugin-slots/CourseBreadcrumbsSlot/index.test.tsx diff --git a/src/courseware/CoursewareContainer.test.jsx b/src/courseware/CoursewareContainer.test.jsx index 18e448580c..6a469eb27c 100644 --- a/src/courseware/CoursewareContainer.test.jsx +++ b/src/courseware/CoursewareContainer.test.jsx @@ -4,7 +4,7 @@ import { AppProvider } from '@edx/frontend-platform/react'; import { QueryClientProvider } from '@tanstack/react-query'; import { waitForElementToBeRemoved } from '@testing-library/dom'; import '@testing-library/jest-dom'; -import { render, screen } from '@testing-library/react'; +import { act, render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import React from 'react'; import { @@ -20,6 +20,8 @@ import { createTestQueryClient, initializeMockApp, waitFor } from '../setupTest' import { DECODE_ROUTES } from '../constants'; import CoursewareContainer from './CoursewareContainer'; +import { coursewareQueryKeys } from './data/queryKeys'; +import { courseHomeQueryKeys } from '../course-home/data/queryKeys'; import { buildSimpleCourseBlocks, buildBinaryCourseBlocks } from '../shared/data/__factories__/courseBlocks.factory'; import initializeStore from '../store'; import { appendBrowserTimezoneToUrl } from '../utils'; @@ -583,6 +585,51 @@ describe('CoursewareContainer', () => { }); }); + describe('while the learning-sequences outline is pending', () => { + const learningSequencesUrlRegExp = new RegExp(`${getConfig().LMS_BASE_URL}/api/learning_sequences/v1/course_outline/*`); + const unitPath = `/course/${defaultCourseId}/${defaultSequenceBlock.id}/${defaultUnitBlocks[1].id}`; + + // Holds the outline request, as a slow outline would. + const holdOutline = () => { + axiosMock.onGet(learningSequencesUrlRegExp).reply(() => new Promise(() => {})); + }; + + // Both metadata queries have succeeded, and the page has had a render with the outline still held. + const waitForMetadataWithOutlinePending = async () => { + await waitFor(() => { + expect(queryClient.getQueryState(coursewareQueryKeys.metadata(defaultCourseId))?.status).toBe('success'); + expect(queryClient.getQueryState(courseHomeQueryKeys.metadata(defaultCourseId))?.status).toBe('success'); + }); + await act(async () => { await new Promise((resolve) => { setTimeout(resolve, 0); }); }); + }; + + const renderUnit = () => { + history.push(unitPath); + render({component}); + }; + + beforeEach(() => { + setUpMockRequests(); + }); + + it('disables the Next control', async () => { + mockRenderUnitNav = true; + holdOutline(); + renderUnit(); + await waitForMetadataWithOutlinePending(); + + expect(screen.getByText(/^next$/i).closest('a, button')).toBeDisabled(); + }); + + it('does not show a page title with an empty segment', async () => { + holdOutline(); + renderUnit(); + await waitForMetadataWithOutlinePending(); + + expect(document.title).not.toMatch(/^\s*\||\|\s*\|/); + }); + }); + describe('request count', () => { it('requests the course metadata once per load', async () => { setUpMockRequests(); 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/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(); + }); +}); From 1e531236e681669b208989d81597ce3cbde7f4be Mon Sep 17 00:00:00 2001 From: Brian Smith Date: Tue, 29 Sep 2026 12:04:17 -0400 Subject: [PATCH 2/2] fix: render the courseware and course-end pages correctly while the outline is loading Since the courseware and course-end pages stopped waiting for the learning-sequences outline, they render in a window where only the metadata has loaded. In that window: - The unit and sequence navigation now renders disabled until it has a link to navigate to, instead of links to the current page. - Course sets the page title only once the outline has loaded, leaving the tab page's title in place rather than one with empty segments. - The course breadcrumbs show their existing not-loaded state instead of throwing inside an inserted plugin. - The course-end page waits for the outline through TabPage, which now accepts several tab-data queries (tabDataQueries) and shows every failed query's error detail, de-duplicated. When the outline fails, the course-end page again redirects to the course home, as it did before the React Query conversion. Closes #2138 Co-Authored-By: Claude Opus 5.5 --- src/courseware/course/Course.jsx | 11 ++-- .../course/breadcrumbs/CourseBreadcrumbs.jsx | 2 +- .../course/course-exit/CourseExit.jsx | 8 ++- .../SequenceNavigation.jsx | 2 +- .../sequence-navigation/UnitNavigation.jsx | 2 +- .../UnitNavigation.test.jsx | 6 +- .../generic/PreviousButton.jsx | 2 +- src/tab-page/TabPage.test.jsx | 55 +++++++++++++++++++ src/tab-page/TabPage.tsx | 46 +++++++++++----- 9 files changed, 107 insertions(+), 27 deletions(-) diff --git a/src/courseware/course/Course.jsx b/src/courseware/course/Course.jsx index 9b0f42028d..64beda62ad 100644 --- a/src/courseware/course/Course.jsx +++ b/src/courseware/course/Course.jsx @@ -35,7 +35,8 @@ const Course = ({ isStaff, originalUserIsStaff, } = useCourseHomeMeta(courseId, { enabled: false }).data ?? {}; - const minimalSequenceMetadata = useMinimalCourseOutline(courseId, { enabled: false }).data?.sequences[sequenceId]; + const minimalCourseOutlineQuery = useMinimalCourseOutline(courseId, { enabled: false }); + const minimalSequenceMetadata = minimalCourseOutlineQuery.data?.sequences[sequenceId]; const section = useModel('sections', minimalSequenceMetadata ? minimalSequenceMetadata.sectionId : null); const navigate = useNavigate(); const { pathname } = useLocation(); @@ -76,9 +77,11 @@ const Course = ({ return ( - - {`${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/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/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 (