-
Notifications
You must be signed in to change notification settings - Fork 80
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
jesperhodge
wants to merge
27
commits into
master
Choose a base branch
from
feat--course-optimizer-page
base: master
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
+1,315
−0
Draft
Changes from all commits
Commits
Show all changes
27 commits
Select commit
Hold shift + click to select a range
24589a6
feat: add course optimizer page
jesperhodge d1f4145
feat: make course optimizer nav menu dependent on waffle flag
jesperhodge 29e871e
feat: add link check button
jesperhodge e5e9862
feat: add link check polling
jesperhodge 0bffcc8
feat: only poll when a link check is in progress
jesperhodge 8de9d8a
feat: add course stepper
jesperhodge aee4014
feat: add results display
jesperhodge f86ee0c
feat: add results collapsible
jesperhodge 3ec3c33
feat: make a cool design
jesperhodge 329f228
feat: design improvements
jesperhodge 9942bf8
feat: design improvements
jesperhodge 869676d
feat: load actual data
jesperhodge 919cb07
feat: add checkbox and info icons
jesperhodge 0d62bd2
feat: hide locked links when unchecked
jesperhodge 8e38065
feat: add translations
jesperhodge 08850e4
refactor: extract components
jesperhodge 31bfd10
refactor: decouple components
jesperhodge ddce942
fix: scan stage display
jesperhodge daba24e
refactor: utilize typescript
jesperhodge 0b8f9c0
fix: lint
jesperhodge 28ec193
fix: types
jesperhodge 1431a1a
fix: lint
jesperhodge d431762
test: api
jesperhodge 67fc392
test: thunks
jesperhodge 780d05b
test: thunks
jesperhodge ad83a22
test: add count broken links function test
jesperhodge a98dc51
test: count broken links
jesperhodge File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,164 @@ | ||
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> | ||
</> | ||
); | ||
}; | ||
CourseOptimizerPage.propTypes = { | ||
courseId: PropTypes.string.isRequired, | ||
}; | ||
|
||
export default CourseOptimizerPage; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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); | ||
}); | ||
}); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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'; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I strongly prefer TypeScript types over
propTypes
, because TypeScript types get checked by CI whereas propTypes warnings are routinely ignored. Open up the JS console on any authoring MFE page in your browser, and you'll see the problem.