From 24589a62934f1c81837750973162b361bb900c5e Mon Sep 17 00:00:00 2001 From: Jesper Hodge Date: Wed, 20 Nov 2024 17:05:21 -0500 Subject: [PATCH 01/27] feat: add course optimizer page --- src/CourseAuthoringRoutes.jsx | 5 + src/header/hooks.js | 4 + src/header/messages.js | 5 + src/optimizer-page/CourseOptimizerPage.jsx | 106 +++++++++++++++++++++ 4 files changed, 120 insertions(+) create mode 100644 src/optimizer-page/CourseOptimizerPage.jsx 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..74c98a7576 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']), }, + { + 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/optimizer-page/CourseOptimizerPage.jsx b/src/optimizer-page/CourseOptimizerPage.jsx new file mode 100644 index 0000000000..d226822c1f --- /dev/null +++ b/src/optimizer-page/CourseOptimizerPage.jsx @@ -0,0 +1,106 @@ +import React, { useEffect } from 'react'; +import PropTypes from 'prop-types'; +import { useDispatch, useSelector } from 'react-redux'; +import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; +import { + Container, Layout, Button, Card, +} from '@openedx/paragon'; +import { ArrowCircleDown as ArrowCircleDownIcon } from '@openedx/paragon/icons'; +import Cookies from 'universal-cookie'; +import { getConfig } from '@edx/frontend-platform'; +import { Helmet } from 'react-helmet'; + +import InternetConnectionAlert from '../generic/internet-connection-alert'; +import ConnectionErrorAlert from '../generic/ConnectionErrorAlert'; +import SubHeader from '../generic/sub-header/SubHeader'; +import { RequestStatus } from '../data/constants'; +import { useModel } from '../generic/model-store'; +// import messages from './messages'; +// import ExportSidebar from './export-sidebar/ExportSidebar'; +// import { +// getCurrentStage, getError, getExportTriggered, getLoadingStatus, getSavingStatus, +// } from './data/selectors'; +// import { startExportingCourse } from './data/thunks'; +// import { EXPORT_STAGES, LAST_EXPORT_COOKIE_NAME } from './data/constants'; +// import { updateExportTriggered, updateSavingStatus, updateSuccessDate } from './data/slice'; +// import ExportModalError from './export-modal-error/ExportModalError'; +// import ExportFooter from './export-footer/ExportFooter'; +// import ExportStepper from './export-stepper/ExportStepper'; + +const CourseOptimizerPage = ({ intl, courseId }) => { + const dispatch = useDispatch(); + // const exportTriggered = useSelector(getExportTriggered); + const courseDetails = useModel('courseDetails', courseId); + // const currentStage = useSelector(getCurrentStage); + // const { msg: errorMessage } = useSelector(getError); + // const loadingStatus = useSelector(getLoadingStatus); + // const savingStatus = useSelector(getSavingStatus); + // const cookies = new Cookies(); + // const isShowExportButton = !exportTriggered || errorMessage || currentStage === EXPORT_STAGES.SUCCESS; + // const anyRequestFailed = savingStatus === RequestStatus.FAILED || loadingStatus === RequestStatus.FAILED; + // const isLoadingDenied = loadingStatus === RequestStatus.DENIED; + // const anyRequestInProgress = savingStatus === RequestStatus.PENDING || loadingStatus === RequestStatus.IN_PROGRESS; + + // useEffect(() => { + // const cookieData = cookies.get(LAST_EXPORT_COOKIE_NAME); + // if (cookieData) { + // dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL })); + // dispatch(updateExportTriggered(true)); + // dispatch(updateSuccessDate(cookieData.date)); + // } + // }, []); + + // if (isLoadingDenied) { + // return ( + // + // + // + // ); + // } + + return ( + <> + + + Title + + + +
+ + +
+ +

Small

+

Description

+ + + +
+
+
+
+
+ + ); +}; + +CourseOptimizerPage.propTypes = { + intl: intlShape.isRequired, + courseId: PropTypes.string.isRequired, +}; + +CourseOptimizerPage.defaultProps = {}; +export default injectIntl(CourseOptimizerPage); From d1f414519ad50c622f63c0165fa618c8c88b4289 Mon Sep 17 00:00:00 2001 From: Jesper Hodge Date: Thu, 21 Nov 2024 11:31:16 -0500 Subject: [PATCH 02/27] feat: make course optimizer nav menu dependent on waffle flag --- src/header/hooks.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/header/hooks.js b/src/header/hooks.js index 74c98a7576..d1e5e3a449 100644 --- a/src/header/hooks.js +++ b/src/header/hooks.js @@ -103,10 +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; }; From 29e871ed04e15e1f5b552e8d3487bd0c33ad98e2 Mon Sep 17 00:00:00 2001 From: Jesper Hodge Date: Fri, 22 Nov 2024 16:27:40 -0500 Subject: [PATCH 03/27] feat: add link check button --- src/optimizer-page/CourseOptimizerPage.jsx | 34 +++-- src/optimizer-page/data/api.js | 18 +++ src/optimizer-page/data/api.test.js | 47 +++++++ src/optimizer-page/data/constants.js | 8 ++ src/optimizer-page/data/selectors.js | 8 ++ src/optimizer-page/data/slice.js | 63 +++++++++ src/optimizer-page/data/thunks.js | 100 ++++++++++++++ src/optimizer-page/data/thunks.test.js | 146 +++++++++++++++++++++ src/store.js | 2 + 9 files changed, 416 insertions(+), 10 deletions(-) create mode 100644 src/optimizer-page/data/api.js create mode 100644 src/optimizer-page/data/api.test.js create mode 100644 src/optimizer-page/data/constants.js create mode 100644 src/optimizer-page/data/selectors.js create mode 100644 src/optimizer-page/data/slice.js create mode 100644 src/optimizer-page/data/thunks.js create mode 100644 src/optimizer-page/data/thunks.test.js diff --git a/src/optimizer-page/CourseOptimizerPage.jsx b/src/optimizer-page/CourseOptimizerPage.jsx index d226822c1f..a84d37248b 100644 --- a/src/optimizer-page/CourseOptimizerPage.jsx +++ b/src/optimizer-page/CourseOptimizerPage.jsx @@ -17,26 +17,26 @@ import { RequestStatus } from '../data/constants'; import { useModel } from '../generic/model-store'; // import messages from './messages'; // import ExportSidebar from './export-sidebar/ExportSidebar'; -// import { -// getCurrentStage, getError, getExportTriggered, getLoadingStatus, getSavingStatus, -// } from './data/selectors'; -// import { startExportingCourse } from './data/thunks'; -// import { EXPORT_STAGES, LAST_EXPORT_COOKIE_NAME } from './data/constants'; -// import { updateExportTriggered, updateSavingStatus, updateSuccessDate } from './data/slice'; +import { + getCurrentStage, getError, getLinkCheckTriggered, getLoadingStatus, getSavingStatus, +} from './data/selectors'; +import { startLinkCheck } from './data/thunks'; +import { EXPORT_STAGES, LAST_EXPORT_COOKIE_NAME } from './data/constants'; +import { updateLinkCheckTriggered, updateSavingStatus, updateSuccessDate } from './data/slice'; // import ExportModalError from './export-modal-error/ExportModalError'; // import ExportFooter from './export-footer/ExportFooter'; // import ExportStepper from './export-stepper/ExportStepper'; const CourseOptimizerPage = ({ intl, courseId }) => { const dispatch = useDispatch(); - // const exportTriggered = useSelector(getExportTriggered); + const exportTriggered = useSelector(getLinkCheckTriggered); const courseDetails = useModel('courseDetails', courseId); - // const currentStage = useSelector(getCurrentStage); - // const { msg: errorMessage } = useSelector(getError); + const currentStage = useSelector(getCurrentStage); + const { msg: errorMessage } = useSelector(getError); // const loadingStatus = useSelector(getLoadingStatus); // const savingStatus = useSelector(getSavingStatus); // const cookies = new Cookies(); - // const isShowExportButton = !exportTriggered || errorMessage || currentStage === EXPORT_STAGES.SUCCESS; + const isShowExportButton = !exportTriggered || errorMessage || currentStage === EXPORT_STAGES.SUCCESS; // const anyRequestFailed = savingStatus === RequestStatus.FAILED || loadingStatus === RequestStatus.FAILED; // const isLoadingDenied = loadingStatus === RequestStatus.DENIED; // const anyRequestInProgress = savingStatus === RequestStatus.PENDING || loadingStatus === RequestStatus.IN_PROGRESS; @@ -87,6 +87,20 @@ const CourseOptimizerPage = ({ intl, courseId }) => { className="h3 px-3 text-black mb-4" title="title" /> + {isShowExportButton && ( + + + + )} +

Current stage: {currentStage}

diff --git a/src/optimizer-page/data/api.js b/src/optimizer-page/data/api.js new file mode 100644 index 0000000000..cf6530ae9b --- /dev/null +++ b/src/optimizer-page/data/api.js @@ -0,0 +1,18 @@ +import { camelCaseObject, getConfig } from '@edx/frontend-platform'; +import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; + +const getApiBaseUrl = () => getConfig().STUDIO_BASE_URL; +export const postLinkCheckCourseApiUrl = (courseId) => new URL(`link_check/${courseId}`, getApiBaseUrl()).href; +export const getLinkCheckStatusApiUrl = (courseId) => new URL(`link_check_status/${courseId}`, getApiBaseUrl()).href; + +export async function postLinkCheck(courseId) { + const { data } = await getAuthenticatedHttpClient() + .post(postLinkCheckCourseApiUrl(courseId)); + return camelCaseObject(data); +} + +export async function getLinkCheckStatus(courseId) { + const { data } = await getAuthenticatedHttpClient() + .get(getLinkCheckStatusApiUrl(courseId)); + return camelCaseObject(data); +} 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/constants.js b/src/optimizer-page/data/constants.js new file mode 100644 index 0000000000..5824c61c7d --- /dev/null +++ b/src/optimizer-page/data/constants.js @@ -0,0 +1,8 @@ +export const LAST_EXPORT_COOKIE_NAME = 'lastexport'; +export const EXPORT_STAGES = { + PREPARING: 0, + EXPORTING: 1, + COMPRESSING: 2, + SUCCESS: 3, +}; +export const SUCCESS_DATE_FORMAT = 'MM/DD/yyyy'; diff --git a/src/optimizer-page/data/selectors.js b/src/optimizer-page/data/selectors.js new file mode 100644 index 0000000000..7f414d7d5b --- /dev/null +++ b/src/optimizer-page/data/selectors.js @@ -0,0 +1,8 @@ +export const getLinkCheckTriggered = (state) => state.courseOptimizer.linkCheckTriggered; +export const getCurrentStage = (state) => state.courseOptimizer.currentStage; +export const getDownloadPath = (state) => state.courseOptimizer.downloadPath; +export const getSuccessDate = (state) => state.courseOptimizer.successDate; +export const getError = (state) => state.courseOptimizer.error; +export const getIsErrorModalOpen = (state) => state.courseOptimizer.isErrorModalOpen; +export const getLoadingStatus = (state) => state.courseOptimizer.loadingStatus; +export const getSavingStatus = (state) => state.courseOptimizer.savingStatus; diff --git a/src/optimizer-page/data/slice.js b/src/optimizer-page/data/slice.js new file mode 100644 index 0000000000..75c9edb993 --- /dev/null +++ b/src/optimizer-page/data/slice.js @@ -0,0 +1,63 @@ +/* eslint-disable no-param-reassign */ +import { createSlice } from '@reduxjs/toolkit'; + +const initialState = { + linkCheckTriggered: false, + currentStage: 0, + error: { msg: null, unitUrl: null }, + downloadPath: null, + successDate: null, + isErrorModalOpen: false, + loadingStatus: '', + savingStatus: '', +}; + +const slice = createSlice({ + name: 'courseOptimizer', + initialState, + reducers: { + updateLinkCheckTriggered: (state, { payload }) => { + state.linkCheckTriggered = payload; + }, + updateCurrentStage: (state, { payload }) => { + if (payload >= state.currentStage) { + 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 { + updateLinkCheckTriggered, + updateCurrentStage, + updateDownloadPath, + updateSuccessDate, + updateError, + updateIsErrorModalOpen, + reset, + updateLoadingStatus, + updateSavingStatus, +} = slice.actions; + +export const { + reducer, +} = slice; diff --git a/src/optimizer-page/data/thunks.js b/src/optimizer-page/data/thunks.js new file mode 100644 index 0000000000..eb91c127a8 --- /dev/null +++ b/src/optimizer-page/data/thunks.js @@ -0,0 +1,100 @@ +import Cookies from 'universal-cookie'; +import moment from 'moment'; +import { getConfig } from '@edx/frontend-platform'; + +import { RequestStatus } from '../../data/constants'; +// import { setExportCookie } from '../utils'; +import { EXPORT_STAGES, LAST_EXPORT_COOKIE_NAME } from './constants'; + +import { + postLinkCheck, + getLinkCheckStatus, +} from './api'; +import { + updateLinkCheckTriggered, + updateCurrentStage, + updateDownloadPath, + updateSuccessDate, + updateError, + updateIsErrorModalOpen, + reset, + updateLoadingStatus, + updateSavingStatus, +} from './slice'; + +// function setExportDate({ +// date, exportStatus, exportOutput, dispatch, +// }) { +// // If there is no cookie for the last export date, set it now. +// const cookies = new Cookies(); +// const cookieData = cookies.get(LAST_EXPORT_COOKIE_NAME); +// if (!cookieData?.completed) { +// // setExportCookie(date, exportStatus === EXPORT_STAGES.SUCCESS); +// } +// // If we don't have export date set yet via cookie, set success date to current date. +// if (exportOutput && !cookieData?.completed) { +// dispatch(updateSuccessDate(date)); +// } +// } + +export function startLinkCheck(courseId) { + return async (dispatch) => { + dispatch(updateSavingStatus({ status: RequestStatus.PENDING })); + try { + dispatch(reset()); + dispatch(updateLinkCheckTriggered(true)); + 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 })); + return false; + } + }; +} + +export function fetchExportStatus(courseId) { + return async (dispatch) => { + dispatch(updateLoadingStatus({ status: RequestStatus.IN_PROGRESS })); + try { + const { + exportStatus, exportOutput, exportError, + } = await getLinkCheckStatus(courseId); + dispatch(updateCurrentStage(Math.abs(exportStatus))); + + // const date = moment().valueOf(); + + // setExportDate({ + // date, exportStatus, exportOutput, dispatch, + // }); + + if (exportError) { + const errorMessage = exportError.rawErrorMsg || exportError; + const errorUnitUrl = exportError.editUnitUrl || null; + dispatch(updateError({ msg: errorMessage, unitUrl: errorUnitUrl })); + dispatch(updateIsErrorModalOpen(true)); + } + + if (exportOutput) { + if (exportOutput.startsWith('/')) { + dispatch(updateDownloadPath(`${getConfig().STUDIO_BASE_URL}${exportOutput}`)); + } else { + dispatch(updateDownloadPath(exportOutput)); + } + } + + dispatch(updateLoadingStatus({ status: RequestStatus.SUCCESSFUL })); + return true; + } catch (error) { + 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/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/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, From e5e9862f46eadddda1494eed52b1aa53d991960d Mon Sep 17 00:00:00 2001 From: Jesper Hodge Date: Mon, 25 Nov 2024 13:12:38 -0500 Subject: [PATCH 04/27] feat: add link check polling --- src/optimizer-page/CourseOptimizerPage.jsx | 59 +++++++++++++--------- src/optimizer-page/data/constants.js | 2 +- src/optimizer-page/data/selectors.js | 1 + src/optimizer-page/data/slice.js | 9 ++-- src/optimizer-page/data/thunks.js | 33 +++++------- 5 files changed, 54 insertions(+), 50 deletions(-) diff --git a/src/optimizer-page/CourseOptimizerPage.jsx b/src/optimizer-page/CourseOptimizerPage.jsx index a84d37248b..c4ecdb1056 100644 --- a/src/optimizer-page/CourseOptimizerPage.jsx +++ b/src/optimizer-page/CourseOptimizerPage.jsx @@ -20,43 +20,52 @@ import { useModel } from '../generic/model-store'; import { getCurrentStage, getError, getLinkCheckTriggered, getLoadingStatus, getSavingStatus, } from './data/selectors'; -import { startLinkCheck } from './data/thunks'; -import { EXPORT_STAGES, LAST_EXPORT_COOKIE_NAME } from './data/constants'; +import { startLinkCheck, fetchLinkCheckStatus } from './data/thunks'; +import { LINK_CHECK_STATUSES, LAST_EXPORT_COOKIE_NAME } from './data/constants'; import { updateLinkCheckTriggered, updateSavingStatus, updateSuccessDate } from './data/slice'; // import ExportModalError from './export-modal-error/ExportModalError'; // import ExportFooter from './export-footer/ExportFooter'; // import ExportStepper from './export-stepper/ExportStepper'; +const pollLinkCheckStatus = (dispatch, courseId, delay) => { + const interval = setInterval(() => { + dispatch(fetchLinkCheckStatus(courseId)); + }, delay); + return interval; +}; + const CourseOptimizerPage = ({ intl, courseId }) => { const dispatch = useDispatch(); - const exportTriggered = useSelector(getLinkCheckTriggered); + const linkCheckTriggered = useSelector(getLinkCheckTriggered); const courseDetails = useModel('courseDetails', courseId); const currentStage = useSelector(getCurrentStage); const { msg: errorMessage } = useSelector(getError); - // const loadingStatus = useSelector(getLoadingStatus); - // const savingStatus = useSelector(getSavingStatus); - // const cookies = new Cookies(); - const isShowExportButton = !exportTriggered || errorMessage || currentStage === EXPORT_STAGES.SUCCESS; - // const anyRequestFailed = savingStatus === RequestStatus.FAILED || loadingStatus === RequestStatus.FAILED; - // const isLoadingDenied = loadingStatus === RequestStatus.DENIED; - // const anyRequestInProgress = savingStatus === RequestStatus.PENDING || loadingStatus === RequestStatus.IN_PROGRESS; + const loadingStatus = useSelector(getLoadingStatus); + const savingStatus = useSelector(getSavingStatus); + const isShowExportButton = !linkCheckTriggered || errorMessage || currentStage === LINK_CHECK_STATUSES.SUCCESS; + const anyRequestFailed = savingStatus === RequestStatus.FAILED || loadingStatus === RequestStatus.FAILED; + const isLoadingDenied = loadingStatus === RequestStatus.DENIED; + const anyRequestInProgress = savingStatus === RequestStatus.PENDING || loadingStatus === RequestStatus.IN_PROGRESS; + + useEffect(() => { + // load link check status immediately after the page is loaded + dispatch(fetchLinkCheckStatus(courseId)); - // useEffect(() => { - // const cookieData = cookies.get(LAST_EXPORT_COOKIE_NAME); - // if (cookieData) { - // dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL })); - // dispatch(updateExportTriggered(true)); - // dispatch(updateSuccessDate(cookieData.date)); - // } - // }, []); + // start polling link check status every two seconds + const intervalId = pollLinkCheckStatus(dispatch, courseId, 2000); - // if (isLoadingDenied) { - // return ( - // - // - // - // ); - // } + return () => { + clearInterval(intervalId); + }; + }, []); + + if (isLoadingDenied) { + return ( + + + + ); + } return ( <> diff --git a/src/optimizer-page/data/constants.js b/src/optimizer-page/data/constants.js index 5824c61c7d..c0fe23ea0f 100644 --- a/src/optimizer-page/data/constants.js +++ b/src/optimizer-page/data/constants.js @@ -1,5 +1,5 @@ export const LAST_EXPORT_COOKIE_NAME = 'lastexport'; -export const EXPORT_STAGES = { +export const LINK_CHECK_STATUSES = { PREPARING: 0, EXPORTING: 1, COMPRESSING: 2, diff --git a/src/optimizer-page/data/selectors.js b/src/optimizer-page/data/selectors.js index 7f414d7d5b..b7e7073287 100644 --- a/src/optimizer-page/data/selectors.js +++ b/src/optimizer-page/data/selectors.js @@ -6,3 +6,4 @@ export const getError = (state) => state.courseOptimizer.error; export const getIsErrorModalOpen = (state) => state.courseOptimizer.isErrorModalOpen; export const getLoadingStatus = (state) => state.courseOptimizer.loadingStatus; export const getSavingStatus = (state) => state.courseOptimizer.savingStatus; +export const getLinkCheckResult = (state) => state.courseOptimizer.linkCheckResult; diff --git a/src/optimizer-page/data/slice.js b/src/optimizer-page/data/slice.js index 75c9edb993..f8dfb5c120 100644 --- a/src/optimizer-page/data/slice.js +++ b/src/optimizer-page/data/slice.js @@ -3,6 +3,7 @@ import { createSlice } from '@reduxjs/toolkit'; const initialState = { linkCheckTriggered: false, + linkCheckResult: {}, currentStage: 0, error: { msg: null, unitUrl: null }, downloadPath: null, @@ -19,10 +20,11 @@ const slice = createSlice({ updateLinkCheckTriggered: (state, { payload }) => { state.linkCheckTriggered = payload; }, + updateLinkCheckResult: (state, { payload }) => { + state.linkCheckResult = payload; + }, updateCurrentStage: (state, { payload }) => { - if (payload >= state.currentStage) { - state.currentStage = payload; - } + state.currentStage = payload; }, updateDownloadPath: (state, { payload }) => { state.downloadPath = payload; @@ -48,6 +50,7 @@ const slice = createSlice({ export const { updateLinkCheckTriggered, + updateLinkCheckResult, updateCurrentStage, updateDownloadPath, updateSuccessDate, diff --git a/src/optimizer-page/data/thunks.js b/src/optimizer-page/data/thunks.js index eb91c127a8..11d36fdbed 100644 --- a/src/optimizer-page/data/thunks.js +++ b/src/optimizer-page/data/thunks.js @@ -12,9 +12,8 @@ import { } from './api'; import { updateLinkCheckTriggered, + updateLinkCheckResult, updateCurrentStage, - updateDownloadPath, - updateSuccessDate, updateError, updateIsErrorModalOpen, reset, @@ -56,34 +55,26 @@ export function startLinkCheck(courseId) { }; } -export function fetchExportStatus(courseId) { +export function fetchLinkCheckStatus(courseId) { return async (dispatch) => { dispatch(updateLoadingStatus({ status: RequestStatus.IN_PROGRESS })); + console.log('fetchLinkCheckStatus'); + try { const { - exportStatus, exportOutput, exportError, + linkCheckStatus, + linkCheckOutput, } = await getLinkCheckStatus(courseId); - dispatch(updateCurrentStage(Math.abs(exportStatus))); - - // const date = moment().valueOf(); - - // setExportDate({ - // date, exportStatus, exportOutput, dispatch, - // }); + console.log('linkCheckStatus', linkCheckStatus); + dispatch(updateCurrentStage(linkCheckStatus)); - if (exportError) { - const errorMessage = exportError.rawErrorMsg || exportError; - const errorUnitUrl = exportError.editUnitUrl || null; - dispatch(updateError({ msg: errorMessage, unitUrl: errorUnitUrl })); + if (!linkCheckStatus || linkCheckStatus < 0) { + dispatch(updateError({ msg: 'Link Check Failed' })); dispatch(updateIsErrorModalOpen(true)); } - if (exportOutput) { - if (exportOutput.startsWith('/')) { - dispatch(updateDownloadPath(`${getConfig().STUDIO_BASE_URL}${exportOutput}`)); - } else { - dispatch(updateDownloadPath(exportOutput)); - } + if (linkCheckOutput) { + dispatch(updateLinkCheckResult(linkCheckOutput)); } dispatch(updateLoadingStatus({ status: RequestStatus.SUCCESSFUL })); From 0bffcc80bed5f3faef307dd8cd9fd985d44978cd Mon Sep 17 00:00:00 2001 From: Jesper Hodge Date: Tue, 26 Nov 2024 12:27:42 -0500 Subject: [PATCH 05/27] feat: only poll when a link check is in progress --- src/optimizer-page/CourseOptimizerPage.jsx | 35 ++++++++++++++-------- src/optimizer-page/data/constants.js | 16 +++++++--- src/optimizer-page/data/selectors.js | 2 +- src/optimizer-page/data/slice.js | 10 +++---- src/optimizer-page/data/thunks.js | 16 +++++++--- 5 files changed, 52 insertions(+), 27 deletions(-) diff --git a/src/optimizer-page/CourseOptimizerPage.jsx b/src/optimizer-page/CourseOptimizerPage.jsx index c4ecdb1056..4676734529 100644 --- a/src/optimizer-page/CourseOptimizerPage.jsx +++ b/src/optimizer-page/CourseOptimizerPage.jsx @@ -1,4 +1,4 @@ -import React, { useEffect } from 'react'; +import React, { useEffect, useRef } from 'react'; import PropTypes from 'prop-types'; import { useDispatch, useSelector } from 'react-redux'; import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; @@ -18,11 +18,11 @@ import { useModel } from '../generic/model-store'; // import messages from './messages'; // import ExportSidebar from './export-sidebar/ExportSidebar'; import { - getCurrentStage, getError, getLinkCheckTriggered, getLoadingStatus, getSavingStatus, + getCurrentStage, getError, getLinkCheckInProgress, getLoadingStatus, getSavingStatus, } from './data/selectors'; import { startLinkCheck, fetchLinkCheckStatus } from './data/thunks'; -import { LINK_CHECK_STATUSES, LAST_EXPORT_COOKIE_NAME } from './data/constants'; -import { updateLinkCheckTriggered, updateSavingStatus, updateSuccessDate } from './data/slice'; +import { LINK_CHECK_STATUSES, LINK_CHECK_IN_PROGRESS_STATUSES } from './data/constants'; +import { updateSavingStatus, updateSuccessDate } from './data/slice'; // import ExportModalError from './export-modal-error/ExportModalError'; // import ExportFooter from './export-footer/ExportFooter'; // import ExportStepper from './export-stepper/ExportStepper'; @@ -36,28 +36,37 @@ const pollLinkCheckStatus = (dispatch, courseId, delay) => { const CourseOptimizerPage = ({ intl, courseId }) => { const dispatch = useDispatch(); - const linkCheckTriggered = useSelector(getLinkCheckTriggered); + const linkCheckInProgress = useSelector(getLinkCheckInProgress); + const savingStatus = useSelector(getSavingStatus); + const loadingStatus = useSelector(getLoadingStatus); const courseDetails = useModel('courseDetails', courseId); const currentStage = useSelector(getCurrentStage); const { msg: errorMessage } = useSelector(getError); - const loadingStatus = useSelector(getLoadingStatus); - const savingStatus = useSelector(getSavingStatus); - const isShowExportButton = !linkCheckTriggered || errorMessage || currentStage === LINK_CHECK_STATUSES.SUCCESS; + const isShowExportButton = !linkCheckInProgress || errorMessage; const anyRequestFailed = savingStatus === RequestStatus.FAILED || loadingStatus === RequestStatus.FAILED; const isLoadingDenied = loadingStatus === RequestStatus.DENIED; const anyRequestInProgress = savingStatus === RequestStatus.PENDING || loadingStatus === RequestStatus.IN_PROGRESS; + const interval = useRef(null); + + console.log('linkCheckInProgress', linkCheckInProgress); useEffect(() => { - // load link check status immediately after the page is loaded dispatch(fetchLinkCheckStatus(courseId)); + }, []); - // start polling link check status every two seconds - const intervalId = pollLinkCheckStatus(dispatch, courseId, 2000); + useEffect(() => { + if (linkCheckInProgress === null || linkCheckInProgress) { + clearInterval(interval.current); + interval.current = pollLinkCheckStatus(dispatch, courseId, 2000); + } else if (interval.current) { + clearInterval(interval.current); + interval.current = null; + } return () => { - clearInterval(intervalId); + if (interval.current) { clearInterval(interval.current); } }; - }, []); + }, [linkCheckInProgress]); if (isLoadingDenied) { return ( diff --git a/src/optimizer-page/data/constants.js b/src/optimizer-page/data/constants.js index c0fe23ea0f..6b6d8f58f5 100644 --- a/src/optimizer-page/data/constants.js +++ b/src/optimizer-page/data/constants.js @@ -1,8 +1,16 @@ export const LAST_EXPORT_COOKIE_NAME = 'lastexport'; export const LINK_CHECK_STATUSES = { - PREPARING: 0, - EXPORTING: 1, - COMPRESSING: 2, - SUCCESS: 3, + UNINITIATED: 'Uninitiated', + PENDING: 'Pending', + IN_PROGRESS: 'In-Progress', + SUCCEEDED: 'Succeeded', + FAILED: 'Failed', + CANCELED: 'Canceled', + RETRYING: 'Retrying', }; +export const LINK_CHECK_IN_PROGRESS_STATUSES = [ + LINK_CHECK_STATUSES.PENDING, + LINK_CHECK_STATUSES.IN_PROGRESS, + LINK_CHECK_STATUSES.RETRYING, +]; export const SUCCESS_DATE_FORMAT = 'MM/DD/yyyy'; diff --git a/src/optimizer-page/data/selectors.js b/src/optimizer-page/data/selectors.js index b7e7073287..cadb28717e 100644 --- a/src/optimizer-page/data/selectors.js +++ b/src/optimizer-page/data/selectors.js @@ -1,4 +1,4 @@ -export const getLinkCheckTriggered = (state) => state.courseOptimizer.linkCheckTriggered; +export const getLinkCheckInProgress = (state) => state.courseOptimizer.linkCheckInProgress; export const getCurrentStage = (state) => state.courseOptimizer.currentStage; export const getDownloadPath = (state) => state.courseOptimizer.downloadPath; export const getSuccessDate = (state) => state.courseOptimizer.successDate; diff --git a/src/optimizer-page/data/slice.js b/src/optimizer-page/data/slice.js index f8dfb5c120..ee8d1d8736 100644 --- a/src/optimizer-page/data/slice.js +++ b/src/optimizer-page/data/slice.js @@ -2,9 +2,9 @@ import { createSlice } from '@reduxjs/toolkit'; const initialState = { - linkCheckTriggered: false, + linkCheckInProgress: null, linkCheckResult: {}, - currentStage: 0, + currentStage: null, error: { msg: null, unitUrl: null }, downloadPath: null, successDate: null, @@ -17,8 +17,8 @@ const slice = createSlice({ name: 'courseOptimizer', initialState, reducers: { - updateLinkCheckTriggered: (state, { payload }) => { - state.linkCheckTriggered = payload; + updateLinkCheckInProgress: (state, { payload }) => { + state.linkCheckInProgress = payload; }, updateLinkCheckResult: (state, { payload }) => { state.linkCheckResult = payload; @@ -49,7 +49,7 @@ const slice = createSlice({ }); export const { - updateLinkCheckTriggered, + updateLinkCheckInProgress, updateLinkCheckResult, updateCurrentStage, updateDownloadPath, diff --git a/src/optimizer-page/data/thunks.js b/src/optimizer-page/data/thunks.js index 11d36fdbed..288a032de0 100644 --- a/src/optimizer-page/data/thunks.js +++ b/src/optimizer-page/data/thunks.js @@ -11,7 +11,7 @@ import { getLinkCheckStatus, } from './api'; import { - updateLinkCheckTriggered, + updateLinkCheckInProgress, updateLinkCheckResult, updateCurrentStage, updateError, @@ -39,9 +39,10 @@ import { export function startLinkCheck(courseId) { return async (dispatch) => { dispatch(updateSavingStatus({ status: RequestStatus.PENDING })); + dispatch(updateLinkCheckInProgress(true)); + dispatch(updateCurrentStage(1)); try { - dispatch(reset()); - dispatch(updateLinkCheckTriggered(true)); + // dispatch(reset()); const data = await postLinkCheck(courseId); dispatch(updateCurrentStage(data.linkCheckStatus)); // setExportCookie(moment().valueOf(), exportData.exportStatus === EXPORT_STAGES.SUCCESS); @@ -50,11 +51,13 @@ export function startLinkCheck(courseId) { 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 })); @@ -65,7 +68,12 @@ export function fetchLinkCheckStatus(courseId) { linkCheckStatus, linkCheckOutput, } = await getLinkCheckStatus(courseId); - console.log('linkCheckStatus', linkCheckStatus); + if (linkCheckStatus === 1 || linkCheckStatus === 2) { + dispatch(updateLinkCheckInProgress(true)); + } else { + dispatch(updateLinkCheckInProgress(false)); + } + dispatch(updateCurrentStage(linkCheckStatus)); if (!linkCheckStatus || linkCheckStatus < 0) { From 8de9d8a887bb6305d5e9ab1e229342e74dbd513a Mon Sep 17 00:00:00 2001 From: Jesper Hodge Date: Tue, 26 Nov 2024 18:14:47 -0500 Subject: [PATCH 06/27] feat: add course stepper --- src/optimizer-page/CourseOptimizerPage.jsx | 73 ++++++++++++++-------- src/optimizer-page/data/thunks.js | 8 ++- src/optimizer-page/messages.js | 58 +++++++++++++++++ 3 files changed, 111 insertions(+), 28 deletions(-) create mode 100644 src/optimizer-page/messages.js diff --git a/src/optimizer-page/CourseOptimizerPage.jsx b/src/optimizer-page/CourseOptimizerPage.jsx index 4676734529..87b14e12ad 100644 --- a/src/optimizer-page/CourseOptimizerPage.jsx +++ b/src/optimizer-page/CourseOptimizerPage.jsx @@ -1,31 +1,25 @@ -import React, { useEffect, useRef } from 'react'; +import { useEffect, useRef } from 'react'; import PropTypes from 'prop-types'; import { useDispatch, useSelector } from 'react-redux'; import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; import { Container, Layout, Button, Card, } from '@openedx/paragon'; -import { ArrowCircleDown as ArrowCircleDownIcon } from '@openedx/paragon/icons'; -import Cookies from 'universal-cookie'; -import { getConfig } from '@edx/frontend-platform'; +import { Search as SearchIcon } from '@openedx/paragon/icons'; import { Helmet } from 'react-helmet'; -import InternetConnectionAlert from '../generic/internet-connection-alert'; +import CourseStepper from '../generic/course-stepper'; import ConnectionErrorAlert from '../generic/ConnectionErrorAlert'; import SubHeader from '../generic/sub-header/SubHeader'; import { RequestStatus } from '../data/constants'; -import { useModel } from '../generic/model-store'; -// import messages from './messages'; -// import ExportSidebar from './export-sidebar/ExportSidebar'; +import messages from './messages'; import { - getCurrentStage, getError, getLinkCheckInProgress, getLoadingStatus, getSavingStatus, + getCurrentStage, getError, getLinkCheckInProgress, getLoadingStatus, } from './data/selectors'; import { startLinkCheck, fetchLinkCheckStatus } from './data/thunks'; -import { LINK_CHECK_STATUSES, LINK_CHECK_IN_PROGRESS_STATUSES } from './data/constants'; -import { updateSavingStatus, updateSuccessDate } from './data/slice'; +import { useModel } from '../generic/model-store'; // import ExportModalError from './export-modal-error/ExportModalError'; // import ExportFooter from './export-footer/ExportFooter'; -// import ExportStepper from './export-stepper/ExportStepper'; const pollLinkCheckStatus = (dispatch, courseId, delay) => { const interval = setInterval(() => { @@ -37,18 +31,32 @@ const pollLinkCheckStatus = (dispatch, courseId, delay) => { const CourseOptimizerPage = ({ intl, courseId }) => { const dispatch = useDispatch(); const linkCheckInProgress = useSelector(getLinkCheckInProgress); - const savingStatus = useSelector(getSavingStatus); const loadingStatus = useSelector(getLoadingStatus); - const courseDetails = useModel('courseDetails', courseId); const currentStage = useSelector(getCurrentStage); const { msg: errorMessage } = useSelector(getError); const isShowExportButton = !linkCheckInProgress || errorMessage; - const anyRequestFailed = savingStatus === RequestStatus.FAILED || loadingStatus === RequestStatus.FAILED; const isLoadingDenied = loadingStatus === RequestStatus.DENIED; - const anyRequestInProgress = savingStatus === RequestStatus.PENDING || loadingStatus === RequestStatus.IN_PROGRESS; const interval = useRef(null); + const courseDetails = useModel('courseDetails', courseId); + const linkCheckPresent = !!currentStage; - console.log('linkCheckInProgress', linkCheckInProgress); + 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(() => { dispatch(fetchLinkCheckStatus(courseId)); @@ -69,6 +77,8 @@ const CourseOptimizerPage = ({ intl, courseId }) => { }, [linkCheckInProgress]); if (isLoadingDenied) { + if (interval.current) { clearInterval(interval.current); } + return ( @@ -80,7 +90,11 @@ const CourseOptimizerPage = ({ intl, courseId }) => { <> - Title + {intl.formatMessage(messages.pageTitle, { + headingTitle: intl.formatMessage(messages.headingTitle), + courseName: courseDetails?.name, + siteName: process.env.SITE_NAME, + })} @@ -95,15 +109,15 @@ const CourseOptimizerPage = ({ intl, courseId }) => {
-

Small

-

Description

+

{intl.formatMessage(messages.description1)}

+

{intl.formatMessage(messages.description2)}

{isShowExportButton && ( @@ -112,13 +126,20 @@ const CourseOptimizerPage = ({ intl, courseId }) => { block className="mb-4" onClick={() => dispatch(startLinkCheck(courseId))} - iconBefore={ArrowCircleDownIcon} + iconBefore={SearchIcon} > - Scan for broken links + {intl.formatMessage(messages.buttonTitle)} )} -

Current stage: {currentStage}

+ {linkCheckPresent && ( + + )}
diff --git a/src/optimizer-page/data/thunks.js b/src/optimizer-page/data/thunks.js index 288a032de0..278c9d9e74 100644 --- a/src/optimizer-page/data/thunks.js +++ b/src/optimizer-page/data/thunks.js @@ -61,7 +61,11 @@ export function startLinkCheck(courseId) { export function fetchLinkCheckStatus(courseId) { return async (dispatch) => { dispatch(updateLoadingStatus({ status: RequestStatus.IN_PROGRESS })); - console.log('fetchLinkCheckStatus'); + + /* ****** Debugging ******** */ + // dispatch(updateLinkCheckInProgress(true)); + // dispatch(updateCurrentStage(3)); + // return true; try { const { @@ -76,7 +80,7 @@ export function fetchLinkCheckStatus(courseId) { dispatch(updateCurrentStage(linkCheckStatus)); - if (!linkCheckStatus || linkCheckStatus < 0) { + if (linkCheckStatus === undefined || linkCheckStatus === null || linkCheckStatus < 0) { dispatch(updateError({ msg: 'Link Check Failed' })); dispatch(updateIsErrorModalOpen(true)); } diff --git a/src/optimizer-page/messages.js b/src/optimizer-page/messages.js new file mode 100644 index 0000000000..adbd08f1a4 --- /dev/null +++ b/src/optimizer-page/messages.js @@ -0,0 +1,58 @@ +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.title-under-button', + 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; From aee40148241db806469a0312d2581bc5cb18dea1 Mon Sep 17 00:00:00 2001 From: Jesper Hodge Date: Wed, 27 Nov 2024 17:17:27 -0500 Subject: [PATCH 07/27] feat: add results display --- src/index.scss | 1 + src/optimizer-page/CourseOptimizerPage.jsx | 23 +++-- src/optimizer-page/data/api.js | 7 +- src/optimizer-page/data/slice.js | 2 +- src/optimizer-page/data/thunks.js | 3 +- src/optimizer-page/messages.js | 6 +- src/optimizer-page/mocks/mockApiResponse.js | 98 +++++++++++++++++++ .../scan-results/ScanResults.jsx | 95 ++++++++++++++++++ .../scan-results/ScanResults.scss | 5 + src/optimizer-page/scan-results/index.js | 4 + 10 files changed, 230 insertions(+), 14 deletions(-) create mode 100644 src/optimizer-page/mocks/mockApiResponse.js create mode 100644 src/optimizer-page/scan-results/ScanResults.jsx create mode 100644 src/optimizer-page/scan-results/ScanResults.scss create mode 100644 src/optimizer-page/scan-results/index.js 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 index 87b14e12ad..6e8ba1a3e8 100644 --- a/src/optimizer-page/CourseOptimizerPage.jsx +++ b/src/optimizer-page/CourseOptimizerPage.jsx @@ -14,10 +14,11 @@ import SubHeader from '../generic/sub-header/SubHeader'; import { RequestStatus } from '../data/constants'; import messages from './messages'; import { - getCurrentStage, getError, getLinkCheckInProgress, getLoadingStatus, + 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'; // import ExportModalError from './export-modal-error/ExportModalError'; // import ExportFooter from './export-footer/ExportFooter'; @@ -33,6 +34,7 @@ const CourseOptimizerPage = ({ intl, courseId }) => { 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; @@ -63,7 +65,7 @@ const CourseOptimizerPage = ({ intl, courseId }) => { }, []); useEffect(() => { - if (linkCheckInProgress === null || linkCheckInProgress) { + if (linkCheckInProgress === null || linkCheckInProgress || !linkCheckResult) { clearInterval(interval.current); interval.current = pollLinkCheckStatus(dispatch, courseId, 2000); } else if (interval.current) { @@ -74,7 +76,7 @@ const CourseOptimizerPage = ({ intl, courseId }) => { return () => { if (interval.current) { clearInterval(interval.current); } }; - }, [linkCheckInProgress]); + }, [linkCheckInProgress, linkCheckResult]); if (isLoadingDenied) { if (interval.current) { clearInterval(interval.current); } @@ -133,14 +135,17 @@ const CourseOptimizerPage = ({ intl, courseId }) => { )} {linkCheckPresent && ( - + + + )} + diff --git a/src/optimizer-page/data/api.js b/src/optimizer-page/data/api.js index cf6530ae9b..2bb3f5862b 100644 --- a/src/optimizer-page/data/api.js +++ b/src/optimizer-page/data/api.js @@ -1,5 +1,6 @@ import { camelCaseObject, getConfig } from '@edx/frontend-platform'; import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; +import mockApiResponse from '../mocks/mockApiResponse'; const getApiBaseUrl = () => getConfig().STUDIO_BASE_URL; export const postLinkCheckCourseApiUrl = (courseId) => new URL(`link_check/${courseId}`, getApiBaseUrl()).href; @@ -8,11 +9,13 @@ export const getLinkCheckStatusApiUrl = (courseId) => new URL(`link_check_status export async function postLinkCheck(courseId) { const { data } = await getAuthenticatedHttpClient() .post(postLinkCheckCourseApiUrl(courseId)); - return camelCaseObject(data); + // return camelCaseObject(data); + return mockApiResponse; } export async function getLinkCheckStatus(courseId) { const { data } = await getAuthenticatedHttpClient() .get(getLinkCheckStatusApiUrl(courseId)); - return camelCaseObject(data); + // return camelCaseObject(data); + return mockApiResponse; } diff --git a/src/optimizer-page/data/slice.js b/src/optimizer-page/data/slice.js index ee8d1d8736..052e4af1eb 100644 --- a/src/optimizer-page/data/slice.js +++ b/src/optimizer-page/data/slice.js @@ -3,7 +3,7 @@ import { createSlice } from '@reduxjs/toolkit'; const initialState = { linkCheckInProgress: null, - linkCheckResult: {}, + linkCheckResult: null, currentStage: null, error: { msg: null, unitUrl: null }, downloadPath: null, diff --git a/src/optimizer-page/data/thunks.js b/src/optimizer-page/data/thunks.js index 278c9d9e74..20385bf977 100644 --- a/src/optimizer-page/data/thunks.js +++ b/src/optimizer-page/data/thunks.js @@ -72,6 +72,7 @@ export function fetchLinkCheckStatus(courseId) { linkCheckStatus, linkCheckOutput, } = await getLinkCheckStatus(courseId); + console.log('linkCheckOutput: ', linkCheckOutput); if (linkCheckStatus === 1 || linkCheckStatus === 2) { dispatch(updateLinkCheckInProgress(true)); } else { @@ -92,7 +93,7 @@ export function fetchLinkCheckStatus(courseId) { dispatch(updateLoadingStatus({ status: RequestStatus.SUCCESSFUL })); return true; } catch (error) { - if (error.response && error.response.status === 403) { + if (error?.response && error?.response.status === 403) { dispatch(updateLoadingStatus({ status: RequestStatus.DENIED })); } else { dispatch(updateLoadingStatus({ courseId, status: RequestStatus.FAILED })); diff --git a/src/optimizer-page/messages.js b/src/optimizer-page/messages.js index adbd08f1a4..f708c489c3 100644 --- a/src/optimizer-page/messages.js +++ b/src/optimizer-page/messages.js @@ -22,7 +22,11 @@ const messages = defineMessages({ 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.title-under-button', + 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: { diff --git a/src/optimizer-page/mocks/mockApiResponse.js b/src/optimizer-page/mocks/mockApiResponse.js new file mode 100644 index 0000000000..e7aa121c59 --- /dev/null +++ b/src/optimizer-page/mocks/mockApiResponse.js @@ -0,0 +1,98 @@ +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'], + }, + { + id: 'block-1-1-1-2', + url: 'https://example.com/intro-guide', + brokenLinks: ['https://example.com/broken-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'], + }, + ], + }, + ], + }, + { + 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'], + }, + { + id: 'block-1-2-1-2', + url: 'https://example.com/broken-link', + brokenLinks: ['https://example.com/broken-link'], + }, + ], + }, + ], + }, + ], + }, + { + 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'], + }, + { + id: 'block-2-1-1-2', + url: 'https://example.com/broken-link-algo', + brokenLinks: ['https://example.com/broken-link-algo'], + }, + ], + }, + ], + }, + ], + }, + ], + }, +}; + +export default mockApiResponse; diff --git a/src/optimizer-page/scan-results/ScanResults.jsx b/src/optimizer-page/scan-results/ScanResults.jsx new file mode 100644 index 0000000000..c14c8a2c23 --- /dev/null +++ b/src/optimizer-page/scan-results/ScanResults.jsx @@ -0,0 +1,95 @@ +import { + Container, Layout, Button, Card, Collapsible, +} from '@openedx/paragon'; +import { useState, useCallback } from 'react'; + +const SectionCollapsible = ({ title, children, redItalics }) => { + const styling = 'card-lg'; + const collapsibleTitle = ( +
+

{title}{redItalics}

+
+ ); + + return ( + {collapsibleTitle}

} + > + {children} +
+ ); +}; + +const ScanResults = ({ data }) => { + if (!data || !data.sections) { + return ( + +

Scan Results

+ +

No data available

+
+
+ ); + } + const { sections } = data; + console.log('data: ', data); + console.log('sections: ', sections); + + const countBrokenLinksPerSection = useCallback(() => { + const counts = []; + 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]); + + const brokenLinkCounts = countBrokenLinksPerSection(); + + return ( + <> +
+

Broken Links Scan

+
+ + {sections?.map((section, index) => ( + + {section.subsections.map((subsection) => ( +

+

{subsection.displayName}

+ {subsection.units.map((unit) => ( +

+

{unit.displayName}

+ {unit.blocks.map((block) => ( +

+

+ URL: + {' '} + {block.url} +

+

+ Broken Links: + {' '} + {block.brokenLinks.join(', ')} +

+

+ ))} +

+ ))} +

+ ))} +
+ ))} + + ); +}; + +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..4aa0af43db --- /dev/null +++ b/src/optimizer-page/scan-results/ScanResults.scss @@ -0,0 +1,5 @@ +.red-italics { + color: $brand-400; + margin-left: 3rem; + font-weight: 400; +} \ No newline at end of file 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 }; From f86ee0c8b57108c97f3e779e84a5d39be16dcabd Mon Sep 17 00:00:00 2001 From: Jesper Hodge Date: Wed, 27 Nov 2024 18:50:18 -0500 Subject: [PATCH 08/27] feat: add results collapsible --- .../scan-results/ScanResults.jsx | 21 +++++++++++++++---- .../scan-results/ScanResults.scss | 11 ++++++++-- 2 files changed, 26 insertions(+), 6 deletions(-) diff --git a/src/optimizer-page/scan-results/ScanResults.jsx b/src/optimizer-page/scan-results/ScanResults.jsx index c14c8a2c23..aebaf3d6c2 100644 --- a/src/optimizer-page/scan-results/ScanResults.jsx +++ b/src/optimizer-page/scan-results/ScanResults.jsx @@ -1,13 +1,15 @@ import { - Container, Layout, Button, Card, Collapsible, + Container, Layout, Button, Card, Collapsible, Icon, } from '@openedx/paragon'; +import { ArrowRight, ArrowDropDown } from '@openedx/paragon/icons'; import { useState, useCallback } from 'react'; const SectionCollapsible = ({ title, children, redItalics }) => { + const [isOpen, setIsOpen] = useState(false); const styling = 'card-lg'; const collapsibleTitle = (
-

{title}{redItalics}

+ {title}{redItalics}
); @@ -15,8 +17,14 @@ const SectionCollapsible = ({ title, children, redItalics }) => { {collapsibleTitle}

} + iconWhenClosed="" + iconWhenOpen="" + open={isOpen} + onClick={() => setIsOpen(!isOpen)} > - {children} + + {children} +
); }; @@ -61,7 +69,12 @@ const ScanResults = ({ data }) => { {sections?.map((section, index) => ( - + {section.subsections.map((subsection) => (

{subsection.displayName}

diff --git a/src/optimizer-page/scan-results/ScanResults.scss b/src/optimizer-page/scan-results/ScanResults.scss index 4aa0af43db..9015bba6b8 100644 --- a/src/optimizer-page/scan-results/ScanResults.scss +++ b/src/optimizer-page/scan-results/ScanResults.scss @@ -1,5 +1,12 @@ .red-italics { - color: $brand-400; - margin-left: 3rem; + color: $brand-500; + margin-left: 2rem; font-weight: 400; + font-size: 80%; + font-style: italic; +} + +.open-arrow { + transform: translate(-10px, 5px); + display: inline-block; } \ No newline at end of file From 3ec3c33c1a37dec74479254a84c03af53e41571e Mon Sep 17 00:00:00 2001 From: Jesper Hodge Date: Wed, 27 Nov 2024 19:50:55 -0500 Subject: [PATCH 09/27] feat: make a cool design --- .../scan-results/ScanResults.jsx | 86 +++++++++++++++++-- .../scan-results/ScanResults.scss | 74 +++++++++++++--- 2 files changed, 143 insertions(+), 17 deletions(-) diff --git a/src/optimizer-page/scan-results/ScanResults.jsx b/src/optimizer-page/scan-results/ScanResults.jsx index aebaf3d6c2..5ffff48df6 100644 --- a/src/optimizer-page/scan-results/ScanResults.jsx +++ b/src/optimizer-page/scan-results/ScanResults.jsx @@ -1,5 +1,5 @@ import { - Container, Layout, Button, Card, Collapsible, Icon, + Container, Layout, Button, Card, Collapsible, Icon, Table, } from '@openedx/paragon'; import { ArrowRight, ArrowDropDown } from '@openedx/paragon/icons'; import { useState, useCallback } from 'react'; @@ -20,7 +20,7 @@ const SectionCollapsible = ({ title, children, redItalics }) => { iconWhenClosed="" iconWhenOpen="" open={isOpen} - onClick={() => setIsOpen(!isOpen)} + onToggle={() => setIsOpen(!isOpen)} > {children} @@ -63,7 +63,7 @@ const ScanResults = ({ data }) => { const brokenLinkCounts = countBrokenLinksPerSection(); return ( - <> +

Broken Links Scan

@@ -75,7 +75,81 @@ const ScanResults = ({ data }) => { title={section.displayName} redItalics={`${brokenLinkCounts[index]} broken links`} > - {section.subsections.map((subsection) => ( +

Subsection A

+
+

Unit 1

+
+

Block with broken Links

+ {}, + width: 'col-3', + hideHeader: true, + }, + { + key: 'brokenLink', + columnSortable: false, + onSort: () => {}, + width: 'col-6', + hideHeader: true, + }, + ]} + // className="table-striped" + /> + + +

Subsection B

+
+

Unit 1

+
+

Block with broken Links

+
{}, + width: 'col-3', + hideHeader: true, + }, + { + key: 'brokenLink', + columnSortable: false, + onSort: () => {}, + width: 'col-6', + hideHeader: true, + }, + ]} + // className="table-striped" + /> + + + {/* {section.subsections.map((subsection) => (

{subsection.displayName}

{subsection.units.map((unit) => ( @@ -98,10 +172,10 @@ const ScanResults = ({ data }) => {

))}

- ))} + ))} */} ))} - + ); }; diff --git a/src/optimizer-page/scan-results/ScanResults.scss b/src/optimizer-page/scan-results/ScanResults.scss index 9015bba6b8..d096e1d12b 100644 --- a/src/optimizer-page/scan-results/ScanResults.scss +++ b/src/optimizer-page/scan-results/ScanResults.scss @@ -1,12 +1,64 @@ -.red-italics { - color: $brand-500; - margin-left: 2rem; - font-weight: 400; - font-size: 80%; - font-style: italic; -} - -.open-arrow { - transform: translate(-10px, 5px); - display: inline-block; +.scan-results { + thead { + display: none; + } + + .red-italics { + color: $brand-500; + margin-left: 2rem; + font-weight: 400; + font-size: 80%; + font-style: italic; + } + + .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; + border-left: 5px solid $brand-400; + } + + /* Subsection Header */ + .unit-header { + font-size: 16px; /* Slightly smaller than Section */ + font-weight: 500; + margin-left: .5rem; + margin-top: 10px; + color: #555; + } + + /* Unit Header */ + .block-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; + } + + .block { + padding: 0 3rem; + } } \ No newline at end of file From 329f22849044f42f16d959d509aa5edc6b40d9c8 Mon Sep 17 00:00:00 2001 From: Jesper Hodge Date: Mon, 2 Dec 2024 14:19:38 -0500 Subject: [PATCH 10/27] feat: design improvements --- src/optimizer-page/CourseOptimizerPage.jsx | 4 +- .../scan-results/ScanResults.jsx | 136 +++++++++++++----- .../scan-results/ScanResults.scss | 7 +- 3 files changed, 107 insertions(+), 40 deletions(-) diff --git a/src/optimizer-page/CourseOptimizerPage.jsx b/src/optimizer-page/CourseOptimizerPage.jsx index 6e8ba1a3e8..46378442b5 100644 --- a/src/optimizer-page/CourseOptimizerPage.jsx +++ b/src/optimizer-page/CourseOptimizerPage.jsx @@ -88,6 +88,8 @@ const CourseOptimizerPage = ({ intl, courseId }) => { ); } + console.log('courseOptimizerPage: linkCheckResult: ', linkCheckResult); + return ( <> @@ -145,7 +147,7 @@ const CourseOptimizerPage = ({ intl, courseId }) => { )} - + {linkCheckPresent && } diff --git a/src/optimizer-page/scan-results/ScanResults.jsx b/src/optimizer-page/scan-results/ScanResults.jsx index 5ffff48df6..1bb0d0225e 100644 --- a/src/optimizer-page/scan-results/ScanResults.jsx +++ b/src/optimizer-page/scan-results/ScanResults.jsx @@ -1,7 +1,7 @@ import { Container, Layout, Button, Card, Collapsible, Icon, Table, } from '@openedx/paragon'; -import { ArrowRight, ArrowDropDown } from '@openedx/paragon/icons'; +import { ArrowRight, ArrowDropDown, OpenInNew } from '@openedx/paragon/icons'; import { useState, useCallback } from 'react'; const SectionCollapsible = ({ title, children, redItalics }) => { @@ -30,7 +30,7 @@ const SectionCollapsible = ({ title, children, redItalics }) => { }; const ScanResults = ({ data }) => { - if (!data || !data.sections) { + if (!data) { return (

Scan Results

@@ -40,6 +40,17 @@ const ScanResults = ({ data }) => {
); } + + if (!data.sections) { + return ( + +

Scan Results

+ +
{JSON.stringify(data, null, 2) }
+
+
+ ); + } const { sections } = data; console.log('data: ', data); console.log('sections: ', sections); @@ -62,6 +73,9 @@ const ScanResults = ({ data }) => { const brokenLinkCounts = countBrokenLinksPerSection(); + const blockLink = Go to Block; + const brokenLink = https://broken.example.com; + return (
@@ -79,16 +93,88 @@ const ScanResults = ({ data }) => {

Unit 1

-

Block with broken Links

+

Broken links found in Block "My_Block_Name":

{}, + width: 'col-3', + hideHeader: true, + }, + { + key: 'brokenLink', + columnSortable: false, + onSort: () => {}, + width: 'col-6', + hideHeader: true, + }, + ]} + // className="table-striped" + /> + + +
+

Unit 2

+
+

Broken links found in Block "My_Block_Name":

+
{}, + width: 'col-3', + hideHeader: true, + }, + { + key: 'brokenLink', + columnSortable: false, + onSort: () => {}, + width: 'col-6', + hideHeader: true, + }, + ]} + // className="table-striped" + /> + + +

Subsection B

+
+
+

Unit 1

+
{ /> -

Subsection B

-

Unit 1

-

Block with broken Links

+

Unit 2

{ /> - {/* {section.subsections.map((subsection) => ( -

-

{subsection.displayName}

- {subsection.units.map((unit) => ( -

-

{unit.displayName}

- {unit.blocks.map((block) => ( -

-

- URL: - {' '} - {block.url} -

-

- Broken Links: - {' '} - {block.brokenLinks.join(', ')} -

-

- ))} -

- ))} -

- ))} */} ))} diff --git a/src/optimizer-page/scan-results/ScanResults.scss b/src/optimizer-page/scan-results/ScanResults.scss index d096e1d12b..b9347db99e 100644 --- a/src/optimizer-page/scan-results/ScanResults.scss +++ b/src/optimizer-page/scan-results/ScanResults.scss @@ -61,4 +61,9 @@ .block { padding: 0 3rem; } -} \ No newline at end of file + + .broken-link { + color: $brand-500; + text-decoration: none; + } +} From 9942bf810362b80cf4961f0a63abcb8a7384f36d Mon Sep 17 00:00:00 2001 From: Jesper Hodge Date: Tue, 3 Dec 2024 10:45:37 -0500 Subject: [PATCH 11/27] feat: design improvements --- .../scan-results/ScanResults.jsx | 76 +------------------ .../scan-results/ScanResults.scss | 1 - 2 files changed, 1 insertion(+), 76 deletions(-) diff --git a/src/optimizer-page/scan-results/ScanResults.jsx b/src/optimizer-page/scan-results/ScanResults.jsx index 1bb0d0225e..8f818043cb 100644 --- a/src/optimizer-page/scan-results/ScanResults.jsx +++ b/src/optimizer-page/scan-results/ScanResults.jsx @@ -89,80 +89,7 @@ const ScanResults = ({ data }) => { title={section.displayName} redItalics={`${brokenLinkCounts[index]} broken links`} > -

Subsection A

-
-

Unit 1

-
-

Broken links found in Block "My_Block_Name":

-
{}, - width: 'col-3', - hideHeader: true, - }, - { - key: 'brokenLink', - columnSortable: false, - onSort: () => {}, - width: 'col-6', - hideHeader: true, - }, - ]} - // className="table-striped" - /> - - -
-

Unit 2

-
-

Broken links found in Block "My_Block_Name":

-
{}, - width: 'col-3', - hideHeader: true, - }, - { - key: 'brokenLink', - columnSortable: false, - onSort: () => {}, - width: 'col-6', - hideHeader: true, - }, - ]} - // className="table-striped" - /> - - -

Subsection B

+

Subsection A

Unit 1

@@ -229,7 +156,6 @@ const ScanResults = ({ data }) => { hideHeader: true, }, ]} - // className="table-striped" />
diff --git a/src/optimizer-page/scan-results/ScanResults.scss b/src/optimizer-page/scan-results/ScanResults.scss index b9347db99e..ffcf8a6a49 100644 --- a/src/optimizer-page/scan-results/ScanResults.scss +++ b/src/optimizer-page/scan-results/ScanResults.scss @@ -23,7 +23,6 @@ background-color: rgb(248, 247, 246); /* Subtle gray background */ padding: 10px; margin-bottom: 10px; - border-left: 5px solid $brand-400; } /* Subsection Header */ From 869676dba22dce6242b9b2c576172dc6b76f5316 Mon Sep 17 00:00:00 2001 From: Jesper Hodge Date: Fri, 6 Dec 2024 18:17:05 -0500 Subject: [PATCH 12/27] feat: load actual data --- src/optimizer-page/data/api.js | 10 +- .../scan-results/ScanResults.jsx | 210 +++++++++--------- .../scan-results/ScanResults.scss | 18 +- 3 files changed, 120 insertions(+), 118 deletions(-) diff --git a/src/optimizer-page/data/api.js b/src/optimizer-page/data/api.js index 2bb3f5862b..b213782e3b 100644 --- a/src/optimizer-page/data/api.js +++ b/src/optimizer-page/data/api.js @@ -3,19 +3,17 @@ import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; import mockApiResponse from '../mocks/mockApiResponse'; const getApiBaseUrl = () => getConfig().STUDIO_BASE_URL; -export const postLinkCheckCourseApiUrl = (courseId) => new URL(`link_check/${courseId}`, getApiBaseUrl()).href; -export const getLinkCheckStatusApiUrl = (courseId) => new URL(`link_check_status/${courseId}`, getApiBaseUrl()).href; +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) { const { data } = await getAuthenticatedHttpClient() .post(postLinkCheckCourseApiUrl(courseId)); - // return camelCaseObject(data); - return mockApiResponse; + return camelCaseObject(data); } export async function getLinkCheckStatus(courseId) { const { data } = await getAuthenticatedHttpClient() .get(getLinkCheckStatusApiUrl(courseId)); - // return camelCaseObject(data); - return mockApiResponse; + return camelCaseObject(data); } diff --git a/src/optimizer-page/scan-results/ScanResults.jsx b/src/optimizer-page/scan-results/ScanResults.jsx index 8f818043cb..1fc58b29f3 100644 --- a/src/optimizer-page/scan-results/ScanResults.jsx +++ b/src/optimizer-page/scan-results/ScanResults.jsx @@ -4,57 +4,54 @@ import { import { ArrowRight, ArrowDropDown, OpenInNew } from '@openedx/paragon/icons'; import { useState, useCallback } from 'react'; -const SectionCollapsible = ({ title, children, redItalics }) => { +const SectionCollapsible = ({ + 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} - -
+ +
+ {collapsibleTitle}

} + iconWhenClosed="" + iconWhenOpen="" + open={isOpen} + onToggle={() => setIsOpen(!isOpen)} + > + + {children} + +
+
); }; -const ScanResults = ({ data }) => { - if (!data) { - return ( - -

Scan Results

- -

No data available

-
-
- ); +function getBaseUrl(url) { + try { + const parsedUrl = new URL(url); + return `${parsedUrl.origin}`; + } catch (error) { + return null; } +} - if (!data.sections) { - return ( - -

Scan Results

- -
{JSON.stringify(data, null, 2) }
-
-
- ); - } - const { sections } = data; - console.log('data: ', data); - console.log('sections: ', sections); +const BrokenLinkHref = ({ href }) => ( +
+ {href} +
+); + +const GoToBlock = ({ block }) => Go to Block; +const ScanResults = ({ data }) => { const countBrokenLinksPerSection = useCallback(() => { const counts = []; sections.forEach((section) => { @@ -71,10 +68,22 @@ const ScanResults = ({ data }) => { return counts; }, [data?.sections]); - const brokenLinkCounts = countBrokenLinksPerSection(); + if (!data) { + return ( + +

Scan Results

+ +

No data available

+
+
+ ); + } + + const { sections } = data; + console.log('data: ', data); + console.log('sections: ', sections); - const blockLink = Go to Block; - const brokenLink = https://broken.example.com; + const brokenLinkCounts = countBrokenLinksPerSection(); return (
@@ -84,81 +93,60 @@ const ScanResults = ({ data }) => { {sections?.map((section, index) => ( -

Subsection A

-
-
-

Unit 1

-
{}, - width: 'col-3', - hideHeader: true, - }, - { - key: 'brokenLink', - columnSortable: false, - onSort: () => {}, - width: 'col-6', - hideHeader: true, - }, - ]} - // className="table-striped" - /> - - -
-
-

Unit 2

-
{}, - width: 'col-3', - hideHeader: true, - }, - { - key: 'brokenLink', - columnSortable: false, - onSort: () => {}, - width: 'col-6', - hideHeader: true, - }, - ]} - /> - - + {section.subsections.map((subsection) => ( + <> +

{subsection.displayName}

+ {subsection.units.map((unit) => ( +
+

{unit.displayName}

+
{ + const blockBrokenLinks = block.brokenLinks.map((link) => ({ + blockLink: , + brokenLink: , + status: 'Status: BROKEN ?', + })); + acc.push(...blockBrokenLinks); + const blockLockedLinks = block.lockedLinks.map((link) => ({ + blockLink: , + brokenLink: , + status: 'Status: LOCKED ?', + })); + 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, + }, + ]} + /> + + ))} + + ))} ))} diff --git a/src/optimizer-page/scan-results/ScanResults.scss b/src/optimizer-page/scan-results/ScanResults.scss index ffcf8a6a49..43ff4bb3a6 100644 --- a/src/optimizer-page/scan-results/ScanResults.scss +++ b/src/optimizer-page/scan-results/ScanResults.scss @@ -11,6 +11,15 @@ font-style: italic; } + .section { + &.is-open { + &:not(:first-child) { + margin-top: 1rem; + } + margin-bottom: 1rem; + } + } + .open-arrow { transform: translate(-10px, 5px); display: inline-block; @@ -57,7 +66,7 @@ font-weight: bold; } - .block { + .unit { padding: 0 3rem; } @@ -65,4 +74,11 @@ color: $brand-500; text-decoration: none; } + + .broken-link-container { + max-width: 18rem; + text-wrap: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } } From 919cb07b65985c5ee2d0fa2dd1b88c1b2208cc4a Mon Sep 17 00:00:00 2001 From: Jesper Hodge Date: Mon, 9 Dec 2024 17:45:39 -0500 Subject: [PATCH 13/27] feat: add checkbox and info icons --- .../scan-results/ScanResults.jsx | 33 ++++++++++++++++--- .../scan-results/ScanResults.scss | 23 +++++++++++++ 2 files changed, 51 insertions(+), 5 deletions(-) diff --git a/src/optimizer-page/scan-results/ScanResults.jsx b/src/optimizer-page/scan-results/ScanResults.jsx index 1fc58b29f3..51f67d3f79 100644 --- a/src/optimizer-page/scan-results/ScanResults.jsx +++ b/src/optimizer-page/scan-results/ScanResults.jsx @@ -1,7 +1,9 @@ import { - Container, Layout, Button, Card, Collapsible, Icon, Table, + Container, Layout, Button, Card, Collapsible, Icon, Table, CheckBox, OverlayTrigger, Tooltip, } from '@openedx/paragon'; -import { ArrowRight, ArrowDropDown, OpenInNew } from '@openedx/paragon/icons'; +import { + ArrowRight, ArrowDropDown, OpenInNew, Question, Lock, LinkOff, +} from '@openedx/paragon/icons'; import { useState, useCallback } from 'react'; const SectionCollapsible = ({ @@ -51,7 +53,22 @@ const BrokenLinkHref = ({ href }) => ( const GoToBlock = ({ block }) => Go to Block; +const lockedInfoIcon = ( + + These course files are "locked" so we cannot test whether they work or not. + + )} + > + + +); const ScanResults = ({ data }) => { + const [showLockedLinks, setShowLockedLinks] = useState(true); + const countBrokenLinksPerSection = useCallback(() => { const counts = []; sections.forEach((section) => { @@ -88,7 +105,13 @@ const ScanResults = ({ data }) => { return (
-

Broken Links Scan

+
+

Broken Links Scan

+ + { setShowLockedLinks(!showLockedLinks); }} label="Show Locked Course Files" /> + {lockedInfoIcon} + +
{sections?.map((section, index) => ( @@ -108,13 +131,13 @@ const ScanResults = ({ data }) => { const blockBrokenLinks = block.brokenLinks.map((link) => ({ blockLink: , brokenLink: , - status: 'Status: BROKEN ?', + status: Status: Broken, })); acc.push(...blockBrokenLinks); const blockLockedLinks = block.lockedLinks.map((link) => ({ blockLink: , brokenLink: , - status: 'Status: LOCKED ?', + status: Status: LOCKED {lockedInfoIcon}, })); acc.push(...blockLockedLinks); return acc; diff --git a/src/optimizer-page/scan-results/ScanResults.scss b/src/optimizer-page/scan-results/ScanResults.scss index 43ff4bb3a6..2b772ff526 100644 --- a/src/optimizer-page/scan-results/ScanResults.scss +++ b/src/optimizer-page/scan-results/ScanResults.scss @@ -81,4 +81,27 @@ 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; + } } From 0d62bd267dd7a70371d5c2ec0c6f2fbc1c8a9f05 Mon Sep 17 00:00:00 2001 From: Jesper Hodge Date: Mon, 9 Dec 2024 17:47:39 -0500 Subject: [PATCH 14/27] feat: hide locked links when unchecked --- src/optimizer-page/scan-results/ScanResults.jsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/optimizer-page/scan-results/ScanResults.jsx b/src/optimizer-page/scan-results/ScanResults.jsx index 51f67d3f79..ef7c4c1a73 100644 --- a/src/optimizer-page/scan-results/ScanResults.jsx +++ b/src/optimizer-page/scan-results/ScanResults.jsx @@ -134,6 +134,10 @@ const ScanResults = ({ data }) => { status: Status: Broken, })); acc.push(...blockBrokenLinks); + if (!showLockedLinks) { + return acc; + } + const blockLockedLinks = block.lockedLinks.map((link) => ({ blockLink: , brokenLink: , From 8e380659aa91fe6cb3cbb9976de368174284f586 Mon Sep 17 00:00:00 2001 From: Jesper Hodge Date: Fri, 13 Dec 2024 09:36:46 -0500 Subject: [PATCH 15/27] feat: add translations --- src/optimizer-page/CourseOptimizerPage.jsx | 2 - .../scan-results/ScanResults.jsx | 165 +++++++++++++----- src/optimizer-page/scan-results/messages.js | 42 +++++ 3 files changed, 160 insertions(+), 49 deletions(-) create mode 100644 src/optimizer-page/scan-results/messages.js diff --git a/src/optimizer-page/CourseOptimizerPage.jsx b/src/optimizer-page/CourseOptimizerPage.jsx index 46378442b5..b6911271d2 100644 --- a/src/optimizer-page/CourseOptimizerPage.jsx +++ b/src/optimizer-page/CourseOptimizerPage.jsx @@ -19,8 +19,6 @@ import { import { startLinkCheck, fetchLinkCheckStatus } from './data/thunks'; import { useModel } from '../generic/model-store'; import { ScanResults } from './scan-results'; -// import ExportModalError from './export-modal-error/ExportModalError'; -// import ExportFooter from './export-footer/ExportFooter'; const pollLinkCheckStatus = (dispatch, courseId, delay) => { const interval = setInterval(() => { diff --git a/src/optimizer-page/scan-results/ScanResults.jsx b/src/optimizer-page/scan-results/ScanResults.jsx index ef7c4c1a73..305053b22b 100644 --- a/src/optimizer-page/scan-results/ScanResults.jsx +++ b/src/optimizer-page/scan-results/ScanResults.jsx @@ -1,10 +1,26 @@ +import { useState, useCallback } from 'react'; import { - Container, Layout, Button, Card, Collapsible, Icon, Table, CheckBox, OverlayTrigger, Tooltip, + Container, + Layout, + Button, + Card, + Collapsible, + Icon, + Table, + CheckBox, + OverlayTrigger, + Tooltip, } from '@openedx/paragon'; import { - ArrowRight, ArrowDropDown, OpenInNew, Question, Lock, LinkOff, + ArrowRight, + ArrowDropDown, + OpenInNew, + Question, + Lock, + LinkOff, } from '@openedx/paragon/icons'; -import { useState, useCallback } from 'react'; +import { useIntl } from '@edx/frontend-platform/i18n'; +import messages from './messages'; const SectionCollapsible = ({ title, children, redItalics, className, @@ -13,24 +29,27 @@ const SectionCollapsible = ({ const styling = 'card-lg'; const collapsibleTitle = (
- {title}{redItalics} + + {title} + {redItalics}
); return ( -
{collapsibleTitle}

} + title={( +

+ {collapsibleTitle} +

+ )} iconWhenClosed="" iconWhenOpen="" open={isOpen} onToggle={() => setIsOpen(!isOpen)} > - - {children} - + {children}
); @@ -47,26 +66,52 @@ function getBaseUrl(url) { const BrokenLinkHref = ({ href }) => ( ); -const GoToBlock = ({ block }) => Go to Block; - -const lockedInfoIcon = ( - - These course files are "locked" so we cannot test whether they work or not. - - )} - > - - +const GoToBlock = ({ block }) => ( + + + + Go to Block + + +); + +const LockedInfoIcon = () => { + const intl = useIntl(); + + return ( + + {intl.formatMessage(messages.lockedInfoTooltip)} + + )} + > + + + ); +}; + +const InfoCard = ({ text }) => ( + +

+ {text} +

+
); + const ScanResults = ({ data }) => { + const intl = useIntl(); const [showLockedLinks, setShowLockedLinks] = useState(true); const countBrokenLinksPerSection = useCallback(() => { @@ -86,14 +131,10 @@ const ScanResults = ({ data }) => { }, [data?.sections]); if (!data) { - return ( - -

Scan Results

- -

No data available

-
-
- ); + return ; + } + if (!data.sections) { + return ; } const { sections } = data; @@ -106,10 +147,18 @@ const ScanResults = ({ data }) => {
-

Broken Links Scan

+

{intl.formatMessage(messages.scanHeader)}

- { setShowLockedLinks(!showLockedLinks); }} label="Show Locked Course Files" /> - {lockedInfoIcon} + { + setShowLockedLinks(!showLockedLinks); + }} + label={intl.formatMessage(messages.lockedCheckboxLabel)} + /> +
@@ -118,31 +167,53 @@ const ScanResults = ({ data }) => { {section.subsections.map((subsection) => ( <> -

{subsection.displayName}

+

+ {subsection.displayName} +

{subsection.units.map((unit) => (

{unit.displayName}

{ - const blockBrokenLinks = block.brokenLinks.map((link) => ({ - blockLink: , - brokenLink: , - status: Status: Broken, - })); + 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: Status: LOCKED {lockedInfoIcon}, - })); + const blockLockedLinks = block.lockedLinks.map( + (link) => ({ + blockLink: , + brokenLink: , + status: ( + + + {intl.formatMessage(messages.lockedLinkStatus)} + + ), + }), + ); acc.push(...blockLockedLinks); return acc; }, [])} 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; From 08850e408e8abb0f416357e9fef681558b9f3f2e Mon Sep 17 00:00:00 2001 From: Jesper Hodge Date: Fri, 13 Dec 2024 14:28:42 -0500 Subject: [PATCH 16/27] refactor: extract components --- src/optimizer-page/SectionCollapsible.tsx | 51 ++++++++ .../scan-results/ScanResults.jsx | 118 ++++++++++-------- .../scan-results/ScanResults.scss | 2 +- 3 files changed, 121 insertions(+), 50 deletions(-) create mode 100644 src/optimizer-page/SectionCollapsible.tsx 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/scan-results/ScanResults.jsx b/src/optimizer-page/scan-results/ScanResults.jsx index 305053b22b..9f1947149a 100644 --- a/src/optimizer-page/scan-results/ScanResults.jsx +++ b/src/optimizer-page/scan-results/ScanResults.jsx @@ -1,10 +1,6 @@ import { useState, useCallback } from 'react'; import { - Container, - Layout, - Button, Card, - Collapsible, Icon, Table, CheckBox, @@ -12,8 +8,6 @@ import { Tooltip, } from '@openedx/paragon'; import { - ArrowRight, - ArrowDropDown, OpenInNew, Question, Lock, @@ -21,48 +15,7 @@ import { } from '@openedx/paragon/icons'; import { useIntl } from '@edx/frontend-platform/i18n'; import messages from './messages'; - -const SectionCollapsible = ({ - 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} -
-
- ); -}; - -function getBaseUrl(url) { - try { - const parsedUrl = new URL(url); - return `${parsedUrl.origin}`; - } catch (error) { - return null; - } -} +import SectionCollapsible from '../SectionCollapsible'; const BrokenLinkHref = ({ href }) => (
@@ -110,6 +63,73 @@ const InfoCard = ({ text }) => ( ); +const BrokenLinkTable = ({ unit }) => ( + <> +

{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, + }, + ]} + /> + +); + const ScanResults = ({ data }) => { const intl = useIntl(); const [showLockedLinks, setShowLockedLinks] = useState(true); @@ -179,7 +199,7 @@ const ScanResults = ({ data }) => { {subsection.units.map((unit) => (
-

{unit.displayName}

+

{unit.displayName}

{ const blockBrokenLinks = block.brokenLinks.map( diff --git a/src/optimizer-page/scan-results/ScanResults.scss b/src/optimizer-page/scan-results/ScanResults.scss index 2b772ff526..6a3e947657 100644 --- a/src/optimizer-page/scan-results/ScanResults.scss +++ b/src/optimizer-page/scan-results/ScanResults.scss @@ -44,7 +44,7 @@ } /* Unit Header */ - .block-header { + .unit-header { font-size: 14px; font-weight: 700; margin-bottom: 5px; From 31bfd103384bb69b4f98ec8eec9ada022bc251f3 Mon Sep 17 00:00:00 2001 From: Jesper Hodge Date: Fri, 13 Dec 2024 14:45:05 -0500 Subject: [PATCH 17/27] refactor: decouple components --- .../scan-results/ScanResults.jsx | 196 ++++++------------ 1 file changed, 69 insertions(+), 127 deletions(-) diff --git a/src/optimizer-page/scan-results/ScanResults.jsx b/src/optimizer-page/scan-results/ScanResults.jsx index 9f1947149a..392a85281b 100644 --- a/src/optimizer-page/scan-results/ScanResults.jsx +++ b/src/optimizer-page/scan-results/ScanResults.jsx @@ -63,72 +63,75 @@ const InfoCard = ({ text }) => ( ); -const BrokenLinkTable = ({ unit }) => ( - <> -

{unit.displayName}

-
{ - const blockBrokenLinks = block.brokenLinks.map( - (link) => ({ - blockLink: , - brokenLink: , - status: ( - - - {intl.formatMessage(messages.brokenLinkStatus)} - - ), - }), - ); - acc.push(...blockBrokenLinks); - if (!showLockedLinks) { +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; - } - - 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, - }, - ]} - /> - -); + }, [])} + 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, + }, + ]} + /> + + ); +}; const ScanResults = ({ data }) => { const intl = useIntl(); @@ -199,68 +202,7 @@ const ScanResults = ({ data }) => { {subsection.units.map((unit) => (
-

{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, - }, - ]} - /> + ))} From ddce9425af68959272b407afe1dffb75abc6fce0 Mon Sep 17 00:00:00 2001 From: Jesper Hodge Date: Fri, 13 Dec 2024 15:50:05 -0500 Subject: [PATCH 18/27] fix: scan stage display --- src/optimizer-page/CourseOptimizerPage.jsx | 4 +-- src/optimizer-page/data/constants.js | 15 ++++++++++ src/optimizer-page/data/thunks.js | 34 +++++++++++++--------- 3 files changed, 38 insertions(+), 15 deletions(-) diff --git a/src/optimizer-page/CourseOptimizerPage.jsx b/src/optimizer-page/CourseOptimizerPage.jsx index b6911271d2..f9393fdb4b 100644 --- a/src/optimizer-page/CourseOptimizerPage.jsx +++ b/src/optimizer-page/CourseOptimizerPage.jsx @@ -86,7 +86,7 @@ const CourseOptimizerPage = ({ intl, courseId }) => { ); } - console.log('courseOptimizerPage: linkCheckResult: ', linkCheckResult); + console.log('currentStage: ', currentStage); return ( <> @@ -138,7 +138,7 @@ const CourseOptimizerPage = ({ intl, courseId }) => { diff --git a/src/optimizer-page/data/constants.js b/src/optimizer-page/data/constants.js index 6b6d8f58f5..f912cd5191 100644 --- a/src/optimizer-page/data/constants.js +++ b/src/optimizer-page/data/constants.js @@ -8,9 +8,24 @@ export const LINK_CHECK_STATUSES = { 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/thunks.js b/src/optimizer-page/data/thunks.js index 20385bf977..061c9785e3 100644 --- a/src/optimizer-page/data/thunks.js +++ b/src/optimizer-page/data/thunks.js @@ -4,12 +4,15 @@ import { getConfig } from '@edx/frontend-platform'; import { RequestStatus } from '../../data/constants'; // import { setExportCookie } from '../utils'; -import { EXPORT_STAGES, LAST_EXPORT_COOKIE_NAME } from './constants'; - import { - postLinkCheck, - getLinkCheckStatus, -} from './api'; + EXPORT_STAGES, + LAST_EXPORT_COOKIE_NAME, + LINK_CHECK_FAILURE_STATUSES, + LINK_CHECK_IN_PROGRESS_STATUSES, + SCAN_STAGES, +} from './constants'; + +import { postLinkCheck, getLinkCheckStatus } from './api'; import { updateLinkCheckInProgress, updateLinkCheckResult, @@ -68,20 +71,23 @@ export function fetchLinkCheckStatus(courseId) { // return true; try { - const { - linkCheckStatus, - linkCheckOutput, - } = await getLinkCheckStatus(courseId); + const { linkCheckStatus, linkCheckOutput } = await getLinkCheckStatus( + courseId, + ); console.log('linkCheckOutput: ', linkCheckOutput); - if (linkCheckStatus === 1 || linkCheckStatus === 2) { + if (LINK_CHECK_IN_PROGRESS_STATUSES.includes(linkCheckStatus)) { dispatch(updateLinkCheckInProgress(true)); } else { dispatch(updateLinkCheckInProgress(false)); } - dispatch(updateCurrentStage(linkCheckStatus)); + dispatch(updateCurrentStage(SCAN_STAGES[linkCheckStatus])); - if (linkCheckStatus === undefined || linkCheckStatus === null || linkCheckStatus < 0) { + if ( + linkCheckStatus === undefined + || linkCheckStatus === null + || LINK_CHECK_FAILURE_STATUSES.includes(linkCheckStatus) + ) { dispatch(updateError({ msg: 'Link Check Failed' })); dispatch(updateIsErrorModalOpen(true)); } @@ -96,7 +102,9 @@ export function fetchLinkCheckStatus(courseId) { if (error?.response && error?.response.status === 403) { dispatch(updateLoadingStatus({ status: RequestStatus.DENIED })); } else { - dispatch(updateLoadingStatus({ courseId, status: RequestStatus.FAILED })); + dispatch( + updateLoadingStatus({ courseId, status: RequestStatus.FAILED }), + ); } return false; } From daba24eaac2d550cbc08447b700e17446c7d24e6 Mon Sep 17 00:00:00 2001 From: Jesper Hodge Date: Sat, 14 Dec 2024 18:22:41 -0500 Subject: [PATCH 19/27] refactor: utilize typescript --- src/optimizer-page/CourseOptimizerPage.jsx | 29 ++-- src/optimizer-page/data/{api.js => api.ts} | 12 +- .../data/{constants.js => constants.ts} | 9 ++ src/optimizer-page/data/selectors.js | 9 -- src/optimizer-page/data/selectors.ts | 11 ++ .../data/{slice.js => slice.ts} | 21 ++- .../data/{thunks.js => thunks.ts} | 27 +--- src/optimizer-page/mocks/mockApiResponse.js | 7 + .../scan-results/BrokenLinkTable.jsx | 108 +++++++++++++++ .../scan-results/LockedInfoIcon.jsx | 30 ++++ .../scan-results/ScanResults.jsx | 130 ++---------------- src/optimizer-page/types.ts | 26 ++++ 12 files changed, 244 insertions(+), 175 deletions(-) rename src/optimizer-page/data/{api.js => api.ts} (63%) rename src/optimizer-page/data/{constants.js => constants.ts} (79%) delete mode 100644 src/optimizer-page/data/selectors.js create mode 100644 src/optimizer-page/data/selectors.ts rename src/optimizer-page/data/{slice.js => slice.ts} (74%) rename src/optimizer-page/data/{thunks.js => thunks.ts} (75%) create mode 100644 src/optimizer-page/scan-results/BrokenLinkTable.jsx create mode 100644 src/optimizer-page/scan-results/LockedInfoIcon.jsx create mode 100644 src/optimizer-page/types.ts diff --git a/src/optimizer-page/CourseOptimizerPage.jsx b/src/optimizer-page/CourseOptimizerPage.jsx index f9393fdb4b..9ec212c052 100644 --- a/src/optimizer-page/CourseOptimizerPage.jsx +++ b/src/optimizer-page/CourseOptimizerPage.jsx @@ -1,7 +1,7 @@ import { useEffect, useRef } from 'react'; import PropTypes from 'prop-types'; import { useDispatch, useSelector } from 'react-redux'; -import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; +import { useIntl } from '@edx/frontend-platform/i18n'; import { Container, Layout, Button, Card, } from '@openedx/paragon'; @@ -27,7 +27,7 @@ const pollLinkCheckStatus = (dispatch, courseId, delay) => { return interval; }; -const CourseOptimizerPage = ({ intl, courseId }) => { +const CourseOptimizerPage = ({ courseId }) => { const dispatch = useDispatch(); const linkCheckInProgress = useSelector(getLinkCheckInProgress); const loadingStatus = useSelector(getLoadingStatus); @@ -39,6 +39,7 @@ const CourseOptimizerPage = ({ intl, courseId }) => { const interval = useRef(null); const courseDetails = useModel('courseDetails', courseId); const linkCheckPresent = !!currentStage; + const intl = useIntl(); const courseStepperSteps = [ { @@ -58,11 +59,7 @@ const CourseOptimizerPage = ({ intl, courseId }) => { }, ]; - useEffect(() => { - dispatch(fetchLinkCheckStatus(courseId)); - }, []); - - useEffect(() => { + const pollLinkCheckStatusDuringScan = () => { if (linkCheckInProgress === null || linkCheckInProgress || !linkCheckResult) { clearInterval(interval.current); interval.current = pollLinkCheckStatus(dispatch, courseId, 2000); @@ -70,6 +67,14 @@ const CourseOptimizerPage = ({ intl, courseId }) => { clearInterval(interval.current); interval.current = null; } + }; + + useEffect(() => { + dispatch(fetchLinkCheckStatus(courseId)); + }, []); + + useEffect(() => { + pollLinkCheckStatusDuringScan(); return () => { if (interval.current) { clearInterval(interval.current); } @@ -86,8 +91,6 @@ const CourseOptimizerPage = ({ intl, courseId }) => { ); } - console.log('currentStage: ', currentStage); - return ( <> @@ -155,10 +158,4 @@ const CourseOptimizerPage = ({ intl, courseId }) => { ); }; -CourseOptimizerPage.propTypes = { - intl: intlShape.isRequired, - courseId: PropTypes.string.isRequired, -}; - -CourseOptimizerPage.defaultProps = {}; -export default injectIntl(CourseOptimizerPage); +export default CourseOptimizerPage; diff --git a/src/optimizer-page/data/api.js b/src/optimizer-page/data/api.ts similarity index 63% rename from src/optimizer-page/data/api.js rename to src/optimizer-page/data/api.ts index b213782e3b..3d7d89e2c6 100644 --- a/src/optimizer-page/data/api.js +++ b/src/optimizer-page/data/api.ts @@ -1,18 +1,24 @@ import { camelCaseObject, getConfig } from '@edx/frontend-platform'; import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; -import mockApiResponse from '../mocks/mockApiResponse'; +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) { +export async function postLinkCheck(courseId: string): Promise<{ linkCheckStatus: LinkCheckStatusTypes }> { const { data } = await getAuthenticatedHttpClient() .post(postLinkCheckCourseApiUrl(courseId)); return camelCaseObject(data); } -export async function getLinkCheckStatus(courseId) { +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.js b/src/optimizer-page/data/constants.ts similarity index 79% rename from src/optimizer-page/data/constants.js rename to src/optimizer-page/data/constants.ts index f912cd5191..0ad3006d10 100644 --- a/src/optimizer-page/data/constants.js +++ b/src/optimizer-page/data/constants.ts @@ -8,6 +8,15 @@ export const LINK_CHECK_STATUSES = { 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, diff --git a/src/optimizer-page/data/selectors.js b/src/optimizer-page/data/selectors.js deleted file mode 100644 index cadb28717e..0000000000 --- a/src/optimizer-page/data/selectors.js +++ /dev/null @@ -1,9 +0,0 @@ -export const getLinkCheckInProgress = (state) => state.courseOptimizer.linkCheckInProgress; -export const getCurrentStage = (state) => state.courseOptimizer.currentStage; -export const getDownloadPath = (state) => state.courseOptimizer.downloadPath; -export const getSuccessDate = (state) => state.courseOptimizer.successDate; -export const getError = (state) => state.courseOptimizer.error; -export const getIsErrorModalOpen = (state) => state.courseOptimizer.isErrorModalOpen; -export const getLoadingStatus = (state) => state.courseOptimizer.loadingStatus; -export const getSavingStatus = (state) => state.courseOptimizer.savingStatus; -export const getLinkCheckResult = (state) => state.courseOptimizer.linkCheckResult; 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.js b/src/optimizer-page/data/slice.ts similarity index 74% rename from src/optimizer-page/data/slice.js rename to src/optimizer-page/data/slice.ts index 052e4af1eb..e38b3d0262 100644 --- a/src/optimizer-page/data/slice.js +++ b/src/optimizer-page/data/slice.ts @@ -1,7 +1,26 @@ /* eslint-disable no-param-reassign */ import { createSlice } from '@reduxjs/toolkit'; +import { LinkCheckResult } from '../types'; -const initialState = { +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, diff --git a/src/optimizer-page/data/thunks.js b/src/optimizer-page/data/thunks.ts similarity index 75% rename from src/optimizer-page/data/thunks.js rename to src/optimizer-page/data/thunks.ts index 061c9785e3..ebeb9eaf1a 100644 --- a/src/optimizer-page/data/thunks.js +++ b/src/optimizer-page/data/thunks.ts @@ -1,12 +1,5 @@ -import Cookies from 'universal-cookie'; -import moment from 'moment'; -import { getConfig } from '@edx/frontend-platform'; - import { RequestStatus } from '../../data/constants'; -// import { setExportCookie } from '../utils'; import { - EXPORT_STAGES, - LAST_EXPORT_COOKIE_NAME, LINK_CHECK_FAILURE_STATUSES, LINK_CHECK_IN_PROGRESS_STATUSES, SCAN_STAGES, @@ -19,27 +12,11 @@ import { updateCurrentStage, updateError, updateIsErrorModalOpen, - reset, updateLoadingStatus, updateSavingStatus, } from './slice'; -// function setExportDate({ -// date, exportStatus, exportOutput, dispatch, -// }) { -// // If there is no cookie for the last export date, set it now. -// const cookies = new Cookies(); -// const cookieData = cookies.get(LAST_EXPORT_COOKIE_NAME); -// if (!cookieData?.completed) { -// // setExportCookie(date, exportStatus === EXPORT_STAGES.SUCCESS); -// } -// // If we don't have export date set yet via cookie, set success date to current date. -// if (exportOutput && !cookieData?.completed) { -// dispatch(updateSuccessDate(date)); -// } -// } - -export function startLinkCheck(courseId) { +export function startLinkCheck(courseId: string) { return async (dispatch) => { dispatch(updateSavingStatus({ status: RequestStatus.PENDING })); dispatch(updateLinkCheckInProgress(true)); @@ -98,7 +75,7 @@ export function fetchLinkCheckStatus(courseId) { dispatch(updateLoadingStatus({ status: RequestStatus.SUCCESSFUL })); return true; - } catch (error) { + } catch (error: any) { if (error?.response && error?.response.status === 403) { dispatch(updateLoadingStatus({ status: RequestStatus.DENIED })); } else { diff --git a/src/optimizer-page/mocks/mockApiResponse.js b/src/optimizer-page/mocks/mockApiResponse.js index e7aa121c59..95826650f7 100644 --- a/src/optimizer-page/mocks/mockApiResponse.js +++ b/src/optimizer-page/mocks/mockApiResponse.js @@ -18,11 +18,13 @@ const mockApiResponse = { 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'], }, ], }, @@ -34,6 +36,7 @@ const mockApiResponse = { 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'], }, ], }, @@ -51,11 +54,13 @@ const mockApiResponse = { 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'], }, ], }, @@ -79,11 +84,13 @@ const mockApiResponse = { 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'], }, ], }, 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 index 392a85281b..0632737935 100644 --- a/src/optimizer-page/scan-results/ScanResults.jsx +++ b/src/optimizer-page/scan-results/ScanResults.jsx @@ -1,56 +1,13 @@ -import { useState, useCallback } from 'react'; +import { useState, useMemo } 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'; - -const BrokenLinkHref = ({ href }) => ( - -); - -const GoToBlock = ({ block }) => ( - - - - Go to Block - - -); - -const LockedInfoIcon = () => { - const intl = useIntl(); - - return ( - - {intl.formatMessage(messages.lockedInfoTooltip)} - - )} - > - - - ); -}; +import BrokenLinkTable from './BrokenLinkTable'; +import LockedInfoIcon from './LockedInfoIcon'; const InfoCard = ({ text }) => ( @@ -63,83 +20,16 @@ const InfoCard = ({ text }) => ( ); -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, - }, - ]} - /> - - ); -}; - -const ScanResults = ({ data }) => { +export const ScanResults = ({ data }) => { const intl = useIntl(); const [showLockedLinks, setShowLockedLinks] = useState(true); - const countBrokenLinksPerSection = useCallback(() => { + const brokenLinkCounts = useMemo(() => { + if (!data?.sections) { + return []; + } const counts = []; - sections.forEach((section) => { + data.sections.forEach((section) => { let count = 0; section.subsections.forEach((subsection) => { subsection.units.forEach((unit) => { @@ -164,8 +54,6 @@ const ScanResults = ({ data }) => { console.log('data: ', data); console.log('sections: ', sections); - const brokenLinkCounts = countBrokenLinksPerSection(); - return (
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[]; +} From 0b8f9c01e35f7dfa429e5bb650a343ae391f3615 Mon Sep 17 00:00:00 2001 From: Jesper Hodge Date: Mon, 16 Dec 2024 11:09:04 -0500 Subject: [PATCH 20/27] fix: lint --- .../scan-results/BrokenLinkTable.jsx | 2 +- src/optimizer-page/scan-results/ScanResults.scss | 15 +++++---------- 2 files changed, 6 insertions(+), 11 deletions(-) diff --git a/src/optimizer-page/scan-results/BrokenLinkTable.jsx b/src/optimizer-page/scan-results/BrokenLinkTable.jsx index e36a7be302..838ea45d6f 100644 --- a/src/optimizer-page/scan-results/BrokenLinkTable.jsx +++ b/src/optimizer-page/scan-results/BrokenLinkTable.jsx @@ -39,7 +39,7 @@ const BrokenLinkTable = ({ unit, showLockedLinks }) => { const intl = useIntl(); return ( <> -

{unit.displayName}

+

{unit.displayName}

{ const blockBrokenLinks = block.brokenLinks.map( diff --git a/src/optimizer-page/scan-results/ScanResults.scss b/src/optimizer-page/scan-results/ScanResults.scss index 6a3e947657..aba547ef39 100644 --- a/src/optimizer-page/scan-results/ScanResults.scss +++ b/src/optimizer-page/scan-results/ScanResults.scss @@ -16,6 +16,7 @@ &:not(:first-child) { margin-top: 1rem; } + margin-bottom: 1rem; } } @@ -29,25 +30,19 @@ .subsection-header { font-size: 16px; /* Slightly smaller */ font-weight: 600; /* Reduced boldness */ - background-color: rgb(248, 247, 246); /* Subtle gray background */ + background-color: $dark-100; 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; + color: $primary-500; } /* Block Links */ @@ -83,7 +78,7 @@ } .locked-links-checkbox { - margin-top: 0.45rem; + margin-top: .45rem; } .locked-links-checkbox-wrapper { @@ -94,7 +89,7 @@ .link-status-text { display: flex; align-items: center; - gap: 0.5rem; + gap: .5rem; } .broken-link-icon { From 28ec193c212d6d291a91fc623880ca64bfb403f0 Mon Sep 17 00:00:00 2001 From: Jesper Hodge Date: Mon, 16 Dec 2024 11:30:56 -0500 Subject: [PATCH 21/27] fix: types --- src/optimizer-page/CourseOptimizerPage.jsx | 5 +- src/optimizer-page/SectionCollapsible.tsx | 4 +- src/optimizer-page/data/api.test.js | 47 ------ src/optimizer-page/data/selectors.ts | 2 +- src/optimizer-page/data/thunks.test.js | 146 ------------------ src/optimizer-page/data/thunks.ts | 1 - ...rokenLinkTable.jsx => BrokenLinkTable.tsx} | 82 +++++----- .../{ScanResults.jsx => ScanResults.tsx} | 15 +- src/optimizer-page/scan-results/index.js | 3 +- src/optimizer-page/types.ts | 6 +- 10 files changed, 62 insertions(+), 249 deletions(-) delete mode 100644 src/optimizer-page/data/api.test.js delete mode 100644 src/optimizer-page/data/thunks.test.js rename src/optimizer-page/scan-results/{BrokenLinkTable.jsx => BrokenLinkTable.tsx} (61%) rename src/optimizer-page/scan-results/{ScanResults.jsx => ScanResults.tsx} (90%) diff --git a/src/optimizer-page/CourseOptimizerPage.jsx b/src/optimizer-page/CourseOptimizerPage.jsx index 9ec212c052..6606c1f78c 100644 --- a/src/optimizer-page/CourseOptimizerPage.jsx +++ b/src/optimizer-page/CourseOptimizerPage.jsx @@ -18,7 +18,7 @@ import { } from './data/selectors'; import { startLinkCheck, fetchLinkCheckStatus } from './data/thunks'; import { useModel } from '../generic/model-store'; -import { ScanResults } from './scan-results'; +import ScanResults from './scan-results'; const pollLinkCheckStatus = (dispatch, courseId, delay) => { const interval = setInterval(() => { @@ -157,5 +157,8 @@ const CourseOptimizerPage = ({ courseId }) => { ); }; +CourseOptimizerPage.propTypes = { + courseId: PropTypes.string.isRequired, +}; export default CourseOptimizerPage; diff --git a/src/optimizer-page/SectionCollapsible.tsx b/src/optimizer-page/SectionCollapsible.tsx index 418b29b2f8..83c1e382e2 100644 --- a/src/optimizer-page/SectionCollapsible.tsx +++ b/src/optimizer-page/SectionCollapsible.tsx @@ -12,11 +12,11 @@ interface Props { title: string; children: React.ReactNode; redItalics: string; - className: string; + className?: string; } const SectionCollapsible: FC = ({ - title, children, redItalics, className, + title, children, redItalics, className = '', }) => { const [isOpen, setIsOpen] = useState(false); const styling = 'card-lg'; diff --git a/src/optimizer-page/data/api.test.js b/src/optimizer-page/data/api.test.js deleted file mode 100644 index 70acc8b243..0000000000 --- a/src/optimizer-page/data/api.test.js +++ /dev/null @@ -1,47 +0,0 @@ -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/selectors.ts b/src/optimizer-page/data/selectors.ts index cef0f0babf..79a80a077a 100644 --- a/src/optimizer-page/data/selectors.ts +++ b/src/optimizer-page/data/selectors.ts @@ -1,4 +1,4 @@ -import { RootState } from "./slice"; +import { RootState } from './slice'; export const getLinkCheckInProgress = (state: RootState) => state.courseOptimizer.linkCheckInProgress; export const getCurrentStage = (state: RootState) => state.courseOptimizer.currentStage; diff --git a/src/optimizer-page/data/thunks.test.js b/src/optimizer-page/data/thunks.test.js deleted file mode 100644 index e8dd9762f3..0000000000 --- a/src/optimizer-page/data/thunks.test.js +++ /dev/null @@ -1,146 +0,0 @@ -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 index ebeb9eaf1a..2baf1d6269 100644 --- a/src/optimizer-page/data/thunks.ts +++ b/src/optimizer-page/data/thunks.ts @@ -51,7 +51,6 @@ export function fetchLinkCheckStatus(courseId) { const { linkCheckStatus, linkCheckOutput } = await getLinkCheckStatus( courseId, ); - console.log('linkCheckOutput: ', linkCheckOutput); if (LINK_CHECK_IN_PROGRESS_STATUSES.includes(linkCheckStatus)) { dispatch(updateLinkCheckInProgress(true)); } else { diff --git a/src/optimizer-page/scan-results/BrokenLinkTable.jsx b/src/optimizer-page/scan-results/BrokenLinkTable.tsx similarity index 61% rename from src/optimizer-page/scan-results/BrokenLinkTable.jsx rename to src/optimizer-page/scan-results/BrokenLinkTable.tsx index 838ea45d6f..d528e7376e 100644 --- a/src/optimizer-page/scan-results/BrokenLinkTable.jsx +++ b/src/optimizer-page/scan-results/BrokenLinkTable.tsx @@ -1,24 +1,12 @@ -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 { 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 SectionCollapsible from '../SectionCollapsible'; import LockedInfoIcon from './LockedInfoIcon'; -const BrokenLinkHref = ({ href }) => ( +const BrokenLinkHref: FC<{ href: string }> = ({ href }) => ( ); -const GoToBlock = ({ block }) => ( +const GoToBlock: FC<{ block: { url: string } }> = ({ block }) => ( @@ -35,48 +23,62 @@ const GoToBlock = ({ block }) => ( ); -const BrokenLinkTable = ({ unit, showLockedLinks }) => { +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) => ({ + data={unit.blocks.reduce( + ( + acc: TableData, + block, + ) => { + const blockBrokenLinks = block.brokenLinks.map((link) => ({ blockLink: , brokenLink: , status: ( - + {intl.formatMessage(messages.brokenLinkStatus)} ), - }), - ); - acc.push(...blockBrokenLinks); - if (!showLockedLinks) { - return acc; - } + })); + acc.push(...blockBrokenLinks); + if (!showLockedLinks) { + return acc; + } - const blockLockedLinks = block.lockedLinks.map( - (link) => ({ + const blockLockedLinks = block.lockedLinks.map((link) => ({ blockLink: , brokenLink: , status: ( - {intl.formatMessage(messages.lockedLinkStatus)} + {intl.formatMessage(messages.lockedLinkStatus)}{' '} + ), - }), - ); - acc.push(...blockLockedLinks); - return acc; - }, [])} + })); + acc.push(...blockLockedLinks); + return acc; + }, + [], + )} columns={[ { key: 'blockLink', diff --git a/src/optimizer-page/scan-results/ScanResults.jsx b/src/optimizer-page/scan-results/ScanResults.tsx similarity index 90% rename from src/optimizer-page/scan-results/ScanResults.jsx rename to src/optimizer-page/scan-results/ScanResults.tsx index 0632737935..e54fdcf895 100644 --- a/src/optimizer-page/scan-results/ScanResults.jsx +++ b/src/optimizer-page/scan-results/ScanResults.tsx @@ -1,4 +1,4 @@ -import { useState, useMemo } from 'react'; +import { useState, useMemo, FC } from 'react'; import { Card, CheckBox, @@ -8,8 +8,9 @@ import messages from './messages'; import SectionCollapsible from '../SectionCollapsible'; import BrokenLinkTable from './BrokenLinkTable'; import LockedInfoIcon from './LockedInfoIcon'; +import { LinkCheckResult } from 'optimizer-page/types'; -const InfoCard = ({ text }) => ( +const InfoCard: FC<{ text: string}> = ({ text }) => (

( ); -export const ScanResults = ({ data }) => { +interface Props { + data: LinkCheckResult | null; +}; + +export const ScanResults: FC = ({ data }) => { const intl = useIntl(); const [showLockedLinks, setShowLockedLinks] = useState(true); @@ -28,7 +33,7 @@ export const ScanResults = ({ data }) => { if (!data?.sections) { return []; } - const counts = []; + const counts: number[] = []; data.sections.forEach((section) => { let count = 0; section.subsections.forEach((subsection) => { @@ -51,8 +56,6 @@ export const ScanResults = ({ data }) => { } const { sections } = data; - console.log('data: ', data); - console.log('sections: ', sections); return (
diff --git a/src/optimizer-page/scan-results/index.js b/src/optimizer-page/scan-results/index.js index 6edc85d9ea..ab1d4b80ba 100644 --- a/src/optimizer-page/scan-results/index.js +++ b/src/optimizer-page/scan-results/index.js @@ -1,4 +1,3 @@ import ScanResults from './ScanResults'; -// eslint-disable-next-line import/prefer-default-export -export { ScanResults }; +export default ScanResults; diff --git a/src/optimizer-page/types.ts b/src/optimizer-page/types.ts index f6e2a1a456..e5034e889c 100644 --- a/src/optimizer-page/types.ts +++ b/src/optimizer-page/types.ts @@ -1,4 +1,4 @@ -interface Unit { +export interface Unit { id: string; displayName: string; blocks: { @@ -9,13 +9,13 @@ interface Unit { }[]; } -interface SubSection { +export interface SubSection { id: string; displayName: string; units: Unit[]; } -interface Section { +export interface Section { id: string; displayName: string; subsections: SubSection[]; From 1431a1a9ae1054f7a877f3b1025babbb1e63d100 Mon Sep 17 00:00:00 2001 From: Jesper Hodge Date: Mon, 16 Dec 2024 12:31:51 -0500 Subject: [PATCH 22/27] fix: lint --- src/optimizer-page/scan-results/ScanResults.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/optimizer-page/scan-results/ScanResults.tsx b/src/optimizer-page/scan-results/ScanResults.tsx index e54fdcf895..a1a673cc37 100644 --- a/src/optimizer-page/scan-results/ScanResults.tsx +++ b/src/optimizer-page/scan-results/ScanResults.tsx @@ -8,9 +8,9 @@ import messages from './messages'; import SectionCollapsible from '../SectionCollapsible'; import BrokenLinkTable from './BrokenLinkTable'; import LockedInfoIcon from './LockedInfoIcon'; -import { LinkCheckResult } from 'optimizer-page/types'; +import { LinkCheckResult } from '../types'; -const InfoCard: FC<{ text: string}> = ({ text }) => ( +const InfoCard: FC<{ text: string }> = ({ text }) => (

= ({ text }) => ( interface Props { data: LinkCheckResult | null; -}; +} -export const ScanResults: FC = ({ data }) => { +const ScanResults: FC = ({ data }) => { const intl = useIntl(); const [showLockedLinks, setShowLockedLinks] = useState(true); From d431762de12edd6309889e580b3358076d905dc9 Mon Sep 17 00:00:00 2001 From: Jesper Hodge Date: Mon, 16 Dec 2024 17:00:05 -0500 Subject: [PATCH 23/27] test: api --- src/optimizer-page/data/api.test.js | 34 +++++++++++++++++++++ src/optimizer-page/mocks/mockApiResponse.js | 5 +-- 2 files changed, 37 insertions(+), 2 deletions(-) create mode 100644 src/optimizer-page/data/api.test.js diff --git a/src/optimizer-page/data/api.test.js b/src/optimizer-page/data/api.test.js new file mode 100644 index 0000000000..09a1f52fa0 --- /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/mocks/mockApiResponse.js b/src/optimizer-page/mocks/mockApiResponse.js index 95826650f7..455e76a57e 100644 --- a/src/optimizer-page/mocks/mockApiResponse.js +++ b/src/optimizer-page/mocks/mockApiResponse.js @@ -1,6 +1,7 @@ const mockApiResponse = { - linkCheckStatus: 200, - linkCheckOutput: { + LinkCheckStatus: 200, + LinkCheckCreatedAt: '2024-12-14T00:26:50.838350Z', + LinkCheckOutput: { sections: [ { id: 'section-1', From 67fc3928df522d41a25f4080dc3335072c07b9f9 Mon Sep 17 00:00:00 2001 From: Jesper Hodge Date: Tue, 17 Dec 2024 15:17:44 -0500 Subject: [PATCH 24/27] test: thunks --- src/optimizer-page/data/thunks.test.js | 71 +++++++++++++++++++++ src/optimizer-page/data/thunks.ts | 11 ++-- src/optimizer-page/mocks/mockApiResponse.js | 2 +- 3 files changed, 77 insertions(+), 7 deletions(-) create mode 100644 src/optimizer-page/data/thunks.test.js diff --git a/src/optimizer-page/data/thunks.test.js b/src/optimizer-page/data/thunks.test.js new file mode 100644 index 0000000000..2038bce61f --- /dev/null +++ b/src/optimizer-page/data/thunks.test.js @@ -0,0 +1,71 @@ +import { startLinkCheck, fetchLinkCheckStatus } from "./thunks"; +import * as api from "./api"; +import { LINK_CHECK_STATUSES } from "./constants"; +import { RequestStatus } from "../../data/constants"; + +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.mock.calls.filter( + (call) => + call[0].type === "courseOptimizer/updateCurrentStage" && + call[0].payload === inProgressStageId + ).length + ).toBe(2); + }); + }); + + describe("failed request should set stage and request ", () => { + it("should set request status to failed", async () => { + const failureStageId = -1; + 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", + }); + }); + }); +}); diff --git a/src/optimizer-page/data/thunks.ts b/src/optimizer-page/data/thunks.ts index 2baf1d6269..83a55a7785 100644 --- a/src/optimizer-page/data/thunks.ts +++ b/src/optimizer-page/data/thunks.ts @@ -2,6 +2,7 @@ import { RequestStatus } from '../../data/constants'; import { LINK_CHECK_FAILURE_STATUSES, LINK_CHECK_IN_PROGRESS_STATUSES, + LINK_CHECK_STATUSES, SCAN_STAGES, } from './constants'; @@ -20,18 +21,16 @@ export function startLinkCheck(courseId: string) { return async (dispatch) => { dispatch(updateSavingStatus({ status: RequestStatus.PENDING })); dispatch(updateLinkCheckInProgress(true)); - dispatch(updateCurrentStage(1)); + dispatch(updateCurrentStage(SCAN_STAGES[LINK_CHECK_STATUSES.PENDING])); 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 })); + await dispatch(updateCurrentStage(SCAN_STAGES[data.linkCheckStatus])); + 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; } }; diff --git a/src/optimizer-page/mocks/mockApiResponse.js b/src/optimizer-page/mocks/mockApiResponse.js index 455e76a57e..19911a0168 100644 --- a/src/optimizer-page/mocks/mockApiResponse.js +++ b/src/optimizer-page/mocks/mockApiResponse.js @@ -1,5 +1,5 @@ const mockApiResponse = { - LinkCheckStatus: 200, + LinkCheckStatus: 'In-Progress', LinkCheckCreatedAt: '2024-12-14T00:26:50.838350Z', LinkCheckOutput: { sections: [ From 780d05b87f2fa0c4c55145364a8f2e470f07c052 Mon Sep 17 00:00:00 2001 From: Jesper Hodge Date: Wed, 18 Dec 2024 12:56:52 -0500 Subject: [PATCH 25/27] test: thunks --- src/optimizer-page/data/thunks.test.js | 101 ++++++++++++++++++++ src/optimizer-page/data/thunks.ts | 7 +- src/optimizer-page/mocks/mockApiResponse.js | 2 +- 3 files changed, 105 insertions(+), 5 deletions(-) diff --git a/src/optimizer-page/data/thunks.test.js b/src/optimizer-page/data/thunks.test.js index 2038bce61f..245bfdb96c 100644 --- a/src/optimizer-page/data/thunks.test.js +++ b/src/optimizer-page/data/thunks.test.js @@ -2,6 +2,7 @@ import { startLinkCheck, fetchLinkCheckStatus } from "./thunks"; import * as api from "./api"; import { LINK_CHECK_STATUSES } from "./constants"; import { RequestStatus } from "../../data/constants"; +import mockApiResponse from "../mocks/mockApiResponse"; describe("startLinkCheck thunk", () => { const dispatch = jest.fn(); @@ -69,3 +70,103 @@ describe("startLinkCheck thunk", () => { }); }); }); + +describe("fetchLinkCheckStatus thunk", () => { + describe("successful request", () => { + it("should return scan result", async () => { + const dispatch = jest.fn(); + const getState = jest.fn(); + const courseId = "course-123"; + const mockGetLinkCheckStatus = 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", + }); + }); + }); + + describe("failed request", () => { + it("should set request status to failed", async () => { + const dispatch = jest.fn(); + const getState = jest.fn(); + const courseId = "course-123"; + const mockGetLinkCheckStatus = jest + .spyOn(api, "getLinkCheckStatus") + .mockRejectedValue(new Error("error")); + + await fetchLinkCheckStatus(courseId)(dispatch, getState); + + expect(dispatch).toHaveBeenCalledWith({ + payload: { status: RequestStatus.FAILED }, + type: "courseOptimizer/updateLoadingStatus", + }); + }); + }); + + describe("failed scan", () => { + it("should set error message", async () => { + const mockGetLinkCheckStatus = jest + .spyOn(api, "getLinkCheckStatus") + .mockResolvedValue({ + linkCheckStatus: LINK_CHECK_STATUSES.FAILED, + linkCheckOutput: mockApiResponse.LinkCheckOutput, + linkCheckCreatedAt: mockApiResponse.LinkCheckCreatedAt, + }); + + const dispatch = jest.fn(); + const getState = jest.fn(); + const courseId = "course-123"; + + 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 index 83a55a7785..99f8689606 100644 --- a/src/optimizer-page/data/thunks.ts +++ b/src/optimizer-page/data/thunks.ts @@ -55,6 +55,7 @@ export function fetchLinkCheckStatus(courseId) { } else { dispatch(updateLinkCheckInProgress(false)); } + console.log('linkCheckStatus:', linkCheckStatus); dispatch(updateCurrentStage(SCAN_STAGES[linkCheckStatus])); @@ -65,9 +66,7 @@ export function fetchLinkCheckStatus(courseId) { ) { dispatch(updateError({ msg: 'Link Check Failed' })); dispatch(updateIsErrorModalOpen(true)); - } - - if (linkCheckOutput) { + } else if (linkCheckOutput) { dispatch(updateLinkCheckResult(linkCheckOutput)); } @@ -78,7 +77,7 @@ export function fetchLinkCheckStatus(courseId) { dispatch(updateLoadingStatus({ status: RequestStatus.DENIED })); } else { dispatch( - updateLoadingStatus({ courseId, status: RequestStatus.FAILED }), + updateLoadingStatus({ status: RequestStatus.FAILED }), ); } return false; diff --git a/src/optimizer-page/mocks/mockApiResponse.js b/src/optimizer-page/mocks/mockApiResponse.js index 19911a0168..dd3b54e399 100644 --- a/src/optimizer-page/mocks/mockApiResponse.js +++ b/src/optimizer-page/mocks/mockApiResponse.js @@ -1,5 +1,5 @@ const mockApiResponse = { - LinkCheckStatus: 'In-Progress', + LinkCheckStatus: 'Succeeded', LinkCheckCreatedAt: '2024-12-14T00:26:50.838350Z', LinkCheckOutput: { sections: [ From ad83a222205aa7931da76932dc4c3ae2fa2a4dfb Mon Sep 17 00:00:00 2001 From: Jesper Hodge Date: Wed, 18 Dec 2024 15:12:45 -0500 Subject: [PATCH 26/27] test: add count broken links function test --- .../scan-results/ScanResults.tsx | 20 ++++------------- src/optimizer-page/utils.test.js | 0 src/optimizer-page/utils.ts | 22 +++++++++++++++++++ 3 files changed, 26 insertions(+), 16 deletions(-) create mode 100644 src/optimizer-page/utils.test.js create mode 100644 src/optimizer-page/utils.ts diff --git a/src/optimizer-page/scan-results/ScanResults.tsx b/src/optimizer-page/scan-results/ScanResults.tsx index a1a673cc37..b0c7cfa4b4 100644 --- a/src/optimizer-page/scan-results/ScanResults.tsx +++ b/src/optimizer-page/scan-results/ScanResults.tsx @@ -9,6 +9,7 @@ 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 }) => ( @@ -25,27 +26,14 @@ interface Props { data: LinkCheckResult | null; } + + const ScanResults: FC = ({ data }) => { const intl = useIntl(); const [showLockedLinks, setShowLockedLinks] = useState(true); const brokenLinkCounts = useMemo(() => { - if (!data?.sections) { - return []; - } - const counts: number[] = []; - 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; + return countBrokenLinks(data); }, [data?.sections]); if (!data) { diff --git a/src/optimizer-page/utils.test.js b/src/optimizer-page/utils.test.js new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/optimizer-page/utils.ts b/src/optimizer-page/utils.ts new file mode 100644 index 0000000000..19a626247c --- /dev/null +++ b/src/optimizer-page/utils.ts @@ -0,0 +1,22 @@ +import { LinkCheckResult } from './types'; + +const countBrokenLinks = (data: LinkCheckResult | null): number[] => { + if (!data?.sections) { + return []; + } + const counts: number[] = []; + 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; +}; + +export default countBrokenLinks; From a98dc51621f7183f2aa089c5b7477e7037de8a50 Mon Sep 17 00:00:00 2001 From: Jesper Hodge Date: Wed, 18 Dec 2024 16:26:35 -0500 Subject: [PATCH 27/27] test: count broken links --- src/optimizer-page/utils.test.js | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/src/optimizer-page/utils.test.js b/src/optimizer-page/utils.test.js index e69de29bb2..d689ac3eef 100644 --- a/src/optimizer-page/utils.test.js +++ b/src/optimizer-page/utils.test.js @@ -0,0 +1,32 @@ +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([5, 2]); + }); + + it('should return 0 if there are no broken links', () => { + const data = { + sections: [ + { + subsections: [ + { + units: [ + { + blocks: [ + { + brokenLinks: [], + }, + ], + }, + ], + }, + ], + }, + ], + }; + expect(countBrokenLinks(data)).toStrictEqual([0]); + }); +});