diff --git a/packages/grafana-ui/src/components/Dropdown/Dropdown.tsx b/packages/grafana-ui/src/components/Dropdown/Dropdown.tsx index 01bdef10bec..4bc664e75aa 100644 --- a/packages/grafana-ui/src/components/Dropdown/Dropdown.tsx +++ b/packages/grafana-ui/src/components/Dropdown/Dropdown.tsx @@ -1,6 +1,6 @@ import { css } from '@emotion/css'; import { FocusScope } from '@react-aria/focus'; -import React, { useRef, useState } from 'react'; +import React, { useEffect, useRef, useState } from 'react'; import { usePopperTooltip } from 'react-popper-tooltip'; import { CSSTransition } from 'react-transition-group'; @@ -12,12 +12,19 @@ export interface Props { overlay: React.ReactElement | (() => React.ReactElement); placement?: TooltipPlacement; children: React.ReactElement | ((isOpen: boolean) => React.ReactElement); + /** Amount in pixels to nudge the dropdown vertically and horizontally, respectively. */ + offset?: [number, number]; + onVisibleChange?: (state: boolean) => void; } -export const Dropdown = React.memo(({ children, overlay, placement }: Props) => { +export const Dropdown = React.memo(({ children, overlay, placement, offset, onVisibleChange }: Props) => { const [show, setShow] = useState(false); const transitionRef = useRef(null); + useEffect(() => { + onVisibleChange?.(show); + }, [onVisibleChange, show]); + const { getArrowProps, getTooltipProps, setTooltipRef, setTriggerRef, visible } = usePopperTooltip({ visible: show, placement: placement, @@ -25,7 +32,7 @@ export const Dropdown = React.memo(({ children, overlay, placement }: Props) => interactive: true, delayHide: 0, delayShow: 0, - offset: [0, 8], + offset: offset ?? [0, 8], trigger: ['click'], }); diff --git a/packages/grafana-ui/src/components/PanelChrome/HoverWidget.tsx b/packages/grafana-ui/src/components/PanelChrome/HoverWidget.tsx new file mode 100644 index 00000000000..e2c61f98f7e --- /dev/null +++ b/packages/grafana-ui/src/components/PanelChrome/HoverWidget.tsx @@ -0,0 +1,108 @@ +import { css } from '@emotion/css'; +import classnames from 'classnames'; +import React, { ReactElement, useCallback, useRef, useState } from 'react'; + +import { GrafanaTheme2 } from '@grafana/data'; + +import { useStyles2 } from '../../themes'; +import { Icon } from '../Icon/Icon'; + +import { PanelMenu } from './PanelMenu'; + +interface Props { + children?: React.ReactNode; + menu: ReactElement | (() => ReactElement); + title?: string; + offset?: number; + dragClass?: string; +} + +export function HoverWidget({ menu, title, dragClass, children, offset = -32 }: Props) { + const styles = useStyles2(getStyles); + const draggableRef = useRef(null); + + // Capture the pointer to keep the widget visible while dragging + const onPointerDown = useCallback((e: React.PointerEvent) => { + draggableRef.current?.setPointerCapture(e.pointerId); + }, []); + + const onPointerUp = useCallback((e: React.PointerEvent) => { + draggableRef.current?.releasePointerCapture(e.pointerId); + }, []); + + const [menuOpen, setMenuOpen] = useState(false); + + if (children === undefined || React.Children.count(children) === 0) { + return null; + } + + return ( +
+
+ +
+ {children} +
+ +
+
+ ); +} + +function getStyles(theme: GrafanaTheme2) { + return { + hidden: css({ + visibility: 'hidden', + opacity: '0', + }), + container: css({ + label: 'hover-container-widget', + transition: `all .1s linear`, + display: 'flex', + position: 'absolute', + zIndex: 1, + boxSizing: 'border-box', + alignItems: 'center', + background: theme.colors.background.secondary, + color: theme.colors.text.primary, + border: `1px solid ${theme.colors.border.weak}`, + borderRadius: '1px', + height: theme.spacing(4), + boxShadow: theme.shadows.z1, + }), + square: css({ + display: 'flex', + justifyContent: 'center', + alignItems: 'center', + width: theme.spacing(4), + height: '100%', + }), + draggable: css({ + cursor: 'move', + }), + menuButton: css({ + color: theme.colors.text.primary, + '&:hover': { + background: 'inherit', + }, + }), + title: css({ + padding: theme.spacing(0.75), + }), + }; +} diff --git a/packages/grafana-ui/src/components/PanelChrome/PanelChrome.test.tsx b/packages/grafana-ui/src/components/PanelChrome/PanelChrome.test.tsx index 68eaeebc3f9..5fb4e4e072f 100644 --- a/packages/grafana-ui/src/components/PanelChrome/PanelChrome.test.tsx +++ b/packages/grafana-ui/src/components/PanelChrome/PanelChrome.test.tsx @@ -60,22 +60,12 @@ it('renders panel with a header if prop leftItems', () => { expect(screen.getByTestId('header-container')).toBeInTheDocument(); }); -// todo implement when hoverHeader is implemented -it.skip('renders panel without header if no title, no leftItems, and hoverHeader is undefined', () => { - setup(); - - expect(screen.getByTestId('header-container')).toBeInTheDocument(); +it('renders panel with hover header if no title, no leftItems, hoverHeader is undefined but menu is present', () => { + setup({ title: '', leftItems: undefined, hoverHeader: undefined, menu:
Menu
}); + expect(screen.getByTestId('hover-header-container')).toBeInTheDocument(); }); -// todo implement when hoverHeader is implemented -it.skip('renders panel with a fixed header if prop hoverHeader is false', () => { - setup({ hoverHeader: false }); - - expect(screen.getByTestId('header-container')).toBeInTheDocument(); -}); - -// todo implement when hoverHeader is implemented -it.skip('renders panel with a hovering header if prop hoverHeader is true', () => { +it('renders panel with a hovering header if prop hoverHeader is true', () => { setup({ title: 'Test Panel Header', hoverHeader: true }); expect(screen.queryByTestId('header-container')).not.toBeInTheDocument(); @@ -97,10 +87,10 @@ 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 header if prop menu', () => { - setup({ menu:
Menu
}); +it('renders panel with a hover header if prop menu is present and hoverHeader is false', () => { + setup({ menu:
Menu
, hoverHeader: false }); - expect(screen.getByTestId('header-container')).toBeInTheDocument(); + expect(screen.getByTestId('hover-header-container')).toBeInTheDocument(); }); it('renders panel with a show-on-hover menu icon if prop menu', () => { diff --git a/packages/grafana-ui/src/components/PanelChrome/PanelChrome.tsx b/packages/grafana-ui/src/components/PanelChrome/PanelChrome.tsx index 85f9ec0b829..e4d8fabf324 100644 --- a/packages/grafana-ui/src/components/PanelChrome/PanelChrome.tsx +++ b/packages/grafana-ui/src/components/PanelChrome/PanelChrome.tsx @@ -5,13 +5,13 @@ import { GrafanaTheme2, LoadingState } from '@grafana/data'; import { selectors } from '@grafana/e2e-selectors'; import { useStyles2, useTheme2 } from '../../themes'; -import { Dropdown } from '../Dropdown/Dropdown'; import { Icon } from '../Icon/Icon'; import { LoadingBar } from '../LoadingBar/LoadingBar'; -import { ToolbarButton } from '../ToolbarButton'; import { Tooltip } from '../Tooltip'; +import { HoverWidget } from './HoverWidget'; import { PanelDescription } from './PanelDescription'; +import { PanelMenu } from './PanelMenu'; import { PanelStatus } from './PanelStatus'; import { TitleItem } from './TitleItem'; @@ -23,9 +23,10 @@ export interface PanelChromeProps { height: number; children: (innerWidth: number, innerHeight: number) => ReactNode; padding?: PanelPadding; + hoverHeaderOffset?: number; title?: string; description?: string | (() => string); - titleItems?: ReactNode[]; + titleItems?: ReactNode; menu?: ReactElement | (() => ReactElement); dragClass?: string; dragClassCancel?: string; @@ -70,11 +71,12 @@ export function PanelChrome({ title = '', description = '', displayMode = 'default', - titleItems = [], + titleItems, menu, dragClass, dragClassCancel, hoverHeader = false, + hoverHeaderOffset, loadingState, statusMessage, statusMessageOnClick, @@ -91,7 +93,7 @@ export function PanelChrome({ const hasHeader = hoverHeader === false && (title.length > 0 || - titleItems.length > 0 || + titleItems !== undefined || description !== '' || loadingState === LoadingState.Streaming || (leftItems?.length ?? 0) > 0); @@ -105,71 +107,83 @@ export function PanelChrome({ }; const containerStyles: CSSProperties = { width, height }; - const ariaLabel = title ? selectors.components.Panels.Panel.containerByTitle(title) : 'Panel'; - if (displayMode === 'transparent') { containerStyles.backgroundColor = 'transparent'; containerStyles.border = 'none'; } + const ariaLabel = title ? selectors.components.Panels.Panel.containerByTitle(title) : 'Panel'; + + const headerContent = ( + <> + {title && ( +
+ {title} +
+ )} + + + + {titleItems !== undefined && ( +
+ {titleItems} +
+ )} + + {loadingState === LoadingState.Streaming && ( + + + + + + )} + + ); + return ( -
+
{loadingState === LoadingState.Loading ? ( ) : null}
-
- {title && ( -
- {title} -
- )} + {(hoverHeader || !hasHeader) && menu && ( + + {headerContent} + + )} - + {hasHeader && ( +
+ {headerContent} - {titleItems.length > 0 && ( -
- {titleItems.map((item) => item)} -
- )} - - {loadingState === LoadingState.Streaming && ( - - - - - - )} - -
- {menu && ( - - + {menu && ( + - - )} + )} - {leftItems &&
{itemsRenderer(leftItems, (item) => item)}
} + {leftItems &&
{itemsRenderer(leftItems, (item) => item)}
} +
+ )} - {statusMessage && ( - - )} -
+ {statusMessage && ( + + )}
{children(innerWidth, innerHeight)} @@ -228,10 +242,15 @@ const getStyles = (theme: GrafanaTheme2) => { flexDirection: 'column', flex: '1 1 0', - '&:focus-within, &:hover': { + '.show-on-hover': { + visibility: 'hidden', + opacity: '0', + }, + '&:focus-visible, &:hover': { // only show menu icon on hover or focused panel - '.menu-icon': { + '.show-on-hover': { visibility: 'visible', + opacity: '1', }, }, @@ -239,6 +258,14 @@ const getStyles = (theme: GrafanaTheme2) => { outline: `1px solid ${theme.colors.action.focus}`, }, }), + regularHeader: css({ + '&:focus-within': { + '.show-on-hover': { + visibility: 'visible', + opacity: '1', + }, + }, + }), loadingBarContainer: css({ label: 'panel-loading-bar-container', position: 'absolute', @@ -273,6 +300,7 @@ const getStyles = (theme: GrafanaTheme2) => { textOverflow: 'ellipsis', overflow: 'hidden', whiteSpace: 'nowrap', + maxWidth: theme.spacing(50), fontSize: theme.typography.h6.fontSize, fontWeight: theme.typography.h6.fontWeight, }), @@ -294,6 +322,10 @@ const getStyles = (theme: GrafanaTheme2) => { position: 'absolute', left: '50%', transform: 'translateX(-50%)', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + zIndex: theme.zIndex.tooltip, }), leftItems: css({ display: 'flex', @@ -307,6 +339,7 @@ const getStyles = (theme: GrafanaTheme2) => { }), titleItems: css({ display: 'flex', + height: '100%', }), }; }; diff --git a/packages/grafana-ui/src/components/PanelChrome/PanelDescription.tsx b/packages/grafana-ui/src/components/PanelChrome/PanelDescription.tsx index 256a02ee884..d5030e70c4e 100644 --- a/packages/grafana-ui/src/components/PanelChrome/PanelDescription.tsx +++ b/packages/grafana-ui/src/components/PanelChrome/PanelDescription.tsx @@ -1,6 +1,9 @@ import { css, cx } from '@emotion/css'; import React from 'react'; +import { GrafanaTheme2 } from '@grafana/data'; + +import { useStyles2 } from '../../themes'; import { Icon } from '../Icon/Icon'; import { Tooltip } from '../Tooltip'; @@ -12,7 +15,7 @@ interface Props { } export function PanelDescription({ description, className }: Props) { - const styles = getStyles(); + const styles = useStyles2(getStyles); const getDescriptionContent = (): JSX.Element => { // description @@ -34,7 +37,7 @@ export function PanelDescription({ description, className }: Props) { ) : null; } -const getStyles = () => { +const getStyles = (theme: GrafanaTheme2) => { return { description: css({ code: { diff --git a/packages/grafana-ui/src/components/PanelChrome/PanelMenu.tsx b/packages/grafana-ui/src/components/PanelChrome/PanelMenu.tsx new file mode 100644 index 00000000000..e5ad7b1ee64 --- /dev/null +++ b/packages/grafana-ui/src/components/PanelChrome/PanelMenu.tsx @@ -0,0 +1,40 @@ +import { cx } from '@emotion/css'; +import React, { ReactElement } from 'react'; + +import { Dropdown } from '../Dropdown/Dropdown'; +import { ToolbarButton } from '../ToolbarButton'; +import { TooltipPlacement } from '../Tooltip'; + +interface PanelMenuProps { + menu: ReactElement | (() => ReactElement); + menuButtonClass?: string; + dragClassCancel?: string; + title?: string; + placement?: TooltipPlacement; + offset?: [number, number]; + onVisibleChange?: (state: boolean) => void; +} + +export function PanelMenu({ + menu, + title, + placement = 'bottom', + offset, + dragClassCancel, + menuButtonClass, + onVisibleChange, +}: PanelMenuProps) { + return ( + + + + ); +} diff --git a/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeaderMenu.tsx b/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeaderMenu.tsx index 16449d447c2..9fbea527b5b 100644 --- a/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeaderMenu.tsx +++ b/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeaderMenu.tsx @@ -1,3 +1,4 @@ +import classnames from 'classnames'; import React, { PureComponent } from 'react'; import { PanelMenuItem } from '@grafana/data'; @@ -7,13 +8,15 @@ import { PanelHeaderMenuItem } from './PanelHeaderMenuItem'; export interface Props { items: PanelMenuItem[]; style?: React.CSSProperties; + itemsClassName?: string; + className?: string; } export class PanelHeaderMenu extends PureComponent { renderItems = (menu: PanelMenuItem[], isSubMenu = false) => { return (
    @@ -36,6 +39,10 @@ export class PanelHeaderMenu extends PureComponent { }; render() { - return
    {this.renderItems(this.props.items)}
    ; + return ( +
    + {this.renderItems(this.props.items)} +
    + ); } } diff --git a/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeaderMenuWrapper.tsx b/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeaderMenuWrapper.tsx index 9f09720d676..f6ed09562ce 100644 --- a/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeaderMenuWrapper.tsx +++ b/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeaderMenuWrapper.tsx @@ -13,14 +13,28 @@ interface Props { loadingState?: LoadingState; onClose: () => void; style?: React.CSSProperties; + menuItemsClassName?: string; + menuWrapperClassName?: string; } -export function PanelHeaderMenuWrapper({ style, panel, dashboard, loadingState }: Props) { +export function PanelHeaderMenuWrapper({ + style, + panel, + dashboard, + loadingState, + menuItemsClassName, + menuWrapperClassName, +}: Props) { return ( - {({ items }) => { - return ; - }} + {({ items }) => ( + + )} ); } diff --git a/public/app/features/dashboard/dashgrid/PanelLinks.tsx b/public/app/features/dashboard/dashgrid/PanelLinks.tsx index b56672787ed..fd9916608e1 100644 --- a/public/app/features/dashboard/dashgrid/PanelLinks.tsx +++ b/public/app/features/dashboard/dashgrid/PanelLinks.tsx @@ -47,6 +47,8 @@ export function PanelLinks({ panelLinks, onShowPanelLinks }: Props) { const getStyles = (theme: GrafanaTheme2) => { return { menuTrigger: css({ + height: '100%', + background: 'inherit', border: 'none', borderRadius: `${theme.shape.borderRadius()}`, cursor: 'context-menu', diff --git a/public/app/features/dashboard/dashgrid/PanelStateWrapper.tsx b/public/app/features/dashboard/dashgrid/PanelStateWrapper.tsx index 03d80ac3a0c..e49911328ac 100644 --- a/public/app/features/dashboard/dashgrid/PanelStateWrapper.tsx +++ b/public/app/features/dashboard/dashgrid/PanelStateWrapper.tsx @@ -1,3 +1,4 @@ +import { css } from '@emotion/css'; import classNames from 'classnames'; import React, { PureComponent } from 'react'; import { Subscription } from 'rxjs'; @@ -636,9 +637,12 @@ export class PanelStateWrapper extends PureComponent { const title = panel.getDisplayTitle(); const padding: PanelPadding = plugin.noPadding ? 'none' : 'md'; - const dragClass = !(isViewing || isEditing) ? 'grid-drag-handle' : ''; - - const titleItems = [ + const showTitleItems = + (panel.links && panel.links.length > 0 && this.onShowPanelLinks) || + (data.series.length > 0 && data.series.some((v) => (v.meta?.notices?.length ?? 0) > 0)) || + (data.request && data.request.timeInfo) || + alertState; + const titleItems = showTitleItems && ( { panelId={panel.id} panelLinks={panel.links} onShowPanelLinks={this.onShowPanelLinks} - />, - ]; + /> + ); + const overrideStyles: { menuItemsClassName?: string; menuWrapperClassName?: string; pos?: React.CSSProperties } = { + menuItemsClassName: undefined, + menuWrapperClassName: undefined, + pos: { top: 0, left: '-156px' }, + }; + + if (config.featureToggles.newPanelChromeUI) { + // set override styles + overrideStyles.menuItemsClassName = css` + width: inherit; + top: inherit; + left: inherit; + position: inherit; + float: inherit; + `; + overrideStyles.menuWrapperClassName = css` + position: inherit; + width: inherit; + top: inherit; + left: inherit; + float: inherit; + .dropdown-submenu > .dropdown-menu { + position: absolute; + } + `; + overrideStyles.pos = undefined; + } + + // custom styles is neeeded to override legacy panel-menu styles and prevent menu from being cut off let menu; if (!dashboard.meta.publicDashboardAccessToken) { menu = (
    {}} + menuItemsClassName={overrideStyles.menuItemsClassName} + menuWrapperClassName={overrideStyles.menuWrapperClassName} />
    ); } + const dragClass = !(isViewing || isEditing) ? 'grid-drag-handle' : ''; if (config.featureToggles.newPanelChromeUI) { + // Shift the hover menu down if it's on the top row so it doesn't get clipped by topnav + const hoverHeaderOffset = (panel.gridPos?.y ?? 0) === 0 ? -16 : undefined; + return ( { dragClass={dragClass} dragClassCancel="grid-drag-cancel" padding={padding} + hoverHeaderOffset={hoverHeaderOffset} + hoverHeader={title ? false : true} displayMode={transparent ? 'transparent' : 'default'} > {(innerWidth, innerHeight) => ( diff --git a/public/app/features/dashboard/state/PanelModel.ts b/public/app/features/dashboard/state/PanelModel.ts index d55fba4feaf..769e00ff2c5 100644 --- a/public/app/features/dashboard/state/PanelModel.ts +++ b/public/app/features/dashboard/state/PanelModel.ts @@ -339,6 +339,9 @@ export class PanelModel implements DataConfigSource, IPanelModel { if (manuallyUpdated) { this.configRev++; } + + // Maybe a bit heavy. Could add a "GridPosChanged" event instead? + this.render(); } runAllPanelQueries({