Feat course optimizer page (#1533)

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](https://github.com/openedx/platform-roadmap/issues/388)
This commit is contained in:
Jesper Hodge
2025-01-13 11:44:25 -05:00
committed by GitHub
parent e6bce560bc
commit 8385c4e8ed
29 changed files with 1724 additions and 8 deletions

View File

@@ -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';
@@ -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>}

View File

@@ -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

View File

@@ -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;
};

View File

@@ -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', () => ({
@@ -17,7 +18,7 @@ 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',
@@ -25,7 +26,7 @@ describe('header utils', () => {
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',
@@ -38,7 +39,7 @@ 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',
@@ -46,7 +47,7 @@ describe('header utils', () => {
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',
@@ -54,11 +55,11 @@ describe('header utils', () => {
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');
@@ -66,7 +67,7 @@ describe('header utils', () => {
});
describe('getToolsMenuItems', () => {
it('should include export tags option', () => {
it('when tags enabled should include export tags option', () => {
setConfig({
...getConfig(),
ENABLE_TAGGING_TAXONOMY_PAGES: 'true',
@@ -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',
@@ -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);
});
});
});

View File

@@ -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',

View File

@@ -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) {

View File

@@ -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();
});
});
});
});

View File

@@ -0,0 +1,176 @@
/* eslint-disable no-param-reassign */
import {
useEffect, useRef, FC, MutableRefObject,
} from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { useIntl } from '@edx/frontend-platform/i18n';
import {
Container, Layout, Button, Card,
} from '@openedx/paragon';
import { Search as SearchIcon } from '@openedx/paragon/icons';
import { Helmet } from 'react-helmet';
import CourseStepper from '../generic/course-stepper';
import ConnectionErrorAlert from '../generic/ConnectionErrorAlert';
import SubHeader from '../generic/sub-header/SubHeader';
import { RequestFailureStatuses } from '../data/constants';
import messages from './messages';
import {
getCurrentStage, getError, getLinkCheckInProgress, getLoadingStatus, getSavingStatus, getLinkCheckResult,
getLastScannedAt,
} from './data/selectors';
import { startLinkCheck, fetchLinkCheckStatus } from './data/thunks';
import { useModel } from '../generic/model-store';
import ScanResults from './scan-results';
const pollLinkCheckStatus = (dispatch: any, courseId: string, delay: number): number => {
const interval = setInterval(() => {
dispatch(fetchLinkCheckStatus(courseId));
}, delay);
return interval as unknown as number;
};
export function pollLinkCheckDuringScan(
linkCheckInProgress: boolean | null,
interval: MutableRefObject<number | undefined>,
dispatch: any,
courseId: string,
) {
if (linkCheckInProgress === null || linkCheckInProgress) {
clearInterval(interval.current as number | undefined);
interval.current = pollLinkCheckStatus(dispatch, courseId, 2000);
} else if (interval.current) {
clearInterval(interval.current);
interval.current = undefined;
}
}
const CourseOptimizerPage: FC<{ courseId: string }> = ({ courseId }) => {
const dispatch = useDispatch();
const linkCheckInProgress = useSelector(getLinkCheckInProgress);
const loadingStatus = useSelector(getLoadingStatus);
const savingStatus = useSelector(getSavingStatus);
const currentStage = useSelector(getCurrentStage);
const linkCheckResult = useSelector(getLinkCheckResult);
const lastScannedAt = useSelector(getLastScannedAt);
const { msg: errorMessage } = useSelector(getError);
const isShowExportButton = !linkCheckInProgress || errorMessage;
const isLoadingDenied = (RequestFailureStatuses as string[]).includes(loadingStatus);
const isSavingDenied = (RequestFailureStatuses as string[]).includes(savingStatus);
const interval = useRef<number | undefined>(undefined);
const courseDetails = useModel('courseDetails', courseId);
const linkCheckPresent = !!currentStage;
const intl = useIntl();
const courseStepperSteps = [
{
title: intl.formatMessage(messages.preparingStepTitle),
description: intl.formatMessage(messages.preparingStepDescription),
key: 'course-step-preparing',
},
{
title: intl.formatMessage(messages.scanningStepTitle),
description: intl.formatMessage(messages.scanningStepDescription),
key: 'course-step-scanning',
},
{
title: intl.formatMessage(messages.successStepTitle),
description: intl.formatMessage(messages.successStepDescription),
key: 'course-step-success',
},
];
useEffect(() => {
// when first entering the page, fetch any existing scan results
dispatch(fetchLinkCheckStatus(courseId));
}, []);
useEffect(() => {
// when a scan starts, start polling for the results as long as the scan status fetched
// signals it is still in progress
pollLinkCheckDuringScan(linkCheckInProgress, interval, dispatch, courseId);
return () => {
if (interval.current) { clearInterval(interval.current); }
};
}, [linkCheckInProgress, linkCheckResult]);
if (isLoadingDenied || isSavingDenied) {
if (interval.current) { clearInterval(interval.current); }
return (
// <Container size="xl" className="course-unit px-4 mt-4">
<ConnectionErrorAlert />
// </Container>
);
}
return (
<>
<Helmet>
<title>
{intl.formatMessage(messages.pageTitle, {
headingTitle: intl.formatMessage(messages.headingTitle),
courseName: courseDetails?.name,
siteName: process.env.SITE_NAME,
})}
</title>
</Helmet>
<Container size="xl" className="mt-4 px-4 export">
<section className="setting-items mb-4">
<Layout
lg={[{ span: 9 }, { span: 3 }]}
md={[{ span: 9 }, { span: 3 }]}
sm={[{ span: 9 }, { span: 3 }]}
xs={[{ span: 9 }, { span: 3 }]}
xl={[{ span: 9 }, { span: 3 }]}
>
<Layout.Element>
<article>
<SubHeader
title={intl.formatMessage(messages.headingTitle)}
subtitle={intl.formatMessage(messages.headingSubtitle)}
/>
<p className="small">{intl.formatMessage(messages.description1)}</p>
<p className="small">{intl.formatMessage(messages.description2)}</p>
<Card>
<Card.Header
className="h3 px-3 text-black mb-4"
title={intl.formatMessage(messages.card1Title)}
/>
{isShowExportButton && (
<Card.Section className="px-3 py-1">
<Button
size="lg"
block
className="mb-4"
onClick={() => dispatch(startLinkCheck(courseId))}
iconBefore={SearchIcon}
>
{intl.formatMessage(messages.buttonTitle)} {lastScannedAt && `(${intl.formatMessage(messages.lastScannedOn)} ${intl.formatDate(lastScannedAt, { year: 'numeric', month: 'long', day: 'numeric' })})`}
</Button>
</Card.Section>
)}
{linkCheckPresent && (
<Card.Section className="px-3 py-1">
<CourseStepper
// @ts-ignore
steps={courseStepperSteps}
activeKey={currentStage}
hasError={currentStage < 0 || !!errorMessage}
errorMessage={errorMessage}
/>
</Card.Section>
)}
</Card>
{linkCheckPresent && <ScanResults data={linkCheckResult} />}
</article>
</Layout.Element>
</Layout>
</section>
</Container>
</>
);
};
export default CourseOptimizerPage;

View File

@@ -0,0 +1,34 @@
import mockApiResponse from '../mocks/mockApiResponse';
import { initializeMocks } from '../../testUtils';
import * as api from './api';
import { LINK_CHECK_STATUSES } from './constants';
describe('Course Optimizer API', () => {
describe('postLinkCheck', () => {
it('should get an affirmative response on starting a scan', async () => {
const { axiosMock } = initializeMocks();
const courseId = 'course-123';
const url = api.postLinkCheckCourseApiUrl(courseId);
axiosMock.onPost(url).reply(200, { LinkCheckStatus: LINK_CHECK_STATUSES.IN_PROGRESS });
const data = await api.postLinkCheck(courseId);
expect(data.linkCheckStatus).toEqual(LINK_CHECK_STATUSES.IN_PROGRESS);
expect(axiosMock.history.post[0].url).toEqual(url);
});
});
describe('getLinkCheckStatus', () => {
it('should get the status of a scan', async () => {
const { axiosMock } = initializeMocks();
const courseId = 'course-123';
const url = api.getLinkCheckStatusApiUrl(courseId);
axiosMock.onGet(url).reply(200, mockApiResponse);
const data = await api.getLinkCheckStatus(courseId);
expect(data.linkCheckOutput).toEqual(mockApiResponse.LinkCheckOutput);
expect(data.linkCheckStatus).toEqual(mockApiResponse.LinkCheckStatus);
expect(data.linkCheckCreatedAt).toEqual(mockApiResponse.LinkCheckCreatedAt);
expect(axiosMock.history.get[0].url).toEqual(url);
});
});
});

View File

@@ -0,0 +1,26 @@
import { camelCaseObject, getConfig } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { LinkCheckResult } from '../types';
import { LinkCheckStatusTypes } from './constants';
export interface LinkCheckStatusApiResponseBody {
linkCheckStatus: LinkCheckStatusTypes;
linkCheckOutput: LinkCheckResult;
linkCheckCreatedAt: string;
}
const getApiBaseUrl = () => getConfig().STUDIO_BASE_URL;
export const postLinkCheckCourseApiUrl = (courseId) => new URL(`api/contentstore/v0/link_check/${courseId}`, getApiBaseUrl()).href;
export const getLinkCheckStatusApiUrl = (courseId) => new URL(`api/contentstore/v0/link_check_status/${courseId}`, getApiBaseUrl()).href;
export async function postLinkCheck(courseId: string): Promise<{ linkCheckStatus: LinkCheckStatusTypes }> {
const { data } = await getAuthenticatedHttpClient()
.post(postLinkCheckCourseApiUrl(courseId));
return camelCaseObject(data);
}
export async function getLinkCheckStatus(courseId: string): Promise<LinkCheckStatusApiResponseBody> {
const { data } = await getAuthenticatedHttpClient()
.get(getLinkCheckStatusApiUrl(courseId));
return camelCaseObject(data);
}

View File

@@ -0,0 +1,40 @@
export const LAST_EXPORT_COOKIE_NAME = 'lastexport';
export const LINK_CHECK_STATUSES = {
UNINITIATED: 'Uninitiated',
PENDING: 'Pending',
IN_PROGRESS: 'In-Progress',
SUCCEEDED: 'Succeeded',
FAILED: 'Failed',
CANCELED: 'Canceled',
RETRYING: 'Retrying',
};
export enum LinkCheckStatusTypes {
UNINITIATED = 'Uninitiated',
PENDING = 'Pending',
IN_PROGRESS = 'In-Progress',
SUCCEEDED = 'Succeeded',
FAILED = 'Failed',
CANCELED = 'Canceled',
RETRYING = 'Retrying',
}
export const SCAN_STAGES = {
[LINK_CHECK_STATUSES.UNINITIATED]: 0,
[LINK_CHECK_STATUSES.PENDING]: 1,
[LINK_CHECK_STATUSES.IN_PROGRESS]: 1,
[LINK_CHECK_STATUSES.RETRYING]: 1,
[LINK_CHECK_STATUSES.SUCCEEDED]: 2,
[LINK_CHECK_STATUSES.FAILED]: -1,
[LINK_CHECK_STATUSES.CANCELED]: -1,
};
export const LINK_CHECK_IN_PROGRESS_STATUSES = [
LINK_CHECK_STATUSES.PENDING,
LINK_CHECK_STATUSES.IN_PROGRESS,
LINK_CHECK_STATUSES.RETRYING,
];
export const LINK_CHECK_FAILURE_STATUSES = [
LINK_CHECK_STATUSES.FAILED,
LINK_CHECK_STATUSES.CANCELED,
];
export const SUCCESS_DATE_FORMAT = 'MM/DD/yyyy';

View File

@@ -0,0 +1,12 @@
import { RootState } from './slice';
export const getLinkCheckInProgress = (state: RootState) => state.courseOptimizer.linkCheckInProgress;
export const getCurrentStage = (state: RootState) => state.courseOptimizer.currentStage;
export const getDownloadPath = (state: RootState) => state.courseOptimizer.downloadPath;
export const getSuccessDate = (state: RootState) => state.courseOptimizer.successDate;
export const getError = (state: RootState) => state.courseOptimizer.error;
export const getIsErrorModalOpen = (state: RootState) => state.courseOptimizer.isErrorModalOpen;
export const getLoadingStatus = (state: RootState) => state.courseOptimizer.loadingStatus;
export const getSavingStatus = (state: RootState) => state.courseOptimizer.savingStatus;
export const getLinkCheckResult = (state: RootState) => state.courseOptimizer.linkCheckResult;
export const getLastScannedAt = (state: RootState) => state.courseOptimizer.lastScannedAt;

View File

@@ -0,0 +1,111 @@
import { AnyAction, configureStore, ThunkMiddleware } from '@reduxjs/toolkit';
import { ToolkitStore } from '@reduxjs/toolkit/dist/configureStore';
import {
CourseOptimizerState,
reducer,
updateLinkCheckInProgress,
updateLinkCheckResult,
updateLastScannedAt,
updateCurrentStage,
updateDownloadPath,
updateSuccessDate,
updateError,
updateIsErrorModalOpen,
reset,
updateLoadingStatus,
updateSavingStatus,
} from './slice';
describe('courseOptimizer slice', () => {
let store: ToolkitStore<CourseOptimizerState, AnyAction, [ThunkMiddleware<CourseOptimizerState, AnyAction>]>;
beforeEach(() => {
store = configureStore({ reducer });
});
it('should handle initial state', () => {
expect(store.getState()).toEqual({
linkCheckInProgress: null,
linkCheckResult: null,
lastScannedAt: null,
currentStage: null,
error: { msg: null, unitUrl: null },
downloadPath: null,
successDate: null,
isErrorModalOpen: false,
loadingStatus: '',
savingStatus: '',
});
});
it('should handle updateLinkCheckInProgress', () => {
store.dispatch(updateLinkCheckInProgress(true));
expect(store.getState().linkCheckInProgress).toBe(true);
});
it('should handle updateLinkCheckResult', () => {
const result = { valid: true };
store.dispatch(updateLinkCheckResult(result));
expect(store.getState().linkCheckResult).toEqual(result);
});
it('should handle updateLastScannedAt', () => {
const date = '2023-10-01';
store.dispatch(updateLastScannedAt(date));
expect(store.getState().lastScannedAt).toBe(date);
});
it('should handle updateCurrentStage', () => {
store.dispatch(updateCurrentStage(2));
expect(store.getState().currentStage).toBe(2);
});
it('should handle updateDownloadPath', () => {
const path = '/path/to/download';
store.dispatch(updateDownloadPath(path));
expect(store.getState().downloadPath).toBe(path);
});
it('should handle updateSuccessDate', () => {
const date = '2023-10-01';
store.dispatch(updateSuccessDate(date));
expect(store.getState().successDate).toBe(date);
});
it('should handle updateError', () => {
const error = { msg: 'Error message', unitUrl: 'http://example.com' };
store.dispatch(updateError(error));
expect(store.getState().error).toEqual(error);
});
it('should handle updateIsErrorModalOpen', () => {
store.dispatch(updateIsErrorModalOpen(true));
expect(store.getState().isErrorModalOpen).toBe(true);
});
it('should handle reset', () => {
store.dispatch(reset());
expect(store.getState()).toEqual({
linkCheckInProgress: null,
linkCheckResult: null,
lastScannedAt: null,
currentStage: null,
error: { msg: null, unitUrl: null },
downloadPath: null,
successDate: null,
isErrorModalOpen: false,
loadingStatus: '',
savingStatus: '',
});
});
it('should handle updateLoadingStatus', () => {
store.dispatch(updateLoadingStatus({ status: 'loading' }));
expect(store.getState().loadingStatus).toBe('loading');
});
it('should handle updateSavingStatus', () => {
store.dispatch(updateSavingStatus({ status: 'saving' }));
expect(store.getState().savingStatus).toBe('saving');
});
});

View File

@@ -0,0 +1,91 @@
/* eslint-disable no-param-reassign */
import { createSlice } from '@reduxjs/toolkit';
import { LinkCheckResult } from '../types';
export interface CourseOptimizerState {
linkCheckInProgress: boolean | null;
linkCheckResult: LinkCheckResult | null;
lastScannedAt: string | null;
currentStage: number | null;
error: { msg: string | null; unitUrl: string | null };
downloadPath: string | null;
successDate: string | null;
isErrorModalOpen: boolean;
loadingStatus: string;
savingStatus: string;
}
export type RootState = {
[key: string]: any;
} & {
courseOptimizer: CourseOptimizerState;
};
const initialState: CourseOptimizerState = {
linkCheckInProgress: null,
linkCheckResult: null,
lastScannedAt: null,
currentStage: null,
error: { msg: null, unitUrl: null },
downloadPath: null,
successDate: null,
isErrorModalOpen: false,
loadingStatus: '',
savingStatus: '',
};
const slice = createSlice({
name: 'courseOptimizer',
initialState,
reducers: {
updateLinkCheckInProgress: (state, { payload }) => {
state.linkCheckInProgress = payload;
},
updateLinkCheckResult: (state, { payload }) => {
state.linkCheckResult = payload;
},
updateLastScannedAt: (state, { payload }) => {
state.lastScannedAt = payload;
},
updateCurrentStage: (state, { payload }) => {
state.currentStage = payload;
},
updateDownloadPath: (state, { payload }) => {
state.downloadPath = payload;
},
updateSuccessDate: (state, { payload }) => {
state.successDate = payload;
},
updateError: (state, { payload }) => {
state.error = payload;
},
updateIsErrorModalOpen: (state, { payload }) => {
state.isErrorModalOpen = payload;
},
reset: () => initialState,
updateLoadingStatus: (state, { payload }) => {
state.loadingStatus = payload.status;
},
updateSavingStatus: (state, { payload }) => {
state.savingStatus = payload.status;
},
},
});
export const {
updateLinkCheckInProgress,
updateLinkCheckResult,
updateLastScannedAt,
updateCurrentStage,
updateDownloadPath,
updateSuccessDate,
updateError,
updateIsErrorModalOpen,
reset,
updateLoadingStatus,
updateSavingStatus,
} = slice.actions;
export const {
reducer,
} = slice;

View File

@@ -0,0 +1,193 @@
import { startLinkCheck, fetchLinkCheckStatus } from './thunks';
import * as api from './api';
import { LINK_CHECK_STATUSES } from './constants';
import { RequestStatus } from '../../data/constants';
import mockApiResponse from '../mocks/mockApiResponse';
describe('startLinkCheck thunk', () => {
const dispatch = jest.fn();
const getState = jest.fn();
const courseId = 'course-123';
let mockGetStartLinkCheck;
beforeEach(() => {
jest.clearAllMocks();
mockGetStartLinkCheck = jest.spyOn(api, 'postLinkCheck').mockResolvedValue({
linkCheckStatus: LINK_CHECK_STATUSES.IN_PROGRESS,
});
});
describe('successful request', () => {
it('should set link check stage and request statuses to their in-progress states', async () => {
const inProgressStageId = 1;
await startLinkCheck(courseId)(dispatch, getState);
expect(dispatch).toHaveBeenCalledWith({
payload: { status: RequestStatus.PENDING },
type: 'courseOptimizer/updateSavingStatus',
});
expect(dispatch).toHaveBeenCalledWith({
payload: true,
type: 'courseOptimizer/updateLinkCheckInProgress',
});
expect(dispatch).toHaveBeenCalledWith({
payload: { status: RequestStatus.SUCCESSFUL },
type: 'courseOptimizer/updateSavingStatus',
});
expect(dispatch).toHaveBeenCalledWith({
payload: inProgressStageId,
type: 'courseOptimizer/updateCurrentStage',
});
});
});
describe('failed request should set stage and request ', () => {
it('should set request status to failed', async () => {
mockGetStartLinkCheck.mockRejectedValue(new Error('error'));
await startLinkCheck(courseId)(dispatch, getState);
expect(dispatch).toHaveBeenCalledWith({
payload: { status: RequestStatus.FAILED },
type: 'courseOptimizer/updateSavingStatus',
});
expect(dispatch).toHaveBeenCalledWith({
payload: false,
type: 'courseOptimizer/updateLinkCheckInProgress',
});
expect(dispatch).toHaveBeenCalledWith({
payload: -1,
type: 'courseOptimizer/updateCurrentStage',
});
});
});
});
describe('fetchLinkCheckStatus thunk', () => {
const dispatch = jest.fn();
const getState = jest.fn();
const courseId = 'course-123';
beforeEach(() => {
jest.clearAllMocks();
});
describe('successful request', () => {
it('should return scan result', async () => {
jest
.spyOn(api, 'getLinkCheckStatus')
.mockResolvedValue({
linkCheckStatus: mockApiResponse.LinkCheckStatus,
linkCheckOutput: mockApiResponse.LinkCheckOutput,
linkCheckCreatedAt: mockApiResponse.LinkCheckCreatedAt,
});
await fetchLinkCheckStatus(courseId)(dispatch, getState);
expect(dispatch).toHaveBeenCalledWith({
payload: false,
type: 'courseOptimizer/updateLinkCheckInProgress',
});
expect(dispatch).toHaveBeenCalledWith({
payload: 2,
type: 'courseOptimizer/updateCurrentStage',
});
expect(dispatch).toHaveBeenCalledWith({
payload: mockApiResponse.LinkCheckOutput,
type: 'courseOptimizer/updateLinkCheckResult',
});
expect(dispatch).toHaveBeenCalledWith({
payload: { status: RequestStatus.SUCCESSFUL },
type: 'courseOptimizer/updateLoadingStatus',
});
});
it('with link check in progress should set current stage to 1', async () => {
jest
.spyOn(api, 'getLinkCheckStatus')
.mockResolvedValue({
linkCheckStatus: LINK_CHECK_STATUSES.IN_PROGRESS,
});
await fetchLinkCheckStatus(courseId)(dispatch, getState);
expect(dispatch).toHaveBeenCalledWith({
payload: 1,
type: 'courseOptimizer/updateCurrentStage',
});
});
});
describe('failed request', () => {
it('should set request status to failed', async () => {
jest
.spyOn(api, 'getLinkCheckStatus')
.mockRejectedValue(new Error('error'));
await fetchLinkCheckStatus(courseId)(dispatch, getState);
expect(dispatch).toHaveBeenCalledWith({
payload: { status: RequestStatus.FAILED },
type: 'courseOptimizer/updateLoadingStatus',
});
});
});
describe('unauthorized request', () => {
it('should set request status to denied', async () => {
jest.spyOn(api, 'getLinkCheckStatus').mockRejectedValue({ response: { status: 403 } });
await fetchLinkCheckStatus(courseId)(dispatch, getState);
expect(dispatch).toHaveBeenCalledWith({
payload: { status: RequestStatus.DENIED },
type: 'courseOptimizer/updateLoadingStatus',
});
});
});
describe('failed scan', () => {
it('should set error message', async () => {
jest
.spyOn(api, 'getLinkCheckStatus')
.mockResolvedValue({
linkCheckStatus: LINK_CHECK_STATUSES.FAILED,
linkCheckOutput: mockApiResponse.LinkCheckOutput,
linkCheckCreatedAt: mockApiResponse.LinkCheckCreatedAt,
});
await fetchLinkCheckStatus(courseId)(dispatch, getState);
expect(dispatch).toHaveBeenCalledWith({
payload: true,
type: 'courseOptimizer/updateIsErrorModalOpen',
});
expect(dispatch).toHaveBeenCalledWith({
payload: { msg: 'Link Check Failed' },
type: 'courseOptimizer/updateError',
});
expect(dispatch).toHaveBeenCalledWith({
payload: { status: RequestStatus.SUCCESSFUL },
type: 'courseOptimizer/updateLoadingStatus',
});
expect(dispatch).toHaveBeenCalledWith({
payload: -1,
type: 'courseOptimizer/updateCurrentStage',
});
expect(dispatch).not.toHaveBeenCalledWith({
payload: expect.anything(),
type: 'courseOptimizer/updateLinkCheckResult',
});
});
});
});

View File

@@ -0,0 +1,81 @@
import { RequestStatus } from '../../data/constants';
import {
LINK_CHECK_FAILURE_STATUSES,
LINK_CHECK_IN_PROGRESS_STATUSES,
LINK_CHECK_STATUSES,
SCAN_STAGES,
} from './constants';
import { postLinkCheck, getLinkCheckStatus } from './api';
import {
updateLinkCheckInProgress,
updateLinkCheckResult,
updateCurrentStage,
updateError,
updateIsErrorModalOpen,
updateLoadingStatus,
updateSavingStatus,
updateLastScannedAt,
} from './slice';
export function startLinkCheck(courseId: string) {
return async (dispatch) => {
dispatch(updateSavingStatus({ status: RequestStatus.PENDING }));
dispatch(updateLinkCheckInProgress(true));
dispatch(updateCurrentStage(SCAN_STAGES[LINK_CHECK_STATUSES.PENDING]));
try {
await postLinkCheck(courseId);
await dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL }));
return true;
} catch (error) {
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
dispatch(updateLinkCheckInProgress(false));
dispatch(updateCurrentStage(SCAN_STAGES[LINK_CHECK_STATUSES.CANCELED]));
return false;
}
};
}
export function fetchLinkCheckStatus(courseId) {
return async (dispatch) => {
dispatch(updateLoadingStatus({ status: RequestStatus.IN_PROGRESS }));
try {
const { linkCheckStatus, linkCheckOutput, linkCheckCreatedAt } = await getLinkCheckStatus(
courseId,
);
if (LINK_CHECK_IN_PROGRESS_STATUSES.includes(linkCheckStatus)) {
dispatch(updateLinkCheckInProgress(true));
} else {
dispatch(updateLinkCheckInProgress(false));
}
dispatch(updateCurrentStage(SCAN_STAGES[linkCheckStatus]));
if (
linkCheckStatus === undefined
|| linkCheckStatus === null
|| LINK_CHECK_FAILURE_STATUSES.includes(linkCheckStatus)
) {
dispatch(updateError({ msg: 'Link Check Failed' }));
dispatch(updateIsErrorModalOpen(true));
} else if (linkCheckOutput) {
dispatch(updateLinkCheckResult(linkCheckOutput));
dispatch(updateLastScannedAt(linkCheckCreatedAt));
}
dispatch(updateLoadingStatus({ status: RequestStatus.SUCCESSFUL }));
return true;
} catch (error: any) {
if (error?.response && error?.response.status === 403) {
dispatch(updateLoadingStatus({ status: RequestStatus.DENIED }));
} else {
dispatch(
updateLoadingStatus({ status: RequestStatus.FAILED }),
);
}
return false;
}
};
}

View File

@@ -0,0 +1,71 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
pageTitle: {
id: 'course-authoring.course-optimizer.page.title',
defaultMessage: '{headingTitle} | {courseName} | {siteName}',
},
headingTitle: {
id: 'course-authoring.course-optimizer.heading.title',
defaultMessage: 'Course Optimizer',
},
headingSubtitle: {
id: 'course-authoring.course-optimizer.heading.subtitle',
defaultMessage: 'Tools',
},
description1: {
id: 'course-authoring.course-optimizer.description1',
defaultMessage: `This tool will scan your the published version of your course for broken links.
Unpublished changes will not be included in the scan.
Note that this process will take more time for larger courses.
To update the scan after you have published new changes to your course,
click the "Start Scanning" button again.
`,
},
description2: {
id: 'course-authoring.course-optimizer.description2',
defaultMessage: 'Broken links are links pointing to external websites, images, or videos that do not exist or are no longer available. These links can cause issues for learners when they try to access the content.',
},
card1Title: {
id: 'course-authoring.course-optimizer.card1.title',
defaultMessage: 'Scan my course for broken links',
},
card2Title: {
id: 'course-authoring.course-optimizer.card2.title',
defaultMessage: 'Scan my course for broken links',
},
buttonTitle: {
id: 'course-authoring.course-optimizer.button.title',
defaultMessage: 'Start Scanning',
},
preparingStepTitle: {
id: 'course-authoring.course-optimizer.peparing-step.title',
defaultMessage: 'Preparing',
},
preparingStepDescription: {
id: 'course-authoring.course-optimizer.peparing-step.description',
defaultMessage: 'Preparing to start the scan',
},
scanningStepTitle: {
id: 'course-authoring.course-optimizer.scanning-step.title',
defaultMessage: 'Scanning',
},
scanningStepDescription: {
id: 'course-authoring.course-optimizer.scanning-step.description',
defaultMessage: 'Scanning for broken links in your course (You can now leave this page safely, but avoid making drastic changes to content until the scan is complete)',
},
successStepTitle: {
id: 'course-authoring.course-optimizer.success-step.title',
defaultMessage: 'Success',
},
successStepDescription: {
id: 'course-authoring.course-optimizer.success-step.description',
defaultMessage: 'Your Scan is complete. You can view the list of results below.',
},
lastScannedOn: {
id: 'course-authoring.course-optimizer.last-scanned-on',
defaultMessage: 'Last scanned on',
},
});
export default messages;

View File

@@ -0,0 +1,106 @@
const mockApiResponse = {
LinkCheckStatus: 'Succeeded',
LinkCheckCreatedAt: '2024-12-14T00:26:50.838350Z',
LinkCheckOutput: {
sections: [
{
id: 'section-1',
displayName: 'Introduction to Programming',
subsections: [
{
id: 'subsection-1-1',
displayName: 'Getting Started',
units: [
{
id: 'unit-1-1-1',
displayName: 'Welcome Video',
blocks: [
{
id: 'block-1-1-1-1',
url: 'https://example.com/welcome-video',
brokenLinks: ['https://example.com/broken-link-algo'],
lockedLinks: ['https://example.com/locked-link-algo'],
},
{
id: 'block-1-1-1-2',
url: 'https://example.com/intro-guide',
brokenLinks: ['https://example.com/broken-link-algo'],
lockedLinks: ['https://example.com/locked-link-algo'],
},
],
},
{
id: 'unit-1-1-2',
displayName: 'Course Overview',
blocks: [
{
id: 'block-1-1-2-1',
url: 'https://example.com/course-overview',
brokenLinks: ['https://example.com/broken-link-algo'],
lockedLinks: ['https://example.com/locked-link-algo'],
},
],
},
],
},
{
id: 'subsection-1-2',
displayName: 'Basic Concepts',
units: [
{
id: 'unit-1-2-1',
displayName: 'Variables and Data Types',
blocks: [
{
id: 'block-1-2-1-1',
url: 'https://example.com/variables',
brokenLinks: ['https://example.com/broken-link-algo'],
lockedLinks: ['https://example.com/locked-link-algo'],
},
{
id: 'block-1-2-1-2',
url: 'https://example.com/broken-link',
brokenLinks: ['https://example.com/broken-link'],
lockedLinks: ['https://example.com/locked-link-algo'],
},
],
},
],
},
],
},
{
id: 'section-2',
displayName: 'Advanced Topics',
subsections: [
{
id: 'subsection-2-1',
displayName: 'Algorithms and Data Structures',
units: [
{
id: 'unit-2-1-1',
displayName: 'Sorting Algorithms',
blocks: [
{
id: 'block-2-1-1-1',
url: 'https://example.com/sorting-algorithms',
brokenLinks: ['https://example.com/broken-link-algo'],
lockedLinks: ['https://example.com/locked-link-algo'],
},
{
id: 'block-2-1-1-2',
url: 'https://example.com/broken-link-algo',
brokenLinks: ['https://example.com/broken-link-algo'],
lockedLinks: ['https://example.com/locked-link-algo'],
},
],
},
],
},
],
},
],
},
};
export default mockApiResponse;

View File

@@ -0,0 +1,117 @@
import { Icon, Table } from '@openedx/paragon';
import { OpenInNew, Lock, LinkOff } from '@openedx/paragon/icons';
import { useIntl } from '@edx/frontend-platform/i18n';
import { FC } from 'react';
import { Unit } from '../types';
import messages from './messages';
import LockedInfoIcon from './LockedInfoIcon';
const BrokenLinkHref: FC<{ href: string }> = ({ href }) => (
<div className="broken-link-container">
<a href={href} target="_blank" className="broken-link" rel="noreferrer">
{href}
</a>
</div>
);
const GoToBlock: FC<{ block: { url: string } }> = ({ block }) => (
<span style={{ display: 'flex', gap: '.5rem' }}>
<Icon src={OpenInNew} />
<a href={block.url} target="_blank" rel="noreferrer">
Go to Block
</a>
</span>
);
interface BrokenLinkTableProps {
unit: Unit;
showLockedLinks: boolean;
}
type TableData = {
blockLink: JSX.Element;
brokenLink: JSX.Element;
status: JSX.Element;
}[];
const BrokenLinkTable: FC<BrokenLinkTableProps> = ({
unit,
showLockedLinks,
}) => {
const intl = useIntl();
return (
<>
<p className="unit-header">{unit.displayName}</p>
<Table
data={unit.blocks.reduce(
(
acc: TableData,
block,
) => {
const blockBrokenLinks = block.brokenLinks.map((link) => ({
blockLink: <GoToBlock block={block} />,
blockDisplayName: block.displayName || '',
brokenLink: <BrokenLinkHref href={link} />,
status: (
<span className="link-status-text">
<Icon src={LinkOff} className="broken-link-icon" />
<span>
{intl.formatMessage(messages.brokenLinkStatus)}
</span>
</span>
),
}));
acc.push(...blockBrokenLinks);
if (!showLockedLinks) {
return acc;
}
const blockLockedLinks = block.lockedLinks.map((link) => ({
blockLink: <GoToBlock block={block} />,
blockDisplayName: block.displayName || '',
brokenLink: <BrokenLinkHref href={link} />,
status: (
<span className="link-status-text">
<Icon src={Lock} className="lock-icon" />
{intl.formatMessage(messages.lockedLinkStatus)}{' '}
<LockedInfoIcon />
</span>
),
}));
acc.push(...blockLockedLinks);
return acc;
},
[],
)}
columns={[
{
key: 'blockDisplayName',
columnSortable: false,
width: 'col-3',
hideHeader: true,
},
{
key: 'blockLink',
columnSortable: false,
width: 'col-3',
hideHeader: true,
},
{
key: 'brokenLink',
columnSortable: false,
width: 'col-6',
hideHeader: true,
},
{
key: 'status',
columnSortable: false,
width: 'col-6',
hideHeader: true,
},
]}
/>
</>
);
};
export default BrokenLinkTable;

View File

@@ -0,0 +1,30 @@
import {
Icon,
OverlayTrigger,
Tooltip,
} from '@openedx/paragon';
import {
Question,
} from '@openedx/paragon/icons';
import { useIntl } from '@edx/frontend-platform/i18n';
import messages from './messages';
const LockedInfoIcon = () => {
const intl = useIntl();
return (
<OverlayTrigger
key="top"
placement="top"
overlay={(
<Tooltip variant="light" id="tooltip-top">
{intl.formatMessage(messages.lockedInfoTooltip)}
</Tooltip>
)}
>
<Icon src={Question} />
</OverlayTrigger>
);
};
export default LockedInfoIcon;

View File

@@ -0,0 +1,110 @@
.scan-results {
thead {
display: none;
}
.red-italics {
color: $brand-500;
margin-left: 2rem;
font-weight: 400;
font-size: 80%;
font-style: italic;
}
.yellow-italics {
color: $warning-800;;
margin-left: 2rem;
font-weight: 400;
font-size: 80%;
font-style: italic;
}
.section {
&.is-open {
&:not(:first-child) {
margin-top: 1rem;
}
margin-bottom: 1rem;
}
}
.open-arrow {
transform: translate(-10px, 5px);
display: inline-block;
}
/* Section Header */
.subsection-header {
font-size: 16px; /* Slightly smaller */
font-weight: 600; /* Reduced boldness */
background-color: $dark-100;
padding: 10px;
margin-bottom: 10px;
}
/* Subsection Header */
.unit-header {
margin-left: .5rem;
margin-top: 10px;
font-size: 14px;
font-weight: 700;
margin-bottom: 5px;
color: $primary-500;
}
/* Block Links */
.broken-link-list li {
margin-bottom: 8px; /* Add breathing room */
}
.broken-link-list a {
text-decoration: none;
margin-left: 2rem;
}
/* Broken Links Highlight */
.broken-links-count {
color: red;
font-weight: bold;
}
.unit {
padding: 0 3rem;
}
.broken-link {
color: $brand-500;
text-decoration: none;
}
.broken-link-container {
max-width: 18rem;
text-wrap: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.locked-links-checkbox {
margin-top: .45rem;
}
.locked-links-checkbox-wrapper {
display: flex;
gap: 1rem;
}
.link-status-text {
display: flex;
align-items: center;
gap: .5rem;
}
.broken-link-icon {
color: $brand-500;
}
.lock-icon {
color: $warning-300;
}
}

View File

@@ -0,0 +1,89 @@
import { useState, useMemo, FC } from 'react';
import {
Card,
CheckBox,
} from '@openedx/paragon';
import { useIntl } from '@edx/frontend-platform/i18n';
import messages from './messages';
import SectionCollapsible from './SectionCollapsible';
import BrokenLinkTable from './BrokenLinkTable';
import LockedInfoIcon from './LockedInfoIcon';
import { LinkCheckResult } from '../types';
import { countBrokenLinks } from '../utils';
const InfoCard: FC<{ text: string }> = ({ text }) => (
<Card className="mt-4">
<h3
className="subsection-header"
style={{ margin: '1rem', textAlign: 'center' }}
>
{text}
</h3>
</Card>
);
interface Props {
data: LinkCheckResult | null;
}
const ScanResults: FC<Props> = ({ data }) => {
const intl = useIntl();
const [showLockedLinks, setShowLockedLinks] = useState(true);
const { brokenLinksCounts, lockedLinksCounts } = useMemo(() => countBrokenLinks(data), [data?.sections]);
if (!data?.sections) {
return <InfoCard text={intl.formatMessage(messages.noBrokenLinksCard)} />;
}
const { sections } = data;
return (
<div className="scan-results">
<div className="border-bottom border-light-400 mb-3">
<header className="sub-header-content">
<h2 className="sub-header-content-title">{intl.formatMessage(messages.scanHeader)}</h2>
<span className="locked-links-checkbox-wrapper">
<CheckBox
className="locked-links-checkbox"
type="checkbox"
checked={showLockedLinks}
onClick={() => {
setShowLockedLinks(!showLockedLinks);
}}
label={intl.formatMessage(messages.lockedCheckboxLabel)}
/>
<LockedInfoIcon />
</span>
</header>
</div>
{sections?.map((section, index) => (
<SectionCollapsible
key={section.id}
title={section.displayName}
redItalics={intl.formatMessage(messages.brokenLinksNumber, { count: brokenLinksCounts[index] })}
yellowItalics={!showLockedLinks ? '' : intl.formatMessage(messages.lockedLinksNumber, { count: lockedLinksCounts[index] })}
>
{section.subsections.map((subsection) => (
<>
<h2
className="subsection-header"
style={{ marginBottom: '2rem' }}
>
{subsection.displayName}
</h2>
{subsection.units.map((unit) => (
<div className="unit">
<BrokenLinkTable unit={unit} showLockedLinks={showLockedLinks} />
</div>
))}
</>
))}
</SectionCollapsible>
))}
</div>
);
};
export default ScanResults;

View File

@@ -0,0 +1,53 @@
import { useState, FC } from 'react';
import {
Collapsible,
Icon,
} from '@openedx/paragon';
import {
ArrowRight,
ArrowDropDown,
} from '@openedx/paragon/icons';
interface Props {
title: string;
children: React.ReactNode;
redItalics?: string;
yellowItalics?: string;
className?: string;
}
const SectionCollapsible: FC<Props> = ({
title, children, redItalics = '', yellowItalics = '', className = '',
}) => {
const [isOpen, setIsOpen] = useState(false);
const styling = 'card-lg';
const collapsibleTitle = (
<div className={className}>
<Icon src={isOpen ? ArrowDropDown : ArrowRight} className="open-arrow" />
<strong>{title}</strong>
<span className="red-italics">{redItalics}</span>
<span className="yellow-italics">{yellowItalics}</span>
</div>
);
return (
<div className={`section ${isOpen ? 'is-open' : ''}`}>
<Collapsible
styling={styling}
title={(
<p>
<strong>{collapsibleTitle}</strong>
</p>
)}
iconWhenClosed=""
iconWhenOpen=""
open={isOpen}
onToggle={() => setIsOpen(!isOpen)}
>
<Collapsible.Body>{children}</Collapsible.Body>
</Collapsible>
</div>
);
};
export default SectionCollapsible;

View File

@@ -0,0 +1,3 @@
import ScanResults from './ScanResults';
export default ScanResults;

View File

@@ -0,0 +1,46 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
pageTitle: {
id: 'course-authoring.course-optimizer.page.title',
defaultMessage: '{headingTitle} | {courseName} | {siteName}',
},
noDataCard: {
id: 'course-authoring.course-optimizer.noDataCard',
defaultMessage: 'No Scan data available',
},
noBrokenLinksCard: {
id: 'course-authoring.course-optimizer.emptyResultsCard',
defaultMessage: 'No broken links found',
},
scanHeader: {
id: 'course-authoring.course-optimizer.scanHeader',
defaultMessage: 'Broken Links Scan',
},
lockedCheckboxLabel: {
id: 'course-authoring.course-optimizer.lockedCheckboxLabel',
defaultMessage: 'Show Locked Course Files',
},
brokenLinksNumber: {
id: 'course-authoring.course-optimizer.brokenLinksNumber',
defaultMessage: '{count} broken links',
},
lockedLinksNumber: {
id: 'course-authoring.course-optimizer.lockedLinksNumber',
defaultMessage: '{count} locked links',
},
lockedInfoTooltip: {
id: 'course-authoring.course-optimizer.lockedInfoTooltip',
defaultMessage: 'These course files are "locked", so we cannot verify if the link can access the file.',
},
brokenLinkStatus: {
id: 'course-authoring.course-optimizer.brokenLinkStatus',
defaultMessage: 'Status: Broken',
},
lockedLinkStatus: {
id: 'course-authoring.course-optimizer.lockedLinkStatus',
defaultMessage: 'Status: Locked',
},
});
export default messages;

View File

@@ -0,0 +1,27 @@
export interface Unit {
id: string;
displayName: string;
blocks: {
id: string;
displayName?: string;
url: string;
brokenLinks: string[];
lockedLinks: string[];
}[];
}
export interface SubSection {
id: string;
displayName: string;
units: Unit[];
}
export interface Section {
id: string;
displayName: string;
subsections: SubSection[];
}
export interface LinkCheckResult {
sections: Section[];
}

View File

@@ -0,0 +1,44 @@
import mockApiResponse from './mocks/mockApiResponse';
import { countBrokenLinks } from './utils';
describe('countBrokenLinks', () => {
it('should return the count of broken links', () => {
const data = mockApiResponse.LinkCheckOutput;
expect(countBrokenLinks(data)).toStrictEqual({ brokenLinksCounts: [5, 2], lockedLinksCounts: [5, 2] });
});
it('should return 0 if there are no broken links', () => {
const data = {
sections: [
{
subsections: [
{
units: [
{
blocks: [
{
brokenLinks: [],
},
],
},
],
},
],
},
],
};
expect(countBrokenLinks(data)).toStrictEqual({ brokenLinksCounts: [0], lockedLinksCounts: [0] });
});
it('should return [] if there is no data', () => {
const data = {};
expect(countBrokenLinks(data)).toStrictEqual({ brokenLinksCounts: [], lockedLinksCounts: [] });
});
it('should return [] if there are no sections', () => {
const data = {
sections: [],
};
expect(countBrokenLinks(data)).toStrictEqual({ brokenLinksCounts: [], lockedLinksCounts: [] });
});
});

View File

@@ -0,0 +1,26 @@
/* eslint-disable import/prefer-default-export */
import { LinkCheckResult } from './types';
export const countBrokenLinks = (data: LinkCheckResult | null):
{ brokenLinksCounts: number[], lockedLinksCounts: number[] } => {
if (!data?.sections) {
return { brokenLinksCounts: [], lockedLinksCounts: [] };
}
const brokenLinksCounts: number[] = [];
const lockedLinksCounts: number[] = [];
data.sections.forEach((section) => {
let brokenLinks = 0;
let lockedLinks = 0;
section.subsections.forEach((subsection) => {
subsection.units.forEach((unit) => {
unit.blocks.forEach((block) => {
brokenLinks += block.brokenLinks?.length || 0;
lockedLinks += block.lockedLinks?.length || 0;
});
});
});
brokenLinksCounts.push(brokenLinks);
lockedLinksCounts.push(lockedLinks);
});
return { brokenLinksCounts, lockedLinksCounts };
};

View File

@@ -18,6 +18,7 @@ import { reducer as CourseUpdatesReducer } from './course-updates/data/slice';
import { reducer as processingNotificationReducer } from './generic/processing-notification/data/slice';
import { reducer as helpUrlsReducer } from './help-urls/data/slice';
import { reducer as courseExportReducer } from './export-page/data/slice';
import { reducer as courseOptimizerReducer } from './optimizer-page/data/slice';
import { reducer as genericReducer } from './generic/data/slice';
import { reducer as courseImportReducer } from './import-page/data/slice';
import { reducer as videosReducer } from './files-and-videos/videos-page/data/slice';
@@ -47,6 +48,7 @@ export default function initializeStore(preloadedState = undefined) {
processingNotification: processingNotificationReducer,
helpUrls: helpUrlsReducer,
courseExport: courseExportReducer,
courseOptimizer: courseOptimizerReducer,
generic: genericReducer,
courseImport: courseImportReducer,
videos: videosReducer,