mirror of https://github.com/grafana/grafana.git
				
				
				
			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:
		
							parent
							
								
									8ae4b9060b
								
							
						
					
					
						commit
						0eeeeef08b
					
				| 
						 | 
				
			
			@ -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,
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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-')) {
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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;
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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();
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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" />
 | 
			
		||||
        {showStreaming && (
 | 
			
		||||
          <div className={styles.item} style={itemStyles}>
 | 
			
		||||
            <Tooltip content="Streaming">
 | 
			
		||||
              <Icon name="circle-mono" size="sm" className={styles.streaming} />
 | 
			
		||||
            </Tooltip>
 | 
			
		||||
                  )}
 | 
			
		||||
                </div>
 | 
			
		||||
              ))}
 | 
			
		||||
          </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),
 | 
			
		||||
    }),
 | 
			
		||||
  };
 | 
			
		||||
};
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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',
 | 
			
		||||
      },
 | 
			
		||||
    }),
 | 
			
		||||
  };
 | 
			
		||||
};
 | 
			
		||||
| 
						 | 
				
			
			@ -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';
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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>
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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 (
 | 
			
		||||
    <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">
 | 
			
		||||
      <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 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}`,
 | 
			
		||||
    },
 | 
			
		||||
  }),
 | 
			
		||||
});
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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),
 | 
			
		||||
      },
 | 
			
		||||
    }),
 | 
			
		||||
  };
 | 
			
		||||
};
 | 
			
		||||
| 
						 | 
				
			
			@ -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}`,
 | 
			
		||||
      },
 | 
			
		||||
    }),
 | 
			
		||||
  };
 | 
			
		||||
};
 | 
			
		||||
| 
						 | 
				
			
			@ -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) => (
 | 
			
		||||
            <>
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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  | 
		Loading…
	
		Reference in New Issue