feat: course optimizer page better design

- Add filter functionality to course optimizer broken links to check different results
- modify design, make use of logo with better tooltip
- change message texts in different area of the page
This commit is contained in:
Muhammad Faraz Maqsood
2025-05-06 17:32:07 +05:00
committed by Andres Espinel
parent 96a04d4492
commit c2e4f7f51e
11 changed files with 357 additions and 116 deletions

View File

@@ -132,8 +132,7 @@ const CourseOptimizerPage: FC<{ courseId: string }> = ({ courseId }) => {
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>
<p className="small opt-desc-mb">{intl.formatMessage(messages.description)}</p>
<Card>
<Card.Header
className="h3 px-3 text-black mb-4"
@@ -141,6 +140,7 @@ const CourseOptimizerPage: FC<{ courseId: string }> = ({ courseId }) => {
/>
{isShowExportButton && (
<Card.Section className="px-3 py-1">
<p className="small"> {lastScannedAt && `${intl.formatMessage(messages.lastScannedOn)} ${intl.formatDate(lastScannedAt, { year: 'numeric', month: 'long', day: 'numeric' })}`}</p>
<Button
size="lg"
block
@@ -148,7 +148,7 @@ const CourseOptimizerPage: FC<{ courseId: string }> = ({ courseId }) => {
onClick={() => dispatch(startLinkCheck(courseId))}
iconBefore={SearchIcon}
>
{intl.formatMessage(messages.buttonTitle)} {lastScannedAt && `(${intl.formatMessage(messages.lastScannedOn)} ${intl.formatDate(lastScannedAt, { year: 'numeric', month: 'long', day: 'numeric' })})`}
{intl.formatMessage(messages.buttonTitle)}
</Button>
</Card.Section>
)}

View File

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

View File

@@ -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 (
<OverlayTrigger
key="top"
placement={placement}
overlay={(
<Tooltip variant="dark" id="tooltip-top" className={placement !== 'top' ? 'ml-3' : ''}>
{intl.formatMessage(message1)}
{message1 && <br />}
{intl.formatMessage(message2)}
</Tooltip>
)}
>
<Icon src={icon} />
</OverlayTrigger>
);
};
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;

View File

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

@@ -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 }) => (
<Card className="mt-4">
@@ -28,7 +34,14 @@ interface Props {
const ScanResults: FC<Props> = ({ 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<HTMLButtonElement | null>(null);
const {
brokenLinksCounts,
@@ -36,55 +49,115 @@ const ScanResults: FC<Props> = ({ 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 <InfoCard text={intl.formatMessage(messages.noBrokenLinksCard)} />;
}
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 (
<div className="scan-results">
<div className="border-bottom border-light-400 mb-3">
<div className="scan-header-title-container">
<h2 className="scan-header-title">{intl.formatMessage(messages.scanHeader)}</h2>
</div>
<div className="scan-header-second-title-container">
<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>
<h2 className="broken-links-header-title pt-2">{intl.formatMessage(messages.brokenLinksHeader)}</h2>
<Button
ref={setButtonRef}
variant="outline-primary"
onClick={() => setModalOpen(true)}
disabled={false}
iconAfter={ArrowDropDown}
className="rounded-sm justify-content-between cadence-button"
>
{intl.formatMessage(messages.filterButtonLabel)}
</Button>
</header>
</div>
<FilterModal
isOpen={isModalOpen}
onClose={() => setModalOpen(false)}
onApply={setFilters}
positionRef={buttonRef}
filterOptions={filterOptions}
initialFilters={filters}
activeFilters={activeFilters}
filterBy={filterBy}
add={add}
remove={remove}
set={set}
/>
{activeFilters.length > 0 && <div className="border-bottom border-light-400" />}
{activeFilters.length > 0 && (
<div className="scan-results-active-filters-container">
<span className="scan-results-active-filters-chips">
{activeFilters.map(filter => (
<Chip
key={filter}
iconAfter={CloseSmall}
iconAfterAlt="icon-after"
className="scan-results-active-filters-chip"
onClick={() => {
remove(filter);
const updatedFilters = { ...filters, [filter]: false };
setFilters(updatedFilters);
}}
>
{filterOptions.find(option => option.value === filter)?.name}
</Chip>
))}
</span>
<Button
variant="link"
className="clear-all-btn"
onClick={() => {
clear();
setFilters(initialFilters);
}}
>
{intl.formatMessage(messages.clearFilters)}
</Button>
</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] })}
greenItalics={
intl.formatMessage(messages.externalForbiddenLinksNumber, { count: externalForbiddenLinksCounts[index] })
}
brokenNumber={brokenLinksCounts[index]}
manualNumber={externalForbiddenLinksCounts[index]}
lockedNumber={lockedLinksCounts[index]}
className="section-collapsible-header"
>
{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>
))}
{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 (
<div className="unit">
<BrokenLinkTable unit={unit} filters={filters} />
</div>
);
}
return null;
})}
</>
))}
</SectionCollapsible>

View File

@@ -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<Props> = ({
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 = (
<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>
<span className="green-italics">{greenItalics}</span>
<div className="section-collapsible-header-item">
<Icon src={isOpen ? ArrowDropDown : ArrowRight} />
<strong>{title}</strong>
</div>
<div className="section-collapsible-header-actions">
<span>
<CustomIcon icon={LinkOff} message1={messages.brokenLabel} message2={messages.brokenInfoTooltip} />
{brokenNumber}
</span>
<span>
<CustomIcon icon={ManualIcon} message1={messages.manualLabel} message2={messages.manualInfoTooltip} />
{manualNumber}
</span>
<span>
<CustomIcon icon={lockedIcon} message1={messages.lockedLabel} message2={messages.lockedInfoTooltip} />
{lockedNumber}
</span>
</div>
</div>
);
return (
<div className={`section ${isOpen ? 'is-open' : ''}`}>
<Collapsible
className="section-collapsible-item-container"
styling={styling}
title={(
<p>
<p className="flex-grow-1 section-collapsible-item">
<strong>{collapsibleTitle}</strong>
</p>
)}

View File

@@ -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 (
<ModalPopup isOpen={isOpen} onClose={onClose} positionRef={positionRef} placement="bottom-start">
<div className="filter-modal bg-white rounded shadow-sm w-175">
<Form.Group>
<Form.CheckboxSet
name="course-optimizer-filter"
onChange={handleCheckboxChange}
value={filterBy}
>
{filterOptions.map(({ name, value }) => (
<Form.Checkbox {...{ value, key: value }}>
<span style={{ display: 'flex', gap: '90px' }}>
{name}
{ value === 'brokenLinks' && <CustomIcon icon={LinkOff} message1={messages.brokenLabel} message2={messages.brokenInfoTooltip} placement="right-end" /> }
{ value === 'externalForbiddenLinks' && <CustomIcon icon={ManualIcon} message1={messages.manualLabel} message2={messages.manualInfoTooltip} placement="right-end" /> }
{ value === 'lockedLinks' && <CustomIcon icon={LockedIcon} message1={messages.lockedLabel} message2={messages.lockedInfoTooltip} placement="right-end" /> }
</span>
</Form.Checkbox>
))}
</Form.CheckboxSet>
</Form.Group>
</div>
</ModalPopup>
);
};
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;

View File

@@ -0,0 +1,19 @@
import React from 'react';
const LockedIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path
d="M18 8H17V6C17 3.24 14.76 1 12 1C9.24 1 7 3.24 7 6V8H6C4.9 8 4 8.9 4 10V20C4 21.1 4.9 22 6 22H18C19.1 22 20 21.1 20 20V10C20 8.9 19.1 8 18 8ZM9 6C9 4.34 10.34 3 12 3C13.66 3 15 4.34 15 6V8H9V6ZM18 20H6V10H18V20ZM12 17C13.1 17 14 16.1 14 15C14 13.9 13.1 13 12 13C10.9 13 10 13.9 10 15C10 16.1 10.9 17 12 17Z"
fill="black"
/>
</svg>
);
export default LockedIcon;

View File

@@ -0,0 +1,20 @@
// frontend-app-course-authoring/src/assets/icons/ManualIcon.tsx
import React from 'react';
const ManualIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props} // This allows passing additional props like className, style, etc.
>
<path
d="M12 6C15.79 6 19.17 8.13 20.82 11.5C19.17 14.87 15.79 17 12 17C8.21 17 4.83 14.87 3.18 11.5C4.83 8.13 8.21 6 12 6ZM12 4C7 4 2.73 7.11 1 11.5C2.73 15.89 7 19 12 19C17 19 21.27 15.89 23 11.5C21.27 7.11 17 4 12 4ZM12 9C13.38 9 14.5 10.12 14.5 11.5C14.5 12.88 13.38 14 12 14C10.62 14 9.5 12.88 9.5 11.5C9.5 10.12 10.62 9 12 9ZM12 7C9.52 7 7.5 9.02 7.5 11.5C7.5 13.98 9.52 16 12 16C14.48 16 16.5 13.98 16.5 11.5C16.5 9.02 14.48 7 12 7Z"
fill="black"
/>
</svg>
);
export default ManualIcon;

View File

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

View File

@@ -26,3 +26,9 @@ export interface Section {
export interface LinkCheckResult {
sections: Section[];
}
export interface Filters {
brokenLinks: boolean,
lockedLinks: boolean,
externalForbiddenLinks: boolean,
}