GraphNG: adding possibility to toggle tooltip, graph and legend for series (#29575)

This commit is contained in:
Marcus Andersson 2021-01-06 21:40:32 +01:00 committed by GitHub
parent 78d72007d8
commit 7aa6926ef6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
32 changed files with 1264 additions and 51 deletions

View File

@ -9,6 +9,6 @@ export {
TransformerUIProps,
standardTransformersRegistry,
} from './standardTransformersRegistry';
export { RegexpOrNamesMatcherOptions } from './matchers/nameMatcher';
export { RegexpOrNamesMatcherOptions, ByNamesMatcherOptions, ByNamesMatcherMode } from './matchers/nameMatcher';
export { RenameByRegexTransformerOptions } from './transformers/renameByRegex';
export { outerJoinDataFrames } from './transformers/seriesToColumns';

View File

@ -1,6 +1,7 @@
import { getFieldMatcher } from '../matchers';
import { FieldMatcherID } from './ids';
import { toDataFrame } from '../../dataframe/processDataFrame';
import { ByNamesMatcherMode } from './nameMatcher';
describe('Field Name by Regexp Matcher', () => {
it('Match all with wildcard regex', () => {
@ -113,7 +114,29 @@ describe('Field Multiple Names Matcher', () => {
});
const config = {
id: FieldMatcherID.byNames,
options: ['C'],
options: {
mode: ByNamesMatcherMode.include,
names: ['C'],
},
};
const matcher = getFieldMatcher(config);
for (const field of seriesWithNames.fields) {
const didMatch = matcher(field, seriesWithNames, [seriesWithNames]);
expect(didMatch).toBe(field.name === 'C');
}
});
it('Match should default to include mode', () => {
const seriesWithNames = toDataFrame({
fields: [{ name: 'A hello world' }, { name: 'AAA' }, { name: 'C' }],
});
const config = {
id: FieldMatcherID.byNames,
options: {
names: ['C'],
},
};
const matcher = getFieldMatcher(config);
@ -130,7 +153,10 @@ describe('Field Multiple Names Matcher', () => {
});
const config = {
id: FieldMatcherID.byNames,
options: ['c'],
options: {
mode: ByNamesMatcherMode.include,
names: ['c'],
},
};
const matcher = getFieldMatcher(config);
@ -146,7 +172,10 @@ describe('Field Multiple Names Matcher', () => {
});
const config = {
id: FieldMatcherID.byNames,
options: [],
options: {
mode: ByNamesMatcherMode.include,
names: [],
},
};
const matcher = getFieldMatcher(config);
@ -162,7 +191,10 @@ describe('Field Multiple Names Matcher', () => {
});
const config = {
id: FieldMatcherID.byNames,
options: ['some.instance.path', '112', '13'],
options: {
mode: ByNamesMatcherMode.include,
names: ['some.instance.path', '112', '13'],
},
};
const matcher = getFieldMatcher(config);
@ -171,6 +203,26 @@ describe('Field Multiple Names Matcher', () => {
expect(matcher(field, seriesWithNames, [seriesWithNames])).toBe(true);
}
});
it('Match all but supplied names', () => {
const seriesWithNames = toDataFrame({
fields: [{ name: 'A hello world' }, { name: 'AAA' }, { name: 'C' }],
});
const config = {
id: FieldMatcherID.byNames,
options: {
mode: ByNamesMatcherMode.exclude,
names: ['C'],
},
};
const matcher = getFieldMatcher(config);
for (const field of seriesWithNames.fields) {
const didMatch = matcher(field, seriesWithNames, [seriesWithNames]);
expect(didMatch).toBe(field.name !== 'C');
}
});
});
describe('Field Regexp or Names Matcher', () => {

View File

@ -9,6 +9,28 @@ export interface RegexpOrNamesMatcherOptions {
names?: string[];
}
/**
* Mode to be able to toggle if the names matcher should match fields in provided
* list or all except provided names.
* @public
*/
export enum ByNamesMatcherMode {
exclude = 'exclude',
include = 'include',
}
/**
* Options to instruct the by names matcher to either match all fields in given list
* or all except the fields in the list.
* @public
*/
export interface ByNamesMatcherOptions {
mode?: ByNamesMatcherMode;
names?: string[];
readOnly?: boolean;
prefix?: string;
}
// General Field matcher
const fieldNameMatcher: FieldMatcherInfo<string> = {
id: FieldMatcherID.byName,
@ -27,22 +49,34 @@ const fieldNameMatcher: FieldMatcherInfo<string> = {
},
};
const multipleFieldNamesMatcher: FieldMatcherInfo<string[]> = {
const multipleFieldNamesMatcher: FieldMatcherInfo<ByNamesMatcherOptions> = {
id: FieldMatcherID.byNames,
name: 'Field Names',
description: 'match any of the given the field names',
defaultOptions: [],
defaultOptions: {
mode: ByNamesMatcherMode.include,
names: [],
},
get: (names: string[]): FieldMatcher => {
get: (options: ByNamesMatcherOptions): FieldMatcher => {
const { names, mode = ByNamesMatcherMode.include } = options;
const uniqueNames = new Set<string>(names ?? []);
return (field: Field, frame: DataFrame, allFrames: DataFrame[]) => {
if (mode === ByNamesMatcherMode.exclude) {
return !uniqueNames.has(getFieldDisplayName(field, frame, allFrames));
}
return uniqueNames.has(getFieldDisplayName(field, frame, allFrames));
};
},
getOptionsDisplayText: (names: string[]): string => {
return `Field names: ${names.join(', ')}`;
getOptionsDisplayText: (options: ByNamesMatcherOptions): string => {
const { names, mode } = options;
const displayText = (names ?? []).join(', ');
if (mode === ByNamesMatcherMode.exclude) {
return `All except: ${displayText}`;
}
return `All of: ${displayText}`;
},
};
@ -99,7 +133,10 @@ const regexpOrMultipleNamesMatcher: FieldMatcherInfo<RegexpOrNamesMatcherOptions
get: (options: RegexpOrNamesMatcherOptions): FieldMatcher => {
const regexpMatcher = regexpFieldNameMatcher.get(options?.pattern || '');
const namesMatcher = multipleFieldNamesMatcher.get(options?.names ?? []);
const namesMatcher = multipleFieldNamesMatcher.get({
mode: ByNamesMatcherMode.include,
names: options?.names ?? [],
});
return (field: Field, frame: DataFrame, allFrames: DataFrame[]) => {
return namesMatcher(field, frame, allFrames) || regexpMatcher(field, frame, allFrames);

View File

@ -135,7 +135,9 @@ function getReduceRowCreator(options: ReduceOptions, allFrames: DataFrame[]): Va
if (options.include && options.include.length) {
matcher = getFieldMatcher({
id: FieldMatcherID.byNames,
options: options.include,
options: {
names: options.include,
},
});
}

View File

@ -40,7 +40,7 @@ const getMatcherConfig = (options?: RegexpOrNamesMatcherOptions): MatcherConfig
}
if (!pattern) {
return { id: FieldMatcherID.byNames, options: names };
return { id: FieldMatcherID.byNames, options: { names } };
}
if (!Array.isArray(names) || names.length === 0) {

View File

@ -202,3 +202,13 @@ export interface FieldCalcs extends Record<string, any> {}
export const TIME_SERIES_VALUE_FIELD_NAME = 'Value';
export const TIME_SERIES_TIME_FIELD_NAME = 'Time';
export const TIME_SERIES_METRIC_FIELD_NAME = 'Metric';
/**
* Describes where a specific data frame field is located within a
* dataset of type DataFrame[]
* @public
*/
export interface DataFrameFieldIndex {
frameIndex: number;
fieldIndex: number;
}

View File

@ -15,6 +15,38 @@ export interface ConfigOverrideRule {
properties: DynamicConfigValue[];
}
/**
* Describes config override rules created when interacting with Grafana.
*
* @internal
*/
export interface SystemConfigOverrideRule extends ConfigOverrideRule {
__systemRef: string;
}
/**
* Guard functionality to check if an override rule is of type {@link SystemConfigOverrideRule}.
* It will only return true if the {@link SystemConfigOverrideRule} has the passed systemRef.
*
* @param ref system override reference
* @internal
*/
export function isSystemOverrideWithRef<T extends SystemConfigOverrideRule>(ref: string) {
return (override: ConfigOverrideRule): override is T => {
return (override as T)?.__systemRef === ref;
};
}
/**
* Guard functionality to check if an override rule is of type {@link SystemConfigOverrideRule}.
* It will return true if the {@link SystemConfigOverrideRule} has any systemRef set.
*
* @internal
*/
export const isSystemOverride = (override: ConfigOverrideRule): override is SystemConfigOverrideRule => {
return typeof (override as SystemConfigOverrideRule)?.__systemRef === 'string';
};
export interface FieldConfigSource<TOptions extends object = any> {
// Defaults applied to all numeric fields
defaults: FieldConfig<TOptions>;

View File

@ -116,7 +116,7 @@ export const GraphLegendTableRow: React.FunctionComponent<GraphLegendItemProps>
onLabelClick(item, event);
}
}}
className={styles.label}
className={cx(styles.label, item.disabled && styles.labelDisabled)}
>
{item.label} {item.yAxis === 2 && <span className={styles.yAxisLabel}>(right y-axis)</span>}
</div>

View File

@ -20,6 +20,7 @@ import { LegendDisplayMode, LegendItem, LegendOptions } from '../Legend/Legend';
import { GraphLegend } from '../Graph/GraphLegend';
import { UPlotConfigBuilder } from '../uPlot/config/UPlotConfigBuilder';
import { useRevision } from '../uPlot/hooks';
import { GraphNGLegendEvent, GraphNGLegendEventMode } from './types';
const defaultFormatter = (v: any) => (v == null ? '-' : v.toFixed(1));
@ -27,11 +28,11 @@ export interface XYFieldMatchers {
x: FieldMatcher;
y: FieldMatcher;
}
export interface GraphNGProps extends Omit<PlotProps, 'data' | 'config'> {
data: DataFrame[];
legend?: LegendOptions;
fields?: XYFieldMatchers; // default will assume timeseries data
onLegendClick?: (event: GraphNGLegendEvent) => void;
}
const defaultConfig: GraphFieldConfig = {
@ -49,6 +50,7 @@ export const GraphNG: React.FC<GraphNGProps> = ({
legend,
timeRange,
timeZone,
onLegendClick,
...plotProps
}) => {
const alignedFrameWithGapTest = useMemo(() => alignDataFrames(data, fields), [data, fields]);
@ -56,6 +58,7 @@ export const GraphNG: React.FC<GraphNGProps> = ({
const legendItemsRef = useRef<LegendItem[]>([]);
const hasLegend = useRef(legend && legend.displayMode !== LegendDisplayMode.Hidden);
const alignedFrame = alignedFrameWithGapTest?.frame;
const getDataFrameFieldIndex = alignedFrameWithGapTest?.getDataFrameFieldIndex;
const compareFrames = useCallback((a?: DataFrame | null, b?: DataFrame | null) => {
if (a && b) {
@ -64,6 +67,22 @@ export const GraphNG: React.FC<GraphNGProps> = ({
return false;
}, []);
const onLabelClick = useCallback(
(legend: LegendItem, event: React.MouseEvent) => {
const { fieldIndex } = legend;
if (!onLegendClick || !fieldIndex) {
return;
}
onLegendClick({
fieldIndex,
mode: mapMouseEventToMode(event),
});
},
[onLegendClick, data]
);
// reference change will not triger re-render
const currentTimeRange = useRef<TimeRange>(timeRange);
useLayoutEffect(() => {
@ -109,7 +128,6 @@ export const GraphNG: React.FC<GraphNGProps> = ({
});
}
let seriesIdx = 0;
const legendItems: LegendItem[] = [];
for (let i = 0; i < alignedFrame.fields.length; i++) {
@ -126,6 +144,8 @@ export const GraphNG: React.FC<GraphNGProps> = ({
const fmt = field.display ?? defaultFormatter;
const scaleKey = config.unit || '__fixed';
const colorMode = getFieldColorModeForField(field);
const seriesColor = colorMode.getCalculator(field, theme)(0, 0);
if (customConfig.axisPlacement !== AxisPlacement.Hidden) {
// The builder will manage unique scaleKeys and combine where appropriate
@ -147,11 +167,6 @@ export const GraphNG: React.FC<GraphNGProps> = ({
});
}
// need to update field state here because we use a transform to merge framesP
field.state = { ...field.state, seriesIndex: seriesIdx };
const colorMode = getFieldColorModeForField(field);
const seriesColor = colorMode.getCalculator(field, theme)(0, 0);
const showPoints = customConfig.drawStyle === DrawStyle.Points ? PointVisibility.Always : customConfig.showPoints;
builder.addSeries({
@ -165,20 +180,23 @@ export const GraphNG: React.FC<GraphNGProps> = ({
pointColor: customConfig.pointColor ?? seriesColor,
fillOpacity: customConfig.fillOpacity,
spanNulls: customConfig.spanNulls || false,
show: !customConfig.hideFrom?.graph,
fillGradient: customConfig.fillGradient,
});
if (hasLegend.current) {
if (hasLegend.current && !customConfig.hideFrom?.legend) {
const axisPlacement = builder.getAxisPlacement(scaleKey);
// we need to add this as dep or move it to be done outside.
const dataFrameFieldIndex = getDataFrameFieldIndex ? getDataFrameFieldIndex(i) : undefined;
legendItems.push({
disabled: field.config.custom?.hideFrom?.graph ?? false,
fieldIndex: dataFrameFieldIndex,
color: seriesColor,
label: getFieldDisplayName(field, alignedFrame),
yAxis: axisPlacement === AxisPlacement.Left ? 1 : 2,
});
}
seriesIdx++;
}
legendItemsRef.current = legendItems;
@ -198,7 +216,12 @@ export const GraphNG: React.FC<GraphNGProps> = ({
if (hasLegend && legendItemsRef.current.length > 0) {
legendElement = (
<VizLayout.Legend position={legend!.placement} maxHeight="35%" maxWidth="60%">
<GraphLegend placement={legend!.placement} items={legendItemsRef.current} displayMode={legend!.displayMode} />
<GraphLegend
onLabelClick={onLabelClick}
placement={legend!.placement}
items={legendItemsRef.current}
displayMode={legend!.displayMode}
/>
</VizLayout.Legend>
);
}
@ -221,3 +244,10 @@ export const GraphNG: React.FC<GraphNGProps> = ({
</VizLayout>
);
};
const mapMouseEventToMode = (event: React.MouseEvent): GraphNGLegendEventMode => {
if (event.ctrlKey || event.metaKey || event.shiftKey) {
return GraphNGLegendEventMode.AppendToSelection;
}
return GraphNGLegendEventMode.ToggleSelection;
};

View File

@ -0,0 +1,20 @@
import { DataFrameFieldIndex } from '@grafana/data';
/**
* Mode to describe if a legend is isolated/selected or being appended to an existing
* series selection.
* @public
*/
export enum GraphNGLegendEventMode {
ToggleSelection = 'select',
AppendToSelection = 'append',
}
/**
* Event being triggered when the user interact with the Graph legend.
* @public
*/
export interface GraphNGLegendEvent {
fieldIndex: DataFrameFieldIndex;
mode: GraphNGLegendEventMode;
}

View File

@ -0,0 +1,218 @@
import { ArrayVector, DataFrame, FieldType, toDataFrame } from '@grafana/data';
import { AlignedFrameWithGapTest } from '../uPlot/types';
import { alignDataFrames } from './utils';
describe('alignDataFrames', () => {
describe('aligned frame', () => {
it('should align multiple data frames into one data frame', () => {
const data: DataFrame[] = [
toDataFrame({
fields: [
{ name: 'time', type: FieldType.time, values: [1000, 2000, 3000, 4000] },
{ name: 'temperature A', type: FieldType.number, values: [1, 3, 5, 7] },
],
}),
toDataFrame({
fields: [
{ name: 'time', type: FieldType.time, values: [1000, 2000, 3000, 4000] },
{ name: 'temperature B', type: FieldType.number, values: [0, 2, 6, 7] },
],
}),
];
const aligned = alignDataFrames(data);
expect(aligned?.frame.fields).toEqual([
{
config: {},
state: {},
name: 'time',
type: FieldType.time,
values: new ArrayVector([1000, 2000, 3000, 4000]),
},
{
config: {},
state: {
displayName: 'temperature A',
seriesIndex: 0,
},
name: 'temperature A',
type: FieldType.number,
values: new ArrayVector([1, 3, 5, 7]),
},
{
config: {},
state: {
displayName: 'temperature B',
seriesIndex: 1,
},
name: 'temperature B',
type: FieldType.number,
values: new ArrayVector([0, 2, 6, 7]),
},
]);
});
it('should align multiple data frames into one data frame but only keep first time field', () => {
const data: DataFrame[] = [
toDataFrame({
fields: [
{ name: 'time', type: FieldType.time, values: [1000, 2000, 3000, 4000] },
{ name: 'temperature', type: FieldType.number, values: [1, 3, 5, 7] },
],
}),
toDataFrame({
fields: [
{ name: 'time2', type: FieldType.time, values: [1000, 2000, 3000, 4000] },
{ name: 'temperature B', type: FieldType.number, values: [0, 2, 6, 7] },
],
}),
];
const aligned = alignDataFrames(data);
expect(aligned?.frame.fields).toEqual([
{
config: {},
state: {},
name: 'time',
type: FieldType.time,
values: new ArrayVector([1000, 2000, 3000, 4000]),
},
{
config: {},
state: {
displayName: 'temperature',
seriesIndex: 0,
},
name: 'temperature',
type: FieldType.number,
values: new ArrayVector([1, 3, 5, 7]),
},
{
config: {},
state: {
displayName: 'temperature B',
seriesIndex: 1,
},
name: 'temperature B',
type: FieldType.number,
values: new ArrayVector([0, 2, 6, 7]),
},
]);
});
it('should align multiple data frames into one data frame and skip non-numeric fields', () => {
const data: DataFrame[] = [
toDataFrame({
fields: [
{ name: 'time', type: FieldType.time, values: [1000, 2000, 3000, 4000] },
{ name: 'temperature', type: FieldType.number, values: [1, 3, 5, 7] },
{ name: 'state', type: FieldType.string, values: ['on', 'off', 'off', 'on'] },
],
}),
];
const aligned = alignDataFrames(data);
expect(aligned?.frame.fields).toEqual([
{
config: {},
state: {},
name: 'time',
type: FieldType.time,
values: new ArrayVector([1000, 2000, 3000, 4000]),
},
{
config: {},
state: {
displayName: 'temperature',
seriesIndex: 0,
},
name: 'temperature',
type: FieldType.number,
values: new ArrayVector([1, 3, 5, 7]),
},
]);
});
it('should align multiple data frames into one data frame and skip non-numeric fields', () => {
const data: DataFrame[] = [
toDataFrame({
fields: [
{ name: 'time', type: FieldType.time, values: [1000, 2000, 3000, 4000] },
{ name: 'temperature', type: FieldType.number, values: [1, 3, 5, 7] },
{ name: 'state', type: FieldType.string, values: ['on', 'off', 'off', 'on'] },
],
}),
];
const aligned = alignDataFrames(data);
expect(aligned?.frame.fields).toEqual([
{
config: {},
state: {},
name: 'time',
type: FieldType.time,
values: new ArrayVector([1000, 2000, 3000, 4000]),
},
{
config: {},
state: {
displayName: 'temperature',
seriesIndex: 0,
},
name: 'temperature',
type: FieldType.number,
values: new ArrayVector([1, 3, 5, 7]),
},
]);
});
});
describe('getDataFrameFieldIndex', () => {
let aligned: AlignedFrameWithGapTest | null;
beforeAll(() => {
const data: DataFrame[] = [
toDataFrame({
fields: [
{ name: 'time', type: FieldType.time, values: [1000, 2000, 3000, 4000] },
{ name: 'temperature A', type: FieldType.number, values: [1, 3, 5, 7] },
],
}),
toDataFrame({
fields: [
{ name: 'time', type: FieldType.time, values: [1000, 2000, 3000, 4000] },
{ name: 'temperature B', type: FieldType.number, values: [0, 2, 6, 7] },
{ name: 'humidity', type: FieldType.number, values: [0, 2, 6, 7] },
],
}),
toDataFrame({
fields: [
{ name: 'time', type: FieldType.time, values: [1000, 2000, 3000, 4000] },
{ name: 'temperature C', type: FieldType.number, values: [0, 2, 6, 7] },
],
}),
];
aligned = alignDataFrames(data);
});
it.each`
yDim | index
${1} | ${[0, 1]}
${2} | ${[1, 1]}
${3} | ${[1, 2]}
${4} | ${[2, 1]}
`('should return correct index for yDim', ({ yDim, index }) => {
const [frameIndex, fieldIndex] = index;
expect(aligned?.getDataFrameFieldIndex(yDim)).toEqual({
frameIndex,
fieldIndex,
});
});
});
});

View File

@ -6,6 +6,9 @@ import {
Field,
fieldMatchers,
FieldMatcherID,
FieldType,
FieldState,
DataFrameFieldIndex,
} from '@grafana/data';
import { AlignedFrameWithGapTest } from '../uPlot/types';
import uPlot, { AlignedData, JoinNullMode } from 'uplot';
@ -43,6 +46,7 @@ export function mapDimesions(match: XYFieldMatchers, frame: DataFrame, frames?:
export function alignDataFrames(frames: DataFrame[], fields?: XYFieldMatchers): AlignedFrameWithGapTest | null {
const valuesFromFrames: AlignedData[] = [];
const sourceFields: Field[] = [];
const sourceFieldsRefs: Record<number, DataFrameFieldIndex> = {};
const nullModes: JoinNullMode[][] = [];
// Default to timeseries config
@ -53,11 +57,12 @@ export function alignDataFrames(frames: DataFrame[], fields?: XYFieldMatchers):
};
}
for (const frame of frames) {
for (let frameIndex = 0; frameIndex < frames.length; frameIndex++) {
const frame = frames[frameIndex];
const dims = mapDimesions(fields, frame, frames);
if (!(dims.x.length && dims.y.length)) {
continue; // both x and y matched something!
continue; // no numeric and no time fields
}
if (dims.x.length > 1) {
@ -76,16 +81,23 @@ export function alignDataFrames(frames: DataFrame[], fields?: XYFieldMatchers):
dims.x[0].values.toArray(), // The x axis (time)
];
// Add the Y values
for (const field of dims.y) {
for (let fieldIndex = 0; fieldIndex < frame.fields.length; fieldIndex++) {
const field = frame.fields[fieldIndex];
if (!fields.y(field, frame, frames)) {
continue;
}
let values = field.values.toArray();
let joinNullMode = field.config.custom.spanNulls ? 0 : 2;
let joinNullMode = field.config.custom?.spanNulls ? 0 : 2;
if (field.config.nullValueMode === NullValueMode.AsZero) {
values = values.map(v => (v === null ? 0 : v));
joinNullMode = 0;
}
sourceFieldsRefs[sourceFields.length] = { frameIndex, fieldIndex };
alignedData.push(values);
nullModesFrame.push(joinNullMode);
@ -109,15 +121,33 @@ export function alignDataFrames(frames: DataFrame[], fields?: XYFieldMatchers):
throw new Error('outerJoinValues lost a field?');
}
let seriesIdx = 0;
// Replace the values from the outer-join field
return {
frame: {
length: alignedData![0].length,
fields: alignedData!.map((vals, idx) => ({
...sourceFields[idx],
values: new ArrayVector(vals),
})),
fields: alignedData!.map((vals, idx) => {
let state: FieldState = { ...sourceFields[idx].state };
if (sourceFields[idx].type !== FieldType.time) {
state.seriesIndex = seriesIdx;
seriesIdx++;
}
return {
...sourceFields[idx],
state,
values: new ArrayVector(vals),
};
}),
},
isGap,
getDataFrameFieldIndex: (alignedFieldIndex: number) => {
const index = sourceFieldsRefs[alignedFieldIndex];
if (!index) {
throw new Error(`Could not find index for ${alignedFieldIndex}`);
}
return index;
},
};
}

View File

@ -1,4 +1,4 @@
import { DisplayValue } from '@grafana/data';
import { DataFrameFieldIndex, DisplayValue } from '@grafana/data';
import { LegendList } from './LegendList';
import { LegendTable } from './LegendTable';
@ -42,6 +42,7 @@ export interface LegendItem {
yAxis: number;
disabled?: boolean;
displayValues?: DisplayValue[];
fieldIndex?: DataFrameFieldIndex;
}
export interface LegendComponentProps {

View File

@ -0,0 +1,80 @@
import React, { memo, useMemo, useCallback } from 'react';
import { MatcherUIProps, FieldMatcherUIRegistryItem } from './types';
import {
FieldMatcherID,
fieldMatchers,
getFieldDisplayName,
SelectableValue,
DataFrame,
ByNamesMatcherOptions,
} from '@grafana/data';
import { MultiSelect } from '../Select/Select';
import { Input } from '../Input/Input';
export const FieldNamesMatcherEditor = memo<MatcherUIProps<ByNamesMatcherOptions>>(props => {
const { data, options, onChange: onChangeFromProps } = props;
const { readOnly, prefix } = options;
const names = useFieldDisplayNames(data);
const selectOptions = useSelectOptions(names);
if (readOnly) {
const displayNames = (options.names ?? []).join(', ');
return <Input value={displayNames} readOnly={true} disabled={true} prefix={prefix} />;
}
const onChange = useCallback(
(selections: Array<SelectableValue<string>>) => {
if (!Array.isArray(selections)) {
return;
}
return onChangeFromProps({
...options,
names: selections.reduce((all: string[], current) => {
if (!current?.value || !names.has(current.value)) {
return all;
}
all.push(current.value);
return all;
}, []),
});
},
[names, onChangeFromProps]
);
return <MultiSelect value={options.names} options={selectOptions} onChange={onChange} />;
});
FieldNamesMatcherEditor.displayName = 'FieldNameMatcherEditor';
export const fieldNamesMatcherItem: FieldMatcherUIRegistryItem<ByNamesMatcherOptions> = {
id: FieldMatcherID.byNames,
component: FieldNamesMatcherEditor,
matcher: fieldMatchers.get(FieldMatcherID.byNames),
name: 'Fields with name',
description: 'Set properties for a specific field',
optionsToLabel: options => (options.names ?? []).join(', '),
excludeFromPicker: true,
};
const useFieldDisplayNames = (data: DataFrame[]): Set<string> => {
return useMemo(() => {
const names: Set<string> = new Set();
for (const frame of data) {
for (const field of frame.fields) {
names.add(getFieldDisplayName(field, frame, data));
}
}
return names;
}, [data]);
};
const useSelectOptions = (displayNames: Set<string>): Array<SelectableValue<string>> => {
return useMemo(() => {
return Array.from(displayNames).map(n => ({
value: n,
label: n,
}));
}, [displayNames]);
};

View File

@ -4,7 +4,12 @@ import { fieldNameMatcherItem } from './FieldNameMatcherEditor';
import { fieldNameByRegexMatcherItem } from './FieldNameByRegexMatcherEditor';
import { fieldTypeMatcherItem } from './FieldTypeMatcherEditor';
import { fieldsByFrameRefIdItem } from './FieldsByFrameRefIdMatcher';
import { fieldNamesMatcherItem } from './FieldNamesMatcherEditor';
export const fieldMatchersUI = new Registry<FieldMatcherUIRegistryItem<any>>(() => {
return [fieldNameMatcherItem, fieldNameByRegexMatcherItem, fieldTypeMatcherItem, fieldsByFrameRefIdItem];
});
export const fieldMatchersUI = new Registry<FieldMatcherUIRegistryItem<any>>(() => [
fieldNameMatcherItem,
fieldNameByRegexMatcherItem,
fieldTypeMatcherItem,
fieldsByFrameRefIdItem,
fieldNamesMatcherItem,
]);

View File

@ -160,6 +160,7 @@ export class Sparkline extends PureComponent<Props, State> {
data={{
frame: data,
isGap: () => true, // any null is a gap
getDataFrameFieldIndex: () => undefined,
}}
config={configBuilder}
width={width}

View File

@ -216,3 +216,4 @@ export * from './uPlot/plugins';
export { useRefreshAfterGraphRendered } from './uPlot/hooks';
export { usePlotContext, usePlotData, usePlotPluginContext } from './uPlot/context';
export { GraphNG } from './GraphNG/GraphNG';
export { GraphNGLegendEvent, GraphNGLegendEventMode } from './GraphNG/types';

View File

@ -1,11 +1,12 @@
import React from 'react';
import { UPlotChart } from './Plot';
import { act, render } from '@testing-library/react';
import { ArrayVector, dateTime, FieldConfig, FieldType, MutableDataFrame } from '@grafana/data';
import { ArrayVector, DataFrame, dateTime, FieldConfig, FieldType, MutableDataFrame } from '@grafana/data';
import { GraphFieldConfig, DrawStyle } from '../uPlot/config';
import uPlot from 'uplot';
import createMockRaf from 'mock-raf';
import { UPlotConfigBuilder } from './config/UPlotConfigBuilder';
import { AlignedFrameWithGapTest } from './types';
const mockRaf = createMockRaf();
const setDataMock = jest.fn();
@ -68,7 +69,7 @@ describe('UPlotChart', () => {
it('destroys uPlot instance when component unmounts', () => {
const { data, timeRange, config } = mockData();
const uPlotData = { frame: data, isGap: () => false };
const uPlotData = createPlotData(data);
const { unmount } = render(
<UPlotChart
@ -94,7 +95,7 @@ describe('UPlotChart', () => {
describe('data update', () => {
it('skips uPlot reinitialization when there are no field config changes', () => {
const { data, timeRange, config } = mockData();
const uPlotData = { frame: data, isGap: () => false };
const uPlotData = createPlotData(data);
const { rerender } = render(
<UPlotChart
@ -135,7 +136,7 @@ describe('UPlotChart', () => {
describe('config update', () => {
it('skips uPlot intialization for width and height equal 0', async () => {
const { data, timeRange, config } = mockData();
const uPlotData = { frame: data, isGap: () => false };
const uPlotData = createPlotData(data);
const { queryAllByTestId } = render(
<UPlotChart data={uPlotData} config={config} timeRange={timeRange} timeZone={'browser'} width={0} height={0} />
@ -147,7 +148,7 @@ describe('UPlotChart', () => {
it('reinitializes uPlot when config changes', () => {
const { data, timeRange, config } = mockData();
const uPlotData = { frame: data, isGap: () => false };
const uPlotData = createPlotData(data);
const { rerender } = render(
<UPlotChart
@ -184,7 +185,7 @@ describe('UPlotChart', () => {
it('skips uPlot reinitialization when only dimensions change', () => {
const { data, timeRange, config } = mockData();
const uPlotData = { frame: data, isGap: () => false };
const uPlotData = createPlotData(data);
const { rerender } = render(
<UPlotChart
@ -219,3 +220,11 @@ describe('UPlotChart', () => {
});
});
});
const createPlotData = (frame: DataFrame): AlignedFrameWithGapTest => {
return {
frame,
isGap: () => false,
getDataFrameFieldIndex: () => undefined,
};
};

View File

@ -106,11 +106,21 @@ export interface AxisConfig {
scaleDistribution?: ScaleDistributionConfig;
}
/**
* @alpha
*/
export interface HideSeriesConfig {
tooltip: boolean;
legend: boolean;
graph: boolean;
}
/**
* @alpha
*/
export interface GraphFieldConfig extends LineConfig, AreaConfig, PointsConfig, AxisConfig {
drawStyle?: DrawStyle;
hideFrom?: HideSeriesConfig;
}
/**

View File

@ -402,6 +402,7 @@ describe('UPlotConfigBuilder', () => {
"stroke": "#00ff00",
},
"scale": "scale-x",
"show": true,
"spanGaps": false,
"stroke": "#0000ff",
"width": 1,

View File

@ -15,6 +15,7 @@ import { PlotConfigBuilder } from '../types';
export interface SeriesProps extends LineConfig, AreaConfig, PointsConfig {
drawStyle: DrawStyle;
scaleKey: string;
show?: boolean;
}
export class UPlotSeriesBuilder extends PlotConfigBuilder<SeriesProps, Series> {
@ -29,6 +30,7 @@ export class UPlotSeriesBuilder extends PlotConfigBuilder<SeriesProps, Series> {
pointSize,
scaleKey,
spanNulls,
show = true,
} = this.props;
let lineConfig: Partial<Series> = {};
@ -66,6 +68,7 @@ export class UPlotSeriesBuilder extends PlotConfigBuilder<SeriesProps, Series> {
return {
scale: scaleKey,
spanGaps: spanNulls,
show,
fill: this.getFill(),
...lineConfig,
...pointsConfig,

View File

@ -69,6 +69,10 @@ export const TooltipPlugin: React.FC<TooltipPluginProps> = ({ mode = 'single', t
return agg;
}
if (f.config.custom?.hideFrom?.tooltip) {
return agg;
}
return [
...agg,
{

View File

@ -1,6 +1,6 @@
import React from 'react';
import uPlot, { Options, Series, Hooks } from 'uplot';
import { DataFrame, TimeRange, TimeZone } from '@grafana/data';
import { DataFrame, DataFrameFieldIndex, TimeRange, TimeZone } from '@grafana/data';
import { UPlotConfigBuilder } from './config/UPlotConfigBuilder';
export type PlotSeriesConfig = Pick<Options, 'series' | 'scales' | 'axes' | 'cursor'>;
@ -33,4 +33,5 @@ export abstract class PlotConfigBuilder<P, T> {
export interface AlignedFrameWithGapTest {
frame: DataFrame;
isGap: Series.isGap;
getDataFrameFieldIndex: (alignedFieldIndex: number) => DataFrameFieldIndex | undefined;
}

View File

@ -102,6 +102,7 @@ export class GrafanaApp {
standardFieldConfigEditorRegistry.setInit(getStandardFieldConfigs);
standardTransformersRegistry.setInit(getStandardTransformers);
variableAdapters.setInit(getDefaultVariableAdapters);
setVariableQueryRunner(new VariableQueryRunner());
app.config(

View File

@ -11,6 +11,7 @@ interface DynamicConfigValueEditorProps {
context: FieldOverrideContext;
onRemove: () => void;
isCollapsible?: boolean;
isSystemOverride?: boolean;
}
export const DynamicConfigValueEditor: React.FC<DynamicConfigValueEditorProps> = ({
@ -20,6 +21,7 @@ export const DynamicConfigValueEditor: React.FC<DynamicConfigValueEditorProps> =
onChange,
onRemove,
isCollapsible,
isSystemOverride,
}) => {
const theme = useTheme();
const styles = getStyles(theme);
@ -37,9 +39,11 @@ export const DynamicConfigValueEditor: React.FC<DynamicConfigValueEditorProps> =
{item.name}
{!isExpanded && includeCounter && item.getItemsCount && <Counter value={item.getItemsCount(property.value)} />}
</Label>
<div>
<IconButton name="times" onClick={onRemove} />
</div>
{!isSystemOverride && (
<div>
<IconButton name="times" onClick={onRemove} />
</div>
)}
</HorizontalGroup>
);

View File

@ -6,6 +6,7 @@ import {
FieldConfigOptionsRegistry,
FieldConfigProperty,
GrafanaTheme,
isSystemOverride as isSystemOverrideGuard,
VariableSuggestionsScope,
} from '@grafana/data';
import {
@ -136,6 +137,8 @@ export const OverrideEditor: React.FC<OverrideEditorProps> = ({
);
};
const isSystemOverride = isSystemOverrideGuard(override);
return (
<OptionsGroup renderTitle={renderOverrideTitle} id={name} key={name}>
<Field label={matcherLabel}>
@ -150,6 +153,7 @@ export const OverrideEditor: React.FC<OverrideEditorProps> = ({
<>
{override.properties.map((p, j) => {
const item = registry.getIfExists(p.id);
console.log('item', item);
if (!item) {
return <div>Unknown property: {p.id}</div>;
@ -162,6 +166,7 @@ export const OverrideEditor: React.FC<OverrideEditorProps> = ({
<DynamicConfigValueEditor
key={`${p.id}/${j}`}
isCollapsible={isCollapsible}
isSystemOverride={isSystemOverride}
onChange={value => onDynamicConfigValueChange(j, value)}
onRemove={() => onDynamicConfigValueRemove(j)}
property={p}
@ -173,7 +178,7 @@ export const OverrideEditor: React.FC<OverrideEditorProps> = ({
/>
);
})}
{override.matcher.options && (
{!isSystemOverride && override.matcher.options && (
<div className={styles.propertyPickerWrapper}>
<ValuePicker
label="Add override property"

View File

@ -82,6 +82,7 @@ export const OverrideFieldConfigEditor: React.FC<Props> = props => {
variant="secondary"
options={fieldMatchersUI
.list()
.filter(o => !o.excludeFromPicker)
.map<SelectableValue<string>>(i => ({ label: i.name, value: i.id, description: i.description }))}
onChange={value => onOverrideAdd(value)}
isFullWidth={false}

View File

@ -1,9 +1,10 @@
import React from 'react';
import { TooltipPlugin, ZoomPlugin, GraphNG } from '@grafana/ui';
import React, { useCallback } from 'react';
import { TooltipPlugin, ZoomPlugin, GraphNG, GraphNGLegendEvent } from '@grafana/ui';
import { PanelProps } from '@grafana/data';
import { Options } from './types';
import { AnnotationsPlugin } from './plugins/AnnotationsPlugin';
import { ExemplarsPlugin } from './plugins/ExemplarsPlugin';
import { hideSeriesConfigFactory } from './hideSeriesConfigFactory';
import { ContextMenuPlugin } from './plugins/ContextMenuPlugin';
interface GraphPanelProps extends PanelProps<Options> {}
@ -15,9 +16,18 @@ export const GraphPanel: React.FC<GraphPanelProps> = ({
width,
height,
options,
fieldConfig,
onChangeTimeRange,
onFieldConfigChange,
replaceVariables,
}) => {
const onLegendClick = useCallback(
(event: GraphNGLegendEvent) => {
onFieldConfigChange(hideSeriesConfigFactory(event, fieldConfig, data.series));
},
[fieldConfig, onFieldConfigChange, data.series]
);
return (
<GraphNG
data={data.series}
@ -26,6 +36,7 @@ export const GraphPanel: React.FC<GraphPanelProps> = ({
width={width}
height={height}
legend={options.legend}
onLegendClick={onLegendClick}
>
<TooltipPlugin mode={options.tooltipOptions.mode as any} timeZone={timeZone} />
<ZoomPlugin onZoom={onChangeTimeRange} />

View File

@ -0,0 +1,32 @@
import React, { useCallback } from 'react';
import _ from 'lodash';
import { FilterPill, HorizontalGroup } from '@grafana/ui';
import { FieldConfigEditorProps } from '@grafana/data';
import { HideSeriesConfig } from '@grafana/ui/src/components/uPlot/config';
export const SeriesConfigEditor: React.FC<FieldConfigEditorProps<HideSeriesConfig, {}>> = props => {
const { value, onChange } = props;
const onChangeToggle = useCallback(
(prop: keyof HideSeriesConfig) => {
onChange({ ...value, [prop]: !value[prop] });
},
[value, onChange]
);
return (
<HorizontalGroup spacing="xs">
{Object.keys(value).map((key: keyof HideSeriesConfig) => {
return (
<FilterPill
icon={value[key] ? 'eye-slash' : 'eye'}
onClick={() => onChangeToggle(key)}
key={key}
label={_.startCase(key)}
selected={value[key]}
/>
);
})}
</HorizontalGroup>
);
};

View File

@ -0,0 +1,419 @@
import {
ByNamesMatcherMode,
DataFrame,
FieldConfigSource,
FieldMatcherID,
FieldType,
toDataFrame,
} from '@grafana/data';
import { GraphNGLegendEvent, GraphNGLegendEventMode } from '@grafana/ui';
import { hideSeriesConfigFactory } from './hideSeriesConfigFactory';
describe('hideSeriesConfigFactory', () => {
it('should create config override matching one series', () => {
const event: GraphNGLegendEvent = {
mode: GraphNGLegendEventMode.ToggleSelection,
fieldIndex: {
frameIndex: 0,
fieldIndex: 1,
},
};
const existingConfig: FieldConfigSource = {
defaults: {},
overrides: [],
};
const data: DataFrame[] = [
toDataFrame({
fields: [
{ name: 'time', type: FieldType.time, values: [1000, 2000, 3000, 4000] },
{ name: 'temperature', type: FieldType.number, values: [1, 3, 5, 7] },
],
}),
];
const config = hideSeriesConfigFactory(event, existingConfig, data);
expect(config).toEqual({
defaults: {},
overrides: [createOverride(['temperature'])],
});
});
it('should create config override matching one series if selected with others', () => {
const event: GraphNGLegendEvent = {
mode: GraphNGLegendEventMode.ToggleSelection,
fieldIndex: {
frameIndex: 0,
fieldIndex: 1,
},
};
const existingConfig: FieldConfigSource = {
defaults: {},
overrides: [createOverride(['temperature', 'humidity'])],
};
const data: DataFrame[] = [
toDataFrame({
fields: [
{ name: 'time', type: FieldType.time, values: [1000, 2000, 3000, 4000] },
{ name: 'temperature', type: FieldType.number, values: [1, 3, 5, 7] },
],
}),
toDataFrame({
fields: [
{ name: 'time', type: FieldType.time, values: [1000, 2000, 3000, 4000] },
{ name: 'humidity', type: FieldType.number, values: [1, 3, 5, 7] },
],
}),
toDataFrame({
fields: [
{ name: 'time', type: FieldType.time, values: [1000, 2000, 3000, 4000] },
{ name: 'pressure', type: FieldType.number, values: [1, 3, 5, 7] },
],
}),
];
const config = hideSeriesConfigFactory(event, existingConfig, data);
expect(config).toEqual({
defaults: {},
overrides: [createOverride(['temperature'])],
});
});
it('should create config override that append series to existing override', () => {
const event: GraphNGLegendEvent = {
mode: GraphNGLegendEventMode.AppendToSelection,
fieldIndex: {
frameIndex: 1,
fieldIndex: 1,
},
};
const existingConfig: FieldConfigSource = {
defaults: {},
overrides: [createOverride(['temperature'])],
};
const data: DataFrame[] = [
toDataFrame({
fields: [
{ name: 'time', type: FieldType.time, values: [1000, 2000, 3000, 4000] },
{ name: 'temperature', type: FieldType.number, values: [1, 3, 5, 7] },
],
}),
toDataFrame({
fields: [
{ name: 'time', type: FieldType.time, values: [1000, 2000, 3000, 4000] },
{ name: 'humidity', type: FieldType.number, values: [1, 3, 5, 7] },
],
}),
toDataFrame({
fields: [
{ name: 'time', type: FieldType.time, values: [1000, 2000, 3000, 4000] },
{ name: 'pressure', type: FieldType.number, values: [1, 3, 5, 7] },
],
}),
];
const config = hideSeriesConfigFactory(event, existingConfig, data);
expect(config).toEqual({
defaults: {},
overrides: [createOverride(['temperature', 'humidity'])],
});
});
it('should create config override that hides all series if appending only existing series', () => {
const event: GraphNGLegendEvent = {
mode: GraphNGLegendEventMode.AppendToSelection,
fieldIndex: {
frameIndex: 0,
fieldIndex: 1,
},
};
const existingConfig: FieldConfigSource = {
defaults: {},
overrides: [createOverride(['temperature'])],
};
const data: DataFrame[] = [
toDataFrame({
fields: [
{ name: 'time', type: FieldType.time, values: [1000, 2000, 3000, 4000] },
{ name: 'temperature', type: FieldType.number, values: [1, 3, 5, 7] },
],
}),
toDataFrame({
fields: [
{ name: 'time', type: FieldType.time, values: [1000, 2000, 3000, 4000] },
{ name: 'humidity', type: FieldType.number, values: [1, 3, 5, 7] },
],
}),
];
const config = hideSeriesConfigFactory(event, existingConfig, data);
expect(config).toEqual({
defaults: {},
overrides: [createOverride([])],
});
});
it('should create config override that removes series if appending existing field', () => {
const event: GraphNGLegendEvent = {
mode: GraphNGLegendEventMode.AppendToSelection,
fieldIndex: {
frameIndex: 0,
fieldIndex: 1,
},
};
const existingConfig: FieldConfigSource = {
defaults: {},
overrides: [createOverride(['temperature', 'humidity'])],
};
const data: DataFrame[] = [
toDataFrame({
fields: [
{ name: 'time', type: FieldType.time, values: [1000, 2000, 3000, 4000] },
{ name: 'temperature', type: FieldType.number, values: [1, 3, 5, 7] },
],
}),
toDataFrame({
fields: [
{ name: 'time', type: FieldType.time, values: [1000, 2000, 3000, 4000] },
{ name: 'humidity', type: FieldType.number, values: [1, 3, 5, 7] },
],
}),
];
const config = hideSeriesConfigFactory(event, existingConfig, data);
expect(config).toEqual({
defaults: {},
overrides: [createOverride(['humidity'])],
});
});
it('should create config override replacing existing series', () => {
const event: GraphNGLegendEvent = {
mode: GraphNGLegendEventMode.ToggleSelection,
fieldIndex: {
frameIndex: 1,
fieldIndex: 1,
},
};
const existingConfig: FieldConfigSource = {
defaults: {},
overrides: [createOverride(['temperature'])],
};
const data: DataFrame[] = [
toDataFrame({
fields: [
{ name: 'time', type: FieldType.time, values: [1000, 2000, 3000, 4000] },
{ name: 'temperature', type: FieldType.number, values: [1, 3, 5, 7] },
],
}),
toDataFrame({
fields: [
{ name: 'time', type: FieldType.time, values: [1000, 2000, 3000, 4000] },
{ name: 'humidity', type: FieldType.number, values: [1, 3, 5, 7] },
],
}),
];
const config = hideSeriesConfigFactory(event, existingConfig, data);
expect(config).toEqual({
defaults: {},
overrides: [createOverride(['humidity'])],
});
});
it('should create config override removing existing series', () => {
const event: GraphNGLegendEvent = {
mode: GraphNGLegendEventMode.ToggleSelection,
fieldIndex: {
frameIndex: 0,
fieldIndex: 1,
},
};
const existingConfig: FieldConfigSource = {
defaults: {},
overrides: [createOverride(['temperature'])],
};
const data: DataFrame[] = [
toDataFrame({
fields: [
{ name: 'time', type: FieldType.time, values: [1000, 2000, 3000, 4000] },
{ name: 'temperature', type: FieldType.number, values: [1, 3, 5, 7] },
],
}),
toDataFrame({
fields: [
{ name: 'time', type: FieldType.time, values: [1000, 2000, 3000, 4000] },
{ name: 'humidity', type: FieldType.number, values: [1, 3, 5, 7] },
],
}),
];
const config = hideSeriesConfigFactory(event, existingConfig, data);
expect(config).toEqual({
defaults: {},
overrides: [],
});
});
it('should remove override if all fields are appended', () => {
const event: GraphNGLegendEvent = {
mode: GraphNGLegendEventMode.AppendToSelection,
fieldIndex: {
frameIndex: 1,
fieldIndex: 1,
},
};
const existingConfig: FieldConfigSource = {
defaults: {},
overrides: [createOverride(['temperature'])],
};
const data: DataFrame[] = [
toDataFrame({
fields: [
{ name: 'time', type: FieldType.time, values: [1000, 2000, 3000, 4000] },
{ name: 'temperature', type: FieldType.number, values: [1, 3, 5, 7] },
],
}),
toDataFrame({
fields: [
{ name: 'time', type: FieldType.time, values: [1000, 2000, 3000, 4000] },
{ name: 'humidity', type: FieldType.number, values: [1, 3, 5, 7] },
],
}),
];
const config = hideSeriesConfigFactory(event, existingConfig, data);
expect(config).toEqual({
defaults: {},
overrides: [],
});
});
it('should create config override hiding appended series if no previous override exists', () => {
const event: GraphNGLegendEvent = {
mode: GraphNGLegendEventMode.AppendToSelection,
fieldIndex: {
frameIndex: 0,
fieldIndex: 1,
},
};
const existingConfig: FieldConfigSource = {
defaults: {},
overrides: [],
};
const data: DataFrame[] = [
toDataFrame({
fields: [
{ name: 'time', type: FieldType.time, values: [1000, 2000, 3000, 4000] },
{ name: 'temperature', type: FieldType.number, values: [1, 3, 5, 7] },
],
}),
toDataFrame({
fields: [
{ name: 'time', type: FieldType.time, values: [1000, 2000, 3000, 4000] },
{ name: 'humidity', type: FieldType.number, values: [1, 3, 5, 7] },
],
}),
toDataFrame({
fields: [
{ name: 'time', type: FieldType.time, values: [1000, 2000, 3000, 4000] },
{ name: 'pressure', type: FieldType.number, values: [1, 3, 5, 7] },
],
}),
];
const config = hideSeriesConfigFactory(event, existingConfig, data);
expect(config).toEqual({
defaults: {},
overrides: [createOverride(['humidity', 'pressure'])],
});
});
it('should return existing override if invalid index is passed', () => {
const event: GraphNGLegendEvent = {
mode: GraphNGLegendEventMode.ToggleSelection,
fieldIndex: {
frameIndex: 4,
fieldIndex: 1,
},
};
const existingConfig: FieldConfigSource = {
defaults: {},
overrides: [createOverride(['temperature'])],
};
const data: DataFrame[] = [
toDataFrame({
fields: [
{ name: 'time', type: FieldType.time, values: [1000, 2000, 3000, 4000] },
{ name: 'temperature', type: FieldType.number, values: [1, 3, 5, 7] },
],
}),
toDataFrame({
fields: [
{ name: 'time', type: FieldType.time, values: [1000, 2000, 3000, 4000] },
{ name: 'humidity', type: FieldType.number, values: [1, 3, 5, 7] },
],
}),
];
const config = hideSeriesConfigFactory(event, existingConfig, data);
expect(config).toEqual({
defaults: {},
overrides: [createOverride(['temperature'])],
});
});
});
const createOverride = (matchers: string[]) => {
return {
__systemRef: 'hideSeriesFrom',
matcher: {
id: FieldMatcherID.byNames,
options: {
mode: ByNamesMatcherMode.exclude,
names: matchers,
prefix: 'All except:',
readOnly: true,
},
},
properties: [
{
id: 'custom.hideFrom',
value: {
graph: true,
legend: false,
tooltip: false,
},
},
],
};
};

View File

@ -0,0 +1,175 @@
import {
ByNamesMatcherMode,
DataFrame,
DynamicConfigValue,
FieldConfigSource,
FieldMatcherID,
FieldType,
getFieldDisplayName,
isSystemOverrideWithRef,
SystemConfigOverrideRule,
} from '@grafana/data';
import { GraphNGLegendEvent, GraphNGLegendEventMode } from '@grafana/ui';
const displayOverrideRef = 'hideSeriesFrom';
const isHideSeriesOverride = isSystemOverrideWithRef(displayOverrideRef);
export const hideSeriesConfigFactory = (
event: GraphNGLegendEvent,
fieldConfig: FieldConfigSource<any>,
data: DataFrame[]
): FieldConfigSource<any> => {
const { fieldIndex, mode } = event;
const { overrides } = fieldConfig;
const frame = data[fieldIndex.frameIndex];
if (!frame) {
return fieldConfig;
}
const field = frame.fields[fieldIndex.fieldIndex];
if (!field) {
return fieldConfig;
}
const displayName = getFieldDisplayName(field, frame, data);
const currentIndex = overrides.findIndex(isHideSeriesOverride);
if (currentIndex < 0) {
if (mode === GraphNGLegendEventMode.ToggleSelection) {
const override = createOverride([displayName]);
return {
...fieldConfig,
overrides: [override, ...fieldConfig.overrides],
};
}
const displayNames = getDisplayNames(data, displayName);
const override = createOverride(displayNames);
return {
...fieldConfig,
overrides: [override, ...fieldConfig.overrides],
};
}
const overridesCopy = Array.from(overrides);
const [current] = overridesCopy.splice(currentIndex, 1) as SystemConfigOverrideRule[];
if (mode === GraphNGLegendEventMode.ToggleSelection) {
const existing = getExistingDisplayNames(current);
if (existing[0] === displayName && existing.length === 1) {
return {
...fieldConfig,
overrides: overridesCopy,
};
}
const override = createOverride([displayName]);
return {
...fieldConfig,
overrides: [override, ...overridesCopy],
};
}
const override = createExtendedOverride(current, displayName);
if (allFieldsAreExcluded(override, data)) {
return {
...fieldConfig,
overrides: overridesCopy,
};
}
return {
...fieldConfig,
overrides: [override, ...overridesCopy],
};
};
const createExtendedOverride = (current: SystemConfigOverrideRule, displayName: string): SystemConfigOverrideRule => {
const property = current.properties.find(p => p.id === 'custom.hideFrom');
const existing = getExistingDisplayNames(current);
const index = existing.findIndex(name => name === displayName);
if (index < 0) {
existing.push(displayName);
} else {
existing.splice(index, 1);
}
return createOverride(existing, property);
};
const getExistingDisplayNames = (rule: SystemConfigOverrideRule): string[] => {
const names = rule.matcher.options?.names;
if (!Array.isArray(names)) {
return [];
}
return names;
};
const createOverride = (names: string[], property?: DynamicConfigValue): SystemConfigOverrideRule => {
property = property ?? {
id: 'custom.hideFrom',
value: {
graph: true,
legend: false,
tooltip: false,
},
};
return {
__systemRef: displayOverrideRef,
matcher: {
id: FieldMatcherID.byNames,
options: {
mode: ByNamesMatcherMode.exclude,
names: names,
prefix: 'All except:',
readOnly: true,
},
},
properties: [
{
...property,
value: {
graph: true,
legend: false,
tooltip: false,
},
},
],
};
};
const allFieldsAreExcluded = (override: SystemConfigOverrideRule, data: DataFrame[]): boolean => {
return getExistingDisplayNames(override).length === getDisplayNames(data).length;
};
const getDisplayNames = (data: DataFrame[], excludeName?: string): string[] => {
const unique = new Set<string>();
for (const frame of data) {
for (const field of frame.fields) {
if (field.type !== FieldType.number) {
continue;
}
const name = getFieldDisplayName(field, frame, data);
if (name === excludeName) {
continue;
}
unique.add(name);
}
}
return Array.from(unique);
};

View File

@ -15,6 +15,7 @@ import {
ScaleDistribution,
ScaleDistributionConfig,
} from '@grafana/ui';
import { SeriesConfigEditor } from './HideSeriesConfigEditor';
import { GraphPanel } from './GraphPanel';
import { graphPanelChangedHandler } from './migrations';
import { Options } from './types';
@ -154,6 +155,23 @@ export const plugin = new PanelPlugin<Options, GraphFieldConfig>(GraphPanel)
defaultValue: { type: ScaleDistribution.Linear },
shouldApply: f => f.type === FieldType.number,
process: identityOverrideProcessor,
})
.addCustomEditor({
id: 'hideFrom',
name: 'Hide in area',
category: ['Series'],
path: 'hideFrom',
defaultValue: {
tooltip: false,
graph: false,
legend: false,
},
editor: SeriesConfigEditor,
override: SeriesConfigEditor,
shouldApply: () => true,
hideFromDefaults: true,
hideFromOverrides: true,
process: value => value,
});
},
})