diff --git a/package-lock.json b/package-lock.json
index 227b0e9..95b94a0 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -49,7 +49,8 @@
"@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"
}
},
"node_modules/@adobe/css-tools": {
diff --git a/package.json b/package.json
index 4df3252..5c758a2 100644
--- a/package.json
+++ b/package.json
@@ -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"
}
}
diff --git a/src/studio-header/BrandNav.jsx b/src/studio-header/BrandNav.jsx
index 9342c3b..fd9ecd0 100644
--- a/src/studio-header/BrandNav.jsx
+++ b/src/studio-header/BrandNav.jsx
@@ -1,18 +1,19 @@
import React from 'react';
import PropTypes from 'prop-types';
+import { Link } from 'react-router-dom';
const BrandNav = ({
studioBaseUrl,
logo,
logoAltText,
}) => (
-
+
-
+
);
BrandNav.propTypes = {
diff --git a/src/studio-header/BrandNav.test.jsx b/src/studio-header/BrandNav.test.jsx
new file mode 100644
index 0000000..7ea2d3e
--- /dev/null
+++ b/src/studio-header/BrandNav.test.jsx
@@ -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 = () => (
+
+
+
+);
+
+describe('BrandNav Component', () => {
+ afterEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('renders the logo with the correct alt text', () => {
+ render();
+
+ const img = screen.getByAltText(logoAltText);
+ expect(img).toHaveAttribute('src', logo);
+ });
+
+ it('displays a link that navigates to studioBaseUrl', () => {
+ render();
+
+ const link = screen.getByRole('link');
+ expect(link.href).toBe(studioBaseUrl);
+ });
+});
diff --git a/src/studio-header/CourseLockUp.jsx b/src/studio-header/CourseLockUp.jsx
index c5853d8..c623614 100644
--- a/src/studio-header/CourseLockUp.jsx
+++ b/src/studio-header/CourseLockUp.jsx
@@ -5,6 +5,8 @@ import {
OverlayTrigger,
Tooltip,
} from '@openedx/paragon';
+import { Link } from 'react-router-dom';
+
import messages from './messages';
const CourseLockUp = ({
@@ -23,15 +25,15 @@ const CourseLockUp = ({
)}
>
-
{org} {number}
{title}
-
+
);
diff --git a/src/studio-header/CourseLockUp.test.jsx b/src/studio-header/CourseLockUp.test.jsx
new file mode 100644
index 0000000..5dfc48f
--- /dev/null
+++ b/src/studio-header/CourseLockUp.test.jsx
@@ -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) => (
+
+
+
+
+
+);
+
+describe('CourseLockUp Component', () => {
+ afterEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('renders course org, number, and title', () => {
+ render();
+
+ 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();
+
+ 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();
+
+ const link = screen.getByTestId('course-lock-up-block');
+ expect(link.href).toBe(mockProps.outlineLink);
+ });
+});
diff --git a/src/studio-header/HeaderBody.jsx b/src/studio-header/HeaderBody.jsx
index 536dca1..3ed7403 100644
--- a/src/studio-header/HeaderBody.jsx
+++ b/src/studio-header/HeaderBody.jsx
@@ -103,7 +103,12 @@ const HeaderBody = ({
{mainMenuDropdowns.map(dropdown => {
const { id, buttonTitle, items } = dropdown;
return (
-
+
);
})}
diff --git a/src/studio-header/HeaderBody.test.jsx b/src/studio-header/HeaderBody.test.jsx
new file mode 100644
index 0000000..6e5a5e6
--- /dev/null
+++ b/src/studio-header/HeaderBody.test.jsx
@@ -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) => (
+
+
+
+
+
+);
+
+describe('HeaderBody Component', () => {
+ afterEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('renders the logo and brand navigation', () => {
+ render();
+
+ const logoImage = screen.getByAltText(defaultProps.logoAltText);
+ expect(logoImage).toBeInTheDocument();
+ expect(logoImage).toHaveAttribute('src', defaultProps.logo);
+ });
+
+ it('renders course lockup information', () => {
+ render();
+
+ 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();
+
+ 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();
+
+ 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();
+
+ 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();
+
+ const menuButton = screen.getByTestId('mobile-menu-button');
+ fireEvent.click(menuButton);
+
+ expect(mockToggleModalPopup).toHaveBeenCalled();
+ });
+});
diff --git a/src/studio-header/MobileMenu.jsx b/src/studio-header/MobileMenu.jsx
index 892151c..892f603 100644
--- a/src/studio-header/MobileMenu.jsx
+++ b/src/studio-header/MobileMenu.jsx
@@ -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 }) => (
{items.map(item => (
-
+
{item.title}
-
+
))}
diff --git a/src/studio-header/MobileMenu.test.jsx b/src/studio-header/MobileMenu.test.jsx
new file mode 100644
index 0000000..38041be
--- /dev/null
+++ b/src/studio-header/MobileMenu.test.jsx
@@ -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) => (
+
+
+
+);
+
+describe('MobileMenu Component', () => {
+ afterEach(() => {
+ jest.clearAllMocks();
+ });
+
+ test('renders the mobile menu with dropdowns and items', () => {
+ render(
);
+
+ 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(
);
+
+ 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(
);
+
+ 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(
);
+
+ const mobileMenu = screen.getByTestId('mobile-menu');
+ expect(mobileMenu).toBeInTheDocument();
+
+ const menuItems = screen.queryAllByRole('listitem');
+ expect(menuItems.length).toBe(0);
+ });
+});
diff --git a/src/studio-header/NavDropdownMenu.jsx b/src/studio-header/NavDropdownMenu.jsx
index e46c049..8f46f15 100644
--- a/src/studio-header/NavDropdownMenu.jsx
+++ b/src/studio-header/NavDropdownMenu.jsx
@@ -4,6 +4,7 @@ import {
Dropdown,
DropdownButton,
} from '@openedx/paragon';
+import { Link } from 'react-router-dom';
const NavDropdownMenu = ({
id,
@@ -18,8 +19,9 @@ const NavDropdownMenu = ({
>
{items.map(item => (
{item.title}
@@ -32,8 +34,8 @@ NavDropdownMenu.propTypes = {
id: PropTypes.string.isRequired,
buttonTitle: PropTypes.node.isRequired,
items: PropTypes.arrayOf(PropTypes.shape({
- href: PropTypes.string,
- title: PropTypes.node,
+ href: PropTypes.string.isRequired,
+ title: PropTypes.node.isRequired,
})).isRequired,
};
diff --git a/src/studio-header/NavDropdownMenu.test.jsx b/src/studio-header/NavDropdownMenu.test.jsx
new file mode 100644
index 0000000..887c684
--- /dev/null
+++ b/src/studio-header/NavDropdownMenu.test.jsx
@@ -0,0 +1,67 @@
+import React from 'react';
+import { render, screen, fireEvent } from '@testing-library/react';
+import '@testing-library/jest-dom/extend-expect';
+import { MemoryRouter } from 'react-router-dom';
+
+import NavDropdownMenu from './NavDropdownMenu';
+
+const defaultProps = {
+ id: 'menu-id',
+ buttonTitle: 'Menu',
+ items: [
+ { href: '/item1', title: 'Item 1' },
+ { href: 'https://external.com', title: 'External Link' },
+ ],
+};
+
+const RootWrapper = (props) => (
+
+
+
+);
+
+describe('NavDropdownMenu Component', () => {
+ afterEach(() => {
+ jest.clearAllMocks();
+ });
+
+ test('renders the dropdown button with correct title', () => {
+ render();
+
+ const dropdownButton = screen.getByRole('button', { name: defaultProps.buttonTitle });
+ expect(dropdownButton).toBeInTheDocument();
+ });
+
+ test('renders all dropdown items', () => {
+ render();
+
+ const dropdownButton = screen.getByRole('button', { name: defaultProps.buttonTitle });
+ fireEvent.click(dropdownButton);
+
+ const item1 = screen.getByText(defaultProps.items[0].title);
+ const externalLink = screen.getByText(defaultProps.items[1].title);
+
+ expect(item1).toBeInTheDocument();
+ expect(externalLink).toBeInTheDocument();
+ });
+
+ test('calls onNavigate with the correct URL for internal link', () => {
+ render();
+
+ const dropdownButton = screen.getByRole('button', { name: defaultProps.buttonTitle });
+ fireEvent.click(dropdownButton);
+
+ const item1 = screen.getByText(defaultProps.items[0].title);
+ expect(item1.getAttribute('href')).toBe(defaultProps.items[0].href);
+ });
+
+ test('navigates to external URL when external link is clicked', () => {
+ render();
+
+ const dropdownButton = screen.getByRole('button', { name: defaultProps.buttonTitle });
+ fireEvent.click(dropdownButton);
+
+ const externalLink = screen.getByText(defaultProps.items[1].title);
+ expect(externalLink.getAttribute('href')).toBe(defaultProps.items[1].href);
+ });
+});
diff --git a/src/studio-header/StudioHeader.jsx b/src/studio-header/StudioHeader.jsx
index 0ee6d85..6ad0823 100644
--- a/src/studio-header/StudioHeader.jsx
+++ b/src/studio-header/StudioHeader.jsx
@@ -16,7 +16,8 @@ ensureConfig([
], 'Studio Header component');
const StudioHeader = ({
- number, org, title, containerProps, isHiddenMainMenu, mainMenuDropdowns, outlineLink, searchButtonAction,
+ number, org, title, containerProps, isHiddenMainMenu, mainMenuDropdowns,
+ outlineLink, searchButtonAction, isNewHomePage,
}) => {
const { authenticatedUser, config } = useContext(AppContext);
const props = {
@@ -29,7 +30,7 @@ const StudioHeader = ({
username: authenticatedUser?.username,
isAdmin: authenticatedUser?.administrator,
authenticatedUserAvatar: authenticatedUser?.avatar,
- studioBaseUrl: config.STUDIO_BASE_URL,
+ studioBaseUrl: isNewHomePage ? '/home' : config.STUDIO_BASE_URL,
logoutUrl: config.LOGOUT_URL,
isHiddenMainMenu,
mainMenuDropdowns,
@@ -66,6 +67,7 @@ StudioHeader.propTypes = {
})),
outlineLink: PropTypes.string,
searchButtonAction: PropTypes.func,
+ isNewHomePage: PropTypes.bool.isRequired,
};
StudioHeader.defaultProps = {
diff --git a/src/studio-header/StudioHeader.test.jsx b/src/studio-header/StudioHeader.test.jsx
index 263cac2..793f91a 100644
--- a/src/studio-header/StudioHeader.test.jsx
+++ b/src/studio-header/StudioHeader.test.jsx
@@ -9,6 +9,7 @@ import {
import { AppContext } from '@edx/frontend-platform/react';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import { Context as ResponsiveContext } from 'react-responsive';
+import { MemoryRouter } from 'react-router-dom';
import StudioHeader from './StudioHeader';
import messages from './messages';
@@ -40,15 +41,17 @@ const RootWrapper = ({
return (
// eslint-disable-next-line react/jsx-no-constructed-context-values, react/prop-types
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
);
};
@@ -70,6 +73,7 @@ const props = {
],
outlineLink: 'tEsTLInK',
searchButtonAction: null,
+ isNewHomePage: true,
};
describe('Header', () => {
diff --git a/src/studio-header/utils.js b/src/studio-header/utils.js
index f734c65..e95f03c 100644
--- a/src/studio-header/utils.js
+++ b/src/studio-header/utils.js
@@ -1,3 +1,4 @@
+import { getConfig } from '@edx/frontend-platform';
import messages from './messages';
const getUserMenuItems = ({
@@ -21,7 +22,7 @@ const getUserMenuItems = ({
href: `${studioBaseUrl}`,
title: intl.formatMessage(messages['header.user.menu.studio']),
}, {
- href: `${studioBaseUrl}/maintenance`,
+ href: `${getConfig().STUDIO_BASE_URL}/maintenance`,
title: intl.formatMessage(messages['header.user.menu.maintenance']),
}, {
href: `${logoutUrl}`,