diff --git a/src/components/menu/__tests__/menu.test.js b/src/components/menu/__tests__/menu.test.js
new file mode 100644
index 000000000..129e01e4c
--- /dev/null
+++ b/src/components/menu/__tests__/menu.test.js
@@ -0,0 +1,63 @@
+import React from "react";
+import { render, screen, fireEvent } from "@testing-library/react";
+import "@testing-library/jest-dom";
+import Menu from "../index";
+
+jest.mock("i18n-react/dist/i18n-react", () => ({
+ __esModule: true,
+ default: { translate: (key) => key }
+}));
+
+jest.mock("react-router-dom", () => ({
+ withRouter: (component) => component
+}));
+
+jest.mock("../../../models/member", () => jest.fn().mockImplementation(() => ({
+ hasAccess: () => true
+ })));
+
+jest.mock("../menu-definition", () => ({
+ getGlobalItems: () => [{ name: "directory", linkUrl: "directory" }],
+ getSummitItems: () => [{ name: "summit_dashboard", linkUrl: "dashboard" }]
+}));
+
+const mockHistory = {
+ push: jest.fn(),
+ location: { pathname: "/app/directory" }
+};
+
+const renderMenu = (props = {}) =>
+ render(
);
+
+describe("Menu", () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ test("renders the general section", () => {
+ renderMenu();
+ expect(screen.getByText("menu.general")).toBeInTheDocument();
+ });
+
+ test("does not render summit section when currentSummit is not provided", () => {
+ renderMenu();
+ expect(screen.queryByText("Test Summit")).not.toBeInTheDocument();
+ });
+
+ test("renders summit section when currentSummit has a valid id", () => {
+ renderMenu({ currentSummit: { id: 1, name: "Test Summit" } });
+ expect(screen.getByText("Test Summit")).toBeInTheDocument();
+ });
+
+ test("does not render summit section when currentSummit.id is 0", () => {
+ renderMenu({ currentSummit: { id: 0, name: "Test Summit" } });
+ expect(screen.queryByText("Test Summit")).not.toBeInTheDocument();
+ });
+
+ test("navigates when a menu item is clicked", () => {
+ renderMenu();
+ const link = screen.getByText("menu.directory");
+ fireEvent.click(link);
+ expect(mockHistory.push).toHaveBeenCalledWith("/app/directory");
+ });
+});
diff --git a/src/components/menu/expandable-item.js b/src/components/menu/expandable-item.js
new file mode 100644
index 000000000..a9aace324
--- /dev/null
+++ b/src/components/menu/expandable-item.js
@@ -0,0 +1,54 @@
+/**
+ * Copyright 2026 OpenStack Foundation
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ * http://www.apache.org/licenses/LICENSE-2.0
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ * */
+
+import React, { useState } from "react";
+import ListItemButton from "@mui/material/ListItemButton";
+import Typography from "@mui/material/Typography";
+import Collapse from "@mui/material/Collapse";
+import List from "@mui/material/List";
+import ExpandLess from "@mui/icons-material/ExpandLess";
+import ExpandMore from "@mui/icons-material/ExpandMore";
+
+function ExpandableItem({
+ label,
+ children,
+ defaultOpen = true,
+ isHeader = false
+}) {
+ const [open, setOpen] = useState(defaultOpen);
+
+ return (
+ <>
+ setOpen((prev) => !prev)}
+ sx={{ py: 1, ...(isHeader ? { px: 2 } : { pl: 2 }) }}
+ >
+
+ {label}
+
+ {open ? : }
+
+
+ {children}
+
+ >
+ );
+}
+
+export default ExpandableItem;
diff --git a/src/components/menu/index.js b/src/components/menu/index.js
index 44c0aaf64..2ad078c55 100644
--- a/src/components/menu/index.js
+++ b/src/components/menu/index.js
@@ -1,5 +1,5 @@
/**
- * Copyright 2017 OpenStack Foundation
+ * Copyright 2026 OpenStack Foundation
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
@@ -11,545 +11,114 @@
* limitations under the License.
* */
-import React, { useEffect, useState } from "react";
+import React, { useState } from "react";
import T from "i18n-react/dist/i18n-react";
import { withRouter } from "react-router-dom";
+import Box from "@mui/material/Box";
+import Divider from "@mui/material/Divider";
+import IconButton from "@mui/material/IconButton";
+import MenuIcon from "@mui/icons-material/Menu";
+import ChevronRightIcon from "@mui/icons-material/ChevronRight";
import SubMenuItem from "./sub-menu-item";
import MenuItem from "./menu-item";
+import ExpandableItem from "./expandable-item";
import Member from "../../models/member";
+import { getGlobalItems, getSummitItems } from "./menu-definition";
import styles from "./menu.module.less";
-const getGlobalItems = () => [
- {
- name: "directory",
- iconClass: "fa-fw fa-list-ul",
- linkUrl: "directory"
- },
- {
- name: "speakers",
- iconClass: "fa-users",
- accessRoute: "speakers",
- childs: [
- {
- name: "speaker_list",
- linkUrl: "speakers",
- accessRoute: "speaker-list"
- },
- {
- name: "merge_speakers",
- linkUrl: "speakers/merge",
- accessRoute: "speakers-merge"
- }
- ]
- },
- {
- name: "companies",
- iconClass: "fa fa-copyright",
- linkUrl: "companies",
- accessRoute: "companies"
- },
- {
- name: "sponsors_inventory",
- iconClass: "fa-users",
- accessRoute: "inventory",
- childs: [
- {
- name: "inventory",
- linkUrl: "inventory"
- },
- {
- name: "form_templates",
- linkUrl: "form-templates"
- },
- {
- name: "page_templates",
- linkUrl: "page-templates"
- }
- ]
- },
- {
- name: "sponsorship_types",
- iconClass: "fa fa-handshake-o",
- linkUrl: "sponsorship-types",
- accessRoute: "sponsorship-types"
- },
- {
- name: "tags",
- iconClass: "fa fa-tag",
- linkUrl: "tags",
- accessRoute: "tags"
- },
- {
- name: "sponsored_projects",
- iconClass: "fa fa-cubes",
- linkUrl: "sponsored-projects",
- accessRoute: "sponsored-projects",
- exclusive: "sponsored-projects"
- },
- {
- name: "emails",
- iconClass: "fa-envelope-o",
- accessRoute: "emails",
- childs: [
- { name: "email_templates", linkUrl: "emails/templates" },
- { name: "email_logs", linkUrl: "emails/log" }
- ]
- },
- {
- name: "admin_access",
- iconClass: "fa-arrow-circle-o-right",
- linkUrl: "admin-access",
- accessRoute: "admin-access"
- },
- {
- name: "media_file_types",
- iconClass: "fa-file-text-o",
- linkUrl: "media-file-types",
- accessRoute: "admin-access"
- }
-];
-
-const getSummitItems = (summitId) => [
- {
- name: "audit_log",
- iconClass: "fa-tasks",
- linkUrl: `summits/${summitId}/audit-log`,
- accessRoute: "audit-log"
- },
- {
- name: "dashboard",
- iconClass: "fa-dashboard",
- linkUrl: `summits/${summitId}/dashboard`,
- accessRoute: "general"
- },
- {
- name: "selection_plans",
- iconClass: "fa-bars",
- linkUrl: `summits/${summitId}/selection-plans`,
- accessRoute: "selection_plans"
- },
- {
- name: "events",
- iconClass: "fa-calendar",
- accessRoute: "events",
- childs: [
- { name: "new_event", linkUrl: `summits/${summitId}/events/new` },
- { name: "event_list", linkUrl: `summits/${summitId}/events` },
- { name: "schedule", linkUrl: `summits/${summitId}/events/schedule` },
- { name: "event_types", linkUrl: `summits/${summitId}/event-types` },
- {
- name: "event_categories",
- linkUrl: `summits/${summitId}/event-categories`
- },
- {
- name: "event_category_groups",
- linkUrl: `summits/${summitId}/event-category-groups`
- },
- {
- name: "voteable_presentations",
- linkUrl: `summits/${summitId}/voteable-presentations`
- },
- {
- name: "media_uploads",
- linkUrl: `summits/${summitId}/media-uploads`
- }
- ]
- },
- {
- name: "attendees",
- iconClass: "fa-users",
- accessRoute: "attendees",
- childs: [
- {
- name: "attendee-list",
- linkUrl: `summits/${summitId}/attendees`,
- accessRoute: "attendees"
- },
- {
- name: "badge_checkin",
- linkUrl: `summits/${summitId}/attendees/checkin`
- }
- ]
- },
- {
- name: "summit_speakers",
- iconClass: "fa-users",
- accessRoute: "events",
- childs: [
- {
- name: "submission_invitations",
- iconClass: "fa-ticket",
- linkUrl: `summits/${summitId}/submission-invitations`,
- accessRoute: "speakers"
- },
- {
- name: "speakers",
- iconClass: "fa-users",
- linkUrl: `summits/${summitId}/speakers`,
- accessRoute: "speakers"
- },
- {
- name: "speaker_attendance",
- iconClass: "fa-users",
- linkUrl: `summits/${summitId}/speaker-attendances`,
- accessRoute: "speakers"
- },
- {
- name: "featured_speakers",
- iconClass: "fa-star",
- linkUrl: `summits/${summitId}/featured-speakers`,
- accessRoute: "speakers"
- }
- ]
- },
- {
- name: "track_chairs",
- iconClass: "fa-user-circle-o",
- accessRoute: "track-chairs",
- childs: [
- {
- name: "track_chair_list",
- linkUrl: `summits/${summitId}/track-chairs`
- },
- {
- name: "progress_flags",
- linkUrl: `summits/${summitId}/track-chairs/progress-flags`,
- accessRoute: "progress-flags"
- },
- {
- name: "track_timeframes",
- linkUrl: `summits/${summitId}/track-chairs/track-timeframes`,
- accessRoute: "track-timeframes"
- },
- {
- name: "track_chair_team_lists",
- linkUrl: `summits/${summitId}/track-chairs/team-lists`,
- accessRoute: "team-lists"
- }
- ]
- },
- {
- name: "sponsors",
- iconClass: "fa-handshake-o",
- accessRoute: "sponsors",
- childs: [
- {
- name: "sponsor_list",
- linkUrl: `summits/${summitId}/sponsors`,
- accessRoute: "sponsors"
- },
- {
- name: "sponsor_forms",
- linkUrl: `summits/${summitId}/sponsors/forms`,
- accessRoute: "admin-sponsors"
- },
- {
- name: "sponsor_pages",
- linkUrl: `summits/${summitId}/sponsors/pages`,
- accessRoute: "admin-sponsors"
- },
- {
- name: "sponsorship_list",
- linkUrl: `summits/${summitId}/sponsorships`,
- accessRoute: "admin-sponsors"
- },
- {
- name: "sponsor_users",
- linkUrl: `summits/${summitId}/sponsors/users`,
- accessRoute: "admin-sponsors"
- },
- {
- name: "sponsors_promocodes",
- linkUrl: `summits/${summitId}/sponsors/promocodes`,
- accessRoute: "admin-sponsors"
- },
- {
- name: "sponsor_settings",
- linkUrl: `summits/${summitId}/sponsors/settings`,
- accessRoute: "admin-sponsors"
- },
- {
- name: "badge_scans",
- linkUrl: `summits/${summitId}/badge-scans`,
- accessRoute: "badge-scans"
- }
- ]
- },
- {
- name: "locations",
- iconClass: "fa-map-marker",
- linkUrl: `summits/${summitId}/locations`,
- accessRoute: "locations"
- },
- {
- name: "signage",
- iconClass: "fa-map-signs",
- linkUrl: `summits/${summitId}/signage`,
- accessRoute: "signage"
- },
- {
- name: "purchase_orders",
- iconClass: "fa-money",
- accessRoute: "purchase-orders",
- childs: [
- {
- name: "purchase_order_list",
- linkUrl: `summits/${summitId}/purchase-orders`
- },
- { name: "ticket_list", linkUrl: `summits/${summitId}/tickets` },
- {
- name: "order_extra_questions",
- linkUrl: `summits/${summitId}/order-extra-questions`
- },
- {
- name: "registration_stats",
- linkUrl: `summits/${summitId}/registration-stats`
- }
- ]
- },
- {
- name: "tickets",
- iconClass: "fa-ticket",
- accessRoute: "tickets",
- childs: [
- {
- name: "registration_invitation_list",
- linkUrl: `summits/${summitId}/registration-invitations`
- },
- {
- name: "ticket_type_list",
- linkUrl: `summits/${summitId}/ticket-types`
- },
- {
- name: "promocode_list",
- linkUrl: `summits/${summitId}/promocodes`
- },
- { name: "tax_type_list", linkUrl: `summits/${summitId}/tax-types` },
- {
- name: "refund_policy_list",
- linkUrl: `summits/${summitId}/refund-policies`
- },
- {
- name: "payment_profiles_list",
- linkUrl: `summits/${summitId}/payment-profiles`
- },
- {
- name: "registration_companies_list",
- linkUrl: `summits/${summitId}/registration-companies`
- }
- ]
- },
- {
- name: "badges",
- iconClass: "fa-id-card-o",
- accessRoute: "badges",
- childs: [
- {
- name: "badge_feature_list",
- linkUrl: `summits/${summitId}/badge-features`
- },
- {
- name: "access_level_list",
- linkUrl: `summits/${summitId}/access-levels`
- },
- {
- name: "view_type_list",
- linkUrl: `summits/${summitId}/view-types`
- },
- {
- name: "badge_type_list",
- linkUrl: `summits/${summitId}/badge-types`
- },
- {
- name: "badge_settings",
- linkUrl: `summits/${summitId}/badge-settings`
- }
- ]
- },
- {
- name: "room_bookings",
- iconClass: "fa-bookmark",
- linkUrl: `summits/${summitId}/room-bookings`,
- accessRoute: "room-bookings",
- exclusive: "room-bookings"
- },
- {
- name: "push_notifications",
- iconClass: "fa-paper-plane",
- linkUrl: `summits/${summitId}/push-notifications`,
- accessRoute: "push-notifications"
- },
- {
- name: "room_occupancy",
- iconClass: "fa-male",
- linkUrl: `summits/${summitId}/room-occupancy`,
- accessRoute: "room-occupancy"
- },
- {
- name: "tag_groups",
- iconClass: "fa-tags",
- linkUrl: `summits/${summitId}/tag-groups`,
- accessRoute: "tag-groups"
- },
- {
- name: "reports",
- iconClass: "fa-list-ol",
- linkUrl: `summits/${summitId}/reports`,
- accessRoute: "reports"
- },
- {
- name: "summitdocs",
- iconClass: "fa-file-text",
- linkUrl: `summits/${summitId}/summitdocs`,
- accessRoute: "summitdocs"
- },
- {
- name: "email_flow_events",
- iconClass: "fa-envelope-o",
- accessRoute: "email-flow-events",
- childs: [
- {
- name: "email_flow_overrides",
- linkUrl: `summits/${summitId}/email-flow-events`
- },
- {
- name: "email_flow_settings",
- linkUrl: `summits/${summitId}/email-flow-events-settings`
- }
- ]
- },
- {
- name: "settings",
- iconClass: "fa-cog",
- accessRoute: "settings",
- childs: [
- { name: "marketing", linkUrl: `summits/${summitId}/marketing` },
- {
- name: "schedule_settings",
- linkUrl: `summits/${summitId}/schedule-settings`
- }
- ]
- }
-];
-
const Menu = ({ currentSummit, member, history }) => {
const [menuOpen, setMenuOpen] = useState(false);
- const [subMenuOpen, setSubMenuOpen] = useState([]);
const memberObj = new Member(member);
- const canEditSummit = memberObj.canEditSummit();
const globalItems = getGlobalItems();
- const summitItems = getSummitItems(currentSummit.id);
-
- const closeMenu = () => {
- setMenuOpen(false);
- };
+ const summitItems = currentSummit ? getSummitItems(currentSummit.id) : [];
- const toggleSubMenu = (ev, submenu) => {
- ev.preventDefault();
- let newSubMenuOpen = [];
-
- if (subMenuOpen.includes(submenu)) {
- newSubMenuOpen = subMenuOpen.filter((sm) => sm !== submenu);
- } else {
- newSubMenuOpen = [...subMenuOpen, submenu];
- }
-
- setSubMenuOpen(newSubMenuOpen);
- setMenuOpen(true);
- };
+ const closeMenu = () => setMenuOpen(false);
const onMenuItemClick = (ev, url) => {
ev.preventDefault();
- setMenuOpen(false);
+ closeMenu();
history.push(`/app/${url}`);
};
+ const currentPath = history.location.pathname;
+
const drawMenuItem = (item) => {
const hasAccess =
!item.accessRoute || memberObj.hasAccess(item.accessRoute);
if (!hasAccess) return null;
- if (item.childs) {
+ if (item.subItems) {
return (
toggleSubMenu(e, item.name)}
onItemClick={onMenuItemClick}
+ currentPath={currentPath}
/>
);
}
return (