diff --git a/src/components/menu/__tests__/menu.test.js b/src/components/menu/__tests__/menu.test.js index b417e76aa..36b6e7229 100644 --- a/src/components/menu/__tests__/menu.test.js +++ b/src/components/menu/__tests__/menu.test.js @@ -19,18 +19,20 @@ jest.mock("../../../models/member", () => })) ); -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 menuDefs = { + global: [{ name: "directory", linkUrl: "directory" }], + summit: [{ name: "summit_dashboard", linkUrl: "dashboard" }] +}; + const renderMenu = (props = {}) => - render(); + render( + + ); describe("Menu", () => { beforeEach(() => { diff --git a/src/components/menu/__tests__/sub-menu-item.test.js b/src/components/menu/__tests__/sub-menu-item.test.js new file mode 100644 index 000000000..dd6b01659 --- /dev/null +++ b/src/components/menu/__tests__/sub-menu-item.test.js @@ -0,0 +1,99 @@ +import React from "react"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import "@testing-library/jest-dom"; +import SubMenuItem from "../sub-menu-item"; + +jest.mock("i18n-react/dist/i18n-react", () => ({ + __esModule: true, + default: { translate: (key) => key } +})); + +// folder > schedule > rooms is three levels deep to exercise the recursion +const subItems = [ + { name: "event_list", linkUrl: "events", accessRoute: "events" }, + { + name: "schedule", + subItems: [ + { + name: "schedule_settings", + linkUrl: "schedule/settings", + accessRoute: "schedule" + }, + { + name: "rooms", + subItems: [ + { name: "room_list", linkUrl: "rooms", accessRoute: "rooms" } + ] + } + ] + }, + { + name: "track_chairs", + subItems: [ + { + name: "track_chair_list", + linkUrl: "track-chairs", + accessRoute: "track-chairs" + } + ] + } +]; + +const ALL_ROUTES = ["events", "schedule", "rooms", "track-chairs"]; + +const memberWith = (routes) => ({ + hasAccess: (route) => routes.includes(route) +}); + +const renderSubMenu = (props = {}) => + render( + + ); + +describe("SubMenuItem", () => { + test("hides the folder when none of its children are accessible", () => { + const { container } = renderSubMenu({ memberObj: memberWith([]) }); + + expect(container).toBeEmptyDOMElement(); + }); + + test("hides a nested group when none of its children are accessible", () => { + renderSubMenu({ + memberObj: memberWith(["events", "schedule", "rooms"]), + currentPath: "/app/events" + }); + + expect(screen.getByText("menu.schedule")).toBeInTheDocument(); + expect(screen.queryByText("menu.track_chairs")).not.toBeInTheDocument(); + }); + + test("opens every group down to the current route and leaves siblings closed", () => { + renderSubMenu({ currentPath: "/app/rooms" }); + + expect(screen.getByText("menu.room_list")).toBeInTheDocument(); + expect(screen.getByText("menu.track_chairs")).toBeInTheDocument(); + expect(screen.queryByText("menu.track_chair_list")).not.toBeInTheDocument(); + }); + + test("renders nested groups recursively and navigates from the deepest leaf", async () => { + const onItemClick = jest.fn(); + renderSubMenu({ onItemClick }); + + expect(screen.queryByText("menu.room_list")).not.toBeInTheDocument(); + + await userEvent.click(screen.getByText("menu.summit_events")); + await userEvent.click(screen.getByText("menu.schedule")); + await userEvent.click(screen.getByText("menu.rooms")); + await userEvent.click(screen.getByText("menu.room_list")); + + expect(onItemClick).toHaveBeenCalledWith(expect.anything(), "rooms"); + }); +}); diff --git a/src/components/menu/expandable-item.js b/src/components/menu/expandable-item.js index a9aace324..72d372f84 100644 --- a/src/components/menu/expandable-item.js +++ b/src/components/menu/expandable-item.js @@ -19,11 +19,15 @@ import List from "@mui/material/List"; import ExpandLess from "@mui/icons-material/ExpandLess"; import ExpandMore from "@mui/icons-material/ExpandMore"; +const DEFAULT_PL = 2; +const HEADER_PR = 2; + function ExpandableItem({ label, children, defaultOpen = true, - isHeader = false + isHeader = false, + pl }) { const [open, setOpen] = useState(defaultOpen); @@ -31,7 +35,11 @@ function ExpandableItem({ <> setOpen((prev) => !prev)} - sx={{ py: 1, ...(isHeader ? { px: 2 } : { pl: 2 }) }} + sx={{ + py: 1, + pl: pl ?? DEFAULT_PL, + ...(isHeader && { pr: HEADER_PR }) + }} > { +const Menu = ({ currentSummit, member, history, onNavigate, menuDefs }) => { const memberObj = new Member(member); - const globalItems = getGlobalItems(); - const summitItems = currentSummit ? getSummitItems(currentSummit.id) : []; const onMenuItemClick = (ev, url) => { ev.preventDefault(); @@ -66,14 +63,14 @@ const Menu = ({ currentSummit, member, history, onNavigate }) => { return ( <> - {globalItems.map(drawMenuItem)} + {menuDefs.global.map(drawMenuItem)} {!!currentSummit?.id && ( <> - {summitItems.map(drawMenuItem)} + {menuDefs.summit.map(drawMenuItem)} )} diff --git a/src/components/menu/menu-definition.js b/src/components/menu/menu-definition.js index b03098d26..3fb6aa62a 100644 --- a/src/components/menu/menu-definition.js +++ b/src/components/menu/menu-definition.js @@ -33,36 +33,48 @@ export const getGlobalItems = () => [ ] }, { - name: "companies", - linkUrl: "companies", - accessRoute: "companies" - }, - { - name: "sponsors_inventory", - accessRoute: "inventory", + name: "sponsors_services", subItems: [ { - name: "inventory", - linkUrl: "inventory" - }, - { - name: "form_templates", - linkUrl: "form-templates" + name: "commercial_offerings", + subItems: [ + { name: "inventory", linkUrl: "inventory", accessRoute: "inventory" }, + { + name: "form_templates", + linkUrl: "form-templates", + accessRoute: "inventory" + } + ] }, { name: "page_templates", - linkUrl: "page-templates" + linkUrl: "page-templates", + accessRoute: "inventory" }, { name: "add_on_types", - linkUrl: "add-on-types" + linkUrl: "add-on-types", + accessRoute: "inventory" + }, + { + name: "sponsorship_types", + linkUrl: "sponsorship-types", + accessRoute: "sponsorship-types" } ] }, { - name: "sponsorship_types", - linkUrl: "sponsorship-types", - accessRoute: "sponsorship-types" + name: "emails", + accessRoute: "emails", + subItems: [ + { name: "email_templates", linkUrl: "emails/templates" }, + { name: "email_logs", linkUrl: "emails/log" } + ] + }, + { + name: "companies", + linkUrl: "companies", + accessRoute: "companies" }, { name: "tags", @@ -75,14 +87,6 @@ export const getGlobalItems = () => [ accessRoute: "sponsored-projects", exclusive: "sponsored-projects" }, - { - name: "emails", - accessRoute: "emails", - subItems: [ - { name: "email_templates", linkUrl: "emails/templates" }, - { name: "email_logs", linkUrl: "emails/log" } - ] - }, { name: "admin_access", linkUrl: "admin-access", @@ -96,110 +100,234 @@ export const getGlobalItems = () => [ ]; export const getSummitItems = (summitId) => [ - { - name: "audit_log", - linkUrl: `summits/${summitId}/audit-log`, - accessRoute: "audit-log" - }, { name: "dashboard", linkUrl: `summits/${summitId}/dashboard`, accessRoute: "general" }, { - name: "selection_plans", - linkUrl: `summits/${summitId}/selection-plans`, - accessRoute: "selection_plans" - }, - { - name: "events", - accessRoute: "events", + name: "program_management", subItems: [ - { 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: "events", + subItems: [ + { + name: "new_event", + linkUrl: `summits/${summitId}/events/new`, + accessRoute: "events" + }, + { + name: "event_list", + linkUrl: `summits/${summitId}/events`, + accessRoute: "events" + }, + { + name: "event_types", + linkUrl: `summits/${summitId}/event-types`, + accessRoute: "events" + }, + { + name: "event_categories", + linkUrl: `summits/${summitId}/event-categories`, + accessRoute: "events" + }, + { + name: "event_category_groups", + linkUrl: `summits/${summitId}/event-category-groups`, + accessRoute: "events" + } + ] + }, + { + name: "track_chairs", + accessRoute: "track-chairs", + subItems: [ + { + name: "track_chair_list", + linkUrl: `summits/${summitId}/track-chairs`, + accessRoute: "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: "summit_speakers", + subItems: [ + { + name: "summit_speaker_list", + linkUrl: `summits/${summitId}/speakers`, + accessRoute: "speakers" + }, + { + name: "submission_invitations", + linkUrl: `summits/${summitId}/submission-invitations`, + accessRoute: "speakers" + }, + { + name: "speaker_attendance", + linkUrl: `summits/${summitId}/speaker-attendances`, + accessRoute: "speakers" + }, + { + name: "featured_speakers", + linkUrl: `summits/${summitId}/featured-speakers`, + accessRoute: "speakers" + } + ] + }, + { + name: "selection_plans", + linkUrl: `summits/${summitId}/selection-plans`, + accessRoute: "selection_plans" + }, + { + name: "schedule", + linkUrl: `summits/${summitId}/events/schedule`, + accessRoute: "events" }, { name: "voteable_presentations", - linkUrl: `summits/${summitId}/voteable-presentations` + linkUrl: `summits/${summitId}/voteable-presentations`, + accessRoute: "events" }, { name: "media_uploads", - linkUrl: `summits/${summitId}/media-uploads` - } - ] - }, - { - name: "attendees", - accessRoute: "attendees", - subItems: [ - { - name: "attendee-list", - linkUrl: `summits/${summitId}/attendees`, - accessRoute: "attendees" - }, - { - name: "badge_checkin", - linkUrl: `summits/${summitId}/attendees/checkin` + linkUrl: `summits/${summitId}/media-uploads`, + accessRoute: "events" } ] }, { - name: "summit_speakers", - accessRoute: "events", + name: "attendee_operations", subItems: [ { - name: "submission_invitations", - linkUrl: `summits/${summitId}/submission-invitations`, - accessRoute: "speakers" - }, - { - name: "speakers", - linkUrl: `summits/${summitId}/speakers`, - accessRoute: "speakers" - }, - { - name: "speaker_attendance", - linkUrl: `summits/${summitId}/speaker-attendances`, - accessRoute: "speakers" - }, - { - name: "featured_speakers", - linkUrl: `summits/${summitId}/featured-speakers`, - accessRoute: "speakers" + name: "attendees", + subItems: [ + { + name: "attendee-list", + linkUrl: `summits/${summitId}/attendees`, + accessRoute: "attendees" + }, + { + name: "badge_checkin", + linkUrl: `summits/${summitId}/attendees/checkin`, + accessRoute: "attendees" + } + ] + }, + { + name: "badges", + subItems: [ + { + name: "badge_feature_list", + linkUrl: `summits/${summitId}/badge-features`, + accessRoute: "badges" + }, + { + name: "access_level_list", + linkUrl: `summits/${summitId}/access-levels`, + accessRoute: "badges" + }, + { + name: "view_type_list", + linkUrl: `summits/${summitId}/view-types`, + accessRoute: "badges" + }, + { + name: "badge_type_list", + linkUrl: `summits/${summitId}/badge-types`, + accessRoute: "badges" + }, + { + name: "badge_settings", + linkUrl: `summits/${summitId}/badge-settings`, + accessRoute: "badges" + } + ] } ] }, { - name: "track_chairs", - accessRoute: "track-chairs", + name: "registration", subItems: [ { - 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: "purchase_orders", + subItems: [ + { + name: "purchase_order_list", + linkUrl: `summits/${summitId}/purchase-orders`, + accessRoute: "purchase-orders" + }, + { + name: "ticket_list", + linkUrl: `summits/${summitId}/tickets`, + accessRoute: "purchase-orders" + }, + { + name: "order_extra_questions", + linkUrl: `summits/${summitId}/order-extra-questions`, + accessRoute: "purchase-orders" + }, + { + name: "registration_stats", + linkUrl: `summits/${summitId}/registration-stats`, + accessRoute: "purchase-orders" + } + ] + }, + { + name: "tickets", + subItems: [ + { + name: "registration_invitation_list", + linkUrl: `summits/${summitId}/registration-invitations`, + accessRoute: "tickets" + }, + { + name: "ticket_type_list", + linkUrl: `summits/${summitId}/ticket-types`, + accessRoute: "tickets" + }, + { + name: "promocode_list", + linkUrl: `summits/${summitId}/promocodes`, + accessRoute: "tickets" + }, + { + name: "tax_type_list", + linkUrl: `summits/${summitId}/tax-types`, + accessRoute: "tickets" + }, + { + name: "refund_policy_list", + linkUrl: `summits/${summitId}/refund-policies`, + accessRoute: "tickets" + }, + { + name: "payment_profiles_list", + linkUrl: `summits/${summitId}/payment-profiles`, + accessRoute: "tickets" + }, + { + name: "registration_companies_list", + linkUrl: `summits/${summitId}/registration-companies`, + accessRoute: "tickets" + } + ] } ] }, @@ -260,144 +388,93 @@ export const getSummitItems = (summitId) => [ ] }, { - name: "locations", - linkUrl: `summits/${summitId}/locations`, - accessRoute: "locations" - }, - { - name: "signage", - linkUrl: `summits/${summitId}/signage`, - accessRoute: "signage" - }, - { - name: "purchase_orders", - accessRoute: "purchase-orders", + name: "venue_schedule", subItems: [ { - name: "purchase_order_list", - linkUrl: `summits/${summitId}/purchase-orders` + name: "locations", + linkUrl: `summits/${summitId}/locations`, + accessRoute: "locations" + }, + { + name: "signage", + linkUrl: `summits/${summitId}/signage`, + accessRoute: "signage" }, - { name: "ticket_list", linkUrl: `summits/${summitId}/tickets` }, { - name: "order_extra_questions", - linkUrl: `summits/${summitId}/order-extra-questions` + name: "room_bookings", + linkUrl: `summits/${summitId}/room-bookings`, + accessRoute: "room-bookings", + exclusive: "room-bookings" }, { - name: "registration_stats", - linkUrl: `summits/${summitId}/registration-stats` + name: "room_occupancy", + linkUrl: `summits/${summitId}/room-occupancy`, + accessRoute: "room-occupancy" } ] }, { - name: "tickets", - accessRoute: "tickets", + name: "communications", subItems: [ { - 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: "email_flow_events", + subItems: [ + { + name: "email_flow_overrides", + linkUrl: `summits/${summitId}/email-flow-events`, + accessRoute: "email-flow-events" + }, + { + name: "email_flow_settings", + linkUrl: `summits/${summitId}/email-flow-events-settings`, + accessRoute: "email-flow-events" + } + ] + }, + { + name: "push_notifications", + linkUrl: `summits/${summitId}/push-notifications`, + accessRoute: "push-notifications" } ] }, { - name: "badges", - accessRoute: "badges", + name: "event_administration", subItems: [ { - name: "badge_feature_list", - linkUrl: `summits/${summitId}/badge-features` - }, - { - name: "access_level_list", - linkUrl: `summits/${summitId}/access-levels` + name: "settings", + subItems: [ + { + name: "marketing", + linkUrl: `summits/${summitId}/marketing`, + accessRoute: "settings" + }, + { + name: "schedule_settings", + linkUrl: `summits/${summitId}/schedule-settings`, + accessRoute: "settings" + } + ] }, { - name: "view_type_list", - linkUrl: `summits/${summitId}/view-types` + name: "reports", + linkUrl: `summits/${summitId}/reports`, + accessRoute: "reports" }, { - name: "badge_type_list", - linkUrl: `summits/${summitId}/badge-types` + name: "summitdocs", + linkUrl: `summits/${summitId}/summitdocs`, + accessRoute: "summitdocs" }, { - name: "badge_settings", - linkUrl: `summits/${summitId}/badge-settings` - } - ] - }, - { - name: "room_bookings", - linkUrl: `summits/${summitId}/room-bookings`, - accessRoute: "room-bookings", - exclusive: "room-bookings" - }, - { - name: "push_notifications", - linkUrl: `summits/${summitId}/push-notifications`, - accessRoute: "push-notifications" - }, - { - name: "room_occupancy", - linkUrl: `summits/${summitId}/room-occupancy`, - accessRoute: "room-occupancy" - }, - { - name: "tag_groups", - linkUrl: `summits/${summitId}/tag-groups`, - accessRoute: "tag-groups" - }, - { - name: "reports", - linkUrl: `summits/${summitId}/reports`, - accessRoute: "reports" - }, - { - name: "summitdocs", - linkUrl: `summits/${summitId}/summitdocs`, - accessRoute: "summitdocs" - }, - { - name: "email_flow_events", - accessRoute: "email-flow-events", - subItems: [ - { - name: "email_flow_overrides", - linkUrl: `summits/${summitId}/email-flow-events` + name: "tag_groups", + linkUrl: `summits/${summitId}/tag-groups`, + accessRoute: "tag-groups" }, { - name: "email_flow_settings", - linkUrl: `summits/${summitId}/email-flow-events-settings` - } - ] - }, - { - name: "settings", - accessRoute: "settings", - subItems: [ - { name: "marketing", linkUrl: `summits/${summitId}/marketing` }, - { - name: "schedule_settings", - linkUrl: `summits/${summitId}/schedule-settings` + name: "audit_log", + linkUrl: `summits/${summitId}/audit-log`, + accessRoute: "audit-log" } ] } diff --git a/src/components/menu/menu-item.js b/src/components/menu/menu-item.js index 4b1098001..86dab76cc 100644 --- a/src/components/menu/menu-item.js +++ b/src/components/menu/menu-item.js @@ -17,14 +17,16 @@ import Exclusive from "openstack-uicore-foundation/lib/components/exclusive-wrap import ListItemButton from "@mui/material/ListItemButton"; import Typography from "@mui/material/Typography"; -const MenuItem = ({ name, onClick, exclusive, nested, selected }) => { +const NESTED_PL = 4; +const TOP_LEVEL_PL = 2; + +const MenuItem = ({ name, onClick, exclusive, nested, selected, pl }) => { const itemHtml = ( + !item.hasOwnProperty("accessRoute") || memberObj.hasAccess(item.accessRoute); + +// Do not show a parent folder if the user does not have access to any of the children +const hasVisibleContent = (item, memberObj) => + isAccessible(item, memberObj) && + (item.subItems + ? item.subItems.some((child) => hasVisibleContent(child, memberObj)) + : true); + +const isActive = (item, currentPath, memberObj) => + item.subItems + ? item.subItems + .filter((ch) => isAccessible(ch, memberObj)) + .some((ch) => isActive(ch, currentPath, memberObj)) + : currentPath === `/app/${item.linkUrl}`; + function SubMenuItem({ name, onItemClick, subItems, memberObj, currentPath }) { - const _subItems = subItems.filter( - (item) => - !item.hasOwnProperty("accessRoute") || - memberObj.hasAccess(item.accessRoute) - ); + const _subItems = subItems.filter((item) => isAccessible(item, memberObj)); - if (_subItems.length === 0) return null; + if (!subItems.some((item) => hasVisibleContent(item, memberObj))) { + return null; + } - const isChildActive = _subItems.some( - (ch) => currentPath === `/app/${ch.linkUrl}` + const isChildActive = _subItems.some((ch) => + isActive(ch, currentPath, memberObj) ); + const renderLeaf = (item, pl) => ( + onItemClick(e, item.linkUrl)} + /> + ); + + const renderGroup = (item, pl = GROUP_PL) => { + const groupItems = item.subItems.filter((ch) => + hasVisibleContent(ch, memberObj) + ); + + if (groupItems.length === 0) return null; + + return ( + + isActive(ch, currentPath, memberObj) + )} + > + {groupItems.map((ch) => + ch.subItems + ? renderGroup(ch, pl + GROUP_PL_STEP) + : renderLeaf(ch, pl + GROUP_PL_STEP) + )} + + ); + }; + return ( - {_subItems.map((item) => ( - onItemClick(e, item.linkUrl)} - /> - ))} + {_subItems.map((item) => + item.subItems ? renderGroup(item) : renderLeaf(item) + )} ); } diff --git a/src/components/top-nav/__tests__/top-nav.test.js b/src/components/top-nav/__tests__/top-nav.test.js index e941ee487..74e79f68c 100644 --- a/src/components/top-nav/__tests__/top-nav.test.js +++ b/src/components/top-nav/__tests__/top-nav.test.js @@ -1,7 +1,7 @@ import React from "react"; import { render, screen, fireEvent, act } from "@testing-library/react"; import "@testing-library/jest-dom"; -import TopNav from "../index"; +import TopNav, { addMenuLinks, insertMenuFolders } from "../index"; jest.mock("i18n-react/dist/i18n-react", () => ({ translate: (key) => key @@ -95,3 +95,158 @@ describe("TopNav", () => { }); }); }); + +describe("insertMenuFolders", () => { + const SUMMIT = "/app/summits/1"; + const HOME = { id: "home", title: "home", pathname: "/app" }; + const SUMMIT_CRUMB = { id: "summit", title: "Summit", pathname: SUMMIT }; + // the i18n mock returns keys, so the section crumb reads like its menu folder + const ACTIVITIES = { + id: "activities", + title: "menu.events", + pathname: `${SUMMIT}/events` + }; + const SCHEDULE = { + id: "schedule", + title: "Schedule", + pathname: `${SUMMIT}/events/schedule` + }; + const INVENTORY = { + id: "inv", + title: "Inventory", + pathname: "/app/inventory" + }; + + const menuMap = new Map([ + [`${SUMMIT}/events`, ["program_management", "events"]], + [`${SUMMIT}/events/schedule`, ["program_management"]], + ["/app/inventory", ["sponsors_services"]] + ]); + const setCrumbs = insertMenuFolders(menuMap, 1); + + const titles = (crumbs) => + crumbs.map((c) => + typeof c.title === "string" ? c.title : c.title.props.children + ); + + test("list page: folders after Summit, skipping the one that repeats the section", () => { + expect(titles(setCrumbs([HOME, SUMMIT_CRUMB, ACTIVITIES]))).toEqual([ + "home", + "Summit", + "menu.program_management", + "menu.events" + ]); + }); + + test("detail page: matches the section crumb and keeps the detail crumb", () => { + const talk = { + id: "talk", + title: "Talk X", + pathname: `${SUMMIT}/events/42` + }; + expect(titles(setCrumbs([HOME, SUMMIT_CRUMB, ACTIVITIES, talk]))).toEqual([ + "home", + "Summit", + "menu.program_management", + "menu.events", + "Talk X" + ]); + }); + + test("nested link: deepest match wins and route crumbs above it are kept", () => { + expect( + titles(setCrumbs([HOME, SUMMIT_CRUMB, ACTIVITIES, SCHEDULE])) + ).toEqual([ + "home", + "Summit", + "menu.program_management", + "menu.events", + "Schedule" + ]); + }); + + test("skips a folder that repeats any route crumb, not only the matched one", () => { + const sponsors = { + id: "sponsors", + title: "menu.sponsors", + pathname: `${SUMMIT}/sponsors` + }; + const forms = { + id: "forms", + title: "Forms", + pathname: `${SUMMIT}/sponsors/forms` + }; + const setSponsorCrumbs = insertMenuFolders( + new Map([[forms.pathname, ["sponsors"]]]), + 1 + ); + expect( + titles(setSponsorCrumbs([HOME, SUMMIT_CRUMB, sponsors, forms])) + ).toEqual(["home", "Summit", "menu.sponsors", "Forms"]); + }); + + test("global page: folders after Home", () => { + expect(titles(setCrumbs([HOME, INVENTORY]))).toEqual([ + "home", + "menu.sponsors_services", + "Inventory" + ]); + }); + + test("folder crumbs link to the anchor, not the current page", () => { + const crumbs = setCrumbs([HOME, SUMMIT_CRUMB, ACTIVITIES]); + expect(crumbs[2].pathname).toBe(SUMMIT); + }); + + test("unchanged when the page is not in the menu", () => { + const notInMenu = [ + HOME, + SUMMIT_CRUMB, + { id: "x", title: "X", pathname: `${SUMMIT}/x` } + ]; + expect(setCrumbs(notInMenu)).toBe(notInMenu); + }); +}); + +describe("addMenuLinks", () => { + const items = [ + { + name: "program_management", + subItems: [ + { + name: "events", + subItems: [{ name: "event_list", linkUrl: "summits/1/events" }] + }, + { name: "schedule", linkUrl: "summits/1/events/schedule" } + ] + }, + { name: "dashboard", linkUrl: "summits/1/dashboard" } + ]; + const map = addMenuLinks(items); + + test("keys each link by its crumb pathname with its folders", () => { + expect(map.get("/app/summits/1/events")).toEqual([ + "program_management", + "events" + ]); + }); + + test("a link's folders do not include sibling folders visited before it", () => { + expect(map.get("/app/summits/1/events/schedule")).toEqual([ + "program_management" + ]); + }); + + test("a top-level link has no folders", () => { + expect(map.get("/app/summits/1/dashboard")).toEqual([]); + }); + + test("global and summit links share one lookup", () => { + const shared = addMenuLinks([ + { name: "inventory", linkUrl: "inventory" }, + ...items + ]); + expect(shared.has("/app/inventory")).toBe(true); + expect(shared.has("/app/summits/1/events")).toBe(true); + }); +}); diff --git a/src/components/top-nav/index.js b/src/components/top-nav/index.js index da25d0504..7ec2f4ccf 100644 --- a/src/components/top-nav/index.js +++ b/src/components/top-nav/index.js @@ -11,7 +11,7 @@ * limitations under the License. * */ -import React, { useCallback, useState } from "react"; +import React, { useCallback, useMemo, useState } from "react"; import PropTypes from "prop-types"; import AppBar from "@mui/material/AppBar"; import Toolbar from "@mui/material/Toolbar"; @@ -29,6 +29,7 @@ import { import IdTokenVerifier from "idtoken-verifier"; import AuthButton from "../auth-button"; import Menu from "../menu"; +import { getGlobalItems, getSummitItems } from "../menu/menu-definition"; const DRAWER_WIDTH = 260; const COMPACT_BAR_HEIGHT = 48; @@ -36,6 +37,56 @@ const REGULAR_BAR_HEIGHT = 56; const SUB_BAR_HEIGHT = 36; const SHORT_VIEWPORT_MAX_HEIGHT = 500; +// key: a menu link's pathname; value: the folders it sits in, outermost first +export const addMenuLinks = (items, map = new Map(), folders = []) => { + items.forEach((item) => { + if (item.subItems) { + addMenuLinks(item.subItems, map, [...folders, item.name]); + } else if (item.linkUrl) { + map.set(`/app/${item.linkUrl}`, folders); + } + }); + return map; +}; + +// puts the page's menu folders after the Summit crumb (Home on global pages) +export const insertMenuFolders = (menuMap, summitId) => (crumbs) => { + // deepest crumb that is a menu link = the most specific match + const matchIndex = crumbs.findLastIndex((crumb) => + menuMap.has(crumb.pathname) + ); + if (matchIndex === -1) return crumbs; + + // Summit crumb when present, otherwise Home + const summitIndex = crumbs.findIndex( + (crumb) => crumb.pathname === `/app/summits/${summitId}` + ); + const anchorIndex = Math.max(summitIndex, 0); + if (matchIndex <= anchorIndex) return crumbs; + + const anchor = crumbs[anchorIndex].pathname; + const folders = menuMap.get(crumbs[matchIndex].pathname); + const routeCrumbs = crumbs.slice(anchorIndex + 1); + + // skip a folder that repeats a route crumb (e.g. "Activities / Activities") + const folderCrumbs = folders + .filter( + (name) => + !routeCrumbs.some( + (crumb) => crumb.title === T.translate(`menu.${name}`) + ) + ) + .map((name) => ({ + id: `menu-folder-${name}`, + title: ( + {T.translate(`menu.${name}`)} + ), + pathname: anchor + })); + + return [...crumbs.slice(0, anchorIndex + 1), ...folderCrumbs, ...routeCrumbs]; +}; + /** * Application top bar with an optional slide-out navigation drawer. * @@ -50,6 +101,27 @@ const TopNav = ({ isLoggedUser, currentSummit, member }) => { // The current summit's name, shown next to the title behind a divider. const contextLabel = currentSummit?.id > 0 ? currentSummit.name : null; + const summitId = currentSummit?.id; + + // use this memo as a source when rendering menu definitions + const setMenuDefs = useMemo( + () => ({ + global: getGlobalItems(), + summit: summitId ? getSummitItems(summitId) : [] + }), + [summitId] + ); + + // built once per summit; global links stay findable while a summit is selected + const setCrumbs = useMemo( + () => + insertMenuFolders( + addMenuLinks([...setMenuDefs.global, ...setMenuDefs.summit]), + summitId + ), + [summitId, setMenuDefs] + ); + const closeDrawer = useCallback(() => setOpen(false), []); const toggleDrawer = useCallback(() => setOpen((prev) => !prev), []); @@ -143,10 +215,19 @@ const TopNav = ({ isLoggedUser, currentSummit, member }) => { bgcolor: "background.paper", borderBottom: 1, borderColor: "divider", - overflowX: "auto" + overflowX: "auto", + // menu folder crumbs are labels, not links + "& .breadcrumbs__crumb:has(.crumb-folder)": { + pointerEvents: "none", + color: "inherit" + } }} > - + )} {isLoggedUser && ( @@ -172,6 +253,7 @@ const TopNav = ({ isLoggedUser, currentSummit, member }) => { currentSummit={currentSummit} member={member} onNavigate={closeDrawer} + menuDefs={setMenuDefs} /> diff --git a/src/i18n/en.json b/src/i18n/en.json index 2883d16ee..e9ebf0f4b 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -144,6 +144,7 @@ "directory": "Directory", "dashboard": "Dashboard", "selection_plans": "Selection Plans", + "program_management": "Program Management", "events": "Activities", "event_list": "All Activities", "voteable_presentations": "Voteable Presentations", @@ -151,13 +152,15 @@ "new_event": "New Activity", "speakers": "Speakers", "speaker_list": "All Speakers", - "summit_speakers": "Show Speakers", + "summit_speakers": "Speakers", + "summit_speaker_list": "Speaker List", "speaker_attendance": "Speaker Attendance", "featured_speakers": "Featured Speakers", "merge_speakers": "Merge", "companies": "Companies", - "sponsorship_types": "Tiers", + "sponsorship_types": "Sponsorship Tiers", "tags": "Tags", + "attendee_operations": "Attendee Operations", "attendees": "Attendees", "attendee-list": "Attendee List", "badge_checkin": "Check In", @@ -166,6 +169,7 @@ "event_types": "Activity Types", "event_categories": "Activity Categories", "event_category_groups": "Activity Category Groups", + "venue_schedule": "Venue", "signage": "Signage", "locations": "Locations", "rsvps": "RSVPs", @@ -186,6 +190,7 @@ "tag_groups": "Tag Groups", "reports": "Reports", "room_bookings": "Room Bookings", + "registration": "Registration", "purchase_orders": "Purchase Orders", "purchase_order_list": "Order List", "ticket_list": "Ticket List", @@ -208,6 +213,7 @@ "sent_email": "Sent Emails", "email_logs": "Logs", "summitdocs": "Event Docs", + "communications": "Communications", "email_flow_events": "Email Events", "email_flow_overrides": "Overrides", "email_flow_settings": "Settings", @@ -222,15 +228,17 @@ "track_chair_team_lists": "Team Lists", "progress_flags": "Progress Flags", "sponsored_projects": "Sponsored Projects", + "event_administration": "Event Administration", "settings": "Settings", "schedule_settings": "Schedule", "registration_stats": "Registration Stats", "audit_log": "Audit Log", "submission_invitations": "Submission Invitations", - "sponsors_inventory": "Sponsors", - "form_templates": "Form Templates", + "sponsors_services": "Sponsor Services", + "commercial_offerings": "Commercial Offerings", + "form_templates": "Order Forms", "inventory": "Inventory", - "page_templates": "Pages", + "page_templates": "File Exchange Pages", "add_on_types": "Add-On Types" }, "schedule": { @@ -4121,7 +4129,7 @@ } }, "form_template_list": { - "form_templates": "Form Templates", + "form_templates": "Order Form Templates", "form": "form", "alert_info": "To add an order form, select New Form Template. To edit an existing form, click on the list item. To edit the items within the form, click manage items on the form line you wish to edit.", "code_column_label": "Code", @@ -4268,7 +4276,7 @@ } }, "page_template_list": { - "page_templates": "Page Templates", + "page_templates": "File Exchange Page Templates", "page": "page", "alert_info": "You can create or archive Pages from the list. To edit a Page click on the item's Edit botton.", "code": "Code",