diff --git a/src/core/public/chrome/chrome_service.tsx b/src/core/public/chrome/chrome_service.tsx index 822b4c23822e..438ab523fe13 100644 --- a/src/core/public/chrome/chrome_service.tsx +++ b/src/core/public/chrome/chrome_service.tsx @@ -109,7 +109,7 @@ export interface StartDeps { overlays: OverlayStart; } -type CollapsibleNavHeaderRender = () => JSX.Element | null; +export type CollapsibleNavHeaderRender = () => JSX.Element | null; /** @internal */ export class ChromeService { @@ -299,6 +299,8 @@ export class ChromeService { survey={injectedMetadata.getSurvey()} collapsibleNavHeaderRender={this.collapsibleNavHeaderRender} sidecarConfig$={sidecarConfig$} + navGroupsMap$={navGroup.getNavGroupsMap$()} + navGroupEnabled={navGroup.getNavGroupEnabled()} /> ), diff --git a/src/core/public/chrome/ui/header/__snapshots__/header.test.tsx.snap b/src/core/public/chrome/ui/header/__snapshots__/header.test.tsx.snap index 790f24bc20e9..333ae4f26877 100644 --- a/src/core/public/chrome/ui/header/__snapshots__/header.test.tsx.snap +++ b/src/core/public/chrome/ui/header/__snapshots__/header.test.tsx.snap @@ -1739,6 +1739,18 @@ exports[`Header handles visibility and lock changes 1`] = ` "thrownError": null, } } + navGroupEnabled={false} + navGroupsMap$={ + BehaviorSubject { + "_isScalar": false, + "_value": Object {}, + "closed": false, + "hasError": false, + "isStopped": false, + "observers": Array [], + "thrownError": null, + } + } navLinks$={ BehaviorSubject { "_isScalar": false, @@ -8389,6 +8401,18 @@ exports[`Header renders condensed header 1`] = ` "thrownError": null, } } + navGroupEnabled={false} + navGroupsMap$={ + BehaviorSubject { + "_isScalar": false, + "_value": Object {}, + "closed": false, + "hasError": false, + "isStopped": false, + "observers": Array [], + "thrownError": null, + } + } navLinks$={ BehaviorSubject { "_isScalar": false, diff --git a/src/core/public/chrome/ui/header/collapsible_nav.tsx b/src/core/public/chrome/ui/header/collapsible_nav.tsx index 9c9223aa501b..b0740694acd1 100644 --- a/src/core/public/chrome/ui/header/collapsible_nav.tsx +++ b/src/core/public/chrome/ui/header/collapsible_nav.tsx @@ -51,6 +51,7 @@ import { HttpStart } from '../../../http'; import { OnIsLockedUpdate } from './'; import { createEuiListItem, createRecentNavLink, isModifiedOrPrevented } from './nav_link'; import type { Logos } from '../../../../common/types'; +import { CollapsibleNavHeaderRender } from '../../chrome_service'; function getAllCategories(allCategorizedLinks: Record) { const allCategories = {} as Record; @@ -89,7 +90,7 @@ function setIsCategoryOpen(id: string, isOpen: boolean, storage: Storage) { interface Props { appId$: InternalApplicationStart['currentAppId$']; basePath: HttpStart['basePath']; - collapsibleNavHeaderRender?: () => JSX.Element | null; + collapsibleNavHeaderRender?: CollapsibleNavHeaderRender; id: string; isLocked: boolean; isNavOpen: boolean; diff --git a/src/core/public/chrome/ui/header/collapsible_nav_group_enabled.scss b/src/core/public/chrome/ui/header/collapsible_nav_group_enabled.scss new file mode 100644 index 000000000000..61fd32d6e773 --- /dev/null +++ b/src/core/public/chrome/ui/header/collapsible_nav_group_enabled.scss @@ -0,0 +1,25 @@ +.context-nav-wrapper { + background-color: $ouiCollapsibleNavBackgroundColor; + + .full-width { + width: 100%; + } + + .no-padding { + padding: 0; + } + + .no-hover { + &:hover { + text-decoration: none; + } + } + + .wrapper { + overflow-y: auto; + } + + .second-navigation { + border-left: $euiBorderThin; + } +} diff --git a/src/core/public/chrome/ui/header/collapsible_nav_group_enabled.tsx b/src/core/public/chrome/ui/header/collapsible_nav_group_enabled.tsx new file mode 100644 index 000000000000..234f2ace2fb7 --- /dev/null +++ b/src/core/public/chrome/ui/header/collapsible_nav_group_enabled.tsx @@ -0,0 +1,501 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import './collapsible_nav_group_enabled.scss'; +import { + EuiCollapsibleNavGroup, + EuiFlexItem, + EuiHorizontalRule, + EuiListGroup, + EuiListGroupItem, + EuiShowFor, + EuiFlyout, + EuiButtonIcon, + EuiFlexGroup, +} from '@elastic/eui'; +import { i18n } from '@osd/i18n'; +import { groupBy, sortBy } from 'lodash'; +import React, { Fragment, useEffect, useMemo, useRef, useState } from 'react'; +import useObservable from 'react-use/lib/useObservable'; +import * as Rx from 'rxjs'; +import { ChromeNavLink } from '../..'; +import { AppCategory, ChromeNavGroup, NavGroupType } from '../../../../types'; +import { InternalApplicationStart } from '../../../application/types'; +import { HttpStart } from '../../../http'; +import { OnIsLockedUpdate } from './'; +import { createEuiListItem } from './nav_link'; +import type { Logos } from '../../../../common/types'; +import { CollapsibleNavHeaderRender } from '../../chrome_service'; +import { ChromeRegistrationNavLink, NavGroupItemInMap } from '../../nav_group'; + +function getAllCategories(allCategorizedLinks: Record) { + const allCategories = {} as Record; + + for (const [key, value] of Object.entries(allCategorizedLinks)) { + allCategories[key] = value[0].category; + } + + return allCategories; +} + +const LinkItemType = { + LINK: 'link', + CATEGORY: 'category', +} as const; + +function getOrderedLinksOrCategories( + navLinks: ChromeNavLink[] +): Array< + { order?: number } & ( + | { itemType: 'link'; link: ChromeNavLink } + | { itemType: 'category'; category?: AppCategory; links?: ChromeNavLink[] } + ) +> { + const groupedNavLinks = groupBy(navLinks, (link) => link?.category?.id); + const { undefined: unknowns = [], ...allCategorizedLinks } = groupedNavLinks; + const categoryDictionary = getAllCategories(allCategorizedLinks); + return sortBy( + [ + ...unknowns.map((linkWithoutCategory) => ({ + itemType: LinkItemType.LINK, + link: linkWithoutCategory, + order: linkWithoutCategory.order, + })), + ...Object.keys(allCategorizedLinks).map((categoryKey) => ({ + itemType: LinkItemType.CATEGORY, + category: categoryDictionary[categoryKey], + order: categoryDictionary[categoryKey]?.order, + links: allCategorizedLinks[categoryKey], + })), + ], + (item) => item.order + ); +} + +function getCategoryLocalStorageKey(id: string) { + return `core.newNav.navGroup.${id}`; +} + +function getIsCategoryOpen(id: string, storage: Storage) { + const value = storage.getItem(getCategoryLocalStorageKey(id)) ?? 'true'; + + return value === 'true'; +} + +function setIsCategoryOpen(id: string, isOpen: boolean, storage: Storage) { + storage.setItem(getCategoryLocalStorageKey(id), `${isOpen}`); +} + +interface Props { + appId$: InternalApplicationStart['currentAppId$']; + basePath: HttpStart['basePath']; + collapsibleNavHeaderRender?: CollapsibleNavHeaderRender; + id: string; + isLocked: boolean; + isNavOpen: boolean; + navLinks$: Rx.Observable; + storage?: Storage; + onIsLockedUpdate: OnIsLockedUpdate; + closeNav: () => void; + navigateToApp: InternalApplicationStart['navigateToApp']; + navigateToUrl: InternalApplicationStart['navigateToUrl']; + customNavLink$: Rx.Observable; + logos: Logos; + navGroupsMap$: Rx.Observable>; +} + +interface NavGroupsProps { + logos: Logos; + storage: Storage; + navLinks: ChromeNavLink[]; + readyForEUI: any; + suffix?: React.ReactElement; + style?: React.CSSProperties; +} + +function NavGroups({ navLinks, logos, storage, readyForEUI, suffix, style }: NavGroupsProps) { + const orderedLinksOrCategories = getOrderedLinksOrCategories(navLinks); + return ( + + {/* OpenSearchDashboards, Observability, Security, and Management sections */} + {orderedLinksOrCategories.map((linkOrCategory) => { + if (linkOrCategory.itemType === LinkItemType.CATEGORY) { + const category = linkOrCategory.category as AppCategory; + const opensearchLinkLogo = + category?.id === 'opensearchDashboards' ? logos.Mark.url : category.euiIconType; + + return ( + setIsCategoryOpen(category.id, isCategoryOpen, storage)} + data-test-subj={`collapsibleNavGroup-${category.id}`} + data-test-opensearch-logo={opensearchLinkLogo} + > + readyForEUI(link))} + maxWidth="none" + color="subdued" + gutterSize="none" + size="s" + /> + + ); + } + + return ( + + + + + + ); + })} + {suffix} + + ); +} + +function fullfillRegistrationLinksToChromeNavLinks( + registerNavLinks: ChromeRegistrationNavLink[], + navLinks: ChromeNavLink[] +): Array { + const allExistingNavLinkId = navLinks.map((link) => link.id); + return ( + registerNavLinks + ?.filter((navLink) => allExistingNavLinkId.includes(navLink.id)) + .map((navLink) => ({ + ...navLinks[allExistingNavLinkId.indexOf(navLink.id)], + ...navLink, + })) || [] + ); +} + +export function CollapsibleNavGroupEnabled({ + basePath, + collapsibleNavHeaderRender, + id, + isLocked, + isNavOpen, + storage = window.localStorage, + onIsLockedUpdate, + closeNav, + navigateToApp, + navigateToUrl, + logos, + ...observables +}: Props) { + const navLinks = useObservable(observables.navLinks$, []).filter((link) => !link.hidden); + const customNavLink = useObservable(observables.customNavLink$, undefined); + const appId = useObservable(observables.appId$, ''); + const navGroupsMap = useObservable(observables.navGroupsMap$, {}); + const lockRef = useRef(null); + + const [focusGroup, setFocusGroup] = useState(undefined); + + const [shouldShrinkSecondNavigation, setShouldShrinkSecondNavigation] = useState(false); + const readyForEUI = (link: ChromeNavLink, needsIcon: boolean = false) => { + return createEuiListItem({ + link, + appId, + dataTestSubj: 'collapsibleNavAppLink', + navigateToApp, + onClick: closeNav, + ...(needsIcon && { basePath }), + }); + }; + + useEffect(() => { + if (!focusGroup && appId) { + const orderedGroups = sortBy(Object.values(navGroupsMap), (group) => group.order); + const findMatchedGroup = orderedGroups.find( + (group) => !!group.navLinks.find((navLink) => navLink.id === appId) + ); + setFocusGroup(findMatchedGroup); + } + }, [appId, navGroupsMap, focusGroup]); + + const secondNavigation = focusGroup ? ( + <> + {shouldShrinkSecondNavigation ? ( +
+ setShouldShrinkSecondNavigation(false)} + /> +
+ ) : null} + {!shouldShrinkSecondNavigation && ( + <> +
+ + +

+ {focusGroup.title} +

+
+ + setShouldShrinkSecondNavigation(true)} + /> + +
+
+ + + )} + + ) : null; + + const secondNavigationWidth = useMemo(() => { + if (shouldShrinkSecondNavigation) { + return 48; + } + + return 320; + }, [shouldShrinkSecondNavigation]); + + const flyoutSize = useMemo(() => { + if (focusGroup) { + return 320 + secondNavigationWidth; + } + + return 320; + }, [focusGroup, secondNavigationWidth]); + + const onGroupClick = ( + e: React.MouseEvent, + group: ChromeNavGroup + ) => { + const fullfilledLinks = fullfillRegistrationLinksToChromeNavLinks( + navGroupsMap[group.id]?.navLinks, + navLinks + ); + const orderedLinksOrCategories = getOrderedLinksOrCategories(fullfilledLinks); + setFocusGroup(group); + let firstLink: ChromeNavLink | null = null; + orderedLinksOrCategories.find((linkOrCategory) => { + if (linkOrCategory.itemType === LinkItemType.CATEGORY) { + if (linkOrCategory.links?.length) { + firstLink = linkOrCategory.links[0]; + return true; + } + } else if (linkOrCategory.itemType === LinkItemType.LINK) { + firstLink = linkOrCategory.link; + return true; + } + }); + if (firstLink) { + const propsForEui = readyForEUI(firstLink); + propsForEui.onClick(e); + } + }; + + const allLinksWithNavGroup = Object.values(navGroupsMap).reduce( + (total, navGroup) => [...total, ...navGroup.navLinks.map((navLink) => navLink.id)], + [] as string[] + ); + + return ( + <> + {isNavOpen || isLocked ? ( + +
+
+ {customNavLink && ( + + + + + + + + + + )} + + !allLinksWithNavGroup.includes(link.id))} + logos={logos} + storage={storage} + readyForEUI={readyForEUI} + suffix={ +
+ + + {sortBy( + Object.values(navGroupsMap).filter( + (item) => item.type === NavGroupType.SYSTEM + ), + (navGroup) => navGroup.order + ).map((group) => { + return ( + { + if (focusGroup?.id === group.id) { + setFocusGroup(undefined); + } else { + onGroupClick(e, group); + } + }} + /> + ); + })} + + + {collapsibleNavHeaderRender && collapsibleNavHeaderRender()} + + + {sortBy( + Object.values(navGroupsMap).filter((item) => !item.type), + (navGroup) => navGroup.order + ).map((group) => { + return ( + { + if (focusGroup?.id === group.id) { + setFocusGroup(undefined); + } else { + onGroupClick(e, group); + } + }} + /> + ); + })} + + + {/* Docking button only for larger screens that can support it*/} + + + + { + onIsLockedUpdate(!isLocked); + if (lockRef.current) { + lockRef.current.focus(); + } + }} + iconType={isLocked ? 'lock' : 'lockOpen'} + /> + + + +
+ } + /> +
+ {secondNavigation && ( +
+ {secondNavigation} +
+ )} +
+
+ ) : null} + {secondNavigation && !isLocked ? ( + {}} + size={secondNavigationWidth} + side="left" + hideCloseButton + > + {secondNavigation} + + ) : null} + + ); +} diff --git a/src/core/public/chrome/ui/header/header.test.tsx b/src/core/public/chrome/ui/header/header.test.tsx index 0d3bc7e70d45..97e582538005 100644 --- a/src/core/public/chrome/ui/header/header.test.tsx +++ b/src/core/public/chrome/ui/header/header.test.tsx @@ -77,6 +77,8 @@ function mockProps() { dockedMode: SIDECAR_DOCKED_MODE.RIGHT, paddingSize: 640, }), + navGroupsMap$: new BehaviorSubject({}), + navGroupEnabled: false, }; } diff --git a/src/core/public/chrome/ui/header/header.tsx b/src/core/public/chrome/ui/header/header.tsx index b8b40fa6c39f..871c5ed09fc7 100644 --- a/src/core/public/chrome/ui/header/header.tsx +++ b/src/core/public/chrome/ui/header/header.tsx @@ -54,7 +54,11 @@ import { } from '../..'; import { InternalApplicationStart } from '../../../application/types'; import { HttpStart } from '../../../http'; -import { ChromeHelpExtension, ChromeBranding } from '../../chrome_service'; +import { + ChromeHelpExtension, + ChromeBranding, + CollapsibleNavHeaderRender, +} from '../../chrome_service'; import { OnIsLockedUpdate } from './'; import { CollapsibleNav } from './collapsible_nav'; import { HeaderBadge } from './header_badge'; @@ -66,13 +70,15 @@ import { HeaderActionMenu } from './header_action_menu'; import { HeaderLogo } from './header_logo'; import type { Logos } from '../../../../common/types'; import { ISidecarConfig, getOsdSidecarPaddingStyle } from '../../../overlays'; +import { CollapsibleNavGroupEnabled } from './collapsible_nav_group_enabled'; +import { NavGroupItemInMap } from '../../nav_group'; export interface HeaderProps { opensearchDashboardsVersion: string; application: InternalApplicationStart; appTitle$: Observable; badge$: Observable; breadcrumbs$: Observable; - collapsibleNavHeaderRender?: () => JSX.Element | null; + collapsibleNavHeaderRender?: CollapsibleNavHeaderRender; customNavLink$: Observable; homeHref: string; isVisible$: Observable; @@ -95,6 +101,8 @@ export interface HeaderProps { logos: Logos; survey: string | undefined; sidecarConfig$: Observable; + navGroupsMap$: Observable>; + navGroupEnabled: boolean; } export function Header({ @@ -108,6 +116,7 @@ export function Header({ survey, logos, collapsibleNavHeaderRender, + navGroupEnabled, ...observables }: HeaderProps) { const isVisible = useObservable(observables.isVisible$, false); @@ -253,28 +262,52 @@ export function Header({ - { - setIsNavOpen(false); - if (toggleCollapsibleNavRef.current) { - toggleCollapsibleNavRef.current.focus(); - } - }} - customNavLink$={observables.customNavLink$} - logos={logos} - /> + {navGroupEnabled ? ( + { + setIsNavOpen(false); + if (toggleCollapsibleNavRef.current) { + toggleCollapsibleNavRef.current.focus(); + } + }} + customNavLink$={observables.customNavLink$} + logos={logos} + navGroupsMap$={observables.navGroupsMap$} + /> + ) : ( + { + setIsNavOpen(false); + if (toggleCollapsibleNavRef.current) { + toggleCollapsibleNavRef.current.focus(); + } + }} + customNavLink$={observables.customNavLink$} + logos={logos} + /> + )} );