Skip to content

Commit

Permalink
Feat course optimizer page (#1533)
Browse files Browse the repository at this point in the history
Course Optimizer is a feature approved by the Openedx community that adds a "Course Optimizer" page to studio where users can run a scan of a course for broken links - links that point to pages that have a 404.

Depends on backend: openedx/edx-platform#35887 - test together.

This also requires adding a nav menu item to edx-platform legacy studio. That should be implemented before enabling the waffle flag on prod.

Links:
- [Internal JIRA ticket](https://2u-internal.atlassian.net/browse/TNL-11809)
- [Course Optimizer Discovery](https://2u-internal.atlassian.net/wiki/spaces/TNL/pages/1426587703/TNL-11744+Course+Optimizer+Discovery)
- [Openedx community proposal](openedx/platform-roadmap#388)
  • Loading branch information
jesperhodge authored Jan 13, 2025
1 parent e6bce56 commit 8385c4e
Show file tree
Hide file tree
Showing 29 changed files with 1,724 additions and 8 deletions.
5 changes: 5 additions & 0 deletions src/CourseAuthoringRoutes.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { CourseUpdates } from './course-updates';
import { CourseUnit, IframeProvider } from './course-unit';
import { Certificates } from './certificates';
import CourseExportPage from './export-page/CourseExportPage';
import CourseOptimizerPage from './optimizer-page/CourseOptimizerPage';
import CourseImportPage from './import-page/CourseImportPage';
import { DECODED_ROUTES } from './constants';
import CourseChecklist from './course-checklist';
Expand Down Expand Up @@ -118,6 +119,10 @@ const CourseAuthoringRoutes = () => {
path="export"
element={<PageWrap><CourseExportPage courseId={courseId} /></PageWrap>}
/>
<Route
path="optimizer"
element={<PageWrap><CourseOptimizerPage courseId={courseId} /></PageWrap>}
/>
<Route
path="checklists"
element={<PageWrap><CourseChecklist courseId={courseId} /></PageWrap>}
Expand Down
7 changes: 7 additions & 0 deletions src/data/constants.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,13 @@ export const RequestStatus = /** @type {const} */ ({
NOT_FOUND: 'not-found',
});

export const RequestFailureStatuses = [
RequestStatus.FAILED,
RequestStatus.DENIED,
RequestStatus.PARTIAL_FAILURE,
RequestStatus.NOT_FOUND,
];

/**
* Team sizes enum
* @enum
Expand Down
4 changes: 4 additions & 0 deletions src/header/hooks.js
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,10 @@ export const useToolsMenuItems = courseId => {
href: `/course/${courseId}/checklists`,
title: intl.formatMessage(messages['header.links.checklists']),
},
...(waffleFlags.enableCourseOptimizer ? [{
href: `/course/${courseId}/optimizer`,
title: intl.formatMessage(messages['header.links.optimizer']),
}] : []),
];
return items;
};
29 changes: 21 additions & 8 deletions src/header/hooks.test.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { useSelector } from 'react-redux';
import { getConfig, setConfig } from '@edx/frontend-platform';
import { renderHook } from '@testing-library/react-hooks';
import messages from './messages';
import { useContentMenuItems, useToolsMenuItems, useSettingMenuItems } from './hooks';

jest.mock('@edx/frontend-platform/i18n', () => ({
Expand All @@ -17,15 +18,15 @@ jest.mock('react-redux', () => ({

describe('header utils', () => {
describe('getContentMenuItems', () => {
it('should include Video Uploads option', () => {
it('when video upload page enabled should include Video Uploads option', () => {
setConfig({
...getConfig(),
ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN: 'true',
});
const actualItems = renderHook(() => useContentMenuItems('course-123')).result.current;
expect(actualItems).toHaveLength(5);
});
it('should not include Video Uploads option', () => {
it('when video upload page disabled should not include Video Uploads option', () => {
setConfig({
...getConfig(),
ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN: 'false',
Expand All @@ -38,35 +39,35 @@ describe('header utils', () => {
describe('getSettingsMenuitems', () => {
useSelector.mockReturnValue({ canAccessAdvancedSettings: true });

it('should include certificates option', () => {
it('when certificate page enabled should include certificates option', () => {
setConfig({
...getConfig(),
ENABLE_CERTIFICATE_PAGE: 'true',
});
const actualItems = renderHook(() => useSettingMenuItems('course-123')).result.current;
expect(actualItems).toHaveLength(6);
});
it('should not include certificates option', () => {
it('when certificate page disabled should not include certificates option', () => {
setConfig({
...getConfig(),
ENABLE_CERTIFICATE_PAGE: 'false',
});
const actualItems = renderHook(() => useSettingMenuItems('course-123')).result.current;
expect(actualItems).toHaveLength(5);
});
it('should include advanced settings option', () => {
it('when user has access to advanced settings should include advanced settings option', () => {
const actualItemsTitle = renderHook(() => useSettingMenuItems('course-123')).result.current.map((item) => item.title);
expect(actualItemsTitle).toContain('Advanced Settings');
});
it('should not include advanced settings option', () => {
it('when user has no access to advanced settings should not include advanced settings option', () => {
useSelector.mockReturnValue({ canAccessAdvancedSettings: false });
const actualItemsTitle = renderHook(() => useSettingMenuItems('course-123')).result.current.map((item) => item.title);
expect(actualItemsTitle).not.toContain('Advanced Settings');
});
});

describe('getToolsMenuItems', () => {
it('should include export tags option', () => {
it('when tags enabled should include export tags option', () => {
setConfig({
...getConfig(),
ENABLE_TAGGING_TAXONOMY_PAGES: 'true',
Expand All @@ -79,7 +80,7 @@ describe('header utils', () => {
'Checklists',
]);
});
it('should not include export tags option', () => {
it('when tags disabled should not include export tags option', () => {
setConfig({
...getConfig(),
ENABLE_TAGGING_TAXONOMY_PAGES: 'false',
Expand All @@ -91,5 +92,17 @@ describe('header utils', () => {
'Checklists',
]);
});

it('when course optimizer enabled should include optimizer option', () => {
useSelector.mockReturnValue({ enableCourseOptimizer: true });
const actualItemsTitle = renderHook(() => useToolsMenuItems('course-123')).result.current.map((item) => item.title);
expect(actualItemsTitle).toContain(messages['header.links.optimizer'].defaultMessage);
});

it('when course optimizer disabled should not include optimizer option', () => {
useSelector.mockReturnValue({ enableCourseOptimizer: false });
const actualItemsTitle = renderHook(() => useToolsMenuItems('course-123')).result.current.map((item) => item.title);
expect(actualItemsTitle).not.toContain(messages['header.links.optimizer'].defaultMessage);
});
});
});
5 changes: 5 additions & 0 deletions src/header/messages.js
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,11 @@ const messages = defineMessages({
defaultMessage: 'Export Course',
description: 'Link to Studio Export page',
},
'header.links.optimizer': {
id: 'header.links.optimizer',
defaultMessage: 'Optimize Course',
description: 'Fix broken links and other issues in your course',
},
'header.links.exportTags': {
id: 'header.links.exportTags',
defaultMessage: 'Export Tags',
Expand Down
1 change: 1 addition & 0 deletions src/index.scss
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
@import "search-manager";
@import "certificates/scss/Certificates";
@import "group-configurations/GroupConfigurations";
@import "optimizer-page/scan-results/ScanResults";

// To apply the glow effect to the selected Section/Subsection, in the Course Outline
div.row:has(> div > div.highlight) {
Expand Down
193 changes: 193 additions & 0 deletions src/optimizer-page/CourseOptimizerPage.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
/* eslint-disable @typescript-eslint/no-shadow */
/* eslint-disable react/jsx-filename-extension */
import {
fireEvent, render, waitFor, screen,
} from '@testing-library/react';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import { AppProvider } from '@edx/frontend-platform/react';
import { initializeMockApp } from '@edx/frontend-platform';

import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import MockAdapter from 'axios-mock-adapter';
import initializeStore from '../store';
import messages from './messages';
import generalMessages from '../messages';
import scanResultsMessages from './scan-results/messages';
import CourseOptimizerPage, { pollLinkCheckDuringScan } from './CourseOptimizerPage';
import { postLinkCheckCourseApiUrl, getLinkCheckStatusApiUrl } from './data/api';
import mockApiResponse from './mocks/mockApiResponse';
import * as thunks from './data/thunks';

let store;
let axiosMock;
const courseId = '123';
const courseName = 'About Node JS';

jest.mock('../generic/model-store', () => ({
useModel: jest.fn().mockReturnValue({
name: courseName,
}),
}));

const OptimizerPage = () => (
<AppProvider store={store}>
<IntlProvider locale="en" messages={{}}>
<CourseOptimizerPage courseId={courseId} />
</IntlProvider>
</AppProvider>
);

describe('CourseOptimizerPage', () => {
describe('pollLinkCheckDuringScan', () => {
let mockFetchLinkCheckStatus;
beforeEach(() => {
mockFetchLinkCheckStatus = jest.fn();
jest.spyOn(thunks, 'fetchLinkCheckStatus').mockImplementation(mockFetchLinkCheckStatus);
jest.useFakeTimers();
jest.spyOn(global, 'setInterval').mockImplementation((cb) => { cb(); return true; });
});

afterEach(() => {
jest.clearAllTimers();
jest.useRealTimers();
jest.restoreAllMocks();
});

it('should start polling if linkCheckInProgress has never been started (is null)', () => {
const linkCheckInProgress = null;
const interval = { current: null };
const dispatch = jest.fn();
const courseId = 'course-123';
pollLinkCheckDuringScan(linkCheckInProgress, interval, dispatch, courseId);
expect(interval.current).toBeTruthy();
expect(mockFetchLinkCheckStatus).toHaveBeenCalled();
});

it('should start polling if link check is in progress', () => {
const linkCheckInProgress = true;
const interval = { current: null };
const dispatch = jest.fn();
const courseId = 'course-123';
pollLinkCheckDuringScan(linkCheckInProgress, interval, dispatch, courseId);
expect(interval.current).toBeTruthy();
});
it('should not start polling if link check is not in progress', () => {
const linkCheckInProgress = false;
const interval = { current: null };
const dispatch = jest.fn();
const courseId = 'course-123';
pollLinkCheckDuringScan(linkCheckInProgress, interval, dispatch, courseId);
expect(interval.current).toBeFalsy();
});
it('should clear the interval if link check is finished', () => {
const linkCheckInProgress = false;
const interval = { current: 1 };
const dispatch = jest.fn();
const courseId = 'course-123';
pollLinkCheckDuringScan(linkCheckInProgress, interval, dispatch, courseId);
expect(interval.current).toBeUndefined();
});
});

describe('CourseOptimizerPage component', () => {
beforeEach(() => {
jest.useRealTimers();
jest.clearAllMocks();
initializeMockApp({
authenticatedUser: {
userId: 3,
username: 'abc123',
administrator: true,
roles: [],
},
});
store = initializeStore();
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
axiosMock
.onPost(postLinkCheckCourseApiUrl(courseId))
.reply(200, { LinkCheckStatus: 'In-Progress' });
axiosMock
.onGet(getLinkCheckStatusApiUrl(courseId))
.reply(200, mockApiResponse);
});

it('should render the component', () => {
const { getByText, queryByText } = render(<OptimizerPage />);
expect(getByText(messages.headingTitle.defaultMessage)).toBeInTheDocument();
expect(getByText(messages.buttonTitle.defaultMessage)).toBeInTheDocument();
expect(queryByText(messages.preparingStepTitle)).not.toBeInTheDocument();
});

it('should start scan after clicking the scan button', async () => {
const { getByText } = render(<OptimizerPage />);
expect(getByText(messages.headingTitle.defaultMessage)).toBeInTheDocument();
fireEvent.click(getByText(messages.buttonTitle.defaultMessage));
await waitFor(() => {
expect(getByText(messages.preparingStepTitle.defaultMessage)).toBeInTheDocument();
});
});

it('should list broken links results', async () => {
const {
getByText, queryAllByText, getAllByText, container,
} = render(<OptimizerPage />);
expect(getByText(messages.headingTitle.defaultMessage)).toBeInTheDocument();
fireEvent.click(getByText(messages.buttonTitle.defaultMessage));
await waitFor(() => {
expect(getByText('5 broken links')).toBeInTheDocument();
expect(getByText('5 locked links')).toBeInTheDocument();
});
const collapsibleTrigger = container.querySelector('.collapsible-trigger');
expect(collapsibleTrigger).toBeInTheDocument();
fireEvent.click(collapsibleTrigger);
await waitFor(() => {
expect(getAllByText(scanResultsMessages.brokenLinkStatus.defaultMessage)[0]).toBeInTheDocument();
expect(queryAllByText(scanResultsMessages.lockedLinkStatus.defaultMessage)[0]).toBeInTheDocument();
});
});

it('should not list locked links results when show locked links is unchecked', async () => {
const {
getByText, getAllByText, getByLabelText, queryAllByText, queryByText, container,
} = render(<OptimizerPage />);
expect(getByText(messages.headingTitle.defaultMessage)).toBeInTheDocument();
fireEvent.click(getByText(messages.buttonTitle.defaultMessage));
await waitFor(() => {
expect(getByText('5 broken links')).toBeInTheDocument();
});
fireEvent.click(getByLabelText(scanResultsMessages.lockedCheckboxLabel.defaultMessage));
const collapsibleTrigger = container.querySelector('.collapsible-trigger');
expect(collapsibleTrigger).toBeInTheDocument();
fireEvent.click(collapsibleTrigger);
await waitFor(() => {
expect(queryByText('5 locked links')).not.toBeInTheDocument();
expect(getAllByText(scanResultsMessages.brokenLinkStatus.defaultMessage)[0]).toBeInTheDocument();
expect(queryAllByText(scanResultsMessages.lockedLinkStatus.defaultMessage)?.[0]).toBeUndefined();
});
});

it('should show no broken links found message', async () => {
axiosMock
.onGet(getLinkCheckStatusApiUrl(courseId))
.reply(200, { LinkCheckStatus: 'Succeeded' });
const { getByText } = render(<OptimizerPage />);
expect(getByText(messages.headingTitle.defaultMessage)).toBeInTheDocument();
fireEvent.click(getByText(messages.buttonTitle.defaultMessage));
await waitFor(() => {
expect(getByText(scanResultsMessages.noBrokenLinksCard.defaultMessage)).toBeInTheDocument();
});
});

it('should show error message if request does not go through', async () => {
axiosMock
.onPost(postLinkCheckCourseApiUrl(courseId))
.reply(500);
render(<OptimizerPage />);
expect(screen.getByText(messages.headingTitle.defaultMessage)).toBeInTheDocument();
fireEvent.click(screen.getByText(messages.buttonTitle.defaultMessage));
await waitFor(() => {
expect(screen.getByText(generalMessages.supportText.defaultMessage)).toBeInTheDocument();
});
});
});
});
Loading

0 comments on commit 8385c4e

Please sign in to comment.