Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions .env.development
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,11 @@ ECOMMERCE_BASE_URL=http://localhost:18130
LANGUAGE_PREFERENCE_COOKIE_NAME=openedx-language-preference
LMS_BASE_URL=http://localhost:18000
STUDIO_BASE_URL=http://localhost:18010
LOGIN_URL=http://localhost:18000/login
LOGOUT_URL=http://localhost:18000/logout
MARKETING_SITE_BASE_URL=http://localhost:18000
LOGIN_URL=/login
LOGOUT_URL=/logout
MARKETING_SITE_BASE_URL=http://local.openedx.io:8000
ORDER_HISTORY_URL=localhost:1996/orders
REFRESH_ACCESS_TOKEN_ENDPOINT=http://localhost:18000/login_refresh
REFRESH_ACCESS_TOKEN_ENDPOINT=http://local.openedx.io:8000/login_refresh
SEGMENT_KEY=null
SITE_NAME=Open edX
USER_INFO_COOKIE_NAME=edx-user-info
Expand Down
5 changes: 0 additions & 5 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,3 @@ jobs:
run: npm run build
- name: i18n_extract
run: npm run i18n_extract
- name: Coverage
uses: codecov/codecov-action@v4
with:
token: ${{ secrets.CODECOV_TOKEN }}
fail_ci_if_error: true
2,885 changes: 1,621 additions & 1,264 deletions package-lock.json

Large diffs are not rendered by default.

10 changes: 5 additions & 5 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@edx/frontend-component-header",
"version": "1.0.0-semantically-released",
"name": "@pearsonedunext/frontend-component-header",
"version": "5.0.0-post",
"description": "The standard header for Open edX",
"main": "dist/index.js",
"publishConfig": {
Expand All @@ -22,14 +22,14 @@
],
"repository": {
"type": "git",
"url": "git+https://github.com/openedx/frontend-component-header.git"
"url": "git+https://github.com/Pearson-Advance/frontend-component-header.git"
},
"author": "edX",
"license": "AGPL-3.0",
"bugs": {
"url": "https://github.com/openedx/frontend-component-header/issues"
"url": "https://github.com/Pearson-Advance/frontend-component-header/issues"
},
"homepage": "https://github.com/openedx/frontend-component-header#readme",
"homepage": "https://github.com/Pearson-Advance/frontend-component-header#readme",
"devDependencies": {
"@edx/brand": "npm:@openedx/brand-openedx@^1.2.2",
"@edx/browserslist-config": "^1.1.1",
Expand Down
11 changes: 9 additions & 2 deletions src/Header.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ import {
} from '@edx/frontend-platform';

import PropTypes from 'prop-types';
import useGetMenuOptionsByRole from './hooks';

import DesktopHeaderSlot from './plugin-slots/DesktopHeaderSlot';
import MobileHeaderSlot from './plugin-slots/MobileHeaderSlot';

Expand Down Expand Up @@ -47,11 +49,13 @@ subscribe(APP_CONFIG_INITIALIZED, () => {
* See the documentation for the structure of user menu item.
*/
const Header = ({
mainMenuItems, secondaryMenuItems, userMenuItems,
mainMenuItems, secondaryMenuItems, userMenuItems, appID,
}) => {
const { authenticatedUser, config } = useContext(AppContext);
const intl = useIntl();

const itemsByRole = useGetMenuOptionsByRole(appID);

const defaultMainMenu = [
{
type: 'item',
Expand All @@ -67,6 +71,7 @@ const Header = ({
href: `${config.LMS_BASE_URL}/dashboard`,
content: intl.formatMessage(messages['header.user.menu.dashboard']),
},
...itemsByRole,
{
type: 'item',
href: `${config.ACCOUNT_PROFILE_URL}/u/${authenticatedUser.username}`,
Expand Down Expand Up @@ -113,7 +118,7 @@ const Header = ({
logoAltText: config.SITE_NAME,
logoDestination: `${config.LMS_BASE_URL}/dashboard`,
loggedIn: authenticatedUser !== null,
username: authenticatedUser !== null ? authenticatedUser.username : null,
username: authenticatedUser !== null ? authenticatedUser.name || authenticatedUser.username : null,
avatar: authenticatedUser !== null ? authenticatedUser.avatar : null,
mainMenu: getConfig().AUTHN_MINIMAL_HEADER ? [] : mainMenu,
secondaryMenu: getConfig().AUTHN_MINIMAL_HEADER ? [] : secondaryMenu,
Expand All @@ -137,6 +142,7 @@ Header.defaultProps = {
mainMenuItems: null,
secondaryMenuItems: null,
userMenuItems: null,
appID: 'header-component',
};

Header.propTypes = {
Expand All @@ -157,6 +163,7 @@ Header.propTypes = {
isActive: PropTypes.bool,
})),
})),
appID: PropTypes.string,
};

export default Header;
4 changes: 4 additions & 0 deletions src/Header.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ import { Context as ResponsiveContext } from 'react-responsive';

import Header from './index';

jest.mock('@edx/frontend-platform/logging', () => ({
logError: jest.fn(),
}));

const HeaderComponent = ({ width, contextValue }) => (
<ResponsiveContext.Provider value={width}>
<IntlProvider locale="en" messages={{}}>
Expand Down
26 changes: 26 additions & 0 deletions src/helpers/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
const EDX_HEADER_COOKIE_PAYLOAD = 'edx-jwt-cookie-header-payload';
const DEFAULT_ROLES = [];

function getCookie(name) {
return document.cookie
.split('; ')
.map(c => c.split('='))
.find(([key]) => key === name)?.[1] || null;
}

function getUserRolesFromCookie() {
let roles = DEFAULT_ROLES;
const headerPayload = getCookie(EDX_HEADER_COOKIE_PAYLOAD);

if (headerPayload) {
const [, payload] = headerPayload.split('.')
.map(part => JSON.parse(atob(part.replace(/-/g, '+').replace(/_/g, '/'))));
roles = payload?.extra_data?.permission_roles || DEFAULT_ROLES;

return roles;
}

return roles;
}

export { getUserRolesFromCookie, DEFAULT_ROLES };
89 changes: 89 additions & 0 deletions src/hooks/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import { useState, useEffect } from 'react';
import { logError } from '@edx/frontend-platform/logging';

import { getUserRolesFromCookie, DEFAULT_ROLES } from '../helpers';

const GLOBAL_STAFF = 'GLOBAL_STAFF';
const INSTITUTION_ADMIN = 'INSTITUTION_ADMIN';
const INSTRUCTOR = 'INSTRUCTOR';
const ROLES_PRIORITY = [GLOBAL_STAFF, INSTITUTION_ADMIN, INSTRUCTOR];

async function fetchMfeConfig(id = '') {
const URL = `${process.env.LMS_BASE_URL}/api/mfe_config/v1?mfe=${id}`;
const res = await fetch(URL);

if (!res.ok) { return logError('Unable to get mfe settings'); }

return res.json();
}

function getUserLinksByRole(userRoles, paths) {
const { instructorPath, institutionPath } = paths;

const CERTPREP_MANAGER_ITEM = {
type: 'item',
href: institutionPath,
content: 'Skilling Administrator',
};

const INSTRUCTOR_PORTAL_ITEM = {
type: 'item',
href: instructorPath,
content: 'Instructor Portal',
};

const ROLES_PERMISSIONS = {
GLOBAL_STAFF: [CERTPREP_MANAGER_ITEM],
INSTITUTION_ADMIN: [CERTPREP_MANAGER_ITEM],
INSTRUCTOR: [INSTRUCTOR_PORTAL_ITEM],
};

const roles = Array.isArray(userRoles) ? userRoles : [userRoles];
const validRoles = roles.filter(role => ROLES_PRIORITY.includes(role));

if (!validRoles.length) { return DEFAULT_ROLES; }

const sortedRoles = validRoles.sort(
(a, b) => ROLES_PRIORITY.indexOf(a) - ROLES_PRIORITY.indexOf(b),
);

const highestRole = sortedRoles[0];

if ((highestRole === GLOBAL_STAFF || highestRole === INSTITUTION_ADMIN) && roles.includes(INSTRUCTOR)) {
return [...(ROLES_PERMISSIONS[highestRole] || []), INSTRUCTOR_PORTAL_ITEM];
}

return ROLES_PERMISSIONS[highestRole] || [];
}

export async function loadMenuOptions(appID) {
const mfeSettings = await fetchMfeConfig(appID);

if (!mfeSettings.ENABLE_DROPDOWN_CUSTOM_OPTIONS) {
return [];
}

const roles = getUserRolesFromCookie();

return getUserLinksByRole(roles, {
instructorPath: mfeSettings.INSTRUCTOR_PORTAL_PATH,
institutionPath: mfeSettings.INSTITUTION_PORTAL_PATH,
});
}

function useGetMenuOptionsByRole(appID) {
const [userLinks, setUserLinks] = useState([]);

useEffect(() => {
loadMenuOptions(appID)
.then(setUserLinks)
.catch((err) => {
logError('Error loading custom options:', err);
setUserLinks([]);
});
}, [appID]);

return userLinks;
}

export default useGetMenuOptionsByRole;
12 changes: 10 additions & 2 deletions src/learning-header/AuthenticatedUserDropdown.jsx
Original file line number Diff line number Diff line change
@@ -1,23 +1,31 @@
import React from 'react';
import React, { useContext } from 'react';
import PropTypes from 'prop-types';

import { faUserCircle } from '@fortawesome/free-solid-svg-icons';
import { getConfig } from '@edx/frontend-platform';
import { AppContext } from '@edx/frontend-platform/react';
import { useIntl } from '@edx/frontend-platform/i18n';
import { Dropdown } from '@openedx/paragon';

import LearningUserMenuToggleSlot from '../plugin-slots/LearningUserMenuToggleSlot';
import LearningUserMenuSlot from '../plugin-slots/LearningUserMenuSlot';

import messages from './messages';
import useGetMenuOptionsByRole from '../hooks';

const AuthenticatedUserDropdown = ({ username }) => {
const intl = useIntl();
const { authenticatedUser } = useContext(AppContext);
const itemsByRole = useGetMenuOptionsByRole();

const displayName = authenticatedUser?.name || username;

const dropdownItems = [
{
message: intl.formatMessage(messages.dashboard),
href: `${getConfig().LMS_BASE_URL}/dashboard`,
},
...itemsByRole,
{
message: intl.formatMessage(messages.profile),
href: `${getConfig().ACCOUNT_PROFILE_URL}/u/${username}`,
Expand All @@ -39,7 +47,7 @@ const AuthenticatedUserDropdown = ({ username }) => {
return (
<Dropdown className="user-dropdown ml-3">
<Dropdown.Toggle variant="outline-primary" aria-label={intl.formatMessage(messages.userOptionsDropdownLabel)}>
<LearningUserMenuToggleSlot label={username} icon={faUserCircle} />
<LearningUserMenuToggleSlot label={displayName} icon={faUserCircle} />
</Dropdown.Toggle>
<Dropdown.Menu className="dropdown-menu-right">
<LearningUserMenuSlot items={dropdownItems} />
Expand Down
2 changes: 1 addition & 1 deletion src/learning-header/LearningHeader.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ describe('Header', () => {

it('displays user button', () => {
render(<Header />);
expect(screen.getByText(authenticatedUser.username)).toBeInTheDocument();
expect(screen.getByText(authenticatedUser.name)).toBeInTheDocument();
});

it('displays course data', () => {
Expand Down
2 changes: 2 additions & 0 deletions src/setupTest.js
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ class MockLoggingService {
export const authenticatedUser = {
userId: 'abc123',
username: 'Mock User',
name: 'Mock User Name',
roles: [],
administrator: false,
};
Expand All @@ -68,6 +69,7 @@ export function initializeMockApp() {
authenticatedUser: {
userId: 'abc123',
username: 'Mock User',
name: 'Mock User Name',
roles: [],
administrator: false,
},
Expand Down