[chrome] Improve breadcrumb extension (#209765)

## Summary

fix https://github.com/elastic/kibana/issues/208728

This PR improves breadcrumb extension point for adding starring next to
a dashboard breadcrumb https://github.com/elastic/kibana/issues/200315:

- Fix breadcrumb extension didn't render in solution nav
- Support multiple extensions (search sessions are deprecated and need
to be enabled with kibana.yml flag, but we still need to support both UI
elements)
- Improve DX to unmount the extension 

To test: 

- Add `data.search.sessions.enabled: true` and see that search session
UI appears in solution nav.
- To test multiple, add more extensions by using
`chrome.setBreadcrumbsAppendExtension`, e.g. in
`src/platform/plugins/shared/data/public/search/search_service.ts` .
This actually gonna be used in
https://github.com/elastic/kibana/issues/200315

![Screenshot 2025-02-05 at 14 41
21](https://github.com/user-attachments/assets/f4bece3e-6b09-4afb-94b5-291a7387118c)
This commit is contained in:
Anton Dosov 2025-02-07 15:49:35 +01:00 committed by GitHub
parent e21e7482e7
commit 02a88d13c2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 191 additions and 77 deletions

View file

@ -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;
}
`;

View file

@ -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();
chrome.setBreadcrumbsAppendExtension({
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,
},
],
]
`);
});
});

View file

@ -270,9 +270,9 @@ export class ChromeService {
);
const helpExtension$ = new BehaviorSubject<ChromeHelpExtension | undefined>(undefined);
const breadcrumbs$ = new BehaviorSubject<ChromeBreadcrumb[]>([]);
const breadcrumbsAppendExtension$ = new BehaviorSubject<
ChromeBreadcrumbsAppendExtension | undefined
>(undefined);
const breadcrumbsAppendExtensions$ = new BehaviorSubject<ChromeBreadcrumbsAppendExtension[]>(
[]
);
const badge$ = new BehaviorSubject<ChromeBadge | undefined>(undefined);
const customNavLink$ = new BehaviorSubject<ChromeNavLink | undefined>(undefined);
const helpSupportUrl$ = new BehaviorSubject<string>(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(),

View file

@ -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<ChromeBreadcrumbsAppendExtension[]>;
}
export const BreadcrumbsWithExtensionsWrapper = ({
breadcrumbsAppendExtensions$,
children,
}: PropsWithChildren<Props>) => {
const breadcrumbsAppendExtensions = useObservable(breadcrumbsAppendExtensions$, []);
return breadcrumbsAppendExtensions.length === 0 ? (
<>{children}</>
) : (
<EuiFlexGroup
responsive={false}
wrap={false}
alignItems={'center'}
className={'header__breadcrumbsWithExtensionContainer'}
gutterSize={'none'}
>
{children}
{breadcrumbsAppendExtensions.map((breadcrumbsAppendExtension, index) => {
const isLast = breadcrumbsAppendExtensions.length - 1 === index;
return (
<HeaderExtension
key={index}
extension={breadcrumbsAppendExtension.content}
containerClassName={classnames({
'header__breadcrumbsAppendExtension--last': isLast,
})}
/>
);
})}
</EuiFlexGroup>
);
};

View file

@ -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<ChromeBreadcrumbsAppendExtension[]>(
[]
);
const component = mountWithIntl(
<Header
{...mockProps()}
@ -93,7 +93,7 @@ describe('Header', () => {
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 = '<div class="my-extension">__render__</div>';
return () => (root.innerHTML = '');
breadcrumbsAppendExtensions$.next([
{
content: (root: HTMLDivElement) => {
root.innerHTML = '<div class="my-extension1">__render__</div>';
return () => (root.innerHTML = '');
},
},
})
{
content: (root: HTMLDivElement) => {
root.innerHTML = '<div class="my-extension2">__render__</div>';
return () => (root.innerHTML = '');
},
},
])
);
component.update();
expect(component.find('HeaderExtension').exists()).toBeTruthy();
expect(component.find('HeaderExtension').length).toBe(2);
expect(
component.find('HeaderExtension').getDOMNode().querySelector('.my-extension')
component.find('HeaderExtension').at(0).getDOMNode().querySelector('.my-extension1')
).toBeTruthy();
expect(
component.find('HeaderExtension').at(1).getDOMNode().querySelector('.my-extension2')
).toBeTruthy();
});
});

View file

@ -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<ChromeUserBanner | undefined>;
badge$: Observable<ChromeBadge | undefined>;
breadcrumbs$: Observable<ChromeBreadcrumb[]>;
breadcrumbsAppendExtension$: Observable<ChromeBreadcrumbsAppendExtension | undefined>;
breadcrumbsAppendExtensions$: Observable<ChromeBreadcrumbsAppendExtension[]>;
customNavLink$: Observable<ChromeNavLink | undefined>;
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<HTMLButtonElement & { euiAnimate: () => void }>();
@ -206,24 +203,11 @@ export function Header({
<HeaderNavControls side="left" navControls$={observables.navControlsLeft$} />
</EuiHeaderSection>
{!breadcrumbsAppendExtension ? (
Breadcrumbs
) : (
<EuiFlexGroup
responsive={false}
wrap={false}
alignItems={'center'}
className={'header__breadcrumbsWithExtensionContainer'}
gutterSize={'none'}
>
{Breadcrumbs}
<HeaderExtension
extension={breadcrumbsAppendExtension.content}
containerClassName={'header__breadcrumbsAppendExtension'}
/>
</EuiFlexGroup>
)}
<BreadcrumbsWithExtensionsWrapper
breadcrumbsAppendExtensions$={breadcrumbsAppendExtensions$}
>
{Breadcrumbs}
</BreadcrumbsWithExtensionsWrapper>
<HeaderBadge badge$={observables.badge$} />

View file

@ -21,6 +21,7 @@ describe('Header', () => {
const mockProps: Omit<ProjectHeaderProps, 'children'> = {
application: mockApplication,
breadcrumbs$: Rx.of([]),
breadcrumbsAppendExtensions$: Rx.of([]),
actionMenu$: Rx.of(undefined),
docLinks: docLinksServiceMock.createStartContract(),
globalHelpExtensionMenuLinks$: Rx.of([]),

View file

@ -12,15 +12,16 @@ import {
EuiHeaderLogo,
EuiHeaderSection,
EuiHeaderSectionItem,
EuiLoadingSpinner,
useEuiTheme,
EuiThemeComputed,
EuiImage,
EuiLoadingSpinner,
EuiThemeComputed,
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<ComponentProps<typeof HeaderHelpMenu>, 'isServerless'> {
headerBanner$: Observable<ChromeUserBanner | undefined>;
breadcrumbs$: Observable<ChromeBreadcrumb[]>;
breadcrumbsAppendExtensions$: Observable<ChromeBreadcrumbsAppendExtension[]>;
actionMenu$: Observable<MountPoint | undefined>;
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}
>
<Breadcrumbs breadcrumbs$={observables.breadcrumbs$} />
<BreadcrumbsWithExtensionsWrapper
breadcrumbsAppendExtensions$={breadcrumbsAppendExtensions$}
>
<Breadcrumbs breadcrumbs$={observables.breadcrumbs$} />
</BreadcrumbsWithExtensionsWrapper>
</RedirectAppLinks>
</EuiHeaderSectionItem>
</EuiHeaderSection>

View file

@ -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));

View file

@ -23,6 +23,8 @@ export interface ChromeBreadcrumb extends EuiBreadcrumb {
/** @public */
export interface ChromeBreadcrumbsAppendExtension {
content: MountPoint<HTMLDivElement>;
/** The order in which the extension should be appended to the breadcrumbs. Default is 50 */
order?: number;
}
/** @public */

View file

@ -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<ChromeBreadcrumbsAppendExtension | undefined>;
getBreadcrumbsAppendExtensions$(): Observable<ChromeBreadcrumbsAppendExtension[]>;
/**
* Mount an element next to the last breadcrumb
*/
setBreadcrumbsAppendExtension(
breadcrumbsAppendExtension?: ChromeBreadcrumbsAppendExtension
): void;
breadcrumbsAppendExtension: ChromeBreadcrumbsAppendExtension
): () => void;
/**
* Get an observable of the current custom nav link

View file

@ -76,13 +76,8 @@ export const useBreadcrumbs = (
useEffect(() => {
if (breadcrumbsAppendExtension) {
setBreadcrumbsAppendExtension(breadcrumbsAppendExtension);
return setBreadcrumbsAppendExtension(breadcrumbsAppendExtension);
}
return () => {
if (breadcrumbsAppendExtension) {
setBreadcrumbsAppendExtension(undefined);
}
};
}, [breadcrumbsAppendExtension, setBreadcrumbsAppendExtension]);
useEffect(() => {

View file

@ -66,7 +66,7 @@ export const DecorateWithKibanaContext: DecoratorFn = (story) => {
},
},
setBreadcrumbs: () => {},
setBreadcrumbsAppendExtension: () => {},
setBreadcrumbsAppendExtension: () => () => {},
},
data: {
search: {

View file

@ -116,13 +116,8 @@ export const useBreadcrumbs = (
useEffect(() => {
if (breadcrumbsAppendExtension) {
setBreadcrumbsAppendExtension(breadcrumbsAppendExtension);
return setBreadcrumbsAppendExtension(breadcrumbsAppendExtension);
}
return () => {
if (breadcrumbsAppendExtension) {
setBreadcrumbsAppendExtension(undefined);
}
};
}, [breadcrumbsAppendExtension, setBreadcrumbsAppendExtension]);
useEffect(() => {