Skip to content

Commit

Permalink
feat: improved SPA routes
Browse files Browse the repository at this point in the history
  • Loading branch information
PKulkoRaccoonGang authored and arbrandes committed Nov 1, 2024
1 parent 3e4eb21 commit cb7774b
Show file tree
Hide file tree
Showing 15 changed files with 394 additions and 28 deletions.
3 changes: 2 additions & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@
"@openedx/paragon": ">= 21.5.7 < 23.0.0",
"prop-types": "^15.5.10",
"react": "^16.9.0 || ^17.0.0",
"react-dom": "^16.9.0 || ^17.0.0"
"react-dom": "^16.9.0 || ^17.0.0",
"react-router-dom": "^6.14.2"
}
}
5 changes: 3 additions & 2 deletions src/studio-header/BrandNav.jsx
Original file line number Diff line number Diff line change
@@ -1,18 +1,19 @@
import React from 'react';
import PropTypes from 'prop-types';
import { Link } from 'react-router-dom';

const BrandNav = ({
studioBaseUrl,
logo,
logoAltText,
}) => (
<a href={studioBaseUrl}>
<Link to={studioBaseUrl}>
<img
src={logo}
alt={logoAltText}
className="d-block logo"
/>
</a>
</Link>
);

BrandNav.propTypes = {
Expand Down
40 changes: 40 additions & 0 deletions src/studio-header/BrandNav.test.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import '@testing-library/jest-dom/extend-expect';
import { MemoryRouter } from 'react-router-dom';

import BrandNav from './BrandNav';

const studioBaseUrl = 'https://example.com/';
const logo = 'logo.png';
const logoAltText = 'Example Logo';

const RootWrapper = () => (
<MemoryRouter>
<BrandNav
studioBaseUrl={studioBaseUrl}
logo={logo}
logoAltText={logoAltText}
/>
</MemoryRouter>
);

describe('BrandNav Component', () => {
afterEach(() => {
jest.clearAllMocks();
});

it('renders the logo with the correct alt text', () => {
render(<RootWrapper />);

const img = screen.getByAltText(logoAltText);
expect(img).toHaveAttribute('src', logo);
});

it('displays a link that navigates to studioBaseUrl', () => {
render(<RootWrapper />);

const link = screen.getByRole('link');
expect(link.href).toBe(studioBaseUrl);
});
});
8 changes: 5 additions & 3 deletions src/studio-header/CourseLockUp.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import {
OverlayTrigger,
Tooltip,
} from '@openedx/paragon';
import { Link } from 'react-router-dom';

import messages from './messages';

const CourseLockUp = ({
Expand All @@ -23,15 +25,15 @@ const CourseLockUp = ({
</Tooltip>
)}
>
<a
<Link
className="course-title-lockup mr-2"
href={outlineLink}
to={outlineLink}
aria-label={intl.formatMessage(messages['header.label.courseOutline'])}
data-testid="course-lock-up-block"
>
<span className="d-block small m-0 text-gray-800" data-testid="course-org-number">{org} {number}</span>
<span className="d-block m-0 font-weight-bold text-gray-800" data-testid="course-title">{title}</span>
</a>
</Link>
</OverlayTrigger>
);

Expand Down
58 changes: 58 additions & 0 deletions src/studio-header/CourseLockUp.test.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import '@testing-library/jest-dom/extend-expect';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import { MemoryRouter } from 'react-router-dom';

import CourseLockUp from './CourseLockUp';
import messages from './messages';

const mockProps = {
number: '101',
org: 'EDX',
title: 'Course Title',
outlineLink: 'https://example.com/course-outline',
};

const RootWrapper = (props) => (
<MemoryRouter>
<IntlProvider locale="en" messages={messages}>
<CourseLockUp {...props} />
</IntlProvider>
</MemoryRouter>
);

describe('CourseLockUp Component', () => {
afterEach(() => {
jest.clearAllMocks();
});

it('renders course org, number, and title', () => {
render(<RootWrapper {...mockProps} />);

const courseOrgNumber = screen.getByTestId('course-org-number');
const courseTitle = screen.getByTestId('course-title');

expect(courseOrgNumber).toBeInTheDocument();
expect(courseOrgNumber).toHaveTextContent(`${mockProps.org} ${mockProps.number}`);
expect(courseTitle).toBeInTheDocument();
expect(courseTitle).toHaveTextContent(mockProps.title);
});

it('renders the link with correct aria-label', () => {
render(<RootWrapper {...mockProps} />);

const link = screen.getByTestId('course-lock-up-block');
expect(link).toHaveAttribute(
'aria-label',
messages['header.label.courseOutline'].defaultMessage,
);
});

it('navigates to an absolute URL when clicked', () => {
render(<RootWrapper {...mockProps} />);

const link = screen.getByTestId('course-lock-up-block');
expect(link.href).toBe(mockProps.outlineLink);
});
});
7 changes: 6 additions & 1 deletion src/studio-header/HeaderBody.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,12 @@ const HeaderBody = ({
{mainMenuDropdowns.map(dropdown => {
const { id, buttonTitle, items } = dropdown;
return (
<NavDropdownMenu key={id} {...{ id, buttonTitle, items }} />
<NavDropdownMenu
key={id}
{...{
id, buttonTitle, items,
}}
/>
);
})}
</Nav>
Expand Down
102 changes: 102 additions & 0 deletions src/studio-header/HeaderBody.test.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import '@testing-library/jest-dom/extend-expect';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import { MemoryRouter } from 'react-router-dom';

import HeaderBody from './HeaderBody';
import messages from './messages';

const mockOnNavigate = jest.fn();
const mockSearchButtonAction = jest.fn();
const mockToggleModalPopup = jest.fn();
const mockSetModalPopupTarget = jest.fn();

const defaultProps = {
studioBaseUrl: 'https://example.com',
logoutUrl: 'https://example.com/logout',
onNavigate: mockOnNavigate,
setModalPopupTarget: mockSetModalPopupTarget,
toggleModalPopup: mockToggleModalPopup,
searchButtonAction: mockSearchButtonAction,
username: 'testuser',
authenticatedUserAvatar: 'avatar.png',
isAdmin: true,
isMobile: false,
isHiddenMainMenu: false,
mainMenuDropdowns: [],
logo: 'logo.png',
logoAltText: 'Test Logo',
number: '101',
org: 'EDX',
title: 'Test Course',
outlineLink: '/courses/edx/course-101',
};

const RootWrapper = (props) => (
<MemoryRouter>
<IntlProvider locale="en" messages={messages}>
<HeaderBody {...props} />
</IntlProvider>
</MemoryRouter>
);

describe('HeaderBody Component', () => {
afterEach(() => {
jest.clearAllMocks();
});

it('renders the logo and brand navigation', () => {
render(<RootWrapper {...defaultProps} />);

const logoImage = screen.getByAltText(defaultProps.logoAltText);
expect(logoImage).toBeInTheDocument();
expect(logoImage).toHaveAttribute('src', defaultProps.logo);
});

it('renders course lockup information', () => {
render(<RootWrapper {...defaultProps} />);

const courseTitle = screen.getByText(defaultProps.title);
const courseOrgNumber = screen.getByText(`${defaultProps.org} ${defaultProps.number}`);

expect(courseTitle).toBeInTheDocument();
expect(courseOrgNumber).toBeInTheDocument();
});

it('renders a course lock-up link with the correct outline URL', () => {
render(<RootWrapper {...defaultProps} />);

const courseLockUpLink = screen.getByTestId('course-lock-up-block');
expect(courseLockUpLink.getAttribute('href')).toBe(defaultProps.outlineLink);
});

it('displays search button and triggers searchButtonAction on click', () => {
render(<RootWrapper {...defaultProps} />);

const searchButton = screen.getByLabelText(messages['header.label.search.nav'].defaultMessage);
expect(searchButton).toBeInTheDocument();

fireEvent.click(searchButton);
expect(mockSearchButtonAction).toHaveBeenCalled();
});

it('displays user menu with username and avatar', () => {
render(<RootWrapper {...defaultProps} />);

const userMenu = screen.getByText(defaultProps.username);
const avatarImage = screen.getByAltText(defaultProps.username);

expect(userMenu).toBeInTheDocument();
expect(avatarImage).toHaveAttribute('src', defaultProps.authenticatedUserAvatar);
});

it('toggles mobile menu popup when button is clicked in mobile view', () => {
render(<RootWrapper {...defaultProps} isMobile isModalPopupOpen={false} />);

const menuButton = screen.getByTestId('mobile-menu-button');
fireEvent.click(menuButton);

expect(mockToggleModalPopup).toHaveBeenCalled();
});
});
9 changes: 4 additions & 5 deletions src/studio-header/MobileMenu.jsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
import React from 'react';
import PropTypes from 'prop-types';
import { Collapsible } from '@openedx/paragon';
import { Link } from 'react-router-dom';

const MobileMenu = ({
mainMenuDropdowns,
}) => (
const MobileMenu = ({ mainMenuDropdowns }) => (
<div
className="ml-4 p-2 bg-light-100 border border-gray-200 small rounded"
data-testid="mobile-menu"
Expand All @@ -21,9 +20,9 @@ const MobileMenu = ({
<ul className="p-0" style={{ listStyleType: 'none' }}>
{items.map(item => (
<li className="mobile-menu-item">
<a href={item.href}>
<Link to={item.href}>
{item.title}
</a>
</Link>
</li>
))}
</ul>
Expand Down
81 changes: 81 additions & 0 deletions src/studio-header/MobileMenu.test.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';

import '@testing-library/jest-dom/extend-expect';
import MobileMenu from './MobileMenu';

const mockOnNavigate = jest.fn();

const defaultProps = {
mainMenuDropdowns: [
{
id: 'menu1',
buttonTitle: 'Menu 1',
items: [
{ href: '/menu1/item1', title: 'Item 1' },
{ href: '/menu1/item2', title: 'Item 2' },
],
},
{
id: 'menu2',
buttonTitle: 'Menu 2',
items: [
{ href: 'https://external-link.com', title: 'External Link' },
],
},
],
onNavigate: mockOnNavigate,
};

const RootWrapper = (props) => (
<MemoryRouter>
<MobileMenu {...props} />
</MemoryRouter>
);

describe('MobileMenu Component', () => {
afterEach(() => {
jest.clearAllMocks();
});

test('renders the mobile menu with dropdowns and items', () => {
render(<RootWrapper {...defaultProps} />);

const menu1Title = screen.getByText('Menu 1');
const menu2Title = screen.getByText('Menu 2');

expect(menu1Title).toBeInTheDocument();
expect(menu2Title).toBeInTheDocument();
});

test('navigates to internal URL when item is clicked', () => {
render(<RootWrapper {...defaultProps} />);

const menu1Title = screen.getByText(defaultProps.mainMenuDropdowns[0].buttonTitle);
fireEvent.click(menu1Title);

const menuItem = screen.getByText(defaultProps.mainMenuDropdowns[0].items[0].title);
expect(menuItem.getAttribute('href')).toBe(defaultProps.mainMenuDropdowns[0].items[0].href);
});

test('navigates to an external URL when external link is clicked', () => {
render(<RootWrapper {...defaultProps} />);

const menu2Title = screen.getByText(defaultProps.mainMenuDropdowns[1].buttonTitle);
fireEvent.click(menu2Title);

const externalLink = screen.getByText(defaultProps.mainMenuDropdowns[1].items[0].title);
expect(externalLink.getAttribute('href')).toBe(defaultProps.mainMenuDropdowns[1].items[0].href);
});

test('renders empty state when there are no dropdowns', () => {
render(<RootWrapper mainMenuDropdowns={[]} onNavigate={mockOnNavigate} />);

const mobileMenu = screen.getByTestId('mobile-menu');
expect(mobileMenu).toBeInTheDocument();

const menuItems = screen.queryAllByRole('listitem');
expect(menuItems.length).toBe(0);
});
});
Loading

0 comments on commit cb7774b

Please sign in to comment.