feat: add sections filter & UI changes

- TNL-11973: Previously Filters functionality was only working for subsections and units inside sections. Now sections are also filtered.
- TNL-11974: New request, Show "no results found" if no results match the filters
- TNL-11975: UI Change, Align filter menu popup to left side of filter button
- TNL-11976: UI Change, Remove underline below "Course optimizer" title
- TNL-11978: UI Change, Change title to "Scan my course"
- TNL-11989: UI Change, Use empty space to display link, don't truncate text before the space runs out
- TNL-11977: New request, Remove this stuff(scanning steps) when scan is complete, it'll disappear after 2.5 seconds
- TNL-11979: UI Change, Move "This tool will scan your course..." text inside of Scan card
- TNL-11980: UI Change, Move "Last scanned on..." date text below Scan button
- TNL-11981: UI Change, Remove icon from "Start scanning" button
- TNL-11983: UI Change, "Start scanning" button should be smaller, made it medium sized
- TNL-11984: UI Change, Remove dividing line under subsection name in expanded card
- TNL-11985: UI Change, Fix alignment of dividing lines, links, and icons in expanded cards to match Figma.
- TNL-11986: UI Change, Match color of the broken icon with other Icons
- TNL-11987: UI Change, Fix alignment of Filter chips to match Figma
- Also added Beta Badge for course optimizer.
- Added tests for codecov coverage
This commit is contained in:
Muhammad Faraz Maqsood
2025-05-15 18:21:37 +05:00
committed by Muhammad Faraz Maqsood
parent cfb4944d43
commit 3c69733170
16 changed files with 256 additions and 76 deletions

View File

@@ -1,11 +1,13 @@
import { getConfig } from '@edx/frontend-platform';
import { useIntl } from '@edx/frontend-platform/i18n';
import { useSelector } from 'react-redux';
import { Badge } from '@openedx/paragon';
import { getPagePath } from '../utils';
import { getWaffleFlags } from '../data/selectors';
import { getStudioHomeData } from '../studio-home/data/selectors';
import messages from './messages';
import courseOptimizerMessages from '../optimizer-page/messages';
export const useContentMenuItems = courseId => {
const intl = useIntl();
@@ -113,7 +115,12 @@ export const useToolsMenuItems = courseId => {
},
...(waffleFlags.enableCourseOptimizer ? [{
href: `/course/${courseId}/optimizer`,
title: intl.formatMessage(messages['header.links.optimizer']),
title: (
<>
{intl.formatMessage(messages['header.links.optimizer'])}
<Badge variant="primary" className="ml-2">{intl.formatMessage(courseOptimizerMessages.beta)}</Badge>
</>
),
}] : []),
];
return items;

View File

@@ -117,8 +117,10 @@ describe('header utils', () => {
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);
const optimizerItem = renderHook(() => useToolsMenuItems('course-123')).result.current.find(
item => item.href === '/course/course-123/optimizer',
);
expect(optimizerItem).toBeDefined();
});
it('when course optimizer disabled should not include optimizer option', () => {

View File

@@ -15,7 +15,7 @@ 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 { mockApiResponse, mockApiResponseForNoResultFound } from './mocks/mockApiResponse';
import * as thunks from './data/thunks';
let store;
@@ -37,8 +37,8 @@ const OptimizerPage = () => (
</AppProvider>
);
const setupOptimizerPage = async () => {
axiosMock.onGet(getLinkCheckStatusApiUrl(courseId)).reply(200, mockApiResponse);
const setupOptimizerPage = async (apiResponse = mockApiResponse) => {
axiosMock.onGet(getLinkCheckStatusApiUrl(courseId)).reply(200, apiResponse);
const optimizerPage = render(<OptimizerPage />);
// Click the scan button
@@ -171,6 +171,29 @@ describe('CourseOptimizerPage', () => {
});
});
it('should show only locked links when lockedLinks filter is selected', async () => {
const {
getByText,
getByLabelText,
queryByText,
container,
} = await setupOptimizerPage();
// Check if the modal is opened
expect(getByText('Locked')).toBeInTheDocument();
// Select the broken links checkbox
fireEvent.click(getByLabelText(scanResultsMessages.lockedLabel.defaultMessage));
const collapsibleTrigger = container.querySelector('.collapsible-trigger');
expect(collapsibleTrigger).toBeInTheDocument();
fireEvent.click(collapsibleTrigger);
await waitFor(() => {
expect(getByText('Test Locked Links')).toBeInTheDocument();
expect(queryByText('Test Broken Links')).not.toBeInTheDocument();
expect(queryByText('Test Manual Links')).not.toBeInTheDocument();
});
});
it('should show only broken links when brokenLinks filter is selected', async () => {
const {
getByText,
@@ -323,5 +346,20 @@ describe('CourseOptimizerPage', () => {
expect(getByText('Test Locked Links')).toBeInTheDocument();
});
});
it('should show no results found message when filter with no links is selected', async () => {
const {
getByText,
getByLabelText,
} = await setupOptimizerPage(mockApiResponseForNoResultFound);
// Check if the modal is opened
expect(getByText('Locked')).toBeInTheDocument();
// Select the broken links checkbox
fireEvent.click(getByLabelText(scanResultsMessages.lockedLabel.defaultMessage));
await waitFor(() => {
expect(getByText(scanResultsMessages.noResultsFound.defaultMessage)).toBeInTheDocument();
});
});
});
});

View File

@@ -1,13 +1,12 @@
/* eslint-disable no-param-reassign */
import {
useEffect, useRef, FC, MutableRefObject,
useEffect, useState, useRef, FC, MutableRefObject,
} from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { useIntl } from '@edx/frontend-platform/i18n';
import {
Container, Layout, Button, Card,
Badge, 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';
@@ -60,6 +59,7 @@ const CourseOptimizerPage: FC<{ courseId: string }> = ({ courseId }) => {
const interval = useRef<number | undefined>(undefined);
const courseDetails = useModel('courseDetails', courseId);
const linkCheckPresent = currentStage != null ? currentStage >= 0 : !!currentStage;
const [showStepper, setShowStepper] = useState(false);
const intl = useIntl();
@@ -96,6 +96,22 @@ const CourseOptimizerPage: FC<{ courseId: string }> = ({ courseId }) => {
};
}, [linkCheckInProgress, linkCheckResult]);
const stepperVisibleCondition = linkCheckPresent && ((!linkCheckResult || linkCheckInProgress) && currentStage !== 2);
useEffect(() => {
let timeout: NodeJS.Timeout;
if (stepperVisibleCondition) {
setShowStepper(true);
} else {
timeout = setTimeout(() => {
// ignoring below line as we didn't wrote tests for scanning process
// istanbul ignore next
setShowStepper(false);
}, 2500);
}
return () => clearTimeout(timeout);
}, [stepperVisibleCondition]);
if (isLoadingDenied || isSavingDenied) {
if (interval.current) { clearInterval(interval.current); }
@@ -129,30 +145,37 @@ const CourseOptimizerPage: FC<{ courseId: string }> = ({ courseId }) => {
<Layout.Element>
<article>
<SubHeader
title={intl.formatMessage(messages.headingTitle)}
hideBorder
title={
(
<span style={{ display: 'inline-flex', alignItems: 'center' }}>
{intl.formatMessage(messages.headingTitle)}
<Badge variant="primary" className="ml-2">{intl.formatMessage(messages.beta)}</Badge>
</span>
)
}
subtitle={intl.formatMessage(messages.headingSubtitle)}
/>
<p className="small opt-desc-mb">{intl.formatMessage(messages.description)}</p>
<Card>
<Card.Header
className="h3 px-3 text-black mb-4"
className="scan-header h3 px-3 text-black mb-2"
title={intl.formatMessage(messages.card1Title)}
/>
<p className="px-3 py-1 small ">{intl.formatMessage(messages.description)}</p>
{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"
size="md"
block
className="mb-4"
className="mb-3"
onClick={() => dispatch(startLinkCheck(courseId))}
iconBefore={SearchIcon}
>
{intl.formatMessage(messages.buttonTitle)}
</Button>
<p className="small"> {lastScannedAt && `${intl.formatMessage(messages.lastScannedOn)} ${intl.formatDate(lastScannedAt, { year: 'numeric', month: 'long', day: 'numeric' })}`}</p>
</Card.Section>
)}
{linkCheckPresent && (
{showStepper && (
<Card.Section className="px-3 py-1">
<CourseStepper
// @ts-ignore

View File

@@ -1,4 +1,4 @@
import mockApiResponse from '../mocks/mockApiResponse';
import { mockApiResponse } from '../mocks/mockApiResponse';
import { initializeMocks } from '../../testUtils';
import * as api from './api';
import { LINK_CHECK_STATUSES } from './constants';

View File

@@ -2,7 +2,7 @@ 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';
import { mockApiResponse } from '../mocks/mockApiResponse';
describe('startLinkCheck thunk', () => {
const dispatch = jest.fn();

View File

@@ -9,6 +9,10 @@ const messages = defineMessages({
id: 'course-authoring.course-optimizer.heading.title',
defaultMessage: 'Course optimizer',
},
beta: {
id: 'course-authoring.course-optimizer.beta',
defaultMessage: 'Beta',
},
headingSubtitle: {
id: 'course-authoring.course-optimizer.heading.subtitle',
defaultMessage: 'Tools',
@@ -19,11 +23,7 @@ const messages = defineMessages({
},
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',
defaultMessage: 'Scan my course',
},
buttonTitle: {
id: 'course-authoring.course-optimizer.button.title',

View File

@@ -1,4 +1,4 @@
const mockApiResponse = {
export const mockApiResponse = {
LinkCheckStatus: 'Succeeded',
LinkCheckCreatedAt: '2024-12-14T00:26:50.838350Z',
LinkCheckOutput: {
@@ -123,4 +123,36 @@ const mockApiResponse = {
},
};
export default mockApiResponse;
export const mockApiResponseForNoResultFound = {
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: 'Test Broken Links',
blocks: [
{
id: 'block-1-1-1-5',
url: 'https://example.com/welcome-video',
brokenLinks: ['https://example.com/broken-link-algo1'],
lockedLinks: [],
externalForbiddenLinks: [],
},
],
},
],
},
],
},
],
},
};

View File

@@ -45,7 +45,7 @@ const GoToBlock: FC<{ block: { url: string, displayName: string } }> = ({ block
const LinksCol: FC<{ block: { url: string, displayName: string }, href: string }> = ({ block, href }) => (
<span className="links-container">
<GoToBlock block={{ url: block.url, displayName: block.displayName || 'Go to block' }} />
<Icon className="arrow-forward-ios" src={ArrowForwardIos} />
<Icon className="arrow-forward-ios" src={ArrowForwardIos} style={{ color: '#8F8F8F' }} />
<BrokenLinkHref href={href} />
</span>
);
@@ -64,7 +64,7 @@ const BrokenLinkTable: FC<BrokenLinkTableProps> = ({
unit,
filters,
}) => (
<Card className="unit-card rounded-sm pt-2 pl-3 pr-4 mb-2.5">
<Card className="unit-card rounded-sm mb-4">
<p className="unit-header">{unit.displayName}</p>
<Table
data={unit.blocks.reduce(

View File

@@ -26,7 +26,7 @@ const CustomIcon = ({
</Tooltip>
)}
>
<Icon src={icon} />
<Icon src={icon} style={{ color: '#000000' }} />
</OverlayTrigger>
);
};

View File

@@ -6,10 +6,10 @@
.section {
&.is-open {
&:not(:first-child) {
margin-top: 1rem;
margin-top: 1.5rem;
}
margin-bottom: 1rem;
margin-bottom: 1.5rem;
}
}
@@ -29,11 +29,32 @@
.unit-card{
border: 1px solid #BCBCBC;
box-shadow: 0 1px 2px rgb(0 0 0 / .15);
padding: 14px 32px 4px !important;
.table{
margin-bottom: .5rem;
tbody {
tr {
display: flex;
align-items: center;
border-top: 1px solid #CCCCCC;
td {
padding: 16px 0;
border-top: none !important;
}
td:nth-child(2) {
padding: 16px 0 16px 24px;
}
}
}
}
}
/* Subsection Header */
.unit-header {
margin-left: .5rem;
margin-top: 10px;
font-size: 14px;
font-weight: 700;
@@ -71,20 +92,19 @@
display: flex;
align-items: center;
gap: 1.5rem;
width:38rem;
width: 56rem;
}
.go-to-block-link-container {
max-width: 20rem;
max-width: 60%;
width: fit-content;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.broken-link-container {
max-width: 20rem;
text-wrap: nowrap;
max-width: 95%;
width: fit-content;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
@@ -108,12 +128,14 @@
display: flex;
align-items: center;
justify-content: space-between;
color: #000000 !important;
gap: 24px;
}
.section-collapsible-header-item {
display: flex;
align-items: center;
gap: .2rem;
gap: 8px;
}
.section-collapsible-header-actions {
@@ -127,7 +149,7 @@
align-items: center;
justify-content: space-between;
gap: 8px;
color: var(--gray-700, #454545);
color: #000000;
font-variant-numeric: lining-nums tabular-nums;
font-family: Inter, sans-serif;
font-size: 14px;
@@ -139,12 +161,20 @@
.section-collapsible-item-container {
box-shadow: 0 1px 2px rgb(0 0 0 / .15);
.collapsible-trigger{
border: none;
}
}
.section-collapsible-item {
margin-right: -24px;
}
.section-collapsible-item-body {
margin: -16px -9px -12px -10px;
}
.scan-header-title-container {
margin-top: 32px;
}
@@ -198,12 +228,14 @@
justify-content: center;
align-items: center;
gap: 4px;
padding-left: 0;
}
.scan-results-active-filters-chip {
border-radius: 6px;
border: 1px solid var(--light-300, #F2F0EF);
background: var(--extras-white, #FFFFFF);
margin-left: 0
}
.clear-all-btn {
@@ -215,6 +247,29 @@
}
.filter-modal {
margin-left: -1.5rem;
padding: 16px 16px 1px;
}
.no-results-found-container{
display: flex;
height: 94px;
justify-content: center;
align-items: center;
gap: 10px;
align-self: stretch;
}
.no-results-found{
color: var(--gray-700, #454545);
font-family: Inter, sans-serif;
font-size: 18px;
font-style: normal;
font-weight: 500;
line-height: 24px;
}
.scan-header {
& .pgn__card-header-content {
margin-top: 1.5rem !important;
}
}

View File

@@ -34,6 +34,7 @@ interface Props {
}
const ScanResults: FC<Props> = ({ data }) => {
let hasSectionsRendered = false;
const intl = useIntl();
const [isOpen, open, close] = useToggle(false);
const initialFilters = {
@@ -60,6 +61,7 @@ const ScanResults: FC<Props> = ({ data }) => {
}
const { sections } = data;
const filterOptions = [
{ name: intl.formatMessage(messages.brokenLabel), value: 'brokenLinks' },
{ name: intl.formatMessage(messages.manualLabel), value: 'externalForbiddenLinks' },
@@ -135,37 +137,54 @@ const ScanResults: FC<Props> = ({ data }) => {
</div>
)}
{sections?.map((section, index) => (
<SectionCollapsible
key={section.id}
title={section.displayName}
brokenNumber={brokenLinksCounts[index]}
manualNumber={externalForbiddenLinksCounts[index]}
lockedNumber={lockedLinksCounts[index]}
className="section-collapsible-header"
>
{section.subsections.map((subsection) => (
<>
{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>
))}
{sections?.map((section, index) => {
const shouldRenderSection = (
(!filters.brokenLinks && !filters.externalForbiddenLinks && !filters.lockedLinks)
|| (filters.brokenLinks && brokenLinksCounts[index] > 0)
|| (filters.externalForbiddenLinks && externalForbiddenLinksCounts[index] > 0)
|| (filters.lockedLinks && lockedLinksCounts[index] > 0)
);
if (shouldRenderSection) {
hasSectionsRendered = true;
return (
<SectionCollapsible
key={section.id}
title={section.displayName}
brokenNumber={brokenLinksCounts[index]}
manualNumber={externalForbiddenLinksCounts[index]}
lockedNumber={lockedLinksCounts[index]}
className="section-collapsible-header"
>
{section.subsections.map((subsection) => (
<>
{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>
);
}
return hasSectionsRendered === false ? (
<div className="no-results-found-container">
<h3 className="no-results-found">{intl.formatMessage(messages.noResultsFound)}</h3>
</div>
) : null;
})}
</div>
);
};

View File

@@ -26,7 +26,7 @@ const SectionCollapsible: FC<Props> = ({
title, children, brokenNumber = 0, manualNumber = 0, lockedNumber = 0, className = '',
}) => {
const [isOpen, setIsOpen] = useState(false);
const styling = 'card-lg rounded-sm shadow-outline';
const styling = 'card-lg rounded-sm';
const collapsibleTitle = (
<div className={className}>
<div className="section-collapsible-header-item">
@@ -65,7 +65,7 @@ const SectionCollapsible: FC<Props> = ({
open={isOpen}
onToggle={() => setIsOpen(!isOpen)}
>
<Collapsible.Body>{children}</Collapsible.Body>
<Collapsible.Body className="section-collapsible-item-body">{children}</Collapsible.Body>
</Collapsible>
</div>
);

View File

@@ -41,7 +41,7 @@ const FilterModal = ({
};
return (
<ModalPopup isOpen={isOpen} onClose={onClose} positionRef={positionRef} placement="bottom-start">
<ModalPopup isOpen={isOpen} onClose={onClose} positionRef={positionRef} placement="bottom-end">
<div className="filter-modal bg-white rounded shadow-sm w-175">
<Form.Group>
<Form.CheckboxSet

View File

@@ -13,6 +13,10 @@ const messages = defineMessages({
id: 'course-authoring.course-optimizer.emptyResultsCard',
defaultMessage: 'No broken links found',
},
noResultsFound: {
id: 'course-authoring.course-optimizer.noResultsFound',
defaultMessage: 'No results found',
},
scanHeader: {
id: 'course-authoring.course-optimizer.scanHeader',
defaultMessage: 'Scan results',

View File

@@ -1,4 +1,4 @@
import mockApiResponse from './mocks/mockApiResponse';
import { mockApiResponse } from './mocks/mockApiResponse';
import { countBrokenLinks } from './utils';
describe('countBrokenLinks', () => {