Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
/*
* 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, { useState } from 'react';
import type { Meta, StoryObj } from '@storybook/react';
import { action } from '@storybook/addon-actions';
import {
EuiBadge,
EuiFlexGroup,
EuiFlexItem,
EuiHeader,
EuiHeaderSection,
EuiPageTemplate,
EuiPopover,
EuiTitle,
} from '@elastic/eui';
import type { AppHeaderBadge } from '../types';
import { AppBadge } from './app_badge';

const MAX_VISIBLE_BADGES = 2;
const OVERFLOW_THRESHOLD = 3;

interface AppBadgeStoryProps {
title: string;
badges: AppHeaderBadge[];
}

const HeaderWithBadges = ({ title, badges }: AppBadgeStoryProps) => {
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
const shouldOverflow = badges.length > OVERFLOW_THRESHOLD;
const visibleBadges = shouldOverflow ? badges.slice(0, MAX_VISIBLE_BADGES) : badges;
const overflowBadges = shouldOverflow ? badges.slice(MAX_VISIBLE_BADGES) : [];

return (
<EuiHeader>
<EuiHeaderSection>
<EuiFlexGroup alignItems="center" gutterSize="s" responsive={false} wrap={false}>
<EuiFlexItem grow={false}>
<EuiTitle size="xs">
<h1>{title}</h1>
</EuiTitle>
</EuiFlexItem>
{visibleBadges.map((badge) => (
<EuiFlexItem grow={false} key={badge.label}>
<AppBadge badge={badge} />
</EuiFlexItem>
))}
{overflowBadges.length > 0 && (
<EuiFlexItem grow={false}>
<EuiPopover
aria-label="More badges"
button={
<EuiBadge
color="hollow"
onClick={() => setIsPopoverOpen((open) => !open)}
onClickAriaLabel={`Show ${overflowBadges.length} more badges`}
>
+{overflowBadges.length}
</EuiBadge>
}
isOpen={isPopoverOpen}
closePopover={() => setIsPopoverOpen(false)}
panelPaddingSize="s"
>
<EuiFlexGroup direction="column" gutterSize="xs" alignItems="center">
{overflowBadges.map((badge) => (
<EuiFlexItem grow={false} key={badge.label}>
<AppBadge badge={badge} />
</EuiFlexItem>
))}
</EuiFlexGroup>
</EuiPopover>
</EuiFlexItem>
)}
</EuiFlexGroup>
</EuiHeaderSection>
</EuiHeader>
);
};

const meta: Meta<AppBadgeStoryProps> = {
title: 'Chrome/App Badge',
component: HeaderWithBadges,
decorators: [
(Story) => (
<EuiPageTemplate>
<Story />
</EuiPageTemplate>
),
],
parameters: {
docs: {
description: {
component:
'Badges displayed inline next to the page title in the Chrome header. ' +
'Each badge can be a simple label, have a click handler, or open a context menu.',
},
},
},
};

export default meta;

type Story = StoryObj<AppBadgeStoryProps>;

export const Badges: Story = {
args: {
title: '[Logs] Web Traffic',
badges: [
{ label: 'Beta' },
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Beta and New badges are clickable even if they don't have an onClick

{
label: 'Tech Preview',
color: 'warning',
tooltip: 'This feature is in tech preview and may change.',
onClick: action('tech-preview-clicked'),
onClickAriaLabel: 'Click to learn more about tech preview',
},
{
label: 'Managed',
color: 'primary',
popoverWidth: 180,
items: [
{
name: 'View details',
icon: 'inspect',
onClick: action('view-details-clicked'),
},
{
name: 'Export',
icon: 'exportAction',
popoverWidth: 180,
items: [
{
name: 'Export as CSV',
icon: 'document',
onClick: action('export-csv-clicked'),
},
{
name: 'Export as JSON',
icon: 'document',
onClick: action('export-json-clicked'),
},
],
},
{
name: 'Unlink',
icon: 'unlink',
onClick: action('unlink-clicked'),
},
],
},
{ label: 'New', color: 'primary' },
],
},
};

export const ThreeBadges: Story = {
name: 'Three badges',
args: {
title: '[Logs] Web Traffic',
badges: [
{ label: 'Beta' },
{
label: 'Tech Preview',
color: 'warning',
tooltip: 'This feature is in tech preview and may change.',
},
{
label: 'Managed',
color: 'primary',
onClick: action('managed-clicked'),
onClickAriaLabel: 'Click to view managed details',
},
],
},
};
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
*/

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

if (config || staticItems?.length) {
if (config || hasStaticItems) {
return (
<Suspense>
<AppMenuComponent config={config} staticItems={staticItems} />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,4 +37,5 @@ export {
getPopoverPanels,
getPopoverActionItems,
getIsSelectedColor,
hasNonGlobalStaticItems,
} from './src';
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@

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

if ((!config || hasNoItems(config)) && !hasNonGlobalStaticItems) {
if ((!config || hasNoItems(config)) && !hasVisibleStaticItems) {
return null;
}

Expand All @@ -78,7 +78,7 @@ export const AppMenuComponent = ({
shouldOverflow: shouldOverflowBase,
} = getAppMenuItems({
config,
hasStaticItems: hasNonGlobalStaticItems,
hasStaticItems: hasVisibleStaticItems,
});

const processedStaticItems = processStaticItems(staticItems);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,4 +40,5 @@ export {
getPopoverActionItems,
getPopoverSwitchItems,
getIsSelectedColor,
hasNonGlobalStaticItems,
} from './utils';
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,9 @@ export const processStaticItems = (staticItems?: AppMenuItemType[]): AppMenuItem
overflow: true,
}));

export const hasNonGlobalStaticItems = (staticItems?: Array<{ global?: boolean }>): boolean =>
!!staticItems?.some((item) => !item.global);

export const isDisabled = (disableButton: AppMenuItemCommon['disableButton']) =>
Boolean(isFunction(disableButton) ? disableButton() : disableButton);

Expand Down
2 changes: 1 addition & 1 deletion src/core/packages/chrome/browser-components/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,4 +21,4 @@ export { GridLayoutProjectSideNav } from './src/project/sidenav/grid_layout_side
export { Sidebar } from './src/sidebar';
export { AppMenuBar } from './src/project/app_menu';
export { HeaderBreadcrumbsBadges, HeaderTopBanner, ChromelessHeader } from './src/shared';
export { useHasAppMenu, useHasInlineAppHeader } from './src/shared/chrome_hooks';
export { useHasAppMenu, useHasInlineAppHeader, useIsNextChrome } from './src/shared/chrome_hooks';
3 changes: 3 additions & 0 deletions src/core/packages/chrome/browser-components/moon.yml
Original file line number Diff line number Diff line change
Expand Up @@ -35,9 +35,12 @@ dependsOn:
- '@kbn/core-chrome-layout-constants'
- '@kbn/core-chrome-sidebar-components'
- '@kbn/ui-side-navigation'
- '@kbn/core-chrome-feature-flags'
- '@kbn/core-custom-branding-browser'
- '@kbn/core-custom-branding-common'
- '@kbn/core-doc-links-browser'
- '@kbn/core-feature-flags-browser'
- '@kbn/core-feature-flags-browser-mocks'
- '@kbn/core-doc-links-browser-mocks'
- '@kbn/core-http-browser'
- '@kbn/core-http-browser-mocks'
Expand Down
2 changes: 2 additions & 0 deletions src/core/packages/chrome/browser-components/src/context.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import type { InternalApplicationStart } from '@kbn/core-application-browser-int
import type { DocLinksStart } from '@kbn/core-doc-links-browser';
import type { HttpStart } from '@kbn/core-http-browser';
import type { CustomBrandingStart } from '@kbn/core-custom-branding-browser';
import type { FeatureFlagsStart } from '@kbn/core-feature-flags-browser';

export interface ChromeComponentsDeps {
application: Pick<
Expand All @@ -22,6 +23,7 @@ export interface ChromeComponentsDeps {
http: Pick<HttpStart, 'basePath' | 'getLoadingCount$'>;
docLinks: DocLinksStart;
customBranding: Pick<CustomBrandingStart, 'customBranding$'>;
featureFlags: Pick<FeatureFlagsStart, 'getBooleanValue' | 'getBooleanValue$'>;
}

const ChromeComponentsContext = createContext<ChromeComponentsDeps | null>(null);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import type { DocLinksStart } from '@kbn/core-doc-links-browser';
import type { CustomBranding } from '@kbn/core-custom-branding-common';
import { useObservable } from '@kbn/use-observable';
import { useChromeService } from '@kbn/core-chrome-browser-context';
import { isNextChrome } from '@kbn/core-chrome-feature-flags';
import { useChromeComponentsDeps } from '../context';

/**
Expand Down Expand Up @@ -247,21 +248,12 @@ export function useAppMenu() {
return useObservable(appMenu$, undefined);
}

/**
* Returns the current legacy action menu mount point, or `undefined` if none is set.
* @deprecated Legacy action menus use imperative mount points. Prefer `chrome.setAppMenu()`.
*/
export function useCurrentActionMenu(): MountPoint | undefined {
const { application } = useChromeComponentsDeps();
return useObservable(application.currentActionMenu$, undefined);
}

/**
* Whether a legacy action menu mount point is currently set.
* @deprecated Legacy action menus use imperative mount points. Prefer `chrome.setAppMenu()`.
*/
export function useHasLegacyActionMenu(): boolean {
return !!useCurrentActionMenu();
return !!useInternalLegacyActionMenu();
}

/** Whether the current app menu (registered via `chrome.setAppMenu()`) has items configured. */
Expand Down Expand Up @@ -292,9 +284,20 @@ export function useGlobalSearch(): GlobalSearchConfig | undefined {
return useObservable(config$, undefined);
}

/** Returns whether the next-chrome experience is enabled via feature flag. */
export function useIsNextChrome(): boolean {
const { featureFlags } = useChromeComponentsDeps();
return isNextChrome(featureFlags);
}

/** Whether an inline `AppHeader` is currently mounted by the active app. */
export function useHasInlineAppHeader(): boolean {
const chrome = useChromeService();
const inlineAppHeader$ = useMemo(() => chrome.next.inlineAppHeader.get$(), [chrome]);
return useObservable(inlineAppHeader$, false);
}

export function useInternalLegacyActionMenu(): MountPoint | undefined {
const { legacyActionMenu$ } = useChromeService().componentDeps;
return useObservable(legacyActionMenu$, undefined);
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ import { mount } from 'enzyme';
import { BehaviorSubject } from 'rxjs';
import { act } from 'react-dom/test-utils';
import type { MountPoint, UnmountCallback } from '@kbn/core-mount-utils-browser';
import { chromeServiceMock } from '@kbn/core-chrome-browser-mocks';
import { ChromeServiceProvider } from '@kbn/core-chrome-browser-context';
import { ChromeComponentsProvider } from '../context';
import { createMockChromeComponentsDeps } from '../test_helpers';
import { HeaderActionMenu } from './header_action_menu';
Expand Down Expand Up @@ -60,7 +62,19 @@ describe('HeaderActionMenu', () => {
const renderWithProvider = (ui: React.ReactElement) => {
const deps = createMockChromeComponentsDeps();
deps.application = { ...deps.application, currentActionMenu$: menuMount$ };
return mount(<ChromeComponentsProvider value={deps}>{ui}</ChromeComponentsProvider>);
const chromeMock = chromeServiceMock.createStartContract();
const chrome = {
...chromeMock,
componentDeps: {
...chromeMock.componentDeps,
legacyActionMenu$: menuMount$,
},
};
return mount(
<ChromeServiceProvider value={{ chrome }}>
<ChromeComponentsProvider value={deps}>{ui}</ChromeComponentsProvider>
</ChromeServiceProvider>
);
};

it('mounts the current value of the provided observable', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,14 @@
import type { FC } from 'react';
import React, { useRef, useLayoutEffect } from 'react';
import type { UnmountCallback } from '@kbn/core-mount-utils-browser';
import { useCurrentActionMenu } from './chrome_hooks';
import { useInternalLegacyActionMenu } from './chrome_hooks';

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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ export const HeaderExtension = ({ extension, display, containerClassName }: Prop
if (!isMountPoint(extension)) {
return (
<Suspense fallback={null}>
<div className={containerClassName} style={style}>
<div css={mountPointContainerCss} className={containerClassName} style={style}>
{extension}
</div>
</Suspense>
Expand Down
Loading
Loading