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