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:
@@ -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>}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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) {
|
||||
|
||||
193
src/optimizer-page/CourseOptimizerPage.test.js
Normal file
193
src/optimizer-page/CourseOptimizerPage.test.js
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
176
src/optimizer-page/CourseOptimizerPage.tsx
Normal file
176
src/optimizer-page/CourseOptimizerPage.tsx
Normal 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;
|
||||
34
src/optimizer-page/data/api.test.js
Normal file
34
src/optimizer-page/data/api.test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
26
src/optimizer-page/data/api.ts
Normal file
26
src/optimizer-page/data/api.ts
Normal 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);
|
||||
}
|
||||
40
src/optimizer-page/data/constants.ts
Normal file
40
src/optimizer-page/data/constants.ts
Normal 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';
|
||||
12
src/optimizer-page/data/selectors.ts
Normal file
12
src/optimizer-page/data/selectors.ts
Normal 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;
|
||||
111
src/optimizer-page/data/slice.test.ts
Normal file
111
src/optimizer-page/data/slice.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
91
src/optimizer-page/data/slice.ts
Normal file
91
src/optimizer-page/data/slice.ts
Normal 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;
|
||||
193
src/optimizer-page/data/thunks.test.js
Normal file
193
src/optimizer-page/data/thunks.test.js
Normal 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',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
81
src/optimizer-page/data/thunks.ts
Normal file
81
src/optimizer-page/data/thunks.ts
Normal 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;
|
||||
}
|
||||
};
|
||||
}
|
||||
71
src/optimizer-page/messages.js
Normal file
71
src/optimizer-page/messages.js
Normal 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;
|
||||
106
src/optimizer-page/mocks/mockApiResponse.js
Normal file
106
src/optimizer-page/mocks/mockApiResponse.js
Normal 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;
|
||||
117
src/optimizer-page/scan-results/BrokenLinkTable.tsx
Normal file
117
src/optimizer-page/scan-results/BrokenLinkTable.tsx
Normal 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;
|
||||
30
src/optimizer-page/scan-results/LockedInfoIcon.jsx
Normal file
30
src/optimizer-page/scan-results/LockedInfoIcon.jsx
Normal 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;
|
||||
110
src/optimizer-page/scan-results/ScanResults.scss
Normal file
110
src/optimizer-page/scan-results/ScanResults.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
89
src/optimizer-page/scan-results/ScanResults.tsx
Normal file
89
src/optimizer-page/scan-results/ScanResults.tsx
Normal 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;
|
||||
53
src/optimizer-page/scan-results/SectionCollapsible.tsx
Normal file
53
src/optimizer-page/scan-results/SectionCollapsible.tsx
Normal 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;
|
||||
3
src/optimizer-page/scan-results/index.js
Normal file
3
src/optimizer-page/scan-results/index.js
Normal file
@@ -0,0 +1,3 @@
|
||||
import ScanResults from './ScanResults';
|
||||
|
||||
export default ScanResults;
|
||||
46
src/optimizer-page/scan-results/messages.js
Normal file
46
src/optimizer-page/scan-results/messages.js
Normal 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;
|
||||
27
src/optimizer-page/types.ts
Normal file
27
src/optimizer-page/types.ts
Normal 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[];
|
||||
}
|
||||
44
src/optimizer-page/utils.test.js
Normal file
44
src/optimizer-page/utils.test.js
Normal 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: [] });
|
||||
});
|
||||
});
|
||||
26
src/optimizer-page/utils.ts
Normal file
26
src/optimizer-page/utils.ts
Normal 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 };
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user