From 24589a62934f1c81837750973162b361bb900c5e Mon Sep 17 00:00:00 2001
From: Jesper Hodge
Date: Wed, 20 Nov 2024 17:05:21 -0500
Subject: [PATCH 01/43] 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/43] 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/43] 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/43] 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/43] 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/43] 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/43] 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 = (
+
+ );
+
+ 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 (
+ <>
+
+
+
+
+ {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/43] 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/43] 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 (
- <>
+
@@ -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/43] 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/43] 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/43] 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 }) => (
+
+);
+
+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/43] 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
+
+ { 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/43] 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/43] 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/43] 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/43] 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/43] 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/43] 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/43] 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/43] 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/43] 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/43] 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/43] 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/43] 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/43] 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/43] 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]);
+ });
+});
From 7763348ca0c62dc42af4c047faeafd4e0ff82432 Mon Sep 17 00:00:00 2001
From: Jesper Hodge
Date: Thu, 19 Dec 2024 11:44:32 -0500
Subject: [PATCH 28/43] fix: permanent in progress state
---
src/generic/course-stepper/index.jsx | 4 +
src/optimizer-page/data/thunks.test.js | 99 +++++++++----------
src/optimizer-page/data/thunks.ts | 5 +-
.../scan-results/ScanResults.tsx | 2 +-
src/optimizer-page/utils.test.js | 14 ++-
src/optimizer-page/utils.ts | 5 +-
6 files changed, 71 insertions(+), 58 deletions(-)
diff --git a/src/generic/course-stepper/index.jsx b/src/generic/course-stepper/index.jsx
index caf341f77d..09f46f5a7b 100644
--- a/src/generic/course-stepper/index.jsx
+++ b/src/generic/course-stepper/index.jsx
@@ -25,6 +25,10 @@ const CourseStepper = ({
const isLastStepDone = isLastStep && isActiveStep;
const completedStep = index < activeKey && !hasError;
+ console.log('current step', index);
+ console.log('activeKey', activeKey);
+ console.log('lastStepIndex', lastStepIndex);
+
const getStepIcon = () => {
if (completedStep) {
return CheckCircle;
diff --git a/src/optimizer-page/data/thunks.test.js b/src/optimizer-page/data/thunks.test.js
index 245bfdb96c..9b866566d2 100644
--- a/src/optimizer-page/data/thunks.test.js
+++ b/src/optimizer-page/data/thunks.test.js
@@ -1,84 +1,81 @@
-import { startLinkCheck, fetchLinkCheckStatus } from "./thunks";
-import * as api from "./api";
-import { LINK_CHECK_STATUSES } from "./constants";
-import { RequestStatus } from "../../data/constants";
-import mockApiResponse from "../mocks/mockApiResponse";
+import { 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", () => {
+describe('startLinkCheck thunk', () => {
const dispatch = jest.fn();
const getState = jest.fn();
- const courseId = "course-123";
+ const courseId = 'course-123';
let mockGetStartLinkCheck;
beforeEach(() => {
jest.clearAllMocks();
- mockGetStartLinkCheck = jest.spyOn(api, "postLinkCheck").mockResolvedValue({
+ 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 () => {
+ 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",
+ type: 'courseOptimizer/updateSavingStatus',
});
expect(dispatch).toHaveBeenCalledWith({
payload: true,
- type: "courseOptimizer/updateLinkCheckInProgress",
+ type: 'courseOptimizer/updateLinkCheckInProgress',
});
expect(dispatch).toHaveBeenCalledWith({
payload: { status: RequestStatus.SUCCESSFUL },
- type: "courseOptimizer/updateSavingStatus",
+ type: 'courseOptimizer/updateSavingStatus',
});
- expect(
- dispatch.mock.calls.filter(
- (call) =>
- call[0].type === "courseOptimizer/updateCurrentStage" &&
- call[0].payload === inProgressStageId
- ).length
- ).toBe(2);
+ expect(dispatch).toHaveBeenCalledWith({
+ payload: inProgressStageId,
+ type: 'courseOptimizer/updateCurrentStage',
+ });
});
});
- describe("failed request should set stage and request ", () => {
- it("should set request status to failed", async () => {
+ describe('failed request should set stage and request ', () => {
+ it('should set request status to failed', async () => {
const failureStageId = -1;
- mockGetStartLinkCheck.mockRejectedValue(new Error("error"));
+ mockGetStartLinkCheck.mockRejectedValue(new Error('error'));
await startLinkCheck(courseId)(dispatch, getState);
expect(dispatch).toHaveBeenCalledWith({
payload: { status: RequestStatus.FAILED },
- type: "courseOptimizer/updateSavingStatus",
+ type: 'courseOptimizer/updateSavingStatus',
});
expect(dispatch).toHaveBeenCalledWith({
payload: false,
- type: "courseOptimizer/updateLinkCheckInProgress",
+ type: 'courseOptimizer/updateLinkCheckInProgress',
});
expect(dispatch).toHaveBeenCalledWith({
payload: -1,
- type: "courseOptimizer/updateCurrentStage",
+ type: 'courseOptimizer/updateCurrentStage',
});
});
});
});
-describe("fetchLinkCheckStatus thunk", () => {
- describe("successful request", () => {
- it("should return scan result", async () => {
+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 courseId = 'course-123';
const mockGetLinkCheckStatus = jest
- .spyOn(api, "getLinkCheckStatus")
+ .spyOn(api, 'getLinkCheckStatus')
.mockResolvedValue({
linkCheckStatus: mockApiResponse.LinkCheckStatus,
linkCheckOutput: mockApiResponse.LinkCheckOutput,
@@ -89,48 +86,48 @@ describe("fetchLinkCheckStatus thunk", () => {
expect(dispatch).toHaveBeenCalledWith({
payload: false,
- type: "courseOptimizer/updateLinkCheckInProgress",
+ type: 'courseOptimizer/updateLinkCheckInProgress',
});
expect(dispatch).toHaveBeenCalledWith({
payload: 2,
- type: "courseOptimizer/updateCurrentStage",
+ type: 'courseOptimizer/updateCurrentStage',
});
expect(dispatch).toHaveBeenCalledWith({
payload: mockApiResponse.LinkCheckOutput,
- type: "courseOptimizer/updateLinkCheckResult",
+ type: 'courseOptimizer/updateLinkCheckResult',
});
expect(dispatch).toHaveBeenCalledWith({
payload: { status: RequestStatus.SUCCESSFUL },
- type: "courseOptimizer/updateLoadingStatus",
+ type: 'courseOptimizer/updateLoadingStatus',
});
});
});
- describe("failed request", () => {
- it("should set request status to failed", async () => {
+ describe('failed request', () => {
+ it('should set request status to failed', async () => {
const dispatch = jest.fn();
const getState = jest.fn();
- const courseId = "course-123";
+ const courseId = 'course-123';
const mockGetLinkCheckStatus = jest
- .spyOn(api, "getLinkCheckStatus")
- .mockRejectedValue(new Error("error"));
+ .spyOn(api, 'getLinkCheckStatus')
+ .mockRejectedValue(new Error('error'));
await fetchLinkCheckStatus(courseId)(dispatch, getState);
expect(dispatch).toHaveBeenCalledWith({
payload: { status: RequestStatus.FAILED },
- type: "courseOptimizer/updateLoadingStatus",
+ type: 'courseOptimizer/updateLoadingStatus',
});
});
});
- describe("failed scan", () => {
- it("should set error message", async () => {
+ describe('failed scan', () => {
+ it('should set error message', async () => {
const mockGetLinkCheckStatus = jest
- .spyOn(api, "getLinkCheckStatus")
+ .spyOn(api, 'getLinkCheckStatus')
.mockResolvedValue({
linkCheckStatus: LINK_CHECK_STATUSES.FAILED,
linkCheckOutput: mockApiResponse.LinkCheckOutput,
@@ -139,33 +136,33 @@ describe("fetchLinkCheckStatus thunk", () => {
const dispatch = jest.fn();
const getState = jest.fn();
- const courseId = "course-123";
+ const courseId = 'course-123';
await fetchLinkCheckStatus(courseId)(dispatch, getState);
expect(dispatch).toHaveBeenCalledWith({
payload: true,
- type: "courseOptimizer/updateIsErrorModalOpen",
+ type: 'courseOptimizer/updateIsErrorModalOpen',
});
expect(dispatch).toHaveBeenCalledWith({
- payload: { msg: "Link Check Failed" },
- type: "courseOptimizer/updateError",
+ payload: { msg: 'Link Check Failed' },
+ type: 'courseOptimizer/updateError',
});
expect(dispatch).toHaveBeenCalledWith({
payload: { status: RequestStatus.SUCCESSFUL },
- type: "courseOptimizer/updateLoadingStatus",
+ type: 'courseOptimizer/updateLoadingStatus',
});
expect(dispatch).toHaveBeenCalledWith({
payload: -1,
- type: "courseOptimizer/updateCurrentStage",
+ type: 'courseOptimizer/updateCurrentStage',
});
expect(dispatch).not.toHaveBeenCalledWith({
payload: expect.anything(),
- type: "courseOptimizer/updateLinkCheckResult",
+ type: 'courseOptimizer/updateLinkCheckResult',
});
});
});
diff --git a/src/optimizer-page/data/thunks.ts b/src/optimizer-page/data/thunks.ts
index 99f8689606..63698b9224 100644
--- a/src/optimizer-page/data/thunks.ts
+++ b/src/optimizer-page/data/thunks.ts
@@ -23,8 +23,7 @@ export function startLinkCheck(courseId: string) {
dispatch(updateLinkCheckInProgress(true));
dispatch(updateCurrentStage(SCAN_STAGES[LINK_CHECK_STATUSES.PENDING]));
try {
- const data = await postLinkCheck(courseId);
- await dispatch(updateCurrentStage(SCAN_STAGES[data.linkCheckStatus]));
+ await postLinkCheck(courseId);
await dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL }));
return true;
} catch (error) {
@@ -58,6 +57,7 @@ export function fetchLinkCheckStatus(courseId) {
console.log('linkCheckStatus:', linkCheckStatus);
dispatch(updateCurrentStage(SCAN_STAGES[linkCheckStatus]));
+ console.log('updated current stage to:', SCAN_STAGES[linkCheckStatus]);
if (
linkCheckStatus === undefined
@@ -73,6 +73,7 @@ export function fetchLinkCheckStatus(courseId) {
dispatch(updateLoadingStatus({ status: RequestStatus.SUCCESSFUL }));
return true;
} catch (error: any) {
+ console.log('found some error');
if (error?.response && error?.response.status === 403) {
dispatch(updateLoadingStatus({ status: RequestStatus.DENIED }));
} else {
diff --git a/src/optimizer-page/scan-results/ScanResults.tsx b/src/optimizer-page/scan-results/ScanResults.tsx
index b0c7cfa4b4..e930eea718 100644
--- a/src/optimizer-page/scan-results/ScanResults.tsx
+++ b/src/optimizer-page/scan-results/ScanResults.tsx
@@ -9,7 +9,7 @@ import SectionCollapsible from '../SectionCollapsible';
import BrokenLinkTable from './BrokenLinkTable';
import LockedInfoIcon from './LockedInfoIcon';
import { LinkCheckResult } from '../types';
-import countBrokenLinks from '../utils';
+import { countBrokenLinks } from '../utils';
const InfoCard: FC<{ text: string }> = ({ text }) => (
diff --git a/src/optimizer-page/utils.test.js b/src/optimizer-page/utils.test.js
index d689ac3eef..07983888b9 100644
--- a/src/optimizer-page/utils.test.js
+++ b/src/optimizer-page/utils.test.js
@@ -1,5 +1,5 @@
import mockApiResponse from './mocks/mockApiResponse';
-import countBrokenLinks from './utils';
+import { countBrokenLinks } from './utils';
describe('countBrokenLinks', () => {
it('should return the count of broken links', () => {
@@ -29,4 +29,16 @@ describe('countBrokenLinks', () => {
};
expect(countBrokenLinks(data)).toStrictEqual([0]);
});
+
+ it('should return [] if there is no data', () => {
+ const data = {};
+ expect(countBrokenLinks(data)).toStrictEqual([]);
+ });
+
+ it('should return [] if there are no sections', () => {
+ const data = {
+ sections: [],
+ };
+ expect(countBrokenLinks(data)).toStrictEqual([]);
+ });
});
diff --git a/src/optimizer-page/utils.ts b/src/optimizer-page/utils.ts
index 19a626247c..dd76763761 100644
--- a/src/optimizer-page/utils.ts
+++ b/src/optimizer-page/utils.ts
@@ -1,6 +1,7 @@
+/* eslint-disable import/prefer-default-export */
import { LinkCheckResult } from './types';
-const countBrokenLinks = (data: LinkCheckResult | null): number[] => {
+export const countBrokenLinks = (data: LinkCheckResult | null): number[] => {
if (!data?.sections) {
return [];
}
@@ -18,5 +19,3 @@ const countBrokenLinks = (data: LinkCheckResult | null): number[] => {
});
return counts;
};
-
-export default countBrokenLinks;
From baf17b301c984cd8dd9079c83fc2ba0a8c4e7afb Mon Sep 17 00:00:00 2001
From: Jesper Hodge
Date: Thu, 19 Dec 2024 14:57:25 -0500
Subject: [PATCH 29/43] test: poll link check
---
src/optimizer-page/CourseOptimizerPage.jsx | 23 +++++-----
.../CourseOptimizerPage.test.js | 43 +++++++++++++++++++
2 files changed, 55 insertions(+), 11 deletions(-)
create mode 100644 src/optimizer-page/CourseOptimizerPage.test.js
diff --git a/src/optimizer-page/CourseOptimizerPage.jsx b/src/optimizer-page/CourseOptimizerPage.jsx
index 6606c1f78c..486af193c7 100644
--- a/src/optimizer-page/CourseOptimizerPage.jsx
+++ b/src/optimizer-page/CourseOptimizerPage.jsx
@@ -1,3 +1,4 @@
+/* eslint-disable no-param-reassign */
import { useEffect, useRef } from 'react';
import PropTypes from 'prop-types';
import { useDispatch, useSelector } from 'react-redux';
@@ -27,6 +28,16 @@ const pollLinkCheckStatus = (dispatch, courseId, delay) => {
return interval;
};
+export function pollLinkCheckDuringScan(linkCheckInProgress, linkCheckResult, interval, dispatch, courseId) {
+ if (linkCheckInProgress === null || linkCheckInProgress || !linkCheckResult) {
+ clearInterval(interval.current);
+ interval.current = pollLinkCheckStatus(dispatch, courseId, 2000);
+ } else if (interval.current) {
+ clearInterval(interval.current);
+ interval.current = null;
+ }
+}
+
const CourseOptimizerPage = ({ courseId }) => {
const dispatch = useDispatch();
const linkCheckInProgress = useSelector(getLinkCheckInProgress);
@@ -59,22 +70,12 @@ const CourseOptimizerPage = ({ courseId }) => {
},
];
- const pollLinkCheckStatusDuringScan = () => {
- if (linkCheckInProgress === null || linkCheckInProgress || !linkCheckResult) {
- clearInterval(interval.current);
- interval.current = pollLinkCheckStatus(dispatch, courseId, 2000);
- } else if (interval.current) {
- clearInterval(interval.current);
- interval.current = null;
- }
- };
-
useEffect(() => {
dispatch(fetchLinkCheckStatus(courseId));
}, []);
useEffect(() => {
- pollLinkCheckStatusDuringScan();
+ pollLinkCheckDuringScan(linkCheckInProgress, linkCheckResult, interval, dispatch, courseId);
return () => {
if (interval.current) { clearInterval(interval.current); }
diff --git a/src/optimizer-page/CourseOptimizerPage.test.js b/src/optimizer-page/CourseOptimizerPage.test.js
new file mode 100644
index 0000000000..d6a4f1c493
--- /dev/null
+++ b/src/optimizer-page/CourseOptimizerPage.test.js
@@ -0,0 +1,43 @@
+import { pollLinkCheckDuringScan } from './CourseOptimizerPage';
+
+describe('CourseOptimizerPage', () => {
+ describe('pollLinkCheckDuringScan', () => {
+ it('should start polling if linkCheckInProgress has never been started (is null)', () => {
+ const linkCheckInProgress = null;
+ const linkCheckResult = 'someresult';
+ const interval = { current: null };
+ const dispatch = jest.fn();
+ const courseId = 'course-123';
+ pollLinkCheckDuringScan(linkCheckInProgress, linkCheckResult, interval, dispatch, courseId);
+ expect(interval.current).not.toBeNull();
+ });
+
+ it('should start polling if link check is in progress', () => {
+ const linkCheckInProgress = true;
+ const linkCheckResult = 'someresult';
+ const interval = { current: null };
+ const dispatch = jest.fn();
+ const courseId = 'course-123';
+ pollLinkCheckDuringScan(linkCheckInProgress, linkCheckResult, interval, dispatch, courseId);
+ expect(interval.current).not.toBeNull();
+ });
+ it('should start polling if there is no linkCheckResult', () => {
+ const linkCheckInProgress = false;
+ const linkCheckResult = null;
+ const interval = { current: null };
+ const dispatch = jest.fn();
+ const courseId = 'course-123';
+ pollLinkCheckDuringScan(linkCheckInProgress, linkCheckResult, interval, dispatch, courseId);
+ expect(interval.current).not.toBeNull();
+ });
+ it('should clear the interval if link check is finished', () => {
+ const linkCheckInProgress = false;
+ const linkCheckResult = 'someresult';
+ const interval = { current: 1 };
+ const dispatch = jest.fn();
+ const courseId = 'course-123';
+ pollLinkCheckDuringScan(linkCheckInProgress, linkCheckResult, interval, dispatch, courseId);
+ expect(interval.current).toBeNull();
+ });
+ });
+});
From 2a1aefc94266e93f9f2e6053f3ac60992c4de6fb Mon Sep 17 00:00:00 2001
From: Jesper Hodge
Date: Thu, 19 Dec 2024 19:17:50 -0500
Subject: [PATCH 30/43] test: optimizer page
---
src/optimizer-page/CourseOptimizerPage.jsx | 5 +-
.../CourseOptimizerPage.test.js | 112 +++++++++++++++++-
.../scan-results/BrokenLinkTable.tsx | 4 +-
.../scan-results/ScanResults.tsx | 11 +-
4 files changed, 117 insertions(+), 15 deletions(-)
diff --git a/src/optimizer-page/CourseOptimizerPage.jsx b/src/optimizer-page/CourseOptimizerPage.jsx
index 486af193c7..3d2bb5d339 100644
--- a/src/optimizer-page/CourseOptimizerPage.jsx
+++ b/src/optimizer-page/CourseOptimizerPage.jsx
@@ -29,7 +29,7 @@ const pollLinkCheckStatus = (dispatch, courseId, delay) => {
};
export function pollLinkCheckDuringScan(linkCheckInProgress, linkCheckResult, interval, dispatch, courseId) {
- if (linkCheckInProgress === null || linkCheckInProgress || !linkCheckResult) {
+ if (linkCheckInProgress === null || linkCheckInProgress) {
clearInterval(interval.current);
interval.current = pollLinkCheckStatus(dispatch, courseId, 2000);
} else if (interval.current) {
@@ -71,10 +71,13 @@ const CourseOptimizerPage = ({ courseId }) => {
];
useEffect(() => {
+ // when first entering the page, fetch any existing scan results
dispatch(fetchLinkCheckStatus(courseId));
}, []);
useEffect(() => {
+ // when a scan starts, start polling for the results as long as the scan status fetched
+ // signals it is still in progress
pollLinkCheckDuringScan(linkCheckInProgress, linkCheckResult, interval, dispatch, courseId);
return () => {
diff --git a/src/optimizer-page/CourseOptimizerPage.test.js b/src/optimizer-page/CourseOptimizerPage.test.js
index d6a4f1c493..bc2926395c 100644
--- a/src/optimizer-page/CourseOptimizerPage.test.js
+++ b/src/optimizer-page/CourseOptimizerPage.test.js
@@ -1,4 +1,40 @@
-import { pollLinkCheckDuringScan } from './CourseOptimizerPage';
+/* eslint-disable @typescript-eslint/no-shadow */
+/* eslint-disable react/jsx-filename-extension */
+import {
+ fireEvent, queryByText, render, waitFor,
+} from '@testing-library/react';
+import { IntlProvider } from '@edx/frontend-platform/i18n';
+import { AppProvider } from '@edx/frontend-platform/react';
+import { initializeMockApp } from '@edx/frontend-platform';
+
+import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
+import MockAdapter from 'axios-mock-adapter';
+import { get } from 'lodash';
+import initializeStore from '../store';
+import messages from './messages';
+import scanResultsMessages from './scan-results/messages';
+import CourseOptimizerPage, { pollLinkCheckDuringScan } from './CourseOptimizerPage';
+import { postLinkCheckCourseApiUrl, getLinkCheckStatusApiUrl } from './data/api';
+import mockApiResponse from './mocks/mockApiResponse';
+
+let store;
+let axiosMock;
+const courseId = '123';
+const courseName = 'About Node JS';
+
+jest.mock('../generic/model-store', () => ({
+ useModel: jest.fn().mockReturnValue({
+ name: courseName,
+ }),
+}));
+
+const OptimizerPage = () => (
+
+
+
+
+
+);
describe('CourseOptimizerPage', () => {
describe('pollLinkCheckDuringScan', () => {
@@ -21,18 +57,18 @@ describe('CourseOptimizerPage', () => {
pollLinkCheckDuringScan(linkCheckInProgress, linkCheckResult, interval, dispatch, courseId);
expect(interval.current).not.toBeNull();
});
- it('should start polling if there is no linkCheckResult', () => {
+ it('should not start polling if link check is not in progress', () => {
const linkCheckInProgress = false;
const linkCheckResult = null;
const interval = { current: null };
const dispatch = jest.fn();
const courseId = 'course-123';
pollLinkCheckDuringScan(linkCheckInProgress, linkCheckResult, interval, dispatch, courseId);
- expect(interval.current).not.toBeNull();
+ expect(interval.current).toBeNull();
});
it('should clear the interval if link check is finished', () => {
const linkCheckInProgress = false;
- const linkCheckResult = 'someresult';
+ const linkCheckResult = null;
const interval = { current: 1 };
const dispatch = jest.fn();
const courseId = 'course-123';
@@ -40,4 +76,72 @@ describe('CourseOptimizerPage', () => {
expect(interval.current).toBeNull();
});
});
+
+ describe('CourseOptimizerPage component', () => {
+ beforeEach(() => {
+ initializeMockApp({
+ authenticatedUser: {
+ userId: 3,
+ username: 'abc123',
+ administrator: true,
+ roles: [],
+ },
+ });
+ store = initializeStore();
+ axiosMock = new MockAdapter(getAuthenticatedHttpClient());
+ axiosMock
+ .onPost(postLinkCheckCourseApiUrl(courseId))
+ .reply(200, { LinkCheckStatus: 'In-Progress' });
+ axiosMock
+ .onGet(getLinkCheckStatusApiUrl(courseId))
+ .reply(200, mockApiResponse);
+ });
+
+ // postLinkCheckCourseApiUrl
+ // getLinkCheckStatusApiUrl
+
+ it('should render the component', () => {
+ const { getByText, queryByText } = render();
+ expect(getByText(messages.headingTitle.defaultMessage)).toBeInTheDocument();
+ expect(getByText(messages.buttonTitle.defaultMessage)).toBeInTheDocument();
+ expect(queryByText(messages.preparingStepTitle)).not.toBeInTheDocument();
+ });
+
+ it('should start scan after clicking the scan button', async () => {
+ const { getByText } = render();
+ expect(getByText(messages.headingTitle.defaultMessage)).toBeInTheDocument();
+ fireEvent.click(getByText(messages.buttonTitle.defaultMessage));
+ await waitFor(() => {
+ expect(getByText(messages.preparingStepTitle.defaultMessage)).toBeInTheDocument();
+ });
+ });
+
+ it('should list broken links results', async () => {
+ const { getByText, getAllByText, container } = render();
+ expect(getByText(messages.headingTitle.defaultMessage)).toBeInTheDocument();
+ fireEvent.click(getByText(messages.buttonTitle.defaultMessage));
+ await waitFor(() => {
+ expect(getByText('5 broken links')).toBeInTheDocument();
+ });
+ const collapsibleTrigger = container.querySelector('.collapsible-trigger');
+ expect(collapsibleTrigger).toBeInTheDocument();
+ fireEvent.click(collapsibleTrigger);
+ await waitFor(() => {
+ expect(getAllByText(scanResultsMessages.brokenLinkStatus.defaultMessage)[0]).toBeInTheDocument();
+ expect(getAllByText(scanResultsMessages.lockedLinkStatus.defaultMessage)[0]).toBeInTheDocument();
+ });
+ });
+
+ it('should show no broken links found message', async () => {
+ axiosMock
+ .onGet(getLinkCheckStatusApiUrl(courseId))
+ .reply(200, { LinkCheckStatus: 'Succeeded' });
+ const { getByText } = render();
+ expect(getByText(messages.headingTitle.defaultMessage)).toBeInTheDocument();
+ fireEvent.click(getByText(messages.buttonTitle.defaultMessage));
+ await waitFor(() => {
+ expect(getByText(scanResultsMessages.noBrokenLinksCard.defaultMessage)).toBeInTheDocument();
+ });
+ });
+ });
});
diff --git a/src/optimizer-page/scan-results/BrokenLinkTable.tsx b/src/optimizer-page/scan-results/BrokenLinkTable.tsx
index d528e7376e..59da8a410f 100644
--- a/src/optimizer-page/scan-results/BrokenLinkTable.tsx
+++ b/src/optimizer-page/scan-results/BrokenLinkTable.tsx
@@ -54,7 +54,9 @@ const BrokenLinkTable: FC = ({
status: (
- {intl.formatMessage(messages.brokenLinkStatus)}
+
+ {intl.formatMessage(messages.brokenLinkStatus)}
+
),
}));
diff --git a/src/optimizer-page/scan-results/ScanResults.tsx b/src/optimizer-page/scan-results/ScanResults.tsx
index e930eea718..c98fc1bd63 100644
--- a/src/optimizer-page/scan-results/ScanResults.tsx
+++ b/src/optimizer-page/scan-results/ScanResults.tsx
@@ -26,20 +26,13 @@ interface Props {
data: LinkCheckResult | null;
}
-
-
const ScanResults: FC = ({ data }) => {
const intl = useIntl();
const [showLockedLinks, setShowLockedLinks] = useState(true);
- const brokenLinkCounts = useMemo(() => {
- return countBrokenLinks(data);
- }, [data?.sections]);
+ const brokenLinkCounts = useMemo(() => countBrokenLinks(data), [data?.sections]);
- if (!data) {
- return ;
- }
- if (!data.sections) {
+ if (!data?.sections) {
return ;
}
From e3cd4ad25a3f8a1e07c46d82bde4c20c9d151fd4 Mon Sep 17 00:00:00 2001
From: Jesper Hodge
Date: Thu, 19 Dec 2024 19:48:20 -0500
Subject: [PATCH 31/43] fix: types
---
...imizerPage.jsx => CourseOptimizerPage.tsx} | 30 +++++++++++--------
1 file changed, 17 insertions(+), 13 deletions(-)
rename src/optimizer-page/{CourseOptimizerPage.jsx => CourseOptimizerPage.tsx} (88%)
diff --git a/src/optimizer-page/CourseOptimizerPage.jsx b/src/optimizer-page/CourseOptimizerPage.tsx
similarity index 88%
rename from src/optimizer-page/CourseOptimizerPage.jsx
rename to src/optimizer-page/CourseOptimizerPage.tsx
index 3d2bb5d339..c6db9e06f9 100644
--- a/src/optimizer-page/CourseOptimizerPage.jsx
+++ b/src/optimizer-page/CourseOptimizerPage.tsx
@@ -1,6 +1,7 @@
/* eslint-disable no-param-reassign */
-import { useEffect, useRef } from 'react';
-import PropTypes from 'prop-types';
+import {
+ useEffect, useRef, FC, MutableRefObject,
+} from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { useIntl } from '@edx/frontend-platform/i18n';
import {
@@ -21,24 +22,29 @@ import { startLinkCheck, fetchLinkCheckStatus } from './data/thunks';
import { useModel } from '../generic/model-store';
import ScanResults from './scan-results';
-const pollLinkCheckStatus = (dispatch, courseId, delay) => {
+const pollLinkCheckStatus = (dispatch: any, courseId: string, delay: number): number => {
const interval = setInterval(() => {
dispatch(fetchLinkCheckStatus(courseId));
}, delay);
- return interval;
+ return interval as unknown as number;
};
-export function pollLinkCheckDuringScan(linkCheckInProgress, linkCheckResult, interval, dispatch, courseId) {
+export function pollLinkCheckDuringScan(
+ linkCheckInProgress: boolean | null,
+ interval: MutableRefObject,
+ dispatch: any,
+ courseId: string,
+) {
if (linkCheckInProgress === null || linkCheckInProgress) {
- clearInterval(interval.current);
+ clearInterval(interval.current as number | undefined);
interval.current = pollLinkCheckStatus(dispatch, courseId, 2000);
} else if (interval.current) {
clearInterval(interval.current);
- interval.current = null;
+ interval.current = undefined;
}
}
-const CourseOptimizerPage = ({ courseId }) => {
+const CourseOptimizerPage: FC<{ courseId: string }> = ({ courseId }) => {
const dispatch = useDispatch();
const linkCheckInProgress = useSelector(getLinkCheckInProgress);
const loadingStatus = useSelector(getLoadingStatus);
@@ -47,7 +53,7 @@ const CourseOptimizerPage = ({ courseId }) => {
const { msg: errorMessage } = useSelector(getError);
const isShowExportButton = !linkCheckInProgress || errorMessage;
const isLoadingDenied = loadingStatus === RequestStatus.DENIED;
- const interval = useRef(null);
+ const interval = useRef(undefined);
const courseDetails = useModel('courseDetails', courseId);
const linkCheckPresent = !!currentStage;
const intl = useIntl();
@@ -78,7 +84,7 @@ const CourseOptimizerPage = ({ courseId }) => {
useEffect(() => {
// when a scan starts, start polling for the results as long as the scan status fetched
// signals it is still in progress
- pollLinkCheckDuringScan(linkCheckInProgress, linkCheckResult, interval, dispatch, courseId);
+ pollLinkCheckDuringScan(linkCheckInProgress, interval, dispatch, courseId);
return () => {
if (interval.current) { clearInterval(interval.current); }
@@ -144,6 +150,7 @@ const CourseOptimizerPage = ({ courseId }) => {
{linkCheckPresent && (
{
>
);
};
-CourseOptimizerPage.propTypes = {
- courseId: PropTypes.string.isRequired,
-};
export default CourseOptimizerPage;
From b6c8a052793d6176d2aeb4409c2738463a387226 Mon Sep 17 00:00:00 2001
From: Jesper Hodge
Date: Fri, 20 Dec 2024 11:32:26 -0500
Subject: [PATCH 32/43] feat: add last scan date and update optimizer
description
---
src/optimizer-page/CourseOptimizerPage.tsx | 4 +++-
src/optimizer-page/data/api.ts | 1 +
src/optimizer-page/data/selectors.ts | 1 +
src/optimizer-page/data/slice.ts | 6 ++++++
src/optimizer-page/data/thunks.ts | 13 ++++---------
src/optimizer-page/messages.js | 11 ++++++++++-
6 files changed, 25 insertions(+), 11 deletions(-)
diff --git a/src/optimizer-page/CourseOptimizerPage.tsx b/src/optimizer-page/CourseOptimizerPage.tsx
index c6db9e06f9..22e995484d 100644
--- a/src/optimizer-page/CourseOptimizerPage.tsx
+++ b/src/optimizer-page/CourseOptimizerPage.tsx
@@ -17,6 +17,7 @@ import { RequestStatus } from '../data/constants';
import messages from './messages';
import {
getCurrentStage, getError, getLinkCheckInProgress, getLoadingStatus, getLinkCheckResult,
+ getLastScannedAt,
} from './data/selectors';
import { startLinkCheck, fetchLinkCheckStatus } from './data/thunks';
import { useModel } from '../generic/model-store';
@@ -50,6 +51,7 @@ const CourseOptimizerPage: FC<{ courseId: string }> = ({ courseId }) => {
const loadingStatus = useSelector(getLoadingStatus);
const currentStage = useSelector(getCurrentStage);
const linkCheckResult = useSelector(getLinkCheckResult);
+ const lastScannedAt = useSelector(getLastScannedAt);
const { msg: errorMessage } = useSelector(getError);
const isShowExportButton = !linkCheckInProgress || errorMessage;
const isLoadingDenied = loadingStatus === RequestStatus.DENIED;
@@ -143,7 +145,7 @@ const CourseOptimizerPage: FC<{ courseId: string }> = ({ courseId }) => {
onClick={() => dispatch(startLinkCheck(courseId))}
iconBefore={SearchIcon}
>
- {intl.formatMessage(messages.buttonTitle)}
+ {intl.formatMessage(messages.buttonTitle)} {lastScannedAt && `(${intl.formatMessage(messages.lastScannedOn)} ${intl.formatDate(lastScannedAt, { year: 'numeric', month: 'long', day: 'numeric' })})`}
)}
diff --git a/src/optimizer-page/data/api.ts b/src/optimizer-page/data/api.ts
index 3d7d89e2c6..af88da3c50 100644
--- a/src/optimizer-page/data/api.ts
+++ b/src/optimizer-page/data/api.ts
@@ -6,6 +6,7 @@ import { LinkCheckStatusTypes } from './constants';
export interface LinkCheckStatusApiResponseBody {
linkCheckStatus: LinkCheckStatusTypes;
linkCheckOutput: LinkCheckResult;
+ linkCheckCreatedAt: string;
}
const getApiBaseUrl = () => getConfig().STUDIO_BASE_URL;
diff --git a/src/optimizer-page/data/selectors.ts b/src/optimizer-page/data/selectors.ts
index 79a80a077a..7454157c5f 100644
--- a/src/optimizer-page/data/selectors.ts
+++ b/src/optimizer-page/data/selectors.ts
@@ -9,3 +9,4 @@ export const getIsErrorModalOpen = (state: RootState) => state.courseOptimizer.i
export const getLoadingStatus = (state: RootState) => state.courseOptimizer.loadingStatus;
export const getSavingStatus = (state: RootState) => state.courseOptimizer.savingStatus;
export const getLinkCheckResult = (state: RootState) => state.courseOptimizer.linkCheckResult;
+export const getLastScannedAt = (state: RootState) => state.courseOptimizer.lastScannedAt;
diff --git a/src/optimizer-page/data/slice.ts b/src/optimizer-page/data/slice.ts
index e38b3d0262..7b3f81a8a9 100644
--- a/src/optimizer-page/data/slice.ts
+++ b/src/optimizer-page/data/slice.ts
@@ -5,6 +5,7 @@ import { LinkCheckResult } from '../types';
export interface CourseOptimizerState {
linkCheckInProgress: boolean | null;
linkCheckResult: LinkCheckResult | null;
+ lastScannedAt: string | null;
currentStage: number | null;
error: { msg: string | null; unitUrl: string | null };
downloadPath: string | null;
@@ -23,6 +24,7 @@ export type RootState = {
const initialState: CourseOptimizerState = {
linkCheckInProgress: null,
linkCheckResult: null,
+ lastScannedAt: null,
currentStage: null,
error: { msg: null, unitUrl: null },
downloadPath: null,
@@ -42,6 +44,9 @@ const slice = createSlice({
updateLinkCheckResult: (state, { payload }) => {
state.linkCheckResult = payload;
},
+ updateLastScannedAt: (state, { payload }) => {
+ state.lastScannedAt = payload;
+ },
updateCurrentStage: (state, { payload }) => {
state.currentStage = payload;
},
@@ -70,6 +75,7 @@ const slice = createSlice({
export const {
updateLinkCheckInProgress,
updateLinkCheckResult,
+ updateLastScannedAt,
updateCurrentStage,
updateDownloadPath,
updateSuccessDate,
diff --git a/src/optimizer-page/data/thunks.ts b/src/optimizer-page/data/thunks.ts
index 63698b9224..35985744f6 100644
--- a/src/optimizer-page/data/thunks.ts
+++ b/src/optimizer-page/data/thunks.ts
@@ -15,6 +15,7 @@ import {
updateIsErrorModalOpen,
updateLoadingStatus,
updateSavingStatus,
+ updateLastScannedAt,
} from './slice';
export function startLinkCheck(courseId: string) {
@@ -40,24 +41,18 @@ export function fetchLinkCheckStatus(courseId) {
return async (dispatch) => {
dispatch(updateLoadingStatus({ status: RequestStatus.IN_PROGRESS }));
- /* ****** Debugging ******** */
- // dispatch(updateLinkCheckInProgress(true));
- // dispatch(updateCurrentStage(3));
- // return true;
-
try {
- const { linkCheckStatus, linkCheckOutput } = await getLinkCheckStatus(
+ const { linkCheckStatus, linkCheckOutput, linkCheckCreatedAt } = await getLinkCheckStatus(
courseId,
);
+ console.log('linkCheckCreatedAt', linkCheckCreatedAt);
if (LINK_CHECK_IN_PROGRESS_STATUSES.includes(linkCheckStatus)) {
dispatch(updateLinkCheckInProgress(true));
} else {
dispatch(updateLinkCheckInProgress(false));
}
- console.log('linkCheckStatus:', linkCheckStatus);
dispatch(updateCurrentStage(SCAN_STAGES[linkCheckStatus]));
- console.log('updated current stage to:', SCAN_STAGES[linkCheckStatus]);
if (
linkCheckStatus === undefined
@@ -68,12 +63,12 @@ export function fetchLinkCheckStatus(courseId) {
dispatch(updateIsErrorModalOpen(true));
} else if (linkCheckOutput) {
dispatch(updateLinkCheckResult(linkCheckOutput));
+ dispatch(updateLastScannedAt(linkCheckCreatedAt));
}
dispatch(updateLoadingStatus({ status: RequestStatus.SUCCESSFUL }));
return true;
} catch (error: any) {
- console.log('found some error');
if (error?.response && error?.response.status === 403) {
dispatch(updateLoadingStatus({ status: RequestStatus.DENIED }));
} else {
diff --git a/src/optimizer-page/messages.js b/src/optimizer-page/messages.js
index f708c489c3..d83108bd40 100644
--- a/src/optimizer-page/messages.js
+++ b/src/optimizer-page/messages.js
@@ -15,7 +15,12 @@ const messages = defineMessages({
},
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.',
+ defaultMessage: `This tool will scan your the published version of your course for broken links.
+ Unpublished changes will not be included in the scan.
+ Note that this process will take more time for larger courses.
+ To update the scan after you have published new changes to your course,
+ click the "Start Scanning" button again.
+ `,
},
description2: {
id: 'course-authoring.course-optimizer.description2',
@@ -57,6 +62,10 @@ const messages = defineMessages({
id: 'course-authoring.course-optimizer.success-step.description',
defaultMessage: 'Your Scan is complete. You can view the list of results below.',
},
+ lastScannedOn: {
+ id: 'course-authoring.course-optimizer.last-scanned-on',
+ defaultMessage: 'Last scanned on',
+ },
});
export default messages;
From 13cd97ea596a4af42a9ed74dcb50c0affd1c16bc Mon Sep 17 00:00:00 2001
From: Jesper Hodge
Date: Mon, 6 Jan 2025 11:02:21 -0500
Subject: [PATCH 33/43] fix: lint
---
src/generic/course-stepper/index.jsx | 4 ----
src/optimizer-page/CourseOptimizerPage.test.js | 3 +--
src/optimizer-page/data/api.test.js | 4 ++--
src/optimizer-page/data/thunks.test.js | 7 +++----
src/optimizer-page/data/thunks.ts | 3 +--
5 files changed, 7 insertions(+), 14 deletions(-)
diff --git a/src/generic/course-stepper/index.jsx b/src/generic/course-stepper/index.jsx
index 09f46f5a7b..caf341f77d 100644
--- a/src/generic/course-stepper/index.jsx
+++ b/src/generic/course-stepper/index.jsx
@@ -25,10 +25,6 @@ const CourseStepper = ({
const isLastStepDone = isLastStep && isActiveStep;
const completedStep = index < activeKey && !hasError;
- console.log('current step', index);
- console.log('activeKey', activeKey);
- console.log('lastStepIndex', lastStepIndex);
-
const getStepIcon = () => {
if (completedStep) {
return CheckCircle;
diff --git a/src/optimizer-page/CourseOptimizerPage.test.js b/src/optimizer-page/CourseOptimizerPage.test.js
index bc2926395c..622ed7ee0c 100644
--- a/src/optimizer-page/CourseOptimizerPage.test.js
+++ b/src/optimizer-page/CourseOptimizerPage.test.js
@@ -1,7 +1,7 @@
/* eslint-disable @typescript-eslint/no-shadow */
/* eslint-disable react/jsx-filename-extension */
import {
- fireEvent, queryByText, render, waitFor,
+ fireEvent, render, waitFor,
} from '@testing-library/react';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import { AppProvider } from '@edx/frontend-platform/react';
@@ -9,7 +9,6 @@ import { initializeMockApp } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import MockAdapter from 'axios-mock-adapter';
-import { get } from 'lodash';
import initializeStore from '../store';
import messages from './messages';
import scanResultsMessages from './scan-results/messages';
diff --git a/src/optimizer-page/data/api.test.js b/src/optimizer-page/data/api.test.js
index 09a1f52fa0..155dc2d0b4 100644
--- a/src/optimizer-page/data/api.test.js
+++ b/src/optimizer-page/data/api.test.js
@@ -11,7 +11,7 @@ describe('Course Optimizer API', () => {
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);
});
@@ -24,7 +24,7 @@ describe('Course Optimizer API', () => {
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);
diff --git a/src/optimizer-page/data/thunks.test.js b/src/optimizer-page/data/thunks.test.js
index 9b866566d2..e524fbdbec 100644
--- a/src/optimizer-page/data/thunks.test.js
+++ b/src/optimizer-page/data/thunks.test.js
@@ -47,7 +47,6 @@ describe('startLinkCheck thunk', () => {
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);
@@ -74,7 +73,7 @@ describe('fetchLinkCheckStatus thunk', () => {
const dispatch = jest.fn();
const getState = jest.fn();
const courseId = 'course-123';
- const mockGetLinkCheckStatus = jest
+ jest
.spyOn(api, 'getLinkCheckStatus')
.mockResolvedValue({
linkCheckStatus: mockApiResponse.LinkCheckStatus,
@@ -111,7 +110,7 @@ describe('fetchLinkCheckStatus thunk', () => {
const dispatch = jest.fn();
const getState = jest.fn();
const courseId = 'course-123';
- const mockGetLinkCheckStatus = jest
+ jest
.spyOn(api, 'getLinkCheckStatus')
.mockRejectedValue(new Error('error'));
@@ -126,7 +125,7 @@ describe('fetchLinkCheckStatus thunk', () => {
describe('failed scan', () => {
it('should set error message', async () => {
- const mockGetLinkCheckStatus = jest
+ jest
.spyOn(api, 'getLinkCheckStatus')
.mockResolvedValue({
linkCheckStatus: LINK_CHECK_STATUSES.FAILED,
diff --git a/src/optimizer-page/data/thunks.ts b/src/optimizer-page/data/thunks.ts
index 35985744f6..1fb3c57207 100644
--- a/src/optimizer-page/data/thunks.ts
+++ b/src/optimizer-page/data/thunks.ts
@@ -36,7 +36,6 @@ export function startLinkCheck(courseId: string) {
};
}
-// TODO: use new statuses
export function fetchLinkCheckStatus(courseId) {
return async (dispatch) => {
dispatch(updateLoadingStatus({ status: RequestStatus.IN_PROGRESS }));
@@ -45,7 +44,7 @@ export function fetchLinkCheckStatus(courseId) {
const { linkCheckStatus, linkCheckOutput, linkCheckCreatedAt } = await getLinkCheckStatus(
courseId,
);
- console.log('linkCheckCreatedAt', linkCheckCreatedAt);
+
if (LINK_CHECK_IN_PROGRESS_STATUSES.includes(linkCheckStatus)) {
dispatch(updateLinkCheckInProgress(true));
} else {
From 2d1314e9bcbd8f983fac9209a39755fb46c0b1bd Mon Sep 17 00:00:00 2001
From: Jesper Hodge
Date: Mon, 6 Jan 2025 11:11:38 -0500
Subject: [PATCH 34/43] fix: tests
---
src/optimizer-page/CourseOptimizerPage.test.js | 14 +++++---------
1 file changed, 5 insertions(+), 9 deletions(-)
diff --git a/src/optimizer-page/CourseOptimizerPage.test.js b/src/optimizer-page/CourseOptimizerPage.test.js
index 622ed7ee0c..0b3d6136d7 100644
--- a/src/optimizer-page/CourseOptimizerPage.test.js
+++ b/src/optimizer-page/CourseOptimizerPage.test.js
@@ -39,40 +39,36 @@ describe('CourseOptimizerPage', () => {
describe('pollLinkCheckDuringScan', () => {
it('should start polling if linkCheckInProgress has never been started (is null)', () => {
const linkCheckInProgress = null;
- const linkCheckResult = 'someresult';
const interval = { current: null };
const dispatch = jest.fn();
const courseId = 'course-123';
- pollLinkCheckDuringScan(linkCheckInProgress, linkCheckResult, interval, dispatch, courseId);
+ pollLinkCheckDuringScan(linkCheckInProgress, interval, dispatch, courseId);
expect(interval.current).not.toBeNull();
});
it('should start polling if link check is in progress', () => {
const linkCheckInProgress = true;
- const linkCheckResult = 'someresult';
const interval = { current: null };
const dispatch = jest.fn();
const courseId = 'course-123';
- pollLinkCheckDuringScan(linkCheckInProgress, linkCheckResult, interval, dispatch, courseId);
+ pollLinkCheckDuringScan(linkCheckInProgress, interval, dispatch, courseId);
expect(interval.current).not.toBeNull();
});
it('should not start polling if link check is not in progress', () => {
const linkCheckInProgress = false;
- const linkCheckResult = null;
const interval = { current: null };
const dispatch = jest.fn();
const courseId = 'course-123';
- pollLinkCheckDuringScan(linkCheckInProgress, linkCheckResult, interval, dispatch, courseId);
+ pollLinkCheckDuringScan(linkCheckInProgress, interval, dispatch, courseId);
expect(interval.current).toBeNull();
});
it('should clear the interval if link check is finished', () => {
const linkCheckInProgress = false;
- const linkCheckResult = null;
const interval = { current: 1 };
const dispatch = jest.fn();
const courseId = 'course-123';
- pollLinkCheckDuringScan(linkCheckInProgress, linkCheckResult, interval, dispatch, courseId);
- expect(interval.current).toBeNull();
+ pollLinkCheckDuringScan(linkCheckInProgress, interval, dispatch, courseId);
+ expect(interval.current).toBeUndefined();
});
});
From 4bc2f5d8cd588bb3e65b024f9a25492b119c93dd Mon Sep 17 00:00:00 2001
From: Jesper Hodge
Date: Mon, 6 Jan 2025 14:20:56 -0500
Subject: [PATCH 35/43] test: increase coverage
---
src/data/constants.js | 7 ++++
src/generic/ConnectionErrorAlert.jsx | 2 +-
.../CourseOptimizerPage.test.js | 42 +++++++++++++++----
src/optimizer-page/CourseOptimizerPage.tsx | 16 +++----
4 files changed, 52 insertions(+), 15 deletions(-)
diff --git a/src/data/constants.js b/src/data/constants.js
index 2c63f26289..7aa59f063a 100644
--- a/src/data/constants.js
+++ b/src/data/constants.js
@@ -15,6 +15,13 @@ export const RequestStatus = /** @type {const} */ ({
NOT_FOUND: 'not-found',
});
+export const RequestFailureStatuses = [
+ RequestStatus.FAILED,
+ RequestStatus.DENIED,
+ RequestStatus.PARTIAL_FAILURE,
+ RequestStatus.NOT_FOUND,
+];
+
/**
* Team sizes enum
* @enum
diff --git a/src/generic/ConnectionErrorAlert.jsx b/src/generic/ConnectionErrorAlert.jsx
index 44d088a66b..a6ceceedce 100644
--- a/src/generic/ConnectionErrorAlert.jsx
+++ b/src/generic/ConnectionErrorAlert.jsx
@@ -1,4 +1,4 @@
-import React from 'react';
+import React, { useEffect } from 'react';
import { injectIntl, intlShape, FormattedMessage } from '@edx/frontend-platform/i18n';
import { Alert } from '@openedx/paragon';
import { getConfig } from '@edx/frontend-platform';
diff --git a/src/optimizer-page/CourseOptimizerPage.test.js b/src/optimizer-page/CourseOptimizerPage.test.js
index 0b3d6136d7..3801a9b61a 100644
--- a/src/optimizer-page/CourseOptimizerPage.test.js
+++ b/src/optimizer-page/CourseOptimizerPage.test.js
@@ -1,7 +1,7 @@
/* eslint-disable @typescript-eslint/no-shadow */
/* eslint-disable react/jsx-filename-extension */
import {
- fireEvent, render, waitFor,
+ fireEvent, render, waitFor, screen,
} from '@testing-library/react';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import { AppProvider } from '@edx/frontend-platform/react';
@@ -11,10 +11,12 @@ import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import MockAdapter from 'axios-mock-adapter';
import initializeStore from '../store';
import messages from './messages';
+import generalMessages from '../messages';
import scanResultsMessages from './scan-results/messages';
import CourseOptimizerPage, { pollLinkCheckDuringScan } from './CourseOptimizerPage';
import { postLinkCheckCourseApiUrl, getLinkCheckStatusApiUrl } from './data/api';
import mockApiResponse from './mocks/mockApiResponse';
+import * as thunks from './data/thunks';
let store;
let axiosMock;
@@ -37,13 +39,28 @@ const OptimizerPage = () => (
describe('CourseOptimizerPage', () => {
describe('pollLinkCheckDuringScan', () => {
+ let mockFetchLinkCheckStatus;
+ beforeEach(() => {
+ mockFetchLinkCheckStatus = jest.fn();
+ jest.spyOn(thunks, 'fetchLinkCheckStatus').mockImplementation(mockFetchLinkCheckStatus);
+ jest.useFakeTimers();
+ jest.spyOn(global, 'setInterval').mockImplementation((cb) => { cb(); return true; });
+ });
+
+ afterEach(() => {
+ jest.clearAllTimers();
+ jest.useRealTimers();
+ jest.restoreAllMocks();
+ });
+
it('should start polling if linkCheckInProgress has never been started (is null)', () => {
const linkCheckInProgress = null;
const interval = { current: null };
const dispatch = jest.fn();
const courseId = 'course-123';
pollLinkCheckDuringScan(linkCheckInProgress, interval, dispatch, courseId);
- expect(interval.current).not.toBeNull();
+ expect(interval.current).toBeTruthy();
+ expect(mockFetchLinkCheckStatus).toHaveBeenCalled();
});
it('should start polling if link check is in progress', () => {
@@ -52,7 +69,7 @@ describe('CourseOptimizerPage', () => {
const dispatch = jest.fn();
const courseId = 'course-123';
pollLinkCheckDuringScan(linkCheckInProgress, interval, dispatch, courseId);
- expect(interval.current).not.toBeNull();
+ expect(interval.current).toBeTruthy();
});
it('should not start polling if link check is not in progress', () => {
const linkCheckInProgress = false;
@@ -60,7 +77,7 @@ describe('CourseOptimizerPage', () => {
const dispatch = jest.fn();
const courseId = 'course-123';
pollLinkCheckDuringScan(linkCheckInProgress, interval, dispatch, courseId);
- expect(interval.current).toBeNull();
+ expect(interval.current).toBeFalsy();
});
it('should clear the interval if link check is finished', () => {
const linkCheckInProgress = false;
@@ -74,6 +91,8 @@ describe('CourseOptimizerPage', () => {
describe('CourseOptimizerPage component', () => {
beforeEach(() => {
+ jest.useRealTimers();
+ jest.clearAllMocks();
initializeMockApp({
authenticatedUser: {
userId: 3,
@@ -92,9 +111,6 @@ describe('CourseOptimizerPage', () => {
.reply(200, mockApiResponse);
});
- // postLinkCheckCourseApiUrl
- // getLinkCheckStatusApiUrl
-
it('should render the component', () => {
const { getByText, queryByText } = render();
expect(getByText(messages.headingTitle.defaultMessage)).toBeInTheDocument();
@@ -138,5 +154,17 @@ describe('CourseOptimizerPage', () => {
expect(getByText(scanResultsMessages.noBrokenLinksCard.defaultMessage)).toBeInTheDocument();
});
});
+
+ it('should show error message if request does not go through', async () => {
+ axiosMock
+ .onPost(postLinkCheckCourseApiUrl(courseId))
+ .reply(500);
+ render();
+ expect(screen.getByText(messages.headingTitle.defaultMessage)).toBeInTheDocument();
+ fireEvent.click(screen.getByText(messages.buttonTitle.defaultMessage));
+ await waitFor(() => {
+ expect(screen.getByText(generalMessages.supportText.defaultMessage)).toBeInTheDocument();
+ });
+ });
});
});
diff --git a/src/optimizer-page/CourseOptimizerPage.tsx b/src/optimizer-page/CourseOptimizerPage.tsx
index 22e995484d..375c35ac99 100644
--- a/src/optimizer-page/CourseOptimizerPage.tsx
+++ b/src/optimizer-page/CourseOptimizerPage.tsx
@@ -13,10 +13,10 @@ import { Helmet } from 'react-helmet';
import CourseStepper from '../generic/course-stepper';
import ConnectionErrorAlert from '../generic/ConnectionErrorAlert';
import SubHeader from '../generic/sub-header/SubHeader';
-import { RequestStatus } from '../data/constants';
+import { RequestFailureStatuses, RequestStatus } from '../data/constants';
import messages from './messages';
import {
- getCurrentStage, getError, getLinkCheckInProgress, getLoadingStatus, getLinkCheckResult,
+ getCurrentStage, getError, getLinkCheckInProgress, getLoadingStatus, getSavingStatus, getLinkCheckResult,
getLastScannedAt,
} from './data/selectors';
import { startLinkCheck, fetchLinkCheckStatus } from './data/thunks';
@@ -49,12 +49,14 @@ const CourseOptimizerPage: FC<{ courseId: string }> = ({ courseId }) => {
const dispatch = useDispatch();
const linkCheckInProgress = useSelector(getLinkCheckInProgress);
const loadingStatus = useSelector(getLoadingStatus);
+ const savingStatus = useSelector(getSavingStatus);
const currentStage = useSelector(getCurrentStage);
const linkCheckResult = useSelector(getLinkCheckResult);
const lastScannedAt = useSelector(getLastScannedAt);
const { msg: errorMessage } = useSelector(getError);
const isShowExportButton = !linkCheckInProgress || errorMessage;
- const isLoadingDenied = loadingStatus === RequestStatus.DENIED;
+ const isLoadingDenied = (RequestFailureStatuses as string[]).includes(loadingStatus);
+ const isSavingDenied = (RequestFailureStatuses as string[]).includes(savingStatus);
const interval = useRef(undefined);
const courseDetails = useModel('courseDetails', courseId);
const linkCheckPresent = !!currentStage;
@@ -93,13 +95,13 @@ const CourseOptimizerPage: FC<{ courseId: string }> = ({ courseId }) => {
};
}, [linkCheckInProgress, linkCheckResult]);
- if (isLoadingDenied) {
+ if (isLoadingDenied || isSavingDenied) {
if (interval.current) { clearInterval(interval.current); }
return (
-
-
-
+ //
+
+ //
);
}
From 2436706db5bacce6dc512699f4ef68735634f930 Mon Sep 17 00:00:00 2001
From: Jesper Hodge
Date: Mon, 6 Jan 2025 14:40:29 -0500
Subject: [PATCH 36/43] fix: tests
---
src/generic/ConnectionErrorAlert.jsx | 2 +-
src/optimizer-page/CourseOptimizerPage.tsx | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/src/generic/ConnectionErrorAlert.jsx b/src/generic/ConnectionErrorAlert.jsx
index a6ceceedce..44d088a66b 100644
--- a/src/generic/ConnectionErrorAlert.jsx
+++ b/src/generic/ConnectionErrorAlert.jsx
@@ -1,4 +1,4 @@
-import React, { useEffect } from 'react';
+import React from 'react';
import { injectIntl, intlShape, FormattedMessage } from '@edx/frontend-platform/i18n';
import { Alert } from '@openedx/paragon';
import { getConfig } from '@edx/frontend-platform';
diff --git a/src/optimizer-page/CourseOptimizerPage.tsx b/src/optimizer-page/CourseOptimizerPage.tsx
index 375c35ac99..f760e45fcf 100644
--- a/src/optimizer-page/CourseOptimizerPage.tsx
+++ b/src/optimizer-page/CourseOptimizerPage.tsx
@@ -13,7 +13,7 @@ import { Helmet } from 'react-helmet';
import CourseStepper from '../generic/course-stepper';
import ConnectionErrorAlert from '../generic/ConnectionErrorAlert';
import SubHeader from '../generic/sub-header/SubHeader';
-import { RequestFailureStatuses, RequestStatus } from '../data/constants';
+import { RequestFailureStatuses } from '../data/constants';
import messages from './messages';
import {
getCurrentStage, getError, getLinkCheckInProgress, getLoadingStatus, getSavingStatus, getLinkCheckResult,
From 80a5a5a03a6df7851d0cd4ef73da4c96559a9389 Mon Sep 17 00:00:00 2001
From: Jesper Hodge
Date: Mon, 6 Jan 2025 16:56:41 -0500
Subject: [PATCH 37/43] fix: test coverage
---
src/optimizer-page/data/thunks.test.js | 45 ++++++++++++++++++++------
1 file changed, 35 insertions(+), 10 deletions(-)
diff --git a/src/optimizer-page/data/thunks.test.js b/src/optimizer-page/data/thunks.test.js
index e524fbdbec..208b8f4527 100644
--- a/src/optimizer-page/data/thunks.test.js
+++ b/src/optimizer-page/data/thunks.test.js
@@ -68,11 +68,16 @@ describe('startLinkCheck thunk', () => {
});
describe('fetchLinkCheckStatus thunk', () => {
+ const dispatch = jest.fn();
+ const getState = jest.fn();
+ const courseId = 'course-123';
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
describe('successful request', () => {
it('should return scan result', async () => {
- const dispatch = jest.fn();
- const getState = jest.fn();
- const courseId = 'course-123';
jest
.spyOn(api, 'getLinkCheckStatus')
.mockResolvedValue({
@@ -103,13 +108,25 @@ describe('fetchLinkCheckStatus thunk', () => {
type: 'courseOptimizer/updateLoadingStatus',
});
});
+
+ it('with link check in progress should set current stage to 1', async () => {
+ jest
+ .spyOn(api, 'getLinkCheckStatus')
+ .mockResolvedValue({
+ linkCheckStatus: LINK_CHECK_STATUSES.IN_PROGRESS,
+ });
+
+ await fetchLinkCheckStatus(courseId)(dispatch, getState);
+
+ expect(dispatch).toHaveBeenCalledWith({
+ payload: 1,
+ type: 'courseOptimizer/updateCurrentStage',
+ });
+ });
});
describe('failed request', () => {
it('should set request status to failed', async () => {
- const dispatch = jest.fn();
- const getState = jest.fn();
- const courseId = 'course-123';
jest
.spyOn(api, 'getLinkCheckStatus')
.mockRejectedValue(new Error('error'));
@@ -123,6 +140,18 @@ describe('fetchLinkCheckStatus thunk', () => {
});
});
+ describe('unauthorized request', () => {
+ it('should set request status to denied', async () => {
+ jest.spyOn(api, 'getLinkCheckStatus').mockRejectedValue({ response: { status: 403 } });
+ await fetchLinkCheckStatus(courseId)(dispatch, getState);
+
+ expect(dispatch).toHaveBeenCalledWith({
+ payload: { status: RequestStatus.DENIED },
+ type: 'courseOptimizer/updateLoadingStatus',
+ });
+ });
+ });
+
describe('failed scan', () => {
it('should set error message', async () => {
jest
@@ -133,10 +162,6 @@ describe('fetchLinkCheckStatus thunk', () => {
linkCheckCreatedAt: mockApiResponse.LinkCheckCreatedAt,
});
- const dispatch = jest.fn();
- const getState = jest.fn();
- const courseId = 'course-123';
-
await fetchLinkCheckStatus(courseId)(dispatch, getState);
expect(dispatch).toHaveBeenCalledWith({
From db6d4432446d6aca5a60c43ee62cd27befb7beae Mon Sep 17 00:00:00 2001
From: Jesper Hodge
Date: Thu, 9 Jan 2025 17:25:49 -0500
Subject: [PATCH 38/43] fix: PR discussions
---
.../scan-results/BrokenLinkTable.tsx | 10 ++++++++++
.../scan-results/ScanResults.scss | 8 ++++++++
.../scan-results/ScanResults.tsx | 7 ++++---
.../{ => scan-results}/SectionCollapsible.tsx | 6 ++++--
src/optimizer-page/scan-results/messages.js | 6 +++++-
src/optimizer-page/types.ts | 1 +
src/optimizer-page/utils.ts | 19 ++++++++++++-------
7 files changed, 44 insertions(+), 13 deletions(-)
rename src/optimizer-page/{ => scan-results}/SectionCollapsible.tsx (85%)
diff --git a/src/optimizer-page/scan-results/BrokenLinkTable.tsx b/src/optimizer-page/scan-results/BrokenLinkTable.tsx
index 59da8a410f..72d20521f3 100644
--- a/src/optimizer-page/scan-results/BrokenLinkTable.tsx
+++ b/src/optimizer-page/scan-results/BrokenLinkTable.tsx
@@ -50,6 +50,7 @@ const BrokenLinkTable: FC = ({
) => {
const blockBrokenLinks = block.brokenLinks.map((link) => ({
blockLink: ,
+ blockDisplayName: block.displayName || '',
brokenLink: ,
status: (
@@ -67,6 +68,7 @@ const BrokenLinkTable: FC = ({
const blockLockedLinks = block.lockedLinks.map((link) => ({
blockLink: ,
+ blockDisplayName: block.displayName || '',
brokenLink: ,
status: (
@@ -82,6 +84,14 @@ const BrokenLinkTable: FC = ({
[],
)}
columns={[
+
+ {
+ key: 'blockDisplayName',
+ columnSortable: true,
+ onSort: () => {},
+ width: 'col-3',
+ hideHeader: true,
+ },
{
key: 'blockLink',
columnSortable: true,
diff --git a/src/optimizer-page/scan-results/ScanResults.scss b/src/optimizer-page/scan-results/ScanResults.scss
index aba547ef39..1918ad7b0a 100644
--- a/src/optimizer-page/scan-results/ScanResults.scss
+++ b/src/optimizer-page/scan-results/ScanResults.scss
@@ -11,6 +11,14 @@
font-style: italic;
}
+ .yellow-italics {
+ color: $warning-800;;
+ margin-left: 2rem;
+ font-weight: 400;
+ font-size: 80%;
+ font-style: italic;
+ }
+
.section {
&.is-open {
&:not(:first-child) {
diff --git a/src/optimizer-page/scan-results/ScanResults.tsx b/src/optimizer-page/scan-results/ScanResults.tsx
index c98fc1bd63..a409b12495 100644
--- a/src/optimizer-page/scan-results/ScanResults.tsx
+++ b/src/optimizer-page/scan-results/ScanResults.tsx
@@ -5,7 +5,7 @@ import {
} from '@openedx/paragon';
import { useIntl } from '@edx/frontend-platform/i18n';
import messages from './messages';
-import SectionCollapsible from '../SectionCollapsible';
+import SectionCollapsible from './SectionCollapsible';
import BrokenLinkTable from './BrokenLinkTable';
import LockedInfoIcon from './LockedInfoIcon';
import { LinkCheckResult } from '../types';
@@ -30,7 +30,7 @@ const ScanResults: FC = ({ data }) => {
const intl = useIntl();
const [showLockedLinks, setShowLockedLinks] = useState(true);
- const brokenLinkCounts = useMemo(() => countBrokenLinks(data), [data?.sections]);
+ const { brokenLinksCounts, lockedLinksCounts } = useMemo(() => countBrokenLinks(data), [data?.sections]);
if (!data?.sections) {
return ;
@@ -62,7 +62,8 @@ const ScanResults: FC = ({ data }) => {
{section.subsections.map((subsection) => (
<>
diff --git a/src/optimizer-page/SectionCollapsible.tsx b/src/optimizer-page/scan-results/SectionCollapsible.tsx
similarity index 85%
rename from src/optimizer-page/SectionCollapsible.tsx
rename to src/optimizer-page/scan-results/SectionCollapsible.tsx
index 83c1e382e2..077f747cce 100644
--- a/src/optimizer-page/SectionCollapsible.tsx
+++ b/src/optimizer-page/scan-results/SectionCollapsible.tsx
@@ -11,12 +11,13 @@ import {
interface Props {
title: string;
children: React.ReactNode;
- redItalics: string;
+ redItalics?: string;
+ yellowItalics?: string;
className?: string;
}
const SectionCollapsible: FC = ({
- title, children, redItalics, className = '',
+ title, children, redItalics = '', yellowItalics = '', className = '',
}) => {
const [isOpen, setIsOpen] = useState(false);
const styling = 'card-lg';
@@ -25,6 +26,7 @@ const SectionCollapsible: FC = ({
{title}
{redItalics}
+ {yellowItalics}
);
diff --git a/src/optimizer-page/scan-results/messages.js b/src/optimizer-page/scan-results/messages.js
index 9e29a83cf2..7b388bfe98 100644
--- a/src/optimizer-page/scan-results/messages.js
+++ b/src/optimizer-page/scan-results/messages.js
@@ -25,9 +25,13 @@ const messages = defineMessages({
id: 'course-authoring.course-optimizer.brokenLinksNumber',
defaultMessage: '{count} broken links',
},
+ lockedLinksNumber: {
+ id: 'course-authoring.course-optimizer.lockedLinksNumber',
+ defaultMessage: '{count} locked links',
+ },
lockedInfoTooltip: {
id: 'course-authoring.course-optimizer.lockedInfoTooltip',
- defaultMessage: 'These course files are "locked", so we cannot test whether they work or not.',
+ defaultMessage: 'These course files are "locked", so we cannot verify if the link can access the file.',
},
brokenLinkStatus: {
id: 'course-authoring.course-optimizer.brokenLinkStatus',
diff --git a/src/optimizer-page/types.ts b/src/optimizer-page/types.ts
index e5034e889c..831c692819 100644
--- a/src/optimizer-page/types.ts
+++ b/src/optimizer-page/types.ts
@@ -3,6 +3,7 @@ export interface Unit {
displayName: string;
blocks: {
id: string;
+ displayName?: string;
url: string;
brokenLinks: string[];
lockedLinks: string[];
diff --git a/src/optimizer-page/utils.ts b/src/optimizer-page/utils.ts
index dd76763761..92f287dc1c 100644
--- a/src/optimizer-page/utils.ts
+++ b/src/optimizer-page/utils.ts
@@ -1,21 +1,26 @@
/* eslint-disable import/prefer-default-export */
import { LinkCheckResult } from './types';
-export const countBrokenLinks = (data: LinkCheckResult | null): number[] => {
+export const countBrokenLinks = (data: LinkCheckResult | null):
+{ brokenLinksCounts: number[], lockedLinksCounts: number[] } => {
if (!data?.sections) {
- return [];
+ return { brokenLinksCounts: [], lockedLinksCounts: [] };
}
- const counts: number[] = [];
+ const brokenLinksCounts: number[] = [];
+ const lockedLinksCounts: number[] = [];
data.sections.forEach((section) => {
- let count = 0;
+ let brokenLinks = 0;
+ let lockedLinks = 0;
section.subsections.forEach((subsection) => {
subsection.units.forEach((unit) => {
unit.blocks.forEach((block) => {
- count += block.brokenLinks.length;
+ brokenLinks += block.brokenLinks.length;
+ lockedLinks += block.lockedLinks.length;
});
});
});
- counts.push(count);
+ brokenLinksCounts.push(brokenLinks);
+ lockedLinksCounts.push(lockedLinks);
});
- return counts;
+ return { brokenLinksCounts, lockedLinksCounts };
};
From de191577111b87072265157588b2bcb18d5ba6d8 Mon Sep 17 00:00:00 2001
From: Jesper Hodge
Date: Fri, 10 Jan 2025 15:38:05 -0500
Subject: [PATCH 39/43] test: locked links
---
.../CourseOptimizerPage.test.js | 27 +++++++++++++++++--
1 file changed, 25 insertions(+), 2 deletions(-)
diff --git a/src/optimizer-page/CourseOptimizerPage.test.js b/src/optimizer-page/CourseOptimizerPage.test.js
index 3801a9b61a..5f69e18401 100644
--- a/src/optimizer-page/CourseOptimizerPage.test.js
+++ b/src/optimizer-page/CourseOptimizerPage.test.js
@@ -128,18 +128,41 @@ describe('CourseOptimizerPage', () => {
});
it('should list broken links results', async () => {
- const { getByText, getAllByText, container } = render();
+ const {
+ getByText, queryAllByText, getAllByText, container,
+ } = render();
expect(getByText(messages.headingTitle.defaultMessage)).toBeInTheDocument();
fireEvent.click(getByText(messages.buttonTitle.defaultMessage));
await waitFor(() => {
expect(getByText('5 broken links')).toBeInTheDocument();
+ expect(getByText('5 locked links')).toBeInTheDocument();
});
const collapsibleTrigger = container.querySelector('.collapsible-trigger');
expect(collapsibleTrigger).toBeInTheDocument();
fireEvent.click(collapsibleTrigger);
await waitFor(() => {
expect(getAllByText(scanResultsMessages.brokenLinkStatus.defaultMessage)[0]).toBeInTheDocument();
- expect(getAllByText(scanResultsMessages.lockedLinkStatus.defaultMessage)[0]).toBeInTheDocument();
+ expect(queryAllByText(scanResultsMessages.lockedLinkStatus.defaultMessage)[0]).toBeInTheDocument();
+ });
+ });
+
+ it('should not list locked links results when show locked links is unchecked', async () => {
+ const {
+ getByText, getAllByText, getByLabelText, queryAllByText, queryByText, container,
+ } = render();
+ expect(getByText(messages.headingTitle.defaultMessage)).toBeInTheDocument();
+ fireEvent.click(getByText(messages.buttonTitle.defaultMessage));
+ await waitFor(() => {
+ expect(getByText('5 broken links')).toBeInTheDocument();
+ });
+ fireEvent.click(getByLabelText(scanResultsMessages.lockedCheckboxLabel.defaultMessage));
+ const collapsibleTrigger = container.querySelector('.collapsible-trigger');
+ expect(collapsibleTrigger).toBeInTheDocument();
+ fireEvent.click(collapsibleTrigger);
+ await waitFor(() => {
+ expect(queryByText('5 locked links')).not.toBeInTheDocument();
+ expect(getAllByText(scanResultsMessages.brokenLinkStatus.defaultMessage)[0]).toBeInTheDocument();
+ expect(queryAllByText(scanResultsMessages.lockedLinkStatus.defaultMessage)?.[0]).toBeUndefined();
});
});
From 5aec1d5a0fc9935a059c3d64ed5ce054c7205aa6 Mon Sep 17 00:00:00 2001
From: Jesper Hodge
Date: Fri, 10 Jan 2025 15:44:44 -0500
Subject: [PATCH 40/43] fix: tests
---
src/optimizer-page/utils.test.js | 8 ++++----
src/optimizer-page/utils.ts | 4 ++--
2 files changed, 6 insertions(+), 6 deletions(-)
diff --git a/src/optimizer-page/utils.test.js b/src/optimizer-page/utils.test.js
index 07983888b9..0af97a1467 100644
--- a/src/optimizer-page/utils.test.js
+++ b/src/optimizer-page/utils.test.js
@@ -4,7 +4,7 @@ import { countBrokenLinks } from './utils';
describe('countBrokenLinks', () => {
it('should return the count of broken links', () => {
const data = mockApiResponse.LinkCheckOutput;
- expect(countBrokenLinks(data)).toStrictEqual([5, 2]);
+ expect(countBrokenLinks(data)).toStrictEqual({ brokenLinksCounts: [5, 2], lockedLinksCounts: [5, 2] });
});
it('should return 0 if there are no broken links', () => {
@@ -27,18 +27,18 @@ describe('countBrokenLinks', () => {
},
],
};
- expect(countBrokenLinks(data)).toStrictEqual([0]);
+ expect(countBrokenLinks(data)).toStrictEqual({ brokenLinksCounts: [0], lockedLinksCounts: [0] });
});
it('should return [] if there is no data', () => {
const data = {};
- expect(countBrokenLinks(data)).toStrictEqual([]);
+ expect(countBrokenLinks(data)).toStrictEqual({ brokenLinksCounts: [], lockedLinksCounts: [] });
});
it('should return [] if there are no sections', () => {
const data = {
sections: [],
};
- expect(countBrokenLinks(data)).toStrictEqual([]);
+ expect(countBrokenLinks(data)).toStrictEqual({ brokenLinksCounts: [], lockedLinksCounts: [] });
});
});
diff --git a/src/optimizer-page/utils.ts b/src/optimizer-page/utils.ts
index 92f287dc1c..712cf61eb4 100644
--- a/src/optimizer-page/utils.ts
+++ b/src/optimizer-page/utils.ts
@@ -14,8 +14,8 @@ export const countBrokenLinks = (data: LinkCheckResult | null):
section.subsections.forEach((subsection) => {
subsection.units.forEach((unit) => {
unit.blocks.forEach((block) => {
- brokenLinks += block.brokenLinks.length;
- lockedLinks += block.lockedLinks.length;
+ brokenLinks += block.brokenLinks?.length || 0;
+ lockedLinks += block.lockedLinks?.length || 0;
});
});
});
From 3be9b89bff64709dc7b5213c5077179c8f933589 Mon Sep 17 00:00:00 2001
From: Jesper Hodge
Date: Fri, 10 Jan 2025 16:44:19 -0500
Subject: [PATCH 41/43] fix: test
---
src/optimizer-page/scan-results/BrokenLinkTable.tsx | 9 ++-------
1 file changed, 2 insertions(+), 7 deletions(-)
diff --git a/src/optimizer-page/scan-results/BrokenLinkTable.tsx b/src/optimizer-page/scan-results/BrokenLinkTable.tsx
index 72d20521f3..ebd177d12b 100644
--- a/src/optimizer-page/scan-results/BrokenLinkTable.tsx
+++ b/src/optimizer-page/scan-results/BrokenLinkTable.tsx
@@ -84,32 +84,27 @@ const BrokenLinkTable: FC = ({
[],
)}
columns={[
-
{
key: 'blockDisplayName',
- columnSortable: true,
- onSort: () => {},
+ columnSortable: false,
width: 'col-3',
hideHeader: true,
},
{
key: 'blockLink',
- columnSortable: true,
- onSort: () => {},
+ columnSortable: false,
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 9d7e9a35e005071a679eeee20c5498015901fabe Mon Sep 17 00:00:00 2001
From: Jesper Hodge
Date: Fri, 10 Jan 2025 16:59:36 -0500
Subject: [PATCH 42/43] test: ai generate some reducer tests
---
src/optimizer-page/data/slice.test.ts | 111 ++++++++++++++++++++++++++
1 file changed, 111 insertions(+)
create mode 100644 src/optimizer-page/data/slice.test.ts
diff --git a/src/optimizer-page/data/slice.test.ts b/src/optimizer-page/data/slice.test.ts
new file mode 100644
index 0000000000..14ea76d338
--- /dev/null
+++ b/src/optimizer-page/data/slice.test.ts
@@ -0,0 +1,111 @@
+import { AnyAction, configureStore, ThunkMiddleware } from '@reduxjs/toolkit';
+import { ToolkitStore } from '@reduxjs/toolkit/dist/configureStore';
+import {
+ CourseOptimizerState,
+ reducer,
+ updateLinkCheckInProgress,
+ updateLinkCheckResult,
+ updateLastScannedAt,
+ updateCurrentStage,
+ updateDownloadPath,
+ updateSuccessDate,
+ updateError,
+ updateIsErrorModalOpen,
+ reset,
+ updateLoadingStatus,
+ updateSavingStatus,
+} from './slice';
+
+describe('courseOptimizer slice', () => {
+ let store: ToolkitStore]>;
+
+ beforeEach(() => {
+ store = configureStore({ reducer });
+ });
+
+ it('should handle initial state', () => {
+ expect(store.getState()).toEqual({
+ linkCheckInProgress: null,
+ linkCheckResult: null,
+ lastScannedAt: null,
+ currentStage: null,
+ error: { msg: null, unitUrl: null },
+ downloadPath: null,
+ successDate: null,
+ isErrorModalOpen: false,
+ loadingStatus: '',
+ savingStatus: '',
+ });
+ });
+
+ it('should handle updateLinkCheckInProgress', () => {
+ store.dispatch(updateLinkCheckInProgress(true));
+ expect(store.getState().linkCheckInProgress).toBe(true);
+ });
+
+ it('should handle updateLinkCheckResult', () => {
+ const result = { valid: true };
+ store.dispatch(updateLinkCheckResult(result));
+ expect(store.getState().linkCheckResult).toEqual(result);
+ });
+
+ it('should handle updateLastScannedAt', () => {
+ const date = '2023-10-01';
+ store.dispatch(updateLastScannedAt(date));
+ expect(store.getState().lastScannedAt).toBe(date);
+ });
+
+ it('should handle updateCurrentStage', () => {
+ store.dispatch(updateCurrentStage(2));
+ expect(store.getState().currentStage).toBe(2);
+ });
+
+ it('should handle updateDownloadPath', () => {
+ const path = '/path/to/download';
+ store.dispatch(updateDownloadPath(path));
+ expect(store.getState().downloadPath).toBe(path);
+ });
+
+ it('should handle updateSuccessDate', () => {
+ const date = '2023-10-01';
+ store.dispatch(updateSuccessDate(date));
+ expect(store.getState().successDate).toBe(date);
+ });
+
+ it('should handle updateError', () => {
+ const error = { msg: 'Error message', unitUrl: 'http://example.com' };
+ store.dispatch(updateError(error));
+ expect(store.getState().error).toEqual(error);
+ });
+
+ it('should handle updateIsErrorModalOpen', () => {
+ store.dispatch(updateIsErrorModalOpen(true));
+ expect(store.getState().isErrorModalOpen).toBe(true);
+ });
+
+ it('should handle reset', () => {
+ store.dispatch(reset());
+ expect(store.getState()).toEqual({
+ linkCheckInProgress: null,
+ linkCheckResult: null,
+ lastScannedAt: null,
+ currentStage: null,
+ error: { msg: null, unitUrl: null },
+ downloadPath: null,
+ successDate: null,
+ isErrorModalOpen: false,
+ loadingStatus: '',
+ savingStatus: '',
+ });
+ });
+
+ it('should handle updateLoadingStatus', () => {
+ store.dispatch(updateLoadingStatus({ status: 'loading' }));
+ expect(store.getState().loadingStatus).toBe('loading');
+ });
+
+ it('should handle updateSavingStatus', () => {
+ store.dispatch(updateSavingStatus({ status: 'saving' }));
+ expect(store.getState().savingStatus).toBe('saving');
+ });
+});
From 1b8854c1752bd4474c4c5c2f26787aa4b9066c8b Mon Sep 17 00:00:00 2001
From: Jesper Hodge
Date: Fri, 10 Jan 2025 18:36:22 -0500
Subject: [PATCH 43/43] test: menu items
---
src/header/hooks.test.js | 29 +++++++++++++++++++++--------
1 file changed, 21 insertions(+), 8 deletions(-)
diff --git a/src/header/hooks.test.js b/src/header/hooks.test.js
index 9b9f1cbae2..aa0829b33c 100644
--- a/src/header/hooks.test.js
+++ b/src/header/hooks.test.js
@@ -1,6 +1,7 @@
import { useSelector } from 'react-redux';
import { getConfig, setConfig } from '@edx/frontend-platform';
import { renderHook } from '@testing-library/react-hooks';
+import messages from './messages';
import { useContentMenuItems, useToolsMenuItems, useSettingMenuItems } from './hooks';
jest.mock('@edx/frontend-platform/i18n', () => ({
@@ -17,7 +18,7 @@ jest.mock('react-redux', () => ({
describe('header utils', () => {
describe('getContentMenuItems', () => {
- it('should include Video Uploads option', () => {
+ it('when video upload page enabled should include Video Uploads option', () => {
setConfig({
...getConfig(),
ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN: 'true',
@@ -25,7 +26,7 @@ describe('header utils', () => {
const actualItems = renderHook(() => useContentMenuItems('course-123')).result.current;
expect(actualItems).toHaveLength(5);
});
- it('should not include Video Uploads option', () => {
+ it('when video upload page disabled should not include Video Uploads option', () => {
setConfig({
...getConfig(),
ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN: 'false',
@@ -38,7 +39,7 @@ describe('header utils', () => {
describe('getSettingsMenuitems', () => {
useSelector.mockReturnValue({ canAccessAdvancedSettings: true });
- it('should include certificates option', () => {
+ it('when certificate page enabled should include certificates option', () => {
setConfig({
...getConfig(),
ENABLE_CERTIFICATE_PAGE: 'true',
@@ -46,7 +47,7 @@ describe('header utils', () => {
const actualItems = renderHook(() => useSettingMenuItems('course-123')).result.current;
expect(actualItems).toHaveLength(6);
});
- it('should not include certificates option', () => {
+ it('when certificate page disabled should not include certificates option', () => {
setConfig({
...getConfig(),
ENABLE_CERTIFICATE_PAGE: 'false',
@@ -54,11 +55,11 @@ describe('header utils', () => {
const actualItems = renderHook(() => useSettingMenuItems('course-123')).result.current;
expect(actualItems).toHaveLength(5);
});
- it('should include advanced settings option', () => {
+ it('when user has access to advanced settings should include advanced settings option', () => {
const actualItemsTitle = renderHook(() => useSettingMenuItems('course-123')).result.current.map((item) => item.title);
expect(actualItemsTitle).toContain('Advanced Settings');
});
- it('should not include advanced settings option', () => {
+ it('when user has no access to advanced settings should not include advanced settings option', () => {
useSelector.mockReturnValue({ canAccessAdvancedSettings: false });
const actualItemsTitle = renderHook(() => useSettingMenuItems('course-123')).result.current.map((item) => item.title);
expect(actualItemsTitle).not.toContain('Advanced Settings');
@@ -66,7 +67,7 @@ describe('header utils', () => {
});
describe('getToolsMenuItems', () => {
- it('should include export tags option', () => {
+ it('when tags enabled should include export tags option', () => {
setConfig({
...getConfig(),
ENABLE_TAGGING_TAXONOMY_PAGES: 'true',
@@ -79,7 +80,7 @@ describe('header utils', () => {
'Checklists',
]);
});
- it('should not include export tags option', () => {
+ it('when tags disabled should not include export tags option', () => {
setConfig({
...getConfig(),
ENABLE_TAGGING_TAXONOMY_PAGES: 'false',
@@ -91,5 +92,17 @@ describe('header utils', () => {
'Checklists',
]);
});
+
+ it('when course optimizer enabled should include optimizer option', () => {
+ useSelector.mockReturnValue({ enableCourseOptimizer: true });
+ const actualItemsTitle = renderHook(() => useToolsMenuItems('course-123')).result.current.map((item) => item.title);
+ expect(actualItemsTitle).toContain(messages['header.links.optimizer'].defaultMessage);
+ });
+
+ it('when course optimizer disabled should not include optimizer option', () => {
+ useSelector.mockReturnValue({ enableCourseOptimizer: false });
+ const actualItemsTitle = renderHook(() => useToolsMenuItems('course-123')).result.current.map((item) => item.title);
+ expect(actualItemsTitle).not.toContain(messages['header.links.optimizer'].defaultMessage);
+ });
});
});