mirror of https://github.com/grafana/grafana
GraphNG: adding possibility to toggle tooltip, graph and legend for series (#29575)
parent
78d72007d8
commit
7aa6926ef6
@ -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; |
||||||
|
} |
||||||
@ -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, |
||||||
|
}); |
||||||
|
}); |
||||||
|
}); |
||||||
|
}); |
||||||
@ -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]); |
||||||
|
}; |
||||||
@ -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> |
||||||
|
); |
||||||
|
}; |
||||||
@ -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, |
||||||
|
}, |
||||||
|
}, |
||||||
|
], |
||||||
|
}; |
||||||
|
}; |
||||||
@ -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); |
||||||
|
}; |
||||||
Loading…
Reference in new issue