Compare commits

..

2 Commits

Author SHA1 Message Date
Ben Warzeski
dfe91653d1 fix: add axios to dev dependencies 2023-09-14 16:08:26 +00:00
Ben Warzeski
afa1e7bcc9 feat: bump frontend-build to TS alpha 2023-09-12 14:01:20 +00:00
54 changed files with 2890 additions and 1519 deletions

1
.env
View File

@@ -46,4 +46,3 @@ TERMS_OF_SERVICE_URL=''
TWITTER_HASHTAG=''
TWITTER_URL=''
USER_INFO_COOKIE_NAME=''
OPTIMIZELY_FULL_STACK_SDK_KEY=''

View File

@@ -48,4 +48,3 @@ USER_INFO_COOKIE_NAME='edx-user-info'
SESSION_COOKIE_DOMAIN='localhost'
CHAT_RESPONSE_URL='http://localhost:18000/api/learning_assistant/v1/course_id'
PRIVACY_POLICY_URL='http://localhost:18000/privacy'
OPTIMIZELY_FULL_STACK_SDK_KEY=''

View File

@@ -7,4 +7,14 @@ on:
jobs:
commitlint:
uses: openedx/.github/.github/workflows/commitlint.yml@master
runs-on: ubuntu-20.04
steps:
- name: Check out repository
uses: actions/checkout@v3
with:
fetch-depth: 0
- name: remove tsconfig.json # see issue https://github.com/conventional-changelog/commitlint/issues/3256
run: |
rm -f tsconfig.json
- name: Check commits
uses: wagoid/commitlint-github-action@v5

View File

@@ -77,5 +77,5 @@ validate:
.PHONY: validate.ci
validate.ci:
npm ci
npm ci --legacy-peer-deps
make validate

3116
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -30,11 +30,11 @@
},
"dependencies": {
"@edx/brand": "npm:@edx/brand-openedx@1.2.0",
"@edx/frontend-component-footer": "12.2.1",
"@edx/frontend-component-header": "4.6.0",
"@edx/frontend-lib-learning-assistant": "^1.14.0",
"@edx/frontend-lib-special-exams": "2.23.2",
"@edx/frontend-platform": "5.5.2",
"@edx/frontend-component-footer": "12.1.2",
"@edx/frontend-component-header": "4.4.4",
"@edx/frontend-lib-learning-assistant": "^1.11.1",
"@edx/frontend-lib-special-exams": "2.20.1",
"@edx/frontend-platform": "4.6.0",
"@edx/paragon": "20.46.0",
"@edx/react-unit-test-utils": "npm:@edx/react-unit-test-utils@1.7.0",
"@fortawesome/fontawesome-svg-core": "1.3.0",
@@ -55,8 +55,8 @@
"react-dom": "17.0.2",
"react-helmet": "6.1.0",
"react-redux": "7.2.9",
"react-router": "6.15.0",
"react-router-dom": "6.15.0",
"react-router": "5.2.1",
"react-router-dom": "5.3.0",
"react-share": "4.4.1",
"redux": "4.1.2",
"regenerator-runtime": "0.13.11",
@@ -66,12 +66,13 @@
},
"devDependencies": {
"@edx/browserslist-config": "1.2.0",
"@edx/frontend-build": "^12.9.10",
"@edx/frontend-build": "12.9.0-alpha.6",
"@edx/reactifex": "2.2.0",
"@pact-foundation/pact": "^11.0.2",
"@testing-library/jest-dom": "5.16.5",
"@testing-library/react": "12.1.5",
"@testing-library/user-event": "13.5.0",
"axios": "^1.5.0",
"axios-mock-adapter": "1.20.0",
"copy-webpack-plugin": "^11.0.0",
"es-check": "6.2.1",

View File

@@ -1,33 +0,0 @@
export const DECODE_ROUTES = {
ACCESS_DENIED: '/course/:courseId/access-denied',
HOME: '/course/:courseId/home',
LIVE: '/course/:courseId/live',
DATES: '/course/:courseId/dates',
DISCUSSION: '/course/:courseId/discussion/:path/*',
PROGRESS: [
'/course/:courseId/progress/:targetUserId/',
'/course/:courseId/progress',
],
COURSE_END: '/course/:courseId/course-end',
COURSEWARE: [
'/course/:courseId/:sequenceId/:unitId',
'/course/:courseId/:sequenceId',
'/course/:courseId',
],
REDIRECT_HOME: 'home/:courseId',
REDIRECT_SURVEY: 'survey/:courseId',
};
export const ROUTES = {
UNSUBSCRIBE: '/goal-unsubscribe/:token',
REDIRECT: '/redirect/*',
DASHBOARD: 'dashboard',
CONSENT: 'consent',
};
export const REDIRECT_MODES = {
DASHBOARD_REDIRECT: 'dashboard-redirect',
CONSENT_REDIRECT: 'consent-redirect',
HOME_REDIRECT: 'home-redirect',
SURVEY_REDIRECT: 'survey-redirect',
};

View File

@@ -1,5 +1,5 @@
import React from 'react';
import { Routes, Route } from 'react-router-dom';
import { Route } from 'react-router';
import MockAdapter from 'axios-mock-adapter';
import { Factory } from 'rosie';
import { getConfig, history } from '@edx/frontend-platform';
@@ -32,16 +32,11 @@ describe('DatesTab', () => {
component = (
<AppProvider store={store}>
<UserMessagesProvider>
<Routes>
<Route
path="/course/:courseId/dates"
element={(
<TabContainer tab="dates" fetch={fetchDatesTab} slice="courseHome">
<DatesTab />
</TabContainer>
)}
/>
</Routes>
<Route path="/course/:courseId/dates">
<TabContainer tab="dates" fetch={fetchDatesTab} slice="courseHome">
<DatesTab />
</TabContainer>
</Route>
</UserMessagesProvider>
</AppProvider>
);

View File

@@ -2,20 +2,21 @@ import { getConfig } from '@edx/frontend-platform';
import { injectIntl } from '@edx/frontend-platform/i18n';
import React, { useState } from 'react';
import { useSelector } from 'react-redux';
import { useParams, generatePath, useNavigate } from 'react-router-dom';
import { generatePath, useHistory } from 'react-router';
import { useParams } from 'react-router-dom';
import { useIFrameHeight, useIFramePluginEvents } from '../../generic/hooks';
const DiscussionTab = () => {
const { courseId } = useSelector(state => state.courseHome);
const { path } = useParams();
const [originalPath] = useState(path);
const navigate = useNavigate();
const history = useHistory();
const [, iFrameHeight] = useIFrameHeight();
useIFramePluginEvents({
'discussions.navigate': (payload) => {
const basePath = generatePath('/course/:courseId/discussion', { courseId });
navigate(`${basePath}/${payload.path}`);
history.push(`${basePath}/${payload.path}`);
},
});
const discussionsUrl = `${getConfig().DISCUSSIONS_MFE_BASE_URL}/${courseId}/${originalPath}`;

View File

@@ -4,7 +4,7 @@ import { AppProvider } from '@edx/frontend-platform/react';
import { render } from '@testing-library/react';
import MockAdapter from 'axios-mock-adapter';
import React from 'react';
import { Route, Routes } from 'react-router-dom';
import { Route } from 'react-router';
import { Factory } from 'rosie';
import { UserMessagesProvider } from '../../generic/user-messages';
import {
@@ -30,16 +30,11 @@ describe('DiscussionTab', () => {
component = (
<AppProvider store={store}>
<UserMessagesProvider>
<Routes>
<Route
path="/course/:courseId/discussion"
element={(
<TabContainer tab="discussion" fetch={fetchDiscussionTab} slice="courseHome">
<DiscussionTab />
</TabContainer>
)}
/>
</Routes>
<Route path="/course/:courseId/discussion">
<TabContainer tab="discussion" fetch={fetchDiscussionTab} slice="courseHome">
<DiscussionTab />
</TabContainer>
</Route>
</UserMessagesProvider>
</AppProvider>
);

View File

@@ -1,9 +1,7 @@
import React from 'react';
import {
MemoryRouter, Route, Routes,
} from 'react-router-dom';
import { Route } from 'react-router';
import MockAdapter from 'axios-mock-adapter';
import { getConfig } from '@edx/frontend-platform';
import { getConfig, history } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { AppProvider } from '@edx/frontend-platform/react';
import { render, screen } from '@testing-library/react';
@@ -26,16 +24,13 @@ describe('GoalUnsubscribe', () => {
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
store = initializeStore();
component = (
<AppProvider store={store} wrapWithRouter={false}>
<AppProvider store={store}>
<UserMessagesProvider>
<MemoryRouter initialEntries={['/goal-unsubscribe/TOKEN']}>
<Routes>
<Route path="/goal-unsubscribe/:token" element={<GoalUnsubscribe />} />
</Routes>
</MemoryRouter>
<Route path="/goal-unsubscribe/:token" component={GoalUnsubscribe} />
</UserMessagesProvider>
</AppProvider>
);
history.push('/goal-unsubscribe/TOKEN'); // so we can pull token from url
});
it('starts with a spinner', () => {

View File

@@ -1,8 +1,9 @@
import React, { useEffect, useState } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import { useLocation } from 'react-router-dom';
import { useSelector } from 'react-redux';
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
import { history } from '@edx/frontend-platform';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { Button } from '@edx/paragon';
import { AlertList } from '../../generic/user-messages';
@@ -66,7 +67,6 @@ const OutlineTab = ({ intl }) => {
} = useModel('coursewareMeta', courseId);
const [expandAll, setExpandAll] = useState(false);
const navigate = useNavigate();
const eventProperties = {
org_key: org,
@@ -115,10 +115,8 @@ const OutlineTab = ({ intl }) => {
// Deleting the course_start query param as it only needs to be set once
// whenever passed in query params.
currentParams.delete('start_course');
navigate({
pathname: location.pathname,
search: `?${currentParams.toString()}`,
replace: true,
history.replace({
search: currentParams.toString(),
});
}
}, [location.search]);

View File

@@ -1,6 +1,7 @@
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import { history } from '@edx/frontend-platform';
import { createSelector } from '@reduxjs/toolkit';
import { defaultMemoize as memoize } from 'reselect';
@@ -16,46 +17,45 @@ import { TabPage } from '../tab-page';
import Course from './course';
import { handleNextSectionCelebration } from './course/celebration';
import withParamsAndNavigation from './utils';
// Look at where this is called in componentDidUpdate for more info about its usage
const checkResumeRedirect = memoize((courseStatus, courseId, sequenceId, firstSequenceId, navigate) => {
const checkResumeRedirect = memoize((courseStatus, courseId, sequenceId, firstSequenceId) => {
if (courseStatus === 'loaded' && !sequenceId) {
// Note that getResumeBlock is just an API call, not a redux thunk.
getResumeBlock(courseId).then((data) => {
// This is a replace because we don't want this change saved in the browser's history.
if (data.sectionId && data.unitId) {
navigate(`/course/${courseId}/${data.sectionId}/${data.unitId}`, { replace: true });
history.replace(`/course/${courseId}/${data.sectionId}/${data.unitId}`);
} else if (firstSequenceId) {
navigate(`/course/${courseId}/${firstSequenceId}`, { replace: true });
history.replace(`/course/${courseId}/${firstSequenceId}`);
}
});
}
});
// Look at where this is called in componentDidUpdate for more info about its usage
const checkSectionUnitToUnitRedirect = memoize((courseStatus, courseId, sequenceStatus, section, unitId, navigate) => {
const checkSectionUnitToUnitRedirect = memoize((courseStatus, courseId, sequenceStatus, section, unitId) => {
if (courseStatus === 'loaded' && sequenceStatus === 'failed' && section && unitId) {
navigate(`/course/${courseId}/${unitId}`, { replace: true });
history.replace(`/course/${courseId}/${unitId}`);
}
});
// Look at where this is called in componentDidUpdate for more info about its usage
const checkSectionToSequenceRedirect = memoize((courseStatus, courseId, sequenceStatus, section, unitId, navigate) => {
const checkSectionToSequenceRedirect = memoize((courseStatus, courseId, sequenceStatus, section, unitId) => {
if (courseStatus === 'loaded' && sequenceStatus === 'failed' && section && !unitId) {
// If the section is non-empty, redirect to its first sequence.
if (section.sequenceIds && section.sequenceIds[0]) {
navigate(`/course/${courseId}/${section.sequenceIds[0]}`, { replace: true });
history.replace(`/course/${courseId}/${section.sequenceIds[0]}`);
// Otherwise, just go to the course root, letting the resume redirect take care of things.
} else {
navigate(`/course/${courseId}`, { replace: true });
history.replace(`/course/${courseId}`);
}
}
});
// Look at where this is called in componentDidUpdate for more info about its usage
const checkUnitToSequenceUnitRedirect = memoize(
(courseStatus, courseId, sequenceStatus, sequenceMightBeUnit, sequenceId, section, routeUnitId, navigate) => {
(courseStatus, courseId, sequenceStatus, sequenceMightBeUnit, sequenceId, section, routeUnitId) => {
if (courseStatus === 'loaded' && sequenceStatus === 'failed' && !section && !routeUnitId) {
if (sequenceMightBeUnit) {
// If the sequence failed to load as a sequence, but it is marked as a possible unit, then
@@ -64,62 +64,60 @@ const checkUnitToSequenceUnitRedirect = memoize(
getSequenceForUnitDeprecated(courseId, unitId).then(
parentId => {
if (parentId) {
navigate(`/course/${courseId}/${parentId}/${unitId}`, { replace: true });
history.replace(`/course/${courseId}/${parentId}/${unitId}`);
} else {
navigate(`/course/${courseId}`, { replace: true });
history.replace(`/course/${courseId}`);
}
},
() => { // error case
navigate(`/course/${courseId}`, { replace: true });
history.replace(`/course/${courseId}`);
},
);
} else {
// Invalid sequence that isn't a unit either. Redirect up to main course.
navigate(`/course/${courseId}`, { replace: true });
history.replace(`/course/${courseId}`);
}
}
},
);
// Look at where this is called in componentDidUpdate for more info about its usage
const checkSequenceToSequenceUnitRedirect = memoize((courseId, sequenceStatus, sequence, unitId, navigate) => {
const checkSequenceToSequenceUnitRedirect = memoize((courseId, sequenceStatus, sequence, unitId) => {
if (sequenceStatus === 'loaded' && sequence.id && !unitId) {
if (sequence.unitIds !== undefined && sequence.unitIds.length > 0) {
const nextUnitId = sequence.unitIds[sequence.activeUnitIndex];
// This is a replace because we don't want this change saved in the browser's history.
navigate(`/course/${courseId}/${sequence.id}/${nextUnitId}`, { replace: true });
history.replace(`/course/${courseId}/${sequence.id}/${nextUnitId}`);
}
}
});
// Look at where this is called in componentDidUpdate for more info about its usage
const checkSequenceUnitMarkerToSequenceUnitRedirect = memoize(
(courseId, sequenceStatus, sequence, unitId, navigate) => {
if (sequenceStatus !== 'loaded' || !sequence.id) {
return;
}
const checkSequenceUnitMarkerToSequenceUnitRedirect = memoize((courseId, sequenceStatus, sequence, unitId) => {
if (sequenceStatus !== 'loaded' || !sequence.id) {
return;
}
const hasUnits = sequence.unitIds?.length > 0;
const hasUnits = sequence.unitIds?.length > 0;
if (unitId === 'first') {
if (hasUnits) {
const firstUnitId = sequence.unitIds[0];
navigate(`/course/${courseId}/${sequence.id}/${firstUnitId}`, { replace: true });
} else {
if (unitId === 'first') {
if (hasUnits) {
const firstUnitId = sequence.unitIds[0];
history.replace(`/course/${courseId}/${sequence.id}/${firstUnitId}`);
} else {
// No units... go to general sequence page
navigate(`/course/${courseId}/${sequence.id}`, { replace: true });
}
} else if (unitId === 'last') {
if (hasUnits) {
const lastUnitId = sequence.unitIds[sequence.unitIds.length - 1];
navigate(`/course/${courseId}/${sequence.id}/${lastUnitId}`, { replace: true });
} else {
// No units... go to general sequence page
navigate(`/course/${courseId}/${sequence.id}`, { replace: true });
}
history.replace(`/course/${courseId}/${sequence.id}`);
}
},
);
} else if (unitId === 'last') {
if (hasUnits) {
const lastUnitId = sequence.unitIds[sequence.unitIds.length - 1];
history.replace(`/course/${courseId}/${sequence.id}/${lastUnitId}`);
} else {
// No units... go to general sequence page
history.replace(`/course/${courseId}/${sequence.id}`);
}
}
});
class CoursewareContainer extends Component {
checkSaveSequencePosition = memoize((unitId) => {
@@ -147,8 +145,12 @@ class CoursewareContainer extends Component {
componentDidMount() {
const {
routeCourseId,
routeSequenceId,
match: {
params: {
courseId: routeCourseId,
sequenceId: routeSequenceId,
},
},
} = this.props;
// Load data whenever the course or sequence ID changes.
this.checkFetchCourse(routeCourseId);
@@ -165,10 +167,13 @@ class CoursewareContainer extends Component {
sequence,
firstSequenceId,
sectionViaSequenceId,
routeCourseId,
routeSequenceId,
routeUnitId,
navigate,
match: {
params: {
courseId: routeCourseId,
sequenceId: routeSequenceId,
unitId: routeUnitId,
},
},
} = this.props;
// Load data whenever the course or sequence ID changes.
@@ -197,7 +202,7 @@ class CoursewareContainer extends Component {
// Check resume redirect:
// /course/:courseId -> /course/:courseId/:sequenceId/:unitId
// based on sequence/unit where user was last active.
checkResumeRedirect(courseStatus, courseId, sequenceId, firstSequenceId, navigate);
checkResumeRedirect(courseStatus, courseId, sequenceId, firstSequenceId);
// Check section-unit to unit redirect:
// /course/:courseId/:sectionId/:unitId -> /course/:courseId/:unitId
@@ -210,40 +215,42 @@ class CoursewareContainer extends Component {
// otherwise, we could get stuck in a redirect loop, since a sequence that failed to load
// would endlessly redirect to itself through `checkSectionUnitToUnitRedirect`
// and `checkUnitToSequenceUnitRedirect`.
checkSectionUnitToUnitRedirect(courseStatus, courseId, sequenceStatus, sectionViaSequenceId, routeUnitId, navigate);
checkSectionUnitToUnitRedirect(courseStatus, courseId, sequenceStatus, sectionViaSequenceId, routeUnitId);
// Check section to sequence redirect:
// /course/:courseId/:sectionId -> /course/:courseId/:sequenceId
// by redirecting to the first sequence within the section.
checkSectionToSequenceRedirect(courseStatus, courseId, sequenceStatus, sectionViaSequenceId, routeUnitId, navigate);
checkSectionToSequenceRedirect(courseStatus, courseId, sequenceStatus, sectionViaSequenceId, routeUnitId);
// Check unit to sequence-unit redirect:
// /course/:courseId/:unitId -> /course/:courseId/:sequenceId/:unitId
// by filling in the ID of the parent sequence of :unitId.
checkUnitToSequenceUnitRedirect((
courseStatus, courseId, sequenceStatus, sequenceMightBeUnit,
sequenceId, sectionViaSequenceId, routeUnitId, navigate
courseStatus, courseId, sequenceStatus, sequenceMightBeUnit, sequenceId, sectionViaSequenceId, routeUnitId
));
// Check sequence to sequence-unit redirect:
// /course/:courseId/:sequenceId -> /course/:courseId/:sequenceId/:unitId
// by filling in the ID the most-recently-active unit in the sequence, OR
// the ID of the first unit the sequence if none is active.
checkSequenceToSequenceUnitRedirect(courseId, sequenceStatus, sequence, routeUnitId, navigate);
checkSequenceToSequenceUnitRedirect(courseId, sequenceStatus, sequence, routeUnitId);
// Check sequence-unit marker to sequence-unit redirect:
// /course/:courseId/:sequenceId/first -> /course/:courseId/:sequenceId/:unitId
// /course/:courseId/:sequenceId/last -> /course/:courseId/:sequenceId/:unitId
// by filling in the ID the first or last unit in the sequence.
// "Sequence unit marker" is an invented term used only in this component.
checkSequenceUnitMarkerToSequenceUnitRedirect(courseId, sequenceStatus, sequence, routeUnitId, navigate);
checkSequenceUnitMarkerToSequenceUnitRedirect(courseId, sequenceStatus, sequence, routeUnitId);
}
handleUnitNavigationClick = () => {
const {
courseId,
sequenceId,
routeUnitId,
courseId, sequenceId,
match: {
params: {
unitId: routeUnitId,
},
},
} = this.props;
this.props.checkBlockCompletion(courseId, sequenceId, routeUnitId);
@@ -272,7 +279,11 @@ class CoursewareContainer extends Component {
courseStatus,
courseId,
sequenceId,
routeUnitId,
match: {
params: {
unitId: routeUnitId,
},
},
} = this.props;
return (
@@ -315,9 +326,13 @@ const courseShape = PropTypes.shape({
});
CoursewareContainer.propTypes = {
routeCourseId: PropTypes.string.isRequired,
routeSequenceId: PropTypes.string,
routeUnitId: PropTypes.string,
match: PropTypes.shape({
params: PropTypes.shape({
courseId: PropTypes.string.isRequired,
sequenceId: PropTypes.string,
unitId: PropTypes.string,
}).isRequired,
}).isRequired,
courseId: PropTypes.string,
sequenceId: PropTypes.string,
firstSequenceId: PropTypes.string,
@@ -333,14 +348,11 @@ CoursewareContainer.propTypes = {
checkBlockCompletion: PropTypes.func.isRequired,
fetchCourse: PropTypes.func.isRequired,
fetchSequence: PropTypes.func.isRequired,
navigate: PropTypes.func.isRequired,
};
CoursewareContainer.defaultProps = {
courseId: null,
sequenceId: null,
routeSequenceId: null,
routeUnitId: null,
firstSequenceId: null,
nextSequence: null,
previousSequence: null,
@@ -455,4 +467,4 @@ export default connect(mapStateToProps, {
saveSequencePosition,
fetchCourse,
fetchSequence,
})(withParamsAndNavigation(CoursewareContainer));
})(CoursewareContainer);

View File

@@ -5,16 +5,13 @@ import { waitForElementToBeRemoved, fireEvent } from '@testing-library/dom';
import '@testing-library/jest-dom/extend-expect';
import { render, screen } from '@testing-library/react';
import React from 'react';
import {
BrowserRouter, MemoryRouter, Route, Routes,
} from 'react-router-dom';
import { Route, Switch } from 'react-router';
import { Factory } from 'rosie';
import MockAdapter from 'axios-mock-adapter';
import { UserMessagesProvider } from '../generic/user-messages';
import tabMessages from '../tab-page/messages';
import { initializeMockApp, waitFor } from '../setupTest';
import { DECODE_ROUTES } from '../constants';
import CoursewareContainer from './CoursewareContainer';
import { buildSimpleCourseBlocks, buildBinaryCourseBlocks } from '../shared/data/__factories__/courseBlocks.factory';
@@ -83,16 +80,18 @@ describe('CoursewareContainer', () => {
store = initializeStore();
component = (
<AppProvider store={store} wrapWithRouter={false}>
<AppProvider store={store}>
<UserMessagesProvider>
<Routes>
{DECODE_ROUTES.COURSEWARE.map((route) => (
<Route
path={route}
element={<CoursewareContainer />}
/>
))}
</Routes>
<Switch>
<Route
path={[
'/course/:courseId/:sequenceId/:unitId',
'/course/:courseId/:sequenceId',
'/course/:courseId',
]}
component={CoursewareContainer}
/>
</Switch>
</UserMessagesProvider>
</AppProvider>
);
@@ -152,7 +151,7 @@ describe('CoursewareContainer', () => {
}
async function loadContainer() {
const { container } = render(<BrowserRouter>{component}</BrowserRouter>);
const { container } = render(component);
// Wait for the page spinner to be removed, such that we can wait for our main
// content to load before making any assertions.
await waitForElementToBeRemoved(screen.getByRole('status'));
@@ -161,7 +160,7 @@ describe('CoursewareContainer', () => {
it('should initialize to show a spinner', () => {
history.push('/course/abc123');
render(<MemoryRouter initialEntries={['/course/abc123']}>{component}</MemoryRouter>);
render(component);
const spinner = screen.getByRole('status');

View File

@@ -1,44 +1,56 @@
import React from 'react';
import { Routes, Route } from 'react-router-dom';
import { Switch, useRouteMatch } from 'react-router';
import { getConfig } from '@edx/frontend-platform';
import { FormattedMessage } from '@edx/frontend-platform/i18n';
import { PageWrap } from '@edx/frontend-platform/react';
import { PageRoute } from '@edx/frontend-platform/react';
import queryString from 'query-string';
import PageLoading from '../generic/PageLoading';
import DecodePageRoute from '../decode-page-route';
import { DECODE_ROUTES, REDIRECT_MODES, ROUTES } from '../constants';
import RedirectPage from './RedirectPage';
const CoursewareRedirectLandingPage = () => (
<div className="flex-grow-1">
<PageLoading srMessage={(
<FormattedMessage
id="learn.redirect.interstitial.message"
description="The screen-reader message when a page is about to redirect"
defaultMessage="Redirecting..."
/>
const CoursewareRedirectLandingPage = () => {
const { path } = useRouteMatch();
return (
<div className="flex-grow-1">
<PageLoading srMessage={(
<FormattedMessage
id="learn.redirect.interstitial.message"
description="The screen-reader message when a page is about to redirect"
defaultMessage="Redirecting..."
/>
)}
/>
/>
<Routes>
<Route
path={DECODE_ROUTES.REDIRECT_SURVEY}
element={<DecodePageRoute><RedirectPage pattern="/courses/:courseId/survey" mode={REDIRECT_MODES.SURVEY_REDIRECT} /></DecodePageRoute>}
/>
<Route
path={ROUTES.DASHBOARD}
element={<PageWrap><RedirectPage pattern="/dashboard" mode={REDIRECT_MODES.DASHBOARD_REDIRECT} /></PageWrap>}
/>
<Route
path={ROUTES.CONSENT}
element={<PageWrap><RedirectPage mode={REDIRECT_MODES.CONSENT_REDIRECT} /></PageWrap>}
/>
<Route
path={DECODE_ROUTES.REDIRECT_HOME}
element={<DecodePageRoute><RedirectPage pattern="/course/:courseId/home" mode={REDIRECT_MODES.HOME_REDIRECT} /></DecodePageRoute>}
/>
</Routes>
</div>
);
<Switch>
<DecodePageRoute
path={`${path}/survey/:courseId`}
render={({ match }) => {
global.location.assign(`${getConfig().LMS_BASE_URL}/courses/${match.params.courseId}/survey`);
}}
/>
<PageRoute
path={`${path}/dashboard`}
render={({ location }) => {
global.location.assign(`${getConfig().LMS_BASE_URL}/dashboard${location.search}`);
}}
/>
<PageRoute
path={`${path}/consent/`}
render={({ location }) => {
const { consentPath } = queryString.parse(location.search);
global.location.assign(`${getConfig().LMS_BASE_URL}${consentPath}`);
}}
/>
<DecodePageRoute
path={`${path}/home/:courseId`}
render={({ match }) => {
global.location.assign(`/course/${match.params.courseId}/home`);
}}
/>
</Switch>
</div>
);
};
export default CoursewareRedirectLandingPage;

View File

@@ -1,12 +1,19 @@
import React from 'react';
import { MemoryRouter as Router } from 'react-router-dom';
import { Router } from 'react-router';
import { createMemoryHistory } from 'history';
import { render, initializeMockApp } from '../setupTest';
import CoursewareRedirectLandingPage from './CoursewareRedirectLandingPage';
const redirectUrl = jest.fn();
jest.mock('@edx/frontend-platform/analytics');
jest.mock('../decode-page-route', () => jest.fn(({ children }) => <div>{children}</div>));
jest.mock('react-router', () => ({
...jest.requireActual('react-router'),
useRouteMatch: () => ({
path: '/redirect',
}),
}));
describe('CoursewareRedirectLandingPage', () => {
beforeEach(async () => {
@@ -16,8 +23,12 @@ describe('CoursewareRedirectLandingPage', () => {
});
it('Redirects to correct consent URL', () => {
const history = createMemoryHistory({
initialEntries: ['/redirect/consent/?consentPath=%2Fgrant_data_sharing_consent'],
});
render(
<Router initialEntries={['/consent/?consentPath=%2Fgrant_data_sharing_consent']}>
<Router history={history}>
<CoursewareRedirectLandingPage />
</Router>,
);
@@ -26,8 +37,12 @@ describe('CoursewareRedirectLandingPage', () => {
});
it('Redirects to correct consent URL', () => {
const history = createMemoryHistory({
initialEntries: ['/redirect/home/course-v1:edX+DemoX+Demo_Course'],
});
render(
<Router initialEntries={['/home/course-v1:edX+DemoX+Demo_Course']}>
<Router history={history}>
<CoursewareRedirectLandingPage />
</Router>,
);

View File

@@ -1,45 +0,0 @@
import PropTypes from 'prop-types';
import {
generatePath, useParams, useLocation,
} from 'react-router-dom';
import { getConfig } from '@edx/frontend-platform';
import queryString from 'query-string';
import { REDIRECT_MODES } from '../constants';
const RedirectPage = ({
pattern, mode,
}) => {
const { courseId } = useParams();
const location = useLocation();
const { consentPath } = queryString.parse(location?.search);
const BASE_URL = getConfig().LMS_BASE_URL;
switch (mode) {
case REDIRECT_MODES.DASHBOARD_REDIRECT:
global.location.assign(`${BASE_URL}${pattern}${location?.search}`);
break;
case REDIRECT_MODES.CONSENT_REDIRECT:
global.location.assign(`${BASE_URL}${consentPath}`);
break;
case REDIRECT_MODES.HOME_REDIRECT:
global.location.assign(generatePath(pattern, { courseId }));
break;
default:
global.location.assign(`${BASE_URL}${generatePath(pattern, { courseId })}`);
}
return null;
};
RedirectPage.propTypes = {
pattern: PropTypes.string,
mode: PropTypes.string.isRequired,
};
RedirectPage.defaultProps = {
pattern: null,
};
export default RedirectPage;

View File

@@ -1,69 +0,0 @@
import React from 'react';
import { render } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import { getConfig } from '@edx/frontend-platform';
import RedirectPage from './RedirectPage';
import { REDIRECT_MODES } from '../constants';
const BASE_URL = getConfig().LMS_BASE_URL;
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useParams: () => ({
courseId: 'course-id-123',
}),
useLocation: () => ({
search: '?consentPath=/some-path',
}),
}));
describe('RedirectPage component', () => {
beforeEach(() => {
Object.defineProperty(window, 'location', {
writable: true,
value: { assign: jest.fn() },
});
jest.clearAllMocks();
});
it('should handle DASHBOARD_REDIRECT correctly', () => {
render(
<MemoryRouter>
<RedirectPage mode={REDIRECT_MODES.DASHBOARD_REDIRECT} pattern="/dashboard" />
</MemoryRouter>,
);
expect(global.location.assign).toHaveBeenCalledWith(`${BASE_URL}/dashboard?consentPath=/some-path`);
});
it('should handle CONSENT_REDIRECT correctly', () => {
render(
<MemoryRouter>
<RedirectPage mode={REDIRECT_MODES.CONSENT_REDIRECT} />
</MemoryRouter>,
);
expect(global.location.assign).toHaveBeenCalledWith(`${BASE_URL}/some-path`);
});
it('should handle HOME_REDIRECT correctly', () => {
render(
<MemoryRouter>
<RedirectPage mode={REDIRECT_MODES.HOME_REDIRECT} pattern="/course/:courseId/home" />
</MemoryRouter>,
);
expect(global.location.assign).toHaveBeenCalledWith('/course/course-id-123/home');
});
it('should handle the default case correctly', () => {
render(
<MemoryRouter>
<RedirectPage pattern="/default/:courseId" />
</MemoryRouter>,
);
expect(global.location.assign).toHaveBeenCalledWith(`${BASE_URL}/default/course-id-123`);
});
});

View File

@@ -15,10 +15,6 @@ import { executeThunk } from '../../utils';
import * as thunks from '../data/thunks';
jest.mock('@edx/frontend-platform/analytics');
jest.mock('@edx/frontend-lib-special-exams/dist/data/thunks.js', () => ({
...jest.requireActual('@edx/frontend-lib-special-exams/dist/data/thunks.js'),
checkExamEntry: () => jest.fn(),
}));
const recordFirstSectionCelebration = jest.fn();
// eslint-disable-next-line no-import-assign
@@ -69,11 +65,11 @@ describe('Course', () => {
const [firstSequenceId] = Object.keys(state.models.sequences);
mockData.sequenceId = firstSequenceId;
await render(<Course {...mockData} />, { store: testStore, wrapWithRouter: true });
await render(<Course {...mockData} />, { store: testStore });
};
it('loads learning sequence', async () => {
render(<Course {...mockData} />, { wrapWithRouter: true });
render(<Course {...mockData} />);
expect(screen.getByRole('navigation', { name: 'breadcrumb' })).toBeInTheDocument();
expect(await screen.findByText('Loading learning sequence...')).toBeInTheDocument();
@@ -106,7 +102,7 @@ describe('Course', () => {
};
// Set up LocalStorage for testing.
handleNextSectionCelebration(sequenceId, sequenceId, testData.unitId);
render(<Course {...testData} />, { store: testStore, wrapWithRouter: true });
render(<Course {...testData} />, { store: testStore });
const firstSectionCelebrationModal = screen.getByRole('dialog');
expect(firstSectionCelebrationModal).toBeInTheDocument();
@@ -124,7 +120,7 @@ describe('Course', () => {
sequenceId,
unitId: Object.values(models.units)[0].id,
};
render(<Course {...testData} />, { store: testStore, wrapWithRouter: true });
render(<Course {...testData} />, { store: testStore });
const weeklyGoalCelebrationModal = screen.getByRole('dialog');
expect(weeklyGoalCelebrationModal).toBeInTheDocument();
@@ -132,7 +128,7 @@ describe('Course', () => {
});
it('displays notification trigger and toggles active class on click', async () => {
render(<Course {...mockData} />, { wrapWithRouter: true });
render(<Course {...mockData} />);
const notificationTrigger = screen.getByRole('button', { name: /Show notification tray/i });
expect(notificationTrigger).toBeInTheDocument();
@@ -185,7 +181,7 @@ describe('Course', () => {
it('handles click to open/close notification tray', async () => {
sessionStorage.clear();
render(<Course {...mockData} />, { wrapWithRouter: true });
render(<Course {...mockData} />);
expect(sessionStorage.getItem(`notificationTrayStatus.${mockData.courseId}`)).toBe('"open"');
const notificationShowButton = await screen.findByRole('button', { name: /Show notification tray/i });
expect(screen.queryByRole('region', { name: /notification tray/i })).toHaveClass('d-none');
@@ -196,7 +192,7 @@ describe('Course', () => {
it('handles reload persisting notification tray status', async () => {
sessionStorage.clear();
render(<Course {...mockData} />, { wrapWithRouter: true });
render(<Course {...mockData} />);
const notificationShowButton = await screen.findByRole('button', { name: /Show notification tray/i });
fireEvent.click(notificationShowButton);
expect(sessionStorage.getItem(`notificationTrayStatus.${mockData.courseId}`)).toBe('"closed"');
@@ -220,7 +216,7 @@ describe('Course', () => {
// set sessionStorage for a different course before rendering Course
sessionStorage.setItem(`notificationTrayStatus.${courseMetadataSecondCourse.id}`, '"open"');
render(<Course {...mockData} />, { wrapWithRouter: true });
render(<Course {...mockData} />);
expect(sessionStorage.getItem(`notificationTrayStatus.${mockData.courseId}`)).toBe('"open"');
const notificationShowButton = await screen.findByRole('button', { name: /Show notification tray/i });
fireEvent.click(notificationShowButton);
@@ -248,7 +244,7 @@ describe('Course', () => {
sequenceId,
unitId: Object.values(models.units)[1].id, // Corner cases are already covered in `Sequence` tests.
};
render(<Course {...testData} />, { store: testStore, wrapWithRouter: true });
render(<Course {...testData} />, { store: testStore });
loadUnit();
await waitFor(() => expect(screen.queryByText('Loading learning sequence...')).not.toBeInTheDocument());
@@ -280,7 +276,7 @@ describe('Course', () => {
previousSequenceHandler,
unitNavigationHandler,
};
render(<Course {...testData} />, { store: testStore, wrapWithRouter: true });
render(<Course {...testData} />, { store: testStore });
loadUnit();
await waitFor(() => expect(screen.queryByText('Loading learning sequence...')).not.toBeInTheDocument());
@@ -309,7 +305,7 @@ describe('Course', () => {
courseId: courseMetadata.id,
sequenceId: sequenceBlocks[0].id,
};
render(<Course {...testData} />, { store: testStore, wrapWithRouter: true });
render(<Course {...testData} />, { store: testStore });
await waitFor(() => expect(screen.getByText('Some random banner text to display.')).toBeInTheDocument());
});
@@ -343,7 +339,7 @@ describe('Course', () => {
courseId: testCourseMetadata.id,
sequenceId: sequenceBlocks[0].id,
};
render(<Course {...testData} />, { store: testStore, wrapWithRouter: true });
render(<Course {...testData} />, { store: testStore });
await waitFor(() => expect(screen.getByText('Your score is 100%. You have passed the entrance exam.')).toBeInTheDocument());
});
@@ -377,7 +373,7 @@ describe('Course', () => {
courseId: testCourseMetadata.id,
sequenceId: sequenceBlocks[0].id,
};
render(<Course {...testData} />, { store: testStore, wrapWithRouter: true });
render(<Course {...testData} />, { store: testStore });
await waitFor(() => expect(screen.getByText('To access course materials, you must score 70% or higher on this exam. Your current score is 30%.')).toBeInTheDocument());
});
});

View File

@@ -159,7 +159,6 @@ const CourseBreadcrumbs = ({
<Link
className="flex-shrink-0 text-primary"
to={`/course/${courseId}/home`}
replace
>
<FontAwesomeIcon icon={faHome} className="mr-2" />
<FormattedMessage

View File

@@ -26,12 +26,6 @@ jest.mock('react-redux', () => ({
Provider: ({ children }) => children,
useSelector: () => 'loaded',
}));
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
Link: jest.fn().mockImplementation(({ to, children }) => (
<a href={to}>{children}</a>
)),
}));
useModels.mockImplementation((name) => {
if (name === 'sections') {

View File

@@ -1,12 +1,12 @@
import React from 'react';
import PropTypes from 'prop-types';
import { history } from '@edx/frontend-platform';
import { Dropdown } from '@edx/paragon';
import {
sendTrackingLogEvent,
sendTrackEvent,
} from '@edx/frontend-platform/analytics';
import { useNavigate } from 'react-router-dom';
const JumpNavMenuItem = ({
title,
@@ -17,8 +17,6 @@ const JumpNavMenuItem = ({
isDefault,
onClick,
}) => {
const navigate = useNavigate();
function logEvent(targetUrl) {
const eventName = 'edx.ui.lms.jump_nav.selected';
const payload = {
@@ -40,7 +38,7 @@ const JumpNavMenuItem = ({
function handleClick(e) {
const url = destinationUrl();
logEvent(url);
navigate(url);
history.push(url);
if (onClick) { onClick(e); }
}

View File

@@ -1,6 +1,5 @@
import React from 'react';
import { screen, render } from '@testing-library/react';
import { BrowserRouter } from 'react-router-dom';
import JumpNavMenuItem from './JumpNavMenuItem';
import { fireEvent } from '../../setupTest';
@@ -27,11 +26,9 @@ const mockData = {
};
describe('JumpNavMenuItem', () => {
render(
<BrowserRouter>
<JumpNavMenuItem
{...mockData}
/>
</BrowserRouter>,
<JumpNavMenuItem
{...mockData}
/>,
);
it('renders menu Item as expected with button and Text and handles clicks', () => {
expect(screen.queryAllByRole('button')).toHaveLength(1);

View File

@@ -4,7 +4,7 @@ import { getConfig } from '@edx/frontend-platform';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { Button } from '@edx/paragon';
import { useSelector } from 'react-redux';
import { Navigate } from 'react-router-dom';
import { Redirect } from 'react-router-dom';
import CourseCelebration from './CourseCelebration';
import CourseInProgress from './CourseInProgress';
@@ -58,7 +58,7 @@ const CourseExit = ({ intl }) => {
} else if (mode === COURSE_EXIT_MODES.celebration) {
body = (<CourseCelebration />);
} else {
return (<Navigate to={`/course/${courseId}`} replace />);
return (<Redirect to={`/course/${courseId}`} />);
}
return (

View File

@@ -51,7 +51,7 @@ describe('Course Exit Pages', () => {
async function fetchAndRender(component) {
await executeThunk(fetchCourse(courseId), store.dispatch);
render(component, { store, wrapWithRouter: true });
render(component, { store });
}
beforeEach(() => {

View File

@@ -11,10 +11,6 @@ import Sequence from './Sequence';
import { fetchSequenceFailure } from '../../data/slice';
jest.mock('@edx/frontend-platform/analytics');
jest.mock('@edx/frontend-lib-special-exams/dist/data/thunks.js', () => ({
...jest.requireActual('@edx/frontend-lib-special-exams/dist/data/thunks.js'),
checkExamEntry: () => jest.fn(),
}));
describe('Sequence', () => {
let mockData;
@@ -46,10 +42,7 @@ describe('Sequence', () => {
it('renders correctly without data', async () => {
const testStore = await initializeTestStore({ excludeFetchCourse: true, excludeFetchSequence: true }, false);
render(
<Sequence {...mockData} {...{ unitId: undefined, sequenceId: undefined }} />,
{ store: testStore, wrapWithRouter: true },
);
render(<Sequence {...mockData} {...{ unitId: undefined, sequenceId: undefined }} />, { store: testStore });
expect(screen.getByText('There is no content here.')).toBeInTheDocument();
expect(screen.queryByRole('button')).not.toBeInTheDocument();
@@ -78,7 +71,7 @@ describe('Sequence', () => {
}, false);
const { container } = render(
<Sequence {...mockData} {...{ sequenceId: sequenceBlocks[0].id }} />,
{ store: testStore, wrapWithRouter: true },
{ store: testStore },
);
await waitFor(() => expect(screen.queryByText('Loading locked content messaging...')).toBeInTheDocument());
@@ -111,7 +104,7 @@ describe('Sequence', () => {
}, false);
render(
<Sequence {...mockData} {...{ sequenceId: sequenceBlocks[0].id }} />,
{ store: testStore, wrapWithRouter: true },
{ store: testStore },
);
await waitFor(() => {
@@ -128,13 +121,13 @@ describe('Sequence', () => {
it('displays error message on sequence load failure', async () => {
const testStore = await initializeTestStore({ excludeFetchCourse: true, excludeFetchSequence: true }, false);
testStore.dispatch(fetchSequenceFailure({ sequenceId: mockData.sequenceId }));
render(<Sequence {...mockData} />, { store: testStore, wrapWithRouter: true });
render(<Sequence {...mockData} />, { store: testStore });
expect(screen.getByText('There was an error loading this course.')).toBeInTheDocument();
});
it('handles loading unit', async () => {
render(<Sequence {...mockData} />, { wrapWithRouter: true });
render(<Sequence {...mockData} />);
expect(await screen.findByText('Loading learning sequence...')).toBeInTheDocument();
// `Previous`, `Bookmark` and `Close Tray` buttons
expect(screen.getAllByRole('button')).toHaveLength(3);
@@ -174,7 +167,7 @@ describe('Sequence', () => {
sequenceId: sequenceBlocks[1].id,
previousSequenceHandler: jest.fn(),
};
render(<Sequence {...testData} />, { store: testStore, wrapWithRouter: true });
render(<Sequence {...testData} />, { store: testStore });
expect(await screen.findByText('Loading learning sequence...')).toBeInTheDocument();
const sequencePreviousButton = screen.getByRole('link', { name: /previous/i });
@@ -210,7 +203,7 @@ describe('Sequence', () => {
sequenceId: sequenceBlocks[0].id,
nextSequenceHandler: jest.fn(),
};
render(<Sequence {...testData} />, { store: testStore, wrapWithRouter: true });
render(<Sequence {...testData} />, { store: testStore });
expect(await screen.findByText('Loading learning sequence...')).toBeInTheDocument();
const sequenceNextButton = screen.getByRole('link', { name: /next/i });
@@ -248,7 +241,7 @@ describe('Sequence', () => {
previousSequenceHandler: jest.fn(),
nextSequenceHandler: jest.fn(),
};
render(<Sequence {...testData} />, { store: testStore, wrapWithRouter: true });
render(<Sequence {...testData} />, { store: testStore });
await waitFor(() => expect(screen.queryByText('Loading learning sequence...')).toBeInTheDocument());
fireEvent.click(screen.getByRole('link', { name: /previous/i }));
@@ -272,7 +265,7 @@ describe('Sequence', () => {
unitNavigationHandler: jest.fn(),
previousSequenceHandler: jest.fn(),
};
render(<Sequence {...testData} />, { store: testStore, wrapWithRouter: true });
render(<Sequence {...testData} />, { store: testStore });
loadUnit();
await waitFor(() => expect(screen.queryByText('Loading learning sequence...')).not.toBeInTheDocument());
@@ -291,7 +284,7 @@ describe('Sequence', () => {
unitNavigationHandler: jest.fn(),
nextSequenceHandler: jest.fn(),
};
render(<Sequence {...testData} />, { store: testStore, wrapWithRouter: true });
render(<Sequence {...testData} />, { store: testStore });
loadUnit();
await waitFor(() => expect(screen.queryByText('Loading learning sequence...')).not.toBeInTheDocument());
@@ -333,7 +326,7 @@ describe('Sequence', () => {
nextSequenceHandler: jest.fn(),
};
render(<Sequence {...testData} />, { store: innerTestStore, wrapWithRouter: true });
render(<Sequence {...testData} />, { store: innerTestStore });
loadUnit();
await waitFor(() => expect(screen.queryByText('Loading learning sequence...')).not.toBeInTheDocument());
@@ -381,7 +374,7 @@ describe('Sequence', () => {
sequenceId: sequenceBlocks[0].id,
unitNavigationHandler: jest.fn(),
};
render(<Sequence {...testData} />, { store: testStore, wrapWithRouter: true });
render(<Sequence {...testData} />, { store: testStore });
await waitFor(() => expect(screen.queryByText('Loading learning sequence...')).toBeInTheDocument());
fireEvent.click(screen.getByRole('link', { name: targetUnit.display_name }));
@@ -408,13 +401,13 @@ describe('Sequence', () => {
describe('notification feature', () => {
it('renders notification tray in sequence', async () => {
render(<SidebarWrapper contextValue={{ courseId: mockData.courseId, currentSidebar: 'NOTIFICATIONS', toggleSidebar: () => null }} />, { wrapWithRouter: true });
render(<SidebarWrapper contextValue={{ courseId: mockData.courseId, currentSidebar: 'NOTIFICATIONS', toggleSidebar: () => null }} />);
expect(await screen.findByText('Notifications')).toBeInTheDocument();
});
it('handles click on notification tray close button', async () => {
const toggleNotificationTray = jest.fn();
render(<SidebarWrapper contextValue={{ courseId: mockData.courseId, currentSidebar: 'NOTIFICATIONS', toggleSidebar: toggleNotificationTray }} />, { wrapWithRouter: true });
render(<SidebarWrapper contextValue={{ courseId: mockData.courseId, currentSidebar: 'NOTIFICATIONS', toggleSidebar: toggleNotificationTray }} />);
const notificationCloseIconButton = await screen.findByRole('button', { name: /Close notification tray/i });
fireEvent.click(notificationCloseIconButton);
expect(toggleNotificationTray).toHaveBeenCalledTimes(1);
@@ -422,7 +415,7 @@ describe('Sequence', () => {
it('does not render notification tray in sequence by default if in responsive view', async () => {
global.innerWidth = breakpoints.medium.maxWidth;
const { container } = render(<Sequence {...mockData} />, { wrapWithRouter: true });
const { container } = render(<Sequence {...mockData} />);
// unable to test the absence of 'Notifications' by finding it by text, using the class of the tray instead:
expect(container).not.toHaveClass('notification-tray-container');
});

View File

@@ -19,13 +19,13 @@ describe('Sequence Content', () => {
});
it('displays loading message', () => {
render(<SequenceContent {...mockData} />, { wrapWithRouter: true });
render(<SequenceContent {...mockData} />);
expect(screen.getByText('Loading learning sequence...')).toBeInTheDocument();
});
it('displays messages for the locked content', async () => {
const { gatedContent } = store.getState().models.sequences[mockData.sequenceId];
const { container } = render(<SequenceContent {...mockData} gated />, { wrapWithRouter: true });
const { container } = render(<SequenceContent {...mockData} gated />);
expect(screen.getByText('Loading locked content messaging...')).toBeInTheDocument();
expect(await screen.findByText('Content Locked')).toBeInTheDocument();
@@ -38,7 +38,7 @@ describe('Sequence Content', () => {
});
it('displays message for no content', () => {
render(<SequenceContent {...mockData} unitId={null} />, { wrapWithRouter: true });
render(<SequenceContent {...mockData} unitId={null} />);
expect(screen.getByText('There is no content here.')).toBeInTheDocument();
});
});

View File

@@ -74,7 +74,7 @@ const ContentIFrame = ({
<iframe title={title} {...contentIFrameProps} data-testid={testIDs.contentIFrame} />
</div>
)}
{modalOptions.isOpen && (
{modalOptions.open && (
<Modal
body={modalOptions.body
? <div className="unit-modal">{ modalOptions.body }</div>
@@ -84,7 +84,7 @@ const ContentIFrame = ({
allow={IFRAME_FEATURE_POLICY}
frameBorder="0"
src={modalOptions.url}
style={{ width: '100%', height: modalOptions.height }}
style={{ width: '100%', height: '100vh' }}
/>
)}
dialogClassName="modal-lti"

View File

@@ -29,17 +29,16 @@ const iframeBehavior = {
const modalOptions = {
closed: {
isOpen: false,
open: false,
},
withBody: {
body: 'test-body',
isOpen: true,
open: true,
},
withUrl: {
isOpen: true,
open: true,
title: 'test-modal-title',
url: 'test-modal-url',
height: 'test-height',
},
};
@@ -84,7 +83,7 @@ describe('ContentIFrame Component', () => {
});
describe('output', () => {
let component;
describe('if shouldShowContent', () => {
describe('shouldShowContent', () => {
describe('if not hasLoaded', () => {
it('displays errorPage if showError', () => {
hooks.useIFrameBehavior.mockReturnValueOnce({ ...iframeBehavior, showError: true });
@@ -122,7 +121,7 @@ describe('ContentIFrame Component', () => {
});
});
});
describe('if not shouldShowContent', () => {
describe('not shouldShowContent', () => {
it('does not show PageLoading, ErrorPage, or unit-iframe-wrapper', () => {
el = shallow(<ContentIFrame {...{ ...props, shouldShowContent: false }} />);
expect(el.instance.findByType(PageLoading).length).toEqual(0);
@@ -130,13 +129,13 @@ describe('ContentIFrame Component', () => {
expect(el.instance.findByTestId(testIDs.contentIFrame).length).toEqual(0);
});
});
it('does not display modal if modalOptions returns isOpen: false', () => {
it('does not display modal if modalOptions returns open: false', () => {
el = shallow(<ContentIFrame {...props} />);
expect(el.instance.findByType(Modal).length).toEqual(0);
});
describe('if modalOptions.isOpen', () => {
describe('if modalOptions.open', () => {
const testModalOpenAndHandleClose = () => {
test('Modal component isOpen, with handleModalClose from hook', () => {
test('Modal component is open, with handleModalClose from hook', () => {
expect(component.props.onClose).toEqual(modalIFrameData.handleModalClose);
});
};
@@ -165,7 +164,7 @@ describe('ContentIFrame Component', () => {
allow={IFRAME_FEATURE_POLICY}
frameBorder="0"
src={modalOptions.withUrl.url}
style={{ width: '100%', height: modalOptions.withUrl.height }}
style={{ width: '100%', height: '100vh' }}
/>,
);
});

View File

@@ -5,32 +5,28 @@ import { StrictDict, useKeyedState } from '@edx/react-unit-test-utils/dist';
import { useEventListener } from '../../../../../generic/hooks';
export const stateKeys = StrictDict({
isOpen: 'isOpen',
options: 'options',
modalOptions: 'modalOptions',
});
export const DEFAULT_HEIGHT = '100vh';
const useModalIFrameBehavior = () => {
const [isOpen, setIsOpen] = useKeyedState(stateKeys.isOpen, false);
const [options, setOptions] = useKeyedState(stateKeys.options, { height: DEFAULT_HEIGHT });
const [modalOptions, setModalOptions] = useKeyedState(stateKeys.modalOptions, ({ open: false }));
const receiveMessage = React.useCallback(({ data }) => {
const { type, payload } = data;
if (type === 'plugin.modal') {
setOptions((current) => ({ ...current, ...payload }));
setIsOpen(true);
payload.open = true;
setModalOptions(payload);
}
}, []);
useEventListener('message', receiveMessage);
const handleModalClose = () => {
setIsOpen(false);
setModalOptions({ open: false });
};
return {
handleModalClose,
modalOptions: { isOpen, ...options },
modalOptions,
};
};

View File

@@ -2,7 +2,7 @@ import { mockUseKeyedState } from '@edx/react-unit-test-utils';
import { useEventListener } from '../../../../../generic/hooks';
import { messageTypes } from '../constants';
import useModalIFrameBehavior, { stateKeys, DEFAULT_HEIGHT } from './useModalIFrameData';
import useModalIFrameBehavior, { stateKeys } from './useModalIFrameData';
jest.mock('react', () => ({
...jest.requireActual('react'),
@@ -20,49 +20,31 @@ describe('useModalIFrameBehavior', () => {
state.mock();
});
describe('behavior', () => {
it('initializes isOpen to false', () => {
it('initializes modalOptions to closed', () => {
useModalIFrameBehavior();
state.expectInitializedWith(stateKeys.isOpen, false);
});
it('initializes options with default height', () => {
useModalIFrameBehavior();
state.expectInitializedWith(stateKeys.options, { height: DEFAULT_HEIGHT });
state.expectInitializedWith(stateKeys.modalOptions, { open: false });
});
describe('eventListener', () => {
it('consumes modal events and opens sets modal options with open: true', () => {
const oldOptions = { some: 'old', options: 'yeah' };
state.mockVals({
[stateKeys.isOpen]: false,
[stateKeys.options]: oldOptions,
});
useModalIFrameBehavior();
expect(useEventListener).toHaveBeenCalled();
const { cb, prereqs } = useEventListener.mock.calls[0][1];
expect(prereqs).toEqual([]);
const payload = { test: 'values' };
cb({ data: { type: messageTypes.modal, payload } });
expect(state.setState.isOpen).toHaveBeenCalledWith(true);
expect(state.setState.options).toHaveBeenCalled();
const [[setOptionsCb]] = state.setState.options.mock.calls;
expect(setOptionsCb(oldOptions)).toEqual({ ...oldOptions, ...payload });
expect(state.setState.modalOptions).toHaveBeenCalledWith({ ...payload, open: true });
});
});
});
describe('output', () => {
test('handleModalClose sets modal options to closed', () => {
useModalIFrameBehavior().handleModalClose();
state.expectSetStateCalledWith(stateKeys.isOpen, false);
state.expectSetStateCalledWith(stateKeys.modalOptions, { open: false });
});
it('forwards modalOptions from state values', () => {
it('forwards modalOptions from state value', () => {
const modalOptions = { test: 'options' };
state.mockVals({
[stateKeys.options]: modalOptions,
[stateKeys.isOpen]: true,
});
expect(useModalIFrameBehavior().modalOptions).toEqual({
...modalOptions,
isOpen: true,
});
state.mockVal(stateKeys.modalOptions, modalOptions);
expect(useModalIFrameBehavior().modalOptions).toEqual(modalOptions);
});
});
});

View File

@@ -1,9 +1,9 @@
import React, { useCallback } from 'react';
import PropTypes from 'prop-types';
import { useNavigate } from 'react-router-dom';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faLock } from '@fortawesome/free-solid-svg-icons';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { history } from '@edx/frontend-platform';
import { Button } from '@edx/paragon';
import messages from './messages';
@@ -11,9 +11,8 @@ import messages from './messages';
const ContentLock = ({
intl, courseId, prereqSectionName, prereqId, sequenceTitle,
}) => {
const navigate = useNavigate();
const handleClick = useCallback(() => {
navigate(`/course/${courseId}/${prereqId}`);
history.push(`/course/${courseId}/${prereqId}`);
}, [courseId, prereqId]);
return (

View File

@@ -1,16 +1,10 @@
import React from 'react';
import { history } from '@edx/frontend-platform';
import {
render, screen, fireEvent, initializeMockApp,
} from '../../../../setupTest';
import ContentLock from './ContentLock';
const mockNavigate = jest.fn();
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useNavigate: () => mockNavigate,
}));
describe('Content Lock', () => {
const mockData = {
courseId: 'test-course-id',
@@ -25,7 +19,7 @@ describe('Content Lock', () => {
});
it('displays sequence title along with lock icon', () => {
const { container } = render(<ContentLock {...mockData} />, { wrapWithRouter: true });
const { container } = render(<ContentLock {...mockData} />);
const lockIcon = container.querySelector('svg');
expect(lockIcon).toHaveClass('fa-lock');
@@ -34,15 +28,16 @@ describe('Content Lock', () => {
it('displays prerequisite name', () => {
const prereqText = `You must complete the prerequisite: '${mockData.prereqSectionName}' to access this content.`;
render(<ContentLock {...mockData} />, { wrapWithRouter: true });
render(<ContentLock {...mockData} />);
expect(screen.getByText(prereqText)).toBeInTheDocument();
});
it('handles click', () => {
render(<ContentLock {...mockData} />, { wrapWithRouter: true });
history.push = jest.fn();
render(<ContentLock {...mockData} />);
fireEvent.click(screen.getByRole('button'));
expect(mockNavigate).toHaveBeenCalledWith(`/course/${mockData.courseId}/${mockData.prereqId}`);
expect(history.push).toHaveBeenCalledWith(`/course/${mockData.courseId}/${mockData.prereqId}`);
});
});

View File

@@ -1,18 +1,16 @@
import React from 'react';
import PropTypes from 'prop-types';
import { useDispatch } from 'react-redux';
import { getConfig } from '@edx/frontend-platform';
import { getConfig, history } from '@edx/frontend-platform';
import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
import { FormattedMessage, injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { ActionRow, Alert, Button } from '@edx/paragon';
import { useNavigate } from 'react-router-dom';
import { useModel } from '../../../../generic/model-store';
import { saveIntegritySignature } from '../../../data';
import messages from './messages';
const HonorCode = ({ intl, courseId }) => {
const navigate = useNavigate();
const dispatch = useDispatch();
const {
isMasquerading,
@@ -22,7 +20,7 @@ const HonorCode = ({ intl, courseId }) => {
const siteName = getConfig().SITE_NAME;
const honorCodeUrl = `${getConfig().TERMS_OF_SERVICE_URL}#honor-code`;
const handleCancel = () => navigate(`/course/${courseId}/home`);
const handleCancel = () => history.push(`/course/${courseId}/home`);
const handleAgree = () => dispatch(
// If the request is made by a staff user masquerading as a specific learner,

View File

@@ -1,5 +1,5 @@
import React from 'react';
import { getConfig } from '@edx/frontend-platform';
import { getConfig, history } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import MockAdapter from 'axios-mock-adapter';
import { Factory } from 'rosie';
@@ -9,12 +9,12 @@ import {
} from '../../../../setupTest';
import HonorCode from './HonorCode';
const mockNavigate = jest.fn();
initializeMockApp();
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useNavigate: () => mockNavigate,
jest.mock('@edx/frontend-platform', () => ({
...jest.requireActual('@edx/frontend-platform'),
history: {
push: jest.fn(),
},
}));
describe('Honor Code', () => {
@@ -38,15 +38,15 @@ describe('Honor Code', () => {
it('cancel button links to course home ', async () => {
await setupStoreState();
render(<HonorCode {...mockData} />, { wrapWithRouter: true });
render(<HonorCode {...mockData} />);
const cancelButton = screen.getByText('Cancel');
fireEvent.click(cancelButton);
expect(mockNavigate).toHaveBeenCalledWith(`/course/${mockData.courseId}/home`);
expect(history.push).toHaveBeenCalledWith(`/course/${mockData.courseId}/home`);
});
it('calls to save integrity_signature when agreeing', async () => {
await setupStoreState({ username: authenticatedUser.username });
render(<HonorCode {...mockData} />, { wrapWithRouter: true });
render(<HonorCode {...mockData} />);
const agreeButton = screen.getByText('I agree');
fireEvent.click(agreeButton);
await waitFor(() => {
@@ -63,7 +63,7 @@ describe('Honor Code', () => {
username: authenticatedUser.username,
},
);
render(<HonorCode {...mockData} />, { wrapWithRouter: true });
render(<HonorCode {...mockData} />);
const agreeButton = screen.getByText('I agree');
fireEvent.click(agreeButton);
await waitFor(() => {
@@ -80,7 +80,7 @@ describe('Honor Code', () => {
username: 'otheruser',
},
);
render(<HonorCode {...mockData} />, { wrapWithRouter: true });
render(<HonorCode {...mockData} />);
const agreeButton = screen.getByText('I agree');
fireEvent.click(agreeButton);
await waitFor(() => {

View File

@@ -33,13 +33,13 @@ describe('Sequence Navigation', () => {
it('is empty while loading', async () => {
const testStore = await initializeTestStore({ excludeFetchSequence: true }, false);
const { container } = render(<SequenceNavigation {...mockData} />, { store: testStore, wrapWithRouter: true });
const { container } = render(<SequenceNavigation {...mockData} />, { store: testStore });
expect(container).toBeEmptyDOMElement();
});
it('renders empty div without unitId', () => {
const { container } = render(<SequenceNavigation {...mockData} unitId={undefined} />, { wrapWithRouter: true });
const { container } = render(<SequenceNavigation {...mockData} unitId={undefined} />);
expect(getByText(container, (content, element) => (
element.tagName.toLowerCase() === 'div' && element.getAttribute('style')))).toBeEmptyDOMElement();
});
@@ -61,7 +61,7 @@ describe('Sequence Navigation', () => {
sequenceId: sequenceBlocks[0].id,
onNavigate: jest.fn(),
};
render(<SequenceNavigation {...testData} />, { store: testStore, wrapWithRouter: true });
render(<SequenceNavigation {...testData} />, { store: testStore });
const unitButton = screen.getByTitle(unitBlocks[1].display_name);
fireEvent.click(unitButton);
@@ -74,7 +74,7 @@ describe('Sequence Navigation', () => {
it('renders correctly and handles unit button clicks', () => {
const onNavigate = jest.fn();
render(<SequenceNavigation {...mockData} {...{ onNavigate }} />, { wrapWithRouter: true });
render(<SequenceNavigation {...mockData} {...{ onNavigate }} />);
const unitButtons = screen.getAllByRole('link', { name: /\d+/ });
expect(unitButtons).toHaveLength(unitButtons.length);
@@ -83,7 +83,7 @@ describe('Sequence Navigation', () => {
});
it('has both navigation buttons enabled for a non-corner unit of the sequence', () => {
render(<SequenceNavigation {...mockData} />, { wrapWithRouter: true });
render(<SequenceNavigation {...mockData} />);
screen.getAllByRole('link', { name: /previous|next/i }).forEach(button => {
expect(button).toBeEnabled();
@@ -91,7 +91,7 @@ describe('Sequence Navigation', () => {
});
it('has the "Previous" button disabled for the first unit of the sequence', () => {
render(<SequenceNavigation {...mockData} unitId={unitBlocks[0].id} />, { wrapWithRouter: true });
render(<SequenceNavigation {...mockData} unitId={unitBlocks[0].id} />);
expect(screen.getByRole('button', { name: /previous/i })).toBeDisabled();
expect(screen.getByRole('link', { name: /next/i })).toBeEnabled();
@@ -106,7 +106,7 @@ describe('Sequence Navigation', () => {
render(
<SequenceNavigation {...testData} unitId={unitBlocks[unitBlocks.length - 1].id} />,
{ store: testStore, wrapWithRouter: true },
{ store: testStore },
);
expect(screen.getByRole('link', { name: /previous/i })).toBeEnabled();
@@ -122,7 +122,7 @@ describe('Sequence Navigation', () => {
render(
<SequenceNavigation {...testData} unitId={unitBlocks[unitBlocks.length - 1].id} />,
{ store: testStore, wrapWithRouter: true },
{ store: testStore },
);
expect(screen.getByRole('link', { name: /previous/i })).toBeEnabled();
@@ -143,7 +143,7 @@ describe('Sequence Navigation', () => {
render(
<SequenceNavigation {...testData} unitId={unitBlocks[unitBlocks.length - 1].id} />,
{ store: testStore, wrapWithRouter: true },
{ store: testStore },
);
expect(screen.getByRole('link', { name: /previous/i })).toBeEnabled();
@@ -153,7 +153,7 @@ describe('Sequence Navigation', () => {
it('handles "Previous" and "Next" click', () => {
const previousHandler = jest.fn();
const nextHandler = jest.fn();
render(<SequenceNavigation {...mockData} {...{ previousHandler, nextHandler }} />, { wrapWithRouter: true });
render(<SequenceNavigation {...mockData} {...{ previousHandler, nextHandler }} />);
fireEvent.click(screen.getByRole('link', { name: /previous/i }));
expect(previousHandler).toHaveBeenCalledTimes(1);

View File

@@ -40,10 +40,7 @@ describe('Sequence Navigation Dropdown', () => {
unitBlocks.forEach((unit, index) => {
it(`marks unit ${index + 1} as active`, async () => {
const { container } = render(
<SequenceNavigationDropdown {...mockData} unitId={unit.id} />,
{ wrapWithRouter: true },
);
const { container } = render(<SequenceNavigationDropdown {...mockData} unitId={unit.id} />);
const dropdownToggle = container.querySelector('.dropdown-toggle');
await act(async () => {
await fireEvent.click(dropdownToggle);
@@ -62,10 +59,7 @@ describe('Sequence Navigation Dropdown', () => {
it('handles the clicks', () => {
const onNavigate = jest.fn();
const { container } = render(
<SequenceNavigationDropdown {...mockData} onNavigate={onNavigate} />,
{ wrapWithRouter: true },
);
const { container } = render(<SequenceNavigationDropdown {...mockData} onNavigate={onNavigate} />);
const dropdownToggle = container.querySelector('.dropdown-toggle');
act(() => {

View File

@@ -41,7 +41,7 @@ describe('Sequence Navigation Tabs', () => {
it('renders unit buttons', () => {
useIndexOfLastVisibleChild.mockReturnValue([0, null, null]);
render(<SequenceNavigationTabs {...mockData} />, { wrapWithRouter: true });
render(<SequenceNavigationTabs {...mockData} />);
expect(screen.getAllByRole('link')).toHaveLength(unitBlocks.length);
});
@@ -50,7 +50,7 @@ describe('Sequence Navigation Tabs', () => {
let container = null;
await act(async () => {
useIndexOfLastVisibleChild.mockReturnValue([-1, null, null]);
const booyah = render(<SequenceNavigationTabs {...mockData} />, { wrapWithRouter: true });
const booyah = render(<SequenceNavigationTabs {...mockData} />);
container = booyah.container;
const dropdownToggle = container.querySelector('.dropdown-toggle');

View File

@@ -32,12 +32,12 @@ describe('Unit Button', () => {
});
it('hides title by default', () => {
render(<UnitButton {...mockData} />, { wrapWithRouter: true });
render(<UnitButton {...mockData} />);
expect(screen.getByRole('link')).not.toHaveTextContent(unit.display_name);
});
it('shows title', () => {
render(<UnitButton {...mockData} showTitle />, { wrapWithRouter: true });
render(<UnitButton {...mockData} showTitle />);
expect(screen.getByRole('link')).toHaveTextContent(unit.display_name);
});
@@ -49,7 +49,7 @@ describe('Unit Button', () => {
});
it('shows completion for completed unit', () => {
const { container } = render(<UnitButton {...mockData} unitId={completedUnit.id} />, { wrapWithRouter: true });
const { container } = render(<UnitButton {...mockData} unitId={completedUnit.id} />);
const buttonIcons = container.querySelectorAll('svg');
expect(buttonIcons).toHaveLength(2);
expect(buttonIcons[1]).toHaveClass('fa-check');
@@ -70,7 +70,7 @@ describe('Unit Button', () => {
});
it('shows bookmark', () => {
const { container } = render(<UnitButton {...mockData} unitId={bookmarkedUnit.id} />, { wrapWithRouter: true });
const { container } = render(<UnitButton {...mockData} unitId={bookmarkedUnit.id} />);
const buttonIcons = container.querySelectorAll('svg');
expect(buttonIcons).toHaveLength(3);
expect(buttonIcons[2]).toHaveClass('fa-bookmark');
@@ -78,7 +78,7 @@ describe('Unit Button', () => {
it('handles the click', () => {
const onClick = jest.fn();
render(<UnitButton {...mockData} onClick={onClick} />, { wrapWithRouter: true });
render(<UnitButton {...mockData} onClick={onClick} />);
fireEvent.click(screen.getByRole('link'));
expect(onClick).toHaveBeenCalledTimes(1);
});

View File

@@ -32,7 +32,7 @@ describe('Unit Navigation', () => {
unitId=""
onClickPrevious={() => {}}
onClickNext={() => {}}
/>, { wrapWithRouter: true });
/>);
// Only "Previous" and "Next" buttons should be rendered.
expect(screen.getAllByRole('link')).toHaveLength(2);
@@ -46,7 +46,7 @@ describe('Unit Navigation', () => {
{...mockData}
onClickPrevious={onClickPrevious}
onClickNext={onClickNext}
/>, { wrapWithRouter: true });
/>);
fireEvent.click(screen.getByRole('link', { name: /previous/i }));
expect(onClickPrevious).toHaveBeenCalledTimes(1);
@@ -56,7 +56,7 @@ describe('Unit Navigation', () => {
});
it('has the navigation buttons enabled for the non-corner unit in the sequence', () => {
render(<UnitNavigation {...mockData} />, { wrapWithRouter: true });
render(<UnitNavigation {...mockData} />);
screen.getAllByRole('link').forEach(button => {
expect(button).toBeEnabled();
@@ -64,7 +64,7 @@ describe('Unit Navigation', () => {
});
it('has the "Previous" button disabled for the first unit in the sequence', () => {
render(<UnitNavigation {...mockData} unitId={unitBlocks[0].id} />, { wrapWithRouter: true });
render(<UnitNavigation {...mockData} unitId={unitBlocks[0].id} />);
expect(screen.getByRole('button', { name: /previous/i })).toBeDisabled();
expect(screen.getByRole('link', { name: /next/i })).toBeEnabled();
@@ -79,7 +79,7 @@ describe('Unit Navigation', () => {
render(
<UnitNavigation {...testData} unitId={unitBlocks[unitBlocks.length - 1].id} />,
{ store: testStore, wrapWithRouter: true },
{ store: testStore },
);
expect(screen.getByRole('link', { name: /previous/i })).toBeEnabled();
@@ -95,7 +95,7 @@ describe('Unit Navigation', () => {
render(
<UnitNavigation {...testData} unitId={unitBlocks[unitBlocks.length - 1].id} />,
{ store: testStore, wrapWithRouter: true },
{ store: testStore },
);
expect(screen.getByRole('link', { name: /previous/i })).toBeEnabled();
@@ -116,7 +116,7 @@ describe('Unit Navigation', () => {
render(
<UnitNavigation {...testData} unitId={unitBlocks[unitBlocks.length - 1].id} />,
{ store: testStore, wrapWithRouter: true },
{ store: testStore },
);
expect(screen.getByRole('link', { name: /previous/i })).toBeEnabled();

View File

@@ -1,22 +0,0 @@
import React from 'react';
import { useNavigate, useParams } from 'react-router-dom';
const withParamsAndNavigation = WrappedComponent => {
const WithParamsNavigationComponent = props => {
const { courseId, sequenceId, unitId } = useParams();
const navigate = useNavigate();
return (
<WrappedComponent
routeCourseId={courseId}
routeSequenceId={sequenceId}
routeUnitId={unitId}
navigate={navigate}
{...props}
/>
);
};
return WithParamsNavigationComponent;
};
export default withParamsAndNavigation;

View File

@@ -2,16 +2,15 @@
exports[`DecodePageRoute should not modify the url if it does not need to be decoded 1`] = `
<div>
PageWrap: {
"children": [
" ",
[
" ",
[],
" "
],
" "
]
PageRoute: {
"computedMatch": {
"path": "/course/:courseId/home",
"url": "/course/course-v1:edX+DemoX+Demo_Course/home",
"isExact": true,
"params": {
"courseId": "course-v1:edX+DemoX+Demo_Course"
}
}
}
</div>
`;

View File

@@ -1,15 +1,7 @@
import PropTypes from 'prop-types';
import { PageWrap } from '@edx/frontend-platform/react';
import { PageRoute } from '@edx/frontend-platform/react';
import React from 'react';
import {
generatePath, useMatch, Navigate,
} from 'react-router-dom';
import { DECODE_ROUTES } from '../constants';
const ROUTES = [].concat(
...Object.values(DECODE_ROUTES).map(value => (Array.isArray(value) ? value : [value])),
);
import { useHistory, generatePath } from 'react-router';
export const decodeUrl = (encodedUrl) => {
const decodedUrl = decodeURIComponent(encodedUrl);
@@ -19,16 +11,10 @@ export const decodeUrl = (encodedUrl) => {
return decodeUrl(decodedUrl);
};
const DecodePageRoute = ({ children }) => {
let computedMatch = null;
ROUTES.forEach((route) => {
const matchedRoute = useMatch(route);
if (matchedRoute) { computedMatch = matchedRoute; }
});
if (computedMatch) {
const { pathname, pattern, params } = computedMatch;
const DecodePageRoute = (props) => {
const history = useHistory();
if (props.computedMatch) {
const { url, path, params } = props.computedMatch;
Object.keys(params).forEach((param) => {
// only decode params not the entire url.
@@ -36,19 +22,28 @@ const DecodePageRoute = ({ children }) => {
params[param] = decodeUrl(params[param]);
});
const newUrl = generatePath(pattern.path, params);
const newUrl = generatePath(path, params);
// if the url get decoded, reroute to the decoded url
if (newUrl !== pathname) {
return <Navigate to={newUrl} replace />;
if (newUrl !== url) {
history.replace(newUrl);
}
}
return <PageWrap> {children} </PageWrap>;
return <PageRoute {...props} />;
};
DecodePageRoute.propTypes = {
children: PropTypes.node.isRequired,
computedMatch: PropTypes.shape({
url: PropTypes.string.isRequired,
path: PropTypes.string.isRequired,
// eslint-disable-next-line react/forbid-prop-types
params: PropTypes.any,
}),
};
DecodePageRoute.defaultProps = {
computedMatch: null,
};
export default DecodePageRoute;

View File

@@ -1,8 +1,7 @@
import React from 'react';
import { render } from '@testing-library/react';
import {
MemoryRouter as Router, matchPath, Routes, Route, mockNavigate,
} from 'react-router-dom';
import { createMemoryHistory } from 'history';
import { Router, matchPath } from 'react-router';
import DecodePageRoute, { decodeUrl } from '.';
const decodedCourseId = 'course-v1:edX+DemoX+Demo_Course';
@@ -16,90 +15,84 @@ const deepEncodedCourseId = (() => {
})();
jest.mock('@edx/frontend-platform/react', () => ({
PageWrap: (props) => `PageWrap: ${JSON.stringify(props, null, 2)}`,
PageRoute: (props) => `PageRoute: ${JSON.stringify(props, null, 2)}`,
}));
jest.mock('../constants', () => ({
DECODE_ROUTES: {
MOCK_ROUTE_1: '/course/:courseId/home',
MOCK_ROUTE_2: `/course/:courseId/${encodeURIComponent('some+thing')}/:unitId`,
},
}));
jest.mock('react-router-dom', () => {
const mockNavigation = jest.fn();
// eslint-disable-next-line react/prop-types
const Navigate = ({ to }) => {
mockNavigation(to);
return <div />;
};
return {
...jest.requireActual('react-router-dom'),
Navigate,
mockNavigate: mockNavigation,
};
});
const renderPage = (props) => {
const memHistory = createMemoryHistory({
initialEntries: [props?.path],
});
const history = {
...memHistory,
replace: jest.fn(),
};
const { container } = render(
<Router initialEntries={[props?.pathname]}>
<Routes>
<Route path={props?.pattern?.path} element={<DecodePageRoute> {[]} </DecodePageRoute>} />
</Routes>
<Router history={history}>
<DecodePageRoute computedMatch={props} />
</Router>,
);
return { container };
return {
container,
history,
props,
};
};
describe('DecodePageRoute', () => {
afterEach(() => {
mockNavigate.mockClear();
});
it('should not modify the url if it does not need to be decoded', () => {
const props = matchPath({
const props = matchPath(`/course/${decodedCourseId}/home`, {
path: '/course/:courseId/home',
}, `/course/${decodedCourseId}/home`);
const { container } = renderPage(props);
});
const { container, history } = renderPage(props);
expect(props.pathname).toContain(decodedCourseId);
expect(mockNavigate).not.toHaveBeenCalled();
expect(props.url).toContain(decodedCourseId);
expect(history.replace).not.toHaveBeenCalled();
expect(container).toMatchSnapshot();
});
it('should decode the url and replace the history if necessary', () => {
const props = matchPath({
const props = matchPath(`/course/${encodedCourseId}/home`, {
path: '/course/:courseId/home',
}, `/course/${encodedCourseId}/home`);
renderPage(props);
});
const { history } = renderPage(props);
expect(props.pathname).not.toContain(decodedCourseId);
expect(props.pathname).toContain(encodedCourseId);
expect(mockNavigate).toHaveBeenCalledWith(`/course/${decodedCourseId}/home`);
expect(props.url).not.toContain(decodedCourseId);
expect(props.url).toContain(encodedCourseId);
expect(history.replace.mock.calls[0][0]).toContain(decodedCourseId);
});
it('should decode the url multiple times if necessary', () => {
const props = matchPath({
const props = matchPath(`/course/${deepEncodedCourseId}/home`, {
path: '/course/:courseId/home',
}, `/course/${deepEncodedCourseId}/home`);
renderPage(props);
});
const { history } = renderPage(props);
expect(props.pathname).not.toContain(decodedCourseId);
expect(props.pathname).toContain(deepEncodedCourseId);
expect(mockNavigate).toHaveBeenCalledWith(`/course/${decodedCourseId}/home`);
expect(props.url).not.toContain(decodedCourseId);
expect(props.url).toContain(deepEncodedCourseId);
expect(history.replace.mock.calls[0][0]).toContain(decodedCourseId);
});
it('should only decode the url params and not the entire url', () => {
const decodedUnitId = 'some+thing';
const encodedUnitId = encodeURIComponent(decodedUnitId);
const props = matchPath({
const props = matchPath(`/course/${deepEncodedCourseId}/${encodedUnitId}/${encodedUnitId}`, {
path: `/course/:courseId/${encodedUnitId}/:unitId`,
}, `/course/${deepEncodedCourseId}/${encodedUnitId}/${encodedUnitId}`);
renderPage(props);
});
const { history } = renderPage(props);
expect(mockNavigate).toHaveBeenCalledWith(`/course/${decodedCourseId}/${encodedUnitId}/${decodedUnitId}`);
const decodedUrls = history.replace.mock.calls[0][0].split('/');
// unitId get decoded
expect(decodedUrls.pop()).toContain(decodedUnitId);
// path remain encoded
expect(decodedUrls.pop()).toContain(encodedUnitId);
// courseId get decoded
expect(decodedUrls.pop()).toContain(decodedCourseId);
});
});

View File

@@ -1,8 +1,9 @@
import React, { useEffect } from 'react';
import { LearningHeader as Header } from '@edx/frontend-component-header';
import Footer from '@edx/frontend-component-footer';
import { useParams, Navigate } from 'react-router-dom';
import { useParams } from 'react-router-dom';
import { useDispatch, useSelector } from 'react-redux';
import { Redirect } from 'react-router';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import useActiveEnterpriseAlert from '../alerts/active-enteprise-alert';
import { AlertList } from './user-messages';
@@ -37,7 +38,7 @@ const CourseAccessErrorPage = ({ intl }) => {
);
}
if (courseStatus === LOADED) {
return <Navigate to={`/redirect/home/${courseId}`} replace />;
return (<Redirect to={`/redirect/home/${courseId}`} />);
}
return (
<>

View File

@@ -1,13 +1,11 @@
import React from 'react';
import { history } from '@edx/frontend-platform';
import { Routes, Route } from 'react-router-dom';
import { Route } from 'react-router';
import { initializeTestStore, render, screen } from '../setupTest';
import CourseAccessErrorPage from './CourseAccessErrorPage';
const mockDispatch = jest.fn();
const mockNavigate = jest.fn();
let mockCourseStatus;
jest.mock('react-redux', () => ({
...jest.requireActual('react-redux'),
useDispatch: () => mockDispatch,
@@ -16,10 +14,6 @@ jest.mock('react-redux', () => ({
jest.mock('./PageLoading', () => function () {
return <div data-testid="page-loading" />;
});
jest.mock('react-router-dom', () => ({
...(jest.requireActual('react-router-dom')),
useNavigate: () => mockNavigate,
}));
describe('CourseAccessErrorPage', () => {
let courseId;
@@ -34,36 +28,33 @@ describe('CourseAccessErrorPage', () => {
it('Displays loading in start on page rendering', () => {
mockCourseStatus = 'loading';
render(
<Routes>
<Route path="/course/:courseId/access-denied" element={<CourseAccessErrorPage />} />
</Routes>,
{ wrapWithRouter: true },
<Route path="/course/:courseId/access-denied">
<CourseAccessErrorPage />
</Route>,
);
expect(screen.getByTestId('page-loading')).toBeInTheDocument();
expect(window.location.pathname).toBe(accessDeniedUrl);
expect(history.location.pathname).toBe(accessDeniedUrl);
});
it('Redirect user to homepage if user has access', () => {
mockCourseStatus = 'loaded';
render(
<Routes>
<Route path="/course/:courseId/access-denied" element={<CourseAccessErrorPage />} />
</Routes>,
{ wrapWithRouter: true },
<Route path="/course/:courseId/access-denied">
<CourseAccessErrorPage />
</Route>,
);
expect(window.location.pathname).toBe('/redirect/home/course-v1:edX+DemoX+Demo_Course');
expect(history.location.pathname).toBe('/redirect/home/course-v1:edX+DemoX+Demo_Course');
});
it('For access denied it should render access denied page', () => {
mockCourseStatus = 'denied';
render(
<Routes>
<Route path="/course/:courseId/access-denied" element={<CourseAccessErrorPage />} />
</Routes>,
{ wrapWithRouter: true },
<Route path="/course/:courseId/access-denied">
<CourseAccessErrorPage />
</Route>,
);
expect(screen.getByTestId('access-denied-main')).toBeInTheDocument();
expect(window.location.pathname).toBe(accessDeniedUrl);
expect(history.location.pathname).toBe(accessDeniedUrl);
});
});

View File

@@ -1,4 +1,4 @@
import { Navigate, useLocation } from 'react-router-dom';
import { Redirect, useLocation } from 'react-router-dom';
import PropTypes from 'prop-types';
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
@@ -16,10 +16,10 @@ const PathFixesProvider = ({ children }) => {
// We only check for spaces. That's not the only kind of character that is escaped in URLs, but it would always be
// present for our cases, and I believe it's the only one we use normally.
if (location.pathname.includes(' ') || location.pathname.includes('%20')) {
if (location.pathname.includes(' ')) {
const newLocation = {
...location,
pathname: (location.pathname.replaceAll(' ', '+')).replaceAll('%20', '+'),
pathname: location.pathname.replaceAll(' ', '+'),
};
sendTrackEvent('edx.ui.lms.path_fixed', {
@@ -29,7 +29,7 @@ const PathFixesProvider = ({ children }) => {
search: location.search,
});
return (<Navigate to={newLocation} replace />);
return (<Redirect to={newLocation} />);
}
return children; // pass through

View File

@@ -1,7 +1,5 @@
import React from 'react';
import {
MemoryRouter, Route, Routes, useLocation,
} from 'react-router-dom';
import { MemoryRouter, Route } from 'react-router-dom';
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
@@ -21,20 +19,16 @@ describe('PathFixesProvider', () => {
});
function buildAndRender(path) {
const LocationComponent = () => {
testLocation = useLocation();
return null;
};
render(
<MemoryRouter initialEntries={[path]}>
<PathFixesProvider>
<Routes>
<Route
path="*"
element={<LocationComponent />}
/>
</Routes>
<Route
path="*"
render={routeProps => {
testLocation = routeProps.location;
return null;
}}
/>
</PathFixesProvider>
</MemoryRouter>,
);

View File

@@ -6,10 +6,10 @@ import {
mergeConfig,
getConfig,
} from '@edx/frontend-platform';
import { AppProvider, ErrorPage, PageWrap } from '@edx/frontend-platform/react';
import { AppProvider, ErrorPage, PageRoute } from '@edx/frontend-platform/react';
import React from 'react';
import ReactDOM from 'react-dom';
import { Routes, Route } from 'react-router-dom';
import { Switch } from 'react-router-dom';
import { Helmet } from 'react-helmet';
import { fetchDiscussionTab, fetchLiveTab } from './course-home/data/thunks';
@@ -36,7 +36,6 @@ import PathFixesProvider from './generic/path-fixes';
import LiveTab from './course-home/live-tab/LiveTab';
import CourseAccessErrorPage from './generic/CourseAccessErrorPage';
import DecodePageRoute from './decode-page-route';
import { DECODE_ROUTES, ROUTES } from './constants';
subscribe(APP_READY, () => {
ReactDOM.render(
@@ -47,91 +46,59 @@ subscribe(APP_READY, () => {
<PathFixesProvider>
<NoticesProvider>
<UserMessagesProvider>
<Routes>
<Route path={ROUTES.UNSUBSCRIBE} element={<PageWrap><GoalUnsubscribe /></PageWrap>} />
<Route path={ROUTES.REDIRECT} element={<PageWrap><CoursewareRedirectLandingPage /></PageWrap>} />
<Route
path={DECODE_ROUTES.ACCESS_DENIED}
element={<DecodePageRoute><CourseAccessErrorPage /></DecodePageRoute>}
/>
<Route
path={DECODE_ROUTES.HOME}
element={(
<DecodePageRoute>
<TabContainer tab="outline" fetch={fetchOutlineTab} slice="courseHome">
<OutlineTab />
</TabContainer>
</DecodePageRoute>
)}
/>
<Route
path={DECODE_ROUTES.LIVE}
element={(
<DecodePageRoute>
<TabContainer tab="lti_live" fetch={fetchLiveTab} slice="courseHome">
<LiveTab />
</TabContainer>
</DecodePageRoute>
<Switch>
<PageRoute exact path="/goal-unsubscribe/:token" component={GoalUnsubscribe} />
<PageRoute path="/redirect" component={CoursewareRedirectLandingPage} />
<DecodePageRoute path="/course/:courseId/access-denied" component={CourseAccessErrorPage} />
<DecodePageRoute path="/course/:courseId/home">
<TabContainer tab="outline" fetch={fetchOutlineTab} slice="courseHome">
<OutlineTab />
</TabContainer>
</DecodePageRoute>
<DecodePageRoute path="/course/:courseId/live">
<TabContainer tab="lti_live" fetch={fetchLiveTab} slice="courseHome">
<LiveTab />
</TabContainer>
</DecodePageRoute>
<DecodePageRoute path="/course/:courseId/dates">
<TabContainer tab="dates" fetch={fetchDatesTab} slice="courseHome">
<DatesTab />
</TabContainer>
</DecodePageRoute>
<DecodePageRoute path="/course/:courseId/discussion/:path*">
<TabContainer tab="discussion" fetch={fetchDiscussionTab} slice="courseHome">
<DiscussionTab />
</TabContainer>
</DecodePageRoute>
<DecodePageRoute
path={[
'/course/:courseId/progress/:targetUserId/',
'/course/:courseId/progress',
]}
render={({ match }) => (
<TabContainer
tab="progress"
fetch={(courseId) => fetchProgressTab(courseId, match.params.targetUserId)}
slice="courseHome"
>
<ProgressTab />
</TabContainer>
)}
/>
<Route
path={DECODE_ROUTES.DATES}
element={(
<DecodePageRoute>
<TabContainer tab="dates" fetch={fetchDatesTab} slice="courseHome">
<DatesTab />
</TabContainer>
</DecodePageRoute>
)}
<DecodePageRoute path="/course/:courseId/course-end">
<TabContainer tab="courseware" fetch={fetchCourse} slice="courseware">
<CourseExit />
</TabContainer>
</DecodePageRoute>
<DecodePageRoute
path={[
'/course/:courseId/:sequenceId/:unitId',
'/course/:courseId/:sequenceId',
'/course/:courseId',
]}
component={CoursewareContainer}
/>
<Route
path={DECODE_ROUTES.DISCUSSION}
element={(
<DecodePageRoute>
<TabContainer tab="discussion" fetch={fetchDiscussionTab} slice="courseHome">
<DiscussionTab />
</TabContainer>
</DecodePageRoute>
)}
/>
{DECODE_ROUTES.PROGRESS.map((route) => (
<Route
path={route}
element={(
<DecodePageRoute>
<TabContainer
tab="progress"
fetch={fetchProgressTab}
slice="courseHome"
isProgressTab
>
<ProgressTab />
</TabContainer>
</DecodePageRoute>
)}
/>
))}
<Route
path={DECODE_ROUTES.COURSE_END}
element={(
<DecodePageRoute>
<TabContainer tab="courseware" fetch={fetchCourse} slice="courseware">
<CourseExit />
</TabContainer>
</DecodePageRoute>
)}
/>
{DECODE_ROUTES.COURSEWARE.map((route) => (
<Route
path={route}
element={(
<DecodePageRoute>
<CoursewareContainer />
</DecodePageRoute>
)}
/>
))}
</Routes>
</Switch>
</UserMessagesProvider>
</NoticesProvider>
</PathFixesProvider>

View File

@@ -3,7 +3,7 @@
* @jest-environment jsdom
*/
import React from 'react';
import { Route, Routes } from 'react-router-dom';
import { Route, Switch } from 'react-router';
import { Factory } from 'rosie';
import { getConfig, history } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
@@ -26,7 +26,6 @@ import { buildSimpleCourseBlocks } from '../shared/data/__factories__/courseBloc
import { buildOutlineFromBlocks } from '../courseware/data/__factories__/learningSequencesOutline.factory';
import { UserMessagesProvider } from '../generic/user-messages';
import { DECODE_ROUTES } from '../constants';
initializeMockApp();
jest.mock('@edx/frontend-platform/analytics');
@@ -63,7 +62,7 @@ describe('Course Home Tours', () => {
<LoadedTabPage courseId={courseId} activeTabSlug="outline">
<OutlineTab />
</LoadedTabPage>,
{ store, wrapWithRouter: true },
{ store },
);
}
@@ -214,14 +213,16 @@ describe('Courseware Tour', () => {
component = (
<AppProvider store={store}>
<UserMessagesProvider>
<Routes>
{DECODE_ROUTES.COURSEWARE.map((route) => (
<Route
path={route}
element={<CoursewareContainer />}
/>
))}
</Routes>
<Switch>
<Route
path={[
'/course/:courseId/:sequenceId/:unitId',
'/course/:courseId/:sequenceId',
'/course/:courseId',
]}
component={CoursewareContainer}
/>
</Switch>
</UserMessagesProvider>
</AppProvider>
);

View File

@@ -169,14 +169,13 @@ function render(
ui,
{
store = null,
wrapWithRouter = false,
...renderOptions
} = {},
) {
const Wrapper = ({ children }) => (
// eslint-disable-next-line react/jsx-filename-extension
<IntlProvider locale="en">
<AppProvider store={store || globalStore} wrapWithRouter={wrapWithRouter}>
<AppProvider store={store || globalStore}>
<UserMessagesProvider>
{children}
</UserMessagesProvider>

View File

@@ -12,21 +12,15 @@ const TabContainer = (props) => {
fetch,
slice,
tab,
isProgressTab,
} = props;
const { courseId: courseIdFromUrl, targetUserId } = useParams();
const { courseId: courseIdFromUrl } = useParams();
const dispatch = useDispatch();
useEffect(() => {
// The courseId from the URL is the course we WANT to load.
if (isProgressTab) {
dispatch(fetch(courseIdFromUrl, targetUserId));
} else {
dispatch(fetch(courseIdFromUrl));
}
dispatch(fetch(courseIdFromUrl));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [courseIdFromUrl, targetUserId]);
}, [courseIdFromUrl]);
// The courseId from the store is the course we HAVE loaded. If the URL changes,
// we don't want the application to adjust to it until it has actually loaded the new data.
@@ -53,11 +47,6 @@ TabContainer.propTypes = {
fetch: PropTypes.func.isRequired,
slice: PropTypes.string.isRequired,
tab: PropTypes.string.isRequired,
isProgressTab: PropTypes.bool,
};
TabContainer.defaultProps = {
isProgressTab: false,
};
export default TabContainer;

View File

@@ -1,5 +1,6 @@
import React from 'react';
import { Route, Routes, MemoryRouter } from 'react-router-dom';
import { history } from '@edx/frontend-platform';
import { Route } from 'react-router';
import { initializeTestStore, render, screen } from '../setupTest';
import { TabContainer } from './index';
@@ -30,19 +31,13 @@ describe('Tab Container', () => {
});
it('renders correctly', () => {
history.push(`/course/${courseId}`);
render(
<MemoryRouter initialEntries={[`/course/${courseId}`]}>
<Routes>
<Route
path="/course/:courseId"
element={(
<TabContainer {...mockData}>
children={[]}
</TabContainer>
)}
/>
</Routes>
</MemoryRouter>,
<Route path="/course/:courseId">
<TabContainer {...mockData}>
children={[]}
</TabContainer>
</Route>,
);
expect(mockFetch).toHaveBeenCalledTimes(1);
@@ -54,25 +49,22 @@ describe('Tab Container', () => {
it('Should handle passing in a targetUserId', () => {
const targetUserId = '1';
history.push(`/course/${courseId}/progress/${targetUserId}/`);
render(
<MemoryRouter initialEntries={[`/course/${courseId}/progress/${targetUserId}/`]}>
<Routes>
<Route
path="/course/:courseId/progress/:targetUserId/"
element={(
<TabContainer
fetch={mockFetch}
tab="dummy"
slice="courseHome"
isProgressTab
>
children={[]}
</TabContainer>
)}
/>
</Routes>
</MemoryRouter>,
<Route
path="/course/:courseId/progress/:targetUserId/"
render={({ match }) => (
<TabContainer
fetch={() => mockFetch(match.params.courseId, match.params.targetUserId)}
tab="dummy"
slice="courseHome"
>
children={[]}
</TabContainer>
)}
/>,
);
expect(mockFetch).toHaveBeenCalledTimes(1);

View File

@@ -2,7 +2,7 @@ import React from 'react';
import PropTypes from 'prop-types';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { useDispatch, useSelector } from 'react-redux';
import { Navigate } from 'react-router-dom';
import { Redirect } from 'react-router';
import Footer from '@edx/frontend-component-footer';
import { Toast } from '@edx/paragon';
@@ -41,7 +41,7 @@ const TabPage = ({ intl, ...props }) => {
if (courseStatus === 'denied') {
const redirectUrl = getAccessDeniedRedirectUrl(courseId, activeTabSlug, courseAccess, start);
if (redirectUrl) {
return (<Navigate to={redirectUrl} replace />);
return (<Redirect to={redirectUrl} />);
}
}