PanelChrome: Refactor and refine items next to title (#60514)

Co-authored-by: Torkel Ödegaard <torkel@grafana.com>
Co-authored-by: Polina Boneva <13227501+polibb@users.noreply.github.com>
Co-authored-by: polinaboneva <polina.boneva@grafana.com>
This commit is contained in:
Alexa V 2023-01-16 15:56:39 +00:00 committed by GitHub
parent 8ae4b9060b
commit 0eeeeef08b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 419 additions and 96 deletions

View File

@ -44,6 +44,7 @@ export const availableIconsIndex = {
'check-circle': true,
'check-square': true,
circle: true,
'circle-mono': true,
'clipboard-alt': true,
'clock-nine': true,
cloud: true,

View File

@ -1,6 +1,14 @@
import { IconName, IconSize } from '../../types/icon';
const alwaysMonoIcons: IconName[] = ['grafana', 'favorite', 'heart-break', 'heart', 'panel-add', 'library-panel'];
const alwaysMonoIcons: IconName[] = [
'grafana',
'favorite',
'heart-break',
'heart',
'panel-add',
'library-panel',
'circle-mono',
];
export function getIconSubDir(name: IconName, type: string): string {
if (name?.startsWith('gf-')) {

View File

@ -12,8 +12,6 @@ import { withCenteredStory } from '../../utils/storybook/withCenteredStory';
import { HorizontalGroup, VerticalGroup } from '../Layout/Layout';
import { Menu } from '../Menu/Menu';
import { PanelChromeInfoState } from './PanelChrome';
const meta: ComponentMeta<typeof PanelChrome> = {
title: 'Visualizations/PanelChrome',
component: PanelChrome,
@ -235,29 +233,11 @@ const ErrorIcon = [
const leftItems = { LoadingIcon, ErrorIcon, Default };
const titleItems: PanelChromeInfoState[] = [
{
icon: 'info',
tooltip:
'Description text with very long descriptive words that describe what is going on in the panel and not beyond. Or maybe beyond, not up to us.',
},
{
icon: 'external-link-alt',
tooltip: 'wearegoingonanadventure.openanewtab.maybe',
onClick: () => {},
},
{
icon: 'clock-nine',
tooltip: 'Time range: 2021-09-01 00:00:00 to 2021-09-01 00:00:00',
onClick: () => {},
},
{
icon: 'heart',
tooltip: 'Health of the panel',
},
];
const description =
'Description text with very long descriptive words that describe what is going on in the panel and not beyond. Or maybe beyond, not up to us.';
Basic.argTypes = {
description: { control: { type: 'text' } },
leftItems: {
options: Object.keys(leftItems),
mapping: leftItems,
@ -276,9 +256,8 @@ Basic.args = {
width: 400,
height: 200,
title: 'Very long title that should get ellipsis when there is no more space',
titleItems,
description,
menu,
loadingState: LoadingState.Loading,
};
export default meta;

View File

@ -49,13 +49,7 @@ it('renders panel with a header with title in place if prop title', () => {
it('renders panel with a header if prop titleItems', () => {
setup({
titleItems: [
{
icon: 'info-circle',
tooltip: 'This is the panel description',
onClick: () => {},
},
],
titleItems: [<div key="title-item-test"> This should be a self-contained node </div>],
});
expect(screen.getByTestId('header-container')).toBeInTheDocument();
@ -63,13 +57,7 @@ it('renders panel with a header if prop titleItems', () => {
it('renders panel with a header with icons in place if prop titleItems', () => {
setup({
titleItems: [
{
icon: 'info-circle',
tooltip: 'This is the panel description',
onClick: () => {},
},
],
titleItems: [<div key="title-item-test"> This should be a self-contained node </div>],
});
expect(screen.getByTestId('title-items-container')).toBeInTheDocument();

View File

@ -1,18 +1,18 @@
import { css, cx } from '@emotion/css';
import { isEmpty } from 'lodash';
import React, { CSSProperties, ReactElement, ReactNode } from 'react';
import React, { CSSProperties, ReactNode, ReactElement } from 'react';
import { GrafanaTheme2, isIconName, LoadingState } from '@grafana/data';
import { GrafanaTheme2, LoadingState } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
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 { ToolbarButton } from '../ToolbarButton';
import { PopoverContent, Tooltip } from '../Tooltip';
import { Tooltip } from '../Tooltip';
import { PanelDescription } from './PanelDescription';
import { PanelStatus } from './PanelStatus';
interface Status {
@ -20,17 +20,6 @@ interface Status {
onClick?: (e: React.SyntheticEvent) => void;
}
/**
* @internal
*/
export interface PanelChromeInfoState {
icon: IconName;
label?: string | ReactNode;
tooltip?: PopoverContent;
variant?: IconButtonVariant;
onClick?: () => void;
}
/**
* @internal
*/
@ -40,7 +29,8 @@ export interface PanelChromeProps {
children: (innerWidth: number, innerHeight: number) => ReactNode;
padding?: PanelPadding;
title?: string;
titleItems?: PanelChromeInfoState[];
description?: string | (() => string);
titleItems?: ReactNode[];
menu?: ReactElement | (() => ReactElement);
/** dragClass, hoverHeader not yet implemented */
// dragClass?: string;
@ -69,6 +59,7 @@ export function PanelChrome({
children,
padding = 'md',
title = '',
description = '',
titleItems = [],
menu,
// dragClass,
@ -82,7 +73,16 @@ export function PanelChrome({
// To Do rely on hoverHeader prop for header, not separate props
// once hoverHeader is implemented
const hasHeader = title.length > 0 || leftItems.length > 0;
//
// Backwards compatibility for having a designated space for the header
const hasHeader =
hoverHeader === false &&
(title.length > 0 ||
titleItems.length > 0 ||
description !== '' ||
loadingState === LoadingState.Streaming ||
leftItems.length > 0);
const headerHeight = getHeaderHeight(theme, hasHeader);
const { contentStyle, innerWidth, innerHeight } = getContentStyle(padding, theme, width, headerHeight, height);
@ -114,8 +114,10 @@ export function PanelChrome({
return null;
}
};
const ariaLabel = title ? selectors.components.Panels.Panel.containerByTitle(title) : 'Panel';
return (
<div className={styles.container} style={containerStyles}>
<div className={styles.container} style={containerStyles} aria-label={ariaLabel}>
<div className={styles.loadingBarContainer}>
{showLoading ? <LoadingBar width={'28%'} height={'2px'} /> : null}
</div>
@ -127,29 +129,19 @@ export function PanelChrome({
</h6>
)}
{showStreaming && (
<div className={styles.item} style={itemStyles}>
<Tooltip content="Streaming">
<Icon name="circle" type="mono" size="sm" className={styles.streaming} />
</Tooltip>
<PanelDescription description={description} />
{titleItems && (
<div className={styles.titleItems} data-testid="title-items-container">
{titleItems.map((item) => item)}
</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>
))}
{showStreaming && (
<div className={styles.item} style={itemStyles}>
<Tooltip content="Streaming">
<Icon name="circle-mono" size="sm" className={styles.streaming} />
</Tooltip>
</div>
)}
@ -172,7 +164,6 @@ export function PanelChrome({
{renderStatus()}
</div>
<div className={styles.content} style={contentStyle}>
{children(innerWidth, innerHeight)}
</div>
@ -299,5 +290,11 @@ const getStyles = (theme: GrafanaTheme2) => {
display: 'flex',
alignItems: 'center',
}),
titleItems: css({
display: 'flex',
alignItems: 'center',
overflow: 'hidden',
padding: theme.spacing(1),
}),
};
};

View File

@ -0,0 +1,75 @@
import { css } from '@emotion/css';
import React from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { useTheme2 } from '../../themes';
import { getFocusStyles, getMouseFocusStyles } from '../../themes/mixins';
import { Icon } from '../Icon/Icon';
import { Tooltip } from '../Tooltip';
interface Props {
description: string | (() => string);
}
export function PanelDescription({ description }: Props) {
const theme = useTheme2();
const styles = getStyles(theme);
const getDescriptionContent = (): JSX.Element => {
// description
const panelDescription = typeof description === 'function' ? description() : description;
return (
<div className="panel-info-content markdown-html">
<div dangerouslySetInnerHTML={{ __html: panelDescription }} />
</div>
);
};
return description !== '' ? (
<Tooltip interactive content={getDescriptionContent}>
<span className={styles.description}>
<Icon name="info-circle" size="lg" aria-label="description" />
</span>
</Tooltip>
) : null;
}
const getStyles = (theme: GrafanaTheme2) => {
return {
description: css({
color: `${theme.colors.text.secondary}`,
backgroundColor: `${theme.colors.background.primary}`,
cursor: 'auto',
border: 'none',
borderRadius: `${theme.shape.borderRadius()}`,
padding: `${theme.spacing(0, 1)}`,
height: ` ${theme.spacing(theme.components.height.md)}`,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
'&:focus, &:focus-visible': {
...getFocusStyles(theme),
zIndex: 1,
},
'&: focus:not(:focus-visible)': getMouseFocusStyles(theme),
'&:hover ': {
boxShadow: `${theme.shadows.z1}`,
color: `${theme.colors.text.primary}`,
background: `${theme.colors.background.secondary}`,
},
code: {
whiteSpace: 'normal',
wordWrap: 'break-word',
},
'pre > code': {
display: 'block',
},
}),
};
};

View File

@ -38,6 +38,8 @@ export {
type ErrorIndicatorProps as PanelChromeErrorIndicatorProps,
} from './ErrorIndicator';
export { PanelDescription } from './PanelDescription';
export { usePanelContext, PanelContextProvider, type PanelContext, PanelContextRoot } from './PanelContext';
export * from './types';

View File

@ -58,6 +58,7 @@ export const Tooltip = React.memo(({ children, theme, interactive, show, placeme
<>
{React.cloneElement(children, {
ref: setTriggerRef,
tabIndex: 0, // tooltip should be keyboard focusable
})}
{visible && (
<Portal>

View File

@ -1,7 +1,9 @@
import { css } from '@emotion/css';
import React, { FC } from 'react';
import { QueryResultMetaNotice } from '@grafana/data';
import { Icon, Tooltip } from '@grafana/ui';
import { GrafanaTheme2, QueryResultMetaNotice } from '@grafana/data';
import { Icon, ToolbarButton, Tooltip, useStyles2 } from '@grafana/ui';
import { getFocusStyles, getMouseFocusStyles } from '@grafana/ui/src/themes/mixins';
interface Props {
notice: QueryResultMetaNotice;
@ -9,20 +11,67 @@ interface Props {
}
export const PanelHeaderNotice: FC<Props> = ({ notice, onClick }) => {
const styles = useStyles2(getStyles);
const iconName =
notice.severity === 'error' || notice.severity === 'warning' ? 'exclamation-triangle' : 'info-circle';
if (notice.inspect && onClick) {
return (
<ToolbarButton
className={styles.notice}
icon={iconName}
key={notice.severity}
tooltip={notice.text}
onClick={(e) => onClick(e, notice.inspect!)}
/>
);
}
if (notice.link) {
return (
<a className={styles.notice} aria-label={notice.text} href={notice.link} target="_blank" rel="noreferrer">
<Icon name={iconName} style={{ marginRight: '8px' }} />
</a>
);
}
return (
<Tooltip content={notice.text} key={notice.severity}>
{notice.inspect ? (
<div className="panel-info-notice pointer" onClick={(e) => onClick(e, notice.inspect!)}>
<Icon name={iconName} style={{ marginRight: '8px' }} />
</div>
) : (
<a className="panel-info-notice" href={notice.link} target="_blank" rel="noreferrer">
<Icon name={iconName} style={{ marginRight: '8px' }} />
</a>
)}
<Tooltip key={notice.severity} content={notice.text}>
<span className={styles.iconTooltip}>
<Icon name={iconName} size="lg" />
</span>
</Tooltip>
);
};
const getStyles = (theme: GrafanaTheme2) => ({
notice: css({
border: 'none',
borderRadius: theme.shape.borderRadius(),
}),
iconTooltip: css({
color: `${theme.colors.text.secondary}`,
backgroundColor: `${theme.colors.background.primary}`,
cursor: 'auto',
border: 'none',
borderRadius: `${theme.shape.borderRadius()}`,
padding: `${theme.spacing(0, 1)}`,
height: ` ${theme.spacing(theme.components.height.md)}`,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
'&:focus, &:focus-visible': {
...getFocusStyles(theme),
zIndex: 1,
},
'&: focus:not(:focus-visible)': getMouseFocusStyles(theme),
'&:hover ': {
boxShadow: `${theme.shadows.z1}`,
color: `${theme.colors.text.primary}`,
background: `${theme.colors.background.secondary}`,
},
}),
});

View File

@ -0,0 +1,107 @@
import { css, cx } from '@emotion/css';
import React from 'react';
import { PanelData, GrafanaTheme2, PanelModel, LinkModel, AlertState, DataLink } from '@grafana/data';
import { Icon, Tooltip, useStyles2 } from '@grafana/ui';
import { getFocusStyles, getMouseFocusStyles } from '@grafana/ui/src/themes/mixins';
import { PanelLinks } from '../PanelLinks';
import { PanelHeaderNotices } from './PanelHeaderNotices';
export interface Props {
alertState?: string;
data: PanelData;
panelId: number;
onShowPanelLinks?: () => Array<LinkModel<PanelModel>>;
panelLinks?: DataLink[];
}
export function PanelHeaderTitleItems(props: Props) {
const { alertState, data, panelId, onShowPanelLinks, panelLinks } = props;
const styles = useStyles2(getStyles);
// panel health
const alertStateItem = (
<Tooltip content={`alerting is ${alertState}`}>
<span
className={cx(styles.item, {
[styles.ok]: alertState === AlertState.OK,
[styles.pending]: alertState === AlertState.Pending,
[styles.alerting]: alertState === AlertState.Alerting,
})}
>
<Icon name={alertState === 'alerting' ? 'heart-break' : 'heart'} className="panel-alert-icon" />
</span>
</Tooltip>
);
const timeshift = (
<>
<Tooltip
content={data.request?.range ? `Time Range: ${data.request.range.from} to ${data.request.range.to}` : ''}
>
<span className={cx(styles.item, styles.timeshift)}>
<Icon name="clock-nine" />
{data.request?.timeInfo}
</span>
</Tooltip>
</>
);
return (
<>
{panelLinks && panelLinks.length > 0 && onShowPanelLinks && (
<PanelLinks onShowPanelLinks={onShowPanelLinks} panelLinks={panelLinks} />
)}
{<PanelHeaderNotices panelId={panelId} frames={data.series} />}
{data.request && data.request.timeInfo && timeshift}
{alertState && alertStateItem}
</>
);
}
const getStyles = (theme: GrafanaTheme2) => {
return {
item: css({
label: 'panel-header-item',
backgroundColor: `${theme.colors.background.primary}`,
cursor: 'auto',
border: 'none',
borderRadius: `${theme.shape.borderRadius()}`,
padding: `${theme.spacing(0, 1)}`,
height: `${theme.spacing(theme.components.height.md)}`,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
'&:focus, &:focus-visible': {
...getFocusStyles(theme),
zIndex: 1,
},
'&: focus:not(:focus-visible)': getMouseFocusStyles(theme),
'&:hover ': {
boxShadow: `${theme.shadows.z1}`,
background: `${theme.colors.background.secondary}`,
},
}),
ok: css({
color: theme.colors.success.text,
}),
pending: css({
color: theme.colors.warning.text,
}),
alerting: css({
color: theme.colors.error.text,
}),
timeshift: css({
color: theme.colors.text.link,
'&:hover': {
color: theme.colors.emphasize(theme.colors.text.link, 0.03),
},
}),
};
};

View File

@ -0,0 +1,77 @@
import { css } from '@emotion/css';
import React from 'react';
import { DataLink, GrafanaTheme2, LinkModel } from '@grafana/data';
import { Dropdown, Icon, Menu, ToolbarButton, useStyles2 } from '@grafana/ui';
import { getFocusStyles, getMouseFocusStyles } from '@grafana/ui/src/themes/mixins';
interface Props {
panelLinks: DataLink[];
onShowPanelLinks: () => LinkModel[];
}
export function PanelLinks({ panelLinks, onShowPanelLinks }: Props) {
const styles = useStyles2(getStyles);
const getLinksContent = (): JSX.Element => {
const interpolatedLinks = onShowPanelLinks();
return (
<Menu>
{interpolatedLinks?.map((link, idx) => {
return <Menu.Item key={idx} label={link.title} url={link.href} target={link.target} />;
})}
</Menu>
);
};
if (panelLinks.length === 1) {
const linkModel = onShowPanelLinks()[0];
return (
<a
href={linkModel.href}
onClick={linkModel.onClick}
target={linkModel.target}
title={linkModel.title}
className={styles.singleLink}
>
<Icon name="external-link-alt" size="lg" />
</a>
);
} else {
return (
<Dropdown overlay={getLinksContent}>
<ToolbarButton icon="external-link-alt" aria-label="panel links" className={styles.menuTrigger} />
</Dropdown>
);
}
}
const getStyles = (theme: GrafanaTheme2) => {
return {
menuTrigger: css({
border: 'none',
borderRadius: `${theme.shape.borderRadius()}`,
cursor: 'context-menu',
}),
singleLink: css({
color: theme.colors.text.secondary,
padding: `${theme.spacing(0, 1)}`,
height: ` ${theme.spacing(theme.components.height.md)}`,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
'&:focus, &:focus-visible': {
...getFocusStyles(theme),
zIndex: 1,
},
'&: focus:not(:focus-visible)': getMouseFocusStyles(theme),
'&:hover ': {
boxShadow: `${theme.shadows.z1}`,
color: `${theme.colors.text.primary}`,
background: `${theme.colors.background.secondary}`,
},
}),
};
};

View File

@ -11,17 +11,19 @@ import {
EventFilterOptions,
FieldConfigSource,
getDefaultTimeRange,
LinkModel,
LoadingState,
PanelData,
PanelPlugin,
PanelPluginMeta,
PluginContextProvider,
renderMarkdown,
TimeRange,
toDataFrameDTO,
toUtc,
} from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
import { config, locationService, RefreshEvent } from '@grafana/runtime';
import { getTemplateSrv, config, locationService, RefreshEvent } from '@grafana/runtime';
import { VizLegendOptions } from '@grafana/schema';
import {
ErrorBoundary,
@ -35,6 +37,7 @@ 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 { getPanelLinksSupplier } from 'app/features/panel/panellinks/linkSuppliers';
import { changeSeriesColorConfigFactory } from 'app/plugins/panel/timeseries/overrides/colorSeriesConfigFactory';
import { RenderEvent } from 'app/types/events';
@ -47,6 +50,7 @@ import { loadSnapshotData } from '../utils/loadSnapshotData';
import { PanelHeader } from './PanelHeader/PanelHeader';
import { PanelHeaderMenuWrapper } from './PanelHeader/PanelHeaderMenuWrapper';
import { PanelHeaderTitleItems } from './PanelHeader/PanelHeaderTitleItems';
import { seriesVisibilityConfigFactory } from './SeriesVisibilityConfigFactory';
import { liveTimer } from './liveTimer';
@ -567,6 +571,27 @@ export class PanelStateWrapper extends PureComponent<Props, State> {
return !panel.hasTitle();
}
onShowPanelDescription = () => {
const { panel } = this.props;
const descriptionMarkdown = getTemplateSrv().replace(panel.description, panel.scopedVars);
const interpolatedDescription = renderMarkdown(descriptionMarkdown);
return interpolatedDescription;
};
onShowPanelLinks = (): LinkModel[] => {
const { panel } = this.props;
const linkSupplier = getPanelLinksSupplier(panel);
if (linkSupplier) {
const panelLinks = linkSupplier && linkSupplier.getLinks(panel.replaceVariables);
return panelLinks;
}
return [];
};
onOpenInspector = (e: React.SyntheticEvent, tab: string) => {
e.stopPropagation();
locationService.partial({ inspect: this.props.panel.id, inspectTab: tab });
};
onOpenErrorInspect(e: React.SyntheticEvent, tab: string) {
e.stopPropagation();
locationService.partial({ inspect: this.props.panel.id, inspectTab: tab });
@ -590,6 +615,17 @@ export class PanelStateWrapper extends PureComponent<Props, State> {
const title = panel.getDisplayTitle();
const padding: PanelPadding = plugin.noPadding ? 'none' : 'md';
const titleItems = [
<PanelHeaderTitleItems
key="title-items"
alertState={alertState}
data={data}
panelId={panel.id}
panelLinks={panel.links}
onShowPanelLinks={this.onShowPanelLinks}
/>,
];
let menu;
if (!dashboard.meta.publicDashboardAccessToken) {
menu = (
@ -610,14 +646,16 @@ export class PanelStateWrapper extends PureComponent<Props, State> {
<PanelChrome
width={width}
height={height}
padding={padding}
title={title}
menu={menu}
loadingState={data.state}
status={{
message: errorMessage,
onClick: (e: React.SyntheticEvent) => this.onOpenErrorInspect(e, InspectTab.Error),
}}
description={!!panel.description ? this.onShowPanelDescription : undefined}
titleItems={titleItems}
menu={menu}
padding={padding}
>
{(innerWidth, innerHeight) => (
<>

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/></svg>

After

Width:  |  Height:  |  Size: 99 B

View File

@ -574,4 +574,4 @@
"option-tooltip": "Cľęäř şęľęčŧįőʼnş"
}
}
}
}