Explore: Refactor ExploreGraph (#58660)

* WIP

* revert collapse changes

* use HorizontalGroup instead of custom styles

* fix tests

* use import aliases
This commit is contained in:
Giordano Ricci
2022-11-16 11:16:27 +01:00
committed by GitHub
parent 174a039ee1
commit 2a9381e998
13 changed files with 136 additions and 109 deletions

View File

@@ -0,0 +1,212 @@
import { css, cx } from '@emotion/css';
import { identity } from 'lodash';
import React, { useEffect, useMemo, useState } from 'react';
import { useCounter } from 'react-use';
import {
AbsoluteTimeRange,
applyFieldOverrides,
createFieldConfigRegistry,
DataFrame,
dateTime,
FieldColorModeId,
FieldConfigSource,
getFrameDisplayName,
GrafanaTheme2,
LoadingState,
SplitOpen,
TimeZone,
DashboardCursorSync,
EventBus,
} from '@grafana/data';
import { PanelRenderer } from '@grafana/runtime';
import { GraphDrawStyle, LegendDisplayMode, TooltipDisplayMode, SortOrder } from '@grafana/schema';
import {
Icon,
PanelContext,
PanelContextProvider,
SeriesVisibilityChangeMode,
useStyles2,
useTheme2,
} from '@grafana/ui';
import { defaultGraphConfig, getGraphFieldConfig } from 'app/plugins/panel/timeseries/config';
import { TimeSeriesOptions } from 'app/plugins/panel/timeseries/types';
import { ExploreGraphStyle } from 'app/types';
import { seriesVisibilityConfigFactory } from '../../dashboard/dashgrid/SeriesVisibilityConfigFactory';
import { applyGraphStyle } from './exploreGraphStyleUtils';
const MAX_NUMBER_OF_TIME_SERIES = 20;
interface Props {
data: DataFrame[];
height: number;
width: number;
absoluteRange: AbsoluteTimeRange;
timeZone: TimeZone;
loadingState: LoadingState;
annotations?: DataFrame[];
onHiddenSeriesChanged?: (hiddenSeries: string[]) => void;
tooltipDisplayMode?: TooltipDisplayMode;
splitOpenFn: SplitOpen;
onChangeTime: (timeRange: AbsoluteTimeRange) => void;
graphStyle: ExploreGraphStyle;
anchorToZero?: boolean;
eventBus: EventBus;
}
export function ExploreGraph({
data,
height,
width,
timeZone,
absoluteRange,
onChangeTime,
loadingState,
annotations,
onHiddenSeriesChanged,
splitOpenFn,
graphStyle,
tooltipDisplayMode = TooltipDisplayMode.Single,
anchorToZero = false,
eventBus,
}: Props) {
const theme = useTheme2();
const style = useStyles2(getStyles);
const [showAllTimeSeries, setShowAllTimeSeries] = useState(false);
const [structureRev, { inc }] = useCounter(0);
const fieldConfigRegistry = useMemo(
() => createFieldConfigRegistry(getGraphFieldConfig(defaultGraphConfig), 'Explore'),
[]
);
const [fieldConfig, setFieldConfig] = useState<FieldConfigSource>({
defaults: {
min: anchorToZero ? 0 : undefined,
color: {
mode: FieldColorModeId.PaletteClassic,
},
custom: {
drawStyle: GraphDrawStyle.Line,
fillOpacity: 0,
pointSize: 5,
},
},
overrides: [],
});
const timeRange = {
from: dateTime(absoluteRange.from),
to: dateTime(absoluteRange.to),
raw: {
from: dateTime(absoluteRange.from),
to: dateTime(absoluteRange.to),
},
};
const styledFieldConfig = useMemo(() => applyGraphStyle(fieldConfig, graphStyle), [fieldConfig, graphStyle]);
const dataWithConfig = useMemo(() => {
return applyFieldOverrides({
fieldConfig: styledFieldConfig,
data,
timeZone,
replaceVariables: (value) => value, // We don't need proper replace here as it is only used in getLinks and we use getFieldLinks
theme,
fieldConfigRegistry,
});
}, [fieldConfigRegistry, data, timeZone, theme, styledFieldConfig]);
// We need to increment structureRev when the number of series changes.
// the function passed to useMemo runs during rendering, so when we get a different
// amount of data, structureRev is incremented before we render it
useMemo(inc, [dataWithConfig.length, styledFieldConfig, inc]);
useEffect(() => {
if (onHiddenSeriesChanged) {
const hiddenFrames: string[] = [];
dataWithConfig.forEach((frame) => {
const allFieldsHidden = frame.fields.map((field) => field.config?.custom?.hideFrom?.viz).every(identity);
if (allFieldsHidden) {
hiddenFrames.push(getFrameDisplayName(frame));
}
});
onHiddenSeriesChanged(hiddenFrames);
}
}, [dataWithConfig, onHiddenSeriesChanged]);
const seriesToShow = showAllTimeSeries ? dataWithConfig : dataWithConfig.slice(0, MAX_NUMBER_OF_TIME_SERIES);
const panelContext: PanelContext = {
eventBus,
sync: () => DashboardCursorSync.Crosshair,
onSplitOpen: splitOpenFn,
onToggleSeriesVisibility(label: string, mode: SeriesVisibilityChangeMode) {
setFieldConfig(seriesVisibilityConfigFactory(label, mode, fieldConfig, data));
},
};
const panelOptions: TimeSeriesOptions = useMemo(
() => ({
tooltip: { mode: tooltipDisplayMode, sort: SortOrder.None },
legend: {
displayMode: LegendDisplayMode.List,
showLegend: true,
placement: 'bottom',
calcs: [],
},
}),
[tooltipDisplayMode]
);
return (
<PanelContextProvider value={panelContext}>
{dataWithConfig.length > MAX_NUMBER_OF_TIME_SERIES && !showAllTimeSeries && (
<div className={cx([style.timeSeriesDisclaimer])}>
<Icon className={style.disclaimerIcon} name="exclamation-triangle" />
{`Showing only ${MAX_NUMBER_OF_TIME_SERIES} time series. `}
<span
className={cx([style.showAllTimeSeries])}
onClick={() => {
setShowAllTimeSeries(true);
}}
>{`Show all ${dataWithConfig.length}`}</span>
</div>
)}
<PanelRenderer
data={{ series: seriesToShow, timeRange, state: loadingState, annotations, structureRev }}
pluginId="timeseries"
title=""
width={width}
height={height}
onChangeTimeRange={onChangeTime}
timeZone={timeZone}
options={panelOptions}
/>
</PanelContextProvider>
);
}
const getStyles = (theme: GrafanaTheme2) => ({
timeSeriesDisclaimer: css`
label: time-series-disclaimer;
width: 300px;
margin: ${theme.spacing(1)} auto;
padding: 10px 0;
border-radius: ${theme.spacing(2)};
text-align: center;
background-color: ${theme.colors.background.primary};
`,
disclaimerIcon: css`
label: disclaimer-icon;
color: ${theme.colors.warning.main};
margin-right: ${theme.spacing(0.5)};
`,
showAllTimeSeries: css`
label: show-all-time-series;
cursor: pointer;
color: ${theme.colors.text.link};
`,
});

View File

@@ -0,0 +1,26 @@
import React from 'react';
import { SelectableValue } from '@grafana/data';
import { RadioButtonGroup, HorizontalGroup } from '@grafana/ui';
import { EXPLORE_GRAPH_STYLES, ExploreGraphStyle } from 'app/types';
const ALL_GRAPH_STYLE_OPTIONS: Array<SelectableValue<ExploreGraphStyle>> = EXPLORE_GRAPH_STYLES.map((style) => ({
value: style,
// capital-case it and switch `_` to ` `
label: style[0].toUpperCase() + style.slice(1).replace(/_/, ' '),
}));
type Props = {
graphStyle: ExploreGraphStyle;
onChangeGraphStyle: (style: ExploreGraphStyle) => void;
};
export function ExploreGraphLabel(props: Props) {
const { graphStyle, onChangeGraphStyle } = props;
return (
<HorizontalGroup justify="space-between" wrap>
Graph
<RadioButtonGroup size="sm" options={ALL_GRAPH_STYLE_OPTIONS} value={graphStyle} onChange={onChangeGraphStyle} />
</HorizontalGroup>
);
}

View File

@@ -0,0 +1,70 @@
import React, { useCallback, useState } from 'react';
import { DataFrame, EventBus, AbsoluteTimeRange, TimeZone, SplitOpen, LoadingState } from '@grafana/data';
import { Collapse, useTheme2 } from '@grafana/ui';
import { ExploreGraphStyle } from 'app/types';
import { storeGraphStyle } from '../state/utils';
import { ExploreGraph } from './ExploreGraph';
import { ExploreGraphLabel } from './ExploreGraphLabel';
import { loadGraphStyle } from './utils';
interface Props {
loading: boolean;
data: DataFrame[];
annotations?: DataFrame[];
eventBus: EventBus;
height: number;
width: number;
absoluteRange: AbsoluteTimeRange;
timeZone: TimeZone;
onChangeTime: (absoluteRange: AbsoluteTimeRange) => void;
splitOpenFn: SplitOpen;
loadingState: LoadingState;
}
export const GraphContainer = ({
loading,
data,
eventBus,
height,
width,
absoluteRange,
timeZone,
annotations,
onChangeTime,
splitOpenFn,
loadingState,
}: Props) => {
const [graphStyle, setGraphStyle] = useState(loadGraphStyle);
const theme = useTheme2();
const spacing = parseInt(theme.spacing(2).slice(0, -2), 10);
const onGraphStyleChange = useCallback((graphStyle: ExploreGraphStyle) => {
storeGraphStyle(graphStyle);
setGraphStyle(graphStyle);
}, []);
return (
<Collapse
label={<ExploreGraphLabel graphStyle={graphStyle} onChangeGraphStyle={onGraphStyleChange} />}
loading={loading}
isOpen
>
<ExploreGraph
graphStyle={graphStyle}
data={data}
height={height}
width={width - spacing}
absoluteRange={absoluteRange}
onChangeTime={onChangeTime}
timeZone={timeZone}
annotations={annotations}
splitOpenFn={splitOpenFn}
loadingState={loadingState}
eventBus={eventBus}
/>
</Collapse>
);
};

View File

@@ -0,0 +1,57 @@
import produce from 'immer';
import { FieldConfigSource } from '@grafana/data';
import { GraphDrawStyle, GraphFieldConfig, StackingMode } from '@grafana/schema';
import { ExploreGraphStyle } from 'app/types';
export type FieldConfig = FieldConfigSource<GraphFieldConfig>;
export function applyGraphStyle(config: FieldConfig, style: ExploreGraphStyle): FieldConfig {
return produce(config, (draft) => {
if (draft.defaults.custom === undefined) {
draft.defaults.custom = {};
}
const { custom } = draft.defaults;
if (custom.stacking === undefined) {
custom.stacking = { group: 'A' };
}
switch (style) {
case 'lines':
custom.drawStyle = GraphDrawStyle.Line;
custom.stacking.mode = StackingMode.None;
custom.fillOpacity = 0;
break;
case 'bars':
custom.drawStyle = GraphDrawStyle.Bars;
custom.stacking.mode = StackingMode.None;
custom.fillOpacity = 100;
break;
case 'points':
custom.drawStyle = GraphDrawStyle.Points;
custom.stacking.mode = StackingMode.None;
custom.fillOpacity = 0;
break;
case 'stacked_lines':
custom.drawStyle = GraphDrawStyle.Line;
custom.stacking.mode = StackingMode.Normal;
custom.fillOpacity = 100;
break;
case 'stacked_bars':
custom.drawStyle = GraphDrawStyle.Bars;
custom.stacking.mode = StackingMode.Normal;
custom.fillOpacity = 100;
break;
default: {
// should never happen
// NOTE: casting to `never` will cause typescript
// to verify that the switch statement checks every possible
// enum-value
const invalidValue: never = style;
throw new Error(`Invalid graph-style: ${invalidValue}`);
}
}
});
}

View File

@@ -0,0 +1,26 @@
import store from 'app/core/store';
import { ExploreGraphStyle, EXPLORE_GRAPH_STYLES } from 'app/types';
const GRAPH_STYLE_KEY = 'grafana.explore.style.graph';
export const storeGraphStyle = (graphStyle: string): void => {
store.set(GRAPH_STYLE_KEY, graphStyle);
};
export const loadGraphStyle = (): ExploreGraphStyle => {
return toGraphStyle(store.get(GRAPH_STYLE_KEY));
};
const DEFAULT_GRAPH_STYLE: ExploreGraphStyle = 'lines';
// we use this function to take any kind of data we loaded
// from an external source (URL, localStorage, whatever),
// and extract the graph-style from it, or return the default
// graph-style if we are not able to do that.
// it is important that this function is able to take any form of data,
// (be it objects, or arrays, or booleans or whatever),
// and produce a best-effort graphStyle.
// note that typescript makes sure we make no mistake in this function.
// we do not rely on ` as ` or ` any `.
export const toGraphStyle = (data: unknown): ExploreGraphStyle => {
const found = EXPLORE_GRAPH_STYLES.find((v) => v === data);
return found ?? DEFAULT_GRAPH_STYLE;
};