Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feat course optimizer page #1533

Draft
wants to merge 19 commits into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions src/CourseAuthoringRoutes.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -118,6 +119,10 @@ const CourseAuthoringRoutes = () => {
path="export"
element={<PageWrap><CourseExportPage courseId={courseId} /></PageWrap>}
/>
<Route
path="optimizer"
element={<PageWrap><CourseOptimizerPage courseId={courseId} /></PageWrap>}
/>
<Route
path="checklists"
element={<PageWrap><CourseChecklist courseId={courseId} /></PageWrap>}
Expand Down
4 changes: 4 additions & 0 deletions src/header/hooks.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};
5 changes: 5 additions & 0 deletions src/header/messages.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
1 change: 1 addition & 0 deletions src/index.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
161 changes: 161 additions & 0 deletions src/optimizer-page/CourseOptimizerPage.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
import { useEffect, useRef } from 'react';
import PropTypes from 'prop-types';
import { useDispatch, useSelector } from 'react-redux';
import { useIntl } from '@edx/frontend-platform/i18n';
import {
Container, Layout, Button, Card,
} from '@openedx/paragon';
import { Search as SearchIcon } from '@openedx/paragon/icons';
import { Helmet } from 'react-helmet';

import CourseStepper from '../generic/course-stepper';
import ConnectionErrorAlert from '../generic/ConnectionErrorAlert';
import SubHeader from '../generic/sub-header/SubHeader';
import { RequestStatus } from '../data/constants';
import messages from './messages';
import {
getCurrentStage, getError, getLinkCheckInProgress, getLoadingStatus, getLinkCheckResult,
} from './data/selectors';
import { startLinkCheck, fetchLinkCheckStatus } from './data/thunks';
import { useModel } from '../generic/model-store';
import { ScanResults } from './scan-results';

const pollLinkCheckStatus = (dispatch, courseId, delay) => {
const interval = setInterval(() => {
dispatch(fetchLinkCheckStatus(courseId));
}, delay);
return interval;
};

const CourseOptimizerPage = ({ courseId }) => {
const dispatch = useDispatch();
const linkCheckInProgress = useSelector(getLinkCheckInProgress);
const loadingStatus = useSelector(getLoadingStatus);
const currentStage = useSelector(getCurrentStage);
const linkCheckResult = useSelector(getLinkCheckResult);
const { msg: errorMessage } = useSelector(getError);
const isShowExportButton = !linkCheckInProgress || errorMessage;
const isLoadingDenied = loadingStatus === RequestStatus.DENIED;
const interval = useRef(null);
const courseDetails = useModel('courseDetails', courseId);
const linkCheckPresent = !!currentStage;
const intl = useIntl();

const courseStepperSteps = [
{
title: intl.formatMessage(messages.preparingStepTitle),
description: intl.formatMessage(messages.preparingStepDescription),
key: 'course-step-preparing',
},
{
title: intl.formatMessage(messages.scanningStepTitle),
description: intl.formatMessage(messages.scanningStepDescription),
key: 'course-step-scanning',
},
{
title: intl.formatMessage(messages.successStepTitle),
description: intl.formatMessage(messages.successStepDescription),
key: 'course-step-success',
},
];

const pollLinkCheckStatusDuringScan = () => {
if (linkCheckInProgress === null || linkCheckInProgress || !linkCheckResult) {
clearInterval(interval.current);
interval.current = pollLinkCheckStatus(dispatch, courseId, 2000);
} else if (interval.current) {
clearInterval(interval.current);
interval.current = null;
}
};

useEffect(() => {
dispatch(fetchLinkCheckStatus(courseId));
}, []);

useEffect(() => {
pollLinkCheckStatusDuringScan();

return () => {
if (interval.current) { clearInterval(interval.current); }
};
}, [linkCheckInProgress, linkCheckResult]);

if (isLoadingDenied) {
if (interval.current) { clearInterval(interval.current); }

return (
<Container size="xl" className="course-unit px-4 mt-4">
<ConnectionErrorAlert />
</Container>
);
}

return (
<>
<Helmet>
<title>
{intl.formatMessage(messages.pageTitle, {
headingTitle: intl.formatMessage(messages.headingTitle),
courseName: courseDetails?.name,
siteName: process.env.SITE_NAME,
})}
</title>
</Helmet>
<Container size="xl" className="mt-4 px-4 export">
<section className="setting-items mb-4">
<Layout
lg={[{ span: 9 }, { span: 3 }]}
md={[{ span: 9 }, { span: 3 }]}
sm={[{ span: 9 }, { span: 3 }]}
xs={[{ span: 9 }, { span: 3 }]}
xl={[{ span: 9 }, { span: 3 }]}
>
<Layout.Element>
<article>
<SubHeader
title={intl.formatMessage(messages.headingTitle)}
subtitle={intl.formatMessage(messages.headingSubtitle)}
/>
<p className="small">{intl.formatMessage(messages.description1)}</p>
<p className="small">{intl.formatMessage(messages.description2)}</p>
<Card>
<Card.Header
className="h3 px-3 text-black mb-4"
title={intl.formatMessage(messages.card1Title)}
/>
{isShowExportButton && (
<Card.Section className="px-3 py-1">
<Button
size="lg"
block
className="mb-4"
onClick={() => dispatch(startLinkCheck(courseId))}
iconBefore={SearchIcon}
>
{intl.formatMessage(messages.buttonTitle)}
</Button>
</Card.Section>
)}
{linkCheckPresent && (
<Card.Section className="px-3 py-1">
<CourseStepper
steps={courseStepperSteps}
activeKey={currentStage}
hasError={currentStage < 0 || !!errorMessage}
errorMessage={errorMessage}
/>
</Card.Section>
)}
</Card>
{linkCheckPresent && <ScanResults data={linkCheckResult} />}
</article>
</Layout.Element>
</Layout>
</section>
</Container>
</>
);
};

export default CourseOptimizerPage;
51 changes: 51 additions & 0 deletions src/optimizer-page/SectionCollapsible.tsx
Original file line number Diff line number Diff line change
@@ -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<Props> = ({
title, children, redItalics, className,
}) => {
const [isOpen, setIsOpen] = useState(false);
const styling = 'card-lg';
const collapsibleTitle = (
<div className={className}>
<Icon src={isOpen ? ArrowDropDown : ArrowRight} className="open-arrow" />
<strong>{title}</strong>
<span className="red-italics">{redItalics}</span>
</div>
);

return (
<div className={`section ${isOpen ? 'is-open' : ''}`}>
<Collapsible
styling={styling}
title={(
<p>
<strong>{collapsibleTitle}</strong>
</p>
)}
iconWhenClosed=""
iconWhenOpen=""
open={isOpen}
onToggle={() => setIsOpen(!isOpen)}
>
<Collapsible.Body>{children}</Collapsible.Body>
</Collapsible>
</div>
);
};

export default SectionCollapsible;
47 changes: 47 additions & 0 deletions src/optimizer-page/data/api.test.js
Original file line number Diff line number Diff line change
@@ -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();
});
Comment on lines +11 to +25
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Instead of all this boilerplate, you can just use

const { axiosMock } = initializeMocks();

as the first line of each test case. You can then remove the beforeEach and afterEach you have here, because initializeMocks takes care of that all for you, including jest.clearAllMocks();.

See

/**
* Initialize common mocks that many of our React components will require.
*
* This should be called within each test case, or in `beforeEach()`.
*
* Returns the new `axiosMock` in case you need to mock out axios requests.
*/
export function initializeMocks({ user = defaultUser, initialState = undefined }: {
user?: { userId: number, username: string },
initialState?: Record<string, any>, // TODO: proper typing for our redux state
} = {}) {


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);
});
});
25 changes: 25 additions & 0 deletions src/optimizer-page/data/api.ts
Original file line number Diff line number Diff line change
@@ -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<LinkCheckStatusApiResponseBody> {
const { data } = await getAuthenticatedHttpClient()
.get(getLinkCheckStatusApiUrl(courseId));
return camelCaseObject(data);
}
40 changes: 40 additions & 0 deletions src/optimizer-page/data/constants.ts
Original file line number Diff line number Diff line change
@@ -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';
11 changes: 11 additions & 0 deletions src/optimizer-page/data/selectors.ts
Original file line number Diff line number Diff line change
@@ -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;
Loading
Loading