Compare commits
8 Commits
bw/fronten
...
oliv
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b24568f0bd | ||
|
|
5604def491 | ||
|
|
b788b969c3 | ||
|
|
b7a3d5640a | ||
|
|
3a21d8c807 | ||
|
|
81442bebe9 | ||
|
|
168ed1e184 | ||
|
|
c8e32c3f46 |
1
.env
1
.env
@@ -46,3 +46,4 @@ TERMS_OF_SERVICE_URL=''
|
||||
TWITTER_HASHTAG=''
|
||||
TWITTER_URL=''
|
||||
USER_INFO_COOKIE_NAME=''
|
||||
OPTIMIZELY_FULL_STACK_SDK_KEY=''
|
||||
|
||||
@@ -48,3 +48,4 @@ 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=''
|
||||
|
||||
12
.github/workflows/commitlint.yml
vendored
12
.github/workflows/commitlint.yml
vendored
@@ -7,14 +7,4 @@ on:
|
||||
|
||||
jobs:
|
||||
commitlint:
|
||||
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
|
||||
uses: openedx/.github/.github/workflows/commitlint.yml@master
|
||||
|
||||
2
Makefile
2
Makefile
@@ -77,5 +77,5 @@ validate:
|
||||
|
||||
.PHONY: validate.ci
|
||||
validate.ci:
|
||||
npm ci --legacy-peer-deps
|
||||
npm ci
|
||||
make validate
|
||||
|
||||
3116
package-lock.json
generated
3116
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
17
package.json
17
package.json
@@ -30,11 +30,11 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@edx/brand": "npm:@edx/brand-openedx@1.2.0",
|
||||
"@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/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/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": "5.2.1",
|
||||
"react-router-dom": "5.3.0",
|
||||
"react-router": "6.15.0",
|
||||
"react-router-dom": "6.15.0",
|
||||
"react-share": "4.4.1",
|
||||
"redux": "4.1.2",
|
||||
"regenerator-runtime": "0.13.11",
|
||||
@@ -66,13 +66,12 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@edx/browserslist-config": "1.2.0",
|
||||
"@edx/frontend-build": "12.9.0-alpha.6",
|
||||
"@edx/frontend-build": "^12.9.10",
|
||||
"@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",
|
||||
|
||||
33
src/constants.js
Normal file
33
src/constants.js
Normal file
@@ -0,0 +1,33 @@
|
||||
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',
|
||||
};
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import { Route } from 'react-router';
|
||||
import { Routes, Route } from 'react-router-dom';
|
||||
import MockAdapter from 'axios-mock-adapter';
|
||||
import { Factory } from 'rosie';
|
||||
import { getConfig, history } from '@edx/frontend-platform';
|
||||
@@ -32,11 +32,16 @@ describe('DatesTab', () => {
|
||||
component = (
|
||||
<AppProvider store={store}>
|
||||
<UserMessagesProvider>
|
||||
<Route path="/course/:courseId/dates">
|
||||
<TabContainer tab="dates" fetch={fetchDatesTab} slice="courseHome">
|
||||
<DatesTab />
|
||||
</TabContainer>
|
||||
</Route>
|
||||
<Routes>
|
||||
<Route
|
||||
path="/course/:courseId/dates"
|
||||
element={(
|
||||
<TabContainer tab="dates" fetch={fetchDatesTab} slice="courseHome">
|
||||
<DatesTab />
|
||||
</TabContainer>
|
||||
)}
|
||||
/>
|
||||
</Routes>
|
||||
</UserMessagesProvider>
|
||||
</AppProvider>
|
||||
);
|
||||
|
||||
@@ -2,21 +2,20 @@ import { getConfig } from '@edx/frontend-platform';
|
||||
import { injectIntl } from '@edx/frontend-platform/i18n';
|
||||
import React, { useState } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { generatePath, useHistory } from 'react-router';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { useParams, generatePath, useNavigate } 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 history = useHistory();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [, iFrameHeight] = useIFrameHeight();
|
||||
useIFramePluginEvents({
|
||||
'discussions.navigate': (payload) => {
|
||||
const basePath = generatePath('/course/:courseId/discussion', { courseId });
|
||||
history.push(`${basePath}/${payload.path}`);
|
||||
navigate(`${basePath}/${payload.path}`);
|
||||
},
|
||||
});
|
||||
const discussionsUrl = `${getConfig().DISCUSSIONS_MFE_BASE_URL}/${courseId}/${originalPath}`;
|
||||
|
||||
@@ -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 } from 'react-router';
|
||||
import { Route, Routes } from 'react-router-dom';
|
||||
import { Factory } from 'rosie';
|
||||
import { UserMessagesProvider } from '../../generic/user-messages';
|
||||
import {
|
||||
@@ -30,11 +30,16 @@ describe('DiscussionTab', () => {
|
||||
component = (
|
||||
<AppProvider store={store}>
|
||||
<UserMessagesProvider>
|
||||
<Route path="/course/:courseId/discussion">
|
||||
<TabContainer tab="discussion" fetch={fetchDiscussionTab} slice="courseHome">
|
||||
<DiscussionTab />
|
||||
</TabContainer>
|
||||
</Route>
|
||||
<Routes>
|
||||
<Route
|
||||
path="/course/:courseId/discussion"
|
||||
element={(
|
||||
<TabContainer tab="discussion" fetch={fetchDiscussionTab} slice="courseHome">
|
||||
<DiscussionTab />
|
||||
</TabContainer>
|
||||
)}
|
||||
/>
|
||||
</Routes>
|
||||
</UserMessagesProvider>
|
||||
</AppProvider>
|
||||
);
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import React from 'react';
|
||||
import { Route } from 'react-router';
|
||||
import {
|
||||
MemoryRouter, Route, Routes,
|
||||
} from 'react-router-dom';
|
||||
import MockAdapter from 'axios-mock-adapter';
|
||||
import { getConfig, history } from '@edx/frontend-platform';
|
||||
import { getConfig } 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';
|
||||
@@ -24,13 +26,16 @@ describe('GoalUnsubscribe', () => {
|
||||
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||
store = initializeStore();
|
||||
component = (
|
||||
<AppProvider store={store}>
|
||||
<AppProvider store={store} wrapWithRouter={false}>
|
||||
<UserMessagesProvider>
|
||||
<Route path="/goal-unsubscribe/:token" component={GoalUnsubscribe} />
|
||||
<MemoryRouter initialEntries={['/goal-unsubscribe/TOKEN']}>
|
||||
<Routes>
|
||||
<Route path="/goal-unsubscribe/:token" element={<GoalUnsubscribe />} />
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
</UserMessagesProvider>
|
||||
</AppProvider>
|
||||
);
|
||||
history.push('/goal-unsubscribe/TOKEN'); // so we can pull token from url
|
||||
});
|
||||
|
||||
it('starts with a spinner', () => {
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { useLocation, useNavigate } 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';
|
||||
@@ -67,6 +66,7 @@ const OutlineTab = ({ intl }) => {
|
||||
} = useModel('coursewareMeta', courseId);
|
||||
|
||||
const [expandAll, setExpandAll] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
|
||||
const eventProperties = {
|
||||
org_key: org,
|
||||
@@ -115,8 +115,10 @@ 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');
|
||||
history.replace({
|
||||
search: currentParams.toString(),
|
||||
navigate({
|
||||
pathname: location.pathname,
|
||||
search: `?${currentParams.toString()}`,
|
||||
replace: true,
|
||||
});
|
||||
}
|
||||
}, [location.search]);
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
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';
|
||||
|
||||
@@ -17,45 +16,46 @@ 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) => {
|
||||
const checkResumeRedirect = memoize((courseStatus, courseId, sequenceId, firstSequenceId, navigate) => {
|
||||
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) {
|
||||
history.replace(`/course/${courseId}/${data.sectionId}/${data.unitId}`);
|
||||
navigate(`/course/${courseId}/${data.sectionId}/${data.unitId}`, { replace: true });
|
||||
} else if (firstSequenceId) {
|
||||
history.replace(`/course/${courseId}/${firstSequenceId}`);
|
||||
navigate(`/course/${courseId}/${firstSequenceId}`, { replace: true });
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Look at where this is called in componentDidUpdate for more info about its usage
|
||||
const checkSectionUnitToUnitRedirect = memoize((courseStatus, courseId, sequenceStatus, section, unitId) => {
|
||||
const checkSectionUnitToUnitRedirect = memoize((courseStatus, courseId, sequenceStatus, section, unitId, navigate) => {
|
||||
if (courseStatus === 'loaded' && sequenceStatus === 'failed' && section && unitId) {
|
||||
history.replace(`/course/${courseId}/${unitId}`);
|
||||
navigate(`/course/${courseId}/${unitId}`, { replace: true });
|
||||
}
|
||||
});
|
||||
|
||||
// Look at where this is called in componentDidUpdate for more info about its usage
|
||||
const checkSectionToSequenceRedirect = memoize((courseStatus, courseId, sequenceStatus, section, unitId) => {
|
||||
const checkSectionToSequenceRedirect = memoize((courseStatus, courseId, sequenceStatus, section, unitId, navigate) => {
|
||||
if (courseStatus === 'loaded' && sequenceStatus === 'failed' && section && !unitId) {
|
||||
// If the section is non-empty, redirect to its first sequence.
|
||||
if (section.sequenceIds && section.sequenceIds[0]) {
|
||||
history.replace(`/course/${courseId}/${section.sequenceIds[0]}`);
|
||||
navigate(`/course/${courseId}/${section.sequenceIds[0]}`, { replace: true });
|
||||
// Otherwise, just go to the course root, letting the resume redirect take care of things.
|
||||
} else {
|
||||
history.replace(`/course/${courseId}`);
|
||||
navigate(`/course/${courseId}`, { replace: true });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Look at where this is called in componentDidUpdate for more info about its usage
|
||||
const checkUnitToSequenceUnitRedirect = memoize(
|
||||
(courseStatus, courseId, sequenceStatus, sequenceMightBeUnit, sequenceId, section, routeUnitId) => {
|
||||
(courseStatus, courseId, sequenceStatus, sequenceMightBeUnit, sequenceId, section, routeUnitId, navigate) => {
|
||||
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,60 +64,62 @@ const checkUnitToSequenceUnitRedirect = memoize(
|
||||
getSequenceForUnitDeprecated(courseId, unitId).then(
|
||||
parentId => {
|
||||
if (parentId) {
|
||||
history.replace(`/course/${courseId}/${parentId}/${unitId}`);
|
||||
navigate(`/course/${courseId}/${parentId}/${unitId}`, { replace: true });
|
||||
} else {
|
||||
history.replace(`/course/${courseId}`);
|
||||
navigate(`/course/${courseId}`, { replace: true });
|
||||
}
|
||||
},
|
||||
() => { // error case
|
||||
history.replace(`/course/${courseId}`);
|
||||
navigate(`/course/${courseId}`, { replace: true });
|
||||
},
|
||||
);
|
||||
} else {
|
||||
// Invalid sequence that isn't a unit either. Redirect up to main course.
|
||||
history.replace(`/course/${courseId}`);
|
||||
navigate(`/course/${courseId}`, { replace: true });
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Look at where this is called in componentDidUpdate for more info about its usage
|
||||
const checkSequenceToSequenceUnitRedirect = memoize((courseId, sequenceStatus, sequence, unitId) => {
|
||||
const checkSequenceToSequenceUnitRedirect = memoize((courseId, sequenceStatus, sequence, unitId, navigate) => {
|
||||
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.
|
||||
history.replace(`/course/${courseId}/${sequence.id}/${nextUnitId}`);
|
||||
navigate(`/course/${courseId}/${sequence.id}/${nextUnitId}`, { replace: true });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Look at where this is called in componentDidUpdate for more info about its usage
|
||||
const checkSequenceUnitMarkerToSequenceUnitRedirect = memoize((courseId, sequenceStatus, sequence, unitId) => {
|
||||
if (sequenceStatus !== 'loaded' || !sequence.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
const hasUnits = sequence.unitIds?.length > 0;
|
||||
|
||||
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
|
||||
history.replace(`/course/${courseId}/${sequence.id}`);
|
||||
const checkSequenceUnitMarkerToSequenceUnitRedirect = memoize(
|
||||
(courseId, sequenceStatus, sequence, unitId, navigate) => {
|
||||
if (sequenceStatus !== 'loaded' || !sequence.id) {
|
||||
return;
|
||||
}
|
||||
} else if (unitId === 'last') {
|
||||
if (hasUnits) {
|
||||
const lastUnitId = sequence.unitIds[sequence.unitIds.length - 1];
|
||||
history.replace(`/course/${courseId}/${sequence.id}/${lastUnitId}`);
|
||||
} else {
|
||||
|
||||
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 {
|
||||
// No units... go to general sequence page
|
||||
history.replace(`/course/${courseId}/${sequence.id}`);
|
||||
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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
class CoursewareContainer extends Component {
|
||||
checkSaveSequencePosition = memoize((unitId) => {
|
||||
@@ -145,12 +147,8 @@ class CoursewareContainer extends Component {
|
||||
|
||||
componentDidMount() {
|
||||
const {
|
||||
match: {
|
||||
params: {
|
||||
courseId: routeCourseId,
|
||||
sequenceId: routeSequenceId,
|
||||
},
|
||||
},
|
||||
routeCourseId,
|
||||
routeSequenceId,
|
||||
} = this.props;
|
||||
// Load data whenever the course or sequence ID changes.
|
||||
this.checkFetchCourse(routeCourseId);
|
||||
@@ -167,13 +165,10 @@ class CoursewareContainer extends Component {
|
||||
sequence,
|
||||
firstSequenceId,
|
||||
sectionViaSequenceId,
|
||||
match: {
|
||||
params: {
|
||||
courseId: routeCourseId,
|
||||
sequenceId: routeSequenceId,
|
||||
unitId: routeUnitId,
|
||||
},
|
||||
},
|
||||
routeCourseId,
|
||||
routeSequenceId,
|
||||
routeUnitId,
|
||||
navigate,
|
||||
} = this.props;
|
||||
|
||||
// Load data whenever the course or sequence ID changes.
|
||||
@@ -202,7 +197,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);
|
||||
checkResumeRedirect(courseStatus, courseId, sequenceId, firstSequenceId, navigate);
|
||||
|
||||
// Check section-unit to unit redirect:
|
||||
// /course/:courseId/:sectionId/:unitId -> /course/:courseId/:unitId
|
||||
@@ -215,42 +210,40 @@ 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);
|
||||
checkSectionUnitToUnitRedirect(courseStatus, courseId, sequenceStatus, sectionViaSequenceId, routeUnitId, navigate);
|
||||
|
||||
// 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);
|
||||
checkSectionToSequenceRedirect(courseStatus, courseId, sequenceStatus, sectionViaSequenceId, routeUnitId, navigate);
|
||||
|
||||
// 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
|
||||
courseStatus, courseId, sequenceStatus, sequenceMightBeUnit,
|
||||
sequenceId, sectionViaSequenceId, routeUnitId, navigate
|
||||
));
|
||||
|
||||
// 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);
|
||||
checkSequenceToSequenceUnitRedirect(courseId, sequenceStatus, sequence, routeUnitId, navigate);
|
||||
|
||||
// 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);
|
||||
checkSequenceUnitMarkerToSequenceUnitRedirect(courseId, sequenceStatus, sequence, routeUnitId, navigate);
|
||||
}
|
||||
|
||||
handleUnitNavigationClick = () => {
|
||||
const {
|
||||
courseId, sequenceId,
|
||||
match: {
|
||||
params: {
|
||||
unitId: routeUnitId,
|
||||
},
|
||||
},
|
||||
courseId,
|
||||
sequenceId,
|
||||
routeUnitId,
|
||||
} = this.props;
|
||||
|
||||
this.props.checkBlockCompletion(courseId, sequenceId, routeUnitId);
|
||||
@@ -279,11 +272,7 @@ class CoursewareContainer extends Component {
|
||||
courseStatus,
|
||||
courseId,
|
||||
sequenceId,
|
||||
match: {
|
||||
params: {
|
||||
unitId: routeUnitId,
|
||||
},
|
||||
},
|
||||
routeUnitId,
|
||||
} = this.props;
|
||||
|
||||
return (
|
||||
@@ -326,13 +315,9 @@ const courseShape = PropTypes.shape({
|
||||
});
|
||||
|
||||
CoursewareContainer.propTypes = {
|
||||
match: PropTypes.shape({
|
||||
params: PropTypes.shape({
|
||||
courseId: PropTypes.string.isRequired,
|
||||
sequenceId: PropTypes.string,
|
||||
unitId: PropTypes.string,
|
||||
}).isRequired,
|
||||
}).isRequired,
|
||||
routeCourseId: PropTypes.string.isRequired,
|
||||
routeSequenceId: PropTypes.string,
|
||||
routeUnitId: PropTypes.string,
|
||||
courseId: PropTypes.string,
|
||||
sequenceId: PropTypes.string,
|
||||
firstSequenceId: PropTypes.string,
|
||||
@@ -348,11 +333,14 @@ 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,
|
||||
@@ -467,4 +455,4 @@ export default connect(mapStateToProps, {
|
||||
saveSequencePosition,
|
||||
fetchCourse,
|
||||
fetchSequence,
|
||||
})(CoursewareContainer);
|
||||
})(withParamsAndNavigation(CoursewareContainer));
|
||||
|
||||
@@ -5,13 +5,16 @@ 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 { Route, Switch } from 'react-router';
|
||||
import {
|
||||
BrowserRouter, MemoryRouter, Route, Routes,
|
||||
} from 'react-router-dom';
|
||||
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';
|
||||
@@ -80,18 +83,16 @@ describe('CoursewareContainer', () => {
|
||||
store = initializeStore();
|
||||
|
||||
component = (
|
||||
<AppProvider store={store}>
|
||||
<AppProvider store={store} wrapWithRouter={false}>
|
||||
<UserMessagesProvider>
|
||||
<Switch>
|
||||
<Route
|
||||
path={[
|
||||
'/course/:courseId/:sequenceId/:unitId',
|
||||
'/course/:courseId/:sequenceId',
|
||||
'/course/:courseId',
|
||||
]}
|
||||
component={CoursewareContainer}
|
||||
/>
|
||||
</Switch>
|
||||
<Routes>
|
||||
{DECODE_ROUTES.COURSEWARE.map((route) => (
|
||||
<Route
|
||||
path={route}
|
||||
element={<CoursewareContainer />}
|
||||
/>
|
||||
))}
|
||||
</Routes>
|
||||
</UserMessagesProvider>
|
||||
</AppProvider>
|
||||
);
|
||||
@@ -151,7 +152,7 @@ describe('CoursewareContainer', () => {
|
||||
}
|
||||
|
||||
async function loadContainer() {
|
||||
const { container } = render(component);
|
||||
const { container } = render(<BrowserRouter>{component}</BrowserRouter>);
|
||||
// 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'));
|
||||
@@ -160,7 +161,7 @@ describe('CoursewareContainer', () => {
|
||||
|
||||
it('should initialize to show a spinner', () => {
|
||||
history.push('/course/abc123');
|
||||
render(component);
|
||||
render(<MemoryRouter initialEntries={['/course/abc123']}>{component}</MemoryRouter>);
|
||||
|
||||
const spinner = screen.getByRole('status');
|
||||
|
||||
|
||||
@@ -1,56 +1,44 @@
|
||||
import React from 'react';
|
||||
import { Switch, useRouteMatch } from 'react-router';
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { Routes, Route } from 'react-router-dom';
|
||||
import { FormattedMessage } from '@edx/frontend-platform/i18n';
|
||||
import { PageRoute } from '@edx/frontend-platform/react';
|
||||
import { PageWrap } 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 = () => {
|
||||
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..."
|
||||
/>
|
||||
)}
|
||||
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..."
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<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>
|
||||
);
|
||||
};
|
||||
<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>
|
||||
);
|
||||
|
||||
export default CoursewareRedirectLandingPage;
|
||||
|
||||
@@ -1,19 +1,12 @@
|
||||
import React from 'react';
|
||||
import { Router } from 'react-router';
|
||||
import { createMemoryHistory } from 'history';
|
||||
import { MemoryRouter as Router } from 'react-router-dom';
|
||||
import { render, initializeMockApp } from '../setupTest';
|
||||
import CoursewareRedirectLandingPage from './CoursewareRedirectLandingPage';
|
||||
|
||||
const redirectUrl = jest.fn();
|
||||
|
||||
jest.mock('@edx/frontend-platform/analytics');
|
||||
|
||||
jest.mock('react-router', () => ({
|
||||
...jest.requireActual('react-router'),
|
||||
useRouteMatch: () => ({
|
||||
path: '/redirect',
|
||||
}),
|
||||
}));
|
||||
jest.mock('../decode-page-route', () => jest.fn(({ children }) => <div>{children}</div>));
|
||||
|
||||
describe('CoursewareRedirectLandingPage', () => {
|
||||
beforeEach(async () => {
|
||||
@@ -23,12 +16,8 @@ describe('CoursewareRedirectLandingPage', () => {
|
||||
});
|
||||
|
||||
it('Redirects to correct consent URL', () => {
|
||||
const history = createMemoryHistory({
|
||||
initialEntries: ['/redirect/consent/?consentPath=%2Fgrant_data_sharing_consent'],
|
||||
});
|
||||
|
||||
render(
|
||||
<Router history={history}>
|
||||
<Router initialEntries={['/consent/?consentPath=%2Fgrant_data_sharing_consent']}>
|
||||
<CoursewareRedirectLandingPage />
|
||||
</Router>,
|
||||
);
|
||||
@@ -37,12 +26,8 @@ describe('CoursewareRedirectLandingPage', () => {
|
||||
});
|
||||
|
||||
it('Redirects to correct consent URL', () => {
|
||||
const history = createMemoryHistory({
|
||||
initialEntries: ['/redirect/home/course-v1:edX+DemoX+Demo_Course'],
|
||||
});
|
||||
|
||||
render(
|
||||
<Router history={history}>
|
||||
<Router initialEntries={['/home/course-v1:edX+DemoX+Demo_Course']}>
|
||||
<CoursewareRedirectLandingPage />
|
||||
</Router>,
|
||||
);
|
||||
|
||||
45
src/courseware/RedirectPage.jsx
Normal file
45
src/courseware/RedirectPage.jsx
Normal file
@@ -0,0 +1,45 @@
|
||||
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;
|
||||
69
src/courseware/RedirectPage.test.jsx
Normal file
69
src/courseware/RedirectPage.test.jsx
Normal file
@@ -0,0 +1,69 @@
|
||||
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`);
|
||||
});
|
||||
});
|
||||
@@ -15,6 +15,10 @@ 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
|
||||
@@ -65,11 +69,11 @@ describe('Course', () => {
|
||||
const [firstSequenceId] = Object.keys(state.models.sequences);
|
||||
mockData.sequenceId = firstSequenceId;
|
||||
|
||||
await render(<Course {...mockData} />, { store: testStore });
|
||||
await render(<Course {...mockData} />, { store: testStore, wrapWithRouter: true });
|
||||
};
|
||||
|
||||
it('loads learning sequence', async () => {
|
||||
render(<Course {...mockData} />);
|
||||
render(<Course {...mockData} />, { wrapWithRouter: true });
|
||||
expect(screen.getByRole('navigation', { name: 'breadcrumb' })).toBeInTheDocument();
|
||||
expect(await screen.findByText('Loading learning sequence...')).toBeInTheDocument();
|
||||
|
||||
@@ -102,7 +106,7 @@ describe('Course', () => {
|
||||
};
|
||||
// Set up LocalStorage for testing.
|
||||
handleNextSectionCelebration(sequenceId, sequenceId, testData.unitId);
|
||||
render(<Course {...testData} />, { store: testStore });
|
||||
render(<Course {...testData} />, { store: testStore, wrapWithRouter: true });
|
||||
|
||||
const firstSectionCelebrationModal = screen.getByRole('dialog');
|
||||
expect(firstSectionCelebrationModal).toBeInTheDocument();
|
||||
@@ -120,7 +124,7 @@ describe('Course', () => {
|
||||
sequenceId,
|
||||
unitId: Object.values(models.units)[0].id,
|
||||
};
|
||||
render(<Course {...testData} />, { store: testStore });
|
||||
render(<Course {...testData} />, { store: testStore, wrapWithRouter: true });
|
||||
|
||||
const weeklyGoalCelebrationModal = screen.getByRole('dialog');
|
||||
expect(weeklyGoalCelebrationModal).toBeInTheDocument();
|
||||
@@ -128,7 +132,7 @@ describe('Course', () => {
|
||||
});
|
||||
|
||||
it('displays notification trigger and toggles active class on click', async () => {
|
||||
render(<Course {...mockData} />);
|
||||
render(<Course {...mockData} />, { wrapWithRouter: true });
|
||||
|
||||
const notificationTrigger = screen.getByRole('button', { name: /Show notification tray/i });
|
||||
expect(notificationTrigger).toBeInTheDocument();
|
||||
@@ -181,7 +185,7 @@ describe('Course', () => {
|
||||
|
||||
it('handles click to open/close notification tray', async () => {
|
||||
sessionStorage.clear();
|
||||
render(<Course {...mockData} />);
|
||||
render(<Course {...mockData} />, { wrapWithRouter: true });
|
||||
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');
|
||||
@@ -192,7 +196,7 @@ describe('Course', () => {
|
||||
|
||||
it('handles reload persisting notification tray status', async () => {
|
||||
sessionStorage.clear();
|
||||
render(<Course {...mockData} />);
|
||||
render(<Course {...mockData} />, { wrapWithRouter: true });
|
||||
const notificationShowButton = await screen.findByRole('button', { name: /Show notification tray/i });
|
||||
fireEvent.click(notificationShowButton);
|
||||
expect(sessionStorage.getItem(`notificationTrayStatus.${mockData.courseId}`)).toBe('"closed"');
|
||||
@@ -216,7 +220,7 @@ describe('Course', () => {
|
||||
// set sessionStorage for a different course before rendering Course
|
||||
sessionStorage.setItem(`notificationTrayStatus.${courseMetadataSecondCourse.id}`, '"open"');
|
||||
|
||||
render(<Course {...mockData} />);
|
||||
render(<Course {...mockData} />, { wrapWithRouter: true });
|
||||
expect(sessionStorage.getItem(`notificationTrayStatus.${mockData.courseId}`)).toBe('"open"');
|
||||
const notificationShowButton = await screen.findByRole('button', { name: /Show notification tray/i });
|
||||
fireEvent.click(notificationShowButton);
|
||||
@@ -244,7 +248,7 @@ describe('Course', () => {
|
||||
sequenceId,
|
||||
unitId: Object.values(models.units)[1].id, // Corner cases are already covered in `Sequence` tests.
|
||||
};
|
||||
render(<Course {...testData} />, { store: testStore });
|
||||
render(<Course {...testData} />, { store: testStore, wrapWithRouter: true });
|
||||
|
||||
loadUnit();
|
||||
await waitFor(() => expect(screen.queryByText('Loading learning sequence...')).not.toBeInTheDocument());
|
||||
@@ -276,7 +280,7 @@ describe('Course', () => {
|
||||
previousSequenceHandler,
|
||||
unitNavigationHandler,
|
||||
};
|
||||
render(<Course {...testData} />, { store: testStore });
|
||||
render(<Course {...testData} />, { store: testStore, wrapWithRouter: true });
|
||||
|
||||
loadUnit();
|
||||
await waitFor(() => expect(screen.queryByText('Loading learning sequence...')).not.toBeInTheDocument());
|
||||
@@ -305,7 +309,7 @@ describe('Course', () => {
|
||||
courseId: courseMetadata.id,
|
||||
sequenceId: sequenceBlocks[0].id,
|
||||
};
|
||||
render(<Course {...testData} />, { store: testStore });
|
||||
render(<Course {...testData} />, { store: testStore, wrapWithRouter: true });
|
||||
await waitFor(() => expect(screen.getByText('Some random banner text to display.')).toBeInTheDocument());
|
||||
});
|
||||
|
||||
@@ -339,7 +343,7 @@ describe('Course', () => {
|
||||
courseId: testCourseMetadata.id,
|
||||
sequenceId: sequenceBlocks[0].id,
|
||||
};
|
||||
render(<Course {...testData} />, { store: testStore });
|
||||
render(<Course {...testData} />, { store: testStore, wrapWithRouter: true });
|
||||
await waitFor(() => expect(screen.getByText('Your score is 100%. You have passed the entrance exam.')).toBeInTheDocument());
|
||||
});
|
||||
|
||||
@@ -373,7 +377,7 @@ describe('Course', () => {
|
||||
courseId: testCourseMetadata.id,
|
||||
sequenceId: sequenceBlocks[0].id,
|
||||
};
|
||||
render(<Course {...testData} />, { store: testStore });
|
||||
render(<Course {...testData} />, { store: testStore, wrapWithRouter: true });
|
||||
await waitFor(() => expect(screen.getByText('To access course materials, you must score 70% or higher on this exam. Your current score is 30%.')).toBeInTheDocument());
|
||||
});
|
||||
});
|
||||
|
||||
@@ -159,6 +159,7 @@ const CourseBreadcrumbs = ({
|
||||
<Link
|
||||
className="flex-shrink-0 text-primary"
|
||||
to={`/course/${courseId}/home`}
|
||||
replace
|
||||
>
|
||||
<FontAwesomeIcon icon={faHome} className="mr-2" />
|
||||
<FormattedMessage
|
||||
|
||||
@@ -26,6 +26,12 @@ 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') {
|
||||
|
||||
@@ -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,6 +17,8 @@ const JumpNavMenuItem = ({
|
||||
isDefault,
|
||||
onClick,
|
||||
}) => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
function logEvent(targetUrl) {
|
||||
const eventName = 'edx.ui.lms.jump_nav.selected';
|
||||
const payload = {
|
||||
@@ -38,7 +40,7 @@ const JumpNavMenuItem = ({
|
||||
function handleClick(e) {
|
||||
const url = destinationUrl();
|
||||
logEvent(url);
|
||||
history.push(url);
|
||||
navigate(url);
|
||||
if (onClick) { onClick(e); }
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
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';
|
||||
|
||||
@@ -26,9 +27,11 @@ const mockData = {
|
||||
};
|
||||
describe('JumpNavMenuItem', () => {
|
||||
render(
|
||||
<JumpNavMenuItem
|
||||
{...mockData}
|
||||
/>,
|
||||
<BrowserRouter>
|
||||
<JumpNavMenuItem
|
||||
{...mockData}
|
||||
/>
|
||||
</BrowserRouter>,
|
||||
);
|
||||
it('renders menu Item as expected with button and Text and handles clicks', () => {
|
||||
expect(screen.queryAllByRole('button')).toHaveLength(1);
|
||||
|
||||
@@ -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 { Redirect } from 'react-router-dom';
|
||||
import { Navigate } 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 (<Redirect to={`/course/${courseId}`} />);
|
||||
return (<Navigate to={`/course/${courseId}`} replace />);
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -51,7 +51,7 @@ describe('Course Exit Pages', () => {
|
||||
|
||||
async function fetchAndRender(component) {
|
||||
await executeThunk(fetchCourse(courseId), store.dispatch);
|
||||
render(component, { store });
|
||||
render(component, { store, wrapWithRouter: true });
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
|
||||
@@ -11,6 +11,10 @@ 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;
|
||||
@@ -42,7 +46,10 @@ 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 });
|
||||
render(
|
||||
<Sequence {...mockData} {...{ unitId: undefined, sequenceId: undefined }} />,
|
||||
{ store: testStore, wrapWithRouter: true },
|
||||
);
|
||||
|
||||
expect(screen.getByText('There is no content here.')).toBeInTheDocument();
|
||||
expect(screen.queryByRole('button')).not.toBeInTheDocument();
|
||||
@@ -71,7 +78,7 @@ describe('Sequence', () => {
|
||||
}, false);
|
||||
const { container } = render(
|
||||
<Sequence {...mockData} {...{ sequenceId: sequenceBlocks[0].id }} />,
|
||||
{ store: testStore },
|
||||
{ store: testStore, wrapWithRouter: true },
|
||||
);
|
||||
|
||||
await waitFor(() => expect(screen.queryByText('Loading locked content messaging...')).toBeInTheDocument());
|
||||
@@ -104,7 +111,7 @@ describe('Sequence', () => {
|
||||
}, false);
|
||||
render(
|
||||
<Sequence {...mockData} {...{ sequenceId: sequenceBlocks[0].id }} />,
|
||||
{ store: testStore },
|
||||
{ store: testStore, wrapWithRouter: true },
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -121,13 +128,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 });
|
||||
render(<Sequence {...mockData} />, { store: testStore, wrapWithRouter: true });
|
||||
|
||||
expect(screen.getByText('There was an error loading this course.')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('handles loading unit', async () => {
|
||||
render(<Sequence {...mockData} />);
|
||||
render(<Sequence {...mockData} />, { wrapWithRouter: true });
|
||||
expect(await screen.findByText('Loading learning sequence...')).toBeInTheDocument();
|
||||
// `Previous`, `Bookmark` and `Close Tray` buttons
|
||||
expect(screen.getAllByRole('button')).toHaveLength(3);
|
||||
@@ -167,7 +174,7 @@ describe('Sequence', () => {
|
||||
sequenceId: sequenceBlocks[1].id,
|
||||
previousSequenceHandler: jest.fn(),
|
||||
};
|
||||
render(<Sequence {...testData} />, { store: testStore });
|
||||
render(<Sequence {...testData} />, { store: testStore, wrapWithRouter: true });
|
||||
expect(await screen.findByText('Loading learning sequence...')).toBeInTheDocument();
|
||||
|
||||
const sequencePreviousButton = screen.getByRole('link', { name: /previous/i });
|
||||
@@ -203,7 +210,7 @@ describe('Sequence', () => {
|
||||
sequenceId: sequenceBlocks[0].id,
|
||||
nextSequenceHandler: jest.fn(),
|
||||
};
|
||||
render(<Sequence {...testData} />, { store: testStore });
|
||||
render(<Sequence {...testData} />, { store: testStore, wrapWithRouter: true });
|
||||
expect(await screen.findByText('Loading learning sequence...')).toBeInTheDocument();
|
||||
|
||||
const sequenceNextButton = screen.getByRole('link', { name: /next/i });
|
||||
@@ -241,7 +248,7 @@ describe('Sequence', () => {
|
||||
previousSequenceHandler: jest.fn(),
|
||||
nextSequenceHandler: jest.fn(),
|
||||
};
|
||||
render(<Sequence {...testData} />, { store: testStore });
|
||||
render(<Sequence {...testData} />, { store: testStore, wrapWithRouter: true });
|
||||
await waitFor(() => expect(screen.queryByText('Loading learning sequence...')).toBeInTheDocument());
|
||||
|
||||
fireEvent.click(screen.getByRole('link', { name: /previous/i }));
|
||||
@@ -265,7 +272,7 @@ describe('Sequence', () => {
|
||||
unitNavigationHandler: jest.fn(),
|
||||
previousSequenceHandler: jest.fn(),
|
||||
};
|
||||
render(<Sequence {...testData} />, { store: testStore });
|
||||
render(<Sequence {...testData} />, { store: testStore, wrapWithRouter: true });
|
||||
loadUnit();
|
||||
await waitFor(() => expect(screen.queryByText('Loading learning sequence...')).not.toBeInTheDocument());
|
||||
|
||||
@@ -284,7 +291,7 @@ describe('Sequence', () => {
|
||||
unitNavigationHandler: jest.fn(),
|
||||
nextSequenceHandler: jest.fn(),
|
||||
};
|
||||
render(<Sequence {...testData} />, { store: testStore });
|
||||
render(<Sequence {...testData} />, { store: testStore, wrapWithRouter: true });
|
||||
loadUnit();
|
||||
await waitFor(() => expect(screen.queryByText('Loading learning sequence...')).not.toBeInTheDocument());
|
||||
|
||||
@@ -326,7 +333,7 @@ describe('Sequence', () => {
|
||||
nextSequenceHandler: jest.fn(),
|
||||
};
|
||||
|
||||
render(<Sequence {...testData} />, { store: innerTestStore });
|
||||
render(<Sequence {...testData} />, { store: innerTestStore, wrapWithRouter: true });
|
||||
loadUnit();
|
||||
await waitFor(() => expect(screen.queryByText('Loading learning sequence...')).not.toBeInTheDocument());
|
||||
|
||||
@@ -374,7 +381,7 @@ describe('Sequence', () => {
|
||||
sequenceId: sequenceBlocks[0].id,
|
||||
unitNavigationHandler: jest.fn(),
|
||||
};
|
||||
render(<Sequence {...testData} />, { store: testStore });
|
||||
render(<Sequence {...testData} />, { store: testStore, wrapWithRouter: true });
|
||||
await waitFor(() => expect(screen.queryByText('Loading learning sequence...')).toBeInTheDocument());
|
||||
|
||||
fireEvent.click(screen.getByRole('link', { name: targetUnit.display_name }));
|
||||
@@ -401,13 +408,13 @@ describe('Sequence', () => {
|
||||
|
||||
describe('notification feature', () => {
|
||||
it('renders notification tray in sequence', async () => {
|
||||
render(<SidebarWrapper contextValue={{ courseId: mockData.courseId, currentSidebar: 'NOTIFICATIONS', toggleSidebar: () => null }} />);
|
||||
render(<SidebarWrapper contextValue={{ courseId: mockData.courseId, currentSidebar: 'NOTIFICATIONS', toggleSidebar: () => null }} />, { wrapWithRouter: true });
|
||||
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 }} />);
|
||||
render(<SidebarWrapper contextValue={{ courseId: mockData.courseId, currentSidebar: 'NOTIFICATIONS', toggleSidebar: toggleNotificationTray }} />, { wrapWithRouter: true });
|
||||
const notificationCloseIconButton = await screen.findByRole('button', { name: /Close notification tray/i });
|
||||
fireEvent.click(notificationCloseIconButton);
|
||||
expect(toggleNotificationTray).toHaveBeenCalledTimes(1);
|
||||
@@ -415,7 +422,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} />);
|
||||
const { container } = render(<Sequence {...mockData} />, { wrapWithRouter: true });
|
||||
// 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');
|
||||
});
|
||||
|
||||
@@ -19,13 +19,13 @@ describe('Sequence Content', () => {
|
||||
});
|
||||
|
||||
it('displays loading message', () => {
|
||||
render(<SequenceContent {...mockData} />);
|
||||
render(<SequenceContent {...mockData} />, { wrapWithRouter: true });
|
||||
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 />);
|
||||
const { container } = render(<SequenceContent {...mockData} gated />, { wrapWithRouter: true });
|
||||
|
||||
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} />);
|
||||
render(<SequenceContent {...mockData} unitId={null} />, { wrapWithRouter: true });
|
||||
expect(screen.getByText('There is no content here.')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -74,7 +74,7 @@ const ContentIFrame = ({
|
||||
<iframe title={title} {...contentIFrameProps} data-testid={testIDs.contentIFrame} />
|
||||
</div>
|
||||
)}
|
||||
{modalOptions.open && (
|
||||
{modalOptions.isOpen && (
|
||||
<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: '100vh' }}
|
||||
style={{ width: '100%', height: modalOptions.height }}
|
||||
/>
|
||||
)}
|
||||
dialogClassName="modal-lti"
|
||||
|
||||
@@ -29,16 +29,17 @@ const iframeBehavior = {
|
||||
|
||||
const modalOptions = {
|
||||
closed: {
|
||||
open: false,
|
||||
isOpen: false,
|
||||
},
|
||||
withBody: {
|
||||
body: 'test-body',
|
||||
open: true,
|
||||
isOpen: true,
|
||||
},
|
||||
withUrl: {
|
||||
open: true,
|
||||
isOpen: true,
|
||||
title: 'test-modal-title',
|
||||
url: 'test-modal-url',
|
||||
height: 'test-height',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -83,7 +84,7 @@ describe('ContentIFrame Component', () => {
|
||||
});
|
||||
describe('output', () => {
|
||||
let component;
|
||||
describe('shouldShowContent', () => {
|
||||
describe('if shouldShowContent', () => {
|
||||
describe('if not hasLoaded', () => {
|
||||
it('displays errorPage if showError', () => {
|
||||
hooks.useIFrameBehavior.mockReturnValueOnce({ ...iframeBehavior, showError: true });
|
||||
@@ -121,7 +122,7 @@ describe('ContentIFrame Component', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
describe('not shouldShowContent', () => {
|
||||
describe('if 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);
|
||||
@@ -129,13 +130,13 @@ describe('ContentIFrame Component', () => {
|
||||
expect(el.instance.findByTestId(testIDs.contentIFrame).length).toEqual(0);
|
||||
});
|
||||
});
|
||||
it('does not display modal if modalOptions returns open: false', () => {
|
||||
it('does not display modal if modalOptions returns isOpen: false', () => {
|
||||
el = shallow(<ContentIFrame {...props} />);
|
||||
expect(el.instance.findByType(Modal).length).toEqual(0);
|
||||
});
|
||||
describe('if modalOptions.open', () => {
|
||||
describe('if modalOptions.isOpen', () => {
|
||||
const testModalOpenAndHandleClose = () => {
|
||||
test('Modal component is open, with handleModalClose from hook', () => {
|
||||
test('Modal component isOpen, with handleModalClose from hook', () => {
|
||||
expect(component.props.onClose).toEqual(modalIFrameData.handleModalClose);
|
||||
});
|
||||
};
|
||||
@@ -164,7 +165,7 @@ describe('ContentIFrame Component', () => {
|
||||
allow={IFRAME_FEATURE_POLICY}
|
||||
frameBorder="0"
|
||||
src={modalOptions.withUrl.url}
|
||||
style={{ width: '100%', height: '100vh' }}
|
||||
style={{ width: '100%', height: modalOptions.withUrl.height }}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -5,28 +5,32 @@ import { StrictDict, useKeyedState } from '@edx/react-unit-test-utils/dist';
|
||||
import { useEventListener } from '../../../../../generic/hooks';
|
||||
|
||||
export const stateKeys = StrictDict({
|
||||
modalOptions: 'modalOptions',
|
||||
isOpen: 'isOpen',
|
||||
options: 'options',
|
||||
});
|
||||
|
||||
export const DEFAULT_HEIGHT = '100vh';
|
||||
|
||||
const useModalIFrameBehavior = () => {
|
||||
const [modalOptions, setModalOptions] = useKeyedState(stateKeys.modalOptions, ({ open: false }));
|
||||
const [isOpen, setIsOpen] = useKeyedState(stateKeys.isOpen, false);
|
||||
const [options, setOptions] = useKeyedState(stateKeys.options, { height: DEFAULT_HEIGHT });
|
||||
|
||||
const receiveMessage = React.useCallback(({ data }) => {
|
||||
const { type, payload } = data;
|
||||
if (type === 'plugin.modal') {
|
||||
payload.open = true;
|
||||
setModalOptions(payload);
|
||||
setOptions((current) => ({ ...current, ...payload }));
|
||||
setIsOpen(true);
|
||||
}
|
||||
}, []);
|
||||
useEventListener('message', receiveMessage);
|
||||
|
||||
const handleModalClose = () => {
|
||||
setModalOptions({ open: false });
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
return {
|
||||
handleModalClose,
|
||||
modalOptions,
|
||||
modalOptions: { isOpen, ...options },
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import { mockUseKeyedState } from '@edx/react-unit-test-utils';
|
||||
import { useEventListener } from '../../../../../generic/hooks';
|
||||
import { messageTypes } from '../constants';
|
||||
|
||||
import useModalIFrameBehavior, { stateKeys } from './useModalIFrameData';
|
||||
import useModalIFrameBehavior, { stateKeys, DEFAULT_HEIGHT } from './useModalIFrameData';
|
||||
|
||||
jest.mock('react', () => ({
|
||||
...jest.requireActual('react'),
|
||||
@@ -20,31 +20,49 @@ describe('useModalIFrameBehavior', () => {
|
||||
state.mock();
|
||||
});
|
||||
describe('behavior', () => {
|
||||
it('initializes modalOptions to closed', () => {
|
||||
it('initializes isOpen to false', () => {
|
||||
useModalIFrameBehavior();
|
||||
state.expectInitializedWith(stateKeys.modalOptions, { open: false });
|
||||
state.expectInitializedWith(stateKeys.isOpen, false);
|
||||
});
|
||||
it('initializes options with default height', () => {
|
||||
useModalIFrameBehavior();
|
||||
state.expectInitializedWith(stateKeys.options, { height: DEFAULT_HEIGHT });
|
||||
});
|
||||
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.modalOptions).toHaveBeenCalledWith({ ...payload, open: true });
|
||||
expect(state.setState.isOpen).toHaveBeenCalledWith(true);
|
||||
expect(state.setState.options).toHaveBeenCalled();
|
||||
const [[setOptionsCb]] = state.setState.options.mock.calls;
|
||||
expect(setOptionsCb(oldOptions)).toEqual({ ...oldOptions, ...payload });
|
||||
});
|
||||
});
|
||||
});
|
||||
describe('output', () => {
|
||||
test('handleModalClose sets modal options to closed', () => {
|
||||
useModalIFrameBehavior().handleModalClose();
|
||||
state.expectSetStateCalledWith(stateKeys.modalOptions, { open: false });
|
||||
state.expectSetStateCalledWith(stateKeys.isOpen, false);
|
||||
});
|
||||
it('forwards modalOptions from state value', () => {
|
||||
it('forwards modalOptions from state values', () => {
|
||||
const modalOptions = { test: 'options' };
|
||||
state.mockVal(stateKeys.modalOptions, modalOptions);
|
||||
expect(useModalIFrameBehavior().modalOptions).toEqual(modalOptions);
|
||||
state.mockVals({
|
||||
[stateKeys.options]: modalOptions,
|
||||
[stateKeys.isOpen]: true,
|
||||
});
|
||||
expect(useModalIFrameBehavior().modalOptions).toEqual({
|
||||
...modalOptions,
|
||||
isOpen: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,8 +11,9 @@ import messages from './messages';
|
||||
const ContentLock = ({
|
||||
intl, courseId, prereqSectionName, prereqId, sequenceTitle,
|
||||
}) => {
|
||||
const navigate = useNavigate();
|
||||
const handleClick = useCallback(() => {
|
||||
history.push(`/course/${courseId}/${prereqId}`);
|
||||
navigate(`/course/${courseId}/${prereqId}`);
|
||||
}, [courseId, prereqId]);
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
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',
|
||||
@@ -19,7 +25,7 @@ describe('Content Lock', () => {
|
||||
});
|
||||
|
||||
it('displays sequence title along with lock icon', () => {
|
||||
const { container } = render(<ContentLock {...mockData} />);
|
||||
const { container } = render(<ContentLock {...mockData} />, { wrapWithRouter: true });
|
||||
|
||||
const lockIcon = container.querySelector('svg');
|
||||
expect(lockIcon).toHaveClass('fa-lock');
|
||||
@@ -28,16 +34,15 @@ describe('Content Lock', () => {
|
||||
|
||||
it('displays prerequisite name', () => {
|
||||
const prereqText = `You must complete the prerequisite: '${mockData.prereqSectionName}' to access this content.`;
|
||||
render(<ContentLock {...mockData} />);
|
||||
render(<ContentLock {...mockData} />, { wrapWithRouter: true });
|
||||
|
||||
expect(screen.getByText(prereqText)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('handles click', () => {
|
||||
history.push = jest.fn();
|
||||
render(<ContentLock {...mockData} />);
|
||||
render(<ContentLock {...mockData} />, { wrapWithRouter: true });
|
||||
fireEvent.click(screen.getByRole('button'));
|
||||
|
||||
expect(history.push).toHaveBeenCalledWith(`/course/${mockData.courseId}/${mockData.prereqId}`);
|
||||
expect(mockNavigate).toHaveBeenCalledWith(`/course/${mockData.courseId}/${mockData.prereqId}`);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { getConfig, history } from '@edx/frontend-platform';
|
||||
import { getConfig } 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,
|
||||
@@ -20,7 +22,7 @@ const HonorCode = ({ intl, courseId }) => {
|
||||
const siteName = getConfig().SITE_NAME;
|
||||
const honorCodeUrl = `${getConfig().TERMS_OF_SERVICE_URL}#honor-code`;
|
||||
|
||||
const handleCancel = () => history.push(`/course/${courseId}/home`);
|
||||
const handleCancel = () => navigate(`/course/${courseId}/home`);
|
||||
|
||||
const handleAgree = () => dispatch(
|
||||
// If the request is made by a staff user masquerading as a specific learner,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import { getConfig, history } from '@edx/frontend-platform';
|
||||
import { getConfig } 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('@edx/frontend-platform', () => ({
|
||||
...jest.requireActual('@edx/frontend-platform'),
|
||||
history: {
|
||||
push: jest.fn(),
|
||||
},
|
||||
jest.mock('react-router-dom', () => ({
|
||||
...jest.requireActual('react-router-dom'),
|
||||
useNavigate: () => mockNavigate,
|
||||
}));
|
||||
|
||||
describe('Honor Code', () => {
|
||||
@@ -38,15 +38,15 @@ describe('Honor Code', () => {
|
||||
|
||||
it('cancel button links to course home ', async () => {
|
||||
await setupStoreState();
|
||||
render(<HonorCode {...mockData} />);
|
||||
render(<HonorCode {...mockData} />, { wrapWithRouter: true });
|
||||
const cancelButton = screen.getByText('Cancel');
|
||||
fireEvent.click(cancelButton);
|
||||
expect(history.push).toHaveBeenCalledWith(`/course/${mockData.courseId}/home`);
|
||||
expect(mockNavigate).toHaveBeenCalledWith(`/course/${mockData.courseId}/home`);
|
||||
});
|
||||
|
||||
it('calls to save integrity_signature when agreeing', async () => {
|
||||
await setupStoreState({ username: authenticatedUser.username });
|
||||
render(<HonorCode {...mockData} />);
|
||||
render(<HonorCode {...mockData} />, { wrapWithRouter: true });
|
||||
const agreeButton = screen.getByText('I agree');
|
||||
fireEvent.click(agreeButton);
|
||||
await waitFor(() => {
|
||||
@@ -63,7 +63,7 @@ describe('Honor Code', () => {
|
||||
username: authenticatedUser.username,
|
||||
},
|
||||
);
|
||||
render(<HonorCode {...mockData} />);
|
||||
render(<HonorCode {...mockData} />, { wrapWithRouter: true });
|
||||
const agreeButton = screen.getByText('I agree');
|
||||
fireEvent.click(agreeButton);
|
||||
await waitFor(() => {
|
||||
@@ -80,7 +80,7 @@ describe('Honor Code', () => {
|
||||
username: 'otheruser',
|
||||
},
|
||||
);
|
||||
render(<HonorCode {...mockData} />);
|
||||
render(<HonorCode {...mockData} />, { wrapWithRouter: true });
|
||||
const agreeButton = screen.getByText('I agree');
|
||||
fireEvent.click(agreeButton);
|
||||
await waitFor(() => {
|
||||
|
||||
@@ -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 });
|
||||
const { container } = render(<SequenceNavigation {...mockData} />, { store: testStore, wrapWithRouter: true });
|
||||
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it('renders empty div without unitId', () => {
|
||||
const { container } = render(<SequenceNavigation {...mockData} unitId={undefined} />);
|
||||
const { container } = render(<SequenceNavigation {...mockData} unitId={undefined} />, { wrapWithRouter: true });
|
||||
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 });
|
||||
render(<SequenceNavigation {...testData} />, { store: testStore, wrapWithRouter: true });
|
||||
|
||||
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 }} />);
|
||||
render(<SequenceNavigation {...mockData} {...{ onNavigate }} />, { wrapWithRouter: true });
|
||||
|
||||
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} />);
|
||||
render(<SequenceNavigation {...mockData} />, { wrapWithRouter: true });
|
||||
|
||||
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} />);
|
||||
render(<SequenceNavigation {...mockData} unitId={unitBlocks[0].id} />, { wrapWithRouter: true });
|
||||
|
||||
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 },
|
||||
{ store: testStore, wrapWithRouter: true },
|
||||
);
|
||||
|
||||
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 },
|
||||
{ store: testStore, wrapWithRouter: true },
|
||||
);
|
||||
|
||||
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 },
|
||||
{ store: testStore, wrapWithRouter: true },
|
||||
);
|
||||
|
||||
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 }} />);
|
||||
render(<SequenceNavigation {...mockData} {...{ previousHandler, nextHandler }} />, { wrapWithRouter: true });
|
||||
|
||||
fireEvent.click(screen.getByRole('link', { name: /previous/i }));
|
||||
expect(previousHandler).toHaveBeenCalledTimes(1);
|
||||
|
||||
@@ -40,7 +40,10 @@ describe('Sequence Navigation Dropdown', () => {
|
||||
|
||||
unitBlocks.forEach((unit, index) => {
|
||||
it(`marks unit ${index + 1} as active`, async () => {
|
||||
const { container } = render(<SequenceNavigationDropdown {...mockData} unitId={unit.id} />);
|
||||
const { container } = render(
|
||||
<SequenceNavigationDropdown {...mockData} unitId={unit.id} />,
|
||||
{ wrapWithRouter: true },
|
||||
);
|
||||
const dropdownToggle = container.querySelector('.dropdown-toggle');
|
||||
await act(async () => {
|
||||
await fireEvent.click(dropdownToggle);
|
||||
@@ -59,7 +62,10 @@ describe('Sequence Navigation Dropdown', () => {
|
||||
|
||||
it('handles the clicks', () => {
|
||||
const onNavigate = jest.fn();
|
||||
const { container } = render(<SequenceNavigationDropdown {...mockData} onNavigate={onNavigate} />);
|
||||
const { container } = render(
|
||||
<SequenceNavigationDropdown {...mockData} onNavigate={onNavigate} />,
|
||||
{ wrapWithRouter: true },
|
||||
);
|
||||
|
||||
const dropdownToggle = container.querySelector('.dropdown-toggle');
|
||||
act(() => {
|
||||
|
||||
@@ -41,7 +41,7 @@ describe('Sequence Navigation Tabs', () => {
|
||||
|
||||
it('renders unit buttons', () => {
|
||||
useIndexOfLastVisibleChild.mockReturnValue([0, null, null]);
|
||||
render(<SequenceNavigationTabs {...mockData} />);
|
||||
render(<SequenceNavigationTabs {...mockData} />, { wrapWithRouter: true });
|
||||
|
||||
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} />);
|
||||
const booyah = render(<SequenceNavigationTabs {...mockData} />, { wrapWithRouter: true });
|
||||
container = booyah.container;
|
||||
|
||||
const dropdownToggle = container.querySelector('.dropdown-toggle');
|
||||
|
||||
@@ -32,12 +32,12 @@ describe('Unit Button', () => {
|
||||
});
|
||||
|
||||
it('hides title by default', () => {
|
||||
render(<UnitButton {...mockData} />);
|
||||
render(<UnitButton {...mockData} />, { wrapWithRouter: true });
|
||||
expect(screen.getByRole('link')).not.toHaveTextContent(unit.display_name);
|
||||
});
|
||||
|
||||
it('shows title', () => {
|
||||
render(<UnitButton {...mockData} showTitle />);
|
||||
render(<UnitButton {...mockData} showTitle />, { wrapWithRouter: true });
|
||||
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} />);
|
||||
const { container } = render(<UnitButton {...mockData} unitId={completedUnit.id} />, { wrapWithRouter: true });
|
||||
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} />);
|
||||
const { container } = render(<UnitButton {...mockData} unitId={bookmarkedUnit.id} />, { wrapWithRouter: true });
|
||||
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} />);
|
||||
render(<UnitButton {...mockData} onClick={onClick} />, { wrapWithRouter: true });
|
||||
fireEvent.click(screen.getByRole('link'));
|
||||
expect(onClick).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
@@ -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} />);
|
||||
render(<UnitNavigation {...mockData} />, { wrapWithRouter: true });
|
||||
|
||||
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} />);
|
||||
render(<UnitNavigation {...mockData} unitId={unitBlocks[0].id} />, { wrapWithRouter: true });
|
||||
|
||||
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 },
|
||||
{ store: testStore, wrapWithRouter: true },
|
||||
);
|
||||
|
||||
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 },
|
||||
{ store: testStore, wrapWithRouter: true },
|
||||
);
|
||||
|
||||
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 },
|
||||
{ store: testStore, wrapWithRouter: true },
|
||||
);
|
||||
|
||||
expect(screen.getByRole('link', { name: /previous/i })).toBeEnabled();
|
||||
|
||||
22
src/courseware/utils.jsx
Normal file
22
src/courseware/utils.jsx
Normal file
@@ -0,0 +1,22 @@
|
||||
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;
|
||||
@@ -2,15 +2,16 @@
|
||||
|
||||
exports[`DecodePageRoute should not modify the url if it does not need to be decoded 1`] = `
|
||||
<div>
|
||||
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"
|
||||
}
|
||||
}
|
||||
PageWrap: {
|
||||
"children": [
|
||||
" ",
|
||||
[
|
||||
" ",
|
||||
[],
|
||||
" "
|
||||
],
|
||||
" "
|
||||
]
|
||||
}
|
||||
</div>
|
||||
`;
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import { PageRoute } from '@edx/frontend-platform/react';
|
||||
import { PageWrap } from '@edx/frontend-platform/react';
|
||||
import React from 'react';
|
||||
import { useHistory, generatePath } from 'react-router';
|
||||
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])),
|
||||
);
|
||||
|
||||
export const decodeUrl = (encodedUrl) => {
|
||||
const decodedUrl = decodeURIComponent(encodedUrl);
|
||||
@@ -11,10 +19,16 @@ export const decodeUrl = (encodedUrl) => {
|
||||
return decodeUrl(decodedUrl);
|
||||
};
|
||||
|
||||
const DecodePageRoute = (props) => {
|
||||
const history = useHistory();
|
||||
if (props.computedMatch) {
|
||||
const { url, path, params } = props.computedMatch;
|
||||
const DecodePageRoute = ({ children }) => {
|
||||
let computedMatch = null;
|
||||
|
||||
ROUTES.forEach((route) => {
|
||||
const matchedRoute = useMatch(route);
|
||||
if (matchedRoute) { computedMatch = matchedRoute; }
|
||||
});
|
||||
|
||||
if (computedMatch) {
|
||||
const { pathname, pattern, params } = computedMatch;
|
||||
|
||||
Object.keys(params).forEach((param) => {
|
||||
// only decode params not the entire url.
|
||||
@@ -22,28 +36,19 @@ const DecodePageRoute = (props) => {
|
||||
params[param] = decodeUrl(params[param]);
|
||||
});
|
||||
|
||||
const newUrl = generatePath(path, params);
|
||||
const newUrl = generatePath(pattern.path, params);
|
||||
|
||||
// if the url get decoded, reroute to the decoded url
|
||||
if (newUrl !== url) {
|
||||
history.replace(newUrl);
|
||||
if (newUrl !== pathname) {
|
||||
return <Navigate to={newUrl} replace />;
|
||||
}
|
||||
}
|
||||
|
||||
return <PageRoute {...props} />;
|
||||
return <PageWrap> {children} </PageWrap>;
|
||||
};
|
||||
|
||||
DecodePageRoute.propTypes = {
|
||||
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,
|
||||
children: PropTypes.node.isRequired,
|
||||
};
|
||||
|
||||
export default DecodePageRoute;
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import React from 'react';
|
||||
import { render } from '@testing-library/react';
|
||||
import { createMemoryHistory } from 'history';
|
||||
import { Router, matchPath } from 'react-router';
|
||||
import {
|
||||
MemoryRouter as Router, matchPath, Routes, Route, mockNavigate,
|
||||
} from 'react-router-dom';
|
||||
import DecodePageRoute, { decodeUrl } from '.';
|
||||
|
||||
const decodedCourseId = 'course-v1:edX+DemoX+Demo_Course';
|
||||
@@ -15,84 +16,90 @@ const deepEncodedCourseId = (() => {
|
||||
})();
|
||||
|
||||
jest.mock('@edx/frontend-platform/react', () => ({
|
||||
PageRoute: (props) => `PageRoute: ${JSON.stringify(props, null, 2)}`,
|
||||
PageWrap: (props) => `PageWrap: ${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`,
|
||||
},
|
||||
}));
|
||||
|
||||
const renderPage = (props) => {
|
||||
const memHistory = createMemoryHistory({
|
||||
initialEntries: [props?.path],
|
||||
});
|
||||
jest.mock('react-router-dom', () => {
|
||||
const mockNavigation = jest.fn();
|
||||
|
||||
const history = {
|
||||
...memHistory,
|
||||
replace: 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 { container } = render(
|
||||
<Router history={history}>
|
||||
<DecodePageRoute computedMatch={props} />
|
||||
<Router initialEntries={[props?.pathname]}>
|
||||
<Routes>
|
||||
<Route path={props?.pattern?.path} element={<DecodePageRoute> {[]} </DecodePageRoute>} />
|
||||
</Routes>
|
||||
</Router>,
|
||||
);
|
||||
|
||||
return {
|
||||
container,
|
||||
history,
|
||||
props,
|
||||
};
|
||||
return { container };
|
||||
};
|
||||
|
||||
describe('DecodePageRoute', () => {
|
||||
it('should not modify the url if it does not need to be decoded', () => {
|
||||
const props = matchPath(`/course/${decodedCourseId}/home`, {
|
||||
path: '/course/:courseId/home',
|
||||
});
|
||||
const { container, history } = renderPage(props);
|
||||
afterEach(() => {
|
||||
mockNavigate.mockClear();
|
||||
});
|
||||
|
||||
expect(props.url).toContain(decodedCourseId);
|
||||
expect(history.replace).not.toHaveBeenCalled();
|
||||
it('should not modify the url if it does not need to be decoded', () => {
|
||||
const props = matchPath({
|
||||
path: '/course/:courseId/home',
|
||||
}, `/course/${decodedCourseId}/home`);
|
||||
const { container } = renderPage(props);
|
||||
|
||||
expect(props.pathname).toContain(decodedCourseId);
|
||||
expect(mockNavigate).not.toHaveBeenCalled();
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('should decode the url and replace the history if necessary', () => {
|
||||
const props = matchPath(`/course/${encodedCourseId}/home`, {
|
||||
const props = matchPath({
|
||||
path: '/course/:courseId/home',
|
||||
});
|
||||
const { history } = renderPage(props);
|
||||
}, `/course/${encodedCourseId}/home`);
|
||||
renderPage(props);
|
||||
|
||||
expect(props.url).not.toContain(decodedCourseId);
|
||||
expect(props.url).toContain(encodedCourseId);
|
||||
expect(history.replace.mock.calls[0][0]).toContain(decodedCourseId);
|
||||
expect(props.pathname).not.toContain(decodedCourseId);
|
||||
expect(props.pathname).toContain(encodedCourseId);
|
||||
expect(mockNavigate).toHaveBeenCalledWith(`/course/${decodedCourseId}/home`);
|
||||
});
|
||||
|
||||
it('should decode the url multiple times if necessary', () => {
|
||||
const props = matchPath(`/course/${deepEncodedCourseId}/home`, {
|
||||
const props = matchPath({
|
||||
path: '/course/:courseId/home',
|
||||
});
|
||||
const { history } = renderPage(props);
|
||||
}, `/course/${deepEncodedCourseId}/home`);
|
||||
renderPage(props);
|
||||
|
||||
expect(props.url).not.toContain(decodedCourseId);
|
||||
expect(props.url).toContain(deepEncodedCourseId);
|
||||
expect(history.replace.mock.calls[0][0]).toContain(decodedCourseId);
|
||||
expect(props.pathname).not.toContain(decodedCourseId);
|
||||
expect(props.pathname).toContain(deepEncodedCourseId);
|
||||
expect(mockNavigate).toHaveBeenCalledWith(`/course/${decodedCourseId}/home`);
|
||||
});
|
||||
|
||||
it('should only decode the url params and not the entire url', () => {
|
||||
const decodedUnitId = 'some+thing';
|
||||
const encodedUnitId = encodeURIComponent(decodedUnitId);
|
||||
const props = matchPath(`/course/${deepEncodedCourseId}/${encodedUnitId}/${encodedUnitId}`, {
|
||||
const props = matchPath({
|
||||
path: `/course/:courseId/${encodedUnitId}/:unitId`,
|
||||
});
|
||||
const { history } = renderPage(props);
|
||||
}, `/course/${deepEncodedCourseId}/${encodedUnitId}/${encodedUnitId}`);
|
||||
renderPage(props);
|
||||
|
||||
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);
|
||||
expect(mockNavigate).toHaveBeenCalledWith(`/course/${decodedCourseId}/${encodedUnitId}/${decodedUnitId}`);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { LearningHeader as Header } from '@edx/frontend-component-header';
|
||||
import Footer from '@edx/frontend-component-footer';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { useParams, Navigate } 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';
|
||||
@@ -38,7 +37,7 @@ const CourseAccessErrorPage = ({ intl }) => {
|
||||
);
|
||||
}
|
||||
if (courseStatus === LOADED) {
|
||||
return (<Redirect to={`/redirect/home/${courseId}`} />);
|
||||
return <Navigate to={`/redirect/home/${courseId}`} replace />;
|
||||
}
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import React from 'react';
|
||||
import { history } from '@edx/frontend-platform';
|
||||
import { Route } from 'react-router';
|
||||
import { Routes, Route } from 'react-router-dom';
|
||||
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,
|
||||
@@ -14,6 +16,10 @@ 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;
|
||||
@@ -28,33 +34,36 @@ describe('CourseAccessErrorPage', () => {
|
||||
it('Displays loading in start on page rendering', () => {
|
||||
mockCourseStatus = 'loading';
|
||||
render(
|
||||
<Route path="/course/:courseId/access-denied">
|
||||
<CourseAccessErrorPage />
|
||||
</Route>,
|
||||
<Routes>
|
||||
<Route path="/course/:courseId/access-denied" element={<CourseAccessErrorPage />} />
|
||||
</Routes>,
|
||||
{ wrapWithRouter: true },
|
||||
);
|
||||
expect(screen.getByTestId('page-loading')).toBeInTheDocument();
|
||||
expect(history.location.pathname).toBe(accessDeniedUrl);
|
||||
expect(window.location.pathname).toBe(accessDeniedUrl);
|
||||
});
|
||||
|
||||
it('Redirect user to homepage if user has access', () => {
|
||||
mockCourseStatus = 'loaded';
|
||||
render(
|
||||
<Route path="/course/:courseId/access-denied">
|
||||
<CourseAccessErrorPage />
|
||||
</Route>,
|
||||
<Routes>
|
||||
<Route path="/course/:courseId/access-denied" element={<CourseAccessErrorPage />} />
|
||||
</Routes>,
|
||||
{ wrapWithRouter: true },
|
||||
);
|
||||
expect(history.location.pathname).toBe('/redirect/home/course-v1:edX+DemoX+Demo_Course');
|
||||
expect(window.location.pathname).toBe('/redirect/home/course-v1:edX+DemoX+Demo_Course');
|
||||
});
|
||||
|
||||
it('For access denied it should render access denied page', () => {
|
||||
mockCourseStatus = 'denied';
|
||||
|
||||
render(
|
||||
<Route path="/course/:courseId/access-denied">
|
||||
<CourseAccessErrorPage />
|
||||
</Route>,
|
||||
<Routes>
|
||||
<Route path="/course/:courseId/access-denied" element={<CourseAccessErrorPage />} />
|
||||
</Routes>,
|
||||
{ wrapWithRouter: true },
|
||||
);
|
||||
expect(screen.getByTestId('access-denied-main')).toBeInTheDocument();
|
||||
expect(history.location.pathname).toBe(accessDeniedUrl);
|
||||
expect(window.location.pathname).toBe(accessDeniedUrl);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Redirect, useLocation } from 'react-router-dom';
|
||||
import { Navigate, 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(' ')) {
|
||||
if (location.pathname.includes(' ') || location.pathname.includes('%20')) {
|
||||
const newLocation = {
|
||||
...location,
|
||||
pathname: location.pathname.replaceAll(' ', '+'),
|
||||
pathname: (location.pathname.replaceAll(' ', '+')).replaceAll('%20', '+'),
|
||||
};
|
||||
|
||||
sendTrackEvent('edx.ui.lms.path_fixed', {
|
||||
@@ -29,7 +29,7 @@ const PathFixesProvider = ({ children }) => {
|
||||
search: location.search,
|
||||
});
|
||||
|
||||
return (<Redirect to={newLocation} />);
|
||||
return (<Navigate to={newLocation} replace />);
|
||||
}
|
||||
|
||||
return children; // pass through
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import React from 'react';
|
||||
import { MemoryRouter, Route } from 'react-router-dom';
|
||||
import {
|
||||
MemoryRouter, Route, Routes, useLocation,
|
||||
} from 'react-router-dom';
|
||||
|
||||
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
|
||||
|
||||
@@ -19,16 +21,20 @@ describe('PathFixesProvider', () => {
|
||||
});
|
||||
|
||||
function buildAndRender(path) {
|
||||
const LocationComponent = () => {
|
||||
testLocation = useLocation();
|
||||
return null;
|
||||
};
|
||||
|
||||
render(
|
||||
<MemoryRouter initialEntries={[path]}>
|
||||
<PathFixesProvider>
|
||||
<Route
|
||||
path="*"
|
||||
render={routeProps => {
|
||||
testLocation = routeProps.location;
|
||||
return null;
|
||||
}}
|
||||
/>
|
||||
<Routes>
|
||||
<Route
|
||||
path="*"
|
||||
element={<LocationComponent />}
|
||||
/>
|
||||
</Routes>
|
||||
</PathFixesProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
137
src/index.jsx
137
src/index.jsx
@@ -6,10 +6,10 @@ import {
|
||||
mergeConfig,
|
||||
getConfig,
|
||||
} from '@edx/frontend-platform';
|
||||
import { AppProvider, ErrorPage, PageRoute } from '@edx/frontend-platform/react';
|
||||
import { AppProvider, ErrorPage, PageWrap } from '@edx/frontend-platform/react';
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import { Switch } from 'react-router-dom';
|
||||
import { Routes, Route } from 'react-router-dom';
|
||||
|
||||
import { Helmet } from 'react-helmet';
|
||||
import { fetchDiscussionTab, fetchLiveTab } from './course-home/data/thunks';
|
||||
@@ -36,6 +36,7 @@ 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(
|
||||
@@ -46,59 +47,91 @@ subscribe(APP_READY, () => {
|
||||
<PathFixesProvider>
|
||||
<NoticesProvider>
|
||||
<UserMessagesProvider>
|
||||
<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>
|
||||
<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>
|
||||
)}
|
||||
/>
|
||||
<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.DATES}
|
||||
element={(
|
||||
<DecodePageRoute>
|
||||
<TabContainer tab="dates" fetch={fetchDatesTab} slice="courseHome">
|
||||
<DatesTab />
|
||||
</TabContainer>
|
||||
</DecodePageRoute>
|
||||
)}
|
||||
/>
|
||||
</Switch>
|
||||
<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>
|
||||
</UserMessagesProvider>
|
||||
</NoticesProvider>
|
||||
</PathFixesProvider>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* @jest-environment jsdom
|
||||
*/
|
||||
import React from 'react';
|
||||
import { Route, Switch } from 'react-router';
|
||||
import { Route, Routes } from 'react-router-dom';
|
||||
import { Factory } from 'rosie';
|
||||
import { getConfig, history } from '@edx/frontend-platform';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
@@ -26,6 +26,7 @@ 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');
|
||||
@@ -62,7 +63,7 @@ describe('Course Home Tours', () => {
|
||||
<LoadedTabPage courseId={courseId} activeTabSlug="outline">
|
||||
<OutlineTab />
|
||||
</LoadedTabPage>,
|
||||
{ store },
|
||||
{ store, wrapWithRouter: true },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -213,16 +214,14 @@ describe('Courseware Tour', () => {
|
||||
component = (
|
||||
<AppProvider store={store}>
|
||||
<UserMessagesProvider>
|
||||
<Switch>
|
||||
<Route
|
||||
path={[
|
||||
'/course/:courseId/:sequenceId/:unitId',
|
||||
'/course/:courseId/:sequenceId',
|
||||
'/course/:courseId',
|
||||
]}
|
||||
component={CoursewareContainer}
|
||||
/>
|
||||
</Switch>
|
||||
<Routes>
|
||||
{DECODE_ROUTES.COURSEWARE.map((route) => (
|
||||
<Route
|
||||
path={route}
|
||||
element={<CoursewareContainer />}
|
||||
/>
|
||||
))}
|
||||
</Routes>
|
||||
</UserMessagesProvider>
|
||||
</AppProvider>
|
||||
);
|
||||
|
||||
@@ -169,13 +169,14 @@ 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}>
|
||||
<AppProvider store={store || globalStore} wrapWithRouter={wrapWithRouter}>
|
||||
<UserMessagesProvider>
|
||||
{children}
|
||||
</UserMessagesProvider>
|
||||
|
||||
@@ -12,15 +12,21 @@ const TabContainer = (props) => {
|
||||
fetch,
|
||||
slice,
|
||||
tab,
|
||||
isProgressTab,
|
||||
} = props;
|
||||
|
||||
const { courseId: courseIdFromUrl } = useParams();
|
||||
const { courseId: courseIdFromUrl, targetUserId } = useParams();
|
||||
const dispatch = useDispatch();
|
||||
|
||||
useEffect(() => {
|
||||
// The courseId from the URL is the course we WANT to load.
|
||||
dispatch(fetch(courseIdFromUrl));
|
||||
if (isProgressTab) {
|
||||
dispatch(fetch(courseIdFromUrl, targetUserId));
|
||||
} else {
|
||||
dispatch(fetch(courseIdFromUrl));
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [courseIdFromUrl]);
|
||||
}, [courseIdFromUrl, targetUserId]);
|
||||
|
||||
// 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.
|
||||
@@ -47,6 +53,11 @@ TabContainer.propTypes = {
|
||||
fetch: PropTypes.func.isRequired,
|
||||
slice: PropTypes.string.isRequired,
|
||||
tab: PropTypes.string.isRequired,
|
||||
isProgressTab: PropTypes.bool,
|
||||
};
|
||||
|
||||
TabContainer.defaultProps = {
|
||||
isProgressTab: false,
|
||||
};
|
||||
|
||||
export default TabContainer;
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import React from 'react';
|
||||
import { history } from '@edx/frontend-platform';
|
||||
import { Route } from 'react-router';
|
||||
import { Route, Routes, MemoryRouter } from 'react-router-dom';
|
||||
import { initializeTestStore, render, screen } from '../setupTest';
|
||||
import { TabContainer } from './index';
|
||||
|
||||
@@ -31,13 +30,19 @@ describe('Tab Container', () => {
|
||||
});
|
||||
|
||||
it('renders correctly', () => {
|
||||
history.push(`/course/${courseId}`);
|
||||
render(
|
||||
<Route path="/course/:courseId">
|
||||
<TabContainer {...mockData}>
|
||||
children={[]}
|
||||
</TabContainer>
|
||||
</Route>,
|
||||
<MemoryRouter initialEntries={[`/course/${courseId}`]}>
|
||||
<Routes>
|
||||
<Route
|
||||
path="/course/:courseId"
|
||||
element={(
|
||||
<TabContainer {...mockData}>
|
||||
children={[]}
|
||||
</TabContainer>
|
||||
)}
|
||||
/>
|
||||
</Routes>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1);
|
||||
@@ -49,22 +54,25 @@ describe('Tab Container', () => {
|
||||
|
||||
it('Should handle passing in a targetUserId', () => {
|
||||
const targetUserId = '1';
|
||||
history.push(`/course/${courseId}/progress/${targetUserId}/`);
|
||||
|
||||
render(
|
||||
<Route
|
||||
path="/course/:courseId/progress/:targetUserId/"
|
||||
render={({ match }) => (
|
||||
<TabContainer
|
||||
fetch={() => mockFetch(match.params.courseId, match.params.targetUserId)}
|
||||
tab="dummy"
|
||||
slice="courseHome"
|
||||
>
|
||||
children={[]}
|
||||
</TabContainer>
|
||||
|
||||
)}
|
||||
/>,
|
||||
<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>,
|
||||
);
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1);
|
||||
|
||||
@@ -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 { Redirect } from 'react-router';
|
||||
import { Navigate } from 'react-router-dom';
|
||||
|
||||
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 (<Redirect to={redirectUrl} />);
|
||||
return (<Navigate to={redirectUrl} replace />);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user