diff --git a/src/core/public/chrome/chrome_service.tsx b/src/core/public/chrome/chrome_service.tsx index ccabcc0ceb80..642f19216eed 100644 --- a/src/core/public/chrome/chrome_service.tsx +++ b/src/core/public/chrome/chrome_service.tsx @@ -110,7 +110,7 @@ export interface StartDeps { workspaces: WorkspacesStart; } -type CollapsibleNavHeaderRender = () => JSX.Element | null; +export type CollapsibleNavHeaderRender = () => JSX.Element | null; /** @internal */ export class ChromeService { @@ -301,6 +301,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 b1bcadd89198..2b7a7aed3d9b 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..d6fe9205911d --- /dev/null +++ b/src/core/public/chrome/ui/header/collapsible_nav_group_enabled.scss @@ -0,0 +1,33 @@ +.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; + } + + .padding-horizontal { + padding-left: $ouiSize; + } + + .no-margin-top { + margin-top: $ouiSize / 4; + } +} 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..f94eb9fb0dee --- /dev/null +++ b/src/core/public/chrome/ui/header/collapsible_nav_group_enabled.tsx @@ -0,0 +1,425 @@ +/* + * 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, + EuiSideNavItemType, + EuiSideNav, +} from '@elastic/eui'; +import { i18n } from '@osd/i18n'; +import { 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 { 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 { NavGroupItemInMap } from '../../nav_group'; +import { + fulfillRegistrationLinksToChromeNavLinks, + getOrderedLinksOrCategories, + LinkItem, + LinkItemType, +} from '../../utils'; + +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 { + navLinks: ChromeNavLink[]; + suffix?: React.ReactElement; + style?: React.CSSProperties; + appId?: string; + navigateToApp: InternalApplicationStart['navigateToApp']; + onClick: () => void; +} + +function NavGroups({ navLinks, suffix, style, appId, navigateToApp, onClick }: NavGroupsProps) { + const createNavItem = ({ link }: { link: ChromeNavLink }) => { + const euiListItem = createEuiListItem({ + link, + appId, + dataTestSubj: 'collapsibleNavAppLink', + navigateToApp, + onClick, + }); + + return { + id: link.id, + name:
{link.title}
, + onClick: euiListItem.onClick, + href: euiListItem.href, + className: 'no-margin-top', + isSelected: euiListItem.isActive, + }; + }; + const orderedLinksOrCategories = getOrderedLinksOrCategories(navLinks); + const createSideNavItem = (navLink: LinkItem): EuiSideNavItemType<{}> => { + if (navLink.itemType === LinkItemType.LINK) { + return createNavItem({ + link: navLink.link, + }); + } + + if (navLink.itemType === LinkItemType.PARENT_LINK && navLink.link) { + return { + ...createNavItem({ link: navLink.link }), + items: navLink.links.map((subNavLink) => createSideNavItem(subNavLink)), + }; + } + + if (navLink.itemType === LinkItemType.CATEGORY) { + return { + id: navLink.category?.id ?? '', + name:
{navLink.category?.label ?? ''}
, + items: navLink.links?.map((link) => createSideNavItem(link)), + }; + } + + return {} as EuiSideNavItemType<{}>; + }; + const sideNavItems = orderedLinksOrCategories + .map((navLink) => createSideNavItem(navLink)) + .filter((item): item is EuiSideNavItemType<{}> => !!item); + return ( + + + {suffix} + + ); +} + +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); + + 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 fulfilledLinks = fulfillRegistrationLinksToChromeNavLinks( + navGroupsMap[group.id]?.navLinks, + navLinks + ); + setFocusGroup(group); + + // the `navGroupsMap[group.id]?.navLinks` has already been sorted + const firstLink = fulfilledLinks[0]; + if (firstLink) { + const propsForEui = createEuiListItem({ + link: firstLink, + appId, + dataTestSubj: 'collapsibleNavAppLink', + navigateToApp, + }); + 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))} + 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'} + /> + + + +
+ } + navigateToApp={navigateToApp} + onClick={closeNav} + appId={appId} + /> +
+ {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} + /> + )} );