Skip to content

Commit cb7774b

Browse files
PKulkoRaccoonGangarbrandes
authored andcommitted
feat: improved SPA routes
1 parent 3e4eb21 commit cb7774b

15 files changed

+394
-28
lines changed

package-lock.json

Lines changed: 2 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@
7373
"@openedx/paragon": ">= 21.5.7 < 23.0.0",
7474
"prop-types": "^15.5.10",
7575
"react": "^16.9.0 || ^17.0.0",
76-
"react-dom": "^16.9.0 || ^17.0.0"
76+
"react-dom": "^16.9.0 || ^17.0.0",
77+
"react-router-dom": "^6.14.2"
7778
}
7879
}

src/studio-header/BrandNav.jsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,19 @@
11
import React from 'react';
22
import PropTypes from 'prop-types';
3+
import { Link } from 'react-router-dom';
34

45
const BrandNav = ({
56
studioBaseUrl,
67
logo,
78
logoAltText,
89
}) => (
9-
<a href={studioBaseUrl}>
10+
<Link to={studioBaseUrl}>
1011
<img
1112
src={logo}
1213
alt={logoAltText}
1314
className="d-block logo"
1415
/>
15-
</a>
16+
</Link>
1617
);
1718

1819
BrandNav.propTypes = {

src/studio-header/BrandNav.test.jsx

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import React from 'react';
2+
import { render, screen } from '@testing-library/react';
3+
import '@testing-library/jest-dom/extend-expect';
4+
import { MemoryRouter } from 'react-router-dom';
5+
6+
import BrandNav from './BrandNav';
7+
8+
const studioBaseUrl = 'https://example.com/';
9+
const logo = 'logo.png';
10+
const logoAltText = 'Example Logo';
11+
12+
const RootWrapper = () => (
13+
<MemoryRouter>
14+
<BrandNav
15+
studioBaseUrl={studioBaseUrl}
16+
logo={logo}
17+
logoAltText={logoAltText}
18+
/>
19+
</MemoryRouter>
20+
);
21+
22+
describe('BrandNav Component', () => {
23+
afterEach(() => {
24+
jest.clearAllMocks();
25+
});
26+
27+
it('renders the logo with the correct alt text', () => {
28+
render(<RootWrapper />);
29+
30+
const img = screen.getByAltText(logoAltText);
31+
expect(img).toHaveAttribute('src', logo);
32+
});
33+
34+
it('displays a link that navigates to studioBaseUrl', () => {
35+
render(<RootWrapper />);
36+
37+
const link = screen.getByRole('link');
38+
expect(link.href).toBe(studioBaseUrl);
39+
});
40+
});

src/studio-header/CourseLockUp.jsx

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ import {
55
OverlayTrigger,
66
Tooltip,
77
} from '@openedx/paragon';
8+
import { Link } from 'react-router-dom';
9+
810
import messages from './messages';
911

1012
const CourseLockUp = ({
@@ -23,15 +25,15 @@ const CourseLockUp = ({
2325
</Tooltip>
2426
)}
2527
>
26-
<a
28+
<Link
2729
className="course-title-lockup mr-2"
28-
href={outlineLink}
30+
to={outlineLink}
2931
aria-label={intl.formatMessage(messages['header.label.courseOutline'])}
3032
data-testid="course-lock-up-block"
3133
>
3234
<span className="d-block small m-0 text-gray-800" data-testid="course-org-number">{org} {number}</span>
3335
<span className="d-block m-0 font-weight-bold text-gray-800" data-testid="course-title">{title}</span>
34-
</a>
36+
</Link>
3537
</OverlayTrigger>
3638
);
3739

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import React from 'react';
2+
import { render, screen } from '@testing-library/react';
3+
import '@testing-library/jest-dom/extend-expect';
4+
import { IntlProvider } from '@edx/frontend-platform/i18n';
5+
import { MemoryRouter } from 'react-router-dom';
6+
7+
import CourseLockUp from './CourseLockUp';
8+
import messages from './messages';
9+
10+
const mockProps = {
11+
number: '101',
12+
org: 'EDX',
13+
title: 'Course Title',
14+
outlineLink: 'https://example.com/course-outline',
15+
};
16+
17+
const RootWrapper = (props) => (
18+
<MemoryRouter>
19+
<IntlProvider locale="en" messages={messages}>
20+
<CourseLockUp {...props} />
21+
</IntlProvider>
22+
</MemoryRouter>
23+
);
24+
25+
describe('CourseLockUp Component', () => {
26+
afterEach(() => {
27+
jest.clearAllMocks();
28+
});
29+
30+
it('renders course org, number, and title', () => {
31+
render(<RootWrapper {...mockProps} />);
32+
33+
const courseOrgNumber = screen.getByTestId('course-org-number');
34+
const courseTitle = screen.getByTestId('course-title');
35+
36+
expect(courseOrgNumber).toBeInTheDocument();
37+
expect(courseOrgNumber).toHaveTextContent(`${mockProps.org} ${mockProps.number}`);
38+
expect(courseTitle).toBeInTheDocument();
39+
expect(courseTitle).toHaveTextContent(mockProps.title);
40+
});
41+
42+
it('renders the link with correct aria-label', () => {
43+
render(<RootWrapper {...mockProps} />);
44+
45+
const link = screen.getByTestId('course-lock-up-block');
46+
expect(link).toHaveAttribute(
47+
'aria-label',
48+
messages['header.label.courseOutline'].defaultMessage,
49+
);
50+
});
51+
52+
it('navigates to an absolute URL when clicked', () => {
53+
render(<RootWrapper {...mockProps} />);
54+
55+
const link = screen.getByTestId('course-lock-up-block');
56+
expect(link.href).toBe(mockProps.outlineLink);
57+
});
58+
});

src/studio-header/HeaderBody.jsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,12 @@ const HeaderBody = ({
103103
{mainMenuDropdowns.map(dropdown => {
104104
const { id, buttonTitle, items } = dropdown;
105105
return (
106-
<NavDropdownMenu key={id} {...{ id, buttonTitle, items }} />
106+
<NavDropdownMenu
107+
key={id}
108+
{...{
109+
id, buttonTitle, items,
110+
}}
111+
/>
107112
);
108113
})}
109114
</Nav>

src/studio-header/HeaderBody.test.jsx

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
import React from 'react';
2+
import { render, screen, fireEvent } from '@testing-library/react';
3+
import '@testing-library/jest-dom/extend-expect';
4+
import { IntlProvider } from '@edx/frontend-platform/i18n';
5+
import { MemoryRouter } from 'react-router-dom';
6+
7+
import HeaderBody from './HeaderBody';
8+
import messages from './messages';
9+
10+
const mockOnNavigate = jest.fn();
11+
const mockSearchButtonAction = jest.fn();
12+
const mockToggleModalPopup = jest.fn();
13+
const mockSetModalPopupTarget = jest.fn();
14+
15+
const defaultProps = {
16+
studioBaseUrl: 'https://example.com',
17+
logoutUrl: 'https://example.com/logout',
18+
onNavigate: mockOnNavigate,
19+
setModalPopupTarget: mockSetModalPopupTarget,
20+
toggleModalPopup: mockToggleModalPopup,
21+
searchButtonAction: mockSearchButtonAction,
22+
username: 'testuser',
23+
authenticatedUserAvatar: 'avatar.png',
24+
isAdmin: true,
25+
isMobile: false,
26+
isHiddenMainMenu: false,
27+
mainMenuDropdowns: [],
28+
logo: 'logo.png',
29+
logoAltText: 'Test Logo',
30+
number: '101',
31+
org: 'EDX',
32+
title: 'Test Course',
33+
outlineLink: '/courses/edx/course-101',
34+
};
35+
36+
const RootWrapper = (props) => (
37+
<MemoryRouter>
38+
<IntlProvider locale="en" messages={messages}>
39+
<HeaderBody {...props} />
40+
</IntlProvider>
41+
</MemoryRouter>
42+
);
43+
44+
describe('HeaderBody Component', () => {
45+
afterEach(() => {
46+
jest.clearAllMocks();
47+
});
48+
49+
it('renders the logo and brand navigation', () => {
50+
render(<RootWrapper {...defaultProps} />);
51+
52+
const logoImage = screen.getByAltText(defaultProps.logoAltText);
53+
expect(logoImage).toBeInTheDocument();
54+
expect(logoImage).toHaveAttribute('src', defaultProps.logo);
55+
});
56+
57+
it('renders course lockup information', () => {
58+
render(<RootWrapper {...defaultProps} />);
59+
60+
const courseTitle = screen.getByText(defaultProps.title);
61+
const courseOrgNumber = screen.getByText(`${defaultProps.org} ${defaultProps.number}`);
62+
63+
expect(courseTitle).toBeInTheDocument();
64+
expect(courseOrgNumber).toBeInTheDocument();
65+
});
66+
67+
it('renders a course lock-up link with the correct outline URL', () => {
68+
render(<RootWrapper {...defaultProps} />);
69+
70+
const courseLockUpLink = screen.getByTestId('course-lock-up-block');
71+
expect(courseLockUpLink.getAttribute('href')).toBe(defaultProps.outlineLink);
72+
});
73+
74+
it('displays search button and triggers searchButtonAction on click', () => {
75+
render(<RootWrapper {...defaultProps} />);
76+
77+
const searchButton = screen.getByLabelText(messages['header.label.search.nav'].defaultMessage);
78+
expect(searchButton).toBeInTheDocument();
79+
80+
fireEvent.click(searchButton);
81+
expect(mockSearchButtonAction).toHaveBeenCalled();
82+
});
83+
84+
it('displays user menu with username and avatar', () => {
85+
render(<RootWrapper {...defaultProps} />);
86+
87+
const userMenu = screen.getByText(defaultProps.username);
88+
const avatarImage = screen.getByAltText(defaultProps.username);
89+
90+
expect(userMenu).toBeInTheDocument();
91+
expect(avatarImage).toHaveAttribute('src', defaultProps.authenticatedUserAvatar);
92+
});
93+
94+
it('toggles mobile menu popup when button is clicked in mobile view', () => {
95+
render(<RootWrapper {...defaultProps} isMobile isModalPopupOpen={false} />);
96+
97+
const menuButton = screen.getByTestId('mobile-menu-button');
98+
fireEvent.click(menuButton);
99+
100+
expect(mockToggleModalPopup).toHaveBeenCalled();
101+
});
102+
});

src/studio-header/MobileMenu.jsx

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,9 @@
11
import React from 'react';
22
import PropTypes from 'prop-types';
33
import { Collapsible } from '@openedx/paragon';
4+
import { Link } from 'react-router-dom';
45

5-
const MobileMenu = ({
6-
mainMenuDropdowns,
7-
}) => (
6+
const MobileMenu = ({ mainMenuDropdowns }) => (
87
<div
98
className="ml-4 p-2 bg-light-100 border border-gray-200 small rounded"
109
data-testid="mobile-menu"
@@ -21,9 +20,9 @@ const MobileMenu = ({
2120
<ul className="p-0" style={{ listStyleType: 'none' }}>
2221
{items.map(item => (
2322
<li className="mobile-menu-item">
24-
<a href={item.href}>
23+
<Link to={item.href}>
2524
{item.title}
26-
</a>
25+
</Link>
2726
</li>
2827
))}
2928
</ul>

src/studio-header/MobileMenu.test.jsx

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import React from 'react';
2+
import { render, screen, fireEvent } from '@testing-library/react';
3+
import { MemoryRouter } from 'react-router-dom';
4+
5+
import '@testing-library/jest-dom/extend-expect';
6+
import MobileMenu from './MobileMenu';
7+
8+
const mockOnNavigate = jest.fn();
9+
10+
const defaultProps = {
11+
mainMenuDropdowns: [
12+
{
13+
id: 'menu1',
14+
buttonTitle: 'Menu 1',
15+
items: [
16+
{ href: '/menu1/item1', title: 'Item 1' },
17+
{ href: '/menu1/item2', title: 'Item 2' },
18+
],
19+
},
20+
{
21+
id: 'menu2',
22+
buttonTitle: 'Menu 2',
23+
items: [
24+
{ href: 'https://external-link.com', title: 'External Link' },
25+
],
26+
},
27+
],
28+
onNavigate: mockOnNavigate,
29+
};
30+
31+
const RootWrapper = (props) => (
32+
<MemoryRouter>
33+
<MobileMenu {...props} />
34+
</MemoryRouter>
35+
);
36+
37+
describe('MobileMenu Component', () => {
38+
afterEach(() => {
39+
jest.clearAllMocks();
40+
});
41+
42+
test('renders the mobile menu with dropdowns and items', () => {
43+
render(<RootWrapper {...defaultProps} />);
44+
45+
const menu1Title = screen.getByText('Menu 1');
46+
const menu2Title = screen.getByText('Menu 2');
47+
48+
expect(menu1Title).toBeInTheDocument();
49+
expect(menu2Title).toBeInTheDocument();
50+
});
51+
52+
test('navigates to internal URL when item is clicked', () => {
53+
render(<RootWrapper {...defaultProps} />);
54+
55+
const menu1Title = screen.getByText(defaultProps.mainMenuDropdowns[0].buttonTitle);
56+
fireEvent.click(menu1Title);
57+
58+
const menuItem = screen.getByText(defaultProps.mainMenuDropdowns[0].items[0].title);
59+
expect(menuItem.getAttribute('href')).toBe(defaultProps.mainMenuDropdowns[0].items[0].href);
60+
});
61+
62+
test('navigates to an external URL when external link is clicked', () => {
63+
render(<RootWrapper {...defaultProps} />);
64+
65+
const menu2Title = screen.getByText(defaultProps.mainMenuDropdowns[1].buttonTitle);
66+
fireEvent.click(menu2Title);
67+
68+
const externalLink = screen.getByText(defaultProps.mainMenuDropdowns[1].items[0].title);
69+
expect(externalLink.getAttribute('href')).toBe(defaultProps.mainMenuDropdowns[1].items[0].href);
70+
});
71+
72+
test('renders empty state when there are no dropdowns', () => {
73+
render(<RootWrapper mainMenuDropdowns={[]} onNavigate={mockOnNavigate} />);
74+
75+
const mobileMenu = screen.getByTestId('mobile-menu');
76+
expect(mobileMenu).toBeInTheDocument();
77+
78+
const menuItems = screen.queryAllByRole('listitem');
79+
expect(menuItems.length).toBe(0);
80+
});
81+
});

0 commit comments

Comments
 (0)