-
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
19
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.
Draft
Changes from all commits
Commits
Show all changes
19 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 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,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; |
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,47 @@ | ||
import MockAdapter from 'axios-mock-adapter'; | ||
import { initializeMockApp, getConfig } from '@edx/frontend-platform'; | ||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; | ||
|
||
import { getExportStatus, postExportCourseApiUrl, startCourseExporting } from './api'; | ||
|
||
let axiosMock; | ||
const courseId = 'course-123'; | ||
|
||
describe('API Functions', () => { | ||
beforeEach(() => { | ||
initializeMockApp({ | ||
authenticatedUser: { | ||
userId: 3, | ||
username: 'abc123', | ||
administrator: true, | ||
roles: [], | ||
}, | ||
}); | ||
axiosMock = new MockAdapter(getAuthenticatedHttpClient()); | ||
}); | ||
|
||
afterEach(() => { | ||
jest.clearAllMocks(); | ||
}); | ||
|
||
it('should fetch status on start exporting', async () => { | ||
const data = { exportStatus: 1 }; | ||
axiosMock.onPost(postExportCourseApiUrl(courseId)).reply(200, data); | ||
|
||
const result = await startCourseExporting(courseId); | ||
|
||
expect(axiosMock.history.post[0].url).toEqual(postExportCourseApiUrl(courseId)); | ||
expect(result).toEqual(data); | ||
}); | ||
|
||
it('should fetch on get export status', async () => { | ||
const data = { exportStatus: 2 }; | ||
const queryUrl = new URL(`export_status/${courseId}`, getConfig().STUDIO_BASE_URL).href; | ||
axiosMock.onGet(queryUrl).reply(200, data); | ||
|
||
const result = await getExportStatus(courseId); | ||
|
||
expect(axiosMock.history.get[0].url).toEqual(queryUrl); | ||
expect(result).toEqual(data); | ||
}); | ||
}); |
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.
Instead of all this boilerplate, you can just use
as the first line of each test case. You can then remove the
beforeEach
andafterEach
you have here, becauseinitializeMocks
takes care of that all for you, includingjest.clearAllMocks();
.See
frontend-app-authoring/src/testUtils.tsx
Lines 152 to 162 in f86c609