From 8385c4e8edfa42d956cf59c5e5935afb6911e0ef Mon Sep 17 00:00:00 2001 From: Jesper Hodge <19345795+jesperhodge@users.noreply.github.com> Date: Mon, 13 Jan 2025 11:44:25 -0500 Subject: [PATCH] Feat course optimizer page (#1533) Course Optimizer is a feature approved by the Openedx community that adds a "Course Optimizer" page to studio where users can run a scan of a course for broken links - links that point to pages that have a 404. Depends on backend: openedx/edx-platform#35887 - test together. This also requires adding a nav menu item to edx-platform legacy studio. That should be implemented before enabling the waffle flag on prod. Links: - [Internal JIRA ticket](https://2u-internal.atlassian.net/browse/TNL-11809) - [Course Optimizer Discovery](https://2u-internal.atlassian.net/wiki/spaces/TNL/pages/1426587703/TNL-11744+Course+Optimizer+Discovery) - [Openedx community proposal](https://github.com/openedx/platform-roadmap/issues/388) --- src/CourseAuthoringRoutes.jsx | 5 + src/data/constants.js | 7 + src/header/hooks.js | 4 + src/header/hooks.test.js | 29 ++- src/header/messages.js | 5 + src/index.scss | 1 + .../CourseOptimizerPage.test.js | 193 ++++++++++++++++++ src/optimizer-page/CourseOptimizerPage.tsx | 176 ++++++++++++++++ src/optimizer-page/data/api.test.js | 34 +++ src/optimizer-page/data/api.ts | 26 +++ src/optimizer-page/data/constants.ts | 40 ++++ src/optimizer-page/data/selectors.ts | 12 ++ src/optimizer-page/data/slice.test.ts | 111 ++++++++++ src/optimizer-page/data/slice.ts | 91 +++++++++ src/optimizer-page/data/thunks.test.js | 193 ++++++++++++++++++ src/optimizer-page/data/thunks.ts | 81 ++++++++ src/optimizer-page/messages.js | 71 +++++++ src/optimizer-page/mocks/mockApiResponse.js | 106 ++++++++++ .../scan-results/BrokenLinkTable.tsx | 117 +++++++++++ .../scan-results/LockedInfoIcon.jsx | 30 +++ .../scan-results/ScanResults.scss | 110 ++++++++++ .../scan-results/ScanResults.tsx | 89 ++++++++ .../scan-results/SectionCollapsible.tsx | 53 +++++ src/optimizer-page/scan-results/index.js | 3 + src/optimizer-page/scan-results/messages.js | 46 +++++ src/optimizer-page/types.ts | 27 +++ src/optimizer-page/utils.test.js | 44 ++++ src/optimizer-page/utils.ts | 26 +++ src/store.js | 2 + 29 files changed, 1724 insertions(+), 8 deletions(-) create mode 100644 src/optimizer-page/CourseOptimizerPage.test.js create mode 100644 src/optimizer-page/CourseOptimizerPage.tsx create mode 100644 src/optimizer-page/data/api.test.js create mode 100644 src/optimizer-page/data/api.ts create mode 100644 src/optimizer-page/data/constants.ts create mode 100644 src/optimizer-page/data/selectors.ts create mode 100644 src/optimizer-page/data/slice.test.ts create mode 100644 src/optimizer-page/data/slice.ts create mode 100644 src/optimizer-page/data/thunks.test.js create mode 100644 src/optimizer-page/data/thunks.ts create mode 100644 src/optimizer-page/messages.js create mode 100644 src/optimizer-page/mocks/mockApiResponse.js create mode 100644 src/optimizer-page/scan-results/BrokenLinkTable.tsx create mode 100644 src/optimizer-page/scan-results/LockedInfoIcon.jsx create mode 100644 src/optimizer-page/scan-results/ScanResults.scss create mode 100644 src/optimizer-page/scan-results/ScanResults.tsx create mode 100644 src/optimizer-page/scan-results/SectionCollapsible.tsx create mode 100644 src/optimizer-page/scan-results/index.js create mode 100644 src/optimizer-page/scan-results/messages.js create mode 100644 src/optimizer-page/types.ts create mode 100644 src/optimizer-page/utils.test.js create mode 100644 src/optimizer-page/utils.ts diff --git a/src/CourseAuthoringRoutes.jsx b/src/CourseAuthoringRoutes.jsx index ded2f07eae..4bef342689 100644 --- a/src/CourseAuthoringRoutes.jsx +++ b/src/CourseAuthoringRoutes.jsx @@ -20,6 +20,7 @@ import { CourseUpdates } from './course-updates'; import { CourseUnit, IframeProvider } from './course-unit'; import { Certificates } from './certificates'; import CourseExportPage from './export-page/CourseExportPage'; +import CourseOptimizerPage from './optimizer-page/CourseOptimizerPage'; import CourseImportPage from './import-page/CourseImportPage'; import { DECODED_ROUTES } from './constants'; import CourseChecklist from './course-checklist'; @@ -118,6 +119,10 @@ const CourseAuthoringRoutes = () => { path="export" element={} /> + } + /> } diff --git a/src/data/constants.js b/src/data/constants.js index 2c63f26289..7aa59f063a 100644 --- a/src/data/constants.js +++ b/src/data/constants.js @@ -15,6 +15,13 @@ export const RequestStatus = /** @type {const} */ ({ NOT_FOUND: 'not-found', }); +export const RequestFailureStatuses = [ + RequestStatus.FAILED, + RequestStatus.DENIED, + RequestStatus.PARTIAL_FAILURE, + RequestStatus.NOT_FOUND, +]; + /** * Team sizes enum * @enum diff --git a/src/header/hooks.js b/src/header/hooks.js index 6758fbc27b..d1e5e3a449 100644 --- a/src/header/hooks.js +++ b/src/header/hooks.js @@ -103,6 +103,10 @@ export const useToolsMenuItems = courseId => { href: `/course/${courseId}/checklists`, title: intl.formatMessage(messages['header.links.checklists']), }, + ...(waffleFlags.enableCourseOptimizer ? [{ + href: `/course/${courseId}/optimizer`, + title: intl.formatMessage(messages['header.links.optimizer']), + }] : []), ]; return items; }; diff --git a/src/header/hooks.test.js b/src/header/hooks.test.js index 9b9f1cbae2..aa0829b33c 100644 --- a/src/header/hooks.test.js +++ b/src/header/hooks.test.js @@ -1,6 +1,7 @@ import { useSelector } from 'react-redux'; import { getConfig, setConfig } from '@edx/frontend-platform'; import { renderHook } from '@testing-library/react-hooks'; +import messages from './messages'; import { useContentMenuItems, useToolsMenuItems, useSettingMenuItems } from './hooks'; jest.mock('@edx/frontend-platform/i18n', () => ({ @@ -17,7 +18,7 @@ jest.mock('react-redux', () => ({ describe('header utils', () => { describe('getContentMenuItems', () => { - it('should include Video Uploads option', () => { + it('when video upload page enabled should include Video Uploads option', () => { setConfig({ ...getConfig(), ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN: 'true', @@ -25,7 +26,7 @@ describe('header utils', () => { const actualItems = renderHook(() => useContentMenuItems('course-123')).result.current; expect(actualItems).toHaveLength(5); }); - it('should not include Video Uploads option', () => { + it('when video upload page disabled should not include Video Uploads option', () => { setConfig({ ...getConfig(), ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN: 'false', @@ -38,7 +39,7 @@ describe('header utils', () => { describe('getSettingsMenuitems', () => { useSelector.mockReturnValue({ canAccessAdvancedSettings: true }); - it('should include certificates option', () => { + it('when certificate page enabled should include certificates option', () => { setConfig({ ...getConfig(), ENABLE_CERTIFICATE_PAGE: 'true', @@ -46,7 +47,7 @@ describe('header utils', () => { const actualItems = renderHook(() => useSettingMenuItems('course-123')).result.current; expect(actualItems).toHaveLength(6); }); - it('should not include certificates option', () => { + it('when certificate page disabled should not include certificates option', () => { setConfig({ ...getConfig(), ENABLE_CERTIFICATE_PAGE: 'false', @@ -54,11 +55,11 @@ describe('header utils', () => { const actualItems = renderHook(() => useSettingMenuItems('course-123')).result.current; expect(actualItems).toHaveLength(5); }); - it('should include advanced settings option', () => { + it('when user has access to advanced settings should include advanced settings option', () => { const actualItemsTitle = renderHook(() => useSettingMenuItems('course-123')).result.current.map((item) => item.title); expect(actualItemsTitle).toContain('Advanced Settings'); }); - it('should not include advanced settings option', () => { + it('when user has no access to advanced settings should not include advanced settings option', () => { useSelector.mockReturnValue({ canAccessAdvancedSettings: false }); const actualItemsTitle = renderHook(() => useSettingMenuItems('course-123')).result.current.map((item) => item.title); expect(actualItemsTitle).not.toContain('Advanced Settings'); @@ -66,7 +67,7 @@ describe('header utils', () => { }); describe('getToolsMenuItems', () => { - it('should include export tags option', () => { + it('when tags enabled should include export tags option', () => { setConfig({ ...getConfig(), ENABLE_TAGGING_TAXONOMY_PAGES: 'true', @@ -79,7 +80,7 @@ describe('header utils', () => { 'Checklists', ]); }); - it('should not include export tags option', () => { + it('when tags disabled should not include export tags option', () => { setConfig({ ...getConfig(), ENABLE_TAGGING_TAXONOMY_PAGES: 'false', @@ -91,5 +92,17 @@ describe('header utils', () => { 'Checklists', ]); }); + + 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); + }); + + it('when course optimizer disabled should not include optimizer option', () => { + useSelector.mockReturnValue({ enableCourseOptimizer: false }); + const actualItemsTitle = renderHook(() => useToolsMenuItems('course-123')).result.current.map((item) => item.title); + expect(actualItemsTitle).not.toContain(messages['header.links.optimizer'].defaultMessage); + }); }); }); diff --git a/src/header/messages.js b/src/header/messages.js index 31d5f32fa6..6c578790f5 100644 --- a/src/header/messages.js +++ b/src/header/messages.js @@ -96,6 +96,11 @@ const messages = defineMessages({ defaultMessage: 'Export Course', description: 'Link to Studio Export page', }, + 'header.links.optimizer': { + id: 'header.links.optimizer', + defaultMessage: 'Optimize Course', + description: 'Fix broken links and other issues in your course', + }, 'header.links.exportTags': { id: 'header.links.exportTags', defaultMessage: 'Export Tags', diff --git a/src/index.scss b/src/index.scss index 69f9b8b34f..31ffa2de8d 100644 --- a/src/index.scss +++ b/src/index.scss @@ -31,6 +31,7 @@ @import "search-manager"; @import "certificates/scss/Certificates"; @import "group-configurations/GroupConfigurations"; +@import "optimizer-page/scan-results/ScanResults"; // To apply the glow effect to the selected Section/Subsection, in the Course Outline div.row:has(> div > div.highlight) { diff --git a/src/optimizer-page/CourseOptimizerPage.test.js b/src/optimizer-page/CourseOptimizerPage.test.js new file mode 100644 index 0000000000..5f69e18401 --- /dev/null +++ b/src/optimizer-page/CourseOptimizerPage.test.js @@ -0,0 +1,193 @@ +/* eslint-disable @typescript-eslint/no-shadow */ +/* eslint-disable react/jsx-filename-extension */ +import { + fireEvent, render, waitFor, screen, +} from '@testing-library/react'; +import { IntlProvider } from '@edx/frontend-platform/i18n'; +import { AppProvider } from '@edx/frontend-platform/react'; +import { initializeMockApp } from '@edx/frontend-platform'; + +import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; +import MockAdapter from 'axios-mock-adapter'; +import initializeStore from '../store'; +import messages from './messages'; +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 * as thunks from './data/thunks'; + +let store; +let axiosMock; +const courseId = '123'; +const courseName = 'About Node JS'; + +jest.mock('../generic/model-store', () => ({ + useModel: jest.fn().mockReturnValue({ + name: courseName, + }), +})); + +const OptimizerPage = () => ( + + + + + +); + +describe('CourseOptimizerPage', () => { + describe('pollLinkCheckDuringScan', () => { + let mockFetchLinkCheckStatus; + beforeEach(() => { + mockFetchLinkCheckStatus = jest.fn(); + jest.spyOn(thunks, 'fetchLinkCheckStatus').mockImplementation(mockFetchLinkCheckStatus); + jest.useFakeTimers(); + jest.spyOn(global, 'setInterval').mockImplementation((cb) => { cb(); return true; }); + }); + + afterEach(() => { + jest.clearAllTimers(); + jest.useRealTimers(); + jest.restoreAllMocks(); + }); + + it('should start polling if linkCheckInProgress has never been started (is null)', () => { + const linkCheckInProgress = null; + const interval = { current: null }; + const dispatch = jest.fn(); + const courseId = 'course-123'; + pollLinkCheckDuringScan(linkCheckInProgress, interval, dispatch, courseId); + expect(interval.current).toBeTruthy(); + expect(mockFetchLinkCheckStatus).toHaveBeenCalled(); + }); + + it('should start polling if link check is in progress', () => { + const linkCheckInProgress = true; + const interval = { current: null }; + const dispatch = jest.fn(); + const courseId = 'course-123'; + pollLinkCheckDuringScan(linkCheckInProgress, interval, dispatch, courseId); + expect(interval.current).toBeTruthy(); + }); + it('should not start polling if link check is not in progress', () => { + const linkCheckInProgress = false; + const interval = { current: null }; + const dispatch = jest.fn(); + const courseId = 'course-123'; + pollLinkCheckDuringScan(linkCheckInProgress, interval, dispatch, courseId); + expect(interval.current).toBeFalsy(); + }); + it('should clear the interval if link check is finished', () => { + const linkCheckInProgress = false; + const interval = { current: 1 }; + const dispatch = jest.fn(); + const courseId = 'course-123'; + pollLinkCheckDuringScan(linkCheckInProgress, interval, dispatch, courseId); + expect(interval.current).toBeUndefined(); + }); + }); + + describe('CourseOptimizerPage component', () => { + beforeEach(() => { + jest.useRealTimers(); + jest.clearAllMocks(); + initializeMockApp({ + authenticatedUser: { + userId: 3, + username: 'abc123', + administrator: true, + roles: [], + }, + }); + store = initializeStore(); + axiosMock = new MockAdapter(getAuthenticatedHttpClient()); + axiosMock + .onPost(postLinkCheckCourseApiUrl(courseId)) + .reply(200, { LinkCheckStatus: 'In-Progress' }); + axiosMock + .onGet(getLinkCheckStatusApiUrl(courseId)) + .reply(200, mockApiResponse); + }); + + it('should render the component', () => { + const { getByText, queryByText } = render(); + expect(getByText(messages.headingTitle.defaultMessage)).toBeInTheDocument(); + expect(getByText(messages.buttonTitle.defaultMessage)).toBeInTheDocument(); + expect(queryByText(messages.preparingStepTitle)).not.toBeInTheDocument(); + }); + + it('should start scan after clicking the scan button', async () => { + const { getByText } = render(); + expect(getByText(messages.headingTitle.defaultMessage)).toBeInTheDocument(); + fireEvent.click(getByText(messages.buttonTitle.defaultMessage)); + await waitFor(() => { + expect(getByText(messages.preparingStepTitle.defaultMessage)).toBeInTheDocument(); + }); + }); + + it('should list broken links results', async () => { + const { + getByText, queryAllByText, getAllByText, container, + } = render(); + expect(getByText(messages.headingTitle.defaultMessage)).toBeInTheDocument(); + fireEvent.click(getByText(messages.buttonTitle.defaultMessage)); + await waitFor(() => { + expect(getByText('5 broken links')).toBeInTheDocument(); + expect(getByText('5 locked links')).toBeInTheDocument(); + }); + const collapsibleTrigger = container.querySelector('.collapsible-trigger'); + expect(collapsibleTrigger).toBeInTheDocument(); + fireEvent.click(collapsibleTrigger); + await waitFor(() => { + expect(getAllByText(scanResultsMessages.brokenLinkStatus.defaultMessage)[0]).toBeInTheDocument(); + expect(queryAllByText(scanResultsMessages.lockedLinkStatus.defaultMessage)[0]).toBeInTheDocument(); + }); + }); + + it('should not list locked links results when show locked links is unchecked', async () => { + const { + getByText, getAllByText, getByLabelText, queryAllByText, queryByText, container, + } = render(); + expect(getByText(messages.headingTitle.defaultMessage)).toBeInTheDocument(); + fireEvent.click(getByText(messages.buttonTitle.defaultMessage)); + await waitFor(() => { + expect(getByText('5 broken links')).toBeInTheDocument(); + }); + fireEvent.click(getByLabelText(scanResultsMessages.lockedCheckboxLabel.defaultMessage)); + const collapsibleTrigger = container.querySelector('.collapsible-trigger'); + expect(collapsibleTrigger).toBeInTheDocument(); + fireEvent.click(collapsibleTrigger); + await waitFor(() => { + expect(queryByText('5 locked links')).not.toBeInTheDocument(); + expect(getAllByText(scanResultsMessages.brokenLinkStatus.defaultMessage)[0]).toBeInTheDocument(); + expect(queryAllByText(scanResultsMessages.lockedLinkStatus.defaultMessage)?.[0]).toBeUndefined(); + }); + }); + + it('should show no broken links found message', async () => { + axiosMock + .onGet(getLinkCheckStatusApiUrl(courseId)) + .reply(200, { LinkCheckStatus: 'Succeeded' }); + const { getByText } = render(); + expect(getByText(messages.headingTitle.defaultMessage)).toBeInTheDocument(); + fireEvent.click(getByText(messages.buttonTitle.defaultMessage)); + await waitFor(() => { + expect(getByText(scanResultsMessages.noBrokenLinksCard.defaultMessage)).toBeInTheDocument(); + }); + }); + + it('should show error message if request does not go through', async () => { + axiosMock + .onPost(postLinkCheckCourseApiUrl(courseId)) + .reply(500); + render(); + expect(screen.getByText(messages.headingTitle.defaultMessage)).toBeInTheDocument(); + fireEvent.click(screen.getByText(messages.buttonTitle.defaultMessage)); + await waitFor(() => { + expect(screen.getByText(generalMessages.supportText.defaultMessage)).toBeInTheDocument(); + }); + }); + }); +}); diff --git a/src/optimizer-page/CourseOptimizerPage.tsx b/src/optimizer-page/CourseOptimizerPage.tsx new file mode 100644 index 0000000000..f760e45fcf --- /dev/null +++ b/src/optimizer-page/CourseOptimizerPage.tsx @@ -0,0 +1,176 @@ +/* eslint-disable no-param-reassign */ +import { + useEffect, useRef, FC, MutableRefObject, +} from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { useIntl } from '@edx/frontend-platform/i18n'; +import { + 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'; +import ConnectionErrorAlert from '../generic/ConnectionErrorAlert'; +import SubHeader from '../generic/sub-header/SubHeader'; +import { RequestFailureStatuses } from '../data/constants'; +import messages from './messages'; +import { + getCurrentStage, getError, getLinkCheckInProgress, getLoadingStatus, getSavingStatus, getLinkCheckResult, + getLastScannedAt, +} from './data/selectors'; +import { startLinkCheck, fetchLinkCheckStatus } from './data/thunks'; +import { useModel } from '../generic/model-store'; +import ScanResults from './scan-results'; + +const pollLinkCheckStatus = (dispatch: any, courseId: string, delay: number): number => { + const interval = setInterval(() => { + dispatch(fetchLinkCheckStatus(courseId)); + }, delay); + return interval as unknown as number; +}; + +export function pollLinkCheckDuringScan( + linkCheckInProgress: boolean | null, + interval: MutableRefObject, + dispatch: any, + courseId: string, +) { + if (linkCheckInProgress === null || linkCheckInProgress) { + clearInterval(interval.current as number | undefined); + interval.current = pollLinkCheckStatus(dispatch, courseId, 2000); + } else if (interval.current) { + clearInterval(interval.current); + interval.current = undefined; + } +} + +const CourseOptimizerPage: FC<{ courseId: string }> = ({ courseId }) => { + const dispatch = useDispatch(); + const linkCheckInProgress = useSelector(getLinkCheckInProgress); + const loadingStatus = useSelector(getLoadingStatus); + const savingStatus = useSelector(getSavingStatus); + const currentStage = useSelector(getCurrentStage); + const linkCheckResult = useSelector(getLinkCheckResult); + const lastScannedAt = useSelector(getLastScannedAt); + const { msg: errorMessage } = useSelector(getError); + const isShowExportButton = !linkCheckInProgress || errorMessage; + const isLoadingDenied = (RequestFailureStatuses as string[]).includes(loadingStatus); + const isSavingDenied = (RequestFailureStatuses as string[]).includes(savingStatus); + const interval = useRef(undefined); + const courseDetails = useModel('courseDetails', courseId); + const linkCheckPresent = !!currentStage; + const intl = useIntl(); + + const courseStepperSteps = [ + { + title: intl.formatMessage(messages.preparingStepTitle), + description: intl.formatMessage(messages.preparingStepDescription), + key: 'course-step-preparing', + }, + { + title: intl.formatMessage(messages.scanningStepTitle), + description: intl.formatMessage(messages.scanningStepDescription), + key: 'course-step-scanning', + }, + { + title: intl.formatMessage(messages.successStepTitle), + description: intl.formatMessage(messages.successStepDescription), + key: 'course-step-success', + }, + ]; + + useEffect(() => { + // when first entering the page, fetch any existing scan results + dispatch(fetchLinkCheckStatus(courseId)); + }, []); + + useEffect(() => { + // when a scan starts, start polling for the results as long as the scan status fetched + // signals it is still in progress + pollLinkCheckDuringScan(linkCheckInProgress, interval, dispatch, courseId); + + return () => { + if (interval.current) { clearInterval(interval.current); } + }; + }, [linkCheckInProgress, linkCheckResult]); + + if (isLoadingDenied || isSavingDenied) { + if (interval.current) { clearInterval(interval.current); } + + return ( + // + + // + ); + } + + return ( + <> + + + {intl.formatMessage(messages.pageTitle, { + headingTitle: intl.formatMessage(messages.headingTitle), + courseName: courseDetails?.name, + siteName: process.env.SITE_NAME, + })} + + + +
+ + +
+ +

{intl.formatMessage(messages.description1)}

+

{intl.formatMessage(messages.description2)}

+ + + {isShowExportButton && ( + + + + )} + {linkCheckPresent && ( + + + + )} + + {linkCheckPresent && } +
+
+
+
+
+ + ); +}; + +export default CourseOptimizerPage; diff --git a/src/optimizer-page/data/api.test.js b/src/optimizer-page/data/api.test.js new file mode 100644 index 0000000000..155dc2d0b4 --- /dev/null +++ b/src/optimizer-page/data/api.test.js @@ -0,0 +1,34 @@ +import mockApiResponse from '../mocks/mockApiResponse'; +import { initializeMocks } from '../../testUtils'; +import * as api from './api'; +import { LINK_CHECK_STATUSES } from './constants'; + +describe('Course Optimizer API', () => { + describe('postLinkCheck', () => { + it('should get an affirmative response on starting a scan', async () => { + const { axiosMock } = initializeMocks(); + const courseId = 'course-123'; + const url = api.postLinkCheckCourseApiUrl(courseId); + axiosMock.onPost(url).reply(200, { LinkCheckStatus: LINK_CHECK_STATUSES.IN_PROGRESS }); + const data = await api.postLinkCheck(courseId); + + expect(data.linkCheckStatus).toEqual(LINK_CHECK_STATUSES.IN_PROGRESS); + expect(axiosMock.history.post[0].url).toEqual(url); + }); + }); + + describe('getLinkCheckStatus', () => { + it('should get the status of a scan', async () => { + const { axiosMock } = initializeMocks(); + const courseId = 'course-123'; + const url = api.getLinkCheckStatusApiUrl(courseId); + axiosMock.onGet(url).reply(200, mockApiResponse); + const data = await api.getLinkCheckStatus(courseId); + + expect(data.linkCheckOutput).toEqual(mockApiResponse.LinkCheckOutput); + expect(data.linkCheckStatus).toEqual(mockApiResponse.LinkCheckStatus); + expect(data.linkCheckCreatedAt).toEqual(mockApiResponse.LinkCheckCreatedAt); + expect(axiosMock.history.get[0].url).toEqual(url); + }); + }); +}); diff --git a/src/optimizer-page/data/api.ts b/src/optimizer-page/data/api.ts new file mode 100644 index 0000000000..af88da3c50 --- /dev/null +++ b/src/optimizer-page/data/api.ts @@ -0,0 +1,26 @@ +import { camelCaseObject, getConfig } from '@edx/frontend-platform'; +import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; +import { LinkCheckResult } from '../types'; +import { LinkCheckStatusTypes } from './constants'; + +export interface LinkCheckStatusApiResponseBody { + linkCheckStatus: LinkCheckStatusTypes; + linkCheckOutput: LinkCheckResult; + linkCheckCreatedAt: string; +} + +const getApiBaseUrl = () => getConfig().STUDIO_BASE_URL; +export const postLinkCheckCourseApiUrl = (courseId) => new URL(`api/contentstore/v0/link_check/${courseId}`, getApiBaseUrl()).href; +export const getLinkCheckStatusApiUrl = (courseId) => new URL(`api/contentstore/v0/link_check_status/${courseId}`, getApiBaseUrl()).href; + +export async function postLinkCheck(courseId: string): Promise<{ linkCheckStatus: LinkCheckStatusTypes }> { + const { data } = await getAuthenticatedHttpClient() + .post(postLinkCheckCourseApiUrl(courseId)); + return camelCaseObject(data); +} + +export async function getLinkCheckStatus(courseId: string): Promise { + const { data } = await getAuthenticatedHttpClient() + .get(getLinkCheckStatusApiUrl(courseId)); + return camelCaseObject(data); +} diff --git a/src/optimizer-page/data/constants.ts b/src/optimizer-page/data/constants.ts new file mode 100644 index 0000000000..0ad3006d10 --- /dev/null +++ b/src/optimizer-page/data/constants.ts @@ -0,0 +1,40 @@ +export const LAST_EXPORT_COOKIE_NAME = 'lastexport'; +export const LINK_CHECK_STATUSES = { + UNINITIATED: 'Uninitiated', + PENDING: 'Pending', + IN_PROGRESS: 'In-Progress', + SUCCEEDED: 'Succeeded', + FAILED: 'Failed', + CANCELED: 'Canceled', + RETRYING: 'Retrying', +}; +export enum LinkCheckStatusTypes { + UNINITIATED = 'Uninitiated', + PENDING = 'Pending', + IN_PROGRESS = 'In-Progress', + SUCCEEDED = 'Succeeded', + FAILED = 'Failed', + CANCELED = 'Canceled', + RETRYING = 'Retrying', +} +export const SCAN_STAGES = { + [LINK_CHECK_STATUSES.UNINITIATED]: 0, + [LINK_CHECK_STATUSES.PENDING]: 1, + [LINK_CHECK_STATUSES.IN_PROGRESS]: 1, + [LINK_CHECK_STATUSES.RETRYING]: 1, + [LINK_CHECK_STATUSES.SUCCEEDED]: 2, + [LINK_CHECK_STATUSES.FAILED]: -1, + [LINK_CHECK_STATUSES.CANCELED]: -1, +}; + +export const LINK_CHECK_IN_PROGRESS_STATUSES = [ + LINK_CHECK_STATUSES.PENDING, + LINK_CHECK_STATUSES.IN_PROGRESS, + LINK_CHECK_STATUSES.RETRYING, +]; + +export const LINK_CHECK_FAILURE_STATUSES = [ + LINK_CHECK_STATUSES.FAILED, + LINK_CHECK_STATUSES.CANCELED, +]; +export const SUCCESS_DATE_FORMAT = 'MM/DD/yyyy'; diff --git a/src/optimizer-page/data/selectors.ts b/src/optimizer-page/data/selectors.ts new file mode 100644 index 0000000000..7454157c5f --- /dev/null +++ b/src/optimizer-page/data/selectors.ts @@ -0,0 +1,12 @@ +import { RootState } from './slice'; + +export const getLinkCheckInProgress = (state: RootState) => state.courseOptimizer.linkCheckInProgress; +export const getCurrentStage = (state: RootState) => state.courseOptimizer.currentStage; +export const getDownloadPath = (state: RootState) => state.courseOptimizer.downloadPath; +export const getSuccessDate = (state: RootState) => state.courseOptimizer.successDate; +export const getError = (state: RootState) => state.courseOptimizer.error; +export const getIsErrorModalOpen = (state: RootState) => state.courseOptimizer.isErrorModalOpen; +export const getLoadingStatus = (state: RootState) => state.courseOptimizer.loadingStatus; +export const getSavingStatus = (state: RootState) => state.courseOptimizer.savingStatus; +export const getLinkCheckResult = (state: RootState) => state.courseOptimizer.linkCheckResult; +export const getLastScannedAt = (state: RootState) => state.courseOptimizer.lastScannedAt; diff --git a/src/optimizer-page/data/slice.test.ts b/src/optimizer-page/data/slice.test.ts new file mode 100644 index 0000000000..14ea76d338 --- /dev/null +++ b/src/optimizer-page/data/slice.test.ts @@ -0,0 +1,111 @@ +import { AnyAction, configureStore, ThunkMiddleware } from '@reduxjs/toolkit'; +import { ToolkitStore } from '@reduxjs/toolkit/dist/configureStore'; +import { + CourseOptimizerState, + reducer, + updateLinkCheckInProgress, + updateLinkCheckResult, + updateLastScannedAt, + updateCurrentStage, + updateDownloadPath, + updateSuccessDate, + updateError, + updateIsErrorModalOpen, + reset, + updateLoadingStatus, + updateSavingStatus, +} from './slice'; + +describe('courseOptimizer slice', () => { + let store: ToolkitStore]>; + + beforeEach(() => { + store = configureStore({ reducer }); + }); + + it('should handle initial state', () => { + expect(store.getState()).toEqual({ + linkCheckInProgress: null, + linkCheckResult: null, + lastScannedAt: null, + currentStage: null, + error: { msg: null, unitUrl: null }, + downloadPath: null, + successDate: null, + isErrorModalOpen: false, + loadingStatus: '', + savingStatus: '', + }); + }); + + it('should handle updateLinkCheckInProgress', () => { + store.dispatch(updateLinkCheckInProgress(true)); + expect(store.getState().linkCheckInProgress).toBe(true); + }); + + it('should handle updateLinkCheckResult', () => { + const result = { valid: true }; + store.dispatch(updateLinkCheckResult(result)); + expect(store.getState().linkCheckResult).toEqual(result); + }); + + it('should handle updateLastScannedAt', () => { + const date = '2023-10-01'; + store.dispatch(updateLastScannedAt(date)); + expect(store.getState().lastScannedAt).toBe(date); + }); + + it('should handle updateCurrentStage', () => { + store.dispatch(updateCurrentStage(2)); + expect(store.getState().currentStage).toBe(2); + }); + + it('should handle updateDownloadPath', () => { + const path = '/path/to/download'; + store.dispatch(updateDownloadPath(path)); + expect(store.getState().downloadPath).toBe(path); + }); + + it('should handle updateSuccessDate', () => { + const date = '2023-10-01'; + store.dispatch(updateSuccessDate(date)); + expect(store.getState().successDate).toBe(date); + }); + + it('should handle updateError', () => { + const error = { msg: 'Error message', unitUrl: 'http://example.com' }; + store.dispatch(updateError(error)); + expect(store.getState().error).toEqual(error); + }); + + it('should handle updateIsErrorModalOpen', () => { + store.dispatch(updateIsErrorModalOpen(true)); + expect(store.getState().isErrorModalOpen).toBe(true); + }); + + it('should handle reset', () => { + store.dispatch(reset()); + expect(store.getState()).toEqual({ + linkCheckInProgress: null, + linkCheckResult: null, + lastScannedAt: null, + currentStage: null, + error: { msg: null, unitUrl: null }, + downloadPath: null, + successDate: null, + isErrorModalOpen: false, + loadingStatus: '', + savingStatus: '', + }); + }); + + it('should handle updateLoadingStatus', () => { + store.dispatch(updateLoadingStatus({ status: 'loading' })); + expect(store.getState().loadingStatus).toBe('loading'); + }); + + it('should handle updateSavingStatus', () => { + store.dispatch(updateSavingStatus({ status: 'saving' })); + expect(store.getState().savingStatus).toBe('saving'); + }); +}); diff --git a/src/optimizer-page/data/slice.ts b/src/optimizer-page/data/slice.ts new file mode 100644 index 0000000000..7b3f81a8a9 --- /dev/null +++ b/src/optimizer-page/data/slice.ts @@ -0,0 +1,91 @@ +/* eslint-disable no-param-reassign */ +import { createSlice } from '@reduxjs/toolkit'; +import { LinkCheckResult } from '../types'; + +export interface CourseOptimizerState { + linkCheckInProgress: boolean | null; + linkCheckResult: LinkCheckResult | null; + lastScannedAt: string | null; + currentStage: number | null; + error: { msg: string | null; unitUrl: string | null }; + downloadPath: string | null; + successDate: string | null; + isErrorModalOpen: boolean; + loadingStatus: string; + savingStatus: string; +} + +export type RootState = { + [key: string]: any; +} & { + courseOptimizer: CourseOptimizerState; +}; + +const initialState: CourseOptimizerState = { + linkCheckInProgress: null, + linkCheckResult: null, + lastScannedAt: null, + currentStage: null, + error: { msg: null, unitUrl: null }, + downloadPath: null, + successDate: null, + isErrorModalOpen: false, + loadingStatus: '', + savingStatus: '', +}; + +const slice = createSlice({ + name: 'courseOptimizer', + initialState, + reducers: { + updateLinkCheckInProgress: (state, { payload }) => { + state.linkCheckInProgress = payload; + }, + updateLinkCheckResult: (state, { payload }) => { + state.linkCheckResult = payload; + }, + updateLastScannedAt: (state, { payload }) => { + state.lastScannedAt = payload; + }, + updateCurrentStage: (state, { payload }) => { + state.currentStage = payload; + }, + updateDownloadPath: (state, { payload }) => { + state.downloadPath = payload; + }, + updateSuccessDate: (state, { payload }) => { + state.successDate = payload; + }, + updateError: (state, { payload }) => { + state.error = payload; + }, + updateIsErrorModalOpen: (state, { payload }) => { + state.isErrorModalOpen = payload; + }, + reset: () => initialState, + updateLoadingStatus: (state, { payload }) => { + state.loadingStatus = payload.status; + }, + updateSavingStatus: (state, { payload }) => { + state.savingStatus = payload.status; + }, + }, +}); + +export const { + updateLinkCheckInProgress, + updateLinkCheckResult, + updateLastScannedAt, + updateCurrentStage, + updateDownloadPath, + updateSuccessDate, + updateError, + updateIsErrorModalOpen, + reset, + updateLoadingStatus, + updateSavingStatus, +} = slice.actions; + +export const { + reducer, +} = slice; diff --git a/src/optimizer-page/data/thunks.test.js b/src/optimizer-page/data/thunks.test.js new file mode 100644 index 0000000000..208b8f4527 --- /dev/null +++ b/src/optimizer-page/data/thunks.test.js @@ -0,0 +1,193 @@ +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'; + +describe('startLinkCheck thunk', () => { + const dispatch = jest.fn(); + const getState = jest.fn(); + const courseId = 'course-123'; + let mockGetStartLinkCheck; + + beforeEach(() => { + jest.clearAllMocks(); + + mockGetStartLinkCheck = jest.spyOn(api, 'postLinkCheck').mockResolvedValue({ + linkCheckStatus: LINK_CHECK_STATUSES.IN_PROGRESS, + }); + }); + + describe('successful request', () => { + it('should set link check stage and request statuses to their in-progress states', async () => { + const inProgressStageId = 1; + await startLinkCheck(courseId)(dispatch, getState); + + expect(dispatch).toHaveBeenCalledWith({ + payload: { status: RequestStatus.PENDING }, + type: 'courseOptimizer/updateSavingStatus', + }); + + expect(dispatch).toHaveBeenCalledWith({ + payload: true, + type: 'courseOptimizer/updateLinkCheckInProgress', + }); + + expect(dispatch).toHaveBeenCalledWith({ + payload: { status: RequestStatus.SUCCESSFUL }, + type: 'courseOptimizer/updateSavingStatus', + }); + + expect(dispatch).toHaveBeenCalledWith({ + payload: inProgressStageId, + type: 'courseOptimizer/updateCurrentStage', + }); + }); + }); + + describe('failed request should set stage and request ', () => { + it('should set request status to failed', async () => { + mockGetStartLinkCheck.mockRejectedValue(new Error('error')); + + await startLinkCheck(courseId)(dispatch, getState); + + expect(dispatch).toHaveBeenCalledWith({ + payload: { status: RequestStatus.FAILED }, + type: 'courseOptimizer/updateSavingStatus', + }); + expect(dispatch).toHaveBeenCalledWith({ + payload: false, + type: 'courseOptimizer/updateLinkCheckInProgress', + }); + expect(dispatch).toHaveBeenCalledWith({ + payload: -1, + type: 'courseOptimizer/updateCurrentStage', + }); + }); + }); +}); + +describe('fetchLinkCheckStatus thunk', () => { + const dispatch = jest.fn(); + const getState = jest.fn(); + const courseId = 'course-123'; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('successful request', () => { + it('should return scan result', async () => { + jest + .spyOn(api, 'getLinkCheckStatus') + .mockResolvedValue({ + linkCheckStatus: mockApiResponse.LinkCheckStatus, + linkCheckOutput: mockApiResponse.LinkCheckOutput, + linkCheckCreatedAt: mockApiResponse.LinkCheckCreatedAt, + }); + + await fetchLinkCheckStatus(courseId)(dispatch, getState); + + expect(dispatch).toHaveBeenCalledWith({ + payload: false, + type: 'courseOptimizer/updateLinkCheckInProgress', + }); + + expect(dispatch).toHaveBeenCalledWith({ + payload: 2, + type: 'courseOptimizer/updateCurrentStage', + }); + + expect(dispatch).toHaveBeenCalledWith({ + payload: mockApiResponse.LinkCheckOutput, + type: 'courseOptimizer/updateLinkCheckResult', + }); + + expect(dispatch).toHaveBeenCalledWith({ + payload: { status: RequestStatus.SUCCESSFUL }, + type: 'courseOptimizer/updateLoadingStatus', + }); + }); + + it('with link check in progress should set current stage to 1', async () => { + jest + .spyOn(api, 'getLinkCheckStatus') + .mockResolvedValue({ + linkCheckStatus: LINK_CHECK_STATUSES.IN_PROGRESS, + }); + + await fetchLinkCheckStatus(courseId)(dispatch, getState); + + expect(dispatch).toHaveBeenCalledWith({ + payload: 1, + type: 'courseOptimizer/updateCurrentStage', + }); + }); + }); + + describe('failed request', () => { + it('should set request status to failed', async () => { + jest + .spyOn(api, 'getLinkCheckStatus') + .mockRejectedValue(new Error('error')); + + await fetchLinkCheckStatus(courseId)(dispatch, getState); + + expect(dispatch).toHaveBeenCalledWith({ + payload: { status: RequestStatus.FAILED }, + type: 'courseOptimizer/updateLoadingStatus', + }); + }); + }); + + describe('unauthorized request', () => { + it('should set request status to denied', async () => { + jest.spyOn(api, 'getLinkCheckStatus').mockRejectedValue({ response: { status: 403 } }); + await fetchLinkCheckStatus(courseId)(dispatch, getState); + + expect(dispatch).toHaveBeenCalledWith({ + payload: { status: RequestStatus.DENIED }, + type: 'courseOptimizer/updateLoadingStatus', + }); + }); + }); + + describe('failed scan', () => { + it('should set error message', async () => { + jest + .spyOn(api, 'getLinkCheckStatus') + .mockResolvedValue({ + linkCheckStatus: LINK_CHECK_STATUSES.FAILED, + linkCheckOutput: mockApiResponse.LinkCheckOutput, + linkCheckCreatedAt: mockApiResponse.LinkCheckCreatedAt, + }); + + await fetchLinkCheckStatus(courseId)(dispatch, getState); + + expect(dispatch).toHaveBeenCalledWith({ + payload: true, + type: 'courseOptimizer/updateIsErrorModalOpen', + }); + + expect(dispatch).toHaveBeenCalledWith({ + payload: { msg: 'Link Check Failed' }, + type: 'courseOptimizer/updateError', + }); + + expect(dispatch).toHaveBeenCalledWith({ + payload: { status: RequestStatus.SUCCESSFUL }, + type: 'courseOptimizer/updateLoadingStatus', + }); + + expect(dispatch).toHaveBeenCalledWith({ + payload: -1, + type: 'courseOptimizer/updateCurrentStage', + }); + + expect(dispatch).not.toHaveBeenCalledWith({ + payload: expect.anything(), + type: 'courseOptimizer/updateLinkCheckResult', + }); + }); + }); +}); diff --git a/src/optimizer-page/data/thunks.ts b/src/optimizer-page/data/thunks.ts new file mode 100644 index 0000000000..1fb3c57207 --- /dev/null +++ b/src/optimizer-page/data/thunks.ts @@ -0,0 +1,81 @@ +import { RequestStatus } from '../../data/constants'; +import { + LINK_CHECK_FAILURE_STATUSES, + LINK_CHECK_IN_PROGRESS_STATUSES, + LINK_CHECK_STATUSES, + SCAN_STAGES, +} from './constants'; + +import { postLinkCheck, getLinkCheckStatus } from './api'; +import { + updateLinkCheckInProgress, + updateLinkCheckResult, + updateCurrentStage, + updateError, + updateIsErrorModalOpen, + updateLoadingStatus, + updateSavingStatus, + updateLastScannedAt, +} from './slice'; + +export function startLinkCheck(courseId: string) { + return async (dispatch) => { + dispatch(updateSavingStatus({ status: RequestStatus.PENDING })); + dispatch(updateLinkCheckInProgress(true)); + dispatch(updateCurrentStage(SCAN_STAGES[LINK_CHECK_STATUSES.PENDING])); + try { + await postLinkCheck(courseId); + await dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL })); + return true; + } catch (error) { + dispatch(updateSavingStatus({ status: RequestStatus.FAILED })); + dispatch(updateLinkCheckInProgress(false)); + dispatch(updateCurrentStage(SCAN_STAGES[LINK_CHECK_STATUSES.CANCELED])); + return false; + } + }; +} + +export function fetchLinkCheckStatus(courseId) { + return async (dispatch) => { + dispatch(updateLoadingStatus({ status: RequestStatus.IN_PROGRESS })); + + try { + const { linkCheckStatus, linkCheckOutput, linkCheckCreatedAt } = await getLinkCheckStatus( + courseId, + ); + + if (LINK_CHECK_IN_PROGRESS_STATUSES.includes(linkCheckStatus)) { + dispatch(updateLinkCheckInProgress(true)); + } else { + dispatch(updateLinkCheckInProgress(false)); + } + + dispatch(updateCurrentStage(SCAN_STAGES[linkCheckStatus])); + + if ( + linkCheckStatus === undefined + || linkCheckStatus === null + || LINK_CHECK_FAILURE_STATUSES.includes(linkCheckStatus) + ) { + dispatch(updateError({ msg: 'Link Check Failed' })); + dispatch(updateIsErrorModalOpen(true)); + } else if (linkCheckOutput) { + dispatch(updateLinkCheckResult(linkCheckOutput)); + dispatch(updateLastScannedAt(linkCheckCreatedAt)); + } + + dispatch(updateLoadingStatus({ status: RequestStatus.SUCCESSFUL })); + return true; + } catch (error: any) { + if (error?.response && error?.response.status === 403) { + dispatch(updateLoadingStatus({ status: RequestStatus.DENIED })); + } else { + dispatch( + updateLoadingStatus({ status: RequestStatus.FAILED }), + ); + } + return false; + } + }; +} diff --git a/src/optimizer-page/messages.js b/src/optimizer-page/messages.js new file mode 100644 index 0000000000..d83108bd40 --- /dev/null +++ b/src/optimizer-page/messages.js @@ -0,0 +1,71 @@ +import { defineMessages } from '@edx/frontend-platform/i18n'; + +const messages = defineMessages({ + pageTitle: { + id: 'course-authoring.course-optimizer.page.title', + defaultMessage: '{headingTitle} | {courseName} | {siteName}', + }, + headingTitle: { + id: 'course-authoring.course-optimizer.heading.title', + 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 your 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.', + }, + 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', + }, + buttonTitle: { + id: 'course-authoring.course-optimizer.button.title', + defaultMessage: 'Start Scanning', + }, + preparingStepTitle: { + id: 'course-authoring.course-optimizer.peparing-step.title', + defaultMessage: 'Preparing', + }, + preparingStepDescription: { + id: 'course-authoring.course-optimizer.peparing-step.description', + defaultMessage: 'Preparing to start the scan', + }, + scanningStepTitle: { + id: 'course-authoring.course-optimizer.scanning-step.title', + defaultMessage: 'Scanning', + }, + scanningStepDescription: { + id: 'course-authoring.course-optimizer.scanning-step.description', + defaultMessage: 'Scanning for broken links in your course (You can now leave this page safely, but avoid making drastic changes to content until the scan is complete)', + }, + successStepTitle: { + id: 'course-authoring.course-optimizer.success-step.title', + defaultMessage: 'Success', + }, + successStepDescription: { + id: 'course-authoring.course-optimizer.success-step.description', + defaultMessage: 'Your Scan is complete. You can view the list of results below.', + }, + lastScannedOn: { + id: 'course-authoring.course-optimizer.last-scanned-on', + defaultMessage: 'Last scanned on', + }, +}); + +export default messages; diff --git a/src/optimizer-page/mocks/mockApiResponse.js b/src/optimizer-page/mocks/mockApiResponse.js new file mode 100644 index 0000000000..dd3b54e399 --- /dev/null +++ b/src/optimizer-page/mocks/mockApiResponse.js @@ -0,0 +1,106 @@ +const mockApiResponse = { + 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: 'Welcome Video', + blocks: [ + { + id: 'block-1-1-1-1', + url: 'https://example.com/welcome-video', + brokenLinks: ['https://example.com/broken-link-algo'], + lockedLinks: ['https://example.com/locked-link-algo'], + }, + { + id: 'block-1-1-1-2', + url: 'https://example.com/intro-guide', + brokenLinks: ['https://example.com/broken-link-algo'], + lockedLinks: ['https://example.com/locked-link-algo'], + }, + ], + }, + { + id: 'unit-1-1-2', + displayName: 'Course Overview', + blocks: [ + { + id: 'block-1-1-2-1', + url: 'https://example.com/course-overview', + brokenLinks: ['https://example.com/broken-link-algo'], + lockedLinks: ['https://example.com/locked-link-algo'], + }, + ], + }, + ], + }, + { + id: 'subsection-1-2', + displayName: 'Basic Concepts', + units: [ + { + id: 'unit-1-2-1', + displayName: 'Variables and Data Types', + blocks: [ + { + id: 'block-1-2-1-1', + url: 'https://example.com/variables', + brokenLinks: ['https://example.com/broken-link-algo'], + lockedLinks: ['https://example.com/locked-link-algo'], + }, + { + id: 'block-1-2-1-2', + url: 'https://example.com/broken-link', + brokenLinks: ['https://example.com/broken-link'], + lockedLinks: ['https://example.com/locked-link-algo'], + }, + ], + }, + ], + }, + ], + }, + { + id: 'section-2', + displayName: 'Advanced Topics', + subsections: [ + { + id: 'subsection-2-1', + displayName: 'Algorithms and Data Structures', + units: [ + { + id: 'unit-2-1-1', + displayName: 'Sorting Algorithms', + blocks: [ + { + id: 'block-2-1-1-1', + url: 'https://example.com/sorting-algorithms', + brokenLinks: ['https://example.com/broken-link-algo'], + lockedLinks: ['https://example.com/locked-link-algo'], + }, + { + id: 'block-2-1-1-2', + url: 'https://example.com/broken-link-algo', + brokenLinks: ['https://example.com/broken-link-algo'], + lockedLinks: ['https://example.com/locked-link-algo'], + }, + ], + }, + ], + }, + ], + }, + ], + }, +}; + +export default mockApiResponse; diff --git a/src/optimizer-page/scan-results/BrokenLinkTable.tsx b/src/optimizer-page/scan-results/BrokenLinkTable.tsx new file mode 100644 index 0000000000..ebd177d12b --- /dev/null +++ b/src/optimizer-page/scan-results/BrokenLinkTable.tsx @@ -0,0 +1,117 @@ +import { Icon, Table } from '@openedx/paragon'; +import { OpenInNew, Lock, LinkOff } from '@openedx/paragon/icons'; +import { useIntl } from '@edx/frontend-platform/i18n'; +import { FC } from 'react'; +import { Unit } from '../types'; +import messages from './messages'; +import LockedInfoIcon from './LockedInfoIcon'; + +const BrokenLinkHref: FC<{ href: string }> = ({ href }) => ( + +); + +const GoToBlock: FC<{ block: { url: string } }> = ({ block }) => ( + + + + Go to Block + + +); + +interface BrokenLinkTableProps { + unit: Unit; + showLockedLinks: boolean; +} + +type TableData = { + blockLink: JSX.Element; + brokenLink: JSX.Element; + status: JSX.Element; +}[]; + +const BrokenLinkTable: FC = ({ + unit, + showLockedLinks, +}) => { + const intl = useIntl(); + return ( + <> +

{unit.displayName}

+ { + const blockBrokenLinks = block.brokenLinks.map((link) => ({ + blockLink: , + blockDisplayName: block.displayName || '', + brokenLink: , + status: ( + + + + {intl.formatMessage(messages.brokenLinkStatus)} + + + ), + })); + acc.push(...blockBrokenLinks); + if (!showLockedLinks) { + return acc; + } + + const blockLockedLinks = block.lockedLinks.map((link) => ({ + blockLink: , + blockDisplayName: block.displayName || '', + brokenLink: , + status: ( + + + {intl.formatMessage(messages.lockedLinkStatus)}{' '} + + + ), + })); + acc.push(...blockLockedLinks); + return acc; + }, + [], + )} + columns={[ + { + key: 'blockDisplayName', + columnSortable: false, + width: 'col-3', + hideHeader: true, + }, + { + key: 'blockLink', + columnSortable: false, + width: 'col-3', + hideHeader: true, + }, + { + key: 'brokenLink', + columnSortable: false, + width: 'col-6', + hideHeader: true, + }, + { + key: 'status', + columnSortable: false, + width: 'col-6', + hideHeader: true, + }, + ]} + /> + + ); +}; + +export default BrokenLinkTable; diff --git a/src/optimizer-page/scan-results/LockedInfoIcon.jsx b/src/optimizer-page/scan-results/LockedInfoIcon.jsx new file mode 100644 index 0000000000..788dcb1301 --- /dev/null +++ b/src/optimizer-page/scan-results/LockedInfoIcon.jsx @@ -0,0 +1,30 @@ +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.scss b/src/optimizer-page/scan-results/ScanResults.scss new file mode 100644 index 0000000000..1918ad7b0a --- /dev/null +++ b/src/optimizer-page/scan-results/ScanResults.scss @@ -0,0 +1,110 @@ +.scan-results { + thead { + display: none; + } + + .red-italics { + color: $brand-500; + margin-left: 2rem; + font-weight: 400; + font-size: 80%; + font-style: italic; + } + + .yellow-italics { + color: $warning-800;; + margin-left: 2rem; + font-weight: 400; + font-size: 80%; + font-style: italic; + } + + .section { + &.is-open { + &:not(:first-child) { + margin-top: 1rem; + } + + margin-bottom: 1rem; + } + } + + .open-arrow { + transform: translate(-10px, 5px); + display: inline-block; + } + + /* Section Header */ + .subsection-header { + font-size: 16px; /* Slightly smaller */ + font-weight: 600; /* Reduced boldness */ + background-color: $dark-100; + padding: 10px; + margin-bottom: 10px; + } + + /* Subsection Header */ + .unit-header { + margin-left: .5rem; + margin-top: 10px; + font-size: 14px; + font-weight: 700; + margin-bottom: 5px; + color: $primary-500; + } + + /* Block Links */ + .broken-link-list li { + margin-bottom: 8px; /* Add breathing room */ + } + + .broken-link-list a { + text-decoration: none; + margin-left: 2rem; + } + + /* Broken Links Highlight */ + .broken-links-count { + color: red; + font-weight: bold; + } + + .unit { + padding: 0 3rem; + } + + .broken-link { + color: $brand-500; + text-decoration: none; + } + + .broken-link-container { + max-width: 18rem; + text-wrap: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .locked-links-checkbox { + margin-top: .45rem; + } + + .locked-links-checkbox-wrapper { + display: flex; + gap: 1rem; + } + + .link-status-text { + display: flex; + align-items: center; + gap: .5rem; + } + + .broken-link-icon { + color: $brand-500; + } + + .lock-icon { + color: $warning-300; + } +} diff --git a/src/optimizer-page/scan-results/ScanResults.tsx b/src/optimizer-page/scan-results/ScanResults.tsx new file mode 100644 index 0000000000..a409b12495 --- /dev/null +++ b/src/optimizer-page/scan-results/ScanResults.tsx @@ -0,0 +1,89 @@ +import { useState, useMemo, FC } from 'react'; +import { + Card, + CheckBox, +} from '@openedx/paragon'; +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'; + +const InfoCard: FC<{ text: string }> = ({ text }) => ( + +

+ {text} +

+
+); + +interface Props { + data: LinkCheckResult | null; +} + +const ScanResults: FC = ({ data }) => { + const intl = useIntl(); + const [showLockedLinks, setShowLockedLinks] = useState(true); + + const { brokenLinksCounts, lockedLinksCounts } = useMemo(() => countBrokenLinks(data), [data?.sections]); + + if (!data?.sections) { + return ; + } + + const { sections } = data; + + return ( +
+
+
+

{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/SectionCollapsible.tsx b/src/optimizer-page/scan-results/SectionCollapsible.tsx new file mode 100644 index 0000000000..077f747cce --- /dev/null +++ b/src/optimizer-page/scan-results/SectionCollapsible.tsx @@ -0,0 +1,53 @@ +import { useState, FC } from 'react'; +import { + Collapsible, + Icon, +} from '@openedx/paragon'; +import { + ArrowRight, + ArrowDropDown, +} from '@openedx/paragon/icons'; + +interface Props { + title: string; + children: React.ReactNode; + redItalics?: string; + yellowItalics?: string; + className?: string; +} + +const SectionCollapsible: FC = ({ + title, children, redItalics = '', yellowItalics = '', className = '', +}) => { + const [isOpen, setIsOpen] = useState(false); + const styling = 'card-lg'; + const collapsibleTitle = ( +
+ + {title} + {redItalics} + {yellowItalics} +
+ ); + + return ( +
+ + {collapsibleTitle} +

+ )} + iconWhenClosed="" + iconWhenOpen="" + open={isOpen} + onToggle={() => setIsOpen(!isOpen)} + > + {children} +
+
+ ); +}; + +export default SectionCollapsible; 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..7b388bfe98 --- /dev/null +++ b/src/optimizer-page/scan-results/messages.js @@ -0,0 +1,46 @@ +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', + }, + lockedLinksNumber: { + id: 'course-authoring.course-optimizer.lockedLinksNumber', + defaultMessage: '{count} locked links', + }, + lockedInfoTooltip: { + id: 'course-authoring.course-optimizer.lockedInfoTooltip', + defaultMessage: 'These course files are "locked", so we cannot verify if the link can access the file.', + }, + 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..831c692819 --- /dev/null +++ b/src/optimizer-page/types.ts @@ -0,0 +1,27 @@ +export interface Unit { + id: string; + displayName: string; + blocks: { + id: string; + displayName?: 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..0af97a1467 --- /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({ brokenLinksCounts: [5, 2], lockedLinksCounts: [5, 2] }); + }); + + it('should return 0 if there are no broken links', () => { + const data = { + sections: [ + { + subsections: [ + { + units: [ + { + blocks: [ + { + brokenLinks: [], + }, + ], + }, + ], + }, + ], + }, + ], + }; + expect(countBrokenLinks(data)).toStrictEqual({ brokenLinksCounts: [0], lockedLinksCounts: [0] }); + }); + + it('should return [] if there is no data', () => { + const data = {}; + expect(countBrokenLinks(data)).toStrictEqual({ brokenLinksCounts: [], lockedLinksCounts: [] }); + }); + + it('should return [] if there are no sections', () => { + const data = { + sections: [], + }; + expect(countBrokenLinks(data)).toStrictEqual({ brokenLinksCounts: [], lockedLinksCounts: [] }); + }); +}); diff --git a/src/optimizer-page/utils.ts b/src/optimizer-page/utils.ts new file mode 100644 index 0000000000..712cf61eb4 --- /dev/null +++ b/src/optimizer-page/utils.ts @@ -0,0 +1,26 @@ +/* eslint-disable import/prefer-default-export */ +import { LinkCheckResult } from './types'; + +export const countBrokenLinks = (data: LinkCheckResult | null): +{ brokenLinksCounts: number[], lockedLinksCounts: number[] } => { + if (!data?.sections) { + return { brokenLinksCounts: [], lockedLinksCounts: [] }; + } + const brokenLinksCounts: number[] = []; + const lockedLinksCounts: number[] = []; + data.sections.forEach((section) => { + let brokenLinks = 0; + let lockedLinks = 0; + section.subsections.forEach((subsection) => { + subsection.units.forEach((unit) => { + unit.blocks.forEach((block) => { + brokenLinks += block.brokenLinks?.length || 0; + lockedLinks += block.lockedLinks?.length || 0; + }); + }); + }); + brokenLinksCounts.push(brokenLinks); + lockedLinksCounts.push(lockedLinks); + }); + return { brokenLinksCounts, lockedLinksCounts }; +}; 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,