From 84eb275c8d5e9dce4cca070e3a1bd025cfc698fa Mon Sep 17 00:00:00 2001 From: Polina Boneva <13227501+polibb@users.noreply.github.com> Date: Thu, 12 Jan 2023 11:10:09 +0200 Subject: [PATCH] PanelChrome: Menu is wrapped in a render prop for full outside control (#60537) * setup menu as a render prop sent down from PanelStateWrapper to PanelChrome * let the Dropdown take care of opening the menu in PanelChrome * menu and leftItems are on the right side of the header together * add storybook examples with menu * menu does not need to be a callback because it's opened in a Dropdown anyway * pass down to getPanelMenu whether or not data is streaming atm * stop loading data as well as streaming from menu * override menu's style where needed * reduce snapshot matching in tests --- .../src/components/Dropdown/Dropdown.tsx | 23 +- .../PanelChrome/PanelChrome.story.tsx | 138 ++++++++--- .../components/PanelChrome/PanelChrome.tsx | 45 ++-- .../dashgrid/PanelHeader/PanelHeader.tsx | 6 +- .../dashgrid/PanelHeader/PanelHeaderMenu.tsx | 7 +- .../PanelHeader/PanelHeaderMenuProvider.tsx | 13 +- .../PanelHeader/PanelHeaderMenuTrigger.tsx | 4 +- .../PanelHeader/PanelHeaderMenuWrapper.tsx | 14 +- .../dashboard/dashgrid/PanelStateWrapper.tsx | 17 ++ .../dashboard/utils/getPanelMenu.test.ts | 227 ++++++++++-------- .../features/dashboard/utils/getPanelMenu.ts | 19 ++ 11 files changed, 328 insertions(+), 185 deletions(-) diff --git a/packages/grafana-ui/src/components/Dropdown/Dropdown.tsx b/packages/grafana-ui/src/components/Dropdown/Dropdown.tsx index bd96418eb51..b714d3dfb5d 100644 --- a/packages/grafana-ui/src/components/Dropdown/Dropdown.tsx +++ b/packages/grafana-ui/src/components/Dropdown/Dropdown.tsx @@ -72,17 +72,16 @@ Dropdown.displayName = 'Dropdown'; const getStyles = (duration: number) => { return { - appear: css` - opacity: 0; - position: relative; - transform: scaleY(0.5); - transform-origin: top; - `, - appearActive: css` - opacity: 1; - transform: scaleY(1); - transition: transform ${duration}ms cubic-bezier(0.2, 0, 0.2, 1), - opacity ${duration}ms cubic-bezier(0.2, 0, 0.2, 1); - `, + appear: css({ + opacity: '0', + position: 'relative', + transform: 'scaleY(0.5)', + transformOrigin: 'top', + }), + appearActive: css({ + opacity: '1', + transform: 'scaleY(1)', + transition: `transform ${duration}ms cubic-bezier(0.2, 0, 0.2, 1), opacity ${duration}ms cubic-bezier(0.2, 0, 0.2, 1)`, + }), }; }; diff --git a/packages/grafana-ui/src/components/PanelChrome/PanelChrome.story.tsx b/packages/grafana-ui/src/components/PanelChrome/PanelChrome.story.tsx index ac658843e90..ab52fc5b442 100644 --- a/packages/grafana-ui/src/components/PanelChrome/PanelChrome.story.tsx +++ b/packages/grafana-ui/src/components/PanelChrome/PanelChrome.story.tsx @@ -35,11 +35,10 @@ function getContentStyle(): CSSProperties { }; } -function renderPanel(name: string, overrides: Partial) { +function renderPanel(name: string, overrides?: Partial) { const props: PanelChromeProps = { width: 400, height: 130, - title: 'Default title', children: () => undefined, }; @@ -56,6 +55,37 @@ function renderPanel(name: string, overrides: Partial) { ); } +const menu = ( + + + + + + , + , + , + ]} + /> + , + , + , + , + , + ]} + /> + + + +); + export const Examples = () => { const [loading, setLoading] = useState(true); @@ -65,33 +95,81 @@ export const Examples = () => { - {renderPanel('Default panel with error status', { + {renderPanel('Error status', { title: 'Default title', status: { message: 'Error text', onClick: action('ErrorIndicator: onClick fired'), }, })} - {renderPanel('No padding with error state', { + {renderPanel('No padding, error loadingState', { padding: 'none', title: 'Default title', loadingState: LoadingState.Error, })} - {renderPanel('Default panel with streaming state', { + {renderPanel('No title, error loadingState', { + loadingState: LoadingState.Error, + })} + {renderPanel('Streaming loadingState', { title: 'Default title', loadingState: LoadingState.Streaming, })} + + {renderPanel('Loading loadingState', { + title: 'Default title', + loadingState: LoadingState.Loading, + })} - {renderPanel('No title', { title: '' })} + {renderPanel('Default panel: no non-required props')} + {renderPanel('No padding, no title', { + padding: 'none', + })} {renderPanel('Very long title', { title: 'Very long title that should get ellipsis when there is no more space', })} + {renderPanel('No title, streaming loadingState', { + loadingState: LoadingState.Streaming, + })} + {renderPanel('No title, loading loadingState', { + loadingState: LoadingState.Loading, + })} + + + {renderPanel('Error status, menu', { + title: 'Default title', + menu, + status: { + message: 'Error text', + onClick: action('ErrorIndicator: onClick fired'), + }, + })} + {renderPanel('No padding, error loadingState, menu', { + padding: 'none', + title: 'Default title', + menu, + loadingState: LoadingState.Error, + })} + {renderPanel('No title, error loadingState, menu', { + menu, + loadingState: LoadingState.Error, + })} + {renderPanel('Streaming loadingState, menu', { + title: 'Default title', + menu, + loadingState: LoadingState.Streaming, + })} + + {renderPanel('Loading loadingState, menu', { + title: 'Default title', + menu, + loadingState: LoadingState.Loading, + })} - {renderPanel('Default panel with deprecated error indicator', { + {renderPanel('Deprecated error indicator', { title: 'Default title', leftItems: [ { />, ], })} - {renderPanel('No padding with deprecated loading indicator', { + {renderPanel('No padding, deprecated loading indicator', { padding: 'none', title: 'Default title', leftItems: [ @@ -113,6 +191,19 @@ export const Examples = () => { ], })} + + {renderPanel('Deprecated error indicator, menu', { + title: 'Default title', + menu, + leftItems: [ + , + ], + })} + ); @@ -166,37 +257,6 @@ const titleItems: PanelChromeInfoState[] = [ }, ]; -const menu = ( - - - - - - , - , - , - ]} - /> - , - , - , - , - , - ]} - /> - - - -); - Basic.argTypes = { leftItems: { options: Object.keys(leftItems), diff --git a/packages/grafana-ui/src/components/PanelChrome/PanelChrome.tsx b/packages/grafana-ui/src/components/PanelChrome/PanelChrome.tsx index 332c9ab98a7..5bda625b9ef 100644 --- a/packages/grafana-ui/src/components/PanelChrome/PanelChrome.tsx +++ b/packages/grafana-ui/src/components/PanelChrome/PanelChrome.tsx @@ -40,7 +40,7 @@ export interface PanelChromeProps { padding?: PanelPadding; title?: string; titleItems?: PanelChromeInfoState[]; - menu?: ReactElement; + menu?: ReactElement | (() => ReactElement); /** dragClass, hoverHeader not yet implemented */ // dragClass?: string; hoverHeader?: boolean; @@ -100,15 +100,15 @@ export function PanelChrome({ const showStreaming = loadingState === LoadingState.Streaming && !isUsingDeprecatedLeftItems; const renderStatus = () => { - if (isUsingDeprecatedLeftItems) { - return
{itemsRenderer(leftItems, (item) => item)}
; - } else { - const showError = loadingState === LoadingState.Error || status?.message; - return showError ? ( + const showError = loadingState === LoadingState.Error || status?.message; + if (!isUsingDeprecatedLeftItems && showError) { + return (
- ) : null; + ); + } else { + return null; } }; return ( @@ -150,18 +150,22 @@ export function PanelChrome({ )} - {menu && ( - -
- -
-
- )} +
+ {menu && ( + +
+ +
+
+ )} + + {isUsingDeprecatedLeftItems &&
{itemsRenderer(leftItems, (item) => item)}
} +
{renderStatus()} @@ -286,7 +290,10 @@ const getStyles = (theme: GrafanaTheme2) => { justifyContent: 'center', }), rightAligned: css({ + label: 'right-aligned-container', marginLeft: 'auto', + display: 'flex', + alignItems: 'center', }), }; }; diff --git a/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeader.tsx b/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeader.tsx index c5bf73cf32b..1d2967d6509 100644 --- a/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeader.tsx +++ b/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeader.tsx @@ -1,5 +1,5 @@ import { css, cx } from '@emotion/css'; -import React, { FC } from 'react'; +import React from 'react'; import { DataLink, GrafanaTheme2, PanelData } from '@grafana/data'; import { selectors } from '@grafana/e2e-selectors'; @@ -27,7 +27,7 @@ export interface Props { data: PanelData; } -export const PanelHeader: FC = ({ panel, error, isViewing, isEditing, data, alertState, dashboard }) => { +export function PanelHeader({ panel, error, isViewing, isEditing, data, alertState, dashboard }: Props) { const onCancelQuery = () => panel.getQueryRunner().cancelQuery(); const title = panel.getDisplayTitle(); const className = cx('panel-header', !(isViewing || isEditing) ? 'grid-drag-handle' : ''); @@ -81,7 +81,7 @@ export const PanelHeader: FC = ({ panel, error, isViewing, isEditing, dat ); -}; +} const panelStyles = (theme: GrafanaTheme2) => { return { diff --git a/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeaderMenu.tsx b/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeaderMenu.tsx index af37c1b6dbc..16449d447c2 100644 --- a/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeaderMenu.tsx +++ b/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeaderMenu.tsx @@ -6,12 +6,17 @@ import { PanelHeaderMenuItem } from './PanelHeaderMenuItem'; export interface Props { items: PanelMenuItem[]; + style?: React.CSSProperties; } export class PanelHeaderMenu extends PureComponent { renderItems = (menu: PanelMenuItem[], isSubMenu = false) => { return ( -
    +
      {menu.map((menuItem, idx: number) => { return ( ReactElement; } -export const PanelHeaderMenuProvider: FC = ({ panel, dashboard, children }) => { +export function PanelHeaderMenuProvider({ panel, dashboard, loadingState, children }: Props) { const [items, setItems] = useState([]); const angularComponent = useSelector((state) => getPanelStateForModel(state, panel)?.angularComponent); useEffect(() => { - setItems(getPanelMenu(dashboard, panel, angularComponent)); - }, [dashboard, panel, angularComponent, setItems]); + setItems(getPanelMenu(dashboard, panel, loadingState, angularComponent)); + }, [dashboard, panel, angularComponent, loadingState, setItems]); return children({ items }); -}; +} diff --git a/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeaderMenuTrigger.tsx b/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeaderMenuTrigger.tsx index a00adc6be51..7b9bb06df6d 100644 --- a/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeaderMenuTrigger.tsx +++ b/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeaderMenuTrigger.tsx @@ -11,7 +11,7 @@ interface Props extends Omit, 'children'> { children: (props: PanelHeaderMenuTriggerApi) => ReactElement; } -export const PanelHeaderMenuTrigger = ({ children, ...divProps }: Props) => { +export function PanelHeaderMenuTrigger({ children, ...divProps }: Props) { const [clickCoordinates, setClickCoordinates] = useState({ x: 0, y: 0 }); const [panelMenuOpen, setPanelMenuOpen] = useState(false); @@ -38,7 +38,7 @@ export const PanelHeaderMenuTrigger = ({ children, ...divProps }: Props) => { {children({ panelMenuOpen, closeMenu: () => setPanelMenuOpen(false) })} ); -}; +} function isClick(current: CartesianCoords2D, clicked: CartesianCoords2D, deadZone = 3.5): boolean { // A "deadzone" radius is added so that if the cursor is moved within this radius diff --git a/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeaderMenuWrapper.tsx b/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeaderMenuWrapper.tsx index cc2ac9b90b8..9f09720d676 100644 --- a/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeaderMenuWrapper.tsx +++ b/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeaderMenuWrapper.tsx @@ -1,4 +1,6 @@ -import React, { FC } from 'react'; +import React from 'react'; + +import { LoadingState } from '@grafana/data'; import { DashboardModel, PanelModel } from '../../state'; @@ -8,15 +10,17 @@ import { PanelHeaderMenuProvider } from './PanelHeaderMenuProvider'; interface Props { panel: PanelModel; dashboard: DashboardModel; + loadingState?: LoadingState; onClose: () => void; + style?: React.CSSProperties; } -export const PanelHeaderMenuWrapper: FC = ({ panel, dashboard }) => { +export function PanelHeaderMenuWrapper({ style, panel, dashboard, loadingState }: Props) { return ( - + {({ items }) => { - return ; + return ; }} ); -}; +} diff --git a/public/app/features/dashboard/dashgrid/PanelStateWrapper.tsx b/public/app/features/dashboard/dashgrid/PanelStateWrapper.tsx index d0c6c30bbb0..a6aadfd2a05 100644 --- a/public/app/features/dashboard/dashgrid/PanelStateWrapper.tsx +++ b/public/app/features/dashboard/dashgrid/PanelStateWrapper.tsx @@ -46,6 +46,7 @@ import { DashboardModel, PanelModel } from '../state'; import { loadSnapshotData } from '../utils/loadSnapshotData'; import { PanelHeader } from './PanelHeader/PanelHeader'; +import { PanelHeaderMenuWrapper } from './PanelHeader/PanelHeaderMenuWrapper'; import { seriesVisibilityConfigFactory } from './SeriesVisibilityConfigFactory'; import { liveTimer } from './liveTimer'; @@ -589,6 +590,21 @@ export class PanelStateWrapper extends PureComponent { const title = panel.getDisplayTitle(); const padding: PanelPadding = plugin.noPadding ? 'none' : 'md'; + let menu; + if (!dashboard.meta.publicDashboardAccessToken) { + menu = ( +
      + {}} + /> +
      + ); + } + if (config.featureToggles.newPanelChromeUI) { return ( { height={height} padding={padding} title={title} + menu={menu} loadingState={data.state} status={{ message: errorMessage, diff --git a/public/app/features/dashboard/utils/getPanelMenu.test.ts b/public/app/features/dashboard/utils/getPanelMenu.test.ts index 950b9dfde35..a0023d3d8f6 100644 --- a/public/app/features/dashboard/utils/getPanelMenu.test.ts +++ b/public/app/features/dashboard/utils/getPanelMenu.test.ts @@ -1,4 +1,5 @@ import { PanelMenuItem } from '@grafana/data'; +import { LoadingState } from '@grafana/schema'; import config from 'app/core/config'; import * as actions from 'app/features/explore/state/main'; import { setStore } from 'app/store/store'; @@ -94,117 +95,147 @@ describe('getPanelMenu', () => { `); }); - describe('when panel is in view mode', () => { - it('should return the correct panel menu items', () => { - const getExtendedMenu = () => [{ text: 'Toggle legend', shortcut: 'p l', click: jest.fn() }]; - const ctrl: any = { getExtendedMenu }; - const scope: any = { $$childHead: { ctrl } }; - const angularComponent: any = { getScope: () => scope }; - const panel = new PanelModel({ isViewing: true }); - const dashboard = createDashboardModelFixture({}); + it('should return the correct panel menu items when data is streaming', () => { + const panel = new PanelModel({}); + const dashboard = createDashboardModelFixture({}); - const menuItems = getPanelMenu(dashboard, panel, angularComponent); - expect(menuItems).toMatchInlineSnapshot(` - [ - { - "iconClassName": "eye", - "onClick": [Function], - "shortcut": "v", - "text": "View", - }, - { - "iconClassName": "edit", - "onClick": [Function], - "shortcut": "e", - "text": "Edit", - }, - { - "iconClassName": "share-alt", - "onClick": [Function], - "shortcut": "p s", - "text": "Share", - }, - { - "iconClassName": "compass", - "onClick": [Function], - "shortcut": "x", - "text": "Explore", - }, - { - "iconClassName": "info-circle", - "onClick": [Function], - "shortcut": "i", - "subMenu": [ - { - "onClick": [Function], - "text": "Panel JSON", - }, - ], - "text": "Inspect", - "type": "submenu", - }, - { - "iconClassName": "cube", - "onClick": [Function], - "subMenu": [ - { - "href": undefined, - "onClick": [Function], - "shortcut": "p l", - "text": "Toggle legend", - }, - ], - "text": "More...", - "type": "submenu", - }, - ] - `); - }); + const menuItems = getPanelMenu(dashboard, panel, LoadingState.Streaming); + expect(menuItems).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + iconClassName: 'circle', + text: 'Stop query', + }), + ]) + ); }); - describe('onNavigateToExplore', () => { - const testSubUrl = '/testSubUrl'; - const testUrl = '/testUrl'; - const windowOpen = jest.fn(); - let event: any; - let explore: PanelMenuItem; - let navigateSpy: any; + it('should return the correct panel menu items when data is loading', () => { + const panel = new PanelModel({}); + const dashboard = createDashboardModelFixture({}); - beforeAll(() => { - const panel = new PanelModel({}); - const dashboard = createDashboardModelFixture({}); - const menuItems = getPanelMenu(dashboard, panel); - explore = menuItems.find((item) => item.text === 'Explore') as PanelMenuItem; - navigateSpy = jest.spyOn(actions, 'navigateToExplore'); - window.open = windowOpen; + const menuItems = getPanelMenu(dashboard, panel, LoadingState.Loading); + expect(menuItems).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + iconClassName: 'circle', + text: 'Stop query', + }), + ]) + ); + }); +}); - event = { - ctrlKey: true, - preventDefault: jest.fn(), - }; +describe('when panel is in view mode', () => { + it('should return the correct panel menu items', () => { + const getExtendedMenu = () => [{ text: 'Toggle legend', shortcut: 'p l', click: jest.fn() }]; + const ctrl: any = { getExtendedMenu }; + const scope: any = { $$childHead: { ctrl } }; + const angularComponent: any = { getScope: () => scope }; + const panel = new PanelModel({ isViewing: true }); + const dashboard = createDashboardModelFixture({}); - setStore({ dispatch: jest.fn() } as any); - }); + const menuItems = getPanelMenu(dashboard, panel, undefined, angularComponent); + expect(menuItems).toMatchInlineSnapshot(` + [ + { + "iconClassName": "eye", + "onClick": [Function], + "shortcut": "v", + "text": "View", + }, + { + "iconClassName": "edit", + "onClick": [Function], + "shortcut": "e", + "text": "Edit", + }, + { + "iconClassName": "share-alt", + "onClick": [Function], + "shortcut": "p s", + "text": "Share", + }, + { + "iconClassName": "compass", + "onClick": [Function], + "shortcut": "x", + "text": "Explore", + }, + { + "iconClassName": "info-circle", + "onClick": [Function], + "shortcut": "i", + "subMenu": [ + { + "onClick": [Function], + "text": "Panel JSON", + }, + ], + "text": "Inspect", + "type": "submenu", + }, + { + "iconClassName": "cube", + "onClick": [Function], + "subMenu": [ + { + "href": undefined, + "onClick": [Function], + "shortcut": "p l", + "text": "Toggle legend", + }, + ], + "text": "More...", + "type": "submenu", + }, + ] + `); + }); +}); - it('should navigate to url without subUrl', () => { - explore.onClick!(event); +describe('onNavigateToExplore', () => { + const testSubUrl = '/testSubUrl'; + const testUrl = '/testUrl'; + const windowOpen = jest.fn(); + let event: any; + let explore: PanelMenuItem; + let navigateSpy: any; - const openInNewWindow = navigateSpy.mock.calls[0][1].openInNewWindow; + beforeAll(() => { + const panel = new PanelModel({}); + const dashboard = createDashboardModelFixture({}); + const menuItems = getPanelMenu(dashboard, panel); + explore = menuItems.find((item) => item.text === 'Explore') as PanelMenuItem; + navigateSpy = jest.spyOn(actions, 'navigateToExplore'); + window.open = windowOpen; - openInNewWindow(testUrl); + event = { + ctrlKey: true, + preventDefault: jest.fn(), + }; - expect(windowOpen).toHaveBeenLastCalledWith(testUrl); - }); + setStore({ dispatch: jest.fn() } as any); + }); - it('should navigate to url with subUrl', () => { - config.appSubUrl = testSubUrl; - explore.onClick!(event); + it('should navigate to url without subUrl', () => { + explore.onClick!(event); - const openInNewWindow = navigateSpy.mock.calls[0][1].openInNewWindow; + const openInNewWindow = navigateSpy.mock.calls[0][1].openInNewWindow; - openInNewWindow(testUrl); + openInNewWindow(testUrl); - expect(windowOpen).toHaveBeenLastCalledWith(`${testSubUrl}${testUrl}`); - }); + expect(windowOpen).toHaveBeenLastCalledWith(testUrl); + }); + + it('should navigate to url with subUrl', () => { + config.appSubUrl = testSubUrl; + explore.onClick!(event); + + const openInNewWindow = navigateSpy.mock.calls[0][1].openInNewWindow; + + openInNewWindow(testUrl); + + expect(windowOpen).toHaveBeenLastCalledWith(`${testSubUrl}${testUrl}`); }); }); diff --git a/public/app/features/dashboard/utils/getPanelMenu.ts b/public/app/features/dashboard/utils/getPanelMenu.ts index 2be759fc7b5..5eb59c2f2e4 100644 --- a/public/app/features/dashboard/utils/getPanelMenu.ts +++ b/public/app/features/dashboard/utils/getPanelMenu.ts @@ -1,5 +1,6 @@ import { PanelMenuItem } from '@grafana/data'; import { AngularComponent, getDataSourceSrv, locationService, reportInteraction } from '@grafana/runtime'; +import { LoadingState } from '@grafana/schema'; import { PanelCtrl } from 'app/angular/panel/panel_ctrl'; import config from 'app/core/config'; import { t } from 'app/core/internationalization'; @@ -26,6 +27,7 @@ import { getTimeSrv } from '../services/TimeSrv'; export function getPanelMenu( dashboard: DashboardModel, panel: PanelModel, + loadingState?: LoadingState, angularComponent?: AngularComponent | null ): PanelMenuItem[] { const onViewPanel = (event: React.MouseEvent) => { @@ -98,6 +100,12 @@ export function getPanelMenu( event.preventDefault(); toggleLegend(panel); }; + + const onCancelStreaming = (event: React.MouseEvent) => { + event.preventDefault(); + panel.getQueryRunner().cancelQuery(); + }; + const menu: PanelMenuItem[] = []; if (!panel.isEditing) { @@ -119,6 +127,17 @@ export function getPanelMenu( }); } + if ( + dashboard.canEditPanel(panel) && + (loadingState === LoadingState.Streaming || loadingState === LoadingState.Loading) + ) { + menu.push({ + text: 'Stop query', + iconClassName: 'circle', + onClick: onCancelStreaming, + }); + } + const shareTextTranslation = t('panel.header-menu.share', `Share`); menu.push({