Skip to content

Commit

Permalink
[Stateful sidenav] Update stack management landing page (elastic#191735)
Browse files Browse the repository at this point in the history
(cherry picked from commit 92f1320)
  • Loading branch information
sebelga committed Sep 24, 2024
1 parent 5770e8a commit 4d8df9b
Show file tree
Hide file tree
Showing 32 changed files with 529 additions and 125 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -568,6 +568,8 @@ export class ChromeService {
sideNav: {
getIsCollapsed$: () => this.isSideNavCollapsed$.asObservable(),
setIsCollapsed: setIsSideNavCollapsed,
getPanelSelectedNode$: projectNavigation.getPanelSelectedNode$.bind(projectNavigation),
setPanelSelectedNode: projectNavigation.setPanelSelectedNode.bind(projectNavigation),
},
getActiveSolutionNavId$: () => projectNavigation.getActiveSolutionNavId$(),
project: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1003,4 +1003,69 @@ describe('solution navigations', () => {
expect(activeSolution).toEqual(solution1);
}
});

it('should set and return the nav panel selected node', async () => {
const { projectNavigation } = setup({ navLinkIds: ['link1', 'link2', 'link3'] });

{
const selectedNode = await firstValueFrom(projectNavigation.getPanelSelectedNode$());
expect(selectedNode).toBeNull();
}

{
const node: ChromeProjectNavigationNode = {
id: 'node1',
title: 'Node 1',
path: 'node1',
};
projectNavigation.setPanelSelectedNode(node);

const selectedNode = await firstValueFrom(projectNavigation.getPanelSelectedNode$());

expect(selectedNode).toBe(node);
}

{
const fooSolution: SolutionNavigationDefinition<any> = {
id: 'fooSolution',
title: 'Foo solution',
icon: 'logoSolution',
homePage: 'discover',
navigationTree$: of({
body: [
{
type: 'navGroup',
id: 'group1',
children: [
{ link: 'link1' },
{
id: 'group2',
children: [
{
link: 'link2', // We'll target this node using its id
},
],
},
{ link: 'link3' },
],
},
],
}),
};

projectNavigation.changeActiveSolutionNavigation('foo');
projectNavigation.updateSolutionNavigations({ foo: fooSolution });

projectNavigation.setPanelSelectedNode('link2'); // Set the selected node using its id

const selectedNode = await firstValueFrom(projectNavigation.getPanelSelectedNode$());

expect(selectedNode).toMatchObject({
id: 'link2',
href: '/app/link2',
path: 'group1.group2.link2',
title: 'LINK2',
});
}
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,10 @@ export class ProjectNavigationService {
// The navigation tree for the Side nav UI that still contains layout information (body, footer, etc.)
private navigationTreeUi$ = new BehaviorSubject<NavigationTreeDefinitionUI | null>(null);
private activeNodes$ = new BehaviorSubject<ChromeProjectNavigationNode[][]>([]);
// Keep a reference to the nav node selected when the navigation panel is opened
private readonly panelSelectedNode$ = new BehaviorSubject<ChromeProjectNavigationNode | null>(
null
);

private projectBreadcrumbs$ = new BehaviorSubject<{
breadcrumbs: ChromeProjectBreadcrumb[];
Expand Down Expand Up @@ -187,6 +191,8 @@ export class ProjectNavigationService {
getActiveSolutionNavDefinition$: this.getActiveSolutionNavDefinition$.bind(this),
/** In stateful Kibana, get the id of the active solution navigation */
getActiveSolutionNavId$: () => this.activeSolutionNavDefinitionId$.asObservable(),
getPanelSelectedNode$: () => this.panelSelectedNode$.asObservable(),
setPanelSelectedNode: this.setPanelSelectedNode.bind(this),
};
}

Expand Down Expand Up @@ -415,6 +421,34 @@ export class ProjectNavigationService {
}
}

private setPanelSelectedNode = (_node: string | ChromeProjectNavigationNode | null) => {
const node = typeof _node === 'string' ? this.findNodeById(_node) : _node;
this.panelSelectedNode$.next(node);
};

private findNodeById(id: string): ChromeProjectNavigationNode | null {
const allNodes = this.navigationTree$.getValue();
if (!allNodes) return null;

const find = (nodes: ChromeProjectNavigationNode[]): ChromeProjectNavigationNode | null => {
// Recursively search for the node with the given id
for (const node of nodes) {
if (node.id === id) {
return node;
}
if (node.children) {
const found = find(node.children);
if (found) {
return found;
}
}
}
return null;
};

return find(allNodes);
}

private get http() {
if (!this._http) {
throw new Error('Http service not provided.');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,8 @@ const createStartContractMock = () => {
sideNav: {
getIsCollapsed$: jest.fn(),
setIsCollapsed: jest.fn(),
getPanelSelectedNode$: jest.fn(),
setPanelSelectedNode: jest.fn(),
},
getBreadcrumbsAppendExtension$: jest.fn(),
setBreadcrumbsAppendExtension: jest.fn(),
Expand Down
1 change: 1 addition & 0 deletions packages/core/chrome/core-chrome-browser/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ export type {
GroupDefinition,
ItemDefinition,
PresetDefinition,
PanelSelectedNode,
RecentlyAccessedDefinition,
NavigationGroupPreset,
RootNavigationItemDefinition,
Expand Down
15 changes: 15 additions & 0 deletions packages/core/chrome/core-chrome-browser/src/contracts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import type { ChromeHelpExtension } from './help_extension';
import type { ChromeBreadcrumb, ChromeBreadcrumbsAppendExtension } from './breadcrumb';
import type { ChromeBadge, ChromeStyle, ChromeUserBanner } from './types';
import type { ChromeGlobalHelpExtensionMenuLink } from './help_extension';
import type { PanelSelectedNode } from './project_navigation';

/**
* ChromeStart allows plugins to customize the global chrome header UI and
Expand Down Expand Up @@ -184,6 +185,20 @@ export interface ChromeStart {
* @param isCollapsed The collapsed state of the side nav.
*/
setIsCollapsed(isCollapsed: boolean): void;

/**
* Get an observable of the selected nav node that opens the side nav panel.
*/
getPanelSelectedNode$: () => Observable<PanelSelectedNode | null>;

/**
* Set the selected nav node that opens the side nav panel.
*
* @param node The selected nav node that opens the side nav panel. If a string is provided,
* it will be used as the **id** of the selected nav node. If `null` is provided, the side nav panel
* will be closed.
*/
setPanelSelectedNode(node: string | PanelSelectedNode | null): void;
};

/**
Expand Down
1 change: 1 addition & 0 deletions packages/core/chrome/core-chrome-browser/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ export type { ChromeBadge, ChromeUserBanner, ChromeStyle } from './types';

export type {
ChromeProjectNavigationNode,
PanelSelectedNode,
AppDeepLinkId,
AppId,
CloudLinkId,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
* License v3.0 only", or the "Server Side Public License, v 1".
*/

import type { ComponentType, MouseEventHandler } from 'react';
import type { ComponentType, MouseEventHandler, ReactNode } from 'react';
import type { Location } from 'history';
import type { EuiSideNavItemType, EuiThemeSizes, IconType } from '@elastic/eui';
import type { Observable } from 'rxjs';
Expand Down Expand Up @@ -247,6 +247,13 @@ export interface ChromeProjectNavigationNode extends NodeDefinitionBase {
isElasticInternalLink?: boolean;
}

export type PanelSelectedNode = Pick<
ChromeProjectNavigationNode,
'id' | 'children' | 'path' | 'sideNavStatus' | 'deepLink'
> & {
title: string | ReactNode;
};

/** @public */
export interface SideNavCompProps {
activeNodes: ChromeProjectNavigationNode[][];
Expand Down
5 changes: 5 additions & 0 deletions packages/shared-ux/chrome/navigation/src/services.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ export const NavigationKibanaProvider: FC<PropsWithChildren<NavigationKibanaDepe
const { basePath } = http;
const { navigateToUrl } = core.application;
const isSideNavCollapsed = useObservable(chrome.sideNav.getIsCollapsed$(), true);
const selectedPanelNode = useObservable(chrome.sideNav.getPanelSelectedNode$(), null);

const value: NavigationServices = useMemo(
() => ({
Expand All @@ -47,6 +48,8 @@ export const NavigationKibanaProvider: FC<PropsWithChildren<NavigationKibanaDepe
activeNodes$,
isSideNavCollapsed,
eventTracker: new EventTracker({ reportEvent: analytics.reportEvent }),
selectedPanelNode,
setSelectedPanelNode: chrome.sideNav.setPanelSelectedNode,
}),
[
activeNodes$,
Expand All @@ -55,6 +58,8 @@ export const NavigationKibanaProvider: FC<PropsWithChildren<NavigationKibanaDepe
chrome.recentlyAccessed,
isSideNavCollapsed,
navigateToUrl,
selectedPanelNode,
chrome.sideNav.setPanelSelectedNode,
]
);

Expand Down
5 changes: 5 additions & 0 deletions packages/shared-ux/chrome/navigation/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import type {
ChromeNavLink,
ChromeProjectNavigationNode,
ChromeRecentlyAccessedHistoryItem,
PanelSelectedNode,
} from '@kbn/core-chrome-browser';
import { EventTracker } from './analytics';

Expand All @@ -38,6 +39,8 @@ export interface NavigationServices {
activeNodes$: Observable<ChromeProjectNavigationNode[][]>;
isSideNavCollapsed: boolean;
eventTracker: EventTracker;
selectedPanelNode?: PanelSelectedNode | null;
setSelectedPanelNode?: (node: PanelSelectedNode | null) => void;
}

/**
Expand All @@ -55,6 +58,8 @@ export interface NavigationKibanaDependencies {
};
sideNav: {
getIsCollapsed$: () => Observable<boolean>;
getPanelSelectedNode$: () => Observable<PanelSelectedNode | null>;
setPanelSelectedNode(node: string | PanelSelectedNode | null): void;
};
};
http: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,19 +15,20 @@ import React, {
useMemo,
useState,
ReactNode,
useEffect,
} from 'react';
import type { ChromeProjectNavigationNode } from '@kbn/core-chrome-browser';
import type { ChromeProjectNavigationNode, PanelSelectedNode } from '@kbn/core-chrome-browser';

import { DefaultContent } from './default_content';
import { ContentProvider, PanelNavNode } from './types';
import { ContentProvider } from './types';

export interface PanelContext {
isOpen: boolean;
toggle: () => void;
open: (navNode: PanelNavNode) => void;
open: (navNode: PanelSelectedNode) => void;
close: () => void;
/** The selected node is the node in the main panel that opens the Panel */
selectedNode: PanelNavNode | null;
selectedNode: PanelSelectedNode | null;
/** Handler to retrieve the component to render in the panel */
getContent: () => React.ReactNode;
}
Expand All @@ -37,29 +38,50 @@ const Context = React.createContext<PanelContext | null>(null);
interface Props {
contentProvider?: ContentProvider;
activeNodes: ChromeProjectNavigationNode[][];
selectedNode?: PanelSelectedNode | null;
setSelectedNode?: (node: PanelSelectedNode | null) => void;
}

export const PanelProvider: FC<PropsWithChildren<Props>> = ({
children,
contentProvider,
activeNodes,
selectedNode: selectedNodeProp = null,
setSelectedNode,
}) => {
const [isOpen, setIsOpen] = useState(false);
const [selectedNode, setActiveNode] = useState<PanelNavNode | null>(null);
const [selectedNode, setActiveNode] = useState<PanelSelectedNode | null>(selectedNodeProp);

const toggle = useCallback(() => {
setIsOpen((prev) => !prev);
}, []);

const open = useCallback((navNode: PanelNavNode) => {
setActiveNode(navNode);
setIsOpen(true);
}, []);
const open = useCallback(
(navNode: PanelSelectedNode) => {
setActiveNode(navNode);
setIsOpen(true);
setSelectedNode?.(navNode);
},
[setSelectedNode]
);

const close = useCallback(() => {
setActiveNode(null);
setIsOpen(false);
}, []);
setSelectedNode?.(null);
}, [setSelectedNode]);

useEffect(() => {
if (selectedNodeProp === undefined) return;

setActiveNode(selectedNodeProp);

if (selectedNodeProp) {
setIsOpen(true);
} else {
setIsOpen(false);
}
}, [selectedNodeProp]);

const getContent = useCallback(() => {
if (!selectedNode) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,11 @@
*/

import { EuiFlexGroup, EuiFlexItem, EuiTitle } from '@elastic/eui';
import { ChromeProjectNavigationNode } from '@kbn/core-chrome-browser';
import type { ChromeProjectNavigationNode, PanelSelectedNode } from '@kbn/core-chrome-browser';
import React, { Fragment, type FC } from 'react';

import { PanelGroup } from './panel_group';
import { PanelNavItem } from './panel_nav_item';
import type { PanelNavNode } from './types';

function isGroupNode({ children }: Pick<ChromeProjectNavigationNode, 'children'>) {
return children !== undefined;
Expand All @@ -33,7 +32,7 @@ function isItemNode({ children }: Pick<ChromeProjectNavigationNode, 'children'>)
* @param node The current active node
* @returns The children serialized
*/
function serializeChildren(node: PanelNavNode): ChromeProjectNavigationNode[] | undefined {
function serializeChildren(node: PanelSelectedNode): ChromeProjectNavigationNode[] | undefined {
if (!node.children) return undefined;

const allChildrenAreItems = node.children.every((_node) => {
Expand Down Expand Up @@ -69,7 +68,7 @@ function serializeChildren(node: PanelNavNode): ChromeProjectNavigationNode[] |

interface Props {
/** The selected node is the node in the main panel that opens the Panel */
selectedNode: PanelNavNode;
selectedNode: PanelSelectedNode;
}

export const DefaultContent: FC<Props> = ({ selectedNode }) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,11 @@ import {
import React, { useCallback, type FC } from 'react';
import classNames from 'classnames';

import type { PanelSelectedNode } from '@kbn/core-chrome-browser';
import { usePanel } from './context';
import { getNavPanelStyles, getPanelWrapperStyles } from './styles';
import { PanelNavNode } from './types';

const getTestSubj = (selectedNode: PanelNavNode | null): string | undefined => {
const getTestSubj = (selectedNode: PanelSelectedNode | null): string | undefined => {
if (!selectedNode) return;

const deeplinkId = selectedNode.deepLink?.id;
Expand Down
Loading

0 comments on commit 4d8df9b

Please sign in to comment.