[Serverless nav] Accordion auto-expand state + polish work (#169651)

This commit is contained in:
Sébastien Loix 2023-10-26 10:29:00 +01:00 committed by GitHub
parent 8e59304a06
commit b759b8f279
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
14 changed files with 208 additions and 84 deletions

View file

@ -36,7 +36,7 @@ export const ProjectNavigation: React.FC<{
onCollapseToggle={onCollapseToggle}
css={
isCollapsed
? { display: 'none;' }
? undefined
: { overflow: 'visible', clipPath: 'polygon(0 0, 300% 0, 300% 100%, 0 100%)' }
}
>

View file

@ -8,7 +8,7 @@
import type { ComponentType } from 'react';
import type { Location } from 'history';
import type { EuiAccordionProps, EuiThemeSizes, IconType } from '@elastic/eui';
import type { EuiThemeSizes, IconType } from '@elastic/eui';
import type { AppId as DevToolsApp, DeepLinkId as DevToolsLink } from '@kbn/deeplinks-devtools';
import type {
AppId as AnalyticsApp,
@ -112,6 +112,16 @@ interface NodeDefinitionBase {
* @default 'block'
*/
renderAs?: RenderAs;
/**
* ["group" nodes only] Flag to indicate if the group is initially collapsed or not.
*
* `undefined`: (Recommended) the group will be opened if any of its children nodes matches the current URL.
*
* `false`: the group will be opened event if none of its children nodes matches the current URL.
*
* `true`: the group will be collapsed event if any of its children nodes matches the current URL.
*/
defaultIsCollapsed?: boolean;
/**
* ["group" nodes only] Optional flag to indicate if a horizontal rule should be rendered after the node.
* Note: this property is currently only used for (1) "group" nodes and (2) in the navigation
@ -119,9 +129,11 @@ interface NodeDefinitionBase {
*/
appendHorizontalRule?: boolean;
/**
* ["group" nodes only] Temp prop. Will be removed once the new navigation is fully implemented.
* ["group" nodes only] Flag to indicate if the accordion is collapsible.
* Must be used with `renderAs` set to `"accordion"`
* @default `true`
*/
accordionProps?: Partial<EuiAccordionProps>;
isCollapsible?: boolean;
/**
* ----------------------------------------------------------------------------------------------
* -------------------------------- ITEM NODES ONLY PROPS ---------------------------------------

View file

@ -38,7 +38,6 @@ export interface Props<
ChildrenId extends string = Id
> extends NodeProps<LinkId, Id, ChildrenId> {
unstyled?: boolean;
defaultIsCollapsed?: boolean;
}
function NavigationGroupInternalComp<

View file

@ -74,12 +74,14 @@ function NavigationItemComp<
if (isRootLevel) {
const href = getNavigationNodeHref(navNode);
return (
<EuiCollapsibleNavItem
id={navNode.id}
title={navNode.title}
icon={navNode.icon}
iconProps={{ size: 'm' }}
isSelected={navNode.isActive}
data-test-subj={`nav-item-${navNode.id}`}
linkProps={{
href,

View file

@ -23,8 +23,9 @@ import {
} from '@elastic/eui';
import type { ChromeProjectNavigationNode } from '@kbn/core-chrome-browser';
import type { NavigateToUrlFn } from '../../../types/internal';
import { usePanel } from './panel';
import { nodePathToString } from '../../utils';
import { useNavigation as useServices } from '../../services';
import { usePanel } from './panel';
const getStyles = (euiTheme: EuiThemeComputed<{}>) => css`
* {
@ -51,11 +52,12 @@ interface Props {
export const NavigationItemOpenPanel: FC<Props> = ({ item, navigateToUrl }: Props) => {
const { euiTheme } = useEuiTheme();
const { open: openPanel, close: closePanel, selectedNode } = usePanel();
const { isSideNavCollapsed } = useServices();
const { title, deepLink, isActive, children } = item;
const id = nodePathToString(item);
const href = deepLink?.url ?? item.href;
const isNotMobile = useIsWithinMinBreakpoint('s');
const isIconVisible = isNotMobile && !!children && children.length > 0;
const isIconVisible = isNotMobile && !isSideNavCollapsed && !!children && children.length > 0;
const itemClassNames = classNames(
'sideNavItem',

View file

@ -6,8 +6,9 @@
* Side Public License, v 1.
*/
import React, { FC } from 'react';
import React, { FC, useCallback, useEffect, useMemo, useState } from 'react';
import classNames from 'classnames';
import { css } from '@emotion/css';
import {
EuiTitle,
EuiCollapsibleNavItem,
@ -26,13 +27,15 @@ import { nodePathToString, isAbsoluteLink, getNavigationNodeHref } from '../../u
import { PanelContext, usePanel } from './panel';
import { NavigationItemOpenPanel } from './navigation_item_open_panel';
const DEFAULT_SPACE_BETWEEN_LEVEL_1_GROUPS: EuiThemeSize = 'm';
const DEFAULT_IS_COLLAPSED = true;
const DEFAULT_IS_COLLAPSIBLE = true;
const nodeHasLink = (navNode: ChromeProjectNavigationNode) =>
Boolean(navNode.deepLink) || Boolean(navNode.href);
const nodeHasChildren = (navNode: ChromeProjectNavigationNode) => Boolean(navNode.children?.length);
const DEFAULT_SPACE_BETWEEN_LEVEL_1_GROUPS: EuiThemeSize = 'm';
/**
* Predicate to determine if a node should be visible in the main side nav.
* If it is not visible it will be filtered out and not rendered.
@ -103,7 +106,6 @@ const renderBlockTitle: (
css={({ euiTheme }: any) => {
return {
marginTop: spaceBefore ? euiTheme.size[spaceBefore] : undefined,
// marginTop: euiTheme.size.base,
paddingBlock: euiTheme.size.xs,
paddingInline: euiTheme.size.s,
};
@ -148,12 +150,14 @@ const nodeToEuiCollapsibleNavProps = (
closePanel,
isSideNavCollapsed,
treeDepth,
itemsState,
}: {
navigateToUrl: NavigateToUrlFn;
openPanel: PanelContext['open'];
closePanel: PanelContext['close'];
isSideNavCollapsed: boolean;
treeDepth: number;
itemsState: AccordionItemsState;
}
): {
items: Array<EuiCollapsibleNavItemProps | EuiCollapsibleNavSubItemProps>;
@ -172,7 +176,11 @@ const nodeToEuiCollapsibleNavProps = (
spaceBefore: _spaceBefore,
} = navNode;
const isExternal = Boolean(href) && isAbsoluteLink(href!);
const isSelected = hasChildren && !isItem ? false : isActive;
const isAccordion = hasChildren && !isItem;
const isAccordionExpanded = (itemsState[id]?.isCollapsed ?? DEFAULT_IS_COLLAPSED) === false;
const isSelected = isAccordion && isAccordionExpanded ? false : isActive;
const dataTestSubj = classnames(`nav-item`, `nav-item-${id}`, {
[`nav-item-deepLinkId-${deepLink?.id}`]: !!deepLink,
[`nav-item-id-${id}`]: id,
@ -219,6 +227,7 @@ const nodeToEuiCollapsibleNavProps = (
closePanel,
isSideNavCollapsed,
treeDepth: treeDepth + 1,
itemsState,
})
)
.filter(({ isVisible }) => isVisible)
@ -244,13 +253,6 @@ const nodeToEuiCollapsibleNavProps = (
}
: undefined;
const accordionProps: Partial<EuiAccordionProps> | undefined = isItem
? undefined
: {
initialIsOpen: treeDepth === 0 ? isActive : true, // FIXME open state is controlled on component mount
...navNode.accordionProps,
};
if (renderAs === 'block' && treeDepth > 0 && subItems) {
// Render as a group block (bold title + list of links underneath)
return {
@ -267,7 +269,6 @@ const nodeToEuiCollapsibleNavProps = (
id,
title,
isSelected,
accordionProps,
linkProps,
onClick,
href,
@ -290,6 +291,16 @@ const nodeToEuiCollapsibleNavProps = (
return { items, isVisible };
};
interface AccordionItemsState {
[navNodeId: string]: {
isCollapsible: boolean;
isCollapsed: boolean;
// We want to auto expand the group automatically if the node is active (URL match)
// but once the user manually expand a group we don't want to close it afterward automatically.
doCollapseFromActiveState: boolean;
};
}
interface Props {
navNode: ChromeProjectNavigationNode;
}
@ -298,23 +309,161 @@ export const NavigationSectionUI: FC<Props> = ({ navNode }) => {
const { navigateToUrl, isSideNavCollapsed } = useServices();
const { open: openPanel, close: closePanel } = usePanel();
const { items, isVisible } = nodeToEuiCollapsibleNavProps(navNode, {
navigateToUrl,
openPanel,
closePanel,
isSideNavCollapsed,
treeDepth: 0,
const navNodesById = useMemo(() => {
const byId = {
[nodePathToString(navNode)]: navNode,
};
const parse = (navNodes?: ChromeProjectNavigationNode[]) => {
if (!navNodes) return;
navNodes.forEach((childNode) => {
byId[nodePathToString(childNode)] = childNode;
parse(childNode.children);
});
};
parse(navNode.children);
return byId;
}, [navNode]);
const [itemsState, setItemsState] = useState<AccordionItemsState>(() => {
return Object.entries(navNodesById).reduce<AccordionItemsState>((acc, [_id, node]) => {
if (node.children) {
acc[_id] = {
isCollapsed: !node.isActive ?? DEFAULT_IS_COLLAPSED,
isCollapsible: node.isCollapsible ?? DEFAULT_IS_COLLAPSIBLE,
doCollapseFromActiveState: true,
};
}
return acc;
}, {});
});
const [subItems, setSubItems] = useState<EuiCollapsibleNavSubItemProps[] | undefined>();
const toggleAccordion = useCallback((id: string) => {
setItemsState((prev) => {
const prevValue = prev[id]?.isCollapsed ?? DEFAULT_IS_COLLAPSED;
return {
...prev,
[id]: {
...prev[id],
isCollapsed: !prevValue,
doCollapseFromActiveState: false, // once we manually toggle we don't want to auto-close it when URL changes
},
};
});
}, []);
const setAccordionProps = useCallback(
(
id: string,
_accordionProps?: Partial<EuiAccordionProps>
): Partial<EuiAccordionProps> | undefined => {
const isCollapsed = itemsState[id]?.isCollapsed ?? DEFAULT_IS_COLLAPSED;
const isCollapsible = itemsState[id]?.isCollapsible ?? DEFAULT_IS_COLLAPSIBLE;
let forceState: EuiAccordionProps['forceState'] = isCollapsed ? 'closed' : 'open';
if (!isCollapsible) forceState = 'open'; // Allways open if the accordion is not collapsible
const arrowProps: EuiAccordionProps['arrowProps'] = {
css: isCollapsible ? undefined : { display: 'none' },
'data-test-subj': classNames(`accordionArrow`, `accordionArrow-${id}`),
};
const updated: Partial<EuiAccordionProps> = {
..._accordionProps,
arrowProps,
forceState,
onToggle: () => {
toggleAccordion(id);
},
};
return updated;
},
[itemsState, toggleAccordion]
);
const { items, isVisible } = useMemo(() => {
return nodeToEuiCollapsibleNavProps(navNode, {
navigateToUrl,
openPanel,
closePanel,
isSideNavCollapsed,
treeDepth: 0,
itemsState,
});
}, [closePanel, isSideNavCollapsed, navNode, navigateToUrl, openPanel, itemsState]);
const [props] = items;
const { items: accordionItems } = props;
if (!isEuiCollapsibleNavItemProps(props)) {
throw new Error(`Invalid EuiCollapsibleNavItem props for node ${props.id}`);
}
/**
* Effect to set our internal state of each of the accordions (isCollapsed) based on the
* "isActive" state of the navNode.
*/
useEffect(() => {
setItemsState((prev) => {
return Object.entries(navNodesById).reduce<AccordionItemsState>((acc, [_id, node]) => {
if (node.children && (!prev[_id] || prev[_id].doCollapseFromActiveState)) {
acc[_id] = {
isCollapsed: !node.isActive ?? DEFAULT_IS_COLLAPSED,
isCollapsible: node.isCollapsible ?? DEFAULT_IS_COLLAPSIBLE,
doCollapseFromActiveState: true,
};
}
return acc;
}, prev);
});
}, [navNodesById]);
useEffect(() => {
// Serializer to add recursively the accordionProps to each of the items
// that will control its "open"/"closed" state + handler to toggle the state.
const serializeAccordionItems = (
_items?: EuiCollapsibleNavSubItemProps[]
): EuiCollapsibleNavSubItemProps[] | undefined => {
if (!_items) return;
return _items.map((item: EuiCollapsibleNavSubItemProps) => {
if (item.renderItem) {
return item;
}
const parsed: EuiCollapsibleNavSubItemProps = {
...item,
items: serializeAccordionItems(item.items),
accordionProps: setAccordionProps(item.id!, item.accordionProps),
};
return parsed;
});
};
setSubItems(serializeAccordionItems(accordionItems));
}, [accordionItems, setAccordionProps]);
if (!isVisible) {
return null;
}
return <EuiCollapsibleNavItem {...props} />;
return (
<EuiCollapsibleNavItem
{...props}
// We add this css to prevent showing the outline when the page load when the
// accordion is auto-expanded if one of its children is active
className={css`
.euiAccordion__childWrapper,
.euiAccordion__children,
.euiCollapsibleNavAccordion__children {
outline: none;
}
`}
items={subItems}
accordionProps={setAccordionProps(navNode.id)}
/>
);
};

View file

@ -631,9 +631,7 @@ const navigationDefinitionWithPanel: ProjectNavigationDefinition<any> = {
title: 'Example project',
icon: 'logoObservability',
defaultIsCollapsed: false,
accordionProps: {
arrowProps: { css: { display: 'none' } },
},
isCollapsible: false,
children: [
{
link: 'item1',

View file

@ -8,7 +8,6 @@
import type { ReactNode } from 'react';
import type { EuiAccordionProps } from '@elastic/eui';
import type {
AppDeepLinkId,
ChromeProjectNavigationNode,
@ -76,20 +75,6 @@ export interface GroupDefinition<
ChildrenId extends string = Id
> extends Omit<NodeDefinition<LinkId, Id, ChildrenId>, 'children'> {
type: 'navGroup';
/**
* Flag to indicate if the group is initially collapsed or not.
*
* `undefined`: (Recommended) the group will be opened if any of its children nodes matches the current URL.
*
* `false`: the group will be opened event if none of its children nodes matches the current URL.
*
* `true`: the group will be collapsed event if any of its children nodes matches the current URL.
*/
defaultIsCollapsed?: boolean;
/*
* Pass props to the EUI accordion component used to represent a nav group
*/
accordionProps?: Partial<EuiAccordionProps>;
children: Array<NodeDefinition<LinkId, Id, ChildrenId>>;
}

View file

@ -52,9 +52,7 @@ export const formatNavigationTree = (
breadcrumbStatus: 'hidden',
defaultIsCollapsed: false,
children: bodyChildren,
accordionProps: {
arrowProps: { css: { display: 'none' } },
},
isCollapsible: false,
},
],
footer: formatFooterNodesFromLinks(footerNavItems, footerCategories),

View file

@ -25,9 +25,7 @@ const navigationTree: NavigationTreeDefinition = {
title: 'Observability',
icon: 'logoObservability',
defaultIsCollapsed: false,
accordionProps: {
arrowProps: { css: { display: 'none' } },
},
isCollapsible: false,
breadcrumbStatus: 'hidden',
children: [
{
@ -80,9 +78,6 @@ const navigationTree: NavigationTreeDefinition = {
id: 'aiops',
title: 'AIOps',
renderAs: 'accordion',
accordionProps: {
arrowProps: { css: { display: 'none' } },
},
spaceBefore: null,
children: [
{
@ -135,9 +130,6 @@ const navigationTree: NavigationTreeDefinition = {
defaultMessage: 'Applications',
}),
renderAs: 'accordion',
accordionProps: {
arrowProps: { css: { display: 'none' } },
},
children: [
{
link: 'apm:services',
@ -166,9 +158,6 @@ const navigationTree: NavigationTreeDefinition = {
defaultMessage: 'Infrastructure',
}),
renderAs: 'accordion',
accordionProps: {
arrowProps: { css: { display: 'none' } },
},
children: [
{
link: 'metrics:inventory',

View file

@ -25,9 +25,7 @@ const navigationTree: NavigationTreeDefinition = {
title: 'Elasticsearch',
icon: 'logoElasticsearch',
defaultIsCollapsed: false,
accordionProps: {
arrowProps: { css: { display: 'none' } },
},
isCollapsible: false,
breadcrumbStatus: 'hidden',
children: [
{
@ -76,7 +74,6 @@ const navigationTree: NavigationTreeDefinition = {
},
],
},
{
id: 'content',
title: i18n.translate('xpack.serverlessSearch.nav.content', {

View file

@ -19,7 +19,7 @@ type NavigationId = MlNavId | AlNavId | MgmtNavId | DevNavId | string;
import type { FtrProviderContext } from '../ftr_provider_context';
import type { WebElementWrapper } from '../../../../test/functional/services/lib/web_element_wrapper';
const getSectionIdTestSubj = (sectionId: NavigationId) => `~nav-item-${sectionId} `;
const getSectionIdTestSubj = (sectionId: NavigationId) => `~nav-item-${sectionId}`;
export function SvlCommonNavigationProvider(ctx: FtrProviderContext) {
const testSubjects = ctx.getService('testSubjects');
@ -101,10 +101,7 @@ export function SvlCommonNavigationProvider(ctx: FtrProviderContext) {
},
async isSectionOpen(sectionId: NavigationId) {
await this.expectSectionExists(sectionId);
const section = await testSubjects.find(getSectionIdTestSubj(sectionId));
const collapseBtn = await section.findByCssSelector(
`[aria-controls="${sectionId}"][aria-expanded]`
);
const collapseBtn = await testSubjects.find(`~accordionArrow-${sectionId}`);
const isExpanded = await collapseBtn.getAttribute('aria-expanded');
return isExpanded === 'true';
},
@ -128,10 +125,7 @@ export function SvlCommonNavigationProvider(ctx: FtrProviderContext) {
await this.expectSectionExists(sectionId);
const isOpen = await this.isSectionOpen(sectionId);
if (isOpen) return;
const section = await testSubjects.find(getSectionIdTestSubj(sectionId));
const collapseBtn = await section.findByCssSelector(
`[aria-controls="${sectionId}"][aria-expanded]`
);
const collapseBtn = await testSubjects.find(`~accordionArrow-${sectionId}`);
await collapseBtn.click();
await this.expectSectionOpen(sectionId);
},
@ -139,10 +133,7 @@ export function SvlCommonNavigationProvider(ctx: FtrProviderContext) {
await this.expectSectionExists(sectionId);
const isOpen = await this.isSectionOpen(sectionId);
if (!isOpen) return;
const section = await testSubjects.find(getSectionIdTestSubj(sectionId));
const collapseBtn = await section.findByCssSelector(
`[aria-controls="${sectionId}"][aria-expanded]`
);
const collapseBtn = await testSubjects.find(`~accordionArrow-${sectionId}`);
await collapseBtn.click();
await this.expectSectionClosed(sectionId);
},

View file

@ -7,11 +7,15 @@
import { FtrProviderContext } from '../../ftr_provider_context';
export function MachineLearningNavigationProviderObservability({ getService }: FtrProviderContext) {
export function MachineLearningNavigationProviderObservability({
getService,
getPageObject,
}: FtrProviderContext) {
const testSubjects = getService('testSubjects');
const svlCommonNavigation = getPageObject('svlCommonNavigation');
async function navigateToArea(id: string) {
await testSubjects.click('~nav-item-id-observability_project_nav.aiops');
await svlCommonNavigation.sidenav.openSection('observability_project_nav.aiops');
await testSubjects.existOrFail(`~nav-item-id-observability_project_nav.aiops.ml:${id}`, {
timeout: 60 * 1000,
});

View file

@ -52,7 +52,7 @@ export default function ({ getPageObject, getService }: FtrProviderContext) {
await expect(await browser.getCurrentUrl()).contain('/app/observability-log-explorer');
// check the aiops subsection
await svlCommonNavigation.sidenav.clickLink({ navId: 'observability_project_nav.aiops' }); // open ai ops subsection
await svlCommonNavigation.sidenav.openSection('observability_project_nav.aiops'); // open ai ops subsection
await svlCommonNavigation.sidenav.clickLink({ deepLinkId: 'ml:anomalyDetection' });
await svlCommonNavigation.sidenav.expectLinkActive({ deepLinkId: 'ml:anomalyDetection' });
await svlCommonNavigation.breadcrumbs.expectBreadcrumbExists({ text: 'AIOps' });
@ -85,9 +85,7 @@ export default function ({ getPageObject, getService }: FtrProviderContext) {
await expectNoPageReload();
});
// Skipping this test as it is not supported in the new navigation for now.
// Will be fixed in https://github.com/elastic/kibana/issues/167328
it.skip('active sidenav section is auto opened on load', async () => {
it('active sidenav section is auto opened on load', async () => {
await svlCommonNavigation.sidenav.openSection('project_settings_project_nav');
await svlCommonNavigation.sidenav.clickLink({ deepLinkId: 'management' });
await browser.refresh();