Compare commits

..

9 Commits

Author SHA1 Message Date
renovate[bot]
5e405da37e fix(deps): update dependency @openedx/frontend-plugin-framework to v1.4.1 (#558)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-12-02 07:19:05 +00:00
renovate[bot]
901f39f42c chore(deps): update dependency @openedx/frontend-build to v14.2.0 (#557)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-11-25 06:30:07 +00:00
Brian Smith
346a636b76 feat: add pluginProps to CourseInfoSlot (#550) 2024-11-18 14:22:32 -05:00
renovate[bot]
34dcc88880 chore(deps): update dependency @openedx/paragon to v22.10.0 (#554)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-11-18 05:02:52 +00:00
Kyle McCormick
a229c34535 fix: Remove Studio Maintenance link (#553)
This Studio Maintenance app has been broken for a long time,
so it is being removed from edx-platform:
https://github.com/openedx/edx-platform/pull/35852
2024-11-15 10:52:36 -05:00
renovate[bot]
5d7b4fecf4 chore(deps): update dependency react-router-dom to v6.28.0 (#548)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-11-11 07:42:32 +00:00
renovate[bot]
f04130a7c6 fix(deps): update dependency @openedx/frontend-plugin-framework to v1.4.0 2024-11-04 07:18:59 +00:00
Peter Kulko
cb7774b325 feat: improved SPA routes 2024-11-01 13:03:59 -03:00
Bilal Qamar
3e4eb21d8c test: Remove support for Node 18 (#536) 2024-10-31 16:05:16 -04:00
19 changed files with 431 additions and 95 deletions

View File

@@ -9,9 +9,6 @@ on:
jobs: jobs:
tests: tests:
runs-on: ubuntu-latest runs-on: ubuntu-latest
strategy:
matrix:
node: [18, 20]
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
@@ -20,7 +17,7 @@ jobs:
- name: Setup Nodejs - name: Setup Nodejs
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: ${{ matrix.node }} node-version-file: '.nvmrc'
- name: Install dependencies - name: Install dependencies
run: npm ci run: npm ci
- name: Validate package-lock.json changes - name: Validate package-lock.json changes

61
package-lock.json generated
View File

@@ -27,8 +27,8 @@
"@edx/browserslist-config": "^1.1.1", "@edx/browserslist-config": "^1.1.1",
"@edx/frontend-platform": "8.1.2", "@edx/frontend-platform": "8.1.2",
"@edx/reactifex": "^2.1.1", "@edx/reactifex": "^2.1.1",
"@openedx/frontend-build": "14.1.5", "@openedx/frontend-build": "14.2.0",
"@openedx/paragon": "22.9.0", "@openedx/paragon": "22.10.0",
"@testing-library/dom": "10.4.0", "@testing-library/dom": "10.4.0",
"@testing-library/jest-dom": "5.17.0", "@testing-library/jest-dom": "5.17.0",
"@testing-library/react": "10.4.9", "@testing-library/react": "10.4.9",
@@ -39,7 +39,7 @@
"react": "17.0.2", "react": "17.0.2",
"react-dom": "17.0.2", "react-dom": "17.0.2",
"react-redux": "7.2.9", "react-redux": "7.2.9",
"react-router-dom": "6.27.0", "react-router-dom": "6.28.0",
"react-test-renderer": "17.0.2", "react-test-renderer": "17.0.2",
"redux": "4.2.1", "redux": "4.2.1",
"redux-saga": "1.3.0" "redux-saga": "1.3.0"
@@ -49,7 +49,8 @@
"@openedx/paragon": ">= 21.5.7 < 23.0.0", "@openedx/paragon": ">= 21.5.7 < 23.0.0",
"prop-types": "^15.5.10", "prop-types": "^15.5.10",
"react": "^16.9.0 || ^17.0.0", "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": { "node_modules/@adobe/css-tools": {
@@ -2032,9 +2033,10 @@
"dev": true "dev": true
}, },
"node_modules/@edx/eslint-config": { "node_modules/@edx/eslint-config": {
"version": "4.2.0", "version": "4.3.0",
"resolved": "https://registry.npmjs.org/@edx/eslint-config/-/eslint-config-4.2.0.tgz", "resolved": "https://registry.npmjs.org/@edx/eslint-config/-/eslint-config-4.3.0.tgz",
"integrity": "sha512-2wuIw49uyj6gRwS74qJ8WhBU+X2FOP4uot40sthIC4YU9qCM7WJOcOuAhkRPP1FvZKd3UQH3gZM7eJ85xzDBqA==", "integrity": "sha512-4W9wFG4ALr3xocakCsncgJbK67RHfSmDwHDXKHReFtjxl/FRkxhS6qayz189oChqfANieeV3zRCLaq44bLf+/A==",
"license": "MIT",
"peerDependencies": { "peerDependencies": {
"@typescript-eslint/eslint-plugin": "^5.62.0", "@typescript-eslint/eslint-plugin": "^5.62.0",
"@typescript-eslint/parser": "^5.62.0", "@typescript-eslint/parser": "^5.62.0",
@@ -3184,9 +3186,9 @@
} }
}, },
"node_modules/@openedx/frontend-build": { "node_modules/@openedx/frontend-build": {
"version": "14.1.5", "version": "14.2.0",
"resolved": "https://registry.npmjs.org/@openedx/frontend-build/-/frontend-build-14.1.5.tgz", "resolved": "https://registry.npmjs.org/@openedx/frontend-build/-/frontend-build-14.2.0.tgz",
"integrity": "sha512-QEdl55jNitdQL7RDAuX/EgfxsyBeEZfW3fc9Df4Py5KY6NKjRE7wNLeBMxYCFagEgXwaR1Btiw5NxzByAdlnfg==", "integrity": "sha512-yeBanEnfpYY3gci9vBmPlFZLzTIhbUVjM8DxldPeaHzG7IZzMVR14KNkS470siCpikNxarordg3fGXSDU+QYHA==",
"license": "AGPL-3.0", "license": "AGPL-3.0",
"dependencies": { "dependencies": {
"@babel/cli": "7.24.8", "@babel/cli": "7.24.8",
@@ -3197,7 +3199,7 @@
"@babel/plugin-syntax-dynamic-import": "7.8.3", "@babel/plugin-syntax-dynamic-import": "7.8.3",
"@babel/preset-env": "7.24.8", "@babel/preset-env": "7.24.8",
"@babel/preset-react": "7.24.7", "@babel/preset-react": "7.24.7",
"@edx/eslint-config": "4.2.0", "@edx/eslint-config": "^4.3.0",
"@edx/new-relic-source-map-webpack-plugin": "2.1.0", "@edx/new-relic-source-map-webpack-plugin": "2.1.0",
"@edx/typescript-config": "1.1.0", "@edx/typescript-config": "1.1.0",
"@formatjs/cli": "^6.0.3", "@formatjs/cli": "^6.0.3",
@@ -3319,9 +3321,10 @@
} }
}, },
"node_modules/@openedx/frontend-plugin-framework": { "node_modules/@openedx/frontend-plugin-framework": {
"version": "1.3.0", "version": "1.4.1",
"resolved": "https://registry.npmjs.org/@openedx/frontend-plugin-framework/-/frontend-plugin-framework-1.3.0.tgz", "resolved": "https://registry.npmjs.org/@openedx/frontend-plugin-framework/-/frontend-plugin-framework-1.4.1.tgz",
"integrity": "sha512-qLtX/4HIuWXiIhBdtBuL6mPVbV2un0rsFYx3I5+3tIUf7+T7WRq81a6JHU5QGyAmZy9dfiv7QwbqwiEQOVXVuQ==", "integrity": "sha512-8lVvq+kqb4CsPtD2CIf5nL+Ded6r+dTM/0DIwxCuoUTh4i5aCBwPY3gnKsfa1OS9IEJjeSgiMBieH8WRqUiixw==",
"license": "AGPL-3.0",
"dependencies": { "dependencies": {
"@edx/brand": "npm:@openedx/brand-openedx@^1.2.2", "@edx/brand": "npm:@openedx/brand-openedx@^1.2.2",
"classnames": "^2.3.2", "classnames": "^2.3.2",
@@ -3340,9 +3343,9 @@
} }
}, },
"node_modules/@openedx/paragon": { "node_modules/@openedx/paragon": {
"version": "22.9.0", "version": "22.10.0",
"resolved": "https://registry.npmjs.org/@openedx/paragon/-/paragon-22.9.0.tgz", "resolved": "https://registry.npmjs.org/@openedx/paragon/-/paragon-22.10.0.tgz",
"integrity": "sha512-r5xD+z64U3phkgT4ooUQaxE/4Rv0D91tpS3kA+mLfOT1vMD8jXIjDZp+/k4BEw4yqWQ8Eyb//ar8xiwL/ugojQ==", "integrity": "sha512-uwH/vN6PM9v77NIJ0MUyREdF+3LY/kXIVaOAN+TJKi6JexKoqM7jR30wGuI83YGymwthXDc8T4J54O/wXDoxrQ==",
"license": "Apache-2.0", "license": "Apache-2.0",
"workspaces": [ "workspaces": [
"example", "example",
@@ -3563,9 +3566,9 @@
"dev": true "dev": true
}, },
"node_modules/@remix-run/router": { "node_modules/@remix-run/router": {
"version": "1.20.0", "version": "1.21.0",
"resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.20.0.tgz", "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.21.0.tgz",
"integrity": "sha512-mUnk8rPJBI9loFDZ+YzPGdeniYK+FTmRD1TMCz7ev2SNIozyKKpnGgsxO34u6Z4z/t0ITuu7voi/AshfsGsgFg==", "integrity": "sha512-xfSkCAchbdG5PnbrKqFWwia4Bi61nH+wm8wLEqfHDyp7Y3dZzgqS2itV8i4gAq9pC2HsTpwyBC6Ds8VHZ96JlA==",
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=14.0.0" "node": ">=14.0.0"
@@ -14231,12 +14234,12 @@
} }
}, },
"node_modules/react-router": { "node_modules/react-router": {
"version": "6.27.0", "version": "6.28.0",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-6.27.0.tgz", "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.28.0.tgz",
"integrity": "sha512-YA+HGZXz4jaAkVoYBE98VQl+nVzI+cVI2Oj/06F5ZM+0u3TgedN9Y9kmMRo2mnkSK2nCpNQn0DVob4HCsY/WLw==", "integrity": "sha512-HrYdIFqdrnhDw0PqG/AKjAqEqM7AvxCz0DQ4h2W8k6nqmc5uRBYDag0SBxx9iYz5G8gnuNVLzUe13wl9eAsXXg==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@remix-run/router": "1.20.0" "@remix-run/router": "1.21.0"
}, },
"engines": { "engines": {
"node": ">=14.0.0" "node": ">=14.0.0"
@@ -14246,13 +14249,13 @@
} }
}, },
"node_modules/react-router-dom": { "node_modules/react-router-dom": {
"version": "6.27.0", "version": "6.28.0",
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.27.0.tgz", "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.28.0.tgz",
"integrity": "sha512-+bvtFWMC0DgAFrfKXKG9Fc+BcXWRUO1aJIihbB79xaeq0v5UzfvnM5houGUm1Y461WVRcgAQ+Clh5rdb1eCx4g==", "integrity": "sha512-kQ7Unsl5YdyOltsPGl31zOjLrDv+m2VcIEcIHqYYD3Lp0UppLjrzcfJqDJwXxFw3TH/yvapbnUvPlAj7Kx5nbg==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@remix-run/router": "1.20.0", "@remix-run/router": "1.21.0",
"react-router": "6.27.0" "react-router": "6.28.0"
}, },
"engines": { "engines": {
"node": ">=14.0.0" "node": ">=14.0.0"

View File

@@ -37,8 +37,8 @@
"@edx/browserslist-config": "^1.1.1", "@edx/browserslist-config": "^1.1.1",
"@edx/frontend-platform": "8.1.2", "@edx/frontend-platform": "8.1.2",
"@edx/reactifex": "^2.1.1", "@edx/reactifex": "^2.1.1",
"@openedx/frontend-build": "14.1.5", "@openedx/frontend-build": "14.2.0",
"@openedx/paragon": "22.9.0", "@openedx/paragon": "22.10.0",
"@testing-library/dom": "10.4.0", "@testing-library/dom": "10.4.0",
"@testing-library/jest-dom": "5.17.0", "@testing-library/jest-dom": "5.17.0",
"@testing-library/react": "10.4.9", "@testing-library/react": "10.4.9",
@@ -49,7 +49,7 @@
"react": "17.0.2", "react": "17.0.2",
"react-dom": "17.0.2", "react-dom": "17.0.2",
"react-redux": "7.2.9", "react-redux": "7.2.9",
"react-router-dom": "6.27.0", "react-router-dom": "6.28.0",
"react-test-renderer": "17.0.2", "react-test-renderer": "17.0.2",
"redux": "4.2.1", "redux": "4.2.1",
"redux-saga": "1.3.0" "redux-saga": "1.3.0"
@@ -73,6 +73,7 @@
"@openedx/paragon": ">= 21.5.7 < 23.0.0", "@openedx/paragon": ">= 21.5.7 < 23.0.0",
"prop-types": "^15.5.10", "prop-types": "^15.5.10",
"react": "^16.9.0 || ^17.0.0", "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"
} }
} }

View File

@@ -61,11 +61,6 @@ const messages = defineMessages({
defaultMessage: 'Studio Home', defaultMessage: 'Studio Home',
description: 'Link to the Studio Home', description: 'Link to the Studio Home',
}, },
'header.user.menu.studio.maintenance': {
id: 'header.user.menu.studio.maintenance',
defaultMessage: 'Maintenance',
description: 'Link to the Studio Maintenance',
},
'header.label.account.nav': { 'header.label.account.nav': {
id: 'header.label.account.nav', id: 'header.label.account.nav',
defaultMessage: 'Account', defaultMessage: 'Account',

View File

@@ -13,6 +13,11 @@ const CourseInfoSlot = ({
slotOptions={{ slotOptions={{
mergeProps: true, mergeProps: true,
}} }}
pluginProps={{
courseOrg,
courseNumber,
courseTitle,
}}
> >
<LearningHeaderCourseInfo <LearningHeaderCourseInfo
courseOrg={courseOrg} courseOrg={courseOrg}

View File

@@ -1,18 +1,19 @@
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { Link } from 'react-router-dom';
const BrandNav = ({ const BrandNav = ({
studioBaseUrl, studioBaseUrl,
logo, logo,
logoAltText, logoAltText,
}) => ( }) => (
<a href={studioBaseUrl}> <Link to={studioBaseUrl}>
<img <img
src={logo} src={logo}
alt={logoAltText} alt={logoAltText}
className="d-block logo" className="d-block logo"
/> />
</a> </Link>
); );
BrandNav.propTypes = { BrandNav.propTypes = {

View File

@@ -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);
});
});

View File

@@ -5,6 +5,8 @@ import {
OverlayTrigger, OverlayTrigger,
Tooltip, Tooltip,
} from '@openedx/paragon'; } from '@openedx/paragon';
import { Link } from 'react-router-dom';
import messages from './messages'; import messages from './messages';
const CourseLockUp = ({ const CourseLockUp = ({
@@ -23,15 +25,15 @@ const CourseLockUp = ({
</Tooltip> </Tooltip>
)} )}
> >
<a <Link
className="course-title-lockup mr-2" className="course-title-lockup mr-2"
href={outlineLink} to={outlineLink}
aria-label={intl.formatMessage(messages['header.label.courseOutline'])} aria-label={intl.formatMessage(messages['header.label.courseOutline'])}
data-testid="course-lock-up-block" 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 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> <span className="d-block m-0 font-weight-bold text-gray-800" data-testid="course-title">{title}</span>
</a> </Link>
</OverlayTrigger> </OverlayTrigger>
); );

View File

@@ -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);
});
});

View File

@@ -103,7 +103,12 @@ const HeaderBody = ({
{mainMenuDropdowns.map(dropdown => { {mainMenuDropdowns.map(dropdown => {
const { id, buttonTitle, items } = dropdown; const { id, buttonTitle, items } = dropdown;
return ( return (
<NavDropdownMenu key={id} {...{ id, buttonTitle, items }} /> <NavDropdownMenu
key={id}
{...{
id, buttonTitle, items,
}}
/>
); );
})} })}
</Nav> </Nav>

View File

@@ -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();
});
});

View File

@@ -1,10 +1,9 @@
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { Collapsible } from '@openedx/paragon'; import { Collapsible } from '@openedx/paragon';
import { Link } from 'react-router-dom';
const MobileMenu = ({ const MobileMenu = ({ mainMenuDropdowns }) => (
mainMenuDropdowns,
}) => (
<div <div
className="ml-4 p-2 bg-light-100 border border-gray-200 small rounded" className="ml-4 p-2 bg-light-100 border border-gray-200 small rounded"
data-testid="mobile-menu" data-testid="mobile-menu"
@@ -21,9 +20,9 @@ const MobileMenu = ({
<ul className="p-0" style={{ listStyleType: 'none' }}> <ul className="p-0" style={{ listStyleType: 'none' }}>
{items.map(item => ( {items.map(item => (
<li className="mobile-menu-item"> <li className="mobile-menu-item">
<a href={item.href}> <Link to={item.href}>
{item.title} {item.title}
</a> </Link>
</li> </li>
))} ))}
</ul> </ul>

View File

@@ -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);
});
});

View File

@@ -4,6 +4,7 @@ import {
Dropdown, Dropdown,
DropdownButton, DropdownButton,
} from '@openedx/paragon'; } from '@openedx/paragon';
import { Link } from 'react-router-dom';
const NavDropdownMenu = ({ const NavDropdownMenu = ({
id, id,
@@ -18,8 +19,9 @@ const NavDropdownMenu = ({
> >
{items.map(item => ( {items.map(item => (
<Dropdown.Item <Dropdown.Item
as={Link}
key={`${item.title}-dropdown-item`} key={`${item.title}-dropdown-item`}
href={item.href} to={item.href}
className="small" className="small"
> >
{item.title} {item.title}
@@ -32,8 +34,8 @@ NavDropdownMenu.propTypes = {
id: PropTypes.string.isRequired, id: PropTypes.string.isRequired,
buttonTitle: PropTypes.node.isRequired, buttonTitle: PropTypes.node.isRequired,
items: PropTypes.arrayOf(PropTypes.shape({ items: PropTypes.arrayOf(PropTypes.shape({
href: PropTypes.string, href: PropTypes.string.isRequired,
title: PropTypes.node, title: PropTypes.node.isRequired,
})).isRequired, })).isRequired,
}; };

View File

@@ -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) => (
<MemoryRouter>
<NavDropdownMenu {...props} />
</MemoryRouter>
);
describe('NavDropdownMenu Component', () => {
afterEach(() => {
jest.clearAllMocks();
});
test('renders the dropdown button with correct title', () => {
render(<NavDropdownMenu {...defaultProps} />);
const dropdownButton = screen.getByRole('button', { name: defaultProps.buttonTitle });
expect(dropdownButton).toBeInTheDocument();
});
test('renders all dropdown items', () => {
render(<RootWrapper {...defaultProps} />);
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(<RootWrapper {...defaultProps} />);
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(<RootWrapper {...defaultProps} />);
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);
});
});

View File

@@ -16,7 +16,8 @@ ensureConfig([
], 'Studio Header component'); ], 'Studio Header component');
const StudioHeader = ({ 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 { authenticatedUser, config } = useContext(AppContext);
const props = { const props = {
@@ -29,7 +30,7 @@ const StudioHeader = ({
username: authenticatedUser?.username, username: authenticatedUser?.username,
isAdmin: authenticatedUser?.administrator, isAdmin: authenticatedUser?.administrator,
authenticatedUserAvatar: authenticatedUser?.avatar, authenticatedUserAvatar: authenticatedUser?.avatar,
studioBaseUrl: config.STUDIO_BASE_URL, studioBaseUrl: isNewHomePage ? '/home' : config.STUDIO_BASE_URL,
logoutUrl: config.LOGOUT_URL, logoutUrl: config.LOGOUT_URL,
isHiddenMainMenu, isHiddenMainMenu,
mainMenuDropdowns, mainMenuDropdowns,
@@ -66,6 +67,7 @@ StudioHeader.propTypes = {
})), })),
outlineLink: PropTypes.string, outlineLink: PropTypes.string,
searchButtonAction: PropTypes.func, searchButtonAction: PropTypes.func,
isNewHomePage: PropTypes.bool.isRequired,
}; };
StudioHeader.defaultProps = { StudioHeader.defaultProps = {

View File

@@ -9,9 +9,9 @@ import {
import { AppContext } from '@edx/frontend-platform/react'; import { AppContext } from '@edx/frontend-platform/react';
import { IntlProvider } from '@edx/frontend-platform/i18n'; import { IntlProvider } from '@edx/frontend-platform/i18n';
import { Context as ResponsiveContext } from 'react-responsive'; import { Context as ResponsiveContext } from 'react-responsive';
import { MemoryRouter } from 'react-router-dom';
import StudioHeader from './StudioHeader'; import StudioHeader from './StudioHeader';
import messages from './messages';
const authenticatedUser = { const authenticatedUser = {
userId: 3, userId: 3,
@@ -40,15 +40,17 @@ const RootWrapper = ({
return ( return (
// eslint-disable-next-line react/jsx-no-constructed-context-values, react/prop-types // eslint-disable-next-line react/jsx-no-constructed-context-values, react/prop-types
<IntlProvider locale="en"> <MemoryRouter>
<AppContext.Provider value={appContextValue}> <IntlProvider locale="en">
<ResponsiveContext.Provider value={responsiveContextValue}> <AppContext.Provider value={appContextValue}>
<StudioHeader <ResponsiveContext.Provider value={responsiveContextValue}>
{...props} <StudioHeader
/> {...props}
</ResponsiveContext.Provider> />
</AppContext.Provider> </ResponsiveContext.Provider>
</IntlProvider> </AppContext.Provider>
</IntlProvider>
</MemoryRouter>
); );
}; };
@@ -70,6 +72,7 @@ const props = {
], ],
outlineLink: 'tEsTLInK', outlineLink: 'tEsTLInK',
searchButtonAction: null, searchButtonAction: null,
isNewHomePage: true,
}; };
describe('Header', () => { describe('Header', () => {
@@ -111,16 +114,6 @@ describe('Header', () => {
expect(dropdownOption).toBeVisible(); expect(dropdownOption).toBeVisible();
}); });
it('maintenance should not be in user menu', async () => {
currentUser = { ...authenticatedUser, administrator: false };
const { getAllByRole, queryByText } = render(<RootWrapper {...props} />);
const userMenu = getAllByRole('button')[1];
await waitFor(() => fireEvent.click(userMenu));
const maintenanceButton = queryByText(messages['header.user.menu.maintenance'].defaultMessage);
expect(maintenanceButton).toBeNull();
});
it('user menu should use avatar icon', async () => { it('user menu should use avatar icon', async () => {
currentUser = { ...authenticatedUser, avatar: null }; currentUser = { ...authenticatedUser, avatar: null };
const { getByTestId } = render(<RootWrapper {...props} />); const { getByTestId } = render(<RootWrapper {...props} />);
@@ -182,15 +175,6 @@ describe('Header', () => {
expect(desktopMenu).toBeNull(); expect(desktopMenu).toBeNull();
}); });
it('maintenance should be in user menu', async () => {
const { getAllByRole, getByText } = render(<RootWrapper {...props} />);
const userMenu = getAllByRole('button')[1];
await waitFor(() => fireEvent.click(userMenu));
const maintenanceButton = getByText(messages['header.user.menu.maintenance'].defaultMessage);
expect(maintenanceButton).toBeVisible();
});
it('user menu should use avatar image', async () => { it('user menu should use avatar image', async () => {
const { getByTestId } = render(<RootWrapper {...props} />); const { getByTestId } = render(<RootWrapper {...props} />);
const avatarImage = getByTestId('avatar-image'); const avatarImage = getByTestId('avatar-image');

View File

@@ -6,11 +6,6 @@ const messages = defineMessages({
defaultMessage: 'Studio Home', defaultMessage: 'Studio Home',
description: 'Link to Studio Home', description: 'Link to Studio Home',
}, },
'header.user.menu.maintenance': {
id: 'header.user.menu.maintenance',
defaultMessage: 'Maintenance',
description: 'Link to the Studio maintenance page',
},
'header.user.menu.logout': { 'header.user.menu.logout': {
id: 'header.user.menu.logout', id: 'header.user.menu.logout',
defaultMessage: 'Logout', defaultMessage: 'Logout',

View File

@@ -20,9 +20,6 @@ const getUserMenuItems = ({
{ {
href: `${studioBaseUrl}`, href: `${studioBaseUrl}`,
title: intl.formatMessage(messages['header.user.menu.studio']), title: intl.formatMessage(messages['header.user.menu.studio']),
}, {
href: `${studioBaseUrl}/maintenance`,
title: intl.formatMessage(messages['header.user.menu.maintenance']),
}, { }, {
href: `${logoutUrl}`, href: `${logoutUrl}`,
title: intl.formatMessage(messages['header.user.menu.logout']), title: intl.formatMessage(messages['header.user.menu.logout']),