diff --git a/packages/grafana-data/src/transformations/index.ts b/packages/grafana-data/src/transformations/index.ts index c8b275ddcb1..98ffbce03c8 100644 --- a/packages/grafana-data/src/transformations/index.ts +++ b/packages/grafana-data/src/transformations/index.ts @@ -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'; diff --git a/packages/grafana-data/src/transformations/matchers/nameMatcher.test.ts b/packages/grafana-data/src/transformations/matchers/nameMatcher.test.ts index 5459cd5fffc..42a653a832c 100644 --- a/packages/grafana-data/src/transformations/matchers/nameMatcher.test.ts +++ b/packages/grafana-data/src/transformations/matchers/nameMatcher.test.ts @@ -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', () => { diff --git a/packages/grafana-data/src/transformations/matchers/nameMatcher.ts b/packages/grafana-data/src/transformations/matchers/nameMatcher.ts index efaa7d0a4d1..24609ad8f4d 100644 --- a/packages/grafana-data/src/transformations/matchers/nameMatcher.ts +++ b/packages/grafana-data/src/transformations/matchers/nameMatcher.ts @@ -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 = { id: FieldMatcherID.byName, @@ -27,22 +49,34 @@ const fieldNameMatcher: FieldMatcherInfo = { }, }; -const multipleFieldNamesMatcher: FieldMatcherInfo = { +const multipleFieldNamesMatcher: FieldMatcherInfo = { 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(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 { 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); diff --git a/packages/grafana-data/src/transformations/transformers/calculateField.ts b/packages/grafana-data/src/transformations/transformers/calculateField.ts index 4d48c100f85..df14714871c 100644 --- a/packages/grafana-data/src/transformations/transformers/calculateField.ts +++ b/packages/grafana-data/src/transformations/transformers/calculateField.ts @@ -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, + }, }); } diff --git a/packages/grafana-data/src/transformations/transformers/filterByName.ts b/packages/grafana-data/src/transformations/transformers/filterByName.ts index c898996afac..93ca0b9a3cc 100644 --- a/packages/grafana-data/src/transformations/transformers/filterByName.ts +++ b/packages/grafana-data/src/transformations/transformers/filterByName.ts @@ -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) { diff --git a/packages/grafana-data/src/types/dataFrame.ts b/packages/grafana-data/src/types/dataFrame.ts index f656662294c..a4cd0c889f5 100644 --- a/packages/grafana-data/src/types/dataFrame.ts +++ b/packages/grafana-data/src/types/dataFrame.ts @@ -202,3 +202,13 @@ export interface FieldCalcs extends Record {} 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; +} diff --git a/packages/grafana-data/src/types/fieldOverrides.ts b/packages/grafana-data/src/types/fieldOverrides.ts index c1db1bd754f..31303f08f4a 100644 --- a/packages/grafana-data/src/types/fieldOverrides.ts +++ b/packages/grafana-data/src/types/fieldOverrides.ts @@ -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(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 { // Defaults applied to all numeric fields defaults: FieldConfig; diff --git a/packages/grafana-ui/src/components/Graph/GraphLegendItem.tsx b/packages/grafana-ui/src/components/Graph/GraphLegendItem.tsx index dcbd62023a9..d78c2047615 100644 --- a/packages/grafana-ui/src/components/Graph/GraphLegendItem.tsx +++ b/packages/grafana-ui/src/components/Graph/GraphLegendItem.tsx @@ -116,7 +116,7 @@ export const GraphLegendTableRow: React.FunctionComponent onLabelClick(item, event); } }} - className={styles.label} + className={cx(styles.label, item.disabled && styles.labelDisabled)} > {item.label} {item.yAxis === 2 && (right y-axis)} diff --git a/packages/grafana-ui/src/components/GraphNG/GraphNG.tsx b/packages/grafana-ui/src/components/GraphNG/GraphNG.tsx index 100891508ba..ce9fa5b80f6 100755 --- a/packages/grafana-ui/src/components/GraphNG/GraphNG.tsx +++ b/packages/grafana-ui/src/components/GraphNG/GraphNG.tsx @@ -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 { 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 = ({ legend, timeRange, timeZone, + onLegendClick, ...plotProps }) => { const alignedFrameWithGapTest = useMemo(() => alignDataFrames(data, fields), [data, fields]); @@ -56,6 +58,7 @@ export const GraphNG: React.FC = ({ const legendItemsRef = useRef([]); 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 = ({ 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); useLayoutEffect(() => { @@ -109,7 +128,6 @@ export const GraphNG: React.FC = ({ }); } - let seriesIdx = 0; const legendItems: LegendItem[] = []; for (let i = 0; i < alignedFrame.fields.length; i++) { @@ -126,6 +144,8 @@ export const GraphNG: React.FC = ({ 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 = ({ }); } - // 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 = ({ 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 = ({ if (hasLegend && legendItemsRef.current.length > 0) { legendElement = ( - + ); } @@ -221,3 +244,10 @@ export const GraphNG: React.FC = ({ ); }; + +const mapMouseEventToMode = (event: React.MouseEvent): GraphNGLegendEventMode => { + if (event.ctrlKey || event.metaKey || event.shiftKey) { + return GraphNGLegendEventMode.AppendToSelection; + } + return GraphNGLegendEventMode.ToggleSelection; +}; diff --git a/packages/grafana-ui/src/components/GraphNG/types.ts b/packages/grafana-ui/src/components/GraphNG/types.ts new file mode 100644 index 00000000000..8db0d42df35 --- /dev/null +++ b/packages/grafana-ui/src/components/GraphNG/types.ts @@ -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; +} diff --git a/packages/grafana-ui/src/components/GraphNG/utils.test.ts b/packages/grafana-ui/src/components/GraphNG/utils.test.ts new file mode 100644 index 00000000000..d59005e198a --- /dev/null +++ b/packages/grafana-ui/src/components/GraphNG/utils.test.ts @@ -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, + }); + }); + }); +}); diff --git a/packages/grafana-ui/src/components/GraphNG/utils.ts b/packages/grafana-ui/src/components/GraphNG/utils.ts index 2ecf1cdfabd..b10b6eec0ff 100755 --- a/packages/grafana-ui/src/components/GraphNG/utils.ts +++ b/packages/grafana-ui/src/components/GraphNG/utils.ts @@ -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 = {}; 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; + }, }; } diff --git a/packages/grafana-ui/src/components/Legend/Legend.tsx b/packages/grafana-ui/src/components/Legend/Legend.tsx index 014aa74302f..2df7e6720d2 100644 --- a/packages/grafana-ui/src/components/Legend/Legend.tsx +++ b/packages/grafana-ui/src/components/Legend/Legend.tsx @@ -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 { diff --git a/packages/grafana-ui/src/components/MatchersUI/FieldNamesMatcherEditor.tsx b/packages/grafana-ui/src/components/MatchersUI/FieldNamesMatcherEditor.tsx new file mode 100644 index 00000000000..0dbec556196 --- /dev/null +++ b/packages/grafana-ui/src/components/MatchersUI/FieldNamesMatcherEditor.tsx @@ -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>(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 ; + } + + const onChange = useCallback( + (selections: Array>) => { + 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 ; +}); +FieldNamesMatcherEditor.displayName = 'FieldNameMatcherEditor'; + +export const fieldNamesMatcherItem: FieldMatcherUIRegistryItem = { + 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 => { + return useMemo(() => { + const names: Set = 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): Array> => { + return useMemo(() => { + return Array.from(displayNames).map(n => ({ + value: n, + label: n, + })); + }, [displayNames]); +}; diff --git a/packages/grafana-ui/src/components/MatchersUI/fieldMatchersUI.ts b/packages/grafana-ui/src/components/MatchersUI/fieldMatchersUI.ts index 525dde086d1..26cda2b0ad4 100644 --- a/packages/grafana-ui/src/components/MatchersUI/fieldMatchersUI.ts +++ b/packages/grafana-ui/src/components/MatchersUI/fieldMatchersUI.ts @@ -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>(() => { - return [fieldNameMatcherItem, fieldNameByRegexMatcherItem, fieldTypeMatcherItem, fieldsByFrameRefIdItem]; -}); +export const fieldMatchersUI = new Registry>(() => [ + fieldNameMatcherItem, + fieldNameByRegexMatcherItem, + fieldTypeMatcherItem, + fieldsByFrameRefIdItem, + fieldNamesMatcherItem, +]); diff --git a/packages/grafana-ui/src/components/Sparkline/Sparkline.tsx b/packages/grafana-ui/src/components/Sparkline/Sparkline.tsx index a035956253d..64eeb917c54 100755 --- a/packages/grafana-ui/src/components/Sparkline/Sparkline.tsx +++ b/packages/grafana-ui/src/components/Sparkline/Sparkline.tsx @@ -160,6 +160,7 @@ export class Sparkline extends PureComponent { data={{ frame: data, isGap: () => true, // any null is a gap + getDataFrameFieldIndex: () => undefined, }} config={configBuilder} width={width} diff --git a/packages/grafana-ui/src/components/index.ts b/packages/grafana-ui/src/components/index.ts index 6469d52a6c2..99c7c9df87a 100644 --- a/packages/grafana-ui/src/components/index.ts +++ b/packages/grafana-ui/src/components/index.ts @@ -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'; diff --git a/packages/grafana-ui/src/components/uPlot/Plot.test.tsx b/packages/grafana-ui/src/components/uPlot/Plot.test.tsx index ea0a819853f..04c9ab68995 100644 --- a/packages/grafana-ui/src/components/uPlot/Plot.test.tsx +++ b/packages/grafana-ui/src/components/uPlot/Plot.test.tsx @@ -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( { 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( { 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( @@ -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( { 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( { }); }); }); + +const createPlotData = (frame: DataFrame): AlignedFrameWithGapTest => { + return { + frame, + isGap: () => false, + getDataFrameFieldIndex: () => undefined, + }; +}; diff --git a/packages/grafana-ui/src/components/uPlot/config.ts b/packages/grafana-ui/src/components/uPlot/config.ts index a7c74756a59..23019000cdb 100644 --- a/packages/grafana-ui/src/components/uPlot/config.ts +++ b/packages/grafana-ui/src/components/uPlot/config.ts @@ -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; } /** diff --git a/packages/grafana-ui/src/components/uPlot/config/UPlotConfigBuilder.test.ts b/packages/grafana-ui/src/components/uPlot/config/UPlotConfigBuilder.test.ts index 74708ec79c8..ef7e899d039 100644 --- a/packages/grafana-ui/src/components/uPlot/config/UPlotConfigBuilder.test.ts +++ b/packages/grafana-ui/src/components/uPlot/config/UPlotConfigBuilder.test.ts @@ -402,6 +402,7 @@ describe('UPlotConfigBuilder', () => { "stroke": "#00ff00", }, "scale": "scale-x", + "show": true, "spanGaps": false, "stroke": "#0000ff", "width": 1, diff --git a/packages/grafana-ui/src/components/uPlot/config/UPlotSeriesBuilder.ts b/packages/grafana-ui/src/components/uPlot/config/UPlotSeriesBuilder.ts index 48194755f20..14ba5996a9e 100755 --- a/packages/grafana-ui/src/components/uPlot/config/UPlotSeriesBuilder.ts +++ b/packages/grafana-ui/src/components/uPlot/config/UPlotSeriesBuilder.ts @@ -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 { @@ -29,6 +30,7 @@ export class UPlotSeriesBuilder extends PlotConfigBuilder { pointSize, scaleKey, spanNulls, + show = true, } = this.props; let lineConfig: Partial = {}; @@ -66,6 +68,7 @@ export class UPlotSeriesBuilder extends PlotConfigBuilder { return { scale: scaleKey, spanGaps: spanNulls, + show, fill: this.getFill(), ...lineConfig, ...pointsConfig, diff --git a/packages/grafana-ui/src/components/uPlot/plugins/TooltipPlugin.tsx b/packages/grafana-ui/src/components/uPlot/plugins/TooltipPlugin.tsx index 7f3948d5707..5aa3e1e1f03 100644 --- a/packages/grafana-ui/src/components/uPlot/plugins/TooltipPlugin.tsx +++ b/packages/grafana-ui/src/components/uPlot/plugins/TooltipPlugin.tsx @@ -69,6 +69,10 @@ export const TooltipPlugin: React.FC = ({ mode = 'single', t return agg; } + if (f.config.custom?.hideFrom?.tooltip) { + return agg; + } + return [ ...agg, { diff --git a/packages/grafana-ui/src/components/uPlot/types.ts b/packages/grafana-ui/src/components/uPlot/types.ts index f9dcc32f8b5..bbb81724a12 100755 --- a/packages/grafana-ui/src/components/uPlot/types.ts +++ b/packages/grafana-ui/src/components/uPlot/types.ts @@ -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; @@ -33,4 +33,5 @@ export abstract class PlotConfigBuilder { export interface AlignedFrameWithGapTest { frame: DataFrame; isGap: Series.isGap; + getDataFrameFieldIndex: (alignedFieldIndex: number) => DataFrameFieldIndex | undefined; } diff --git a/public/app/app.ts b/public/app/app.ts index f523cff4440..5835374ba2b 100644 --- a/public/app/app.ts +++ b/public/app/app.ts @@ -102,6 +102,7 @@ export class GrafanaApp { standardFieldConfigEditorRegistry.setInit(getStandardFieldConfigs); standardTransformersRegistry.setInit(getStandardTransformers); variableAdapters.setInit(getDefaultVariableAdapters); + setVariableQueryRunner(new VariableQueryRunner()); app.config( diff --git a/public/app/features/dashboard/components/PanelEditor/DynamicConfigValueEditor.tsx b/public/app/features/dashboard/components/PanelEditor/DynamicConfigValueEditor.tsx index 28b207d83b3..cad57c6c8a7 100644 --- a/public/app/features/dashboard/components/PanelEditor/DynamicConfigValueEditor.tsx +++ b/public/app/features/dashboard/components/PanelEditor/DynamicConfigValueEditor.tsx @@ -11,6 +11,7 @@ interface DynamicConfigValueEditorProps { context: FieldOverrideContext; onRemove: () => void; isCollapsible?: boolean; + isSystemOverride?: boolean; } export const DynamicConfigValueEditor: React.FC = ({ @@ -20,6 +21,7 @@ export const DynamicConfigValueEditor: React.FC = onChange, onRemove, isCollapsible, + isSystemOverride, }) => { const theme = useTheme(); const styles = getStyles(theme); @@ -37,9 +39,11 @@ export const DynamicConfigValueEditor: React.FC = {item.name} {!isExpanded && includeCounter && item.getItemsCount && } -
- -
+ {!isSystemOverride && ( +
+ +
+ )} ); diff --git a/public/app/features/dashboard/components/PanelEditor/OverrideEditor.tsx b/public/app/features/dashboard/components/PanelEditor/OverrideEditor.tsx index f80e1e7b1df..8745763cba6 100644 --- a/public/app/features/dashboard/components/PanelEditor/OverrideEditor.tsx +++ b/public/app/features/dashboard/components/PanelEditor/OverrideEditor.tsx @@ -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 = ({ ); }; + const isSystemOverride = isSystemOverrideGuard(override); + return ( @@ -150,6 +153,7 @@ export const OverrideEditor: React.FC = ({ <> {override.properties.map((p, j) => { const item = registry.getIfExists(p.id); + console.log('item', item); if (!item) { return
Unknown property: {p.id}
; @@ -162,6 +166,7 @@ export const OverrideEditor: React.FC = ({ onDynamicConfigValueChange(j, value)} onRemove={() => onDynamicConfigValueRemove(j)} property={p} @@ -173,7 +178,7 @@ export const OverrideEditor: React.FC = ({ /> ); })} - {override.matcher.options && ( + {!isSystemOverride && override.matcher.options && (
= props => { variant="secondary" options={fieldMatchersUI .list() + .filter(o => !o.excludeFromPicker) .map>(i => ({ label: i.name, value: i.id, description: i.description }))} onChange={value => onOverrideAdd(value)} isFullWidth={false} diff --git a/public/app/plugins/panel/graph3/GraphPanel.tsx b/public/app/plugins/panel/graph3/GraphPanel.tsx index 1e19fa3c437..4490bdf5dd9 100644 --- a/public/app/plugins/panel/graph3/GraphPanel.tsx +++ b/public/app/plugins/panel/graph3/GraphPanel.tsx @@ -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 {} @@ -15,9 +16,18 @@ export const GraphPanel: React.FC = ({ width, height, options, + fieldConfig, onChangeTimeRange, + onFieldConfigChange, replaceVariables, }) => { + const onLegendClick = useCallback( + (event: GraphNGLegendEvent) => { + onFieldConfigChange(hideSeriesConfigFactory(event, fieldConfig, data.series)); + }, + [fieldConfig, onFieldConfigChange, data.series] + ); + return ( = ({ width={width} height={height} legend={options.legend} + onLegendClick={onLegendClick} > diff --git a/public/app/plugins/panel/graph3/HideSeriesConfigEditor.tsx b/public/app/plugins/panel/graph3/HideSeriesConfigEditor.tsx new file mode 100644 index 00000000000..1e14a0b4fc6 --- /dev/null +++ b/public/app/plugins/panel/graph3/HideSeriesConfigEditor.tsx @@ -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> = props => { + const { value, onChange } = props; + + const onChangeToggle = useCallback( + (prop: keyof HideSeriesConfig) => { + onChange({ ...value, [prop]: !value[prop] }); + }, + [value, onChange] + ); + + return ( + + {Object.keys(value).map((key: keyof HideSeriesConfig) => { + return ( + onChangeToggle(key)} + key={key} + label={_.startCase(key)} + selected={value[key]} + /> + ); + })} + + ); +}; diff --git a/public/app/plugins/panel/graph3/hideSeriesConfigFactory.test.ts b/public/app/plugins/panel/graph3/hideSeriesConfigFactory.test.ts new file mode 100644 index 00000000000..97fc8e02ce0 --- /dev/null +++ b/public/app/plugins/panel/graph3/hideSeriesConfigFactory.test.ts @@ -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, + }, + }, + ], + }; +}; diff --git a/public/app/plugins/panel/graph3/hideSeriesConfigFactory.ts b/public/app/plugins/panel/graph3/hideSeriesConfigFactory.ts new file mode 100644 index 00000000000..02eed59e07c --- /dev/null +++ b/public/app/plugins/panel/graph3/hideSeriesConfigFactory.ts @@ -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, + data: DataFrame[] +): FieldConfigSource => { + 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(); + + 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); +}; diff --git a/public/app/plugins/panel/graph3/module.tsx b/public/app/plugins/panel/graph3/module.tsx index c691afe7f35..4e20ced8066 100644 --- a/public/app/plugins/panel/graph3/module.tsx +++ b/public/app/plugins/panel/graph3/module.tsx @@ -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(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, }); }, })