From cb7774b325138348bc7c327b115eb45e9f6dc9bf Mon Sep 17 00:00:00 2001 From: Peter Kulko Date: Mon, 7 Oct 2024 17:11:44 +0300 Subject: [PATCH] feat: improved SPA routes --- package-lock.json | 3 +- package.json | 3 +- src/studio-header/BrandNav.jsx | 5 +- src/studio-header/BrandNav.test.jsx | 40 ++++++++ src/studio-header/CourseLockUp.jsx | 8 +- src/studio-header/CourseLockUp.test.jsx | 58 ++++++++++++ src/studio-header/HeaderBody.jsx | 7 +- src/studio-header/HeaderBody.test.jsx | 102 +++++++++++++++++++++ src/studio-header/MobileMenu.jsx | 9 +- src/studio-header/MobileMenu.test.jsx | 81 ++++++++++++++++ src/studio-header/NavDropdownMenu.jsx | 8 +- src/studio-header/NavDropdownMenu.test.jsx | 67 ++++++++++++++ src/studio-header/StudioHeader.jsx | 6 +- src/studio-header/StudioHeader.test.jsx | 22 +++-- src/studio-header/utils.js | 3 +- 15 files changed, 394 insertions(+), 28 deletions(-) create mode 100644 src/studio-header/BrandNav.test.jsx create mode 100644 src/studio-header/CourseLockUp.test.jsx create mode 100644 src/studio-header/HeaderBody.test.jsx create mode 100644 src/studio-header/MobileMenu.test.jsx create mode 100644 src/studio-header/NavDropdownMenu.test.jsx diff --git a/package-lock.json b/package-lock.json index 227b0e9a6..95b94a062 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 4df3252e0..5c758a204 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 9342c3b62..fd9ecd026 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, }) => ( - + {logoAltText} - + ); BrandNav.propTypes = { diff --git a/src/studio-header/BrandNav.test.jsx b/src/studio-header/BrandNav.test.jsx new file mode 100644 index 000000000..7ea2d3e81 --- /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 c5853d8c8..c6236147e 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 000000000..5dfc48f1f --- /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 536dca1e1..3ed7403af 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 000000000..6e5a5e69f --- /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 892151cad..892f60381 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 000000000..38041bee6 --- /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 e46c04908..8f46f15e1 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 000000000..887c68413 --- /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 0ee6d8539..6ad0823b6 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 263cac2f7..793f91ab7 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 f734c65c7..e95f03c4b 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}`,