diff --git a/src/core/packages/application/common/src/global_app_style.tsx b/src/core/packages/application/common/src/global_app_style.tsx index 595602385da86..b8e75532fc200 100644 --- a/src/core/packages/application/common/src/global_app_style.tsx +++ b/src/core/packages/application/common/src/global_app_style.tsx @@ -138,7 +138,8 @@ export const chromeStyles = (euiTheme: UseEuiTheme['euiTheme']) => css` .header__breadcrumbsWithExtensionContainer { overflow: hidden; // enables text-ellipsis in the last breadcrumb - .euiHeaderBreadcrumbs { + .euiHeaderBreadcrumbs, + .euiBreadcrumbs { // stop breadcrumbs from growing. // this makes the extension appear right next to the last breadcrumb flex-grow: 0; @@ -147,7 +148,7 @@ export const chromeStyles = (euiTheme: UseEuiTheme['euiTheme']) => css` overflow: hidden; // enables text-ellipsis in the last breadcrumb } } - .header__breadcrumbsAppendExtension { + .header__breadcrumbsAppendExtension--last { flex-grow: 1; } `; diff --git a/src/core/packages/chrome/browser-internal/src/chrome_service.test.tsx b/src/core/packages/chrome/browser-internal/src/chrome_service.test.tsx index 893910d1e4e47..ca97562040cc7 100644 --- a/src/core/packages/chrome/browser-internal/src/chrome_service.test.tsx +++ b/src/core/packages/chrome/browser-internal/src/chrome_service.test.tsx @@ -492,21 +492,70 @@ describe('start', () => { describe('breadcrumbsAppendExtension$', () => { it('updates the breadcrumbsAppendExtension$', async () => { const { chrome, service } = await start(); - const promise = chrome.getBreadcrumbsAppendExtension$().pipe(toArray()).toPromise(); + const promise = chrome.getBreadcrumbsAppendExtensions$().pipe(toArray()).toPromise(); + const ext1 = chrome.setBreadcrumbsAppendExtension({ + content: () => () => {}, + }); chrome.setBreadcrumbsAppendExtension({ + order: 0, + content: () => () => {}, + }); + const ext3 = chrome.setBreadcrumbsAppendExtension({ + order: 100, content: () => () => {}, }); + ext3(); + ext1(); service.stop(); await expect(promise).resolves.toMatchInlineSnapshot(` - Array [ - undefined, - Object { - "content": [Function], - }, - ] - `); + Array [ + Array [], + Array [ + Object { + "content": [Function], + }, + ], + Array [ + Object { + "content": [Function], + "order": 0, + }, + Object { + "content": [Function], + }, + ], + Array [ + Object { + "content": [Function], + "order": 0, + }, + Object { + "content": [Function], + }, + Object { + "content": [Function], + "order": 100, + }, + ], + Array [ + Object { + "content": [Function], + "order": 0, + }, + Object { + "content": [Function], + }, + ], + Array [ + Object { + "content": [Function], + "order": 0, + }, + ], + ] + `); }); }); diff --git a/src/core/packages/chrome/browser-internal/src/chrome_service.tsx b/src/core/packages/chrome/browser-internal/src/chrome_service.tsx index e8eb19482da7a..d17db92402018 100644 --- a/src/core/packages/chrome/browser-internal/src/chrome_service.tsx +++ b/src/core/packages/chrome/browser-internal/src/chrome_service.tsx @@ -270,9 +270,9 @@ export class ChromeService { ); const helpExtension$ = new BehaviorSubject(undefined); const breadcrumbs$ = new BehaviorSubject([]); - const breadcrumbsAppendExtension$ = new BehaviorSubject< - ChromeBreadcrumbsAppendExtension | undefined - >(undefined); + const breadcrumbsAppendExtensions$ = new BehaviorSubject( + [] + ); const badge$ = new BehaviorSubject(undefined); const customNavLink$ = new BehaviorSubject(undefined); const helpSupportUrl$ = new BehaviorSubject(docLinks.links.kibana.askElastic); @@ -467,6 +467,9 @@ export class ChromeService { globalHelpExtensionMenuLinks$={globalHelpExtensionMenuLinks$} actionMenu$={application.currentActionMenu$} breadcrumbs$={currentProjectBreadcrumbs$} + breadcrumbsAppendExtensions$={breadcrumbsAppendExtensions$.pipe( + takeUntil(this.stop$) + )} customBranding$={customBranding$} helpExtension$={helpExtension$.pipe(takeUntil(this.stop$))} helpSupportUrl$={helpSupportUrl$.pipe(takeUntil(this.stop$))} @@ -500,7 +503,7 @@ export class ChromeService { badge$={badge$.pipe(takeUntil(this.stop$))} basePath={http.basePath} breadcrumbs$={breadcrumbs$.pipe(takeUntil(this.stop$))} - breadcrumbsAppendExtension$={breadcrumbsAppendExtension$.pipe(takeUntil(this.stop$))} + breadcrumbsAppendExtensions$={breadcrumbsAppendExtensions$.pipe(takeUntil(this.stop$))} customNavLink$={customNavLink$.pipe(takeUntil(this.stop$))} kibanaDocLink={docLinks.links.kibana.guide} docLinks={docLinks} @@ -548,12 +551,24 @@ export class ChromeService { setBreadcrumbs: setClassicBreadcrumbs, - getBreadcrumbsAppendExtension$: () => breadcrumbsAppendExtension$.pipe(takeUntil(this.stop$)), + getBreadcrumbsAppendExtensions$: () => + breadcrumbsAppendExtensions$.pipe(takeUntil(this.stop$)), setBreadcrumbsAppendExtension: ( - breadcrumbsAppendExtension?: ChromeBreadcrumbsAppendExtension + breadcrumbsAppendExtension: ChromeBreadcrumbsAppendExtension ) => { - breadcrumbsAppendExtension$.next(breadcrumbsAppendExtension); + breadcrumbsAppendExtensions$.next( + [...breadcrumbsAppendExtensions$.getValue(), breadcrumbsAppendExtension].sort( + ({ order: orderA = 50 }, { order: orderB = 50 }) => orderA - orderB + ) + ); + return () => { + breadcrumbsAppendExtensions$.next( + breadcrumbsAppendExtensions$ + .getValue() + .filter((ext) => ext !== breadcrumbsAppendExtension) + ); + }; }, getGlobalHelpExtensionMenuLinks$: () => globalHelpExtensionMenuLinks$.asObservable(), diff --git a/src/core/packages/chrome/browser-internal/src/ui/header/breadcrumbs_with_extensions.tsx b/src/core/packages/chrome/browser-internal/src/ui/header/breadcrumbs_with_extensions.tsx new file mode 100644 index 0000000000000..28f19c2dd6800 --- /dev/null +++ b/src/core/packages/chrome/browser-internal/src/ui/header/breadcrumbs_with_extensions.tsx @@ -0,0 +1,53 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import React, { PropsWithChildren } from 'react'; +import { Observable } from 'rxjs'; +import type { ChromeBreadcrumbsAppendExtension } from '@kbn/core-chrome-browser'; +import useObservable from 'react-use/lib/useObservable'; +import { EuiFlexGroup } from '@elastic/eui'; +import classnames from 'classnames'; +import { HeaderExtension } from './header_extension'; + +export interface Props { + breadcrumbsAppendExtensions$: Observable; +} + +export const BreadcrumbsWithExtensionsWrapper = ({ + breadcrumbsAppendExtensions$, + children, +}: PropsWithChildren) => { + const breadcrumbsAppendExtensions = useObservable(breadcrumbsAppendExtensions$, []); + + return breadcrumbsAppendExtensions.length === 0 ? ( + <>{children} + ) : ( + + {children} + {breadcrumbsAppendExtensions.map((breadcrumbsAppendExtension, index) => { + const isLast = breadcrumbsAppendExtensions.length - 1 === index; + return ( + + ); + })} + + ); +}; diff --git a/src/core/packages/chrome/browser-internal/src/ui/header/header.test.tsx b/src/core/packages/chrome/browser-internal/src/ui/header/header.test.tsx index f6253e2836625..a697f9632acda 100644 --- a/src/core/packages/chrome/browser-internal/src/ui/header/header.test.tsx +++ b/src/core/packages/chrome/browser-internal/src/ui/header/header.test.tsx @@ -82,9 +82,9 @@ describe('Header', () => { const recentlyAccessed$ = new BehaviorSubject([ { link: '', label: 'dashboard', id: 'dashboard' }, ]); - const breadcrumbsAppendExtension$ = new BehaviorSubject< - undefined | ChromeBreadcrumbsAppendExtension - >(undefined); + const breadcrumbsAppendExtensions$ = new BehaviorSubject( + [] + ); const component = mountWithIntl(
{ recentlyAccessed$={recentlyAccessed$} isLocked$={isLocked$} customNavLink$={customNavLink$} - breadcrumbsAppendExtension$={breadcrumbsAppendExtension$} + breadcrumbsAppendExtensions$={breadcrumbsAppendExtensions$} headerBanner$={headerBanner$} helpMenuLinks$={of([])} isServerless={false} @@ -108,17 +108,28 @@ describe('Header', () => { expect(component.render()).toMatchSnapshot(); act(() => - breadcrumbsAppendExtension$.next({ - content: (root: HTMLDivElement) => { - root.innerHTML = '
__render__
'; - return () => (root.innerHTML = ''); + breadcrumbsAppendExtensions$.next([ + { + content: (root: HTMLDivElement) => { + root.innerHTML = '
__render__
'; + return () => (root.innerHTML = ''); + }, + }, + { + content: (root: HTMLDivElement) => { + root.innerHTML = '
__render__
'; + return () => (root.innerHTML = ''); + }, }, - }) + ]) ); component.update(); - expect(component.find('HeaderExtension').exists()).toBeTruthy(); + expect(component.find('HeaderExtension').length).toBe(2); + expect( + component.find('HeaderExtension').at(0).getDOMNode().querySelector('.my-extension1') + ).toBeTruthy(); expect( - component.find('HeaderExtension').getDOMNode().querySelector('.my-extension') + component.find('HeaderExtension').at(1).getDOMNode().querySelector('.my-extension2') ).toBeTruthy(); }); }); diff --git a/src/core/packages/chrome/browser-internal/src/ui/header/header.tsx b/src/core/packages/chrome/browser-internal/src/ui/header/header.tsx index 62f2963aef423..181c8495d9de5 100644 --- a/src/core/packages/chrome/browser-internal/src/ui/header/header.tsx +++ b/src/core/packages/chrome/browser-internal/src/ui/header/header.tsx @@ -8,7 +8,6 @@ */ import { - EuiFlexGroup, EuiHeader, EuiHeaderSection, EuiHeaderSectionItem, @@ -19,7 +18,6 @@ import { import { i18n } from '@kbn/i18n'; import classnames from 'classnames'; import React, { createRef, useState } from 'react'; -import useObservable from 'react-use/lib/useObservable'; import type { Observable } from 'rxjs'; import type { HttpStart } from '@kbn/core-http-browser'; import type { InternalApplicationStart } from '@kbn/core-application-browser-internal'; @@ -45,7 +43,7 @@ import { HeaderHelpMenu } from './header_help_menu'; import { HeaderLogo } from './header_logo'; import { HeaderNavControls } from './header_nav_controls'; import { HeaderActionMenu, useHeaderActionMenuMounter } from './header_action_menu'; -import { HeaderExtension } from './header_extension'; +import { BreadcrumbsWithExtensionsWrapper } from './breadcrumbs_with_extensions'; import { HeaderTopBanner } from './header_top_banner'; import { HeaderMenuButton } from './header_menu_button'; import { ScreenReaderRouteAnnouncements, SkipToMainContent } from './screen_reader_a11y'; @@ -56,7 +54,7 @@ export interface HeaderProps { headerBanner$: Observable; badge$: Observable; breadcrumbs$: Observable; - breadcrumbsAppendExtension$: Observable; + breadcrumbsAppendExtensions$: Observable; customNavLink$: Observable; homeHref: string; kibanaDocLink: string; @@ -88,7 +86,7 @@ export function Header({ basePath, onIsLockedUpdate, homeHref, - breadcrumbsAppendExtension$, + breadcrumbsAppendExtensions$, globalHelpExtensionMenuLinks$, customBranding$, isServerless, @@ -96,7 +94,6 @@ export function Header({ }: HeaderProps) { const [isNavOpen, setIsNavOpen] = useState(false); const [navId] = useState(htmlIdGenerator()()); - const breadcrumbsAppendExtension = useObservable(breadcrumbsAppendExtension$); const headerActionMenuMounter = useHeaderActionMenuMounter(application.currentActionMenu$); const toggleCollapsibleNavRef = createRef void }>(); @@ -206,24 +203,11 @@ export function Header({ - - {!breadcrumbsAppendExtension ? ( - Breadcrumbs - ) : ( - - {Breadcrumbs} - - - )} + + {Breadcrumbs} + diff --git a/src/core/packages/chrome/browser-internal/src/ui/project/header.test.tsx b/src/core/packages/chrome/browser-internal/src/ui/project/header.test.tsx index 1452b45555a6b..3c8a0498b045d 100644 --- a/src/core/packages/chrome/browser-internal/src/ui/project/header.test.tsx +++ b/src/core/packages/chrome/browser-internal/src/ui/project/header.test.tsx @@ -21,6 +21,7 @@ describe('Header', () => { const mockProps: Omit = { application: mockApplication, breadcrumbs$: Rx.of([]), + breadcrumbsAppendExtensions$: Rx.of([]), actionMenu$: Rx.of(undefined), docLinks: docLinksServiceMock.createStartContract(), globalHelpExtensionMenuLinks$: Rx.of([]), diff --git a/src/core/packages/chrome/browser-internal/src/ui/project/header.tsx b/src/core/packages/chrome/browser-internal/src/ui/project/header.tsx index e63a27c1d44ed..ca8c8a4780e54 100644 --- a/src/core/packages/chrome/browser-internal/src/ui/project/header.tsx +++ b/src/core/packages/chrome/browser-internal/src/ui/project/header.tsx @@ -12,15 +12,16 @@ import { EuiHeaderLogo, EuiHeaderSection, EuiHeaderSectionItem, + EuiImage, EuiLoadingSpinner, - useEuiTheme, EuiThemeComputed, - EuiImage, + useEuiTheme, } from '@elastic/eui'; import { css } from '@emotion/react'; import type { InternalApplicationStart } from '@kbn/core-application-browser-internal'; import { ChromeBreadcrumb, + type ChromeBreadcrumbsAppendExtension, ChromeGlobalHelpExtensionMenuLink, ChromeHelpExtension, ChromeHelpMenuLink, @@ -33,7 +34,7 @@ import { MountPoint } from '@kbn/core-mount-utils-browser'; import { i18n } from '@kbn/i18n'; import { RedirectAppLinks } from '@kbn/shared-ux-link-redirect-app'; import { Router } from '@kbn/shared-ux-router'; -import React, { useCallback, type ComponentProps } from 'react'; +import React, { type ComponentProps, useCallback } from 'react'; import useObservable from 'react-use/lib/useObservable'; import { debounceTime, Observable } from 'rxjs'; import type { CustomBranding } from '@kbn/core-custom-branding-common'; @@ -46,6 +47,7 @@ import { HeaderTopBanner } from '../header/header_top_banner'; import { ScreenReaderRouteAnnouncements, SkipToMainContent } from '../header/screen_reader_a11y'; import { AppMenuBar } from './app_menu'; import { ProjectNavigation } from './navigation'; +import { BreadcrumbsWithExtensionsWrapper } from '../header/breadcrumbs_with_extensions'; const getHeaderCss = ({ size, colors }: EuiThemeComputed) => ({ logo: { @@ -114,6 +116,7 @@ const headerStrings = { export interface Props extends Pick, 'isServerless'> { headerBanner$: Observable; breadcrumbs$: Observable; + breadcrumbsAppendExtensions$: Observable; actionMenu$: Observable; docLinks: DocLinksStart; children: React.ReactNode; @@ -228,6 +231,7 @@ export const ProjectHeader = ({ toggleSideNav, customBranding$, isServerless, + breadcrumbsAppendExtensions$, ...observables }: Props) => { const headerActionMenuMounter = useHeaderActionMenuMounter(observables.actionMenu$); @@ -282,7 +286,11 @@ export const ProjectHeader = ({ coreStart={{ application }} css={headerCss.redirectAppLinksContainer} > - + + + diff --git a/src/core/packages/chrome/browser-mocks/src/chrome_service.mock.ts b/src/core/packages/chrome/browser-mocks/src/chrome_service.mock.ts index 6be7bb68907eb..ed8b01b324848 100644 --- a/src/core/packages/chrome/browser-mocks/src/chrome_service.mock.ts +++ b/src/core/packages/chrome/browser-mocks/src/chrome_service.mock.ts @@ -59,7 +59,7 @@ const createStartContractMock = () => { getIsFeedbackBtnVisible$: jest.fn(), setIsFeedbackBtnVisible: jest.fn(), }, - getBreadcrumbsAppendExtension$: jest.fn(), + getBreadcrumbsAppendExtensions$: jest.fn(), setBreadcrumbsAppendExtension: jest.fn(), getGlobalHelpExtensionMenuLinks$: jest.fn(), registerGlobalHelpExtensionMenuLink: jest.fn(), @@ -95,7 +95,7 @@ const createStartContractMock = () => { startContract.getIsVisible$.mockReturnValue(new BehaviorSubject(false)); startContract.getBadge$.mockReturnValue(new BehaviorSubject({} as ChromeBadge)); startContract.getBreadcrumbs$.mockReturnValue(new BehaviorSubject([{} as ChromeBreadcrumb])); - startContract.getBreadcrumbsAppendExtension$.mockReturnValue(new BehaviorSubject(undefined)); + startContract.getBreadcrumbsAppendExtensions$.mockReturnValue(new BehaviorSubject([])); startContract.getCustomNavLink$.mockReturnValue(new BehaviorSubject(undefined)); startContract.getGlobalHelpExtensionMenuLinks$.mockReturnValue(new BehaviorSubject([])); startContract.getHelpExtension$.mockReturnValue(new BehaviorSubject(undefined)); diff --git a/src/core/packages/chrome/browser/src/breadcrumb.ts b/src/core/packages/chrome/browser/src/breadcrumb.ts index c0067030b7b0c..b322621aa10fd 100644 --- a/src/core/packages/chrome/browser/src/breadcrumb.ts +++ b/src/core/packages/chrome/browser/src/breadcrumb.ts @@ -23,6 +23,8 @@ export interface ChromeBreadcrumb extends EuiBreadcrumb { /** @public */ export interface ChromeBreadcrumbsAppendExtension { content: MountPoint; + /** The order in which the extension should be appended to the breadcrumbs. Default is 50 */ + order?: number; } /** @public */ diff --git a/src/core/packages/chrome/browser/src/contracts.ts b/src/core/packages/chrome/browser/src/contracts.ts index f5b5d1f0eaf12..eda0dad76dd9c 100644 --- a/src/core/packages/chrome/browser/src/contracts.ts +++ b/src/core/packages/chrome/browser/src/contracts.ts @@ -91,16 +91,16 @@ export interface ChromeStart { setBreadcrumbs(newBreadcrumbs: ChromeBreadcrumb[], params?: ChromeSetBreadcrumbsParams): void; /** - * Get an observable of the current extension appended to breadcrumbs + * Get an observable of the current extensions appended to breadcrumbs */ - getBreadcrumbsAppendExtension$(): Observable; + getBreadcrumbsAppendExtensions$(): Observable; /** * Mount an element next to the last breadcrumb */ setBreadcrumbsAppendExtension( - breadcrumbsAppendExtension?: ChromeBreadcrumbsAppendExtension - ): void; + breadcrumbsAppendExtension: ChromeBreadcrumbsAppendExtension + ): () => void; /** * Get an observable of the current custom nav link diff --git a/src/platform/packages/shared/kbn-typed-react-router-config/src/breadcrumbs/use_breadcrumbs.ts b/src/platform/packages/shared/kbn-typed-react-router-config/src/breadcrumbs/use_breadcrumbs.ts index 26123f35b15e7..6cf09c10e5eb3 100644 --- a/src/platform/packages/shared/kbn-typed-react-router-config/src/breadcrumbs/use_breadcrumbs.ts +++ b/src/platform/packages/shared/kbn-typed-react-router-config/src/breadcrumbs/use_breadcrumbs.ts @@ -76,13 +76,8 @@ export const useBreadcrumbs = ( useEffect(() => { if (breadcrumbsAppendExtension) { - setBreadcrumbsAppendExtension(breadcrumbsAppendExtension); + return setBreadcrumbsAppendExtension(breadcrumbsAppendExtension); } - return () => { - if (breadcrumbsAppendExtension) { - setBreadcrumbsAppendExtension(undefined); - } - }; }, [breadcrumbsAppendExtension, setBreadcrumbsAppendExtension]); useEffect(() => { diff --git a/x-pack/solutions/observability/plugins/infra/public/components/asset_details/__stories__/decorator.tsx b/x-pack/solutions/observability/plugins/infra/public/components/asset_details/__stories__/decorator.tsx index 5cde2fc3ca1ae..d9b8d80aed2ba 100644 --- a/x-pack/solutions/observability/plugins/infra/public/components/asset_details/__stories__/decorator.tsx +++ b/x-pack/solutions/observability/plugins/infra/public/components/asset_details/__stories__/decorator.tsx @@ -66,7 +66,7 @@ export const DecorateWithKibanaContext: DecoratorFn = (story) => { }, }, setBreadcrumbs: () => {}, - setBreadcrumbsAppendExtension: () => {}, + setBreadcrumbsAppendExtension: () => () => {}, }, data: { search: { diff --git a/x-pack/solutions/observability/plugins/observability_shared/public/hooks/use_breadcrumbs.ts b/x-pack/solutions/observability/plugins/observability_shared/public/hooks/use_breadcrumbs.ts index 81ee8857e03f4..efe6113d7a2a0 100644 --- a/x-pack/solutions/observability/plugins/observability_shared/public/hooks/use_breadcrumbs.ts +++ b/x-pack/solutions/observability/plugins/observability_shared/public/hooks/use_breadcrumbs.ts @@ -116,13 +116,8 @@ export const useBreadcrumbs = ( useEffect(() => { if (breadcrumbsAppendExtension) { - setBreadcrumbsAppendExtension(breadcrumbsAppendExtension); + return setBreadcrumbsAppendExtension(breadcrumbsAppendExtension); } - return () => { - if (breadcrumbsAppendExtension) { - setBreadcrumbsAppendExtension(undefined); - } - }; }, [breadcrumbsAppendExtension, setBreadcrumbsAppendExtension]); useEffect(() => {