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.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/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 (