+
+
+ {intl.formatMessage(messages.scanHeader)}
+
+ {
+ setShowLockedLinks(!showLockedLinks);
+ }}
+ label={intl.formatMessage(messages.lockedCheckboxLabel)}
+ />
+
+
+
+
+
+ {sections?.map((section, index) => (
+
+ {section.subsections.map((subsection) => (
+ <>
+
+ {subsection.displayName}
+
+ {subsection.units.map((unit) => (
+
+
+
+ ))}
+ >
+ ))}
+
+ ))}
+
+ );
+};
+
+export default ScanResults;
diff --git a/src/optimizer-page/scan-results/index.js b/src/optimizer-page/scan-results/index.js
new file mode 100644
index 0000000000..ab1d4b80ba
--- /dev/null
+++ b/src/optimizer-page/scan-results/index.js
@@ -0,0 +1,3 @@
+import ScanResults from './ScanResults';
+
+export default ScanResults;
diff --git a/src/optimizer-page/scan-results/messages.js b/src/optimizer-page/scan-results/messages.js
new file mode 100644
index 0000000000..9e29a83cf2
--- /dev/null
+++ b/src/optimizer-page/scan-results/messages.js
@@ -0,0 +1,42 @@
+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',
+ },
+ lockedInfoTooltip: {
+ id: 'course-authoring.course-optimizer.lockedInfoTooltip',
+ defaultMessage: 'These course files are "locked", so we cannot test whether they work or not.',
+ },
+ brokenLinkStatus: {
+ id: 'course-authoring.course-optimizer.brokenLinkStatus',
+ defaultMessage: 'Status: Broken',
+ },
+ lockedLinkStatus: {
+ id: 'course-authoring.course-optimizer.lockedLinkStatus',
+ defaultMessage: 'Status: Locked',
+ },
+});
+
+export default messages;
diff --git a/src/optimizer-page/types.ts b/src/optimizer-page/types.ts
new file mode 100644
index 0000000000..e5034e889c
--- /dev/null
+++ b/src/optimizer-page/types.ts
@@ -0,0 +1,26 @@
+export interface Unit {
+ id: string;
+ displayName: string;
+ blocks: {
+ id: 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[];
+}
diff --git a/src/optimizer-page/utils.test.js b/src/optimizer-page/utils.test.js
new file mode 100644
index 0000000000..07983888b9
--- /dev/null
+++ b/src/optimizer-page/utils.test.js
@@ -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([5, 2]);
+ });
+
+ it('should return 0 if there are no broken links', () => {
+ const data = {
+ sections: [
+ {
+ subsections: [
+ {
+ units: [
+ {
+ blocks: [
+ {
+ brokenLinks: [],
+ },
+ ],
+ },
+ ],
+ },
+ ],
+ },
+ ],
+ };
+ expect(countBrokenLinks(data)).toStrictEqual([0]);
+ });
+
+ it('should return [] if there is no data', () => {
+ const data = {};
+ expect(countBrokenLinks(data)).toStrictEqual([]);
+ });
+
+ it('should return [] if there are no sections', () => {
+ const data = {
+ sections: [],
+ };
+ expect(countBrokenLinks(data)).toStrictEqual([]);
+ });
+});
diff --git a/src/optimizer-page/utils.ts b/src/optimizer-page/utils.ts
new file mode 100644
index 0000000000..dd76763761
--- /dev/null
+++ b/src/optimizer-page/utils.ts
@@ -0,0 +1,21 @@
+/* eslint-disable import/prefer-default-export */
+import { LinkCheckResult } from './types';
+
+export const countBrokenLinks = (data: LinkCheckResult | null): number[] => {
+ if (!data?.sections) {
+ return [];
+ }
+ const counts: number[] = [];
+ data.sections.forEach((section) => {
+ let count = 0;
+ section.subsections.forEach((subsection) => {
+ subsection.units.forEach((unit) => {
+ unit.blocks.forEach((block) => {
+ count += block.brokenLinks.length;
+ });
+ });
+ });
+ counts.push(count);
+ });
+ return counts;
+};
diff --git a/src/store.js b/src/store.js
index bf761aadf7..e979d8591d 100644
--- a/src/store.js
+++ b/src/store.js
@@ -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,