Explore: Context tooltip to copy labels and values from graph (#21405)

This commit is contained in:
Ivana Huckova 2020-01-17 15:52:08 +01:00 committed by GitHub
parent 26f72ccc4e
commit c738a889ed
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 217 additions and 79 deletions

View File

@ -0,0 +1,8 @@
export interface FlotDataPoint {
dataIndex: number;
datapoint: number[];
pageX: number;
pageY: number;
series: any;
seriesIndex: number;
}

View File

@ -22,6 +22,7 @@ export * from './thresholds';
export * from './fieldColor';
export * from './theme';
export * from './orgs';
export * from './flot';
import * as AppEvents from './appEvents';
import { AppEvent } from './appEvents';

View File

@ -5,7 +5,6 @@ import { TimeRange } from '../types/time';
// Types
// import { NullValueMode, GraphSeriesValue, Field, TimeRange } from '@grafana/data';
export interface FlotPairsOptions {
xField: Field;
yField: Field;

View File

@ -22,6 +22,7 @@ const getTooltipContainerStyles = stylesFactory((theme: GrafanaTheme) => {
max-width: 800px;
padding: ${theme.spacing.sm};
border-radius: ${theme.border.radius.sm};
z-index: ${theme.zIndex.tooltip};
`,
};
});

View File

@ -25,7 +25,7 @@ export interface ContextMenuProps {
y: number;
onClose: () => void;
items?: ContextMenuGroup[];
renderHeader?: () => JSX.Element;
renderHeader?: () => React.ReactNode;
}
const getContextMenuStyles = stylesFactory((theme: GrafanaTheme) => {
@ -181,10 +181,11 @@ export const ContextMenu: React.FC<ContextMenuProps> = React.memo(({ x, y, onClo
});
const styles = getContextMenuStyles(theme);
const header = renderHeader && renderHeader();
return (
<Portal>
<div ref={menuRef} style={positionStyles} className={styles.wrapper}>
{renderHeader && <div className={styles.header}>{renderHeader()}</div>}
{header && <div className={styles.header}>{header}</div>}
<List
items={items || []}
renderItem={(item, index) => {

View File

@ -3,11 +3,20 @@ import $ from 'jquery';
import React, { PureComponent } from 'react';
import uniqBy from 'lodash/uniqBy';
// Types
import { TimeRange, GraphSeriesXY, TimeZone, DefaultTimeZone, createDimension } from '@grafana/data';
import {
TimeRange,
GraphSeriesXY,
TimeZone,
DefaultTimeZone,
createDimension,
DateTimeInput,
dateTime,
} from '@grafana/data';
import _ from 'lodash';
import { FlotPosition, FlotItem } from './types';
import { TooltipProps, TooltipContentProps, ActiveDimensions, Tooltip } from '../Chart/Tooltip';
import { GraphTooltip } from './GraphTooltip/GraphTooltip';
import { GraphContextMenu, GraphContextMenuProps, ContextDimensions } from './GraphContextMenu';
import { GraphDimensions } from './GraphTooltip/types';
export interface GraphProps {
@ -27,8 +36,11 @@ export interface GraphProps {
interface GraphState {
pos?: FlotPosition;
contextPos?: FlotPosition;
isTooltipVisible: boolean;
isContextVisible: boolean;
activeItem?: FlotItem<GraphSeriesXY>;
contextItem?: FlotItem<GraphSeriesXY>;
}
export class Graph extends PureComponent<GraphProps, GraphState> {
@ -42,6 +54,7 @@ export class Graph extends PureComponent<GraphProps, GraphState> {
state: GraphState = {
isTooltipVisible: false,
isContextVisible: false,
};
element: HTMLElement | null = null;
@ -59,6 +72,7 @@ export class Graph extends PureComponent<GraphProps, GraphState> {
this.$element = $(this.element);
this.$element.bind('plotselected', this.onPlotSelected);
this.$element.bind('plothover', this.onPlotHover);
this.$element.bind('plotclick', this.onPlotClick);
}
}
@ -81,6 +95,15 @@ export class Graph extends PureComponent<GraphProps, GraphState> {
});
};
onPlotClick = (event: JQueryEventObject, contextPos: FlotPosition, item?: FlotItem<GraphSeriesXY>) => {
this.setState({
isContextVisible: true,
isTooltipVisible: false,
contextItem: item,
contextPos,
});
};
getYAxes(series: GraphSeriesXY[]) {
if (series.length === 0) {
return [{ show: true, min: -1, max: 1 }];
@ -179,6 +202,68 @@ export class Graph extends PureComponent<GraphProps, GraphState> {
});
};
renderContextMenu = () => {
const { series } = this.props;
const { contextPos, contextItem, isContextVisible } = this.state;
if (!isContextVisible || !contextPos || !contextItem || series.length === 0) {
return null;
}
// Indicates column(field) index in y-axis dimension
const seriesIndex = contextItem ? contextItem.series.seriesIndex : 0;
// Indicates row index in context field values
const rowIndex = contextItem ? contextItem.dataIndex : undefined;
const contextDimensions: ContextDimensions<GraphDimensions> = {
// Described x-axis context item
xAxis: [seriesIndex, rowIndex],
// Describes y-axis context item
yAxis: contextItem ? [contextItem.series.seriesIndex, contextItem.dataIndex] : null,
};
const dimensions: GraphDimensions = {
// time/value dimension columns are index-aligned - see getGraphSeriesModel
xAxis: createDimension(
'xAxis',
series.map(s => s.timeField)
),
yAxis: createDimension(
'yAxis',
series.map(s => s.valueField)
),
};
const formatDate = (date: DateTimeInput, format?: string) => {
return dateTime(date)?.format(format);
};
const closeContext = () => this.setState({ isContextVisible: false });
const getContextMenuSource = () => {
return {
datapoint: contextItem.datapoint,
dataIndex: contextItem.dataIndex,
series: contextItem.series,
seriesIndex: contextItem.series.seriesIndex,
pageX: contextPos.pageX,
pageY: contextPos.pageY,
};
};
const contextContentProps: GraphContextMenuProps = {
x: contextPos.pageX,
y: contextPos.pageY,
onClose: closeContext,
getContextMenuSource: getContextMenuSource,
formatSourceDate: formatDate,
dimensions,
contextDimensions,
};
return <GraphContextMenu {...contextContentProps} />;
};
getBarWidth = () => {
const { series } = this.props;
return Math.min(...series.map(s => s.timeStep));
@ -285,6 +370,8 @@ export class Graph extends PureComponent<GraphProps, GraphState> {
render() {
const { height, width, series } = this.props;
const noDataToBeDisplayed = series.length === 0;
const tooltip = this.renderTooltip();
const context = this.renderContextMenu();
return (
<div className="graph-panel">
<div
@ -295,9 +382,9 @@ export class Graph extends PureComponent<GraphProps, GraphState> {
this.setState({ isTooltipVisible: false });
}}
/>
{noDataToBeDisplayed && <div className="datapoints-warning">No data</div>}
{this.renderTooltip()}
{tooltip}
{context}
</div>
);
}

View File

@ -0,0 +1,99 @@
import React, { useContext } from 'react';
import { ContextMenu, ContextMenuProps } from '../ContextMenu/ContextMenu';
import { ThemeContext } from '../../themes';
import { SeriesIcon } from '../Legend/SeriesIcon';
import { GraphDimensions } from './GraphTooltip/types';
import {
DateTimeInput,
FlotDataPoint,
getValueFromDimension,
getDisplayProcessor,
formattedValueToString,
Dimensions,
MS_DATE_TIME_FORMAT,
DEFAULT_DATE_TIME_FORMAT,
} from '@grafana/data';
import { css } from 'emotion';
export type ContextDimensions<T extends Dimensions = any> = { [key in keyof T]: [number, number | undefined] | null };
export type GraphContextMenuProps = ContextMenuProps & {
getContextMenuSource: () => FlotDataPoint | null;
formatSourceDate: (date: DateTimeInput, format?: string) => string;
dimensions?: GraphDimensions;
contextDimensions?: ContextDimensions;
};
export const GraphContextMenu: React.FC<GraphContextMenuProps> = ({
getContextMenuSource,
formatSourceDate,
items,
dimensions,
contextDimensions,
...otherProps
}) => {
const theme = useContext(ThemeContext);
const source = getContextMenuSource();
// Do not render items that do not have label specified
const itemsToRender = items
? items.map(group => ({
...group,
items: group.items.filter(item => item.label),
}))
: [];
const renderHeader = () => {
if (!source) {
return null;
}
// If dimensions supplied, we can calculate and display value
let value;
if (dimensions?.yAxis && contextDimensions?.yAxis?.[1]) {
const valueFromDimensions = getValueFromDimension(
dimensions.yAxis,
contextDimensions.yAxis[0],
contextDimensions.yAxis[1]
);
const display = source.series.valueField.display ?? getDisplayProcessor({ field: source.series.valueField });
value = display(valueFromDimensions);
}
const timeFormat = source.series.hasMsResolution ? MS_DATE_TIME_FORMAT : DEFAULT_DATE_TIME_FORMAT;
return (
<div
className={css`
padding: ${theme.spacing.xs} ${theme.spacing.sm};
font-size: ${theme.typography.size.sm};
z-index: ${theme.zIndex.tooltip};
`}
>
<strong>{formatSourceDate(source.datapoint[0], timeFormat)}</strong>
<div>
<SeriesIcon color={source.series.color} />
<span
className={css`
white-space: nowrap;
padding-left: ${theme.spacing.xs};
`}
>
{source.series.alias || source.series.label}
</span>
{value && (
<span
className={css`
white-space: nowrap;
padding-left: ${theme.spacing.md};
`}
>
{formattedValueToString(value)}
</span>
)}
</div>
</div>
);
};
return <ContextMenu {...otherProps} items={itemsToRender} renderHeader={renderHeader} />;
};

View File

@ -22,6 +22,7 @@ const getSeriesTableRowStyles = stylesFactory((theme: GrafanaTheme) => {
`,
seriesTableRow: css`
display: table-row;
font-size: ${theme.typography.size.sm};
`,
seriesTableCell: css`
display: table-cell;
@ -35,6 +36,10 @@ const getSeriesTableRowStyles = stylesFactory((theme: GrafanaTheme) => {
activeSeries: css`
font-weight: ${theme.typography.weight.bold};
`,
timestamp: css`
font-weight: ${theme.typography.weight.bold};
font-size: ${theme.typography.size.sm};
`,
};
});
@ -60,9 +65,15 @@ interface SeriesTableProps {
}
export const SeriesTable: React.FC<SeriesTableProps> = ({ timestamp, series }) => {
const theme = useTheme();
const styles = getSeriesTableRowStyles(theme);
return (
<>
{timestamp && <div aria-label="Timestamp">{timestamp}</div>}
{timestamp && (
<div className={styles.timestamp} aria-label="Timestamp">
{timestamp}
</div>
)}
{series.map(s => {
return <SeriesTableRow isActive={s.isActive} label={s.label} color={s.color} value={s.value} key={s.label} />;
})}

View File

@ -66,6 +66,7 @@ export { Gauge } from './Gauge/Gauge';
export { Graph } from './Graph/Graph';
export { GraphLegend } from './Graph/GraphLegend';
export { GraphWithLegend } from './Graph/GraphWithLegend';
export { GraphContextMenu } from './Graph/GraphContextMenu';
export { BarGauge, BarGaugeDisplayMode } from './BarGauge/BarGauge';
export { GraphTooltipOptions } from './Graph/GraphTooltip/types';
export { VizRepeater } from './VizRepeater/VizRepeater';

View File

@ -15,10 +15,10 @@ import {
UnitPicker,
DataLinksEditor,
DataSourceHttpSettings,
GraphContextMenu,
} from '@grafana/ui';
import { FunctionEditor } from 'app/plugins/datasource/graphite/FunctionEditor';
import { SearchField } from './components/search/SearchField';
import { GraphContextMenu } from 'app/plugins/panel/graph/GraphContextMenu';
import ReactProfileWrapper from 'app/features/profile/ReactProfileWrapper';
import { LokiAnnotationsQueryEditor } from '../plugins/datasource/loki/components/AnnotationsQueryEditor';
import { HelpModal } from './components/help/HelpModal';

View File

@ -1,62 +0,0 @@
import React, { useContext } from 'react';
import { FlotDataPoint } from './GraphContextMenuCtrl';
import { ContextMenu, ContextMenuProps, SeriesIcon, ThemeContext } from '@grafana/ui';
import { DateTimeInput } from '@grafana/data';
import { css } from 'emotion';
type GraphContextMenuProps = ContextMenuProps & {
getContextMenuSource: () => FlotDataPoint | null;
formatSourceDate: (date: DateTimeInput, format?: string) => string;
};
export const GraphContextMenu: React.FC<GraphContextMenuProps> = ({
getContextMenuSource,
formatSourceDate,
items,
...otherProps
}) => {
const theme = useContext(ThemeContext);
const source = getContextMenuSource();
// Do not render items that do not have label specified
const itemsToRender = items
? items.map(group => ({
...group,
items: group.items.filter(item => item.label),
}))
: [];
const renderHeader = source
? () => {
if (!source) {
return null;
}
const timeFormat = source.series.hasMsResolution ? 'YYYY-MM-DD HH:mm:ss.SSS' : 'YYYY-MM-DD HH:mm:ss';
return (
<div
className={css`
padding: ${theme.spacing.xs} ${theme.spacing.sm};
font-size: ${theme.typography.size.sm};
`}
>
<strong>{formatSourceDate(source.datapoint[0], timeFormat)}</strong>
<div>
<SeriesIcon color={source.series.color} />
<span
className={css`
white-space: nowrap;
padding-left: ${theme.spacing.xs};
`}
>
{source.series.alias}
</span>
</div>
</div>
);
}
: null;
return <ContextMenu {...otherProps} items={itemsToRender} renderHeader={renderHeader} />;
};

View File

@ -1,13 +1,5 @@
import { ContextMenuItem } from '@grafana/ui';
export interface FlotDataPoint {
dataIndex: number;
datapoint: number[];
pageX: number;
pageY: number;
series: any;
seriesIndex: number;
}
import { FlotDataPoint } from '@grafana/data';
export class GraphContextMenuCtrl {
private source?: FlotDataPoint | null;