Skip to content

Commit 8e0d77b

Browse files
authored
Extract Chrome app header infra fixes (#271670)
Related: #259318 ## Summary Ports a small set of Chrome infrastructure fixes from the Chrome Next integration branch to main, covering app menu static item visibility, Chrome Next helper plumbing, inline legacy action menu handling, header extension layout behavior, sidebar spacing, and the AppHeader badge Storybook story.
1 parent d735adb commit 8e0d77b

13 files changed

Lines changed: 241 additions & 20 deletions

File tree

Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
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, { useState } from 'react';
11+
import type { Meta, StoryObj } from '@storybook/react';
12+
import { action } from '@storybook/addon-actions';
13+
import {
14+
EuiBadge,
15+
EuiFlexGroup,
16+
EuiFlexItem,
17+
EuiHeader,
18+
EuiHeaderSection,
19+
EuiPageTemplate,
20+
EuiPopover,
21+
EuiTitle,
22+
} from '@elastic/eui';
23+
import type { AppHeaderBadge } from '../types';
24+
import { AppBadge } from './app_badge';
25+
26+
const MAX_VISIBLE_BADGES = 2;
27+
const OVERFLOW_THRESHOLD = 3;
28+
29+
interface AppBadgeStoryProps {
30+
title: string;
31+
badges: AppHeaderBadge[];
32+
}
33+
34+
const HeaderWithBadges = ({ title, badges }: AppBadgeStoryProps) => {
35+
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
36+
const shouldOverflow = badges.length > OVERFLOW_THRESHOLD;
37+
const visibleBadges = shouldOverflow ? badges.slice(0, MAX_VISIBLE_BADGES) : badges;
38+
const overflowBadges = shouldOverflow ? badges.slice(MAX_VISIBLE_BADGES) : [];
39+
40+
return (
41+
<EuiHeader>
42+
<EuiHeaderSection>
43+
<EuiFlexGroup alignItems="center" gutterSize="s" responsive={false} wrap={false}>
44+
<EuiFlexItem grow={false}>
45+
<EuiTitle size="xs">
46+
<h1>{title}</h1>
47+
</EuiTitle>
48+
</EuiFlexItem>
49+
{visibleBadges.map((badge) => (
50+
<EuiFlexItem grow={false} key={badge.label}>
51+
<AppBadge badge={badge} />
52+
</EuiFlexItem>
53+
))}
54+
{overflowBadges.length > 0 && (
55+
<EuiFlexItem grow={false}>
56+
<EuiPopover
57+
aria-label="More badges"
58+
button={
59+
<EuiBadge
60+
color="hollow"
61+
onClick={() => setIsPopoverOpen((open) => !open)}
62+
onClickAriaLabel={`Show ${overflowBadges.length} more badges`}
63+
>
64+
+{overflowBadges.length}
65+
</EuiBadge>
66+
}
67+
isOpen={isPopoverOpen}
68+
closePopover={() => setIsPopoverOpen(false)}
69+
panelPaddingSize="s"
70+
>
71+
<EuiFlexGroup direction="column" gutterSize="xs" alignItems="center">
72+
{overflowBadges.map((badge) => (
73+
<EuiFlexItem grow={false} key={badge.label}>
74+
<AppBadge badge={badge} />
75+
</EuiFlexItem>
76+
))}
77+
</EuiFlexGroup>
78+
</EuiPopover>
79+
</EuiFlexItem>
80+
)}
81+
</EuiFlexGroup>
82+
</EuiHeaderSection>
83+
</EuiHeader>
84+
);
85+
};
86+
87+
const meta: Meta<AppBadgeStoryProps> = {
88+
title: 'Chrome/App Badge',
89+
component: HeaderWithBadges,
90+
decorators: [
91+
(Story) => (
92+
<EuiPageTemplate>
93+
<Story />
94+
</EuiPageTemplate>
95+
),
96+
],
97+
parameters: {
98+
docs: {
99+
description: {
100+
component:
101+
'Badges displayed inline next to the page title in the Chrome header. ' +
102+
'Each badge can be a simple label, have a click handler, or open a context menu.',
103+
},
104+
},
105+
},
106+
};
107+
108+
export default meta;
109+
110+
type Story = StoryObj<AppBadgeStoryProps>;
111+
112+
export const Badges: Story = {
113+
args: {
114+
title: '[Logs] Web Traffic',
115+
badges: [
116+
{ label: 'Beta' },
117+
{
118+
label: 'Tech Preview',
119+
color: 'warning',
120+
tooltip: 'This feature is in tech preview and may change.',
121+
onClick: action('tech-preview-clicked'),
122+
onClickAriaLabel: 'Click to learn more about tech preview',
123+
},
124+
{
125+
label: 'Managed',
126+
color: 'primary',
127+
popoverWidth: 180,
128+
items: [
129+
{
130+
name: 'View details',
131+
icon: 'inspect',
132+
onClick: action('view-details-clicked'),
133+
},
134+
{
135+
name: 'Export',
136+
icon: 'exportAction',
137+
popoverWidth: 180,
138+
items: [
139+
{
140+
name: 'Export as CSV',
141+
icon: 'document',
142+
onClick: action('export-csv-clicked'),
143+
},
144+
{
145+
name: 'Export as JSON',
146+
icon: 'document',
147+
onClick: action('export-json-clicked'),
148+
},
149+
],
150+
},
151+
{
152+
name: 'Unlink',
153+
icon: 'unlink',
154+
onClick: action('unlink-clicked'),
155+
},
156+
],
157+
},
158+
{ label: 'New', color: 'primary' },
159+
],
160+
},
161+
};
162+
163+
export const ThreeBadges: Story = {
164+
name: 'Three badges',
165+
args: {
166+
title: '[Logs] Web Traffic',
167+
badges: [
168+
{ label: 'Beta' },
169+
{
170+
label: 'Tech Preview',
171+
color: 'warning',
172+
tooltip: 'This feature is in tech preview and may change.',
173+
},
174+
{
175+
label: 'Managed',
176+
color: 'primary',
177+
onClick: action('managed-clicked'),
178+
onClickAriaLabel: 'Click to view managed details',
179+
},
180+
],
181+
},
182+
};

src/core/packages/chrome/app-header/src/app_header/app_menu.tsx

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

1010
import React, { lazy, Suspense } from 'react';
11-
import type { AppMenuConfig } from '@kbn/core-chrome-app-menu-components';
11+
import { hasNonGlobalStaticItems, type AppMenuConfig } from '@kbn/core-chrome-app-menu-components';
1212
import { useHasLegacyActionMenu } from './hooks/chrome';
1313
import { LegacyHeaderActionMenu } from './legacy_action_menu';
1414
import { useAppHeaderMenu } from './hooks';
@@ -27,8 +27,9 @@ export interface AppMenuProps {
2727
export const AppMenu = React.memo<AppMenuProps>(({ menu, docLink, showAddIntegrations }) => {
2828
const { config, staticItems } = useAppHeaderMenu(menu, docLink, showAddIntegrations);
2929
const hasLegacyActionMenu = useHasLegacyActionMenu();
30+
const hasStaticItems = hasNonGlobalStaticItems(staticItems);
3031

31-
if (config || staticItems?.length) {
32+
if (config || hasStaticItems) {
3233
return (
3334
<Suspense>
3435
<AppMenuComponent config={config} staticItems={staticItems} />

src/core/packages/chrome/app-menu/core-chrome-app-menu-components/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,4 +37,5 @@ export {
3737
getPopoverPanels,
3838
getPopoverActionItems,
3939
getIsSelectedColor,
40+
hasNonGlobalStaticItems,
4041
} from './src';

src/core/packages/chrome/app-menu/core-chrome-app-menu-components/src/components/app_menu.tsx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99

1010
import React, { useState } from 'react';
1111
import { EuiHeaderLinks, useIsWithinBreakpoints } from '@elastic/eui';
12-
import { getAppMenuItems, processStaticItems } from '../utils';
12+
import { getAppMenuItems, hasNonGlobalStaticItems, processStaticItems } from '../utils';
1313
import { AppMenuActionButton } from './app_menu_action_button';
1414
import { AppMenuItem } from './app_menu_item';
1515
import { AppMenuOverflowButton } from './app_menu_overflow_button';
@@ -51,9 +51,9 @@ export const AppMenuComponent = ({
5151
* If only global static items are present, we don't want to render
5252
* the app menu.
5353
*/
54-
const hasNonGlobalStaticItems = !!staticItems?.length && staticItems.some((item) => !item.global);
54+
const hasVisibleStaticItems = hasNonGlobalStaticItems(staticItems);
5555

56-
if ((!config || hasNoItems(config)) && !hasNonGlobalStaticItems) {
56+
if ((!config || hasNoItems(config)) && !hasVisibleStaticItems) {
5757
return null;
5858
}
5959

@@ -78,7 +78,7 @@ export const AppMenuComponent = ({
7878
shouldOverflow: shouldOverflowBase,
7979
} = getAppMenuItems({
8080
config,
81-
hasStaticItems: hasNonGlobalStaticItems,
81+
hasStaticItems: hasVisibleStaticItems,
8282
});
8383

8484
const processedStaticItems = processStaticItems(staticItems);

src/core/packages/chrome/app-menu/core-chrome-app-menu-components/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,4 +40,5 @@ export {
4040
getPopoverActionItems,
4141
getPopoverSwitchItems,
4242
getIsSelectedColor,
43+
hasNonGlobalStaticItems,
4344
} from './utils';

src/core/packages/chrome/app-menu/core-chrome-app-menu-components/src/utils.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,9 @@ export const processStaticItems = (staticItems?: AppMenuItemType[]): AppMenuItem
126126
overflow: true,
127127
}));
128128

129+
export const hasNonGlobalStaticItems = (staticItems?: Array<{ global?: boolean }>): boolean =>
130+
!!staticItems?.some((item) => !item.global);
131+
129132
export const isDisabled = (disableButton: AppMenuItemCommon['disableButton']) =>
130133
Boolean(isFunction(disableButton) ? disableButton() : disableButton);
131134

src/core/packages/chrome/browser-components/src/shared/chrome_hooks.ts

Lines changed: 6 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -247,21 +247,12 @@ export function useAppMenu() {
247247
return useObservable(appMenu$, undefined);
248248
}
249249

250-
/**
251-
* Returns the current legacy action menu mount point, or `undefined` if none is set.
252-
* @deprecated Legacy action menus use imperative mount points. Prefer `chrome.setAppMenu()`.
253-
*/
254-
export function useCurrentActionMenu(): MountPoint | undefined {
255-
const { application } = useChromeComponentsDeps();
256-
return useObservable(application.currentActionMenu$, undefined);
257-
}
258-
259250
/**
260251
* Whether a legacy action menu mount point is currently set.
261252
* @deprecated Legacy action menus use imperative mount points. Prefer `chrome.setAppMenu()`.
262253
*/
263254
export function useHasLegacyActionMenu(): boolean {
264-
return !!useCurrentActionMenu();
255+
return !!useInternalLegacyActionMenu();
265256
}
266257

267258
/** Whether the current app menu (registered via `chrome.setAppMenu()`) has items configured. */
@@ -298,3 +289,8 @@ export function useHasInlineAppHeader(): boolean {
298289
const inlineAppHeader$ = useMemo(() => chrome.next.inlineAppHeader.get$(), [chrome]);
299290
return useObservable(inlineAppHeader$, false);
300291
}
292+
293+
export function useInternalLegacyActionMenu(): MountPoint | undefined {
294+
const { legacyActionMenu$ } = useChromeService().componentDeps;
295+
return useObservable(legacyActionMenu$, undefined);
296+
}

src/core/packages/chrome/browser-components/src/shared/header_action_menu.test.tsx

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ import { mount } from 'enzyme';
1313
import { BehaviorSubject } from 'rxjs';
1414
import { act } from 'react-dom/test-utils';
1515
import type { MountPoint, UnmountCallback } from '@kbn/core-mount-utils-browser';
16+
import { chromeServiceMock } from '@kbn/core-chrome-browser-mocks';
17+
import { ChromeServiceProvider } from '@kbn/core-chrome-browser-context';
1618
import { ChromeComponentsProvider } from '../context';
1719
import { createMockChromeComponentsDeps } from '../test_helpers';
1820
import { HeaderActionMenu } from './header_action_menu';
@@ -60,7 +62,19 @@ describe('HeaderActionMenu', () => {
6062
const renderWithProvider = (ui: React.ReactElement) => {
6163
const deps = createMockChromeComponentsDeps();
6264
deps.application = { ...deps.application, currentActionMenu$: menuMount$ };
63-
return mount(<ChromeComponentsProvider value={deps}>{ui}</ChromeComponentsProvider>);
65+
const chromeMock = chromeServiceMock.createStartContract();
66+
const chrome = {
67+
...chromeMock,
68+
componentDeps: {
69+
...chromeMock.componentDeps,
70+
legacyActionMenu$: menuMount$,
71+
},
72+
};
73+
return mount(
74+
<ChromeServiceProvider value={{ chrome }}>
75+
<ChromeComponentsProvider value={deps}>{ui}</ChromeComponentsProvider>
76+
</ChromeServiceProvider>
77+
);
6478
};
6579

6680
it('mounts the current value of the provided observable', () => {

src/core/packages/chrome/browser-components/src/shared/header_action_menu.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,14 +10,14 @@
1010
import type { FC } from 'react';
1111
import React, { useRef, useLayoutEffect } from 'react';
1212
import type { UnmountCallback } from '@kbn/core-mount-utils-browser';
13-
import { useCurrentActionMenu } from './chrome_hooks';
13+
import { useInternalLegacyActionMenu } from './chrome_hooks';
1414

1515
/**
1616
* Renders the currently mounted header action menu set via {@link ChromeStart.setHeaderActionMenu}.
1717
* @deprecated Use {@link HeaderAppMenu} instead. See kibana-team#2651.
1818
*/
1919
export const HeaderActionMenu: FC = () => {
20-
const mount = useCurrentActionMenu();
20+
const mount = useInternalLegacyActionMenu();
2121
const elementRef = useRef<HTMLDivElement>(null);
2222
const unmountRef = useRef<UnmountCallback | null>(null);
2323

src/core/packages/chrome/browser-components/src/shared/header_extension.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ export const HeaderExtension = ({ extension, display, containerClassName }: Prop
5555
if (!isMountPoint(extension)) {
5656
return (
5757
<Suspense fallback={null}>
58-
<div className={containerClassName} style={style}>
58+
<div css={mountPointContainerCss} className={containerClassName} style={style}>
5959
{extension}
6060
</div>
6161
</Suspense>

0 commit comments

Comments
 (0)