diff --git a/src/CourseAuthoringRoutes.jsx b/src/CourseAuthoringRoutes.jsx index 0c9d2a1680..2b21dc9132 100644 --- a/src/CourseAuthoringRoutes.jsx +++ b/src/CourseAuthoringRoutes.jsx @@ -20,6 +20,7 @@ import { CourseUpdates } from './course-updates'; import { CourseUnit } 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/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/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.jsx b/src/optimizer-page/CourseOptimizerPage.jsx new file mode 100644 index 0000000000..9ec212c052 --- /dev/null +++ b/src/optimizer-page/CourseOptimizerPage.jsx @@ -0,0 +1,161 @@ +import { useEffect, useRef } from 'react'; +import PropTypes from 'prop-types'; +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 { RequestStatus } from '../data/constants'; +import messages from './messages'; +import { + getCurrentStage, getError, getLinkCheckInProgress, getLoadingStatus, getLinkCheckResult, +} from './data/selectors'; +import { startLinkCheck, fetchLinkCheckStatus } from './data/thunks'; +import { useModel } from '../generic/model-store'; +import { ScanResults } from './scan-results'; + +const pollLinkCheckStatus = (dispatch, courseId, delay) => { + const interval = setInterval(() => { + dispatch(fetchLinkCheckStatus(courseId)); + }, delay); + return interval; +}; + +const CourseOptimizerPage = ({ courseId }) => { + const dispatch = useDispatch(); + const linkCheckInProgress = useSelector(getLinkCheckInProgress); + const loadingStatus = useSelector(getLoadingStatus); + const currentStage = useSelector(getCurrentStage); + const linkCheckResult = useSelector(getLinkCheckResult); + const { msg: errorMessage } = useSelector(getError); + const isShowExportButton = !linkCheckInProgress || errorMessage; + const isLoadingDenied = loadingStatus === RequestStatus.DENIED; + const interval = useRef(null); + 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', + }, + ]; + + const pollLinkCheckStatusDuringScan = () => { + if (linkCheckInProgress === null || linkCheckInProgress || !linkCheckResult) { + clearInterval(interval.current); + interval.current = pollLinkCheckStatus(dispatch, courseId, 2000); + } else if (interval.current) { + clearInterval(interval.current); + interval.current = null; + } + }; + + useEffect(() => { + dispatch(fetchLinkCheckStatus(courseId)); + }, []); + + useEffect(() => { + pollLinkCheckStatusDuringScan(); + + return () => { + if (interval.current) { clearInterval(interval.current); } + }; + }, [linkCheckInProgress, linkCheckResult]); + + if (isLoadingDenied) { + 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/SectionCollapsible.tsx b/src/optimizer-page/SectionCollapsible.tsx new file mode 100644 index 0000000000..418b29b2f8 --- /dev/null +++ b/src/optimizer-page/SectionCollapsible.tsx @@ -0,0 +1,51 @@ +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; + className: string; +} + +const SectionCollapsible: FC = ({ + title, children, redItalics, className, +}) => { + const [isOpen, setIsOpen] = useState(false); + const styling = 'card-lg'; + const collapsibleTitle = ( +
+ + {title} + {redItalics} +
+ ); + + return ( +
+ + {collapsibleTitle} +

+ )} + iconWhenClosed="" + iconWhenOpen="" + open={isOpen} + onToggle={() => setIsOpen(!isOpen)} + > + {children} +
+
+ ); +}; + +export default SectionCollapsible; diff --git a/src/optimizer-page/data/api.test.js b/src/optimizer-page/data/api.test.js new file mode 100644 index 0000000000..70acc8b243 --- /dev/null +++ b/src/optimizer-page/data/api.test.js @@ -0,0 +1,47 @@ +import MockAdapter from 'axios-mock-adapter'; +import { initializeMockApp, getConfig } from '@edx/frontend-platform'; +import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; + +import { getExportStatus, postExportCourseApiUrl, startCourseExporting } from './api'; + +let axiosMock; +const courseId = 'course-123'; + +describe('API Functions', () => { + beforeEach(() => { + initializeMockApp({ + authenticatedUser: { + userId: 3, + username: 'abc123', + administrator: true, + roles: [], + }, + }); + axiosMock = new MockAdapter(getAuthenticatedHttpClient()); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should fetch status on start exporting', async () => { + const data = { exportStatus: 1 }; + axiosMock.onPost(postExportCourseApiUrl(courseId)).reply(200, data); + + const result = await startCourseExporting(courseId); + + expect(axiosMock.history.post[0].url).toEqual(postExportCourseApiUrl(courseId)); + expect(result).toEqual(data); + }); + + it('should fetch on get export status', async () => { + const data = { exportStatus: 2 }; + const queryUrl = new URL(`export_status/${courseId}`, getConfig().STUDIO_BASE_URL).href; + axiosMock.onGet(queryUrl).reply(200, data); + + const result = await getExportStatus(courseId); + + expect(axiosMock.history.get[0].url).toEqual(queryUrl); + expect(result).toEqual(data); + }); +}); diff --git a/src/optimizer-page/data/api.ts b/src/optimizer-page/data/api.ts new file mode 100644 index 0000000000..3d7d89e2c6 --- /dev/null +++ b/src/optimizer-page/data/api.ts @@ -0,0 +1,25 @@ +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; +} + +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..cef0f0babf --- /dev/null +++ b/src/optimizer-page/data/selectors.ts @@ -0,0 +1,11 @@ +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; diff --git a/src/optimizer-page/data/slice.ts b/src/optimizer-page/data/slice.ts new file mode 100644 index 0000000000..e38b3d0262 --- /dev/null +++ b/src/optimizer-page/data/slice.ts @@ -0,0 +1,85 @@ +/* eslint-disable no-param-reassign */ +import { createSlice } from '@reduxjs/toolkit'; +import { LinkCheckResult } from '../types'; + +export interface CourseOptimizerState { + linkCheckInProgress: boolean | null; + linkCheckResult: LinkCheckResult | 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, + 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; + }, + 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, + 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..e8dd9762f3 --- /dev/null +++ b/src/optimizer-page/data/thunks.test.js @@ -0,0 +1,146 @@ +import Cookies from 'universal-cookie'; +import { fetchExportStatus } from './thunks'; +import * as api from './api'; +import { EXPORT_STAGES } from './constants'; + +jest.mock('universal-cookie', () => jest.fn().mockImplementation(() => ({ + get: jest.fn().mockImplementation(() => ({ completed: false })), +}))); + +jest.mock('../utils', () => ({ + setExportCookie: jest.fn(), +})); + +describe('fetchExportStatus thunk', () => { + const dispatch = jest.fn(); + const getState = jest.fn(); + const courseId = 'course-123'; + const exportStatus = EXPORT_STAGES.COMPRESSING; + const exportOutput = 'export output'; + const exportError = 'export error'; + let mockGetExportStatus; + + beforeEach(() => { + jest.clearAllMocks(); + + mockGetExportStatus = jest.spyOn(api, 'getExportStatus').mockResolvedValue({ + exportStatus, + exportOutput, + exportError, + }); + }); + + it('should dispatch updateCurrentStage with export status', async () => { + mockGetExportStatus.mockResolvedValue({ + exportStatus, + exportOutput, + exportError, + }); + + await fetchExportStatus(courseId)(dispatch, getState); + + expect(dispatch).toHaveBeenCalledWith({ + payload: exportStatus, + type: 'exportPage/updateCurrentStage', + }); + }); + + it('should dispatch updateError on export error', async () => { + mockGetExportStatus.mockResolvedValue({ + exportStatus, + exportOutput, + exportError, + }); + + await fetchExportStatus(courseId)(dispatch, getState); + + expect(dispatch).toHaveBeenCalledWith({ + payload: { + msg: exportError, + unitUrl: null, + }, + type: 'exportPage/updateError', + }); + }); + + it('should dispatch updateIsErrorModalOpen with true if export error', async () => { + mockGetExportStatus.mockResolvedValue({ + exportStatus, + exportOutput, + exportError, + }); + + await fetchExportStatus(courseId)(dispatch, getState); + + expect(dispatch).toHaveBeenCalledWith({ + payload: true, + type: 'exportPage/updateIsErrorModalOpen', + }); + }); + + it('should not dispatch updateIsErrorModalOpen if no export error', async () => { + mockGetExportStatus.mockResolvedValue({ + exportStatus, + exportOutput, + exportError: null, + }); + + await fetchExportStatus(courseId)(dispatch, getState); + + expect(dispatch).not.toHaveBeenCalledWith({ + payload: false, + type: 'exportPage/updateIsErrorModalOpen', + }); + }); + + it("should dispatch updateDownloadPath if there's export output", async () => { + mockGetExportStatus.mockResolvedValue({ + exportStatus, + exportOutput, + exportError, + }); + + await fetchExportStatus(courseId)(dispatch, getState); + + expect(dispatch).toHaveBeenCalledWith({ + payload: exportOutput, + type: 'exportPage/updateDownloadPath', + }); + }); + + it('should dispatch updateSuccessDate with current date if export status is success', async () => { + mockGetExportStatus.mockResolvedValue({ + exportStatus: + EXPORT_STAGES.SUCCESS, + exportOutput, + exportError, + }); + + await fetchExportStatus(courseId)(dispatch, getState); + + expect(dispatch).toHaveBeenCalledWith({ + payload: expect.any(Number), + type: 'exportPage/updateSuccessDate', + }); + }); + + it('should not dispatch updateSuccessDate with current date if last-export cookie is already set', async () => { + mockGetExportStatus.mockResolvedValue({ + exportStatus: + EXPORT_STAGES.SUCCESS, + exportOutput, + exportError, + }); + + Cookies.mockImplementation(() => ({ + get: jest.fn().mockReturnValueOnce({ completed: true }), + })); + + await fetchExportStatus(courseId)(dispatch, getState); + + expect(dispatch).not.toHaveBeenCalledWith({ + payload: expect.any, + type: 'exportPage/updateSuccessDate', + }); + }); +}); diff --git a/src/optimizer-page/data/thunks.ts b/src/optimizer-page/data/thunks.ts new file mode 100644 index 0000000000..ebeb9eaf1a --- /dev/null +++ b/src/optimizer-page/data/thunks.ts @@ -0,0 +1,89 @@ +import { RequestStatus } from '../../data/constants'; +import { + LINK_CHECK_FAILURE_STATUSES, + LINK_CHECK_IN_PROGRESS_STATUSES, + SCAN_STAGES, +} from './constants'; + +import { postLinkCheck, getLinkCheckStatus } from './api'; +import { + updateLinkCheckInProgress, + updateLinkCheckResult, + updateCurrentStage, + updateError, + updateIsErrorModalOpen, + updateLoadingStatus, + updateSavingStatus, +} from './slice'; + +export function startLinkCheck(courseId: string) { + return async (dispatch) => { + dispatch(updateSavingStatus({ status: RequestStatus.PENDING })); + dispatch(updateLinkCheckInProgress(true)); + dispatch(updateCurrentStage(1)); + try { + // dispatch(reset()); + const data = await postLinkCheck(courseId); + dispatch(updateCurrentStage(data.linkCheckStatus)); + // setExportCookie(moment().valueOf(), exportData.exportStatus === EXPORT_STAGES.SUCCESS); + + dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL })); + return true; + } catch (error) { + dispatch(updateSavingStatus({ status: RequestStatus.FAILED })); + dispatch(updateLinkCheckInProgress(false)); + return false; + } + }; +} + +// TODO: use new statuses +export function fetchLinkCheckStatus(courseId) { + return async (dispatch) => { + dispatch(updateLoadingStatus({ status: RequestStatus.IN_PROGRESS })); + + /* ****** Debugging ******** */ + // dispatch(updateLinkCheckInProgress(true)); + // dispatch(updateCurrentStage(3)); + // return true; + + try { + const { linkCheckStatus, linkCheckOutput } = await getLinkCheckStatus( + courseId, + ); + console.log('linkCheckOutput: ', linkCheckOutput); + 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)); + } + + if (linkCheckOutput) { + dispatch(updateLinkCheckResult(linkCheckOutput)); + } + + 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({ courseId, 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..f708c489c3 --- /dev/null +++ b/src/optimizer-page/messages.js @@ -0,0 +1,62 @@ +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 course for broken links. Note that this process will take more time for larger courses.', + }, + 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.', + }, +}); + +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..95826650f7 --- /dev/null +++ b/src/optimizer-page/mocks/mockApiResponse.js @@ -0,0 +1,105 @@ +const mockApiResponse = { + linkCheckStatus: 200, + 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.jsx b/src/optimizer-page/scan-results/BrokenLinkTable.jsx new file mode 100644 index 0000000000..e36a7be302 --- /dev/null +++ b/src/optimizer-page/scan-results/BrokenLinkTable.jsx @@ -0,0 +1,108 @@ +import { useState, useCallback } from 'react'; +import { + Card, + Icon, + Table, + CheckBox, + OverlayTrigger, + Tooltip, +} from '@openedx/paragon'; +import { + OpenInNew, + Question, + Lock, + LinkOff, +} from '@openedx/paragon/icons'; +import { useIntl } from '@edx/frontend-platform/i18n'; +import messages from './messages'; +import SectionCollapsible from '../SectionCollapsible'; +import LockedInfoIcon from './LockedInfoIcon'; + +const BrokenLinkHref = ({ href }) => ( + +); + +const GoToBlock = ({ block }) => ( + + + + Go to Block + + +); + +const BrokenLinkTable = ({ unit, showLockedLinks }) => { + const intl = useIntl(); + return ( + <> +

{unit.displayName}

+ { + const blockBrokenLinks = block.brokenLinks.map( + (link) => ({ + blockLink: , + brokenLink: , + status: ( + + + {intl.formatMessage(messages.brokenLinkStatus)} + + ), + }), + ); + acc.push(...blockBrokenLinks); + if (!showLockedLinks) { + return acc; + } + + const blockLockedLinks = block.lockedLinks.map( + (link) => ({ + blockLink: , + brokenLink: , + status: ( + + + {intl.formatMessage(messages.lockedLinkStatus)} + + ), + }), + ); + acc.push(...blockLockedLinks); + return acc; + }, [])} + columns={[ + { + key: 'blockLink', + columnSortable: true, + onSort: () => {}, + width: 'col-3', + hideHeader: true, + }, + { + key: 'brokenLink', + columnSortable: false, + onSort: () => {}, + width: 'col-6', + hideHeader: true, + }, + { + key: 'status', + columnSortable: false, + onSort: () => {}, + 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.jsx b/src/optimizer-page/scan-results/ScanResults.jsx new file mode 100644 index 0000000000..0632737935 --- /dev/null +++ b/src/optimizer-page/scan-results/ScanResults.jsx @@ -0,0 +1,104 @@ +import { useState, useMemo } 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'; + +const InfoCard = ({ text }) => ( + +

+ {text} +

+
+); + +export const ScanResults = ({ data }) => { + const intl = useIntl(); + const [showLockedLinks, setShowLockedLinks] = useState(true); + + const brokenLinkCounts = useMemo(() => { + if (!data?.sections) { + return []; + } + const counts = []; + data.sections.forEach((section) => { + let count = 0; + section.subsections.forEach((subsection) => { + subsection.units.forEach((unit) => { + unit.blocks.forEach((block) => { + count += block.brokenLinks.length; + }); + }); + }); + counts.push(count); + }); + return counts; + }, [data?.sections]); + + if (!data) { + return ; + } + if (!data.sections) { + return ; + } + + const { sections } = data; + console.log('data: ', data); + console.log('sections: ', sections); + + 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/ScanResults.scss b/src/optimizer-page/scan-results/ScanResults.scss new file mode 100644 index 0000000000..6a3e947657 --- /dev/null +++ b/src/optimizer-page/scan-results/ScanResults.scss @@ -0,0 +1,107 @@ +.scan-results { + thead { + display: none; + } + + .red-italics { + color: $brand-500; + 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: rgb(248, 247, 246); /* Subtle gray background */ + padding: 10px; + margin-bottom: 10px; + } + + /* Subsection Header */ + .unit-header { + font-size: 16px; /* Slightly smaller than Section */ + font-weight: 500; + margin-left: .5rem; + margin-top: 10px; + color: #555; + } + + /* Unit Header */ + .unit-header { + font-size: 14px; + font-weight: 700; + margin-bottom: 5px; + } + + /* 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: 0.45rem; + } + + .locked-links-checkbox-wrapper { + display: flex; + gap: 1rem; + } + + .link-status-text { + display: flex; + align-items: center; + gap: 0.5rem; + } + + .broken-link-icon { + color: $brand-500; + } + + .lock-icon { + color: $warning-300; + } +} diff --git a/src/optimizer-page/scan-results/index.js b/src/optimizer-page/scan-results/index.js new file mode 100644 index 0000000000..6edc85d9ea --- /dev/null +++ b/src/optimizer-page/scan-results/index.js @@ -0,0 +1,4 @@ +import ScanResults from './ScanResults'; + +// eslint-disable-next-line import/prefer-default-export +export { 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..9e29a83cf2 --- /dev/null +++ b/src/optimizer-page/scan-results/messages.js @@ -0,0 +1,42 @@ +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', + }, + lockedInfoTooltip: { + id: 'course-authoring.course-optimizer.lockedInfoTooltip', + defaultMessage: 'These course files are "locked", so we cannot test whether they work or not.', + }, + 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..f6e2a1a456 --- /dev/null +++ b/src/optimizer-page/types.ts @@ -0,0 +1,26 @@ +interface Unit { + id: string; + displayName: string; + blocks: { + id: string; + url: string; + brokenLinks: string[]; + lockedLinks: string[]; + }[]; +} + +interface SubSection { + id: string; + displayName: string; + units: Unit[]; +} + +interface Section { + id: string; + displayName: string; + subsections: SubSection[]; +} + +export interface LinkCheckResult { + sections: Section[]; +} 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,