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/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/header/hooks.js b/src/header/hooks.js index 6758fbc27b..d1e5e3a449 100644 --- a/src/header/hooks.js +++ b/src/header/hooks.js @@ -103,6 +103,10 @@ export const useToolsMenuItems = courseId => { href: `/course/${courseId}/checklists`, title: intl.formatMessage(messages['header.links.checklists']), }, + ...(waffleFlags.enableCourseOptimizer ? [{ + href: `/course/${courseId}/optimizer`, + title: intl.formatMessage(messages['header.links.optimizer']), + }] : []), ]; return items; }; diff --git a/src/header/messages.js b/src/header/messages.js index 31d5f32fa6..6c578790f5 100644 --- a/src/header/messages.js +++ b/src/header/messages.js @@ -96,6 +96,11 @@ const messages = defineMessages({ defaultMessage: 'Export Course', description: 'Link to Studio Export page', }, + 'header.links.optimizer': { + id: 'header.links.optimizer', + defaultMessage: 'Optimize Course', + description: 'Fix broken links and other issues in your course', + }, 'header.links.exportTags': { id: 'header.links.exportTags', defaultMessage: 'Export Tags', diff --git a/src/index.scss b/src/index.scss index 69f9b8b34f..31ffa2de8d 100644 --- a/src/index.scss +++ b/src/index.scss @@ -31,6 +31,7 @@ @import "search-manager"; @import "certificates/scss/Certificates"; @import "group-configurations/GroupConfigurations"; +@import "optimizer-page/scan-results/ScanResults"; // To apply the glow effect to the selected Section/Subsection, in the Course Outline div.row:has(> div > div.highlight) { diff --git a/src/optimizer-page/CourseOptimizerPage.jsx b/src/optimizer-page/CourseOptimizerPage.jsx new file mode 100644 index 0000000000..3d2bb5d339 --- /dev/null +++ b/src/optimizer-page/CourseOptimizerPage.jsx @@ -0,0 +1,168 @@ +/* eslint-disable no-param-reassign */ +import { useEffect, useRef } from 'react'; +import PropTypes from 'prop-types'; +import { useDispatch, useSelector } from 'react-redux'; +import { useIntl } from '@edx/frontend-platform/i18n'; +import { + Container, Layout, Button, Card, +} from '@openedx/paragon'; +import { Search as SearchIcon } from '@openedx/paragon/icons'; +import { Helmet } from 'react-helmet'; + +import CourseStepper from '../generic/course-stepper'; +import ConnectionErrorAlert from '../generic/ConnectionErrorAlert'; +import SubHeader from '../generic/sub-header/SubHeader'; +import { RequestStatus } from '../data/constants'; +import messages from './messages'; +import { + getCurrentStage, getError, getLinkCheckInProgress, getLoadingStatus, getLinkCheckResult, +} from './data/selectors'; +import { startLinkCheck, fetchLinkCheckStatus } from './data/thunks'; +import { useModel } from '../generic/model-store'; +import ScanResults from './scan-results'; + +const pollLinkCheckStatus = (dispatch, courseId, delay) => { + const interval = setInterval(() => { + dispatch(fetchLinkCheckStatus(courseId)); + }, delay); + return interval; +}; + +export function pollLinkCheckDuringScan(linkCheckInProgress, linkCheckResult, interval, dispatch, courseId) { + if (linkCheckInProgress === null || linkCheckInProgress) { + 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); + const loadingStatus = useSelector(getLoadingStatus); + const currentStage = useSelector(getCurrentStage); + const linkCheckResult = useSelector(getLinkCheckResult); + const { msg: errorMessage } = useSelector(getError); + const isShowExportButton = !linkCheckInProgress || errorMessage; + const isLoadingDenied = loadingStatus === RequestStatus.DENIED; + const interval = useRef(null); + const courseDetails = useModel('courseDetails', courseId); + const linkCheckPresent = !!currentStage; + const intl = useIntl(); + + const courseStepperSteps = [ + { + title: intl.formatMessage(messages.preparingStepTitle), + description: intl.formatMessage(messages.preparingStepDescription), + key: 'course-step-preparing', + }, + { + title: intl.formatMessage(messages.scanningStepTitle), + description: intl.formatMessage(messages.scanningStepDescription), + key: 'course-step-scanning', + }, + { + title: intl.formatMessage(messages.successStepTitle), + description: intl.formatMessage(messages.successStepDescription), + key: 'course-step-success', + }, + ]; + + 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 () => { + if (interval.current) { clearInterval(interval.current); } + }; + }, [linkCheckInProgress, linkCheckResult]); + + if (isLoadingDenied) { + if (interval.current) { clearInterval(interval.current); } + + return ( + + + + ); + } + + return ( + <> + + + {intl.formatMessage(messages.pageTitle, { + headingTitle: intl.formatMessage(messages.headingTitle), + courseName: courseDetails?.name, + siteName: process.env.SITE_NAME, + })} + + + +
+ + +
+ +

{intl.formatMessage(messages.description1)}

+

{intl.formatMessage(messages.description2)}

+ + + {isShowExportButton && ( + + + + )} + {linkCheckPresent && ( + + + + )} + + {linkCheckPresent && } +
+
+
+
+
+ + ); +}; +CourseOptimizerPage.propTypes = { + courseId: PropTypes.string.isRequired, +}; + +export default CourseOptimizerPage; diff --git a/src/optimizer-page/CourseOptimizerPage.test.js b/src/optimizer-page/CourseOptimizerPage.test.js new file mode 100644 index 0000000000..bc2926395c --- /dev/null +++ b/src/optimizer-page/CourseOptimizerPage.test.js @@ -0,0 +1,147 @@ +/* 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', () => { + 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 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).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(); + }); + }); + + 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/SectionCollapsible.tsx b/src/optimizer-page/SectionCollapsible.tsx new file mode 100644 index 0000000000..83c1e382e2 --- /dev/null +++ b/src/optimizer-page/SectionCollapsible.tsx @@ -0,0 +1,51 @@ +import { useState, FC } from 'react'; +import { + Collapsible, + Icon, +} from '@openedx/paragon'; +import { + ArrowRight, + ArrowDropDown, +} from '@openedx/paragon/icons'; + +interface Props { + title: string; + children: React.ReactNode; + redItalics: string; + className?: string; +} + +const SectionCollapsible: FC = ({ + title, children, redItalics, className = '', +}) => { + const [isOpen, setIsOpen] = useState(false); + const styling = 'card-lg'; + const collapsibleTitle = ( +
+ + {title} + {redItalics} +
+ ); + + return ( +
+ + {collapsibleTitle} +

+ )} + iconWhenClosed="" + iconWhenOpen="" + open={isOpen} + onToggle={() => setIsOpen(!isOpen)} + > + {children} +
+
+ ); +}; + +export default SectionCollapsible; diff --git a/src/optimizer-page/data/api.test.js b/src/optimizer-page/data/api.test.js new file mode 100644 index 0000000000..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/data/api.ts b/src/optimizer-page/data/api.ts new file mode 100644 index 0000000000..3d7d89e2c6 --- /dev/null +++ b/src/optimizer-page/data/api.ts @@ -0,0 +1,25 @@ +import { camelCaseObject, getConfig } from '@edx/frontend-platform'; +import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; +import { LinkCheckResult } from '../types'; +import { LinkCheckStatusTypes } from './constants'; + +export interface LinkCheckStatusApiResponseBody { + linkCheckStatus: LinkCheckStatusTypes; + linkCheckOutput: LinkCheckResult; +} + +const getApiBaseUrl = () => getConfig().STUDIO_BASE_URL; +export const postLinkCheckCourseApiUrl = (courseId) => new URL(`api/contentstore/v0/link_check/${courseId}`, getApiBaseUrl()).href; +export const getLinkCheckStatusApiUrl = (courseId) => new URL(`api/contentstore/v0/link_check_status/${courseId}`, getApiBaseUrl()).href; + +export async function postLinkCheck(courseId: string): Promise<{ linkCheckStatus: LinkCheckStatusTypes }> { + const { data } = await getAuthenticatedHttpClient() + .post(postLinkCheckCourseApiUrl(courseId)); + return camelCaseObject(data); +} + +export async function getLinkCheckStatus(courseId: string): Promise { + const { data } = await getAuthenticatedHttpClient() + .get(getLinkCheckStatusApiUrl(courseId)); + return camelCaseObject(data); +} diff --git a/src/optimizer-page/data/constants.ts b/src/optimizer-page/data/constants.ts new file mode 100644 index 0000000000..0ad3006d10 --- /dev/null +++ b/src/optimizer-page/data/constants.ts @@ -0,0 +1,40 @@ +export const LAST_EXPORT_COOKIE_NAME = 'lastexport'; +export const LINK_CHECK_STATUSES = { + UNINITIATED: 'Uninitiated', + PENDING: 'Pending', + IN_PROGRESS: 'In-Progress', + SUCCEEDED: 'Succeeded', + FAILED: 'Failed', + CANCELED: 'Canceled', + RETRYING: 'Retrying', +}; +export enum LinkCheckStatusTypes { + UNINITIATED = 'Uninitiated', + PENDING = 'Pending', + IN_PROGRESS = 'In-Progress', + SUCCEEDED = 'Succeeded', + FAILED = 'Failed', + CANCELED = 'Canceled', + RETRYING = 'Retrying', +} +export const SCAN_STAGES = { + [LINK_CHECK_STATUSES.UNINITIATED]: 0, + [LINK_CHECK_STATUSES.PENDING]: 1, + [LINK_CHECK_STATUSES.IN_PROGRESS]: 1, + [LINK_CHECK_STATUSES.RETRYING]: 1, + [LINK_CHECK_STATUSES.SUCCEEDED]: 2, + [LINK_CHECK_STATUSES.FAILED]: -1, + [LINK_CHECK_STATUSES.CANCELED]: -1, +}; + +export const LINK_CHECK_IN_PROGRESS_STATUSES = [ + LINK_CHECK_STATUSES.PENDING, + LINK_CHECK_STATUSES.IN_PROGRESS, + LINK_CHECK_STATUSES.RETRYING, +]; + +export const LINK_CHECK_FAILURE_STATUSES = [ + LINK_CHECK_STATUSES.FAILED, + LINK_CHECK_STATUSES.CANCELED, +]; +export const SUCCESS_DATE_FORMAT = 'MM/DD/yyyy'; diff --git a/src/optimizer-page/data/selectors.ts b/src/optimizer-page/data/selectors.ts new file mode 100644 index 0000000000..79a80a077a --- /dev/null +++ b/src/optimizer-page/data/selectors.ts @@ -0,0 +1,11 @@ +import { RootState } from './slice'; + +export const getLinkCheckInProgress = (state: RootState) => state.courseOptimizer.linkCheckInProgress; +export const getCurrentStage = (state: RootState) => state.courseOptimizer.currentStage; +export const getDownloadPath = (state: RootState) => state.courseOptimizer.downloadPath; +export const getSuccessDate = (state: RootState) => state.courseOptimizer.successDate; +export const getError = (state: RootState) => state.courseOptimizer.error; +export const getIsErrorModalOpen = (state: RootState) => state.courseOptimizer.isErrorModalOpen; +export const getLoadingStatus = (state: RootState) => state.courseOptimizer.loadingStatus; +export const getSavingStatus = (state: RootState) => state.courseOptimizer.savingStatus; +export const getLinkCheckResult = (state: RootState) => state.courseOptimizer.linkCheckResult; diff --git a/src/optimizer-page/data/slice.ts b/src/optimizer-page/data/slice.ts new file mode 100644 index 0000000000..e38b3d0262 --- /dev/null +++ b/src/optimizer-page/data/slice.ts @@ -0,0 +1,85 @@ +/* eslint-disable no-param-reassign */ +import { createSlice } from '@reduxjs/toolkit'; +import { LinkCheckResult } from '../types'; + +export interface CourseOptimizerState { + linkCheckInProgress: boolean | null; + linkCheckResult: LinkCheckResult | null; + currentStage: number | null; + error: { msg: string | null; unitUrl: string | null }; + downloadPath: string | null; + successDate: string | null; + isErrorModalOpen: boolean; + loadingStatus: string; + savingStatus: string; +} + +export type RootState = { + [key: string]: any; +} & { + courseOptimizer: CourseOptimizerState; +}; + +const initialState: CourseOptimizerState = { + linkCheckInProgress: null, + linkCheckResult: null, + currentStage: null, + error: { msg: null, unitUrl: null }, + downloadPath: null, + successDate: null, + isErrorModalOpen: false, + loadingStatus: '', + savingStatus: '', +}; + +const slice = createSlice({ + name: 'courseOptimizer', + initialState, + reducers: { + updateLinkCheckInProgress: (state, { payload }) => { + state.linkCheckInProgress = payload; + }, + updateLinkCheckResult: (state, { payload }) => { + state.linkCheckResult = payload; + }, + updateCurrentStage: (state, { payload }) => { + state.currentStage = payload; + }, + updateDownloadPath: (state, { payload }) => { + state.downloadPath = payload; + }, + updateSuccessDate: (state, { payload }) => { + state.successDate = payload; + }, + updateError: (state, { payload }) => { + state.error = payload; + }, + updateIsErrorModalOpen: (state, { payload }) => { + state.isErrorModalOpen = payload; + }, + reset: () => initialState, + updateLoadingStatus: (state, { payload }) => { + state.loadingStatus = payload.status; + }, + updateSavingStatus: (state, { payload }) => { + state.savingStatus = payload.status; + }, + }, +}); + +export const { + updateLinkCheckInProgress, + updateLinkCheckResult, + updateCurrentStage, + updateDownloadPath, + updateSuccessDate, + updateError, + updateIsErrorModalOpen, + reset, + updateLoadingStatus, + updateSavingStatus, +} = slice.actions; + +export const { + reducer, +} = slice; diff --git a/src/optimizer-page/data/thunks.test.js b/src/optimizer-page/data/thunks.test.js new file mode 100644 index 0000000000..9b866566d2 --- /dev/null +++ b/src/optimizer-page/data/thunks.test.js @@ -0,0 +1,169 @@ +import { startLinkCheck, fetchLinkCheckStatus } from './thunks'; +import * as api from './api'; +import { LINK_CHECK_STATUSES } from './constants'; +import { RequestStatus } from '../../data/constants'; +import mockApiResponse from '../mocks/mockApiResponse'; + +describe('startLinkCheck thunk', () => { + const dispatch = jest.fn(); + const getState = jest.fn(); + const courseId = 'course-123'; + let mockGetStartLinkCheck; + + beforeEach(() => { + jest.clearAllMocks(); + + mockGetStartLinkCheck = jest.spyOn(api, 'postLinkCheck').mockResolvedValue({ + linkCheckStatus: LINK_CHECK_STATUSES.IN_PROGRESS, + }); + }); + + describe('successful request', () => { + it('should set link check stage and request statuses to their in-progress states', async () => { + const inProgressStageId = 1; + await startLinkCheck(courseId)(dispatch, getState); + + expect(dispatch).toHaveBeenCalledWith({ + payload: { status: RequestStatus.PENDING }, + type: 'courseOptimizer/updateSavingStatus', + }); + + expect(dispatch).toHaveBeenCalledWith({ + payload: true, + type: 'courseOptimizer/updateLinkCheckInProgress', + }); + + expect(dispatch).toHaveBeenCalledWith({ + payload: { status: RequestStatus.SUCCESSFUL }, + type: 'courseOptimizer/updateSavingStatus', + }); + + expect(dispatch).toHaveBeenCalledWith({ + payload: inProgressStageId, + type: 'courseOptimizer/updateCurrentStage', + }); + }); + }); + + describe('failed request should set stage and request ', () => { + it('should set request status to failed', async () => { + 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', + }); + }); + }); +}); + +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 new file mode 100644 index 0000000000..63698b9224 --- /dev/null +++ b/src/optimizer-page/data/thunks.ts @@ -0,0 +1,87 @@ +import { RequestStatus } from '../../data/constants'; +import { + LINK_CHECK_FAILURE_STATUSES, + LINK_CHECK_IN_PROGRESS_STATUSES, + LINK_CHECK_STATUSES, + SCAN_STAGES, +} from './constants'; + +import { postLinkCheck, getLinkCheckStatus } from './api'; +import { + updateLinkCheckInProgress, + updateLinkCheckResult, + updateCurrentStage, + updateError, + updateIsErrorModalOpen, + updateLoadingStatus, + updateSavingStatus, +} from './slice'; + +export function startLinkCheck(courseId: string) { + return async (dispatch) => { + dispatch(updateSavingStatus({ status: RequestStatus.PENDING })); + dispatch(updateLinkCheckInProgress(true)); + dispatch(updateCurrentStage(SCAN_STAGES[LINK_CHECK_STATUSES.PENDING])); + try { + await postLinkCheck(courseId); + await dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL })); + return true; + } catch (error) { + dispatch(updateSavingStatus({ status: RequestStatus.FAILED })); + dispatch(updateLinkCheckInProgress(false)); + dispatch(updateCurrentStage(SCAN_STAGES[LINK_CHECK_STATUSES.CANCELED])); + return false; + } + }; +} + +// TODO: use new statuses +export function fetchLinkCheckStatus(courseId) { + return async (dispatch) => { + dispatch(updateLoadingStatus({ status: RequestStatus.IN_PROGRESS })); + + /* ****** Debugging ******** */ + // dispatch(updateLinkCheckInProgress(true)); + // dispatch(updateCurrentStage(3)); + // return true; + + try { + const { linkCheckStatus, linkCheckOutput } = await getLinkCheckStatus( + courseId, + ); + 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 + || linkCheckStatus === null + || LINK_CHECK_FAILURE_STATUSES.includes(linkCheckStatus) + ) { + dispatch(updateError({ msg: 'Link Check Failed' })); + dispatch(updateIsErrorModalOpen(true)); + } else if (linkCheckOutput) { + dispatch(updateLinkCheckResult(linkCheckOutput)); + } + + 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 { + dispatch( + updateLoadingStatus({ status: RequestStatus.FAILED }), + ); + } + return false; + } + }; +} diff --git a/src/optimizer-page/messages.js b/src/optimizer-page/messages.js new file mode 100644 index 0000000000..f708c489c3 --- /dev/null +++ b/src/optimizer-page/messages.js @@ -0,0 +1,62 @@ +import { defineMessages } from '@edx/frontend-platform/i18n'; + +const messages = defineMessages({ + pageTitle: { + id: 'course-authoring.course-optimizer.page.title', + defaultMessage: '{headingTitle} | {courseName} | {siteName}', + }, + headingTitle: { + id: 'course-authoring.course-optimizer.heading.title', + defaultMessage: 'Course Optimizer', + }, + headingSubtitle: { + id: 'course-authoring.course-optimizer.heading.subtitle', + defaultMessage: 'Tools', + }, + description1: { + id: 'course-authoring.course-optimizer.description1', + defaultMessage: 'This tool will scan your course for broken links. Note that this process will take more time for larger courses.', + }, + description2: { + id: 'course-authoring.course-optimizer.description2', + defaultMessage: 'Broken links are links pointing to external websites, images, or videos that do not exist or are no longer available. These links can cause issues for learners when they try to access the content.', + }, + card1Title: { + id: 'course-authoring.course-optimizer.card1.title', + defaultMessage: 'Scan my course for broken links', + }, + card2Title: { + id: 'course-authoring.course-optimizer.card2.title', + defaultMessage: 'Scan my course for broken links', + }, + buttonTitle: { + id: 'course-authoring.course-optimizer.button.title', + defaultMessage: 'Start Scanning', + }, + preparingStepTitle: { + id: 'course-authoring.course-optimizer.peparing-step.title', + defaultMessage: 'Preparing', + }, + preparingStepDescription: { + id: 'course-authoring.course-optimizer.peparing-step.description', + defaultMessage: 'Preparing to start the scan', + }, + scanningStepTitle: { + id: 'course-authoring.course-optimizer.scanning-step.title', + defaultMessage: 'Scanning', + }, + scanningStepDescription: { + id: 'course-authoring.course-optimizer.scanning-step.description', + defaultMessage: 'Scanning for broken links in your course (You can now leave this page safely, but avoid making drastic changes to content until the scan is complete)', + }, + successStepTitle: { + id: 'course-authoring.course-optimizer.success-step.title', + defaultMessage: 'Success', + }, + successStepDescription: { + id: 'course-authoring.course-optimizer.success-step.description', + defaultMessage: 'Your Scan is complete. You can view the list of results below.', + }, +}); + +export default messages; diff --git a/src/optimizer-page/mocks/mockApiResponse.js b/src/optimizer-page/mocks/mockApiResponse.js new file mode 100644 index 0000000000..dd3b54e399 --- /dev/null +++ b/src/optimizer-page/mocks/mockApiResponse.js @@ -0,0 +1,106 @@ +const mockApiResponse = { + LinkCheckStatus: 'Succeeded', + LinkCheckCreatedAt: '2024-12-14T00:26:50.838350Z', + LinkCheckOutput: { + sections: [ + { + id: 'section-1', + displayName: 'Introduction to Programming', + subsections: [ + { + id: 'subsection-1-1', + displayName: 'Getting Started', + units: [ + { + id: 'unit-1-1-1', + displayName: 'Welcome Video', + blocks: [ + { + id: 'block-1-1-1-1', + url: 'https://example.com/welcome-video', + brokenLinks: ['https://example.com/broken-link-algo'], + lockedLinks: ['https://example.com/locked-link-algo'], + }, + { + id: 'block-1-1-1-2', + url: 'https://example.com/intro-guide', + brokenLinks: ['https://example.com/broken-link-algo'], + lockedLinks: ['https://example.com/locked-link-algo'], + }, + ], + }, + { + id: 'unit-1-1-2', + displayName: 'Course Overview', + blocks: [ + { + id: 'block-1-1-2-1', + url: 'https://example.com/course-overview', + brokenLinks: ['https://example.com/broken-link-algo'], + lockedLinks: ['https://example.com/locked-link-algo'], + }, + ], + }, + ], + }, + { + id: 'subsection-1-2', + displayName: 'Basic Concepts', + units: [ + { + id: 'unit-1-2-1', + displayName: 'Variables and Data Types', + blocks: [ + { + id: 'block-1-2-1-1', + url: 'https://example.com/variables', + brokenLinks: ['https://example.com/broken-link-algo'], + lockedLinks: ['https://example.com/locked-link-algo'], + }, + { + id: 'block-1-2-1-2', + url: 'https://example.com/broken-link', + brokenLinks: ['https://example.com/broken-link'], + lockedLinks: ['https://example.com/locked-link-algo'], + }, + ], + }, + ], + }, + ], + }, + { + id: 'section-2', + displayName: 'Advanced Topics', + subsections: [ + { + id: 'subsection-2-1', + displayName: 'Algorithms and Data Structures', + units: [ + { + id: 'unit-2-1-1', + displayName: 'Sorting Algorithms', + blocks: [ + { + id: 'block-2-1-1-1', + url: 'https://example.com/sorting-algorithms', + brokenLinks: ['https://example.com/broken-link-algo'], + lockedLinks: ['https://example.com/locked-link-algo'], + }, + { + id: 'block-2-1-1-2', + url: 'https://example.com/broken-link-algo', + brokenLinks: ['https://example.com/broken-link-algo'], + lockedLinks: ['https://example.com/locked-link-algo'], + }, + ], + }, + ], + }, + ], + }, + ], + }, +}; + +export default mockApiResponse; diff --git a/src/optimizer-page/scan-results/BrokenLinkTable.tsx b/src/optimizer-page/scan-results/BrokenLinkTable.tsx new file mode 100644 index 0000000000..59da8a410f --- /dev/null +++ b/src/optimizer-page/scan-results/BrokenLinkTable.tsx @@ -0,0 +1,112 @@ +import { Icon, Table } from '@openedx/paragon'; +import { OpenInNew, Lock, LinkOff } from '@openedx/paragon/icons'; +import { useIntl } from '@edx/frontend-platform/i18n'; +import { FC } from 'react'; +import { Unit } from '../types'; +import messages from './messages'; +import LockedInfoIcon from './LockedInfoIcon'; + +const BrokenLinkHref: FC<{ href: string }> = ({ href }) => ( + +); + +const GoToBlock: FC<{ block: { url: string } }> = ({ block }) => ( + + + + Go to Block + + +); + +interface BrokenLinkTableProps { + unit: Unit; + showLockedLinks: boolean; +} + +type TableData = { + blockLink: JSX.Element; + brokenLink: JSX.Element; + status: JSX.Element; +}[]; + +const BrokenLinkTable: FC = ({ + unit, + showLockedLinks, +}) => { + const intl = useIntl(); + return ( + <> +

{unit.displayName}

+ { + const blockBrokenLinks = block.brokenLinks.map((link) => ({ + blockLink: , + 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.scss b/src/optimizer-page/scan-results/ScanResults.scss new file mode 100644 index 0000000000..aba547ef39 --- /dev/null +++ b/src/optimizer-page/scan-results/ScanResults.scss @@ -0,0 +1,102 @@ +.scan-results { + thead { + display: none; + } + + .red-italics { + color: $brand-500; + margin-left: 2rem; + font-weight: 400; + font-size: 80%; + font-style: italic; + } + + .section { + &.is-open { + &:not(:first-child) { + margin-top: 1rem; + } + + margin-bottom: 1rem; + } + } + + .open-arrow { + transform: translate(-10px, 5px); + display: inline-block; + } + + /* Section Header */ + .subsection-header { + font-size: 16px; /* Slightly smaller */ + font-weight: 600; /* Reduced boldness */ + background-color: $dark-100; + padding: 10px; + margin-bottom: 10px; + } + + /* Subsection Header */ + .unit-header { + margin-left: .5rem; + margin-top: 10px; + font-size: 14px; + font-weight: 700; + margin-bottom: 5px; + color: $primary-500; + } + + /* Block Links */ + .broken-link-list li { + margin-bottom: 8px; /* Add breathing room */ + } + + .broken-link-list a { + text-decoration: none; + margin-left: 2rem; + } + + /* Broken Links Highlight */ + .broken-links-count { + color: red; + font-weight: bold; + } + + .unit { + padding: 0 3rem; + } + + .broken-link { + color: $brand-500; + text-decoration: none; + } + + .broken-link-container { + max-width: 18rem; + text-wrap: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .locked-links-checkbox { + margin-top: .45rem; + } + + .locked-links-checkbox-wrapper { + display: flex; + gap: 1rem; + } + + .link-status-text { + display: flex; + align-items: center; + gap: .5rem; + } + + .broken-link-icon { + color: $brand-500; + } + + .lock-icon { + color: $warning-300; + } +} diff --git a/src/optimizer-page/scan-results/ScanResults.tsx b/src/optimizer-page/scan-results/ScanResults.tsx new file mode 100644 index 0000000000..c98fc1bd63 --- /dev/null +++ b/src/optimizer-page/scan-results/ScanResults.tsx @@ -0,0 +1,88 @@ +import { useState, useMemo, FC } from 'react'; +import { + Card, + CheckBox, +} from '@openedx/paragon'; +import { useIntl } from '@edx/frontend-platform/i18n'; +import messages from './messages'; +import SectionCollapsible from '../SectionCollapsible'; +import BrokenLinkTable from './BrokenLinkTable'; +import LockedInfoIcon from './LockedInfoIcon'; +import { LinkCheckResult } from '../types'; +import { countBrokenLinks } from '../utils'; + +const InfoCard: FC<{ text: string }> = ({ text }) => ( + +

+ {text} +

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

{intl.formatMessage(messages.scanHeader)}

+ + { + setShowLockedLinks(!showLockedLinks); + }} + label={intl.formatMessage(messages.lockedCheckboxLabel)} + /> + + +
+
+ + {sections?.map((section, index) => ( + + {section.subsections.map((subsection) => ( + <> +

+ {subsection.displayName} +

+ {subsection.units.map((unit) => ( +
+ +
+ ))} + + ))} +
+ ))} +
+ ); +}; + +export default ScanResults; diff --git a/src/optimizer-page/scan-results/index.js b/src/optimizer-page/scan-results/index.js new file mode 100644 index 0000000000..ab1d4b80ba --- /dev/null +++ b/src/optimizer-page/scan-results/index.js @@ -0,0 +1,3 @@ +import ScanResults from './ScanResults'; + +export default ScanResults; diff --git a/src/optimizer-page/scan-results/messages.js b/src/optimizer-page/scan-results/messages.js new file mode 100644 index 0000000000..9e29a83cf2 --- /dev/null +++ b/src/optimizer-page/scan-results/messages.js @@ -0,0 +1,42 @@ +import { defineMessages } from '@edx/frontend-platform/i18n'; + +const messages = defineMessages({ + pageTitle: { + id: 'course-authoring.course-optimizer.page.title', + defaultMessage: '{headingTitle} | {courseName} | {siteName}', + }, + noDataCard: { + id: 'course-authoring.course-optimizer.noDataCard', + defaultMessage: 'No Scan data available', + }, + noBrokenLinksCard: { + id: 'course-authoring.course-optimizer.emptyResultsCard', + defaultMessage: 'No broken links found', + }, + scanHeader: { + id: 'course-authoring.course-optimizer.scanHeader', + defaultMessage: 'Broken Links Scan', + }, + lockedCheckboxLabel: { + id: 'course-authoring.course-optimizer.lockedCheckboxLabel', + defaultMessage: 'Show Locked Course Files', + }, + brokenLinksNumber: { + id: 'course-authoring.course-optimizer.brokenLinksNumber', + defaultMessage: '{count} broken links', + }, + lockedInfoTooltip: { + id: 'course-authoring.course-optimizer.lockedInfoTooltip', + defaultMessage: 'These course files are "locked", so we cannot test whether they work or not.', + }, + brokenLinkStatus: { + id: 'course-authoring.course-optimizer.brokenLinkStatus', + defaultMessage: 'Status: Broken', + }, + lockedLinkStatus: { + id: 'course-authoring.course-optimizer.lockedLinkStatus', + defaultMessage: 'Status: Locked', + }, +}); + +export default messages; diff --git a/src/optimizer-page/types.ts b/src/optimizer-page/types.ts new file mode 100644 index 0000000000..e5034e889c --- /dev/null +++ b/src/optimizer-page/types.ts @@ -0,0 +1,26 @@ +export interface Unit { + id: string; + displayName: string; + blocks: { + id: string; + url: string; + brokenLinks: string[]; + lockedLinks: string[]; + }[]; +} + +export interface SubSection { + id: string; + displayName: string; + units: Unit[]; +} + +export interface Section { + id: string; + displayName: string; + subsections: SubSection[]; +} + +export interface LinkCheckResult { + sections: Section[]; +} diff --git a/src/optimizer-page/utils.test.js b/src/optimizer-page/utils.test.js new file mode 100644 index 0000000000..07983888b9 --- /dev/null +++ b/src/optimizer-page/utils.test.js @@ -0,0 +1,44 @@ +import mockApiResponse from './mocks/mockApiResponse'; +import { countBrokenLinks } from './utils'; + +describe('countBrokenLinks', () => { + it('should return the count of broken links', () => { + const data = mockApiResponse.LinkCheckOutput; + expect(countBrokenLinks(data)).toStrictEqual([5, 2]); + }); + + it('should return 0 if there are no broken links', () => { + const data = { + sections: [ + { + subsections: [ + { + units: [ + { + blocks: [ + { + brokenLinks: [], + }, + ], + }, + ], + }, + ], + }, + ], + }; + 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 new file mode 100644 index 0000000000..dd76763761 --- /dev/null +++ b/src/optimizer-page/utils.ts @@ -0,0 +1,21 @@ +/* eslint-disable import/prefer-default-export */ +import { LinkCheckResult } from './types'; + +export 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; +}; 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,