null }} />, { wrapWithRouter: true });
expect(await screen.findByText('Notifications')).toBeInTheDocument();
});
it('handles click on notification tray close button', async () => {
const toggleNotificationTray = jest.fn();
- render();
+ render(, { 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();
+ const { container } = render(, { 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');
});
diff --git a/src/courseware/course/sequence/SequenceContent.test.jsx b/src/courseware/course/sequence/SequenceContent.test.jsx
index 99a3b078b1..a2f14490d3 100644
--- a/src/courseware/course/sequence/SequenceContent.test.jsx
+++ b/src/courseware/course/sequence/SequenceContent.test.jsx
@@ -19,13 +19,13 @@ describe('Sequence Content', () => {
});
it('displays loading message', () => {
- render();
+ render(, { 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();
+ const { container } = render(, { 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();
+ render(, { wrapWithRouter: true });
expect(screen.getByText('There is no content here.')).toBeInTheDocument();
});
});
diff --git a/src/courseware/course/sequence/content-lock/ContentLock.jsx b/src/courseware/course/sequence/content-lock/ContentLock.jsx
index 26393fec0c..319dcdb70a 100644
--- a/src/courseware/course/sequence/content-lock/ContentLock.jsx
+++ b/src/courseware/course/sequence/content-lock/ContentLock.jsx
@@ -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 (
diff --git a/src/courseware/course/sequence/content-lock/ContentLock.test.jsx b/src/courseware/course/sequence/content-lock/ContentLock.test.jsx
index 500b507866..c2ab9d3dac 100644
--- a/src/courseware/course/sequence/content-lock/ContentLock.test.jsx
+++ b/src/courseware/course/sequence/content-lock/ContentLock.test.jsx
@@ -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();
+ const { container } = render(, { 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();
+ render(, { wrapWithRouter: true });
expect(screen.getByText(prereqText)).toBeInTheDocument();
});
it('handles click', () => {
- history.push = jest.fn();
- render();
+ render(, { wrapWithRouter: true });
fireEvent.click(screen.getByRole('button'));
- expect(history.push).toHaveBeenCalledWith(`/course/${mockData.courseId}/${mockData.prereqId}`);
+ expect(mockNavigate).toHaveBeenCalledWith(`/course/${mockData.courseId}/${mockData.prereqId}`);
});
});
diff --git a/src/courseware/course/sequence/honor-code/HonorCode.jsx b/src/courseware/course/sequence/honor-code/HonorCode.jsx
index 11e7d59468..d1b7d024c0 100644
--- a/src/courseware/course/sequence/honor-code/HonorCode.jsx
+++ b/src/courseware/course/sequence/honor-code/HonorCode.jsx
@@ -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,
diff --git a/src/courseware/course/sequence/honor-code/HonorCode.test.jsx b/src/courseware/course/sequence/honor-code/HonorCode.test.jsx
index c0cf779901..d0c38bde4d 100644
--- a/src/courseware/course/sequence/honor-code/HonorCode.test.jsx
+++ b/src/courseware/course/sequence/honor-code/HonorCode.test.jsx
@@ -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();
+ render(, { 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();
+ render(, { wrapWithRouter: true });
const agreeButton = screen.getByText('I agree');
fireEvent.click(agreeButton);
await waitFor(() => {
@@ -63,7 +63,7 @@ describe('Honor Code', () => {
username: authenticatedUser.username,
},
);
- render();
+ render(, { wrapWithRouter: true });
const agreeButton = screen.getByText('I agree');
fireEvent.click(agreeButton);
await waitFor(() => {
@@ -80,7 +80,7 @@ describe('Honor Code', () => {
username: 'otheruser',
},
);
- render();
+ render(, { wrapWithRouter: true });
const agreeButton = screen.getByText('I agree');
fireEvent.click(agreeButton);
await waitFor(() => {
diff --git a/src/courseware/course/sequence/sequence-navigation/SequenceNavigation.test.jsx b/src/courseware/course/sequence/sequence-navigation/SequenceNavigation.test.jsx
index 1864bde0c0..aa0b157e2d 100644
--- a/src/courseware/course/sequence/sequence-navigation/SequenceNavigation.test.jsx
+++ b/src/courseware/course/sequence/sequence-navigation/SequenceNavigation.test.jsx
@@ -33,13 +33,13 @@ describe('Sequence Navigation', () => {
it('is empty while loading', async () => {
const testStore = await initializeTestStore({ excludeFetchSequence: true }, false);
- const { container } = render(, { store: testStore });
+ const { container } = render(, { store: testStore, wrapWithRouter: true });
expect(container).toBeEmptyDOMElement();
});
it('renders empty div without unitId', () => {
- const { container } = render();
+ const { container } = render(, { 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(, { store: testStore });
+ render(, { 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();
+ render(, { 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();
+ render(, { 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();
+ render(, { 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(
,
- { store: testStore },
+ { store: testStore, wrapWithRouter: true },
);
expect(screen.getByRole('link', { name: /previous/i })).toBeEnabled();
@@ -122,7 +122,7 @@ describe('Sequence Navigation', () => {
render(
,
- { store: testStore },
+ { store: testStore, wrapWithRouter: true },
);
expect(screen.getByRole('link', { name: /previous/i })).toBeEnabled();
@@ -143,7 +143,7 @@ describe('Sequence Navigation', () => {
render(
,
- { 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();
+ render(, { wrapWithRouter: true });
fireEvent.click(screen.getByRole('link', { name: /previous/i }));
expect(previousHandler).toHaveBeenCalledTimes(1);
diff --git a/src/courseware/course/sequence/sequence-navigation/SequenceNavigationDropdown.test.jsx b/src/courseware/course/sequence/sequence-navigation/SequenceNavigationDropdown.test.jsx
index 2da62e5374..acda574105 100644
--- a/src/courseware/course/sequence/sequence-navigation/SequenceNavigationDropdown.test.jsx
+++ b/src/courseware/course/sequence/sequence-navigation/SequenceNavigationDropdown.test.jsx
@@ -40,7 +40,10 @@ describe('Sequence Navigation Dropdown', () => {
unitBlocks.forEach((unit, index) => {
it(`marks unit ${index + 1} as active`, async () => {
- const { container } = render();
+ const { container } = render(
+ ,
+ { 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();
+ const { container } = render(
+ ,
+ { wrapWithRouter: true },
+ );
const dropdownToggle = container.querySelector('.dropdown-toggle');
act(() => {
diff --git a/src/courseware/course/sequence/sequence-navigation/SequenceNavigationTabs.test.jsx b/src/courseware/course/sequence/sequence-navigation/SequenceNavigationTabs.test.jsx
index 3fcc7ae758..b35b33c760 100644
--- a/src/courseware/course/sequence/sequence-navigation/SequenceNavigationTabs.test.jsx
+++ b/src/courseware/course/sequence/sequence-navigation/SequenceNavigationTabs.test.jsx
@@ -41,7 +41,7 @@ describe('Sequence Navigation Tabs', () => {
it('renders unit buttons', () => {
useIndexOfLastVisibleChild.mockReturnValue([0, null, null]);
- render();
+ render(, { 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();
+ const booyah = render(, { wrapWithRouter: true });
container = booyah.container;
const dropdownToggle = container.querySelector('.dropdown-toggle');
diff --git a/src/courseware/course/sequence/sequence-navigation/UnitButton.test.jsx b/src/courseware/course/sequence/sequence-navigation/UnitButton.test.jsx
index a7979625d6..2885a21565 100644
--- a/src/courseware/course/sequence/sequence-navigation/UnitButton.test.jsx
+++ b/src/courseware/course/sequence/sequence-navigation/UnitButton.test.jsx
@@ -32,12 +32,12 @@ describe('Unit Button', () => {
});
it('hides title by default', () => {
- render();
+ render(, { wrapWithRouter: true });
expect(screen.getByRole('link')).not.toHaveTextContent(unit.display_name);
});
it('shows title', () => {
- render();
+ render(, { 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();
+ const { container } = render(, { 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();
+ const { container } = render(, { 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();
+ render(, { wrapWithRouter: true });
fireEvent.click(screen.getByRole('link'));
expect(onClick).toHaveBeenCalledTimes(1);
});
diff --git a/src/courseware/course/sequence/sequence-navigation/UnitNavigation.test.jsx b/src/courseware/course/sequence/sequence-navigation/UnitNavigation.test.jsx
index fdeec1669c..89603e6534 100644
--- a/src/courseware/course/sequence/sequence-navigation/UnitNavigation.test.jsx
+++ b/src/courseware/course/sequence/sequence-navigation/UnitNavigation.test.jsx
@@ -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();
+ render(, { 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();
+ render(, { 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(
,
- { store: testStore },
+ { store: testStore, wrapWithRouter: true },
);
expect(screen.getByRole('link', { name: /previous/i })).toBeEnabled();
@@ -95,7 +95,7 @@ describe('Unit Navigation', () => {
render(
,
- { store: testStore },
+ { store: testStore, wrapWithRouter: true },
);
expect(screen.getByRole('link', { name: /previous/i })).toBeEnabled();
@@ -116,7 +116,7 @@ describe('Unit Navigation', () => {
render(
,
- { store: testStore },
+ { store: testStore, wrapWithRouter: true },
);
expect(screen.getByRole('link', { name: /previous/i })).toBeEnabled();
diff --git a/src/courseware/utils.jsx b/src/courseware/utils.jsx
new file mode 100644
index 0000000000..2c4c337996
--- /dev/null
+++ b/src/courseware/utils.jsx
@@ -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 (
+
+ );
+ };
+ return WithParamsNavigationComponent;
+};
+
+export default withParamsAndNavigation;
diff --git a/src/decode-page-route/__snapshots__/index.test.jsx.snap b/src/decode-page-route/__snapshots__/index.test.jsx.snap
index aff8eac7bd..9a9bd772fa 100644
--- a/src/decode-page-route/__snapshots__/index.test.jsx.snap
+++ b/src/decode-page-route/__snapshots__/index.test.jsx.snap
@@ -2,15 +2,16 @@
exports[`DecodePageRoute should not modify the url if it does not need to be decoded 1`] = `
- 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": [
+ " ",
+ [
+ " ",
+ [],
+ " "
+ ],
+ " "
+ ]
}
`;
diff --git a/src/decode-page-route/index.jsx b/src/decode-page-route/index.jsx
index cc38013b24..eff47fa3fd 100644
--- a/src/decode-page-route/index.jsx
+++ b/src/decode-page-route/index.jsx
@@ -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 ;
}
}
- return ;
+ return {children} ;
};
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;
diff --git a/src/decode-page-route/index.test.jsx b/src/decode-page-route/index.test.jsx
index c32453edb4..8abf352dbd 100644
--- a/src/decode-page-route/index.test.jsx
+++ b/src/decode-page-route/index.test.jsx
@@ -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();
+
+ // eslint-disable-next-line react/prop-types
+ const Navigate = ({ to }) => {
+ mockNavigation(to);
+ return ;
+ };
- const history = {
- ...memHistory,
- replace: jest.fn(),
+ return {
+ ...jest.requireActual('react-router-dom'),
+ Navigate,
+ mockNavigate: mockNavigation,
};
+});
+const renderPage = (props) => {
const { container } = render(
-
-
+
+
+ {[]} } />
+
,
);
- return {
- container,
- history,
- props,
- };
+ return { container };
};
describe('DecodePageRoute', () => {
+ afterEach(() => {
+ mockNavigate.mockClear();
+ });
+
it('should not modify the url if it does not need to be decoded', () => {
- const props = matchPath(`/course/${decodedCourseId}/home`, {
+ const props = matchPath({
path: '/course/:courseId/home',
- });
- const { container, history } = renderPage(props);
+ }, `/course/${decodedCourseId}/home`);
+ const { container } = renderPage(props);
- expect(props.url).toContain(decodedCourseId);
- expect(history.replace).not.toHaveBeenCalled();
+ 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);
-
- 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);
+ }, `/course/${deepEncodedCourseId}/${encodedUnitId}/${encodedUnitId}`);
+ renderPage(props);
- // courseId get decoded
- expect(decodedUrls.pop()).toContain(decodedCourseId);
+ expect(mockNavigate).toHaveBeenCalledWith(`/course/${decodedCourseId}/${encodedUnitId}/${decodedUnitId}`);
});
});
diff --git a/src/generic/CourseAccessErrorPage.jsx b/src/generic/CourseAccessErrorPage.jsx
index 3440382ff4..8eff1f7aec 100644
--- a/src/generic/CourseAccessErrorPage.jsx
+++ b/src/generic/CourseAccessErrorPage.jsx
@@ -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 ();
+ return ;
}
return (
<>
diff --git a/src/generic/CourseAccessErrorPage.test.jsx b/src/generic/CourseAccessErrorPage.test.jsx
index 1361c391ea..340e5d07b9 100644
--- a/src/generic/CourseAccessErrorPage.test.jsx
+++ b/src/generic/CourseAccessErrorPage.test.jsx
@@ -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 ;
});
+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(
-
-
- ,
+
+ } />
+ ,
+ { 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(
-
-
- ,
+
+ } />
+ ,
+ { 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(
-
-
- ,
+
+ } />
+ ,
+ { wrapWithRouter: true },
);
expect(screen.getByTestId('access-denied-main')).toBeInTheDocument();
- expect(history.location.pathname).toBe(accessDeniedUrl);
+ expect(window.location.pathname).toBe(accessDeniedUrl);
});
});
diff --git a/src/generic/path-fixes/PathFixesProvider.jsx b/src/generic/path-fixes/PathFixesProvider.jsx
index 3215660927..83e9552c99 100644
--- a/src/generic/path-fixes/PathFixesProvider.jsx
+++ b/src/generic/path-fixes/PathFixesProvider.jsx
@@ -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 ();
+ return ();
}
return children; // pass through
diff --git a/src/generic/path-fixes/PathFixesProvider.test.jsx b/src/generic/path-fixes/PathFixesProvider.test.jsx
index c20bbd9949..0253acf9e1 100644
--- a/src/generic/path-fixes/PathFixesProvider.test.jsx
+++ b/src/generic/path-fixes/PathFixesProvider.test.jsx
@@ -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(
- {
- testLocation = routeProps.location;
- return null;
- }}
- />
+
+ }
+ />
+
,
);
diff --git a/src/index.jsx b/src/index.jsx
index 05a72f5f61..d99be587a7 100755
--- a/src/index.jsx
+++ b/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, () => {
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- (
- fetchProgressTab(courseId, match.params.targetUserId)}
- slice="courseHome"
- >
-
-
+
+ } />
+ } />
+ }
+ />
+
+
+
+
+
+ )}
+ />
+
+
+
+
+
+ )}
+ />
+
+
+
+
+
)}
/>
-
-
-
-
-
-
+
+
+
+
+ )}
+ />
+ {DECODE_ROUTES.PROGRESS.map((route) => (
+
+
+
+
+
+ )}
+ />
+ ))}
+
+
+
+
+
+ )}
/>
-
+ {DECODE_ROUTES.COURSEWARE.map((route) => (
+
+
+
+ )}
+ />
+ ))}
+
diff --git a/src/product-tours/ProductTours.test.jsx b/src/product-tours/ProductTours.test.jsx
index 4f87f1931d..ae4afdcafc 100644
--- a/src/product-tours/ProductTours.test.jsx
+++ b/src/product-tours/ProductTours.test.jsx
@@ -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', () => {
,
- { store },
+ { store, wrapWithRouter: true },
);
}
@@ -213,16 +214,14 @@ describe('Courseware Tour', () => {
component = (
-
-
-
+
+ {DECODE_ROUTES.COURSEWARE.map((route) => (
+ }
+ />
+ ))}
+
);
diff --git a/src/setupTest.js b/src/setupTest.js
index 225b56b913..371664a781 100755
--- a/src/setupTest.js
+++ b/src/setupTest.js
@@ -169,13 +169,14 @@ function render(
ui,
{
store = null,
+ wrapWithRouter = false,
...renderOptions
} = {},
) {
const Wrapper = ({ children }) => (
// eslint-disable-next-line react/jsx-filename-extension
-
+
{children}
diff --git a/src/tab-page/TabContainer.jsx b/src/tab-page/TabContainer.jsx
index c5331f9fe8..d69e62f40f 100644
--- a/src/tab-page/TabContainer.jsx
+++ b/src/tab-page/TabContainer.jsx
@@ -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;
diff --git a/src/tab-page/TabContainer.test.jsx b/src/tab-page/TabContainer.test.jsx
index 6052412947..fc0d5f39e4 100644
--- a/src/tab-page/TabContainer.test.jsx
+++ b/src/tab-page/TabContainer.test.jsx
@@ -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(
-
-
- children={[]}
-
- ,
+
+
+
+ children={[]}
+
+ )}
+ />
+
+ ,
);
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(
- (
- mockFetch(match.params.courseId, match.params.targetUserId)}
- tab="dummy"
- slice="courseHome"
- >
- children={[]}
-
-
- )}
- />,
+
+
+
+ children={[]}
+
+ )}
+ />
+
+ ,
);
expect(mockFetch).toHaveBeenCalledTimes(1);
diff --git a/src/tab-page/TabPage.jsx b/src/tab-page/TabPage.jsx
index 3b9dea60c6..ed4bb455cb 100644
--- a/src/tab-page/TabPage.jsx
+++ b/src/tab-page/TabPage.jsx
@@ -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 ();
+ return ();
}
}