diff --git a/src/optimizer-page/CourseOptimizerPage.tsx b/src/optimizer-page/CourseOptimizerPage.tsx
index 7fad8a7f7..97bc1977b 100644
--- a/src/optimizer-page/CourseOptimizerPage.tsx
+++ b/src/optimizer-page/CourseOptimizerPage.tsx
@@ -132,8 +132,7 @@ const CourseOptimizerPage: FC<{ courseId: string }> = ({ courseId }) => {
title={intl.formatMessage(messages.headingTitle)}
subtitle={intl.formatMessage(messages.headingSubtitle)}
/>
-
{intl.formatMessage(messages.description1)}
- {intl.formatMessage(messages.description2)}
+ {intl.formatMessage(messages.description)}
= ({ courseId }) => {
/>
{isShowExportButton && (
+ {lastScannedAt && `${intl.formatMessage(messages.lastScannedOn)} ${intl.formatDate(lastScannedAt, { year: 'numeric', month: 'long', day: 'numeric' })}`}
)}
diff --git a/src/optimizer-page/messages.js b/src/optimizer-page/messages.js
index 6bd588a60..7b3103285 100644
--- a/src/optimizer-page/messages.js
+++ b/src/optimizer-page/messages.js
@@ -7,24 +7,15 @@ const messages = defineMessages({
},
headingTitle: {
id: 'course-authoring.course-optimizer.heading.title',
- defaultMessage: 'Course Optimizer',
+ 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 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.',
+ description: {
+ id: 'course-authoring.course-optimizer.description',
+ defaultMessage: 'This tool will scan your course for broken links, and any links that point to pages in your previous course run. Unpublished changes will not be included in the scan. Note that this process will take more time for larger courses.',
},
card1Title: {
id: 'course-authoring.course-optimizer.card1.title',
@@ -36,7 +27,7 @@ const messages = defineMessages({
},
buttonTitle: {
id: 'course-authoring.course-optimizer.button.title',
- defaultMessage: 'Start Scanning',
+ defaultMessage: 'Start scanning',
},
preparingStepTitle: {
id: 'course-authoring.course-optimizer.peparing-step.title',
diff --git a/src/optimizer-page/scan-results/CustomIcon.jsx b/src/optimizer-page/scan-results/CustomIcon.jsx
new file mode 100644
index 000000000..83e82b72a
--- /dev/null
+++ b/src/optimizer-page/scan-results/CustomIcon.jsx
@@ -0,0 +1,46 @@
+import PropTypes from 'prop-types';
+import {
+ Icon,
+ OverlayTrigger,
+ Tooltip,
+} from '@openedx/paragon';
+import { useIntl } from '@edx/frontend-platform/i18n';
+
+const CustomIcon = ({
+ icon,
+ message1,
+ message2,
+ placement = 'top',
+}) => {
+ const intl = useIntl();
+
+ return (
+
+ {intl.formatMessage(message1)}
+ {message1 &&
}
+ {intl.formatMessage(message2)}
+
+ )}
+ >
+
+
+ );
+};
+
+const messagePropsType = {
+ id: PropTypes.string.isRequired,
+ defaultMessage: PropTypes.string.isRequired,
+};
+
+CustomIcon.propTypes = {
+ icon: PropTypes.elementType.isRequired,
+ message1: PropTypes.shape(messagePropsType).isRequired,
+ message2: PropTypes.shape(messagePropsType).isRequired,
+ placement: PropTypes.string,
+};
+
+export default CustomIcon;
diff --git a/src/optimizer-page/scan-results/LockedInfoIcon.jsx b/src/optimizer-page/scan-results/LockedInfoIcon.jsx
deleted file mode 100644
index 788dcb130..000000000
--- a/src/optimizer-page/scan-results/LockedInfoIcon.jsx
+++ /dev/null
@@ -1,30 +0,0 @@
-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 (
-
- {intl.formatMessage(messages.lockedInfoTooltip)}
-
- )}
- >
-
-
- );
-};
-
-export default LockedInfoIcon;
diff --git a/src/optimizer-page/scan-results/ScanResults.tsx b/src/optimizer-page/scan-results/ScanResults.tsx
index c0c32b98c..7ca463cf5 100644
--- a/src/optimizer-page/scan-results/ScanResults.tsx
+++ b/src/optimizer-page/scan-results/ScanResults.tsx
@@ -1,15 +1,21 @@
import { useState, useMemo, FC } from 'react';
import {
Card,
- CheckBox,
+ Chip,
+ Button,
+ useCheckboxSetValues,
} from '@openedx/paragon';
+import {
+ ArrowDropDown,
+ CloseSmall,
+} from '@openedx/paragon/icons';
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';
+import FilterModal from './filterModal';
const InfoCard: FC<{ text: string }> = ({ text }) => (
@@ -28,7 +34,14 @@ interface Props {
const ScanResults: FC = ({ data }) => {
const intl = useIntl();
- const [showLockedLinks, setShowLockedLinks] = useState(true);
+ const [isModalOpen, setModalOpen] = useState(false);
+ const initialFilters = {
+ brokenLinks: false,
+ lockedLinks: false,
+ externalForbiddenLinks: false,
+ };
+ const [filters, setFilters] = useState(initialFilters);
+ const [buttonRef, setButtonRef] = useState(null);
const {
brokenLinksCounts,
@@ -36,55 +49,115 @@ const ScanResults: FC = ({ data }) => {
externalForbiddenLinksCounts,
} = useMemo(() => countBrokenLinks(data), [data?.sections]);
+ const activeFilters = Object.keys(filters).filter(key => filters[key]);
+ const [filterBy, {
+ add, remove, set, clear,
+ }] = useCheckboxSetValues(activeFilters);
+
if (!data?.sections) {
return ;
}
const { sections } = data;
+ const filterOptions = [
+ { name: intl.formatMessage(messages.brokenLabel), value: 'brokenLinks' },
+ { name: intl.formatMessage(messages.manualLabel), value: 'externalForbiddenLinks' },
+ { name: intl.formatMessage(messages.lockedLabel), value: 'lockedLinks' },
+ ];
return (
-
+
+
{intl.formatMessage(messages.scanHeader)}
+
+
- {intl.formatMessage(messages.scanHeader)}
-
- {
- setShowLockedLinks(!showLockedLinks);
- }}
- label={intl.formatMessage(messages.lockedCheckboxLabel)}
- />
-
-
+ {intl.formatMessage(messages.brokenLinksHeader)}
+
+
setModalOpen(false)}
+ onApply={setFilters}
+ positionRef={buttonRef}
+ filterOptions={filterOptions}
+ initialFilters={filters}
+ activeFilters={activeFilters}
+ filterBy={filterBy}
+ add={add}
+ remove={remove}
+ set={set}
+ />
+ {activeFilters.length > 0 && }
+ {activeFilters.length > 0 && (
+
+
+ {activeFilters.map(filter => (
+ {
+ remove(filter);
+ const updatedFilters = { ...filters, [filter]: false };
+ setFilters(updatedFilters);
+ }}
+ >
+ {filterOptions.find(option => option.value === filter)?.name}
+
+ ))}
+
+
+
+ )}
{sections?.map((section, index) => (
{section.subsections.map((subsection) => (
<>
-
- {subsection.displayName}
-
- {subsection.units.map((unit) => (
-
-
-
- ))}
+ {subsection.units.map((unit) => {
+ if (
+ (!filters.brokenLinks && !filters.externalForbiddenLinks && !filters.lockedLinks)
+ || (filters.brokenLinks && unit.blocks.some(block => block.brokenLinks.length > 0))
+ || (filters.externalForbiddenLinks
+ && unit.blocks.some(block => block.externalForbiddenLinks.length > 0))
+ || (filters.lockedLinks && unit.blocks.some(block => block.lockedLinks.length > 0))
+ ) {
+ return (
+
+
+
+ );
+ }
+ return null;
+ })}
>
))}
diff --git a/src/optimizer-page/scan-results/SectionCollapsible.tsx b/src/optimizer-page/scan-results/SectionCollapsible.tsx
index 80c40bf97..c854280f8 100644
--- a/src/optimizer-page/scan-results/SectionCollapsible.tsx
+++ b/src/optimizer-page/scan-results/SectionCollapsible.tsx
@@ -6,38 +6,57 @@ import {
import {
ArrowRight,
ArrowDropDown,
+ LinkOff,
} from '@openedx/paragon/icons';
+import CustomIcon from './CustomIcon';
+import messages from './messages';
+import lockedIcon from './lockedIcon';
+import ManualIcon from './manualIcon';
interface Props {
title: string;
children: React.ReactNode;
- redItalics?: string;
- yellowItalics?: string;
- greenItalics?: string;
+ brokenNumber: number;
+ manualNumber: number;
+ lockedNumber: number;
className?: string;
}
const SectionCollapsible: FC = ({
- title, children, redItalics = '', yellowItalics = '', greenItalics = '', className = '',
+ title, children, brokenNumber = 0, manualNumber = 0, lockedNumber = 0, className = '',
}) => {
const [isOpen, setIsOpen] = useState(false);
- const styling = 'card-lg';
+ const styling = 'card-lg rounded-sm shadow-outline';
const collapsibleTitle = (
-
-
{title}
-
{redItalics}
-
{yellowItalics}
-
{greenItalics}
+
+
+ {title}
+
+
+
+
+ {brokenNumber}
+
+
+
+ {manualNumber}
+
+
+
+ {lockedNumber}
+
+
);
return (
+
{collapsibleTitle}
)}
diff --git a/src/optimizer-page/scan-results/filterModal.jsx b/src/optimizer-page/scan-results/filterModal.jsx
new file mode 100644
index 000000000..6a37b54b3
--- /dev/null
+++ b/src/optimizer-page/scan-results/filterModal.jsx
@@ -0,0 +1,92 @@
+import { ModalPopup, Form } from '@openedx/paragon';
+import { LinkOff } from '@openedx/paragon/icons';
+import { useEffect, useState } from 'react';
+import PropTypes from 'prop-types';
+import CustomIcon from './CustomIcon';
+import messages from './messages';
+import LockedIcon from './lockedIcon';
+import ManualIcon from './manualIcon';
+
+const FilterModal = ({
+ isOpen,
+ onClose,
+ onApply,
+ positionRef,
+ filterOptions,
+ initialFilters,
+ activeFilters,
+ filterBy,
+ add,
+ remove,
+ set,
+}) => {
+ const [previousFilters, setPreviousFilters] = useState(activeFilters);
+ useEffect(() => {
+ if (JSON.stringify(activeFilters) !== JSON.stringify(previousFilters)) {
+ set(activeFilters);
+ setPreviousFilters(activeFilters);
+ }
+ }, [activeFilters]);
+
+ const handleCheckboxChange = (e) => {
+ const { value, checked } = e.target;
+
+ const updatedFilters = { ...initialFilters, [value]: checked };
+ if (e.target.checked) {
+ add(e.target.value);
+ } else {
+ remove(e.target.value);
+ }
+ onApply(updatedFilters);
+ };
+
+ return (
+
+
+
+
+ {filterOptions.map(({ name, value }) => (
+
+
+ {name}
+ { value === 'brokenLinks' && }
+ { value === 'externalForbiddenLinks' && }
+ { value === 'lockedLinks' && }
+
+
+ ))}
+
+
+
+
+ );
+};
+
+FilterModal.propTypes = {
+ isOpen: PropTypes.bool.isRequired,
+ onClose: PropTypes.func.isRequired,
+ onApply: PropTypes.func.isRequired,
+ positionRef: PropTypes.shape({
+ current: PropTypes.instanceOf(Element),
+ }),
+ filterOptions: PropTypes.arrayOf(PropTypes.shape({
+ name: PropTypes.string.isRequired,
+ value: PropTypes.string.isRequired,
+ })).isRequired,
+ initialFilters: PropTypes.shape({
+ brokenLinks: PropTypes.bool.isRequired,
+ lockedLinks: PropTypes.bool.isRequired,
+ externalForbiddenLinks: PropTypes.bool.isRequired,
+ }).isRequired,
+ activeFilters: PropTypes.arrayOf(PropTypes.string).isRequired,
+ filterBy: PropTypes.arrayOf(PropTypes.string).isRequired,
+ add: PropTypes.func.isRequired,
+ remove: PropTypes.func.isRequired,
+ set: PropTypes.func.isRequired,
+};
+
+export default FilterModal;
diff --git a/src/optimizer-page/scan-results/lockedIcon.tsx b/src/optimizer-page/scan-results/lockedIcon.tsx
new file mode 100644
index 000000000..e5bc78532
--- /dev/null
+++ b/src/optimizer-page/scan-results/lockedIcon.tsx
@@ -0,0 +1,19 @@
+import React from 'react';
+
+const LockedIcon: React.FC> = (props) => (
+
+);
+
+export default LockedIcon;
diff --git a/src/optimizer-page/scan-results/manualIcon.tsx b/src/optimizer-page/scan-results/manualIcon.tsx
new file mode 100644
index 000000000..673139d37
--- /dev/null
+++ b/src/optimizer-page/scan-results/manualIcon.tsx
@@ -0,0 +1,20 @@
+// frontend-app-course-authoring/src/assets/icons/ManualIcon.tsx
+import React from 'react';
+
+const ManualIcon: React.FC> = (props) => (
+
+);
+
+export default ManualIcon;
diff --git a/src/optimizer-page/scan-results/messages.js b/src/optimizer-page/scan-results/messages.js
index e3b0c88d3..f4f6c7e78 100644
--- a/src/optimizer-page/scan-results/messages.js
+++ b/src/optimizer-page/scan-results/messages.js
@@ -15,43 +15,48 @@ const messages = defineMessages({
},
scanHeader: {
id: 'course-authoring.course-optimizer.scanHeader',
- defaultMessage: 'Broken Links Scan',
+ defaultMessage: 'Scan results',
+ },
+ brokenLinksHeader: {
+ id: 'course-authoring.course-optimizer.brokenLinksHeader',
+ defaultMessage: 'Broken links',
+ },
+ filterButtonLabel: {
+ id: 'course-authoring.course-optimizer.filterButtonLabel',
+ defaultMessage: 'Filters',
},
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',
- },
- externalForbiddenLinksNumber: {
- id: 'course-authoring.course-optimizer.externalForbiddenLinksNumber',
- defaultMessage: '{count} manual check',
+ lockedLabel: {
+ id: 'course-authoring.course-optimizer.lockedLabel',
+ defaultMessage: 'Locked',
},
lockedInfoTooltip: {
id: 'course-authoring.course-optimizer.lockedInfoTooltip',
- defaultMessage: 'These course files are "locked", so we cannot verify if the link can access the file.',
+ defaultMessage: 'These course files are inaccessible for non-enrolled users so we cannot verify if the link can access the file.',
},
- brokenLinkStatus: {
- id: 'course-authoring.course-optimizer.brokenLinkStatus',
- defaultMessage: 'Status: Broken',
+ brokenLabel: {
+ id: 'course-authoring.course-optimizer.brokenLabel',
+ defaultMessage: 'Broken',
},
- lockedLinkStatus: {
- id: 'course-authoring.course-optimizer.lockedLinkStatus',
- defaultMessage: 'Status: Locked',
+ brokenInfoTooltip: {
+ id: 'course-authoring.course-optimizer.brokenInfoTooltip',
+ defaultMessage: `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.`,
},
- recommendedManualCheckText: {
- id: 'course-authoring.course-optimizer.recommendedManualCheckText',
- defaultMessage: 'Recommended Manual Check',
+ manualLabel: {
+ id: 'course-authoring.course-optimizer.manualLabel',
+ defaultMessage: 'Manual',
},
- recommendedManualCheckTooltip: {
- id: 'course-authoring.course-optimizer.recommendedManualCheckTooltip',
- defaultMessage: 'For websites returning 403, websites often show 403 because they don\'t want bots accessing their content',
+ manualInfoTooltip: {
+ id: 'course-authoring.course-optimizer.manualInfoTooltip',
+ defaultMessage: 'We couldn\'t verify this link. Please check it manually.',
+ },
+ clearFilters: {
+ id: 'course-authoring.course-optimizer.clearFilters',
+ defaultMessage: 'Clear filters',
},
});
diff --git a/src/optimizer-page/types.ts b/src/optimizer-page/types.ts
index d87451631..9e8d946bc 100644
--- a/src/optimizer-page/types.ts
+++ b/src/optimizer-page/types.ts
@@ -26,3 +26,9 @@ export interface Section {
export interface LinkCheckResult {
sections: Section[];
}
+
+export interface Filters {
+ brokenLinks: boolean,
+ lockedLinks: boolean,
+ externalForbiddenLinks: boolean,
+}