PanelChrome: Implement Panel header with error, loading, and streaming data status (#60147)

* dashboards squad mob! 🔱

lastFile:packages/grafana-ui/src/components/LoadingBar/LoadingBar.tsx

* dashboards squad mob! 🔱

* dashboards squad mob! 🔱

lastFile:packages/grafana-ui/src/components/LoadingBar/LoadingBar.tsx

* user essentials mob! 🔱

* create grafana/ui LoadingBar and set it up in Storybook

* Remove test changes on PanelChrome

* Fix mdx page reference

* dashboards squad mob! 🔱

lastFile:public/api-merged.json

* dashboards squad mob! 🔱

* dashboards squad mob! 🔱

* dashboards squad mob! 🔱

* dashboards squad mob! 🔱

lastFile:public/app/features/dashboard/dashgrid/PanelHeader/PanelHeaderState.tsx

* Implemented basic draft of panel header states. Using ToolbarButton instead of IconButton.

* use 'warning' styled Button in ToolbarButton

* make LoadingBar a simple JSX Element; do not use containerWidth; have a wrapper around the loading bar itself;

* fix wrapper around LoadingBar: willChange css prop makes performance of rerendering better

* States: Render general panel query error states and render notices next
to the title

* add streaming to PanelChrome if data is streaming instead of loading

* PanelHeaderState with its own state 'mode'

* clean up useEffect

* notices have their own square space in the size of the panel header

* clean up

* minor fixes

* moving the LoadingBar to core

* LoadingBar is not in grafana/ui

* always have a place for the loading bar in the PanelChrome, otherwise it moves everything when appearing;

remove titleItemsNodes for now - in later development

make no changes to Notice component, not part of this PR

* Revert "moving the LoadingBar to core"

This reverts commit 11f0f4ff2f.

* do not use internal comment as it doesn't do anything

* integrate LoadingBar in PanelChrome from grafana/ui directly

* fix deprecated leftItems comment

* Modify annimation to 1 second

* remove comments

* remove streaming stopped UI because we cannot know when the streaming has stopped

* skip unnecessary test for now

* no point in removing hoverHeader now, even though it's not yet implemented

* small fixes

* error state of the data in a panel is positioned in PanelChrome itself, not in PanelHeaderState

* Fixed loading state jitter

* remove warning state as we have none of it

* streaming cannot be stopped from the icon

* explicit content container width and height

* explicit content container width and height

* edit deprecated comment

* fix LoadingBar to be relative to width of panel; remove explicit width and height on content strict

* no warning state of the data

* status of the panel data given directly to PanelChrome, not a node

* clean up

* clean up console log

Co-authored-by: kay delaney <45561153+kaydelaney@users.noreply.github.com>

* panel title design fits typography h6 styles; render error status only if error or error message are passed to PanelChrome

* add storybook examples; prepare PanelChrome for hoverHeader because this will be a breaking change and it will affect how the storybook example shows up

* show storybook example for streaming panel with title because that's the condition for having a header

* override margin-bottom: 0.45em of h6

Co-authored-by: Alexandra Vargas <alexa1866@gmail.com>
Co-authored-by: Torkel Ödegaard <torkel@grafana.com>
Co-authored-by: kay delaney <45561153+kaydelaney@users.noreply.github.com>
This commit is contained in:
Polina Boneva 2023-01-05 11:48:11 +02:00 committed by GitHub
parent 88a8cba6b0
commit 3f1908464d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 1715 additions and 6333 deletions

View File

@ -18,7 +18,7 @@ export interface Props extends React.ButtonHTMLAttributes<HTMLButtonElement> {
name: IconName;
/** Icon size */
size?: IconSize;
/** Type od the icon - mono or default */
/** Type of the icon - mono or default */
iconType?: IconType;
/** Tooltip content to display on hover */
tooltip?: PopoverContent;

View File

@ -30,7 +30,7 @@ const getStyles = (width?: string, height?: string) => (_: GrafanaTheme2) => {
transform: 'translateX(0)',
},
'100%': {
transform: `translateX(calc(100% - ${barWidth}))`,
transform: `translateX(100%)`,
},
});

View File

@ -4,6 +4,7 @@ import { merge } from 'lodash';
import React, { CSSProperties, useState, ReactNode } from 'react';
import { useInterval } from 'react-use';
import { LoadingState } from '@grafana/data';
import { PanelChrome, PanelChromeProps } from '@grafana/ui';
import { DashboardStoryCanvas } from '../../utils/storybook/DashboardStoryCanvas';
@ -64,26 +65,21 @@ export const Examples = () => {
<DashboardStoryCanvas>
<HorizontalGroup spacing="md" align="flex-start">
<VerticalGroup spacing="md">
{renderPanel('Default panel with error state indicator', {
{renderPanel('Default panel with error status', {
title: 'Default title',
leftItems: [
<PanelChrome.ErrorIndicator
key="errorIndicator"
error="Error text"
onClick={action('ErrorIndicator: onClick fired')}
/>,
],
status: {
message: 'Error text',
onClick: action('ErrorIndicator: onClick fired'),
},
})}
{renderPanel('No padding with error state indicator', {
{renderPanel('No padding with error state', {
padding: 'none',
title: 'Default title',
leftItems: [
<PanelChrome.ErrorIndicator
key="errorIndicator"
error="Error text"
onClick={action('ErrorIndicator: onClick fired')}
/>,
],
loadingState: LoadingState.Error,
})}
{renderPanel('Default panel with streaming state', {
title: 'Default title',
loadingState: LoadingState.Streaming,
})}
</VerticalGroup>
<VerticalGroup spacing="md">
@ -93,22 +89,21 @@ export const Examples = () => {
})}
</VerticalGroup>
</HorizontalGroup>
<HorizontalGroup spacing="md">
<HorizontalGroup spacing="md" align="flex-start">
<VerticalGroup spacing="md">
{renderPanel('No title and loading indicator', {
title: '',
{renderPanel('Default panel with deprecated error indicator', {
title: 'Default title',
leftItems: [
<PanelChrome.LoadingIndicator
loading={loading}
onCancel={() => setLoading(false)}
key="loading-indicator"
<PanelChrome.ErrorIndicator
key="errorIndicator"
error="Error text"
onClick={action('ErrorIndicator: onClick fired')}
/>,
],
})}
</VerticalGroup>
<VerticalGroup spacing="md">
{renderPanel('Very long title', {
title: 'Very long title that should get ellipsis when there is no more space',
{renderPanel('No padding with deprecated loading indicator', {
padding: 'none',
title: 'Default title',
leftItems: [
<PanelChrome.LoadingIndicator
loading={loading}
@ -128,7 +123,9 @@ export const Basic: ComponentStory<typeof PanelChrome> = (args: PanelChromeProps
return (
<PanelChrome {...args}>
{(width: number, height: number) => <div style={{ height, width, ...contentStyle }}>Description text</div>}
{(width: number, height: number) => (
<div style={{ height, width, ...contentStyle }}>Panel in a loading state</div>
)}
</PanelChrome>
);
};
@ -221,6 +218,7 @@ Basic.args = {
title: 'Very long title that should get ellipsis when there is no more space',
titleItems,
menu,
loadingState: LoadingState.Loading,
};
export default meta;

View File

@ -35,12 +35,6 @@ it('renders an empty panel with padding', () => {
expect(screen.getByText("Panel's Content").parentElement).not.toHaveStyle({ padding: '0px' });
});
it('renders an empty panel without a header if no title or titleItems', () => {
setup();
expect(screen.queryByTestId('header-container')).not.toBeInTheDocument();
});
it('renders panel with a header if prop title', () => {
setup({ title: 'Test Panel Header' });
@ -81,10 +75,9 @@ it('renders panel with a header with icons in place if prop titleItems', () => {
expect(screen.getByTestId('title-items-container')).toBeInTheDocument();
});
it('renders panel with a fixed header if prop hoverHeader is false', () => {
setup({ title: 'Test Panel Header', hoverHeader: false });
expect(screen.getByTestId('header-container')).toBeInTheDocument();
it.skip('renders panel with a fixed header if prop hoverHeader is false', () => {
// setup({ title: 'Test Panel Header', hoverHeader: false });
// expect(screen.getByTestId('header-container')).toBeInTheDocument();
});
it('renders panel with a header if prop menu', () => {

View File

@ -1,15 +1,24 @@
import { css, cx } from '@emotion/css';
import React, { CSSProperties, ReactNode } from 'react';
import { isEmpty } from 'lodash';
import React, { CSSProperties, ReactElement, ReactNode } from 'react';
import { GrafanaTheme2, isIconName } from '@grafana/data';
import { GrafanaTheme2, isIconName, LoadingState } from '@grafana/data';
import { useStyles2, useTheme2 } from '../../themes';
import { IconName } from '../../types/icon';
import { Dropdown } from '../Dropdown/Dropdown';
import { Icon } from '../Icon/Icon';
import { IconButton, IconButtonVariant } from '../IconButton/IconButton';
import { LoadingBar } from '../LoadingBar/LoadingBar';
import { PopoverContent, Tooltip } from '../Tooltip';
import { PanelStatus } from './PanelStatus';
interface Status {
message?: string;
onClick?: (e: React.SyntheticEvent) => void;
}
/**
* @internal
*/
@ -31,16 +40,16 @@ export interface PanelChromeProps {
padding?: PanelPadding;
title?: string;
titleItems?: PanelChromeInfoState[];
menu?: React.ReactElement;
/** dragClass, hoverHeader, loadingState, and states not yet implemented */
menu?: ReactElement;
/** dragClass, hoverHeader not yet implemented */
// dragClass?: string;
hoverHeader?: boolean;
// loadingState?: LoadingState;
// states?: ReactNode[];
/** @deprecated in favor of prop states
loadingState?: LoadingState;
status?: Status;
/** @deprecated in favor of props
* status for errors and loadingState for loading and streaming
* which will serve the same purpose
* of showing the panel state in the top right corner
* of itself or its header
* of showing/interacting with the panel's data state
* */
leftItems?: ReactNode[];
}
@ -53,7 +62,7 @@ export type PanelPadding = 'none' | 'md';
/**
* @internal
*/
export const PanelChrome: React.FC<PanelChromeProps> = ({
export function PanelChrome({
width,
height,
children,
@ -63,14 +72,18 @@ export const PanelChrome: React.FC<PanelChromeProps> = ({
menu,
// dragClass,
hoverHeader = false,
// loadingState,
// states = [],
loadingState,
status,
leftItems = [],
}) => {
}: PanelChromeProps) {
const theme = useTheme2();
const styles = useStyles2(getStyles);
const headerHeight = !hoverHeader ? getHeaderHeight(theme, title, leftItems) : 0;
// To Do rely on hoverHeader prop for header, not separate props
// once hoverHeader is implemented
const hasHeader = title.length > 0 || leftItems.length > 0;
const headerHeight = getHeaderHeight(theme, hasHeader);
const { contentStyle, innerWidth, innerHeight } = getContentStyle(padding, theme, width, headerHeight, height);
const headerStyles: CSSProperties = {
@ -82,72 +95,91 @@ export const PanelChrome: React.FC<PanelChromeProps> = ({
};
const containerStyles: CSSProperties = { width, height };
const handleMenuOpen = () => {};
const hasHeader = title || titleItems.length > 0 || menu;
const isUsingDeprecatedLeftItems = isEmpty(status) && !loadingState;
const showLoading = loadingState === LoadingState.Loading && !isUsingDeprecatedLeftItems;
const showStreaming = loadingState === LoadingState.Streaming && !isUsingDeprecatedLeftItems;
const renderStatus = () => {
if (isUsingDeprecatedLeftItems) {
return <div className={cx(styles.rightAligned, styles.items)}>{itemsRenderer(leftItems, (item) => item)}</div>;
} else {
const showError = loadingState === LoadingState.Error || status?.message;
return showError ? (
<div className={styles.errorContainer}>
<PanelStatus message={status?.message} onClick={status?.onClick} />
</div>
) : null;
}
};
return (
<div className={styles.container} style={containerStyles}>
{hasHeader && !hoverHeader && (
<div className={styles.headerContainer} style={headerStyles} data-testid="header-container">
{title && (
<div title={title} className={styles.title}>
{title}
<div className={styles.loadingBarContainer}>
{showLoading ? <LoadingBar width={'28%'} height={'2px'} /> : null}
</div>
<div className={styles.headerContainer} style={headerStyles} data-testid="header-container">
{title && (
<h6 title={title} className={styles.title}>
{title}
</h6>
)}
{showStreaming && (
<div className={styles.item} style={itemStyles}>
<Tooltip content="Streaming">
<Icon name="circle" type="mono" size="sm" className={styles.streaming} />
</Tooltip>
</div>
)}
{titleItems.length > 0 && (
<div className={styles.items} data-testid="title-items-container">
{titleItems
.filter((item) => isIconName(item.icon))
.map((item, i) => (
<div key={`${item.icon}-${i}`} className={styles.item} style={itemStyles}>
{item.onClick ? (
<IconButton tooltip={item.tooltip} name={item.icon} size="sm" onClick={item.onClick} />
) : (
<Tooltip content={item.tooltip ?? ''}>
<Icon name={item.icon} size="sm" />
</Tooltip>
)}
</div>
))}
</div>
)}
{menu && (
<Dropdown overlay={menu} placement="bottom">
<div className={cx(styles.item, styles.menuItem, 'menu-icon')} data-testid="menu-icon" style={itemStyles}>
<IconButton
ariaLabel={`Menu for panel with ${title ? `title ${title}` : 'no title'}`}
tooltip="Menu"
name="ellipsis-v"
size="sm"
/>
</div>
)}
</Dropdown>
)}
{titleItems.length > 0 && (
<div className={styles.items} data-testid="title-items-container">
{titleItems
.filter((item) => isIconName(item.icon))
.map((item, i) => (
<div key={`${item.icon}-${i}`} className={styles.item} style={itemStyles}>
{item.onClick ? (
<IconButton tooltip={item.tooltip} name={item.icon} size="sm" onClick={item.onClick} />
) : (
<Tooltip content={item.tooltip ?? ''}>
<Icon name={item.icon} size="sm" />
</Tooltip>
)}
</div>
))}
</div>
)}
{menu && (
<Dropdown overlay={menu} placement="bottom">
<div className={cx(styles.item, styles.menuItem, 'menu-icon')} data-testid="menu-icon" style={itemStyles}>
<IconButton
ariaLabel={`Menu for panel with ${title ? `title ${title}` : 'no title'}`}
tooltip="Menu"
name="ellipsis-v"
size="sm"
onClick={handleMenuOpen}
/>
</div>
</Dropdown>
)}
{leftItems.length > 0 && (
<div className={cx(styles.rightAligned, styles.items)}>{itemsRenderer(leftItems, (item) => item)}</div>
)}
</div>
)}
{renderStatus()}
</div>
<div className={styles.content} style={contentStyle}>
{children(innerWidth, innerHeight)}
</div>
</div>
);
};
}
const itemsRenderer = (items: ReactNode[], renderer: (items: ReactNode[]) => ReactNode): ReactNode => {
const toRender = React.Children.toArray(items).filter(Boolean);
return toRender.length > 0 ? renderer(toRender) : null;
};
const getHeaderHeight = (theme: GrafanaTheme2, title: string, items: ReactNode[]) => {
if (title.length > 0 || items.length > 0) {
const getHeaderHeight = (theme: GrafanaTheme2, hasHeader: boolean) => {
if (hasHeader) {
return theme.spacing.gridSize * theme.components.panel.headerHeight;
}
return 0;
@ -161,9 +193,12 @@ const getContentStyle = (
height: number
) => {
const chromePadding = (padding === 'md' ? theme.components.panel.padding : 0) * theme.spacing.gridSize;
const panelPadding = chromePadding * 2;
const panelBorder = 1 * 2;
const innerWidth = width - chromePadding * 2 - panelBorder;
const innerHeight = height - headerHeight - chromePadding * 2 - panelBorder;
const innerWidth = width - panelPadding - panelBorder;
const innerHeight = height - headerHeight - panelPadding - panelBorder;
const contentStyle: CSSProperties = {
padding: chromePadding,
@ -185,7 +220,7 @@ const getStyles = (theme: GrafanaTheme2) => {
height: '100%',
display: 'flex',
flexDirection: 'column',
flex: '0 0 0',
flex: '1 1 0',
'&:focus-visible, &:hover': {
// only show menu icon on hover or focused panel
@ -198,11 +233,16 @@ const getStyles = (theme: GrafanaTheme2) => {
outline: `1px solid ${theme.colors.action.focus}`,
},
}),
loadingBarContainer: css({
position: 'absolute',
top: 0,
width: '100%',
overflow: 'hidden',
}),
content: css({
label: 'panel-content',
width: '100%',
contain: 'strict',
flexGrow: 1,
contain: 'strict',
}),
headerContainer: css({
label: 'panel-header',
@ -210,23 +250,41 @@ const getStyles = (theme: GrafanaTheme2) => {
alignItems: 'center',
padding: `0 ${theme.spacing(padding)}`,
}),
streaming: css({
marginRight: 0,
color: theme.colors.success.text,
'&:hover': {
color: theme.colors.success.text,
},
}),
title: css({
marginBottom: 0, // override default h6 margin-bottom
textOverflow: 'ellipsis',
overflow: 'hidden',
whiteSpace: 'nowrap',
fontWeight: theme.typography.fontWeightMedium,
fontSize: theme.typography.h6.fontSize,
fontWeight: theme.typography.h6.fontWeight,
}),
items: css({
display: 'flex',
}),
item: css({
display: 'flex',
justifyContent: 'space-around',
justifyContent: 'center',
alignItems: 'center',
}),
menuItem: css({
visibility: 'hidden',
}),
errorContainer: css({
label: 'error-container',
position: 'absolute',
width: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}),
rightAligned: css({
marginLeft: 'auto',
}),

View File

@ -0,0 +1,43 @@
import { css } from '@emotion/css';
import React from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { useStyles2 } from '../../themes';
import { ToolbarButton } from '../ToolbarButton/ToolbarButton';
export interface Props {
message?: string;
onClick?: (e: React.SyntheticEvent) => void;
}
export function PanelStatus({ message, onClick }: Props) {
const styles = useStyles2(getStyles);
return (
<ToolbarButton
onClick={onClick}
variant={'destructive'}
className={styles.buttonStyles}
icon="exclamation-triangle"
tooltip={message || ''}
/>
);
}
const getStyles = (theme: GrafanaTheme2) => {
const { headerHeight, padding } = theme.components.panel;
return {
buttonStyles: css({
label: 'panel-header-state-button',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: theme.spacing(padding),
width: theme.spacing(headerHeight),
height: theme.spacing(headerHeight),
borderRadius: 0,
}),
};
};

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -34,6 +34,7 @@ import {
import { PANEL_BORDER } from 'app/core/constants';
import { profiler } from 'app/core/profiler';
import { applyPanelTimeOverrides } from 'app/features/dashboard/utils/panel';
import { InspectTab } from 'app/features/inspector/types';
import { changeSeriesColorConfigFactory } from 'app/plugins/panel/timeseries/overrides/colorSeriesConfigFactory';
import { RenderEvent } from 'app/types/events';
@ -45,7 +46,6 @@ import { DashboardModel, PanelModel } from '../state';
import { loadSnapshotData } from '../utils/loadSnapshotData';
import { PanelHeader } from './PanelHeader/PanelHeader';
import { PanelHeaderLoadingIndicator } from './PanelHeader/PanelHeaderLoadingIndicator';
import { seriesVisibilityConfigFactory } from './SeriesVisibilityConfigFactory';
import { liveTimer } from './liveTimer';
@ -566,6 +566,11 @@ export class PanelStateWrapper extends PureComponent<Props, State> {
return !panel.hasTitle();
}
onOpenErrorInspect(e: React.SyntheticEvent, tab: string) {
e.stopPropagation();
locationService.partial({ inspect: this.props.panel.id, inspectTab: tab });
}
render() {
const { dashboard, panel, isViewing, isEditing, width, height, plugin } = this.props;
const { errorMessage, data } = this.state;
@ -581,17 +586,22 @@ export class PanelStateWrapper extends PureComponent<Props, State> {
[`panel-alert-state--${alertState}`]: alertState !== undefined,
});
// for new panel header design
const onCancelQuery = () => panel.getQueryRunner().cancelQuery();
const title = panel.getDisplayTitle();
const noPadding: PanelPadding = plugin.noPadding ? 'none' : 'md';
const leftItems = [
<PanelHeaderLoadingIndicator state={data.state} onClick={onCancelQuery} key="loading-indicator" />,
];
const padding: PanelPadding = plugin.noPadding ? 'none' : 'md';
if (config.featureToggles.newPanelChromeUI) {
return (
<PanelChrome width={width} height={height} title={title} leftItems={leftItems} padding={noPadding}>
<PanelChrome
width={width}
height={height}
padding={padding}
title={title}
loadingState={data.state}
status={{
message: errorMessage,
onClick: (e: React.SyntheticEvent) => this.onOpenErrorInspect(e, InspectTab.Error),
}}
>
{(innerWidth, innerHeight) => (
<>
<ErrorBoundary