diff --git a/src/header/hooks.js b/src/header/hooks.jsx similarity index 93% rename from src/header/hooks.js rename to src/header/hooks.jsx index d318c05ae..d070b8949 100644 --- a/src/header/hooks.js +++ b/src/header/hooks.jsx @@ -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'])} + {intl.formatMessage(courseOptimizerMessages.beta)} + + ), }] : []), ]; return items; diff --git a/src/header/hooks.test.js b/src/header/hooks.test.js index b2296f0de..95546293d 100644 --- a/src/header/hooks.test.js +++ b/src/header/hooks.test.js @@ -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', () => { diff --git a/src/optimizer-page/CourseOptimizerPage.test.js b/src/optimizer-page/CourseOptimizerPage.test.js index 46f1b2da3..a497b0262 100644 --- a/src/optimizer-page/CourseOptimizerPage.test.js +++ b/src/optimizer-page/CourseOptimizerPage.test.js @@ -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 = () => ( ); -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(); // 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(); + }); + }); }); }); diff --git a/src/optimizer-page/CourseOptimizerPage.tsx b/src/optimizer-page/CourseOptimizerPage.tsx index 97bc1977b..ee3859610 100644 --- a/src/optimizer-page/CourseOptimizerPage.tsx +++ b/src/optimizer-page/CourseOptimizerPage.tsx @@ -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(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 }) => {
+ {intl.formatMessage(messages.headingTitle)} + {intl.formatMessage(messages.beta)} + + ) + } subtitle={intl.formatMessage(messages.headingSubtitle)} /> -

{intl.formatMessage(messages.description)}

+

{intl.formatMessage(messages.description)}

{isShowExportButton && ( -

{lastScannedAt && `${intl.formatMessage(messages.lastScannedOn)} ${intl.formatDate(lastScannedAt, { year: 'numeric', month: 'long', day: 'numeric' })}`}

+

{lastScannedAt && `${intl.formatMessage(messages.lastScannedOn)} ${intl.formatDate(lastScannedAt, { year: 'numeric', month: 'long', day: 'numeric' })}`}

)} - {linkCheckPresent && ( + {showStepper && ( { const dispatch = jest.fn(); diff --git a/src/optimizer-page/messages.js b/src/optimizer-page/messages.js index 7b3103285..0416096c8 100644 --- a/src/optimizer-page/messages.js +++ b/src/optimizer-page/messages.js @@ -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', diff --git a/src/optimizer-page/mocks/mockApiResponse.js b/src/optimizer-page/mocks/mockApiResponse.js index 339d99648..98e9db521 100644 --- a/src/optimizer-page/mocks/mockApiResponse.js +++ b/src/optimizer-page/mocks/mockApiResponse.js @@ -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: [], + }, + ], + }, + ], + }, + ], + }, + ], + }, +}; diff --git a/src/optimizer-page/scan-results/BrokenLinkTable.tsx b/src/optimizer-page/scan-results/BrokenLinkTable.tsx index 95a7143ce..132e65c14 100644 --- a/src/optimizer-page/scan-results/BrokenLinkTable.tsx +++ b/src/optimizer-page/scan-results/BrokenLinkTable.tsx @@ -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 }) => ( - + ); @@ -64,7 +64,7 @@ const BrokenLinkTable: FC = ({ unit, filters, }) => ( - +

{unit.displayName}

)} > - + ); }; diff --git a/src/optimizer-page/scan-results/ScanResults.scss b/src/optimizer-page/scan-results/ScanResults.scss index 7dbe227fc..8a58cd9a3 100644 --- a/src/optimizer-page/scan-results/ScanResults.scss +++ b/src/optimizer-page/scan-results/ScanResults.scss @@ -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; + } +} diff --git a/src/optimizer-page/scan-results/ScanResults.tsx b/src/optimizer-page/scan-results/ScanResults.tsx index c4dc73929..041e7b5eb 100644 --- a/src/optimizer-page/scan-results/ScanResults.tsx +++ b/src/optimizer-page/scan-results/ScanResults.tsx @@ -34,6 +34,7 @@ interface Props { } const ScanResults: FC = ({ data }) => { + let hasSectionsRendered = false; const intl = useIntl(); const [isOpen, open, close] = useToggle(false); const initialFilters = { @@ -60,6 +61,7 @@ const ScanResults: FC = ({ 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 = ({ data }) => { )} - {sections?.map((section, index) => ( - - {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 ( -
- -
- ); - } - return null; - })} - - ))} -
- ))} + {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 ( + + {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 ( +
+ +
+ ); + } + return null; + })} + + ))} +
+ ); + } + return hasSectionsRendered === false ? ( +
+

{intl.formatMessage(messages.noResultsFound)}

+
+ ) : null; + })} ); }; diff --git a/src/optimizer-page/scan-results/SectionCollapsible.tsx b/src/optimizer-page/scan-results/SectionCollapsible.tsx index c854280f8..8eea7973d 100644 --- a/src/optimizer-page/scan-results/SectionCollapsible.tsx +++ b/src/optimizer-page/scan-results/SectionCollapsible.tsx @@ -26,7 +26,7 @@ const SectionCollapsible: FC = ({ 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 = (
@@ -65,7 +65,7 @@ const SectionCollapsible: FC = ({ open={isOpen} onToggle={() => setIsOpen(!isOpen)} > - {children} + {children}
); diff --git a/src/optimizer-page/scan-results/filterModal.jsx b/src/optimizer-page/scan-results/filterModal.jsx index 6a37b54b3..16e90901b 100644 --- a/src/optimizer-page/scan-results/filterModal.jsx +++ b/src/optimizer-page/scan-results/filterModal.jsx @@ -41,7 +41,7 @@ const FilterModal = ({ }; return ( - +
{