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
This commit is contained in:
Polina Boneva 2023-01-12 11:10:09 +02:00 committed by GitHub
parent f85a948214
commit 84eb275c8d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 338 additions and 195 deletions

View File

@ -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)`,
}),
};
};

View File

@ -35,11 +35,10 @@ function getContentStyle(): CSSProperties {
};
}
function renderPanel(name: string, overrides: Partial<PanelChromeProps>) {
function renderPanel(name: string, overrides?: Partial<PanelChromeProps>) {
const props: PanelChromeProps = {
width: 400,
height: 130,
title: 'Default title',
children: () => undefined,
};
@ -56,6 +55,37 @@ function renderPanel(name: string, overrides: Partial<PanelChromeProps>) {
);
}
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>
);
export const Examples = () => {
const [loading, setLoading] = useState(true);
@ -65,33 +95,81 @@ export const Examples = () => {
<DashboardStoryCanvas>
<HorizontalGroup spacing="md" align="flex-start">
<VerticalGroup spacing="md">
{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,
})}
</VerticalGroup>
<VerticalGroup spacing="md">
{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,
})}
</VerticalGroup>
<VerticalGroup spacing="md">
{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,
})}
</VerticalGroup>
</HorizontalGroup>
<HorizontalGroup spacing="md" align="flex-start">
<VerticalGroup spacing="md">
{renderPanel('Default panel with deprecated error indicator', {
{renderPanel('Deprecated error indicator', {
title: 'Default title',
leftItems: [
<PanelChrome.ErrorIndicator
@ -101,7 +179,7 @@ export const Examples = () => {
/>,
],
})}
{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 = () => {
],
})}
</VerticalGroup>
<VerticalGroup spacing="md">
{renderPanel('Deprecated error indicator, menu', {
title: 'Default title',
menu,
leftItems: [
<PanelChrome.ErrorIndicator
key="errorIndicator"
error="Error text"
onClick={action('ErrorIndicator: onClick fired')}
/>,
],
})}
</VerticalGroup>
</HorizontalGroup>
</DashboardStoryCanvas>
);
@ -166,37 +257,6 @@ const titleItems: PanelChromeInfoState[] = [
},
];
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),

View File

@ -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 <div className={cx(styles.rightAligned, styles.items)}>{itemsRenderer(leftItems, (item) => item)}</div>;
} else {
const showError = loadingState === LoadingState.Error || status?.message;
return showError ? (
const showError = loadingState === LoadingState.Error || status?.message;
if (!isUsingDeprecatedLeftItems && showError) {
return (
<div className={styles.errorContainer}>
<PanelStatus message={status?.message} onClick={status?.onClick} />
</div>
) : null;
);
} else {
return null;
}
};
return (
@ -150,18 +150,22 @@ export function PanelChrome({
</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"
/>
</div>
</Dropdown>
)}
<div className={styles.rightAligned}>
{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"
/>
</div>
</Dropdown>
)}
{isUsingDeprecatedLeftItems && <div className={styles.items}>{itemsRenderer(leftItems, (item) => item)}</div>}
</div>
{renderStatus()}
</div>
@ -286,7 +290,10 @@ const getStyles = (theme: GrafanaTheme2) => {
justifyContent: 'center',
}),
rightAligned: css({
label: 'right-aligned-container',
marginLeft: 'auto',
display: 'flex',
alignItems: 'center',
}),
};
};

View File

@ -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<Props> = ({ 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<Props> = ({ panel, error, isViewing, isEditing, dat
</div>
</>
);
};
}
const panelStyles = (theme: GrafanaTheme2) => {
return {

View File

@ -6,12 +6,17 @@ import { PanelHeaderMenuItem } from './PanelHeaderMenuItem';
export interface Props {
items: PanelMenuItem[];
style?: React.CSSProperties;
}
export class PanelHeaderMenu extends PureComponent<Props> {
renderItems = (menu: PanelMenuItem[], isSubMenu = false) => {
return (
<ul className="dropdown-menu dropdown-menu--menu panel-menu" role={isSubMenu ? '' : 'menu'}>
<ul
className="dropdown-menu dropdown-menu--menu panel-menu"
style={this.props.style}
role={isSubMenu ? '' : 'menu'}
>
{menu.map((menuItem, idx: number) => {
return (
<PanelHeaderMenuItem

View File

@ -1,6 +1,6 @@
import { FC, ReactElement, useEffect, useState } from 'react';
import { ReactElement, useEffect, useState } from 'react';
import { PanelMenuItem } from '@grafana/data';
import { LoadingState, PanelMenuItem } from '@grafana/data';
import { getPanelStateForModel } from 'app/features/panel/state/selectors';
import { useSelector } from 'app/types';
@ -14,16 +14,17 @@ interface PanelHeaderMenuProviderApi {
interface Props {
panel: PanelModel;
dashboard: DashboardModel;
loadingState?: LoadingState;
children: (props: PanelHeaderMenuProviderApi) => ReactElement;
}
export const PanelHeaderMenuProvider: FC<Props> = ({ panel, dashboard, children }) => {
export function PanelHeaderMenuProvider({ panel, dashboard, loadingState, children }: Props) {
const [items, setItems] = useState<PanelMenuItem[]>([]);
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 });
};
}

View File

@ -11,7 +11,7 @@ interface Props extends Omit<HTMLAttributes<HTMLDivElement>, 'children'> {
children: (props: PanelHeaderMenuTriggerApi) => ReactElement;
}
export const PanelHeaderMenuTrigger = ({ children, ...divProps }: Props) => {
export function PanelHeaderMenuTrigger({ children, ...divProps }: Props) {
const [clickCoordinates, setClickCoordinates] = useState<CartesianCoords2D>({ x: 0, y: 0 });
const [panelMenuOpen, setPanelMenuOpen] = useState<boolean>(false);
@ -38,7 +38,7 @@ export const PanelHeaderMenuTrigger = ({ children, ...divProps }: Props) => {
{children({ panelMenuOpen, closeMenu: () => setPanelMenuOpen(false) })}
</header>
);
};
}
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

View File

@ -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<Props> = ({ panel, dashboard }) => {
export function PanelHeaderMenuWrapper({ style, panel, dashboard, loadingState }: Props) {
return (
<PanelHeaderMenuProvider panel={panel} dashboard={dashboard}>
<PanelHeaderMenuProvider panel={panel} dashboard={dashboard} loadingState={loadingState}>
{({ items }) => {
return <PanelHeaderMenu items={items} />;
return <PanelHeaderMenu style={style} items={items} />;
}}
</PanelHeaderMenuProvider>
);
};
}

View File

@ -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<Props, State> {
const title = panel.getDisplayTitle();
const padding: PanelPadding = plugin.noPadding ? 'none' : 'md';
let menu;
if (!dashboard.meta.publicDashboardAccessToken) {
menu = (
<div data-testid="panel-dropdown">
<PanelHeaderMenuWrapper
style={{ top: 0 }}
panel={panel}
dashboard={dashboard}
loadingState={data.state}
onClose={() => {}}
/>
</div>
);
}
if (config.featureToggles.newPanelChromeUI) {
return (
<PanelChrome
@ -596,6 +612,7 @@ export class PanelStateWrapper extends PureComponent<Props, State> {
height={height}
padding={padding}
title={title}
menu={menu}
loadingState={data.state}
status={{
message: errorMessage,

View File

@ -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;
event = {
ctrlKey: true,
preventDefault: jest.fn(),
};
setStore({ dispatch: jest.fn() } as any);
});
it('should navigate to url without subUrl', () => {
explore.onClick!(event);
const openInNewWindow = navigateSpy.mock.calls[0][1].openInNewWindow;
openInNewWindow(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}`);
});
const menuItems = getPanelMenu(dashboard, panel, LoadingState.Loading);
expect(menuItems).toEqual(
expect.arrayContaining([
expect.objectContaining({
iconClassName: 'circle',
text: 'Stop query',
}),
])
);
});
});
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({});
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",
},
]
`);
});
});
describe('onNavigateToExplore', () => {
const testSubUrl = '/testSubUrl';
const testUrl = '/testUrl';
const windowOpen = jest.fn();
let event: any;
let explore: PanelMenuItem;
let navigateSpy: any;
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;
event = {
ctrlKey: true,
preventDefault: jest.fn(),
};
setStore({ dispatch: jest.fn() } as any);
});
it('should navigate to url without subUrl', () => {
explore.onClick!(event);
const openInNewWindow = navigateSpy.mock.calls[0][1].openInNewWindow;
openInNewWindow(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}`);
});
});

View File

@ -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<any>) => {
@ -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({