mirror of
https://github.com/grafana/grafana.git
synced 2024-12-30 10:47:30 -06:00
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:
parent
238add18ab
commit
f42bb84cbf
7
packages/grafana-data/src/types/geometry.ts
Normal file
7
packages/grafana-data/src/types/geometry.ts
Normal file
@ -0,0 +1,7 @@
|
||||
/**
|
||||
* A coordinate on a two dimensional plane.
|
||||
*/
|
||||
export interface CartesianCoords2D {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
@ -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';
|
||||
|
@ -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}`;
|
||||
|
@ -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}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
|
@ -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();
|
||||
});
|
||||
|
@ -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 }) => {
|
||||
|
@ -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);
|
||||
|
@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
@ -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;
|
||||
`,
|
||||
};
|
||||
}
|
@ -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 });
|
||||
};
|
@ -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),
|
||||
};
|
||||
}
|
@ -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>
|
||||
);
|
||||
};
|
@ -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>
|
||||
);
|
||||
};
|
@ -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} />
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
Loading…
Reference in New Issue
Block a user