From 314c22bc5b01fa265eb228fe0e50b04fe753f354 Mon Sep 17 00:00:00 2001 From: Polina Boneva <13227501+polibb@users.noreply.github.com> Date: Thu, 24 Nov 2022 13:21:18 +0200 Subject: [PATCH] PanelChrome: Implementing the new layout on PanelChrome @ grafana/ui (#57203) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 --- .../grafana-ui/src/components/Menu/Menu.tsx | 22 +- .../components/PanelChrome/ErrorIndicator.tsx | 12 +- .../PanelChrome/LoadingIndicator.tsx | 6 +- .../PanelChrome/PanelChrome.story.tsx | 165 ++++++++++----- .../PanelChrome/PanelChrome.test.tsx | 107 ++++++++++ .../components/PanelChrome/PanelChrome.tsx | 199 +++++++++++++----- 6 files changed, 391 insertions(+), 120 deletions(-) create mode 100644 packages/grafana-ui/src/components/PanelChrome/PanelChrome.test.tsx diff --git a/packages/grafana-ui/src/components/Menu/Menu.tsx b/packages/grafana-ui/src/components/Menu/Menu.tsx index 114f7221a29..b2b159b4d1d 100644 --- a/packages/grafana-ui/src/components/Menu/Menu.tsx +++ b/packages/grafana-ui/src/components/Menu/Menu.tsx @@ -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)}`, + }), }; }; diff --git a/packages/grafana-ui/src/components/PanelChrome/ErrorIndicator.tsx b/packages/grafana-ui/src/components/PanelChrome/ErrorIndicator.tsx index bdf721f621f..d18e460b6e8 100644 --- a/packages/grafana-ui/src/components/PanelChrome/ErrorIndicator.tsx +++ b/packages/grafana-ui/src/components/PanelChrome/ErrorIndicator.tsx @@ -38,11 +38,11 @@ export const ErrorIndicator: React.FC = ({ error, onClick } const getStyles = () => { return { - clickable: css` - cursor: pointer; - `, - icon: css` - color: ${commonColorsPalette.red88}; - `, + clickable: css({ + cursor: 'pointer', + }), + icon: css({ + color: `${commonColorsPalette.red88}`, + }), }; }; diff --git a/packages/grafana-ui/src/components/PanelChrome/LoadingIndicator.tsx b/packages/grafana-ui/src/components/PanelChrome/LoadingIndicator.tsx index a47b08a4a9a..610e0382fc8 100644 --- a/packages/grafana-ui/src/components/PanelChrome/LoadingIndicator.tsx +++ b/packages/grafana-ui/src/components/PanelChrome/LoadingIndicator.tsx @@ -40,8 +40,8 @@ export const LoadingIndicator: React.FC = ({ onCancel, lo const getStyles = () => { return { - clickable: css` - cursor: pointer; - `, + clickable: css({ + cursor: 'pointer', + }), }; }; diff --git a/packages/grafana-ui/src/components/PanelChrome/PanelChrome.story.tsx b/packages/grafana-ui/src/components/PanelChrome/PanelChrome.story.tsx index 6d98df8311b..7e5247cb66d 100644 --- a/packages/grafana-ui/src/components/PanelChrome/PanelChrome.story.tsx +++ b/packages/grafana-ui/src/components/PanelChrome/PanelChrome.story.tsx @@ -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 = { title: 'Visualizations/PanelChrome', @@ -22,17 +25,16 @@ const meta: ComponentMeta = { }, }; -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, theme: GrafanaTheme2) { +function renderPanel(name: string, overrides: Partial) { const props: PanelChromeProps = { width: 400, height: 130, @@ -42,7 +44,7 @@ function renderPanel(name: string, overrides: Partial, theme: merge(props, overrides); - const contentStyle = getContentStyle(theme); + const contentStyle = getContentStyle(); return ( @@ -54,69 +56,75 @@ function renderPanel(name: string, overrides: Partial, theme: } export const Examples = () => { - const theme = useTheme2(); const [loading, setLoading] = useState(true); useInterval(() => setLoading(true), 5000); return ( -
- + + - {renderPanel('Default panel', {}, theme)} - {renderPanel('No padding', { padding: 'none' }, theme)} + {renderPanel('Default panel with error state indicator', { + title: 'Default title', + leftItems: [ + , + ], + })} + {renderPanel('No padding with error state indicator', { + padding: 'none', + title: 'Default title', + leftItems: [ + , + ], + })} - {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', + })} -
- {renderPanel( - 'No title and loading indicator', - { - title: '', - leftItems: [ - setLoading(false)} - key="loading-indicator" - />, - ], - }, - theme - )} + {renderPanel('No title and loading indicator', { + title: '', + leftItems: [ + setLoading(false)} + key="loading-indicator" + />, + ], + })} - {renderPanel( - 'Very long title', - { - title: 'Very long title that should get ellipsis when there is no more space', - leftItems: [ - 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: [ + setLoading(false)} + key="loading-indicator" + />, + ], + })} -
+
); }; export const Basic: ComponentStory = (args: PanelChromeProps) => { - const theme = useTheme2(); - const contentStyle = getContentStyle(theme); + const contentStyle = getContentStyle(); return ( @@ -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 = ( + + + + + + , + , + , + ]} + /> + , + , + , + , + , + ]} + /> + + + +); + 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; diff --git a/packages/grafana-ui/src/components/PanelChrome/PanelChrome.test.tsx b/packages/grafana-ui/src/components/PanelChrome/PanelChrome.test.tsx new file mode 100644 index 00000000000..35508f8e3c7 --- /dev/null +++ b/packages/grafana-ui/src/components/PanelChrome/PanelChrome.test.tsx @@ -0,0 +1,107 @@ +import { screen, render } from '@testing-library/react'; +import React from 'react'; + +import { PanelChrome, PanelChromeProps } from './PanelChrome'; + +const setup = (propOverrides?: Partial) => { + const props: PanelChromeProps = { + width: 100, + height: 100, + children: (innerWidth, innerHeight) => { + return
Panel's Content
; + }, + }; + + Object.assign(props, propOverrides); + return render(); +}; + +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:
Menu
}); + + expect(screen.getByTestId('header-container')).toBeInTheDocument(); +}); + +it('renders panel with a show-on-hover menu icon if prop menu', () => { + setup({ menu:
Menu
}); + + 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', () => {}); diff --git a/packages/grafana-ui/src/components/PanelChrome/PanelChrome.tsx b/packages/grafana-ui/src/components/PanelChrome/PanelChrome.tsx index 02875a6c2ea..685261d1870 100644 --- a/packages/grafana-ui/src/components/PanelChrome/PanelChrome.tsx +++ b/packages/grafana-ui/src/components/PanelChrome/PanelChrome.tsx @@ -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 = ({ - 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 (
-
-
{title}
- {itemsRenderer(leftItems, (items) => { - return
{items}
; - })} -
+ {hasHeader && !hoverHeader && ( +
+ {title && ( +
+ {title} +
+ )} + + {titleItems.length > 0 && ( +
+ {titleItems + .filter((item) => isIconName(item.icon)) + .map((item, i) => ( +
+ {item.onClick ? ( + + ) : ( + + + + )} +
+ ))} +
+ )} + + {menu && ( + +
+ +
+
+ )} + + {leftItems.length > 0 && ( +
{itemsRenderer(leftItems, (item) => item)}
+ )} +
+ )} +
{children(innerWidth, innerHeight)}
@@ -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', + }), }; };