TextPanel: Fixes so panel title is updated when variables change (#30884)

* TextPanel: Fixes so panel title is updated when variables change

* Tests: fixes tests

* Chore: updates after PR comments
This commit is contained in:
Hugo Häggmark 2021-02-09 06:05:34 +01:00 committed by GitHub
parent 238add18ab
commit f42bb84cbf
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 318 additions and 243 deletions

View File

@ -0,0 +1,7 @@
/**
* A coordinate on a two dimensional plane.
*/
export interface CartesianCoords2D {
x: number;
y: number;
}

View File

@ -29,5 +29,6 @@ export * from './explore';
export * from './legacyEvents';
export * from './live';
export * from './variables';
export * from './geometry';
export { GrafanaConfig, BuildInfo, FeatureToggles, LicenseInfo } from './config';

View File

@ -1,5 +1,6 @@
import React, { useState, useCallback, useEffect } from 'react';
import { Global, css as cssCore } from '@emotion/core';
import React, { useCallback, useEffect, useState } from 'react';
import { css as cssCore, Global } from '@emotion/core';
import { CartesianCoords2D } from '@grafana/data';
import { PlotPluginProps } from '../types';
import { usePlotPluginContext } from '../context';
@ -10,9 +11,9 @@ interface ClickPluginAPI {
point: { seriesIdx: number | null; dataIdx: number | null };
coords: {
// coords relative to plot canvas, css px
plotCanvas: Coords;
plotCanvas: CartesianCoords2D;
// coords relative to viewport , css px
viewport: Coords;
viewport: CartesianCoords2D;
};
// coords relative to plot canvas, css px
clearSelection: () => void;
@ -26,11 +27,6 @@ interface ClickPluginProps extends PlotPluginProps {
children: (api: ClickPluginAPI) => React.ReactElement | null;
}
interface Coords {
x: number;
y: number;
}
// Exposes API for Graph click interactions
export const ClickPlugin: React.FC<ClickPluginProps> = ({ id, onClick, children }) => {
const pluginId = `ClickPlugin:${id}`;

View File

@ -10,10 +10,9 @@ import { PanelChromeAngular } from './PanelChromeAngular';
// Actions
import { initDashboardPanel } from '../state/actions';
import { updateLocation } from 'app/core/reducers/location';
// Types
import { PanelModel, DashboardModel } from '../state';
import { DashboardModel, PanelModel } from '../state';
import { StoreState } from 'app/types';
import { PanelPlugin } from '@grafana/data';
@ -40,7 +39,7 @@ const mapStateToProps = (state: StoreState, props: OwnProps) => {
};
};
const mapDispatchToProps = { initDashboardPanel, updateLocation };
const mapDispatchToProps = { initDashboardPanel };
const connector = connect(mapStateToProps, mapDispatchToProps);
@ -76,7 +75,7 @@ export class DashboardPanelUnconnected extends PureComponent<Props, State> {
};
renderPanel(plugin: PanelPlugin) {
const { dashboard, panel, isViewing, isInView, isEditing, updateLocation } = this.props;
const { dashboard, panel, isViewing, isInView, isEditing } = this.props;
return (
<AutoSizer>
@ -110,7 +109,6 @@ export class DashboardPanelUnconnected extends PureComponent<Props, State> {
isInView={isInView}
width={width}
height={height}
updateLocation={updateLocation}
/>
);
}}

View File

@ -1,11 +1,12 @@
import React, { FC } from 'react';
import { ReplaySubject } from 'rxjs';
import { Provider } from 'react-redux';
import configureMockStore from 'redux-mock-store';
import { act, render, screen } from '@testing-library/react';
import { getDefaultTimeRange, LoadingState, PanelData, PanelPlugin, PanelProps } from '@grafana/data';
import { PanelChrome, Props } from './PanelChrome';
import { DashboardModel, PanelModel } from '../state';
import { updateLocation } from '../../../core/actions';
import { PanelQueryRunner } from '../../query/state/PanelQueryRunner';
import { setTimeSrv, TimeSrv } from '../services/TimeSrv';
@ -16,6 +17,8 @@ jest.mock('app/core/profiler', () => ({
}));
function setupTestContext(options: Partial<Props>) {
const mockStore = configureMockStore<any, any>();
const store = mockStore({ dashboard: { panels: [] } });
const subject: ReplaySubject<PanelData> = new ReplaySubject<PanelData>();
const panelQueryRunner = ({
getData: () => subject,
@ -48,19 +51,22 @@ function setupTestContext(options: Partial<Props>) {
isInView: false,
width: 100,
height: 100,
updateLocation: (jest.fn() as unknown) as typeof updateLocation,
};
const props = { ...defaults, ...options };
const { rerender } = render(<PanelChrome {...props} />);
const { rerender } = render(
<Provider store={store}>
<PanelChrome {...props} />
</Provider>
);
return { rerender, props, subject };
return { rerender, props, subject, store };
}
describe('PanelChrome', () => {
describe('when the user scrolls by a panel so fast that it starts loading data but scrolls out of view', () => {
it('then it should load the panel successfully when scrolled into view again', () => {
const { rerender, props, subject } = setupTestContext({});
const { rerender, props, subject, store } = setupTestContext({});
expect(screen.queryByText(/plugin panel to render/i)).not.toBeInTheDocument();
@ -70,7 +76,11 @@ describe('PanelChrome', () => {
});
const newProps = { ...props, isInView: true };
rerender(<PanelChrome {...newProps} />);
rerender(
<Provider store={store}>
<PanelChrome {...newProps} />
</Provider>
);
expect(screen.getByText(/plugin panel to render/i)).toBeInTheDocument();
});

View File

@ -10,7 +10,6 @@ import { getTimeSrv, TimeSrv } from '../services/TimeSrv';
import { applyPanelTimeOverrides } from 'app/features/dashboard/utils/panel';
import { profiler } from 'app/core/profiler';
import config from 'app/core/config';
import { updateLocation } from 'app/core/actions';
// Types
import { DashboardModel, PanelModel } from '../state';
import { PANEL_BORDER } from 'app/core/constants';
@ -40,7 +39,6 @@ export interface Props {
isInView: boolean;
width: number;
height: number;
updateLocation: typeof updateLocation;
}
export interface State {
@ -324,7 +322,7 @@ export class PanelChrome extends Component<Props, State> {
}
render() {
const { dashboard, panel, isViewing, isEditing, width, height, updateLocation } = this.props;
const { dashboard, panel, isViewing, isEditing, width, height } = this.props;
const { errorMessage, data } = this.state;
const { transparent } = panel;
@ -347,7 +345,6 @@ export class PanelChrome extends Component<Props, State> {
isEditing={isEditing}
isViewing={isViewing}
data={data}
updateLocation={updateLocation}
/>
<ErrorBoundary>
{({ error }) => {

View File

@ -14,7 +14,6 @@ import config from 'app/core/config';
import { DashboardModel, PanelModel } from '../state';
import { StoreState } from 'app/types';
import { getDefaultTimeRange, LoadingState, PanelData, PanelPlugin } from '@grafana/data';
import { updateLocation } from 'app/core/actions';
import { PANEL_BORDER } from 'app/core/constants';
import { selectors } from '@grafana/e2e-selectors';
import { RenderEvent } from 'app/types/events';
@ -36,7 +35,6 @@ interface ConnectedProps {
interface DispatchProps {
setPanelAngularComponent: typeof setPanelAngularComponent;
updateLocation: typeof updateLocation;
}
export type Props = OwnProps & ConnectedProps & DispatchProps;
@ -214,7 +212,7 @@ export class PanelChromeAngularUnconnected extends PureComponent<Props, State> {
}
render() {
const { dashboard, panel, isViewing, isEditing, plugin, angularComponent, updateLocation } = this.props;
const { dashboard, panel, isViewing, isEditing, plugin } = this.props;
const { errorMessage, data, alertState } = this.state;
const { transparent } = panel;
@ -239,13 +237,11 @@ export class PanelChromeAngularUnconnected extends PureComponent<Props, State> {
dashboard={dashboard}
title={panel.title}
description={panel.description}
angularComponent={angularComponent}
links={panel.links}
error={errorMessage}
isViewing={isViewing}
isEditing={isEditing}
data={data}
updateLocation={updateLocation}
alertState={alertState}
/>
<div className={panelContentClassNames}>
@ -262,6 +258,6 @@ const mapStateToProps: MapStateToProps<ConnectedProps, OwnProps, StoreState> = (
};
};
const mapDispatchToProps: MapDispatchToProps<DispatchProps, OwnProps> = { setPanelAngularComponent, updateLocation };
const mapDispatchToProps: MapDispatchToProps<DispatchProps, OwnProps> = { setPanelAngularComponent };
export const PanelChromeAngular = connect(mapStateToProps, mapDispatchToProps)(PanelChromeAngularUnconnected);

View File

@ -1,234 +1,74 @@
import React, { PureComponent } from 'react';
import classNames from 'classnames';
import { DataLink, LoadingState, PanelData, PanelMenuItem, QueryResultMetaNotice } from '@grafana/data';
import { AngularComponent, config } from '@grafana/runtime';
import { ClickOutsideWrapper, Icon, IconName, Tooltip, stylesFactory } from '@grafana/ui';
import React, { FC } from 'react';
import { cx } from 'emotion';
import { DataLink, PanelData } from '@grafana/data';
import { Icon } from '@grafana/ui';
import { selectors } from '@grafana/e2e-selectors';
import PanelHeaderCorner from './PanelHeaderCorner';
import { PanelHeaderMenu } from './PanelHeaderMenu';
import { DashboardModel } from 'app/features/dashboard/state/DashboardModel';
import { PanelModel } from 'app/features/dashboard/state/PanelModel';
import { getPanelLinksSupplier } from 'app/features/panel/panellinks/linkSuppliers';
import { getPanelMenu } from 'app/features/dashboard/utils/getPanelMenu';
import { updateLocation } from 'app/core/actions';
import { css } from 'emotion';
import { PanelHeaderNotices } from './PanelHeaderNotices';
import { PanelHeaderMenuTrigger } from './PanelHeaderMenuTrigger';
import { PanelHeaderLoadingIndicator } from './PanelHeaderLoadingIndicator';
import { PanelHeaderMenuWrapper } from './PanelHeaderMenuWrapper';
export interface Props {
panel: PanelModel;
dashboard: DashboardModel;
title?: string;
description?: string;
angularComponent?: AngularComponent | null;
links?: DataLink[];
error?: string;
alertState?: string;
isViewing: boolean;
isEditing: boolean;
data: PanelData;
updateLocation: typeof updateLocation;
}
interface ClickCoordinates {
x: number;
y: number;
}
export const PanelHeader: FC<Props> = ({ panel, error, isViewing, isEditing, data, alertState, dashboard }) => {
const onCancelQuery = () => panel.getQueryRunner().cancelQuery();
const title = panel.replaceVariables(panel.title, {}, 'text');
const className = cx('panel-header', !(isViewing || isEditing) ? 'grid-drag-handle' : '');
interface State {
panelMenuOpen: boolean;
menuItems: PanelMenuItem[];
}
export class PanelHeader extends PureComponent<Props, State> {
clickCoordinates: ClickCoordinates = { x: 0, y: 0 };
state: State = {
panelMenuOpen: false,
menuItems: [],
};
eventToClickCoordinates = (event: React.MouseEvent<HTMLDivElement>) => {
return {
x: Math.floor(event.clientX),
y: Math.floor(event.clientY),
};
};
onMouseDown = (event: React.MouseEvent<HTMLDivElement>) => {
this.clickCoordinates = this.eventToClickCoordinates(event);
};
isClick = (clickCoordinates: ClickCoordinates) => {
return clickCoordinates.x === this.clickCoordinates.x && clickCoordinates.y === this.clickCoordinates.y;
};
onMenuToggle = (event: React.MouseEvent<HTMLDivElement>) => {
if (!this.isClick(this.eventToClickCoordinates(event))) {
return;
}
event.stopPropagation();
const { dashboard, panel, angularComponent } = this.props;
const menuItems = getPanelMenu(dashboard, panel, angularComponent);
this.setState({
panelMenuOpen: !this.state.panelMenuOpen,
menuItems,
});
};
closeMenu = () => {
this.setState({
panelMenuOpen: false,
});
};
onCancelQuery = () => {
this.props.panel.getQueryRunner().cancelQuery();
};
renderLoadingState(state: LoadingState): JSX.Element | null {
if (state === LoadingState.Loading) {
return (
<div className="panel-loading" onClick={this.onCancelQuery}>
<Tooltip content="Cancel query">
<Icon className="panel-loading__spinner spin-clockwise" name="sync" />
</Tooltip>
</div>
);
}
if (state === LoadingState.Streaming) {
const styles = getStyles();
return (
<div className="panel-loading" onClick={this.onCancelQuery}>
<div title="Streaming (click to stop)" className={styles.streamIndicator} />
</div>
);
}
return null;
}
openInspect = (e: React.SyntheticEvent, tab: string) => {
const { updateLocation, panel } = this.props;
e.stopPropagation();
updateLocation({
query: { inspect: panel.id, inspectTab: tab },
partial: true,
});
};
// This will show one icon for each severity
renderNotice = (notice: QueryResultMetaNotice) => {
let iconName: IconName = 'info-circle';
if (notice.severity === 'error' || notice.severity === 'warning') {
iconName = 'exclamation-triangle';
}
return (
<Tooltip content={notice.text} key={notice.severity}>
{notice.inspect ? (
<div className="panel-info-notice pointer" onClick={(e) => this.openInspect(e, notice.inspect!)}>
<Icon name={iconName} style={{ marginRight: '8px' }} />
</div>
) : (
<a className="panel-info-notice" href={notice.link} target="_blank" rel="noreferrer">
<Icon name={iconName} style={{ marginRight: '8px' }} />
</a>
)}
</Tooltip>
);
};
render() {
const { panel, error, isViewing, isEditing, data, alertState } = this.props;
const { menuItems } = this.state;
const title = panel.replaceVariables(panel.title, {}, 'text');
const panelHeaderClass = classNames({
'panel-header': true,
'grid-drag-handle': !(isViewing || isEditing),
});
// dedupe on severity
const notices: Record<string, QueryResultMetaNotice> = {};
for (const series of data.series) {
if (series.meta && series.meta.notices) {
for (const notice of series.meta.notices) {
notices[notice.severity] = notice;
}
}
}
return (
<>
{this.renderLoadingState(data.state)}
<div className={panelHeaderClass}>
<PanelHeaderCorner
panel={panel}
title={panel.title}
description={panel.description}
scopedVars={panel.scopedVars}
links={getPanelLinksSupplier(panel)}
error={error}
/>
<div
className="panel-title-container"
onClick={this.onMenuToggle}
onMouseDown={this.onMouseDown}
aria-label={selectors.components.Panels.Panel.title(title)}
>
<div className="panel-title">
{Object.values(notices).map(this.renderNotice)}
{alertState && (
<Icon
name={alertState === 'alerting' ? 'heart-break' : 'heart'}
className="icon-gf panel-alert-icon"
style={{ marginRight: '4px' }}
size="sm"
/>
)}
<span className="panel-title-text">{title}</span>
<Icon name="angle-down" className="panel-menu-toggle" />
{this.state.panelMenuOpen && (
<ClickOutsideWrapper onClick={this.closeMenu} parent={document}>
<PanelHeaderMenu items={menuItems} />
</ClickOutsideWrapper>
)}
{data.request && data.request.timeInfo && (
<span className="panel-time-info">
<Icon name="clock-nine" size="sm" /> {data.request.timeInfo}
</span>
)}
</div>
</div>
</div>
</>
);
}
}
/*
* Styles
*/
export const getStyles = stylesFactory(() => {
return {
streamIndicator: css`
width: 10px;
height: 10px;
background: ${config.theme.colors.textFaint};
box-shadow: 0 0 2px ${config.theme.colors.textFaint};
border-radius: 50%;
position: relative;
top: 6px;
right: 1px;
`,
};
});
return (
<>
<PanelHeaderLoadingIndicator state={data.state} onClick={onCancelQuery} />
<div className={className}>
<PanelHeaderCorner
panel={panel}
title={panel.title}
description={panel.description}
scopedVars={panel.scopedVars}
links={getPanelLinksSupplier(panel)}
error={error}
/>
<PanelHeaderMenuTrigger aria-label={selectors.components.Panels.Panel.title(title)}>
{({ closeMenu, panelMenuOpen }) => {
return (
<div className="panel-title">
<PanelHeaderNotices frames={data.series} panelId={panel.id} />
{alertState ? (
<Icon
name={alertState === 'alerting' ? 'heart-break' : 'heart'}
className="icon-gf panel-alert-icon"
style={{ marginRight: '4px' }}
size="sm"
/>
) : null}
<span className="panel-title-text">{title}</span>
<Icon name="angle-down" className="panel-menu-toggle" />
<PanelHeaderMenuWrapper panel={panel} dashboard={dashboard} show={panelMenuOpen} onClose={closeMenu} />
{data.request && data.request.timeInfo && (
<span className="panel-time-info">
<Icon name="clock-nine" size="sm" /> {data.request.timeInfo}
</span>
)}
</div>
);
}}
</PanelHeaderMenuTrigger>
</div>
</>
);
};

View File

@ -0,0 +1,48 @@
import React, { FC } from 'react';
import { css } from 'emotion';
import { GrafanaTheme, LoadingState } from '@grafana/data';
import { Icon, Tooltip, useStyles } from '@grafana/ui';
interface Props {
state: LoadingState;
onClick: () => void;
}
export const PanelHeaderLoadingIndicator: FC<Props> = ({ state, onClick }) => {
const styles = useStyles(getStyles);
if (state === LoadingState.Loading) {
return (
<div className="panel-loading" onClick={onClick}>
<Tooltip content="Cancel query">
<Icon className="panel-loading__spinner spin-clockwise" name="sync" />
</Tooltip>
</div>
);
}
if (state === LoadingState.Streaming) {
return (
<div className="panel-loading" onClick={onClick}>
<div title="Streaming (click to stop)" className={styles.streamIndicator} />
</div>
);
}
return null;
};
function getStyles(theme: GrafanaTheme) {
return {
streamIndicator: css`
width: 10px;
height: 10px;
background: ${theme.colors.textFaint};
box-shadow: 0 0 2px ${theme.colors.textFaint};
border-radius: 50%;
position: relative;
top: 6px;
right: 1px;
`,
};
}

View File

@ -0,0 +1,29 @@
import { FC, ReactElement, useEffect, useState } from 'react';
import { useSelector } from 'react-redux';
import { PanelMenuItem } from '@grafana/data';
import { DashboardModel, PanelModel } from '../../state';
import { StoreState } from '../../../../types';
import { getPanelMenu } from '../../utils/getPanelMenu';
interface PanelHeaderMenuProviderApi {
items: PanelMenuItem[];
}
interface Props {
panel: PanelModel;
dashboard: DashboardModel;
children: (props: PanelHeaderMenuProviderApi) => ReactElement;
}
export const PanelHeaderMenuProvider: FC<Props> = ({ panel, dashboard, children }) => {
const [items, setItems] = useState<PanelMenuItem[]>([]);
const angularComponent = useSelector(
(state: StoreState) => state.dashboard.panels[panel.id]?.angularComponent || null
);
useEffect(() => {
setItems(getPanelMenu(dashboard, panel, angularComponent));
}, [dashboard, panel, angularComponent, setItems]);
return children({ items });
};

View File

@ -0,0 +1,51 @@
import React, { FC, HTMLAttributes, MouseEvent, ReactElement, useCallback, useState } from 'react';
import { CartesianCoords2D } from '@grafana/data';
interface PanelHeaderMenuTriggerApi {
panelMenuOpen: boolean;
closeMenu: () => void;
}
interface Props extends HTMLAttributes<HTMLDivElement> {
children: (props: PanelHeaderMenuTriggerApi) => ReactElement;
}
export const PanelHeaderMenuTrigger: FC<Props> = ({ children, ...divProps }) => {
const [clickCoordinates, setClickCoordinates] = useState<CartesianCoords2D>({ x: 0, y: 0 });
const [panelMenuOpen, setPanelMenuOpen] = useState<boolean>(false);
const onMenuToggle = useCallback(
(event: MouseEvent<HTMLDivElement>) => {
if (!isClick(clickCoordinates, eventToClickCoordinates(event))) {
return;
}
event.stopPropagation();
setPanelMenuOpen(!panelMenuOpen);
},
[clickCoordinates, panelMenuOpen, setPanelMenuOpen]
);
const onMouseDown = useCallback(
(event: MouseEvent<HTMLDivElement>) => {
setClickCoordinates(eventToClickCoordinates(event));
},
[setClickCoordinates]
);
return (
<div {...divProps} className="panel-title-container" onClick={onMenuToggle} onMouseDown={onMouseDown}>
{children({ panelMenuOpen, closeMenu: () => setPanelMenuOpen(false) })}
</div>
);
};
function isClick(current: CartesianCoords2D, clicked: CartesianCoords2D): boolean {
return clicked.x === current.x && clicked.y === current.y;
}
function eventToClickCoordinates(event: MouseEvent<HTMLDivElement>): CartesianCoords2D {
return {
x: Math.floor(event.clientX),
y: Math.floor(event.clientY),
};
}

View File

@ -0,0 +1,28 @@
import React, { FC } from 'react';
import { ClickOutsideWrapper } from '@grafana/ui';
import { PanelHeaderMenuProvider } from './PanelHeaderMenuProvider';
import { PanelHeaderMenu } from './PanelHeaderMenu';
import { DashboardModel, PanelModel } from '../../state';
interface Props {
panel: PanelModel;
dashboard: DashboardModel;
show: boolean;
onClose: () => void;
}
export const PanelHeaderMenuWrapper: FC<Props> = ({ show, onClose, panel, dashboard }) => {
if (!show) {
return null;
}
return (
<ClickOutsideWrapper onClick={onClose} parent={document}>
<PanelHeaderMenuProvider panel={panel} dashboard={dashboard}>
{({ items }) => {
return <PanelHeaderMenu items={items} />;
}}
</PanelHeaderMenuProvider>
</ClickOutsideWrapper>
);
};

View File

@ -0,0 +1,27 @@
import React, { FC } from 'react';
import { QueryResultMetaNotice } from '@grafana/data';
import { Icon, Tooltip } from '@grafana/ui';
interface Props {
notice: QueryResultMetaNotice;
onClick: (e: React.SyntheticEvent, tab: string) => void;
}
export const PanelHeaderNotice: FC<Props> = ({ notice, onClick }) => {
const iconName =
notice.severity === 'error' || notice.severity === 'warning' ? 'exclamation-triangle' : 'info-circle';
return (
<Tooltip content={notice.text} key={notice.severity}>
{notice.inspect ? (
<div className="panel-info-notice pointer" onClick={(e) => onClick(e, notice.inspect!)}>
<Icon name={iconName} style={{ marginRight: '8px' }} />
</div>
) : (
<a className="panel-info-notice" href={notice.link} target="_blank" rel="noreferrer">
<Icon name={iconName} style={{ marginRight: '8px' }} />
</a>
)}
</Tooltip>
);
};

View File

@ -0,0 +1,47 @@
import React, { FC, useCallback } from 'react';
import { DataFrame, QueryResultMetaNotice } from '@grafana/data';
import { PanelHeaderNotice } from './PanelHeaderNotice';
import { useDispatch } from 'react-redux';
import { updateLocation } from '../../../../core/actions';
interface Props {
panelId: number;
frames: DataFrame[];
}
export const PanelHeaderNotices: FC<Props> = ({ frames, panelId }) => {
const dispatch = useDispatch();
const openInspect = useCallback(
(e: React.SyntheticEvent, tab: string) => {
e.stopPropagation();
dispatch(
updateLocation({
query: { inspect: panelId, inspectTab: tab },
partial: true,
})
);
},
[panelId]
);
// dedupe on severity
const notices: Record<string, QueryResultMetaNotice> = {};
for (const frame of frames) {
if (!frame.meta || !frame.meta.notices) {
continue;
}
for (const notice of frame.meta.notices) {
notices[notice.severity] = notice;
}
}
return (
<>
{Object.values(notices).map((notice) => (
<PanelHeaderNotice notice={notice} onClick={openInspect} key={notice.severity} />
))}
</>
);
};