Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
5 changes: 5 additions & 0 deletions src/core/packages/chrome/app-header/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,11 @@ export type {
AppHeaderBadge,
AppHeaderBadgeItem,
AppHeaderTab,
AppHeaderMetadataButtonItem,
AppHeaderMetadataHealthItem,
AppHeaderMetadataItem,
AppHeaderMetadataItems,
AppHeaderMetadataTextItem,
AppHeaderMenu,
AppHeaderPadding,
} from './src';
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,12 @@ import React from 'react';
import '@testing-library/jest-dom';
import { BehaviorSubject } from 'rxjs';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { EuiButtonIcon } from '@elastic/eui';
import { EuiButtonIcon, EuiToolTip } from '@elastic/eui';
import type { InternalChromeStart } from '@kbn/core-chrome-browser-internal-types';
import { ChromeServiceProvider } from '@kbn/core-chrome-browser-context';
import { chromeServiceMock } from '@kbn/core-chrome-browser-mocks';
import type { ChromeBadge } from '@kbn/core-chrome-browser';
import type { AppHeaderMetadataItems } from '../types';
import { AppHeaderView } from './app_header';

const renderAppHeader = (
Expand Down Expand Up @@ -55,14 +56,56 @@ describe('AppHeaderView', () => {
it('renders when the only content is a favorite action', () => {
renderAppHeader(
<AppHeaderView
favorite={<EuiButtonIcon aria-label="Favorite" iconType="starEmpty" onClick={jest.fn()} />}
favorite={
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Unrelated autofix 😢

<EuiToolTip content="Favorite" disableScreenReaderOutput>
<EuiButtonIcon aria-label="Favorite" iconType="starEmpty" onClick={jest.fn()} />
</EuiToolTip>
}
/>
);

expect(screen.getByTestId('appHeader')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Favorite' })).toBeInTheDocument();
});

it('renders metadata items as a wrapping row', () => {
const onInspect = jest.fn();

renderAppHeader(
<AppHeaderView
metadata={[
{ type: 'health', label: 'Warning at llm 24', color: 'warning' },
{ type: 'text', label: 'Created by: analyst', 'data-test-subj': 'createdByMetadata' },
{ type: 'button', label: 'Updated by: analyst', onClick: onInspect },
]}
/>
);

expect(screen.getByTestId('appHeaderMetadata')).toBeInTheDocument();
expect(screen.getByText('Warning at llm 24')).toBeInTheDocument();
expect(screen.getByTestId('createdByMetadata')).toHaveTextContent('Created by: analyst');

fireEvent.click(screen.getByRole('button', { name: 'Updated by: analyst' }));

expect(onInspect).toHaveBeenCalledTimes(1);
});

it('limits metadata rendering to three items', () => {
const metadata = [
{ type: 'text', label: 'First' },
{ type: 'text', label: 'Second' },
{ type: 'text', label: 'Third' },
] satisfies AppHeaderMetadataItems;
metadata.push({ type: 'text', label: 'Fourth' });

renderAppHeader(<AppHeaderView metadata={metadata} />);

expect(screen.getByText('First')).toBeInTheDocument();
expect(screen.getByText('Second')).toBeInTheDocument();
expect(screen.getByText('Third')).toBeInTheDocument();
expect(screen.queryByText('Fourth')).not.toBeInTheDocument();
});

it('renders when the only content is a static app menu item', async () => {
renderAppHeader(<AppHeaderView showAddIntegrations />);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,21 @@ import type { ReactNode } from 'react';
import React, { useLayoutEffect } from 'react';
import type { AppMenuConfig } from '@kbn/core-chrome-app-menu-components';
import { useChromeService } from '@kbn/core-chrome-browser-context';
import type { AppHeaderBack, AppHeaderBadge, AppHeaderPadding, AppHeaderTab } from '../types';
import type {
AppHeaderBack,
AppHeaderBadge,
AppHeaderMetadataItems,
AppHeaderPadding,
AppHeaderTab,
} from '../types';
import { useHasLegacyActionMenu } from './hooks/chrome';
import { AppHeaderShell } from './app_header_shell';
import { AppBadges } from './app_badges';
import { AppTabs } from './app_tabs';
import { TitleArea } from './title_area';
import { TitleActions } from './title_actions';
import { AppMenu } from './app_menu';
import { AppHeaderMetadata } from './app_header_metadata';
import { useResolvedBadges, useShareAction } from './hooks';

export interface AppHeaderViewProps {
Expand All @@ -28,6 +35,7 @@ export interface AppHeaderViewProps {
badges?: AppHeaderBadge[];
menu?: AppMenuConfig;
favorite?: ReactNode;
metadata?: AppHeaderMetadataItems;
sticky?: boolean;
padding?: AppHeaderPadding;
docLink?: string;
Expand All @@ -42,6 +50,7 @@ export const AppHeaderView = React.memo<AppHeaderViewProps>(
badges,
menu,
favorite,
metadata,
sticky,
padding,
docLink,
Expand All @@ -58,6 +67,7 @@ export const AppHeaderView = React.memo<AppHeaderViewProps>(
!!menu?.items?.length ||
!!shareAction ||
!!favorite ||
!!metadata?.length ||
!!docLink ||
!!showAddIntegrations ||
hasLegacyActionMenu;
Expand All @@ -74,6 +84,7 @@ export const AppHeaderView = React.memo<AppHeaderViewProps>(
trailing={
<AppMenu menu={menu} docLink={docLink} showAddIntegrations={showAddIntegrations} />
}
metadata={metadata?.length ? <AppHeaderMetadata metadata={metadata} /> : undefined}
tabs={tabs?.length ? <AppTabs tabs={tabs} /> : undefined}
sticky={sticky}
padding={padding}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
/*
* 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 from 'react';
import type { Meta, StoryObj } from '@storybook/react';
import { action } from '@storybook/addon-actions';
import { css } from '@emotion/react';
import { EuiPageTemplate, EuiTitle } from '@elastic/eui';
import type { AppHeaderMetadataItems } from '../types';
import { AppHeaderMetadata } from './app_header_metadata';
import { AppHeaderShell } from './app_header_shell';

interface AppHeaderMetadataStoryProps {
title: string;
metadata: AppHeaderMetadataItems;
width?: number;
}

const HeaderWithMetadata = ({ title, metadata, width }: AppHeaderMetadataStoryProps) => {
return (
<div
css={css`
width: ${width ? `${width}px` : '100%'};
`}
>
<AppHeaderShell
title={
<EuiTitle size="xs">
<h1>{title}</h1>
</EuiTitle>
}
metadata={<AppHeaderMetadata metadata={metadata} />}
padding="m"
sticky={false}
/>
</div>
);
};

const meta: Meta<AppHeaderMetadataStoryProps> = {
title: 'Chrome/App Header Metadata',
component: HeaderWithMetadata,
decorators: [
(Story) => (
<EuiPageTemplate>
<Story />
</EuiPageTemplate>
),
],
parameters: {
docs: {
description: {
component:
'Metadata displayed below the page title in the Chrome app header. ' +
'Metadata supports up to three visible items with no overflow menu.',
},
},
},
};

export default meta;

type Story = StoryObj<AppHeaderMetadataStoryProps>;

export const Metadata: Story = {
args: {
title: 'System Shells via Services',
metadata: [
{ type: 'health', label: 'Warning at llm 24', color: 'warning' },
{ type: 'text', label: 'Created by: analyst on Oct 10, 2024 @ 00:11:03.176' },
{
type: 'button',
label: 'Updated by: analyst on Feb 8, 2026 @ 04:37:53.533',
onClick: action('updated-by-clicked'),
},
],
},
};

export const WrappedMetadata: Story = {
name: 'Wrapped metadata',
args: {
...Metadata.args,
width: 520,
},
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
/*
* 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 from 'react';
import { EuiButtonEmpty, EuiHealth, EuiText, useEuiTheme } from '@elastic/eui';
import { css } from '@emotion/react';
import type { AppHeaderMetadataItem, AppHeaderMetadataItems } from '../types';

const AppHeaderMetadataEntry = ({ item }: { item: AppHeaderMetadataItem }) => {
const { euiTheme } = useEuiTheme();

if (item.type === 'button') {
const buttonInteraction = item.href ? { href: item.href } : { onClick: item.onClick };

return (
<EuiButtonEmpty
color="text"
data-test-subj={item['data-test-subj']}
flush="both"
iconType={item.iconType}
size="xs"
{...buttonInteraction}
>
{item.label}
</EuiButtonEmpty>
);
}

if (item.type === 'health') {
return (
<EuiHealth color={item.color} data-test-subj={item['data-test-subj']} textSize="xs">
{item.label}
</EuiHealth>
);
}

return (
<EuiText
css={css`
color: ${euiTheme.colors.textParagraph};
font-weight: ${euiTheme.font.weight.medium};
`}
data-test-subj={item['data-test-subj']}
size="xs"
>
{item.label}
</EuiText>
);
};

export const AppHeaderMetadata = React.memo<{ metadata: AppHeaderMetadataItems }>(
({ metadata }) => {
return (
<>
{metadata
.slice(0, 3)
.filter((item): item is AppHeaderMetadataItem => item !== undefined)
.map((item, index) => (
<AppHeaderMetadataEntry item={item} key={`${item.type}-${item.label}-${index}`} />
))}
</>
);
}
);

AppHeaderMetadata.displayName = 'AppHeaderMetadata';
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ export interface AppHeaderShellProps {
badges?: ReactNode;
titleActions?: ReactNode;
trailing?: ReactNode;
metadata?: ReactNode;
tabs?: ReactNode;
sticky?: boolean;
padding?: AppHeaderPadding;
Expand Down Expand Up @@ -63,7 +64,8 @@ const resolveLayoutProps = (
const useHeaderStyles = (
sticky: boolean,
padding: AppHeaderPadding | undefined,
hasTabs: boolean
hasTabs: boolean,
hasMetadata: boolean
) => {
const { euiTheme } = useEuiTheme();

Expand Down Expand Up @@ -117,7 +119,7 @@ const useHeaderStyles = (
${paddingBlock &&
css`
padding-block-start: ${paddingBlock};
padding-block-end: ${hasTabs ? euiTheme.size.xs : paddingBlock};
padding-block-end: ${hasTabs || hasMetadata ? euiTheme.size.xs : paddingBlock};
`}
`;

Expand Down Expand Up @@ -147,6 +149,19 @@ const useHeaderStyles = (
align-items: stretch;
`;

const metadataRow = css`
display: flex;
flex-wrap: wrap;
align-items: center;
column-gap: ${euiTheme.size.m};
row-gap: ${euiTheme.size.xs};
min-width: 0;
${paddingBlock &&
css`
padding-block-end: ${hasTabs ? euiTheme.size.xs : paddingBlock};
`}
`;

const titleActionsReveal = css`
display: flex;
flex-shrink: 0;
Expand All @@ -164,14 +179,15 @@ const useHeaderStyles = (
titleGroup,
titleClusterSpacer,
titleActionsReveal,
metadataRow,
tabsRow,
};
}, [euiTheme, sticky, padding, hasTabs]);
}, [euiTheme, sticky, padding, hasTabs, hasMetadata]);
};

export const AppHeaderShell = React.memo<AppHeaderShellProps>(
({ title, badges, titleActions, trailing, tabs, sticky = true, padding }) => {
const styles = useHeaderStyles(sticky, padding, !!tabs);
({ title, badges, titleActions, trailing, metadata, tabs, sticky = true, padding }) => {
const styles = useHeaderStyles(sticky, padding, !!tabs, !!metadata);

return (
<div css={styles.root} data-test-subj="appHeader">
Expand All @@ -190,6 +206,11 @@ export const AppHeaderShell = React.memo<AppHeaderShellProps>(
</div>
{trailing}
</div>
{metadata && (
<div css={styles.metadataRow} data-test-subj="appHeaderMetadata">
{metadata}
</div>
)}
{tabs && (
<div css={styles.tabsRow} data-test-subj="appHeaderTabs">
{tabs}
Expand Down
Loading
Loading