PanelChrome: Implementing the new layout on PanelChrome @ grafana/ui (#57203)

* Use newPanelChromeUI feature flag in DashboardPanel panel rendering

* just render the PanelChromeUI instead of the PanelChrome

* add new props to PanelChrome; have ChromePanel from grafana/ui in DashboardPanel for testing (will remove before finished);

* put icons next to the title of PanelChrome header space

* arrange PanelChrome's title icons into view/edit/status sections

* icons next to title in PanelChrome are surrounded by square focusable space

* items to be render in Header in PanelChrome come in as props

* PanelChrome accepts items next to title from the outside; currently them being ordered in the left side is okay, right side not so much

* revert local changes to DashboardPanel

* cleanup unused imports

* simple PanelChrome render without any header props

* CSS function

* add test PanelChrome prop padding

* add icons next to title if they are passed to PanelChrome

* fixed PanelChrome header: hoverHeader, having a menu prop;

* only show icons with correct icon names; show menu icon only on hover over panel container; minor other fixes

* attempt to resolve hovering in an RTL test for the menu icon to work as expected

* menu opens in a Dropdown if provided as prop

* fixing tooltips and aria-labels

* Fixed issue with light theme in storybook

* comment out props and tests that are not yet used

* Fixed issue where content was overflowing the boundaries

Co-authored-by: Torkel Ödegaard <torkel@grafana.com>
This commit is contained in:
Polina Boneva 2022-11-24 13:21:18 +02:00 committed by GitHub
parent f3da48bd50
commit 314c22bc5b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 391 additions and 120 deletions

View File

@ -57,16 +57,16 @@ export const Menu = Object.assign(MenuComp, {
const getStyles = (theme: GrafanaTheme2) => {
return {
header: css`
padding: ${theme.spacing(0.5, 0.5, 1, 0.5)};
border-bottom: 1px solid ${theme.colors.border.weak};
`,
wrapper: css`
background: ${theme.colors.background.primary};
box-shadow: ${theme.shadows.z3};
display: inline-block;
border-radius: ${theme.shape.borderRadius()};
padding: ${theme.spacing(0.5, 0)};
`,
header: css({
padding: `${theme.spacing(0.5, 0.5, 1, 0.5)}`,
borderBottom: `1px solid ${theme.colors.border.weak}`,
}),
wrapper: css({
background: `${theme.colors.background.primary}`,
boxShadow: `${theme.shadows.z3}`,
display: `inline-block`,
borderRadius: `${theme.shape.borderRadius()}`,
padding: `${theme.spacing(0.5, 0)}`,
}),
};
};

View File

@ -38,11 +38,11 @@ export const ErrorIndicator: React.FC<ErrorIndicatorProps> = ({ error, onClick }
const getStyles = () => {
return {
clickable: css`
cursor: pointer;
`,
icon: css`
color: ${commonColorsPalette.red88};
`,
clickable: css({
cursor: 'pointer',
}),
icon: css({
color: `${commonColorsPalette.red88}`,
}),
};
};

View File

@ -40,8 +40,8 @@ export const LoadingIndicator: React.FC<LoadingIndicatorProps> = ({ onCancel, lo
const getStyles = () => {
return {
clickable: css`
cursor: pointer;
`,
clickable: css({
cursor: 'pointer',
}),
};
};

View File

@ -4,11 +4,14 @@ import { merge } from 'lodash';
import React, { CSSProperties, useState, ReactNode } from 'react';
import { useInterval } from 'react-use';
import { GrafanaTheme2 } from '@grafana/data';
import { PanelChrome, useTheme2, PanelChromeProps } from '@grafana/ui';
import { PanelChrome, PanelChromeProps } from '@grafana/ui';
import { DashboardStoryCanvas } from '../../utils/storybook/DashboardStoryCanvas';
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',
@ -22,17 +25,16 @@ const meta: ComponentMeta<typeof PanelChrome> = {
},
};
function getContentStyle(theme: GrafanaTheme2): CSSProperties {
function getContentStyle(): CSSProperties {
return {
background: theme.colors.background.secondary,
color: theme.colors.text.primary,
background: 'rgba(230,0,0,0.05)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
};
}
function renderPanel(name: string, overrides: Partial<PanelChromeProps>, theme: GrafanaTheme2) {
function renderPanel(name: string, overrides: Partial<PanelChromeProps>) {
const props: PanelChromeProps = {
width: 400,
height: 130,
@ -42,7 +44,7 @@ function renderPanel(name: string, overrides: Partial<PanelChromeProps>, theme:
merge(props, overrides);
const contentStyle = getContentStyle(theme);
const contentStyle = getContentStyle();
return (
<PanelChrome {...props}>
@ -54,69 +56,75 @@ function renderPanel(name: string, overrides: Partial<PanelChromeProps>, theme:
}
export const Examples = () => {
const theme = useTheme2();
const [loading, setLoading] = useState(true);
useInterval(() => setLoading(true), 5000);
return (
<div style={{ background: theme.colors.background.canvas, padding: 100 }}>
<HorizontalGroup spacing="md">
<DashboardStoryCanvas>
<HorizontalGroup spacing="md" align="flex-start">
<VerticalGroup spacing="md">
{renderPanel('Default panel', {}, theme)}
{renderPanel('No padding', { padding: 'none' }, theme)}
{renderPanel('Default panel with error state indicator', {
title: 'Default title',
leftItems: [
<PanelChrome.ErrorIndicator
key="errorIndicator"
error="Error text"
onClick={action('ErrorIndicator: onClick fired')}
/>,
],
})}
{renderPanel('No padding with error state indicator', {
padding: 'none',
title: 'Default title',
leftItems: [
<PanelChrome.ErrorIndicator
key="errorIndicator"
error="Error text"
onClick={action('ErrorIndicator: onClick fired')}
/>,
],
})}
</VerticalGroup>
<VerticalGroup spacing="md">
{renderPanel('No title', { title: '' }, theme)}
{renderPanel(
'Very long title',
{ title: 'Very long title that should get ellipsis when there is no more space' },
theme
)}
{renderPanel('No title', { title: '' })}
{renderPanel('Very long title', {
title: 'Very long title that should get ellipsis when there is no more space',
})}
</VerticalGroup>
</HorizontalGroup>
<div style={{ marginTop: theme.spacing(2) }} />
<HorizontalGroup spacing="md">
<VerticalGroup spacing="md">
{renderPanel(
'No title and loading indicator',
{
title: '',
leftItems: [
<PanelChrome.LoadingIndicator
loading={loading}
onCancel={() => setLoading(false)}
key="loading-indicator"
/>,
],
},
theme
)}
{renderPanel('No title and loading indicator', {
title: '',
leftItems: [
<PanelChrome.LoadingIndicator
loading={loading}
onCancel={() => setLoading(false)}
key="loading-indicator"
/>,
],
})}
</VerticalGroup>
<VerticalGroup spacing="md">
{renderPanel(
'Very long title',
{
title: 'Very long title that should get ellipsis when there is no more space',
leftItems: [
<PanelChrome.LoadingIndicator
loading={loading}
onCancel={() => setLoading(false)}
key="loading-indicator"
/>,
],
},
theme
)}
{renderPanel('Very long title', {
title: 'Very long title that should get ellipsis when there is no more space',
leftItems: [
<PanelChrome.LoadingIndicator
loading={loading}
onCancel={() => setLoading(false)}
key="loading-indicator"
/>,
],
})}
</VerticalGroup>
</HorizontalGroup>
</div>
</DashboardStoryCanvas>
);
};
export const Basic: ComponentStory<typeof PanelChrome> = (args: PanelChromeProps) => {
const theme = useTheme2();
const contentStyle = getContentStyle(theme);
const contentStyle = getContentStyle();
return (
<PanelChrome {...args}>
@ -139,6 +147,59 @@ 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 menu = (
<Menu>
<Menu.Item label="View" icon="eye" />
<Menu.Item label="Edit" icon="edit" />
<Menu.Item label="Share" icon="share-alt" />
<Menu.Item label="Explore" icon="compass" />
<Menu.Item
label="Inspect"
icon="info-circle"
childItems={[
<Menu.Item key="subitem1" label="Data" />,
<Menu.Item key="subitem2" label="Query" />,
<Menu.Item key="subitem3" label="Panel JSON" />,
]}
/>
<Menu.Item
label="More"
icon="cube"
childItems={[
<Menu.Item key="subitem1" label="Duplicate" />,
<Menu.Item key="subitem2" label="Copy" />,
<Menu.Item key="subitem3" label="Create library panel" />,
<Menu.Item key="subitem4" label="Hide legend" />,
<Menu.Item key="subitem5" label="Get help" />,
]}
/>
<Menu.Divider />
<Menu.Item label="Remove" icon="trash-alt" />
</Menu>
);
Basic.argTypes = {
leftItems: {
options: Object.keys(leftItems),
@ -157,7 +218,9 @@ Basic.argTypes = {
Basic.args = {
width: 400,
height: 200,
title: 'Title text',
title: 'Very long title that should get ellipsis when there is no more space',
titleItems,
menu,
};
export default meta;

View File

@ -0,0 +1,107 @@
import { screen, render } from '@testing-library/react';
import React from 'react';
import { PanelChrome, PanelChromeProps } from './PanelChrome';
const setup = (propOverrides?: Partial<PanelChromeProps>) => {
const props: PanelChromeProps = {
width: 100,
height: 100,
children: (innerWidth, innerHeight) => {
return <div style={{ width: innerWidth, height: innerHeight, color: 'pink' }}>Panel&apos;s Content</div>;
},
};
Object.assign(props, propOverrides);
return render(<PanelChrome {...props} />);
};
it('renders an empty panel with required props only', () => {
setup();
expect(screen.getByText("Panel's Content")).toBeInTheDocument();
});
it('renders an empty panel without padding', () => {
setup({ padding: 'none' });
expect(screen.getByText("Panel's Content").parentElement).toHaveStyle({ padding: '0px' });
});
it('renders an empty panel with padding', () => {
setup({ padding: 'md' });
expect(screen.getByText("Panel's Content").style.getPropertyValue('height')).not.toBe('100px');
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' });
expect(screen.getByTestId('header-container')).toBeInTheDocument();
});
it('renders panel with a header with title in place if prop title', () => {
setup({ title: 'Test Panel Header' });
expect(screen.getByText('Test Panel Header')).toBeInTheDocument();
});
it('renders panel with a header if prop titleItems', () => {
setup({
titleItems: [
{
icon: 'info-circle',
tooltip: 'This is the panel description',
onClick: () => {},
},
],
});
expect(screen.getByTestId('header-container')).toBeInTheDocument();
});
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: () => {},
},
],
});
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('renders panel with a header if prop menu', () => {
setup({ menu: <div> Menu </div> });
expect(screen.getByTestId('header-container')).toBeInTheDocument();
});
it('renders panel with a show-on-hover menu icon if prop menu', () => {
setup({ menu: <div> Menu </div> });
expect(screen.getByTestId('menu-icon')).toBeInTheDocument();
expect(screen.getByTestId('menu-icon')).not.toBeVisible();
});
it.skip('renders states in the panel header if any given', () => {});
it.skip('renders leftItems in the panel header if any given when no states prop is given', () => {});
it.skip('renders states in the panel header if both leftItems and states are given', () => {});

View File

@ -1,9 +1,25 @@
import { css } from '@emotion/css';
import { css, cx } from '@emotion/css';
import React, { CSSProperties, ReactNode } from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { GrafanaTheme2, isIconName } 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 { PopoverContent, Tooltip } from '../Tooltip';
/**
* @internal
*/
export interface PanelChromeInfoState {
icon: IconName;
label?: string | ReactNode;
tooltip?: PopoverContent;
variant?: IconButtonVariant;
onClick?: () => void;
}
/**
* @internal
@ -11,10 +27,22 @@ import { useStyles2, useTheme2 } from '../../themes';
export interface PanelChromeProps {
width: number;
height: number;
title?: string;
children: (innerWidth: number, innerHeight: number) => ReactNode;
padding?: PanelPadding;
leftItems?: React.ReactNode[]; // rightItems will be added later (actions links etc.)
children: (innerWidth: number, innerHeight: number) => React.ReactNode;
title?: string;
titleItems?: PanelChromeInfoState[];
menu?: React.ReactElement;
/** dragClass, hoverHeader, loadingState, and states not yet implemented */
// dragClass?: string;
hoverHeader?: boolean;
// loadingState?: LoadingState;
// states?: ReactNode[];
/** @deprecated in favor of prop states
* which will serve the same purpose
* of showing the panel state in the top right corner
* of itself or its header
* */
leftItems?: ReactNode[];
}
/**
@ -26,33 +54,86 @@ export type PanelPadding = 'none' | 'md';
* @internal
*/
export const PanelChrome: React.FC<PanelChromeProps> = ({
title = '',
children,
width,
height,
children,
padding = 'md',
title = '',
titleItems = [],
menu,
// dragClass,
hoverHeader = false,
// loadingState,
// states = [],
leftItems = [],
}) => {
const theme = useTheme2();
const styles = useStyles2(getStyles);
const headerHeight = getHeaderHeight(theme, title, leftItems);
const headerHeight = !hoverHeader ? getHeaderHeight(theme, title, leftItems) : 0;
const { contentStyle, innerWidth, innerHeight } = getContentStyle(padding, theme, width, headerHeight, height);
const headerStyles: CSSProperties = {
height: headerHeight,
};
const itemStyles: CSSProperties = {
minHeight: headerHeight,
minWidth: headerHeight,
};
const containerStyles: CSSProperties = { width, height };
const handleMenuOpen = () => {};
const hasHeader = title || titleItems.length > 0 || menu;
return (
<div className={styles.container} style={containerStyles}>
<div className={styles.header} style={headerStyles}>
<div className={styles.headerTitle}>{title}</div>
{itemsRenderer(leftItems, (items) => {
return <div className={styles.leftItems}>{items}</div>;
})}
</div>
{hasHeader && !hoverHeader && (
<div className={styles.headerContainer} style={headerStyles} data-testid="header-container">
{title && (
<div title={title} className={styles.title}>
{title}
</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"
onClick={handleMenuOpen}
/>
</div>
</Dropdown>
)}
{leftItems.length > 0 && (
<div className={cx(styles.rightAligned, styles.items)}>{itemsRenderer(leftItems, (item) => item)}</div>
)}
</div>
)}
<div className={styles.content} style={contentStyle}>
{children(innerWidth, innerHeight)}
</div>
@ -95,39 +176,59 @@ const getStyles = (theme: GrafanaTheme2) => {
const { padding, background, borderColor } = theme.components.panel;
return {
container: css`
label: panel-container;
background-color: ${background};
border: 1px solid ${borderColor};
position: relative;
border-radius: 3px;
height: 100%;
display: flex;
flex-direction: column;
flex: 0 0 0;
`,
content: css`
label: panel-content;
width: 100%;
flex-grow: 1;
`,
header: css`
label: panel-header;
display: flex;
align-items: center;
`,
headerTitle: css`
label: panel-header;
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
padding-left: ${theme.spacing(padding)};
flex-grow: 1;
font-weight: ${theme.typography.fontWeightMedium};
`,
leftItems: css`
display: flex;
padding-right: ${theme.spacing(padding)};
`,
container: css({
label: 'panel-container',
backgroundColor: background,
border: `1px solid ${borderColor}`,
position: 'relative',
borderRadius: '3px',
height: '100%',
display: 'flex',
flexDirection: 'column',
flex: '0 0 0',
'&:focus-visible, &:hover': {
// only show menu icon on hover or focused panel
'.menu-icon': {
visibility: 'visible',
},
},
'&:focus-visible': {
outline: `1px solid ${theme.colors.action.focus}`,
},
}),
content: css({
label: 'panel-content',
width: '100%',
contain: 'strict',
flexGrow: 1,
}),
headerContainer: css({
label: 'panel-header',
display: 'flex',
alignItems: 'center',
padding: `0 ${theme.spacing(padding)}`,
}),
title: css({
textOverflow: 'ellipsis',
overflow: 'hidden',
whiteSpace: 'nowrap',
fontWeight: theme.typography.fontWeightMedium,
}),
items: css({
display: 'flex',
}),
item: css({
display: 'flex',
justifyContent: 'space-around',
alignItems: 'center',
}),
menuItem: css({
visibility: 'hidden',
}),
rightAligned: css({
marginLeft: 'auto',
}),
};
};