Compare commits

...

11 Commits

Author SHA1 Message Date
Brian Smith
a5024c3fde fix: move overflow: hidden to address mixed-decls warning (#549)
https://sass-lang.com/documentation/breaking-changes/mixed-decls/
2024-12-09 12:20:38 -05:00
renovate[bot]
d7be18e717 chore(deps): update dependency @openedx/frontend-build to v14.2.2 (#559)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-12-09 06:44:08 +00:00
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
20 changed files with 448 additions and 109 deletions

View File

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

88
package-lock.json generated
View File

@@ -27,8 +27,8 @@
"@edx/browserslist-config": "^1.1.1",
"@edx/frontend-platform": "8.1.2",
"@edx/reactifex": "^2.1.1",
"@openedx/frontend-build": "14.1.5",
"@openedx/paragon": "22.9.0",
"@openedx/frontend-build": "14.2.2",
"@openedx/paragon": "22.10.0",
"@testing-library/dom": "10.4.0",
"@testing-library/jest-dom": "5.17.0",
"@testing-library/react": "10.4.9",
@@ -39,7 +39,7 @@
"react": "17.0.2",
"react-dom": "17.0.2",
"react-redux": "7.2.9",
"react-router-dom": "6.27.0",
"react-router-dom": "6.28.0",
"react-test-renderer": "17.0.2",
"redux": "4.2.1",
"redux-saga": "1.3.0"
@@ -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": {
@@ -2032,9 +2033,10 @@
"dev": true
},
"node_modules/@edx/eslint-config": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/@edx/eslint-config/-/eslint-config-4.2.0.tgz",
"integrity": "sha512-2wuIw49uyj6gRwS74qJ8WhBU+X2FOP4uot40sthIC4YU9qCM7WJOcOuAhkRPP1FvZKd3UQH3gZM7eJ85xzDBqA==",
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/@edx/eslint-config/-/eslint-config-4.3.0.tgz",
"integrity": "sha512-4W9wFG4ALr3xocakCsncgJbK67RHfSmDwHDXKHReFtjxl/FRkxhS6qayz189oChqfANieeV3zRCLaq44bLf+/A==",
"license": "MIT",
"peerDependencies": {
"@typescript-eslint/eslint-plugin": "^5.62.0",
"@typescript-eslint/parser": "^5.62.0",
@@ -3184,9 +3186,9 @@
}
},
"node_modules/@openedx/frontend-build": {
"version": "14.1.5",
"resolved": "https://registry.npmjs.org/@openedx/frontend-build/-/frontend-build-14.1.5.tgz",
"integrity": "sha512-QEdl55jNitdQL7RDAuX/EgfxsyBeEZfW3fc9Df4Py5KY6NKjRE7wNLeBMxYCFagEgXwaR1Btiw5NxzByAdlnfg==",
"version": "14.2.2",
"resolved": "https://registry.npmjs.org/@openedx/frontend-build/-/frontend-build-14.2.2.tgz",
"integrity": "sha512-RLhRoYE8+9A4YknEZyZwLreXeUV4u+QKQXLFf07H8YkW2U6A+8f/ANMDb6Vqw4NsQ/s6eg8SjzpWKwX1FO7qwg==",
"license": "AGPL-3.0",
"dependencies": {
"@babel/cli": "7.24.8",
@@ -3197,7 +3199,7 @@
"@babel/plugin-syntax-dynamic-import": "7.8.3",
"@babel/preset-env": "7.24.8",
"@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/typescript-config": "1.1.0",
"@formatjs/cli": "^6.0.3",
@@ -3229,14 +3231,14 @@
"eslint-plugin-react-hooks": "4.6.0",
"express": "^4.18.2",
"file-loader": "6.2.0",
"html-webpack-plugin": "5.6.0",
"html-webpack-plugin": "5.6.3",
"identity-obj-proxy": "3.0.0",
"image-minimizer-webpack-plugin": "3.8.3",
"jest": "29.6.1",
"jest-environment-jsdom": "29.6.1",
"mini-css-extract-plugin": "1.6.2",
"parse5": "7.1.2",
"postcss": "8.4.47",
"postcss": "8.4.49",
"postcss-custom-media": "10.0.8",
"postcss-loader": "7.3.4",
"postcss-rtlcss": "5.1.2",
@@ -3319,9 +3321,10 @@
}
},
"node_modules/@openedx/frontend-plugin-framework": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/@openedx/frontend-plugin-framework/-/frontend-plugin-framework-1.3.0.tgz",
"integrity": "sha512-qLtX/4HIuWXiIhBdtBuL6mPVbV2un0rsFYx3I5+3tIUf7+T7WRq81a6JHU5QGyAmZy9dfiv7QwbqwiEQOVXVuQ==",
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/@openedx/frontend-plugin-framework/-/frontend-plugin-framework-1.4.1.tgz",
"integrity": "sha512-8lVvq+kqb4CsPtD2CIf5nL+Ded6r+dTM/0DIwxCuoUTh4i5aCBwPY3gnKsfa1OS9IEJjeSgiMBieH8WRqUiixw==",
"license": "AGPL-3.0",
"dependencies": {
"@edx/brand": "npm:@openedx/brand-openedx@^1.2.2",
"classnames": "^2.3.2",
@@ -3340,9 +3343,9 @@
}
},
"node_modules/@openedx/paragon": {
"version": "22.9.0",
"resolved": "https://registry.npmjs.org/@openedx/paragon/-/paragon-22.9.0.tgz",
"integrity": "sha512-r5xD+z64U3phkgT4ooUQaxE/4Rv0D91tpS3kA+mLfOT1vMD8jXIjDZp+/k4BEw4yqWQ8Eyb//ar8xiwL/ugojQ==",
"version": "22.10.0",
"resolved": "https://registry.npmjs.org/@openedx/paragon/-/paragon-22.10.0.tgz",
"integrity": "sha512-uwH/vN6PM9v77NIJ0MUyREdF+3LY/kXIVaOAN+TJKi6JexKoqM7jR30wGuI83YGymwthXDc8T4J54O/wXDoxrQ==",
"license": "Apache-2.0",
"workspaces": [
"example",
@@ -3563,9 +3566,9 @@
"dev": true
},
"node_modules/@remix-run/router": {
"version": "1.20.0",
"resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.20.0.tgz",
"integrity": "sha512-mUnk8rPJBI9loFDZ+YzPGdeniYK+FTmRD1TMCz7ev2SNIozyKKpnGgsxO34u6Z4z/t0ITuu7voi/AshfsGsgFg==",
"version": "1.21.0",
"resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.21.0.tgz",
"integrity": "sha512-xfSkCAchbdG5PnbrKqFWwia4Bi61nH+wm8wLEqfHDyp7Y3dZzgqS2itV8i4gAq9pC2HsTpwyBC6Ds8VHZ96JlA==",
"license": "MIT",
"engines": {
"node": ">=14.0.0"
@@ -9464,9 +9467,10 @@
}
},
"node_modules/html-webpack-plugin": {
"version": "5.6.0",
"resolved": "https://registry.npmjs.org/html-webpack-plugin/-/html-webpack-plugin-5.6.0.tgz",
"integrity": "sha512-iwaY4wzbe48AfKLZ/Cc8k0L+FKG6oSNRaZ8x5A/T/IVDGyXcbHncM9TdDa93wn0FsSm82FhTKW7f3vS61thXAw==",
"version": "5.6.3",
"resolved": "https://registry.npmjs.org/html-webpack-plugin/-/html-webpack-plugin-5.6.3.tgz",
"integrity": "sha512-QSf1yjtSAsmf7rYBV7XX86uua4W/vkhIt0xNXKbsi2foEeW7vjJQz4bhnpL3xH+l1ryl1680uNv968Z+X6jSYg==",
"license": "MIT",
"dependencies": {
"@types/html-minifier-terser": "^6.0.0",
"html-minifier-terser": "^6.0.2",
@@ -12627,9 +12631,10 @@
}
},
"node_modules/picocolors": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.0.tgz",
"integrity": "sha512-TQ92mBOW0l3LeMeyLV6mzy/kWr8lkd/hp3mTg7wYK7zJhuBStmGMBG0BdeDZS/dZx1IukaX6Bk11zcln25o1Aw=="
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
"license": "ISC"
},
"node_modules/picomatch": {
"version": "2.3.1",
@@ -12854,9 +12859,9 @@
}
},
"node_modules/postcss": {
"version": "8.4.47",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.47.tgz",
"integrity": "sha512-56rxCq7G/XfB4EkXq9Egn5GCqugWvDFjafDOThIdMBsI15iqPqR5r15TfSr1YPYeEI19YeaXMCbY6u88Y76GLQ==",
"version": "8.4.49",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.49.tgz",
"integrity": "sha512-OCVPnIObs4N29kxTjzLfUryOkvZEq+pf8jTF0lg8E7uETuWHA+v7j3c/xJmiqpX450191LlmZfUKkXxkTry7nA==",
"funding": [
{
"type": "opencollective",
@@ -12871,9 +12876,10 @@
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"dependencies": {
"nanoid": "^3.3.7",
"picocolors": "^1.1.0",
"picocolors": "^1.1.1",
"source-map-js": "^1.2.1"
},
"engines": {
@@ -14231,12 +14237,12 @@
}
},
"node_modules/react-router": {
"version": "6.27.0",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-6.27.0.tgz",
"integrity": "sha512-YA+HGZXz4jaAkVoYBE98VQl+nVzI+cVI2Oj/06F5ZM+0u3TgedN9Y9kmMRo2mnkSK2nCpNQn0DVob4HCsY/WLw==",
"version": "6.28.0",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-6.28.0.tgz",
"integrity": "sha512-HrYdIFqdrnhDw0PqG/AKjAqEqM7AvxCz0DQ4h2W8k6nqmc5uRBYDag0SBxx9iYz5G8gnuNVLzUe13wl9eAsXXg==",
"license": "MIT",
"dependencies": {
"@remix-run/router": "1.20.0"
"@remix-run/router": "1.21.0"
},
"engines": {
"node": ">=14.0.0"
@@ -14246,13 +14252,13 @@
}
},
"node_modules/react-router-dom": {
"version": "6.27.0",
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.27.0.tgz",
"integrity": "sha512-+bvtFWMC0DgAFrfKXKG9Fc+BcXWRUO1aJIihbB79xaeq0v5UzfvnM5houGUm1Y461WVRcgAQ+Clh5rdb1eCx4g==",
"version": "6.28.0",
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.28.0.tgz",
"integrity": "sha512-kQ7Unsl5YdyOltsPGl31zOjLrDv+m2VcIEcIHqYYD3Lp0UppLjrzcfJqDJwXxFw3TH/yvapbnUvPlAj7Kx5nbg==",
"license": "MIT",
"dependencies": {
"@remix-run/router": "1.20.0",
"react-router": "6.27.0"
"@remix-run/router": "1.21.0",
"react-router": "6.28.0"
},
"engines": {
"node": ">=14.0.0"

View File

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

View File

@@ -61,11 +61,6 @@ const messages = defineMessages({
defaultMessage: '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': {
id: 'header.label.account.nav',
defaultMessage: 'Account',

View File

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

View File

@@ -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 = {

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,
Tooltip,
} from '@openedx/paragon';
import { Link } from 'react-router-dom';
import messages from './messages';
const CourseLockUp = ({
@@ -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>
);

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 => {
const { id, buttonTitle, items } = dropdown;
return (
<NavDropdownMenu key={id} {...{ id, buttonTitle, items }} />
<NavDropdownMenu
key={id}
{...{
id, buttonTitle, items,
}}
/>
);
})}
</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 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"
@@ -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>

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,
DropdownButton,
} from '@openedx/paragon';
import { Link } from 'react-router-dom';
const NavDropdownMenu = ({
id,
@@ -18,8 +19,9 @@ const NavDropdownMenu = ({
>
{items.map(item => (
<Dropdown.Item
as={Link}
key={`${item.title}-dropdown-item`}
href={item.href}
to={item.href}
className="small"
>
{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,
};

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');
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 = {

View File

@@ -29,6 +29,8 @@ $white: #FFFFFF;
}
.course-title-lockup {
overflow: hidden;
@media only screen and (min-width: 769px) {
padding: .5rem;
padding-right: $spacer;
@@ -36,8 +38,6 @@ $white: #FFFFFF;
width: 70%;
}
overflow: hidden;
span {
color: #333333;
white-space: nowrap;

View File

@@ -9,9 +9,9 @@ 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';
const authenticatedUser = {
userId: 3,
@@ -40,15 +40,17 @@ const RootWrapper = ({
return (
// eslint-disable-next-line react/jsx-no-constructed-context-values, react/prop-types
<IntlProvider locale="en">
<AppContext.Provider value={appContextValue}>
<ResponsiveContext.Provider value={responsiveContextValue}>
<StudioHeader
{...props}
/>
</ResponsiveContext.Provider>
</AppContext.Provider>
</IntlProvider>
<MemoryRouter>
<IntlProvider locale="en">
<AppContext.Provider value={appContextValue}>
<ResponsiveContext.Provider value={responsiveContextValue}>
<StudioHeader
{...props}
/>
</ResponsiveContext.Provider>
</AppContext.Provider>
</IntlProvider>
</MemoryRouter>
);
};
@@ -70,6 +72,7 @@ const props = {
],
outlineLink: 'tEsTLInK',
searchButtonAction: null,
isNewHomePage: true,
};
describe('Header', () => {
@@ -111,16 +114,6 @@ describe('Header', () => {
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 () => {
currentUser = { ...authenticatedUser, avatar: null };
const { getByTestId } = render(<RootWrapper {...props} />);
@@ -182,15 +175,6 @@ describe('Header', () => {
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 () => {
const { getByTestId } = render(<RootWrapper {...props} />);
const avatarImage = getByTestId('avatar-image');

View File

@@ -6,11 +6,6 @@ const messages = defineMessages({
defaultMessage: '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': {
id: 'header.user.menu.logout',
defaultMessage: 'Logout',

View File

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