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

Open
wants to merge 43 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 32 commits
Commits
Show all changes
43 commits
Select commit Hold shift + click to select a range
24589a6
feat: add course optimizer page
jesperhodge Nov 20, 2024
d1f4145
feat: make course optimizer nav menu dependent on waffle flag
jesperhodge Nov 21, 2024
29e871e
feat: add link check button
jesperhodge Nov 22, 2024
e5e9862
feat: add link check polling
jesperhodge Nov 25, 2024
0bffcc8
feat: only poll when a link check is in progress
jesperhodge Nov 26, 2024
8de9d8a
feat: add course stepper
jesperhodge Nov 26, 2024
aee4014
feat: add results display
jesperhodge Nov 27, 2024
f86ee0c
feat: add results collapsible
jesperhodge Nov 27, 2024
3ec3c33
feat: make a cool design
jesperhodge Nov 28, 2024
329f228
feat: design improvements
jesperhodge Dec 2, 2024
9942bf8
feat: design improvements
jesperhodge Dec 3, 2024
869676d
feat: load actual data
jesperhodge Dec 6, 2024
919cb07
feat: add checkbox and info icons
jesperhodge Dec 9, 2024
0d62bd2
feat: hide locked links when unchecked
jesperhodge Dec 9, 2024
8e38065
feat: add translations
jesperhodge Dec 13, 2024
08850e4
refactor: extract components
jesperhodge Dec 13, 2024
31bfd10
refactor: decouple components
jesperhodge Dec 13, 2024
ddce942
fix: scan stage display
jesperhodge Dec 13, 2024
daba24e
refactor: utilize typescript
jesperhodge Dec 14, 2024
0b8f9c0
fix: lint
jesperhodge Dec 16, 2024
28ec193
fix: types
jesperhodge Dec 16, 2024
1431a1a
fix: lint
jesperhodge Dec 16, 2024
d431762
test: api
jesperhodge Dec 16, 2024
67fc392
test: thunks
jesperhodge Dec 17, 2024
780d05b
test: thunks
jesperhodge Dec 18, 2024
ad83a22
test: add count broken links function test
jesperhodge Dec 18, 2024
a98dc51
test: count broken links
jesperhodge Dec 18, 2024
7763348
fix: permanent in progress state
jesperhodge Dec 19, 2024
baf17b3
test: poll link check
jesperhodge Dec 19, 2024
2a1aefc
test: optimizer page
jesperhodge Dec 20, 2024
e3cd4ad
fix: types
jesperhodge Dec 20, 2024
b6c8a05
feat: add last scan date and update optimizer description
jesperhodge Dec 20, 2024
13cd97e
fix: lint
jesperhodge Jan 6, 2025
2d1314e
fix: tests
jesperhodge Jan 6, 2025
4bc2f5d
test: increase coverage
jesperhodge Jan 6, 2025
2436706
fix: tests
jesperhodge Jan 6, 2025
80a5a5a
fix: test coverage
jesperhodge Jan 6, 2025
db6d443
fix: PR discussions
jesperhodge Jan 9, 2025
de19157
test: locked links
jesperhodge Jan 10, 2025
5aec1d5
fix: tests
jesperhodge Jan 10, 2025
3be9b89
fix: test
jesperhodge Jan 10, 2025
9d7e9a3
test: ai generate some reducer tests
jesperhodge Jan 10, 2025
1b8854c
test: menu items
jesperhodge Jan 10, 2025
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/generic/course-stepper/index.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,10 @@
const isLastStepDone = isLastStep && isActiveStep;
const completedStep = index < activeKey && !hasError;

console.log('current step', index);

Check warning on line 28 in src/generic/course-stepper/index.jsx

View workflow job for this annotation

GitHub Actions / tests

Unexpected console statement
rayzhou-bit marked this conversation as resolved.
Show resolved Hide resolved
console.log('activeKey', activeKey);

Check warning on line 29 in src/generic/course-stepper/index.jsx

View workflow job for this annotation

GitHub Actions / tests

Unexpected console statement
console.log('lastStepIndex', lastStepIndex);

Check warning on line 30 in src/generic/course-stepper/index.jsx

View workflow job for this annotation

GitHub Actions / tests

Unexpected console statement

const getStepIcon = () => {
if (completedStep) {
return CheckCircle;
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
147 changes: 147 additions & 0 deletions src/optimizer-page/CourseOptimizerPage.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
/* eslint-disable @typescript-eslint/no-shadow */
/* eslint-disable react/jsx-filename-extension */
import {
fireEvent, queryByText, render, waitFor,

Check failure on line 4 in src/optimizer-page/CourseOptimizerPage.test.js

View workflow job for this annotation

GitHub Actions / tests

'queryByText' is defined but never used
} 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';

Check failure on line 12 in src/optimizer-page/CourseOptimizerPage.test.js

View workflow job for this annotation

GitHub Actions / tests

'get' is defined but never used
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 = () => (
<AppProvider store={store}>
<IntlProvider locale="en" messages={{}}>
<CourseOptimizerPage courseId={courseId} />
</IntlProvider>
</AppProvider>
);

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(<OptimizerPage />);
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(<OptimizerPage />);
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(<OptimizerPage />);
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(<OptimizerPage />);
expect(getByText(messages.headingTitle.defaultMessage)).toBeInTheDocument();
fireEvent.click(getByText(messages.buttonTitle.defaultMessage));
await waitFor(() => {
expect(getByText(scanResultsMessages.noBrokenLinksCard.defaultMessage)).toBeInTheDocument();
});
});
});
});
174 changes: 174 additions & 0 deletions src/optimizer-page/CourseOptimizerPage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
/* eslint-disable no-param-reassign */
import {
useEffect, useRef, FC, MutableRefObject,
} from 'react';
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,
getLastScannedAt,
} from './data/selectors';
import { startLinkCheck, fetchLinkCheckStatus } from './data/thunks';
import { useModel } from '../generic/model-store';
import ScanResults from './scan-results';

const pollLinkCheckStatus = (dispatch: any, courseId: string, delay: number): number => {
const interval = setInterval(() => {
dispatch(fetchLinkCheckStatus(courseId));
}, delay);
return interval as unknown as number;
};

export function pollLinkCheckDuringScan(
linkCheckInProgress: boolean | null,
interval: MutableRefObject<number | undefined>,
dispatch: any,
courseId: string,
) {
if (linkCheckInProgress === null || linkCheckInProgress) {
clearInterval(interval.current as number | undefined);
interval.current = pollLinkCheckStatus(dispatch, courseId, 2000);
} else if (interval.current) {
clearInterval(interval.current);
interval.current = undefined;
}
}

const CourseOptimizerPage: FC<{ courseId: string }> = ({ courseId }) => {
const dispatch = useDispatch();
const linkCheckInProgress = useSelector(getLinkCheckInProgress);
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;
const interval = useRef<number | undefined>(undefined);
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, interval, dispatch, courseId);

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)} {lastScannedAt && `(${intl.formatMessage(messages.lastScannedOn)} ${intl.formatDate(lastScannedAt, { year: 'numeric', month: 'long', day: 'numeric' })})`}
</Button>
</Card.Section>
)}
{linkCheckPresent && (
<Card.Section className="px-3 py-1">
<CourseStepper
// @ts-ignore
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;
Loading
Loading