Skip to content

Commit 02a88d1

Browse files
authored
[chrome] Improve breadcrumb extension (#209765)
## Summary fix #208728 This PR improves breadcrumb extension point for adding starring next to a dashboard breadcrumb #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 #200315 ![Screenshot 2025-02-05 at 14 41 21](https://github.com/user-attachments/assets/f4bece3e-6b09-4afb-94b5-291a7387118c)
1 parent e21e748 commit 02a88d1

File tree

14 files changed

+189
-75
lines changed

14 files changed

+189
-75
lines changed

src/core/packages/application/common/src/global_app_style.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -138,7 +138,8 @@ export const chromeStyles = (euiTheme: UseEuiTheme['euiTheme']) => css`
138138
139139
.header__breadcrumbsWithExtensionContainer {
140140
overflow: hidden; // enables text-ellipsis in the last breadcrumb
141-
.euiHeaderBreadcrumbs {
141+
.euiHeaderBreadcrumbs,
142+
.euiBreadcrumbs {
142143
// stop breadcrumbs from growing.
143144
// this makes the extension appear right next to the last breadcrumb
144145
flex-grow: 0;
@@ -147,7 +148,7 @@ export const chromeStyles = (euiTheme: UseEuiTheme['euiTheme']) => css`
147148
overflow: hidden; // enables text-ellipsis in the last breadcrumb
148149
}
149150
}
150-
.header__breadcrumbsAppendExtension {
151+
.header__breadcrumbsAppendExtension--last {
151152
flex-grow: 1;
152153
}
153154
`;

src/core/packages/chrome/browser-internal/src/chrome_service.test.tsx

Lines changed: 57 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -492,21 +492,70 @@ describe('start', () => {
492492
describe('breadcrumbsAppendExtension$', () => {
493493
it('updates the breadcrumbsAppendExtension$', async () => {
494494
const { chrome, service } = await start();
495-
const promise = chrome.getBreadcrumbsAppendExtension$().pipe(toArray()).toPromise();
495+
const promise = chrome.getBreadcrumbsAppendExtensions$().pipe(toArray()).toPromise();
496496

497+
const ext1 = chrome.setBreadcrumbsAppendExtension({
498+
content: () => () => {},
499+
});
497500
chrome.setBreadcrumbsAppendExtension({
501+
order: 0,
502+
content: () => () => {},
503+
});
504+
const ext3 = chrome.setBreadcrumbsAppendExtension({
505+
order: 100,
498506
content: () => () => {},
499507
});
508+
ext3();
509+
ext1();
500510
service.stop();
501511

502512
await expect(promise).resolves.toMatchInlineSnapshot(`
503-
Array [
504-
undefined,
505-
Object {
506-
"content": [Function],
507-
},
508-
]
509-
`);
513+
Array [
514+
Array [],
515+
Array [
516+
Object {
517+
"content": [Function],
518+
},
519+
],
520+
Array [
521+
Object {
522+
"content": [Function],
523+
"order": 0,
524+
},
525+
Object {
526+
"content": [Function],
527+
},
528+
],
529+
Array [
530+
Object {
531+
"content": [Function],
532+
"order": 0,
533+
},
534+
Object {
535+
"content": [Function],
536+
},
537+
Object {
538+
"content": [Function],
539+
"order": 100,
540+
},
541+
],
542+
Array [
543+
Object {
544+
"content": [Function],
545+
"order": 0,
546+
},
547+
Object {
548+
"content": [Function],
549+
},
550+
],
551+
Array [
552+
Object {
553+
"content": [Function],
554+
"order": 0,
555+
},
556+
],
557+
]
558+
`);
510559
});
511560
});
512561

src/core/packages/chrome/browser-internal/src/chrome_service.tsx

Lines changed: 22 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -270,9 +270,9 @@ export class ChromeService {
270270
);
271271
const helpExtension$ = new BehaviorSubject<ChromeHelpExtension | undefined>(undefined);
272272
const breadcrumbs$ = new BehaviorSubject<ChromeBreadcrumb[]>([]);
273-
const breadcrumbsAppendExtension$ = new BehaviorSubject<
274-
ChromeBreadcrumbsAppendExtension | undefined
275-
>(undefined);
273+
const breadcrumbsAppendExtensions$ = new BehaviorSubject<ChromeBreadcrumbsAppendExtension[]>(
274+
[]
275+
);
276276
const badge$ = new BehaviorSubject<ChromeBadge | undefined>(undefined);
277277
const customNavLink$ = new BehaviorSubject<ChromeNavLink | undefined>(undefined);
278278
const helpSupportUrl$ = new BehaviorSubject<string>(docLinks.links.kibana.askElastic);
@@ -467,6 +467,9 @@ export class ChromeService {
467467
globalHelpExtensionMenuLinks$={globalHelpExtensionMenuLinks$}
468468
actionMenu$={application.currentActionMenu$}
469469
breadcrumbs$={currentProjectBreadcrumbs$}
470+
breadcrumbsAppendExtensions$={breadcrumbsAppendExtensions$.pipe(
471+
takeUntil(this.stop$)
472+
)}
470473
customBranding$={customBranding$}
471474
helpExtension$={helpExtension$.pipe(takeUntil(this.stop$))}
472475
helpSupportUrl$={helpSupportUrl$.pipe(takeUntil(this.stop$))}
@@ -500,7 +503,7 @@ export class ChromeService {
500503
badge$={badge$.pipe(takeUntil(this.stop$))}
501504
basePath={http.basePath}
502505
breadcrumbs$={breadcrumbs$.pipe(takeUntil(this.stop$))}
503-
breadcrumbsAppendExtension$={breadcrumbsAppendExtension$.pipe(takeUntil(this.stop$))}
506+
breadcrumbsAppendExtensions$={breadcrumbsAppendExtensions$.pipe(takeUntil(this.stop$))}
504507
customNavLink$={customNavLink$.pipe(takeUntil(this.stop$))}
505508
kibanaDocLink={docLinks.links.kibana.guide}
506509
docLinks={docLinks}
@@ -548,12 +551,24 @@ export class ChromeService {
548551

549552
setBreadcrumbs: setClassicBreadcrumbs,
550553

551-
getBreadcrumbsAppendExtension$: () => breadcrumbsAppendExtension$.pipe(takeUntil(this.stop$)),
554+
getBreadcrumbsAppendExtensions$: () =>
555+
breadcrumbsAppendExtensions$.pipe(takeUntil(this.stop$)),
552556

553557
setBreadcrumbsAppendExtension: (
554-
breadcrumbsAppendExtension?: ChromeBreadcrumbsAppendExtension
558+
breadcrumbsAppendExtension: ChromeBreadcrumbsAppendExtension
555559
) => {
556-
breadcrumbsAppendExtension$.next(breadcrumbsAppendExtension);
560+
breadcrumbsAppendExtensions$.next(
561+
[...breadcrumbsAppendExtensions$.getValue(), breadcrumbsAppendExtension].sort(
562+
({ order: orderA = 50 }, { order: orderB = 50 }) => orderA - orderB
563+
)
564+
);
565+
return () => {
566+
breadcrumbsAppendExtensions$.next(
567+
breadcrumbsAppendExtensions$
568+
.getValue()
569+
.filter((ext) => ext !== breadcrumbsAppendExtension)
570+
);
571+
};
557572
},
558573

559574
getGlobalHelpExtensionMenuLinks$: () => globalHelpExtensionMenuLinks$.asObservable(),
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
/*
2+
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
3+
* or more contributor license agreements. Licensed under the "Elastic License
4+
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
5+
* Public License v 1"; you may not use this file except in compliance with, at
6+
* your election, the "Elastic License 2.0", the "GNU Affero General Public
7+
* License v3.0 only", or the "Server Side Public License, v 1".
8+
*/
9+
10+
import React, { PropsWithChildren } from 'react';
11+
import { Observable } from 'rxjs';
12+
import type { ChromeBreadcrumbsAppendExtension } from '@kbn/core-chrome-browser';
13+
import useObservable from 'react-use/lib/useObservable';
14+
import { EuiFlexGroup } from '@elastic/eui';
15+
import classnames from 'classnames';
16+
import { HeaderExtension } from './header_extension';
17+
18+
export interface Props {
19+
breadcrumbsAppendExtensions$: Observable<ChromeBreadcrumbsAppendExtension[]>;
20+
}
21+
22+
export const BreadcrumbsWithExtensionsWrapper = ({
23+
breadcrumbsAppendExtensions$,
24+
children,
25+
}: PropsWithChildren<Props>) => {
26+
const breadcrumbsAppendExtensions = useObservable(breadcrumbsAppendExtensions$, []);
27+
28+
return breadcrumbsAppendExtensions.length === 0 ? (
29+
<>{children}</>
30+
) : (
31+
<EuiFlexGroup
32+
responsive={false}
33+
wrap={false}
34+
alignItems={'center'}
35+
className={'header__breadcrumbsWithExtensionContainer'}
36+
gutterSize={'none'}
37+
>
38+
{children}
39+
{breadcrumbsAppendExtensions.map((breadcrumbsAppendExtension, index) => {
40+
const isLast = breadcrumbsAppendExtensions.length - 1 === index;
41+
return (
42+
<HeaderExtension
43+
key={index}
44+
extension={breadcrumbsAppendExtension.content}
45+
containerClassName={classnames({
46+
'header__breadcrumbsAppendExtension--last': isLast,
47+
})}
48+
/>
49+
);
50+
})}
51+
</EuiFlexGroup>
52+
);
53+
};

src/core/packages/chrome/browser-internal/src/ui/header/header.test.tsx

Lines changed: 22 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -82,9 +82,9 @@ describe('Header', () => {
8282
const recentlyAccessed$ = new BehaviorSubject([
8383
{ link: '', label: 'dashboard', id: 'dashboard' },
8484
]);
85-
const breadcrumbsAppendExtension$ = new BehaviorSubject<
86-
undefined | ChromeBreadcrumbsAppendExtension
87-
>(undefined);
85+
const breadcrumbsAppendExtensions$ = new BehaviorSubject<ChromeBreadcrumbsAppendExtension[]>(
86+
[]
87+
);
8888
const component = mountWithIntl(
8989
<Header
9090
{...mockProps()}
@@ -93,7 +93,7 @@ describe('Header', () => {
9393
recentlyAccessed$={recentlyAccessed$}
9494
isLocked$={isLocked$}
9595
customNavLink$={customNavLink$}
96-
breadcrumbsAppendExtension$={breadcrumbsAppendExtension$}
96+
breadcrumbsAppendExtensions$={breadcrumbsAppendExtensions$}
9797
headerBanner$={headerBanner$}
9898
helpMenuLinks$={of([])}
9999
isServerless={false}
@@ -108,17 +108,28 @@ describe('Header', () => {
108108
expect(component.render()).toMatchSnapshot();
109109

110110
act(() =>
111-
breadcrumbsAppendExtension$.next({
112-
content: (root: HTMLDivElement) => {
113-
root.innerHTML = '<div class="my-extension">__render__</div>';
114-
return () => (root.innerHTML = '');
111+
breadcrumbsAppendExtensions$.next([
112+
{
113+
content: (root: HTMLDivElement) => {
114+
root.innerHTML = '<div class="my-extension1">__render__</div>';
115+
return () => (root.innerHTML = '');
116+
},
117+
},
118+
{
119+
content: (root: HTMLDivElement) => {
120+
root.innerHTML = '<div class="my-extension2">__render__</div>';
121+
return () => (root.innerHTML = '');
122+
},
115123
},
116-
})
124+
])
117125
);
118126
component.update();
119-
expect(component.find('HeaderExtension').exists()).toBeTruthy();
127+
expect(component.find('HeaderExtension').length).toBe(2);
128+
expect(
129+
component.find('HeaderExtension').at(0).getDOMNode().querySelector('.my-extension1')
130+
).toBeTruthy();
120131
expect(
121-
component.find('HeaderExtension').getDOMNode().querySelector('.my-extension')
132+
component.find('HeaderExtension').at(1).getDOMNode().querySelector('.my-extension2')
122133
).toBeTruthy();
123134
});
124135
});

src/core/packages/chrome/browser-internal/src/ui/header/header.tsx

Lines changed: 8 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@
88
*/
99

1010
import {
11-
EuiFlexGroup,
1211
EuiHeader,
1312
EuiHeaderSection,
1413
EuiHeaderSectionItem,
@@ -19,7 +18,6 @@ import {
1918
import { i18n } from '@kbn/i18n';
2019
import classnames from 'classnames';
2120
import React, { createRef, useState } from 'react';
22-
import useObservable from 'react-use/lib/useObservable';
2321
import type { Observable } from 'rxjs';
2422
import type { HttpStart } from '@kbn/core-http-browser';
2523
import type { InternalApplicationStart } from '@kbn/core-application-browser-internal';
@@ -45,7 +43,7 @@ import { HeaderHelpMenu } from './header_help_menu';
4543
import { HeaderLogo } from './header_logo';
4644
import { HeaderNavControls } from './header_nav_controls';
4745
import { HeaderActionMenu, useHeaderActionMenuMounter } from './header_action_menu';
48-
import { HeaderExtension } from './header_extension';
46+
import { BreadcrumbsWithExtensionsWrapper } from './breadcrumbs_with_extensions';
4947
import { HeaderTopBanner } from './header_top_banner';
5048
import { HeaderMenuButton } from './header_menu_button';
5149
import { ScreenReaderRouteAnnouncements, SkipToMainContent } from './screen_reader_a11y';
@@ -56,7 +54,7 @@ export interface HeaderProps {
5654
headerBanner$: Observable<ChromeUserBanner | undefined>;
5755
badge$: Observable<ChromeBadge | undefined>;
5856
breadcrumbs$: Observable<ChromeBreadcrumb[]>;
59-
breadcrumbsAppendExtension$: Observable<ChromeBreadcrumbsAppendExtension | undefined>;
57+
breadcrumbsAppendExtensions$: Observable<ChromeBreadcrumbsAppendExtension[]>;
6058
customNavLink$: Observable<ChromeNavLink | undefined>;
6159
homeHref: string;
6260
kibanaDocLink: string;
@@ -88,15 +86,14 @@ export function Header({
8886
basePath,
8987
onIsLockedUpdate,
9088
homeHref,
91-
breadcrumbsAppendExtension$,
89+
breadcrumbsAppendExtensions$,
9290
globalHelpExtensionMenuLinks$,
9391
customBranding$,
9492
isServerless,
9593
...observables
9694
}: HeaderProps) {
9795
const [isNavOpen, setIsNavOpen] = useState(false);
9896
const [navId] = useState(htmlIdGenerator()());
99-
const breadcrumbsAppendExtension = useObservable(breadcrumbsAppendExtension$);
10097
const headerActionMenuMounter = useHeaderActionMenuMounter(application.currentActionMenu$);
10198

10299
const toggleCollapsibleNavRef = createRef<HTMLButtonElement & { euiAnimate: () => void }>();
@@ -206,24 +203,11 @@ export function Header({
206203

207204
<HeaderNavControls side="left" navControls$={observables.navControlsLeft$} />
208205
</EuiHeaderSection>
209-
210-
{!breadcrumbsAppendExtension ? (
211-
Breadcrumbs
212-
) : (
213-
<EuiFlexGroup
214-
responsive={false}
215-
wrap={false}
216-
alignItems={'center'}
217-
className={'header__breadcrumbsWithExtensionContainer'}
218-
gutterSize={'none'}
219-
>
220-
{Breadcrumbs}
221-
<HeaderExtension
222-
extension={breadcrumbsAppendExtension.content}
223-
containerClassName={'header__breadcrumbsAppendExtension'}
224-
/>
225-
</EuiFlexGroup>
226-
)}
206+
<BreadcrumbsWithExtensionsWrapper
207+
breadcrumbsAppendExtensions$={breadcrumbsAppendExtensions$}
208+
>
209+
{Breadcrumbs}
210+
</BreadcrumbsWithExtensionsWrapper>
227211

228212
<HeaderBadge badge$={observables.badge$} />
229213

src/core/packages/chrome/browser-internal/src/ui/project/header.test.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ describe('Header', () => {
2121
const mockProps: Omit<ProjectHeaderProps, 'children'> = {
2222
application: mockApplication,
2323
breadcrumbs$: Rx.of([]),
24+
breadcrumbsAppendExtensions$: Rx.of([]),
2425
actionMenu$: Rx.of(undefined),
2526
docLinks: docLinksServiceMock.createStartContract(),
2627
globalHelpExtensionMenuLinks$: Rx.of([]),

0 commit comments

Comments
 (0)