Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 48 additions & 1 deletion src/courseware/CoursewareContainer.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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';
Expand Down Expand Up @@ -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(<BrowserRouter>{component}</BrowserRouter>);
};

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();
Expand Down
11 changes: 7 additions & 4 deletions src/courseware/course/Course.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -76,9 +77,11 @@ const Course = ({

return (
<SidebarProvider courseId={courseId} unitId={unitId} widgets={widgets}>
<Helmet>
<title>{`${pageTitleBreadCrumbs.join(' | ')} | ${getConfig().SITE_NAME}`}</title>
</Helmet>
{minimalCourseOutlineQuery.isSuccess && (
<Helmet>
<title>{`${pageTitleBreadCrumbs.join(' | ')} | ${getConfig().SITE_NAME}`}</title>
</Helmet>
)}
<div className="position-relative d-flex align-items-xl-center mb-4 mt-1 flex-column flex-xl-row">
<CourseBreadcrumbsSlot
courseId={courseId}
Expand Down
2 changes: 1 addition & 1 deletion src/courseware/course/breadcrumbs/CourseBreadcrumbs.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ const CourseBreadcrumbs = ({
const { sequences = {} } = useMinimalCourseOutline(courseId, { enabled: false }).data ?? {};

const allSequencesInSections = Object.fromEntries(
useModels('sections', course.sectionIds)?.map((section) => [
useModels('sections', course.sectionIds ?? [])?.map((section) => [
section.id,
{
default: section.id === sectionId,
Expand Down
8 changes: 6 additions & 2 deletions src/courseware/course/course-exit/CourseExit.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (<Navigate to={`/course/${courseId}/home`} replace />);
}

return (
<TabWithTimer
activeTabSlug="courseware"
courseId={courseId}
courseStatus={{ metadataQuery: courseHomeMetaQuery, tabDataQuery: metadataQuery }}
courseStatus={{ metadataQuery: courseHomeMetaQuery, tabDataQueries: [metadataQuery, outlineQuery] }}
>
<CourseExitContent />
</TabWithTimer>
Expand Down
111 changes: 110 additions & 1 deletion src/courseware/course/course-exit/CourseExit.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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');
Expand Down Expand Up @@ -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(
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<Routes>
<Route path="/course/:courseId/course-end" element={<CourseExit />} />
<Route path="/course/:courseId" element={<div>Courseware</div>} />
</Routes>
</BrowserRouter>
</QueryClientProvider>,
{ 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(
<QueryClientProvider client={createTestQueryClient(store)}>
<BrowserRouter>
<Routes>
<Route path="/course/:courseId/course-end" element={<CourseExit />} />
<Route path="/course/:courseId/home" element={<div>Course home</div>} />
</Routes>
</BrowserRouter>
</QueryClientProvider>,
{ 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({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ const SequenceNavigation = ({

const renderNextButton = () => {
let buttonText;
const disabled = isLastUnit && !exitActive;
const disabled = !nextLink || (isLastUnit && !exitActive);

if (isLastUnit && exitText) {
buttonText = exitText;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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'}`;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
Loading
Loading