mirror of
https://github.com/grafana/grafana.git
synced 2024-11-22 08:56:43 -06:00
GraphNG: adding possibility to toggle tooltip, graph and legend for series (#29575)
This commit is contained in:
parent
78d72007d8
commit
7aa6926ef6
@ -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';
|
||||
|
@ -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', () => {
|
||||
|
@ -9,6 +9,28 @@ export interface RegexpOrNamesMatcherOptions {
|
||||
names?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Mode to be able to toggle if the names matcher should match fields in provided
|
||||
* list or all except provided names.
|
||||
* @public
|
||||
*/
|
||||
export enum ByNamesMatcherMode {
|
||||
exclude = 'exclude',
|
||||
include = 'include',
|
||||
}
|
||||
|
||||
/**
|
||||
* Options to instruct the by names matcher to either match all fields in given list
|
||||
* or all except the fields in the list.
|
||||
* @public
|
||||
*/
|
||||
export interface ByNamesMatcherOptions {
|
||||
mode?: ByNamesMatcherMode;
|
||||
names?: string[];
|
||||
readOnly?: boolean;
|
||||
prefix?: string;
|
||||
}
|
||||
|
||||
// General Field matcher
|
||||
const fieldNameMatcher: FieldMatcherInfo<string> = {
|
||||
id: FieldMatcherID.byName,
|
||||
@ -27,22 +49,34 @@ const fieldNameMatcher: FieldMatcherInfo<string> = {
|
||||
},
|
||||
};
|
||||
|
||||
const multipleFieldNamesMatcher: FieldMatcherInfo<string[]> = {
|
||||
const multipleFieldNamesMatcher: FieldMatcherInfo<ByNamesMatcherOptions> = {
|
||||
id: FieldMatcherID.byNames,
|
||||
name: 'Field Names',
|
||||
description: 'match any of the given the field names',
|
||||
defaultOptions: [],
|
||||
defaultOptions: {
|
||||
mode: ByNamesMatcherMode.include,
|
||||
names: [],
|
||||
},
|
||||
|
||||
get: (names: string[]): FieldMatcher => {
|
||||
get: (options: ByNamesMatcherOptions): FieldMatcher => {
|
||||
const { names, mode = ByNamesMatcherMode.include } = options;
|
||||
const uniqueNames = new Set<string>(names ?? []);
|
||||
|
||||
return (field: Field, frame: DataFrame, allFrames: DataFrame[]) => {
|
||||
if (mode === ByNamesMatcherMode.exclude) {
|
||||
return !uniqueNames.has(getFieldDisplayName(field, frame, allFrames));
|
||||
}
|
||||
return uniqueNames.has(getFieldDisplayName(field, frame, allFrames));
|
||||
};
|
||||
},
|
||||
|
||||
getOptionsDisplayText: (names: string[]): string => {
|
||||
return `Field names: ${names.join(', ')}`;
|
||||
getOptionsDisplayText: (options: ByNamesMatcherOptions): string => {
|
||||
const { names, mode } = options;
|
||||
const displayText = (names ?? []).join(', ');
|
||||
if (mode === ByNamesMatcherMode.exclude) {
|
||||
return `All except: ${displayText}`;
|
||||
}
|
||||
return `All of: ${displayText}`;
|
||||
},
|
||||
};
|
||||
|
||||
@ -99,7 +133,10 @@ const regexpOrMultipleNamesMatcher: FieldMatcherInfo<RegexpOrNamesMatcherOptions
|
||||
|
||||
get: (options: RegexpOrNamesMatcherOptions): FieldMatcher => {
|
||||
const regexpMatcher = regexpFieldNameMatcher.get(options?.pattern || '');
|
||||
const namesMatcher = multipleFieldNamesMatcher.get(options?.names ?? []);
|
||||
const namesMatcher = multipleFieldNamesMatcher.get({
|
||||
mode: ByNamesMatcherMode.include,
|
||||
names: options?.names ?? [],
|
||||
});
|
||||
|
||||
return (field: Field, frame: DataFrame, allFrames: DataFrame[]) => {
|
||||
return namesMatcher(field, frame, allFrames) || regexpMatcher(field, frame, allFrames);
|
||||
|
@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -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) {
|
||||
|
@ -202,3 +202,13 @@ export interface FieldCalcs extends Record<string, any> {}
|
||||
export const TIME_SERIES_VALUE_FIELD_NAME = 'Value';
|
||||
export const TIME_SERIES_TIME_FIELD_NAME = 'Time';
|
||||
export const TIME_SERIES_METRIC_FIELD_NAME = 'Metric';
|
||||
|
||||
/**
|
||||
* Describes where a specific data frame field is located within a
|
||||
* dataset of type DataFrame[]
|
||||
* @public
|
||||
*/
|
||||
export interface DataFrameFieldIndex {
|
||||
frameIndex: number;
|
||||
fieldIndex: number;
|
||||
}
|
||||
|
@ -15,6 +15,38 @@ export interface ConfigOverrideRule {
|
||||
properties: DynamicConfigValue[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Describes config override rules created when interacting with Grafana.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
export interface SystemConfigOverrideRule extends ConfigOverrideRule {
|
||||
__systemRef: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Guard functionality to check if an override rule is of type {@link SystemConfigOverrideRule}.
|
||||
* It will only return true if the {@link SystemConfigOverrideRule} has the passed systemRef.
|
||||
*
|
||||
* @param ref system override reference
|
||||
* @internal
|
||||
*/
|
||||
export function isSystemOverrideWithRef<T extends SystemConfigOverrideRule>(ref: string) {
|
||||
return (override: ConfigOverrideRule): override is T => {
|
||||
return (override as T)?.__systemRef === ref;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Guard functionality to check if an override rule is of type {@link SystemConfigOverrideRule}.
|
||||
* It will return true if the {@link SystemConfigOverrideRule} has any systemRef set.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
export const isSystemOverride = (override: ConfigOverrideRule): override is SystemConfigOverrideRule => {
|
||||
return typeof (override as SystemConfigOverrideRule)?.__systemRef === 'string';
|
||||
};
|
||||
|
||||
export interface FieldConfigSource<TOptions extends object = any> {
|
||||
// Defaults applied to all numeric fields
|
||||
defaults: FieldConfig<TOptions>;
|
||||
|
@ -116,7 +116,7 @@ export const GraphLegendTableRow: React.FunctionComponent<GraphLegendItemProps>
|
||||
onLabelClick(item, event);
|
||||
}
|
||||
}}
|
||||
className={styles.label}
|
||||
className={cx(styles.label, item.disabled && styles.labelDisabled)}
|
||||
>
|
||||
{item.label} {item.yAxis === 2 && <span className={styles.yAxisLabel}>(right y-axis)</span>}
|
||||
</div>
|
||||
|
@ -20,6 +20,7 @@ import { LegendDisplayMode, LegendItem, LegendOptions } from '../Legend/Legend';
|
||||
import { GraphLegend } from '../Graph/GraphLegend';
|
||||
import { UPlotConfigBuilder } from '../uPlot/config/UPlotConfigBuilder';
|
||||
import { useRevision } from '../uPlot/hooks';
|
||||
import { GraphNGLegendEvent, GraphNGLegendEventMode } from './types';
|
||||
|
||||
const defaultFormatter = (v: any) => (v == null ? '-' : v.toFixed(1));
|
||||
|
||||
@ -27,11 +28,11 @@ export interface XYFieldMatchers {
|
||||
x: FieldMatcher;
|
||||
y: FieldMatcher;
|
||||
}
|
||||
|
||||
export interface GraphNGProps extends Omit<PlotProps, 'data' | 'config'> {
|
||||
data: DataFrame[];
|
||||
legend?: LegendOptions;
|
||||
fields?: XYFieldMatchers; // default will assume timeseries data
|
||||
onLegendClick?: (event: GraphNGLegendEvent) => void;
|
||||
}
|
||||
|
||||
const defaultConfig: GraphFieldConfig = {
|
||||
@ -49,6 +50,7 @@ export const GraphNG: React.FC<GraphNGProps> = ({
|
||||
legend,
|
||||
timeRange,
|
||||
timeZone,
|
||||
onLegendClick,
|
||||
...plotProps
|
||||
}) => {
|
||||
const alignedFrameWithGapTest = useMemo(() => alignDataFrames(data, fields), [data, fields]);
|
||||
@ -56,6 +58,7 @@ export const GraphNG: React.FC<GraphNGProps> = ({
|
||||
const legendItemsRef = useRef<LegendItem[]>([]);
|
||||
const hasLegend = useRef(legend && legend.displayMode !== LegendDisplayMode.Hidden);
|
||||
const alignedFrame = alignedFrameWithGapTest?.frame;
|
||||
const getDataFrameFieldIndex = alignedFrameWithGapTest?.getDataFrameFieldIndex;
|
||||
|
||||
const compareFrames = useCallback((a?: DataFrame | null, b?: DataFrame | null) => {
|
||||
if (a && b) {
|
||||
@ -64,6 +67,22 @@ export const GraphNG: React.FC<GraphNGProps> = ({
|
||||
return false;
|
||||
}, []);
|
||||
|
||||
const onLabelClick = useCallback(
|
||||
(legend: LegendItem, event: React.MouseEvent) => {
|
||||
const { fieldIndex } = legend;
|
||||
|
||||
if (!onLegendClick || !fieldIndex) {
|
||||
return;
|
||||
}
|
||||
|
||||
onLegendClick({
|
||||
fieldIndex,
|
||||
mode: mapMouseEventToMode(event),
|
||||
});
|
||||
},
|
||||
[onLegendClick, data]
|
||||
);
|
||||
|
||||
// reference change will not triger re-render
|
||||
const currentTimeRange = useRef<TimeRange>(timeRange);
|
||||
useLayoutEffect(() => {
|
||||
@ -109,7 +128,6 @@ export const GraphNG: React.FC<GraphNGProps> = ({
|
||||
});
|
||||
}
|
||||
|
||||
let seriesIdx = 0;
|
||||
const legendItems: LegendItem[] = [];
|
||||
|
||||
for (let i = 0; i < alignedFrame.fields.length; i++) {
|
||||
@ -126,6 +144,8 @@ export const GraphNG: React.FC<GraphNGProps> = ({
|
||||
|
||||
const fmt = field.display ?? defaultFormatter;
|
||||
const scaleKey = config.unit || '__fixed';
|
||||
const colorMode = getFieldColorModeForField(field);
|
||||
const seriesColor = colorMode.getCalculator(field, theme)(0, 0);
|
||||
|
||||
if (customConfig.axisPlacement !== AxisPlacement.Hidden) {
|
||||
// The builder will manage unique scaleKeys and combine where appropriate
|
||||
@ -147,11 +167,6 @@ export const GraphNG: React.FC<GraphNGProps> = ({
|
||||
});
|
||||
}
|
||||
|
||||
// need to update field state here because we use a transform to merge framesP
|
||||
field.state = { ...field.state, seriesIndex: seriesIdx };
|
||||
|
||||
const colorMode = getFieldColorModeForField(field);
|
||||
const seriesColor = colorMode.getCalculator(field, theme)(0, 0);
|
||||
const showPoints = customConfig.drawStyle === DrawStyle.Points ? PointVisibility.Always : customConfig.showPoints;
|
||||
|
||||
builder.addSeries({
|
||||
@ -165,20 +180,23 @@ export const GraphNG: React.FC<GraphNGProps> = ({
|
||||
pointColor: customConfig.pointColor ?? seriesColor,
|
||||
fillOpacity: customConfig.fillOpacity,
|
||||
spanNulls: customConfig.spanNulls || false,
|
||||
show: !customConfig.hideFrom?.graph,
|
||||
fillGradient: customConfig.fillGradient,
|
||||
});
|
||||
|
||||
if (hasLegend.current) {
|
||||
if (hasLegend.current && !customConfig.hideFrom?.legend) {
|
||||
const axisPlacement = builder.getAxisPlacement(scaleKey);
|
||||
// we need to add this as dep or move it to be done outside.
|
||||
const dataFrameFieldIndex = getDataFrameFieldIndex ? getDataFrameFieldIndex(i) : undefined;
|
||||
|
||||
legendItems.push({
|
||||
disabled: field.config.custom?.hideFrom?.graph ?? false,
|
||||
fieldIndex: dataFrameFieldIndex,
|
||||
color: seriesColor,
|
||||
label: getFieldDisplayName(field, alignedFrame),
|
||||
yAxis: axisPlacement === AxisPlacement.Left ? 1 : 2,
|
||||
});
|
||||
}
|
||||
|
||||
seriesIdx++;
|
||||
}
|
||||
|
||||
legendItemsRef.current = legendItems;
|
||||
@ -198,7 +216,12 @@ export const GraphNG: React.FC<GraphNGProps> = ({
|
||||
if (hasLegend && legendItemsRef.current.length > 0) {
|
||||
legendElement = (
|
||||
<VizLayout.Legend position={legend!.placement} maxHeight="35%" maxWidth="60%">
|
||||
<GraphLegend placement={legend!.placement} items={legendItemsRef.current} displayMode={legend!.displayMode} />
|
||||
<GraphLegend
|
||||
onLabelClick={onLabelClick}
|
||||
placement={legend!.placement}
|
||||
items={legendItemsRef.current}
|
||||
displayMode={legend!.displayMode}
|
||||
/>
|
||||
</VizLayout.Legend>
|
||||
);
|
||||
}
|
||||
@ -221,3 +244,10 @@ export const GraphNG: React.FC<GraphNGProps> = ({
|
||||
</VizLayout>
|
||||
);
|
||||
};
|
||||
|
||||
const mapMouseEventToMode = (event: React.MouseEvent): GraphNGLegendEventMode => {
|
||||
if (event.ctrlKey || event.metaKey || event.shiftKey) {
|
||||
return GraphNGLegendEventMode.AppendToSelection;
|
||||
}
|
||||
return GraphNGLegendEventMode.ToggleSelection;
|
||||
};
|
||||
|
20
packages/grafana-ui/src/components/GraphNG/types.ts
Normal file
20
packages/grafana-ui/src/components/GraphNG/types.ts
Normal file
@ -0,0 +1,20 @@
|
||||
import { DataFrameFieldIndex } from '@grafana/data';
|
||||
|
||||
/**
|
||||
* Mode to describe if a legend is isolated/selected or being appended to an existing
|
||||
* series selection.
|
||||
* @public
|
||||
*/
|
||||
export enum GraphNGLegendEventMode {
|
||||
ToggleSelection = 'select',
|
||||
AppendToSelection = 'append',
|
||||
}
|
||||
|
||||
/**
|
||||
* Event being triggered when the user interact with the Graph legend.
|
||||
* @public
|
||||
*/
|
||||
export interface GraphNGLegendEvent {
|
||||
fieldIndex: DataFrameFieldIndex;
|
||||
mode: GraphNGLegendEventMode;
|
||||
}
|
218
packages/grafana-ui/src/components/GraphNG/utils.test.ts
Normal file
218
packages/grafana-ui/src/components/GraphNG/utils.test.ts
Normal file
@ -0,0 +1,218 @@
|
||||
import { ArrayVector, DataFrame, FieldType, toDataFrame } from '@grafana/data';
|
||||
import { AlignedFrameWithGapTest } from '../uPlot/types';
|
||||
import { alignDataFrames } from './utils';
|
||||
|
||||
describe('alignDataFrames', () => {
|
||||
describe('aligned frame', () => {
|
||||
it('should align multiple data frames into one data frame', () => {
|
||||
const data: DataFrame[] = [
|
||||
toDataFrame({
|
||||
fields: [
|
||||
{ name: 'time', type: FieldType.time, values: [1000, 2000, 3000, 4000] },
|
||||
{ name: 'temperature A', type: FieldType.number, values: [1, 3, 5, 7] },
|
||||
],
|
||||
}),
|
||||
toDataFrame({
|
||||
fields: [
|
||||
{ name: 'time', type: FieldType.time, values: [1000, 2000, 3000, 4000] },
|
||||
{ name: 'temperature B', type: FieldType.number, values: [0, 2, 6, 7] },
|
||||
],
|
||||
}),
|
||||
];
|
||||
|
||||
const aligned = alignDataFrames(data);
|
||||
|
||||
expect(aligned?.frame.fields).toEqual([
|
||||
{
|
||||
config: {},
|
||||
state: {},
|
||||
name: 'time',
|
||||
type: FieldType.time,
|
||||
values: new ArrayVector([1000, 2000, 3000, 4000]),
|
||||
},
|
||||
{
|
||||
config: {},
|
||||
state: {
|
||||
displayName: 'temperature A',
|
||||
seriesIndex: 0,
|
||||
},
|
||||
name: 'temperature A',
|
||||
type: FieldType.number,
|
||||
values: new ArrayVector([1, 3, 5, 7]),
|
||||
},
|
||||
{
|
||||
config: {},
|
||||
state: {
|
||||
displayName: 'temperature B',
|
||||
seriesIndex: 1,
|
||||
},
|
||||
name: 'temperature B',
|
||||
type: FieldType.number,
|
||||
values: new ArrayVector([0, 2, 6, 7]),
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should align multiple data frames into one data frame but only keep first time field', () => {
|
||||
const data: DataFrame[] = [
|
||||
toDataFrame({
|
||||
fields: [
|
||||
{ name: 'time', type: FieldType.time, values: [1000, 2000, 3000, 4000] },
|
||||
{ name: 'temperature', type: FieldType.number, values: [1, 3, 5, 7] },
|
||||
],
|
||||
}),
|
||||
toDataFrame({
|
||||
fields: [
|
||||
{ name: 'time2', type: FieldType.time, values: [1000, 2000, 3000, 4000] },
|
||||
{ name: 'temperature B', type: FieldType.number, values: [0, 2, 6, 7] },
|
||||
],
|
||||
}),
|
||||
];
|
||||
|
||||
const aligned = alignDataFrames(data);
|
||||
|
||||
expect(aligned?.frame.fields).toEqual([
|
||||
{
|
||||
config: {},
|
||||
state: {},
|
||||
name: 'time',
|
||||
type: FieldType.time,
|
||||
values: new ArrayVector([1000, 2000, 3000, 4000]),
|
||||
},
|
||||
{
|
||||
config: {},
|
||||
state: {
|
||||
displayName: 'temperature',
|
||||
seriesIndex: 0,
|
||||
},
|
||||
name: 'temperature',
|
||||
type: FieldType.number,
|
||||
values: new ArrayVector([1, 3, 5, 7]),
|
||||
},
|
||||
{
|
||||
config: {},
|
||||
state: {
|
||||
displayName: 'temperature B',
|
||||
seriesIndex: 1,
|
||||
},
|
||||
name: 'temperature B',
|
||||
type: FieldType.number,
|
||||
values: new ArrayVector([0, 2, 6, 7]),
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should align multiple data frames into one data frame and skip non-numeric fields', () => {
|
||||
const data: DataFrame[] = [
|
||||
toDataFrame({
|
||||
fields: [
|
||||
{ name: 'time', type: FieldType.time, values: [1000, 2000, 3000, 4000] },
|
||||
{ name: 'temperature', type: FieldType.number, values: [1, 3, 5, 7] },
|
||||
{ name: 'state', type: FieldType.string, values: ['on', 'off', 'off', 'on'] },
|
||||
],
|
||||
}),
|
||||
];
|
||||
|
||||
const aligned = alignDataFrames(data);
|
||||
|
||||
expect(aligned?.frame.fields).toEqual([
|
||||
{
|
||||
config: {},
|
||||
state: {},
|
||||
name: 'time',
|
||||
type: FieldType.time,
|
||||
values: new ArrayVector([1000, 2000, 3000, 4000]),
|
||||
},
|
||||
{
|
||||
config: {},
|
||||
state: {
|
||||
displayName: 'temperature',
|
||||
seriesIndex: 0,
|
||||
},
|
||||
name: 'temperature',
|
||||
type: FieldType.number,
|
||||
values: new ArrayVector([1, 3, 5, 7]),
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should align multiple data frames into one data frame and skip non-numeric fields', () => {
|
||||
const data: DataFrame[] = [
|
||||
toDataFrame({
|
||||
fields: [
|
||||
{ name: 'time', type: FieldType.time, values: [1000, 2000, 3000, 4000] },
|
||||
{ name: 'temperature', type: FieldType.number, values: [1, 3, 5, 7] },
|
||||
{ name: 'state', type: FieldType.string, values: ['on', 'off', 'off', 'on'] },
|
||||
],
|
||||
}),
|
||||
];
|
||||
|
||||
const aligned = alignDataFrames(data);
|
||||
|
||||
expect(aligned?.frame.fields).toEqual([
|
||||
{
|
||||
config: {},
|
||||
state: {},
|
||||
name: 'time',
|
||||
type: FieldType.time,
|
||||
values: new ArrayVector([1000, 2000, 3000, 4000]),
|
||||
},
|
||||
{
|
||||
config: {},
|
||||
state: {
|
||||
displayName: 'temperature',
|
||||
seriesIndex: 0,
|
||||
},
|
||||
name: 'temperature',
|
||||
type: FieldType.number,
|
||||
values: new ArrayVector([1, 3, 5, 7]),
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getDataFrameFieldIndex', () => {
|
||||
let aligned: AlignedFrameWithGapTest | null;
|
||||
|
||||
beforeAll(() => {
|
||||
const data: DataFrame[] = [
|
||||
toDataFrame({
|
||||
fields: [
|
||||
{ name: 'time', type: FieldType.time, values: [1000, 2000, 3000, 4000] },
|
||||
{ name: 'temperature A', type: FieldType.number, values: [1, 3, 5, 7] },
|
||||
],
|
||||
}),
|
||||
toDataFrame({
|
||||
fields: [
|
||||
{ name: 'time', type: FieldType.time, values: [1000, 2000, 3000, 4000] },
|
||||
{ name: 'temperature B', type: FieldType.number, values: [0, 2, 6, 7] },
|
||||
{ name: 'humidity', type: FieldType.number, values: [0, 2, 6, 7] },
|
||||
],
|
||||
}),
|
||||
toDataFrame({
|
||||
fields: [
|
||||
{ name: 'time', type: FieldType.time, values: [1000, 2000, 3000, 4000] },
|
||||
{ name: 'temperature C', type: FieldType.number, values: [0, 2, 6, 7] },
|
||||
],
|
||||
}),
|
||||
];
|
||||
|
||||
aligned = alignDataFrames(data);
|
||||
});
|
||||
|
||||
it.each`
|
||||
yDim | index
|
||||
${1} | ${[0, 1]}
|
||||
${2} | ${[1, 1]}
|
||||
${3} | ${[1, 2]}
|
||||
${4} | ${[2, 1]}
|
||||
`('should return correct index for yDim', ({ yDim, index }) => {
|
||||
const [frameIndex, fieldIndex] = index;
|
||||
|
||||
expect(aligned?.getDataFrameFieldIndex(yDim)).toEqual({
|
||||
frameIndex,
|
||||
fieldIndex,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
@ -6,6 +6,9 @@ import {
|
||||
Field,
|
||||
fieldMatchers,
|
||||
FieldMatcherID,
|
||||
FieldType,
|
||||
FieldState,
|
||||
DataFrameFieldIndex,
|
||||
} from '@grafana/data';
|
||||
import { AlignedFrameWithGapTest } from '../uPlot/types';
|
||||
import uPlot, { AlignedData, JoinNullMode } from 'uplot';
|
||||
@ -43,6 +46,7 @@ export function mapDimesions(match: XYFieldMatchers, frame: DataFrame, frames?:
|
||||
export function alignDataFrames(frames: DataFrame[], fields?: XYFieldMatchers): AlignedFrameWithGapTest | null {
|
||||
const valuesFromFrames: AlignedData[] = [];
|
||||
const sourceFields: Field[] = [];
|
||||
const sourceFieldsRefs: Record<number, DataFrameFieldIndex> = {};
|
||||
const nullModes: JoinNullMode[][] = [];
|
||||
|
||||
// Default to timeseries config
|
||||
@ -53,11 +57,12 @@ export function alignDataFrames(frames: DataFrame[], fields?: XYFieldMatchers):
|
||||
};
|
||||
}
|
||||
|
||||
for (const frame of frames) {
|
||||
for (let frameIndex = 0; frameIndex < frames.length; frameIndex++) {
|
||||
const frame = frames[frameIndex];
|
||||
const dims = mapDimesions(fields, frame, frames);
|
||||
|
||||
if (!(dims.x.length && dims.y.length)) {
|
||||
continue; // both x and y matched something!
|
||||
continue; // no numeric and no time fields
|
||||
}
|
||||
|
||||
if (dims.x.length > 1) {
|
||||
@ -76,16 +81,23 @@ export function alignDataFrames(frames: DataFrame[], fields?: XYFieldMatchers):
|
||||
dims.x[0].values.toArray(), // The x axis (time)
|
||||
];
|
||||
|
||||
// Add the Y values
|
||||
for (const field of dims.y) {
|
||||
for (let fieldIndex = 0; fieldIndex < frame.fields.length; fieldIndex++) {
|
||||
const field = frame.fields[fieldIndex];
|
||||
|
||||
if (!fields.y(field, frame, frames)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let values = field.values.toArray();
|
||||
let joinNullMode = field.config.custom.spanNulls ? 0 : 2;
|
||||
let joinNullMode = field.config.custom?.spanNulls ? 0 : 2;
|
||||
|
||||
if (field.config.nullValueMode === NullValueMode.AsZero) {
|
||||
values = values.map(v => (v === null ? 0 : v));
|
||||
joinNullMode = 0;
|
||||
}
|
||||
|
||||
sourceFieldsRefs[sourceFields.length] = { frameIndex, fieldIndex };
|
||||
|
||||
alignedData.push(values);
|
||||
nullModesFrame.push(joinNullMode);
|
||||
|
||||
@ -109,15 +121,33 @@ export function alignDataFrames(frames: DataFrame[], fields?: XYFieldMatchers):
|
||||
throw new Error('outerJoinValues lost a field?');
|
||||
}
|
||||
|
||||
let seriesIdx = 0;
|
||||
// Replace the values from the outer-join field
|
||||
return {
|
||||
frame: {
|
||||
length: alignedData![0].length,
|
||||
fields: alignedData!.map((vals, idx) => ({
|
||||
...sourceFields[idx],
|
||||
values: new ArrayVector(vals),
|
||||
})),
|
||||
fields: alignedData!.map((vals, idx) => {
|
||||
let state: FieldState = { ...sourceFields[idx].state };
|
||||
|
||||
if (sourceFields[idx].type !== FieldType.time) {
|
||||
state.seriesIndex = seriesIdx;
|
||||
seriesIdx++;
|
||||
}
|
||||
|
||||
return {
|
||||
...sourceFields[idx],
|
||||
state,
|
||||
values: new ArrayVector(vals),
|
||||
};
|
||||
}),
|
||||
},
|
||||
isGap,
|
||||
getDataFrameFieldIndex: (alignedFieldIndex: number) => {
|
||||
const index = sourceFieldsRefs[alignedFieldIndex];
|
||||
if (!index) {
|
||||
throw new Error(`Could not find index for ${alignedFieldIndex}`);
|
||||
}
|
||||
return index;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
@ -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 {
|
||||
|
@ -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]);
|
||||
};
|
@ -4,7 +4,12 @@ import { fieldNameMatcherItem } from './FieldNameMatcherEditor';
|
||||
import { fieldNameByRegexMatcherItem } from './FieldNameByRegexMatcherEditor';
|
||||
import { fieldTypeMatcherItem } from './FieldTypeMatcherEditor';
|
||||
import { fieldsByFrameRefIdItem } from './FieldsByFrameRefIdMatcher';
|
||||
import { fieldNamesMatcherItem } from './FieldNamesMatcherEditor';
|
||||
|
||||
export const fieldMatchersUI = new Registry<FieldMatcherUIRegistryItem<any>>(() => {
|
||||
return [fieldNameMatcherItem, fieldNameByRegexMatcherItem, fieldTypeMatcherItem, fieldsByFrameRefIdItem];
|
||||
});
|
||||
export const fieldMatchersUI = new Registry<FieldMatcherUIRegistryItem<any>>(() => [
|
||||
fieldNameMatcherItem,
|
||||
fieldNameByRegexMatcherItem,
|
||||
fieldTypeMatcherItem,
|
||||
fieldsByFrameRefIdItem,
|
||||
fieldNamesMatcherItem,
|
||||
]);
|
||||
|
@ -160,6 +160,7 @@ export class Sparkline extends PureComponent<Props, State> {
|
||||
data={{
|
||||
frame: data,
|
||||
isGap: () => true, // any null is a gap
|
||||
getDataFrameFieldIndex: () => undefined,
|
||||
}}
|
||||
config={configBuilder}
|
||||
width={width}
|
||||
|
@ -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';
|
||||
|
@ -1,11 +1,12 @@
|
||||
import React from 'react';
|
||||
import { UPlotChart } from './Plot';
|
||||
import { act, render } from '@testing-library/react';
|
||||
import { ArrayVector, dateTime, FieldConfig, FieldType, MutableDataFrame } from '@grafana/data';
|
||||
import { ArrayVector, DataFrame, dateTime, FieldConfig, FieldType, MutableDataFrame } from '@grafana/data';
|
||||
import { GraphFieldConfig, DrawStyle } from '../uPlot/config';
|
||||
import uPlot from 'uplot';
|
||||
import createMockRaf from 'mock-raf';
|
||||
import { UPlotConfigBuilder } from './config/UPlotConfigBuilder';
|
||||
import { AlignedFrameWithGapTest } from './types';
|
||||
|
||||
const mockRaf = createMockRaf();
|
||||
const setDataMock = jest.fn();
|
||||
@ -68,7 +69,7 @@ describe('UPlotChart', () => {
|
||||
|
||||
it('destroys uPlot instance when component unmounts', () => {
|
||||
const { data, timeRange, config } = mockData();
|
||||
const uPlotData = { frame: data, isGap: () => false };
|
||||
const uPlotData = createPlotData(data);
|
||||
|
||||
const { unmount } = render(
|
||||
<UPlotChart
|
||||
@ -94,7 +95,7 @@ describe('UPlotChart', () => {
|
||||
describe('data update', () => {
|
||||
it('skips uPlot reinitialization when there are no field config changes', () => {
|
||||
const { data, timeRange, config } = mockData();
|
||||
const uPlotData = { frame: data, isGap: () => false };
|
||||
const uPlotData = createPlotData(data);
|
||||
|
||||
const { rerender } = render(
|
||||
<UPlotChart
|
||||
@ -135,7 +136,7 @@ describe('UPlotChart', () => {
|
||||
describe('config update', () => {
|
||||
it('skips uPlot intialization for width and height equal 0', async () => {
|
||||
const { data, timeRange, config } = mockData();
|
||||
const uPlotData = { frame: data, isGap: () => false };
|
||||
const uPlotData = createPlotData(data);
|
||||
|
||||
const { queryAllByTestId } = render(
|
||||
<UPlotChart data={uPlotData} config={config} timeRange={timeRange} timeZone={'browser'} width={0} height={0} />
|
||||
@ -147,7 +148,7 @@ describe('UPlotChart', () => {
|
||||
|
||||
it('reinitializes uPlot when config changes', () => {
|
||||
const { data, timeRange, config } = mockData();
|
||||
const uPlotData = { frame: data, isGap: () => false };
|
||||
const uPlotData = createPlotData(data);
|
||||
|
||||
const { rerender } = render(
|
||||
<UPlotChart
|
||||
@ -184,7 +185,7 @@ describe('UPlotChart', () => {
|
||||
|
||||
it('skips uPlot reinitialization when only dimensions change', () => {
|
||||
const { data, timeRange, config } = mockData();
|
||||
const uPlotData = { frame: data, isGap: () => false };
|
||||
const uPlotData = createPlotData(data);
|
||||
|
||||
const { rerender } = render(
|
||||
<UPlotChart
|
||||
@ -219,3 +220,11 @@ describe('UPlotChart', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
const createPlotData = (frame: DataFrame): AlignedFrameWithGapTest => {
|
||||
return {
|
||||
frame,
|
||||
isGap: () => false,
|
||||
getDataFrameFieldIndex: () => undefined,
|
||||
};
|
||||
};
|
||||
|
@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -402,6 +402,7 @@ describe('UPlotConfigBuilder', () => {
|
||||
"stroke": "#00ff00",
|
||||
},
|
||||
"scale": "scale-x",
|
||||
"show": true,
|
||||
"spanGaps": false,
|
||||
"stroke": "#0000ff",
|
||||
"width": 1,
|
||||
|
@ -15,6 +15,7 @@ import { PlotConfigBuilder } from '../types';
|
||||
export interface SeriesProps extends LineConfig, AreaConfig, PointsConfig {
|
||||
drawStyle: DrawStyle;
|
||||
scaleKey: string;
|
||||
show?: boolean;
|
||||
}
|
||||
|
||||
export class UPlotSeriesBuilder extends PlotConfigBuilder<SeriesProps, Series> {
|
||||
@ -29,6 +30,7 @@ export class UPlotSeriesBuilder extends PlotConfigBuilder<SeriesProps, Series> {
|
||||
pointSize,
|
||||
scaleKey,
|
||||
spanNulls,
|
||||
show = true,
|
||||
} = this.props;
|
||||
|
||||
let lineConfig: Partial<Series> = {};
|
||||
@ -66,6 +68,7 @@ export class UPlotSeriesBuilder extends PlotConfigBuilder<SeriesProps, Series> {
|
||||
return {
|
||||
scale: scaleKey,
|
||||
spanGaps: spanNulls,
|
||||
show,
|
||||
fill: this.getFill(),
|
||||
...lineConfig,
|
||||
...pointsConfig,
|
||||
|
@ -69,6 +69,10 @@ export const TooltipPlugin: React.FC<TooltipPluginProps> = ({ mode = 'single', t
|
||||
return agg;
|
||||
}
|
||||
|
||||
if (f.config.custom?.hideFrom?.tooltip) {
|
||||
return agg;
|
||||
}
|
||||
|
||||
return [
|
||||
...agg,
|
||||
{
|
||||
|
@ -1,6 +1,6 @@
|
||||
import React from 'react';
|
||||
import uPlot, { Options, Series, Hooks } from 'uplot';
|
||||
import { DataFrame, TimeRange, TimeZone } from '@grafana/data';
|
||||
import { DataFrame, DataFrameFieldIndex, TimeRange, TimeZone } from '@grafana/data';
|
||||
import { UPlotConfigBuilder } from './config/UPlotConfigBuilder';
|
||||
|
||||
export type PlotSeriesConfig = Pick<Options, 'series' | 'scales' | 'axes' | 'cursor'>;
|
||||
@ -33,4 +33,5 @@ export abstract class PlotConfigBuilder<P, T> {
|
||||
export interface AlignedFrameWithGapTest {
|
||||
frame: DataFrame;
|
||||
isGap: Series.isGap;
|
||||
getDataFrameFieldIndex: (alignedFieldIndex: number) => DataFrameFieldIndex | undefined;
|
||||
}
|
||||
|
@ -102,6 +102,7 @@ export class GrafanaApp {
|
||||
standardFieldConfigEditorRegistry.setInit(getStandardFieldConfigs);
|
||||
standardTransformersRegistry.setInit(getStandardTransformers);
|
||||
variableAdapters.setInit(getDefaultVariableAdapters);
|
||||
|
||||
setVariableQueryRunner(new VariableQueryRunner());
|
||||
|
||||
app.config(
|
||||
|
@ -11,6 +11,7 @@ interface DynamicConfigValueEditorProps {
|
||||
context: FieldOverrideContext;
|
||||
onRemove: () => void;
|
||||
isCollapsible?: boolean;
|
||||
isSystemOverride?: boolean;
|
||||
}
|
||||
|
||||
export const DynamicConfigValueEditor: React.FC<DynamicConfigValueEditorProps> = ({
|
||||
@ -20,6 +21,7 @@ export const DynamicConfigValueEditor: React.FC<DynamicConfigValueEditorProps> =
|
||||
onChange,
|
||||
onRemove,
|
||||
isCollapsible,
|
||||
isSystemOverride,
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
const styles = getStyles(theme);
|
||||
@ -37,9 +39,11 @@ export const DynamicConfigValueEditor: React.FC<DynamicConfigValueEditorProps> =
|
||||
{item.name}
|
||||
{!isExpanded && includeCounter && item.getItemsCount && <Counter value={item.getItemsCount(property.value)} />}
|
||||
</Label>
|
||||
<div>
|
||||
<IconButton name="times" onClick={onRemove} />
|
||||
</div>
|
||||
{!isSystemOverride && (
|
||||
<div>
|
||||
<IconButton name="times" onClick={onRemove} />
|
||||
</div>
|
||||
)}
|
||||
</HorizontalGroup>
|
||||
);
|
||||
|
||||
|
@ -6,6 +6,7 @@ import {
|
||||
FieldConfigOptionsRegistry,
|
||||
FieldConfigProperty,
|
||||
GrafanaTheme,
|
||||
isSystemOverride as isSystemOverrideGuard,
|
||||
VariableSuggestionsScope,
|
||||
} from '@grafana/data';
|
||||
import {
|
||||
@ -136,6 +137,8 @@ export const OverrideEditor: React.FC<OverrideEditorProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
const isSystemOverride = isSystemOverrideGuard(override);
|
||||
|
||||
return (
|
||||
<OptionsGroup renderTitle={renderOverrideTitle} id={name} key={name}>
|
||||
<Field label={matcherLabel}>
|
||||
@ -150,6 +153,7 @@ export const OverrideEditor: React.FC<OverrideEditorProps> = ({
|
||||
<>
|
||||
{override.properties.map((p, j) => {
|
||||
const item = registry.getIfExists(p.id);
|
||||
console.log('item', item);
|
||||
|
||||
if (!item) {
|
||||
return <div>Unknown property: {p.id}</div>;
|
||||
@ -162,6 +166,7 @@ export const OverrideEditor: React.FC<OverrideEditorProps> = ({
|
||||
<DynamicConfigValueEditor
|
||||
key={`${p.id}/${j}`}
|
||||
isCollapsible={isCollapsible}
|
||||
isSystemOverride={isSystemOverride}
|
||||
onChange={value => onDynamicConfigValueChange(j, value)}
|
||||
onRemove={() => onDynamicConfigValueRemove(j)}
|
||||
property={p}
|
||||
@ -173,7 +178,7 @@ export const OverrideEditor: React.FC<OverrideEditorProps> = ({
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{override.matcher.options && (
|
||||
{!isSystemOverride && override.matcher.options && (
|
||||
<div className={styles.propertyPickerWrapper}>
|
||||
<ValuePicker
|
||||
label="Add override property"
|
||||
|
@ -82,6 +82,7 @@ export const OverrideFieldConfigEditor: React.FC<Props> = props => {
|
||||
variant="secondary"
|
||||
options={fieldMatchersUI
|
||||
.list()
|
||||
.filter(o => !o.excludeFromPicker)
|
||||
.map<SelectableValue<string>>(i => ({ label: i.name, value: i.id, description: i.description }))}
|
||||
onChange={value => onOverrideAdd(value)}
|
||||
isFullWidth={false}
|
||||
|
@ -1,9 +1,10 @@
|
||||
import React from 'react';
|
||||
import { TooltipPlugin, ZoomPlugin, GraphNG } from '@grafana/ui';
|
||||
import React, { useCallback } from 'react';
|
||||
import { TooltipPlugin, ZoomPlugin, GraphNG, GraphNGLegendEvent } from '@grafana/ui';
|
||||
import { PanelProps } from '@grafana/data';
|
||||
import { Options } from './types';
|
||||
import { AnnotationsPlugin } from './plugins/AnnotationsPlugin';
|
||||
import { ExemplarsPlugin } from './plugins/ExemplarsPlugin';
|
||||
import { hideSeriesConfigFactory } from './hideSeriesConfigFactory';
|
||||
import { ContextMenuPlugin } from './plugins/ContextMenuPlugin';
|
||||
|
||||
interface GraphPanelProps extends PanelProps<Options> {}
|
||||
@ -15,9 +16,18 @@ export const GraphPanel: React.FC<GraphPanelProps> = ({
|
||||
width,
|
||||
height,
|
||||
options,
|
||||
fieldConfig,
|
||||
onChangeTimeRange,
|
||||
onFieldConfigChange,
|
||||
replaceVariables,
|
||||
}) => {
|
||||
const onLegendClick = useCallback(
|
||||
(event: GraphNGLegendEvent) => {
|
||||
onFieldConfigChange(hideSeriesConfigFactory(event, fieldConfig, data.series));
|
||||
},
|
||||
[fieldConfig, onFieldConfigChange, data.series]
|
||||
);
|
||||
|
||||
return (
|
||||
<GraphNG
|
||||
data={data.series}
|
||||
@ -26,6 +36,7 @@ export const GraphPanel: React.FC<GraphPanelProps> = ({
|
||||
width={width}
|
||||
height={height}
|
||||
legend={options.legend}
|
||||
onLegendClick={onLegendClick}
|
||||
>
|
||||
<TooltipPlugin mode={options.tooltipOptions.mode as any} timeZone={timeZone} />
|
||||
<ZoomPlugin onZoom={onChangeTimeRange} />
|
||||
|
32
public/app/plugins/panel/graph3/HideSeriesConfigEditor.tsx
Normal file
32
public/app/plugins/panel/graph3/HideSeriesConfigEditor.tsx
Normal file
@ -0,0 +1,32 @@
|
||||
import React, { useCallback } from 'react';
|
||||
import _ from 'lodash';
|
||||
import { FilterPill, HorizontalGroup } from '@grafana/ui';
|
||||
import { FieldConfigEditorProps } from '@grafana/data';
|
||||
import { HideSeriesConfig } from '@grafana/ui/src/components/uPlot/config';
|
||||
|
||||
export const SeriesConfigEditor: React.FC<FieldConfigEditorProps<HideSeriesConfig, {}>> = props => {
|
||||
const { value, onChange } = props;
|
||||
|
||||
const onChangeToggle = useCallback(
|
||||
(prop: keyof HideSeriesConfig) => {
|
||||
onChange({ ...value, [prop]: !value[prop] });
|
||||
},
|
||||
[value, onChange]
|
||||
);
|
||||
|
||||
return (
|
||||
<HorizontalGroup spacing="xs">
|
||||
{Object.keys(value).map((key: keyof HideSeriesConfig) => {
|
||||
return (
|
||||
<FilterPill
|
||||
icon={value[key] ? 'eye-slash' : 'eye'}
|
||||
onClick={() => onChangeToggle(key)}
|
||||
key={key}
|
||||
label={_.startCase(key)}
|
||||
selected={value[key]}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</HorizontalGroup>
|
||||
);
|
||||
};
|
419
public/app/plugins/panel/graph3/hideSeriesConfigFactory.test.ts
Normal file
419
public/app/plugins/panel/graph3/hideSeriesConfigFactory.test.ts
Normal file
@ -0,0 +1,419 @@
|
||||
import {
|
||||
ByNamesMatcherMode,
|
||||
DataFrame,
|
||||
FieldConfigSource,
|
||||
FieldMatcherID,
|
||||
FieldType,
|
||||
toDataFrame,
|
||||
} from '@grafana/data';
|
||||
import { GraphNGLegendEvent, GraphNGLegendEventMode } from '@grafana/ui';
|
||||
import { hideSeriesConfigFactory } from './hideSeriesConfigFactory';
|
||||
|
||||
describe('hideSeriesConfigFactory', () => {
|
||||
it('should create config override matching one series', () => {
|
||||
const event: GraphNGLegendEvent = {
|
||||
mode: GraphNGLegendEventMode.ToggleSelection,
|
||||
fieldIndex: {
|
||||
frameIndex: 0,
|
||||
fieldIndex: 1,
|
||||
},
|
||||
};
|
||||
|
||||
const existingConfig: FieldConfigSource = {
|
||||
defaults: {},
|
||||
overrides: [],
|
||||
};
|
||||
|
||||
const data: DataFrame[] = [
|
||||
toDataFrame({
|
||||
fields: [
|
||||
{ name: 'time', type: FieldType.time, values: [1000, 2000, 3000, 4000] },
|
||||
{ name: 'temperature', type: FieldType.number, values: [1, 3, 5, 7] },
|
||||
],
|
||||
}),
|
||||
];
|
||||
|
||||
const config = hideSeriesConfigFactory(event, existingConfig, data);
|
||||
|
||||
expect(config).toEqual({
|
||||
defaults: {},
|
||||
overrides: [createOverride(['temperature'])],
|
||||
});
|
||||
});
|
||||
|
||||
it('should create config override matching one series if selected with others', () => {
|
||||
const event: GraphNGLegendEvent = {
|
||||
mode: GraphNGLegendEventMode.ToggleSelection,
|
||||
fieldIndex: {
|
||||
frameIndex: 0,
|
||||
fieldIndex: 1,
|
||||
},
|
||||
};
|
||||
|
||||
const existingConfig: FieldConfigSource = {
|
||||
defaults: {},
|
||||
overrides: [createOverride(['temperature', 'humidity'])],
|
||||
};
|
||||
|
||||
const data: DataFrame[] = [
|
||||
toDataFrame({
|
||||
fields: [
|
||||
{ name: 'time', type: FieldType.time, values: [1000, 2000, 3000, 4000] },
|
||||
{ name: 'temperature', type: FieldType.number, values: [1, 3, 5, 7] },
|
||||
],
|
||||
}),
|
||||
toDataFrame({
|
||||
fields: [
|
||||
{ name: 'time', type: FieldType.time, values: [1000, 2000, 3000, 4000] },
|
||||
{ name: 'humidity', type: FieldType.number, values: [1, 3, 5, 7] },
|
||||
],
|
||||
}),
|
||||
toDataFrame({
|
||||
fields: [
|
||||
{ name: 'time', type: FieldType.time, values: [1000, 2000, 3000, 4000] },
|
||||
{ name: 'pressure', type: FieldType.number, values: [1, 3, 5, 7] },
|
||||
],
|
||||
}),
|
||||
];
|
||||
|
||||
const config = hideSeriesConfigFactory(event, existingConfig, data);
|
||||
|
||||
expect(config).toEqual({
|
||||
defaults: {},
|
||||
overrides: [createOverride(['temperature'])],
|
||||
});
|
||||
});
|
||||
|
||||
it('should create config override that append series to existing override', () => {
|
||||
const event: GraphNGLegendEvent = {
|
||||
mode: GraphNGLegendEventMode.AppendToSelection,
|
||||
fieldIndex: {
|
||||
frameIndex: 1,
|
||||
fieldIndex: 1,
|
||||
},
|
||||
};
|
||||
|
||||
const existingConfig: FieldConfigSource = {
|
||||
defaults: {},
|
||||
overrides: [createOverride(['temperature'])],
|
||||
};
|
||||
|
||||
const data: DataFrame[] = [
|
||||
toDataFrame({
|
||||
fields: [
|
||||
{ name: 'time', type: FieldType.time, values: [1000, 2000, 3000, 4000] },
|
||||
{ name: 'temperature', type: FieldType.number, values: [1, 3, 5, 7] },
|
||||
],
|
||||
}),
|
||||
toDataFrame({
|
||||
fields: [
|
||||
{ name: 'time', type: FieldType.time, values: [1000, 2000, 3000, 4000] },
|
||||
{ name: 'humidity', type: FieldType.number, values: [1, 3, 5, 7] },
|
||||
],
|
||||
}),
|
||||
toDataFrame({
|
||||
fields: [
|
||||
{ name: 'time', type: FieldType.time, values: [1000, 2000, 3000, 4000] },
|
||||
{ name: 'pressure', type: FieldType.number, values: [1, 3, 5, 7] },
|
||||
],
|
||||
}),
|
||||
];
|
||||
|
||||
const config = hideSeriesConfigFactory(event, existingConfig, data);
|
||||
|
||||
expect(config).toEqual({
|
||||
defaults: {},
|
||||
overrides: [createOverride(['temperature', 'humidity'])],
|
||||
});
|
||||
});
|
||||
|
||||
it('should create config override that hides all series if appending only existing series', () => {
|
||||
const event: GraphNGLegendEvent = {
|
||||
mode: GraphNGLegendEventMode.AppendToSelection,
|
||||
fieldIndex: {
|
||||
frameIndex: 0,
|
||||
fieldIndex: 1,
|
||||
},
|
||||
};
|
||||
|
||||
const existingConfig: FieldConfigSource = {
|
||||
defaults: {},
|
||||
overrides: [createOverride(['temperature'])],
|
||||
};
|
||||
|
||||
const data: DataFrame[] = [
|
||||
toDataFrame({
|
||||
fields: [
|
||||
{ name: 'time', type: FieldType.time, values: [1000, 2000, 3000, 4000] },
|
||||
{ name: 'temperature', type: FieldType.number, values: [1, 3, 5, 7] },
|
||||
],
|
||||
}),
|
||||
toDataFrame({
|
||||
fields: [
|
||||
{ name: 'time', type: FieldType.time, values: [1000, 2000, 3000, 4000] },
|
||||
{ name: 'humidity', type: FieldType.number, values: [1, 3, 5, 7] },
|
||||
],
|
||||
}),
|
||||
];
|
||||
|
||||
const config = hideSeriesConfigFactory(event, existingConfig, data);
|
||||
|
||||
expect(config).toEqual({
|
||||
defaults: {},
|
||||
overrides: [createOverride([])],
|
||||
});
|
||||
});
|
||||
|
||||
it('should create config override that removes series if appending existing field', () => {
|
||||
const event: GraphNGLegendEvent = {
|
||||
mode: GraphNGLegendEventMode.AppendToSelection,
|
||||
fieldIndex: {
|
||||
frameIndex: 0,
|
||||
fieldIndex: 1,
|
||||
},
|
||||
};
|
||||
|
||||
const existingConfig: FieldConfigSource = {
|
||||
defaults: {},
|
||||
overrides: [createOverride(['temperature', 'humidity'])],
|
||||
};
|
||||
|
||||
const data: DataFrame[] = [
|
||||
toDataFrame({
|
||||
fields: [
|
||||
{ name: 'time', type: FieldType.time, values: [1000, 2000, 3000, 4000] },
|
||||
{ name: 'temperature', type: FieldType.number, values: [1, 3, 5, 7] },
|
||||
],
|
||||
}),
|
||||
toDataFrame({
|
||||
fields: [
|
||||
{ name: 'time', type: FieldType.time, values: [1000, 2000, 3000, 4000] },
|
||||
{ name: 'humidity', type: FieldType.number, values: [1, 3, 5, 7] },
|
||||
],
|
||||
}),
|
||||
];
|
||||
|
||||
const config = hideSeriesConfigFactory(event, existingConfig, data);
|
||||
|
||||
expect(config).toEqual({
|
||||
defaults: {},
|
||||
overrides: [createOverride(['humidity'])],
|
||||
});
|
||||
});
|
||||
|
||||
it('should create config override replacing existing series', () => {
|
||||
const event: GraphNGLegendEvent = {
|
||||
mode: GraphNGLegendEventMode.ToggleSelection,
|
||||
fieldIndex: {
|
||||
frameIndex: 1,
|
||||
fieldIndex: 1,
|
||||
},
|
||||
};
|
||||
|
||||
const existingConfig: FieldConfigSource = {
|
||||
defaults: {},
|
||||
overrides: [createOverride(['temperature'])],
|
||||
};
|
||||
|
||||
const data: DataFrame[] = [
|
||||
toDataFrame({
|
||||
fields: [
|
||||
{ name: 'time', type: FieldType.time, values: [1000, 2000, 3000, 4000] },
|
||||
{ name: 'temperature', type: FieldType.number, values: [1, 3, 5, 7] },
|
||||
],
|
||||
}),
|
||||
toDataFrame({
|
||||
fields: [
|
||||
{ name: 'time', type: FieldType.time, values: [1000, 2000, 3000, 4000] },
|
||||
{ name: 'humidity', type: FieldType.number, values: [1, 3, 5, 7] },
|
||||
],
|
||||
}),
|
||||
];
|
||||
|
||||
const config = hideSeriesConfigFactory(event, existingConfig, data);
|
||||
|
||||
expect(config).toEqual({
|
||||
defaults: {},
|
||||
overrides: [createOverride(['humidity'])],
|
||||
});
|
||||
});
|
||||
|
||||
it('should create config override removing existing series', () => {
|
||||
const event: GraphNGLegendEvent = {
|
||||
mode: GraphNGLegendEventMode.ToggleSelection,
|
||||
fieldIndex: {
|
||||
frameIndex: 0,
|
||||
fieldIndex: 1,
|
||||
},
|
||||
};
|
||||
|
||||
const existingConfig: FieldConfigSource = {
|
||||
defaults: {},
|
||||
overrides: [createOverride(['temperature'])],
|
||||
};
|
||||
|
||||
const data: DataFrame[] = [
|
||||
toDataFrame({
|
||||
fields: [
|
||||
{ name: 'time', type: FieldType.time, values: [1000, 2000, 3000, 4000] },
|
||||
{ name: 'temperature', type: FieldType.number, values: [1, 3, 5, 7] },
|
||||
],
|
||||
}),
|
||||
toDataFrame({
|
||||
fields: [
|
||||
{ name: 'time', type: FieldType.time, values: [1000, 2000, 3000, 4000] },
|
||||
{ name: 'humidity', type: FieldType.number, values: [1, 3, 5, 7] },
|
||||
],
|
||||
}),
|
||||
];
|
||||
|
||||
const config = hideSeriesConfigFactory(event, existingConfig, data);
|
||||
|
||||
expect(config).toEqual({
|
||||
defaults: {},
|
||||
overrides: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('should remove override if all fields are appended', () => {
|
||||
const event: GraphNGLegendEvent = {
|
||||
mode: GraphNGLegendEventMode.AppendToSelection,
|
||||
fieldIndex: {
|
||||
frameIndex: 1,
|
||||
fieldIndex: 1,
|
||||
},
|
||||
};
|
||||
|
||||
const existingConfig: FieldConfigSource = {
|
||||
defaults: {},
|
||||
overrides: [createOverride(['temperature'])],
|
||||
};
|
||||
|
||||
const data: DataFrame[] = [
|
||||
toDataFrame({
|
||||
fields: [
|
||||
{ name: 'time', type: FieldType.time, values: [1000, 2000, 3000, 4000] },
|
||||
{ name: 'temperature', type: FieldType.number, values: [1, 3, 5, 7] },
|
||||
],
|
||||
}),
|
||||
toDataFrame({
|
||||
fields: [
|
||||
{ name: 'time', type: FieldType.time, values: [1000, 2000, 3000, 4000] },
|
||||
{ name: 'humidity', type: FieldType.number, values: [1, 3, 5, 7] },
|
||||
],
|
||||
}),
|
||||
];
|
||||
|
||||
const config = hideSeriesConfigFactory(event, existingConfig, data);
|
||||
|
||||
expect(config).toEqual({
|
||||
defaults: {},
|
||||
overrides: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('should create config override hiding appended series if no previous override exists', () => {
|
||||
const event: GraphNGLegendEvent = {
|
||||
mode: GraphNGLegendEventMode.AppendToSelection,
|
||||
fieldIndex: {
|
||||
frameIndex: 0,
|
||||
fieldIndex: 1,
|
||||
},
|
||||
};
|
||||
|
||||
const existingConfig: FieldConfigSource = {
|
||||
defaults: {},
|
||||
overrides: [],
|
||||
};
|
||||
|
||||
const data: DataFrame[] = [
|
||||
toDataFrame({
|
||||
fields: [
|
||||
{ name: 'time', type: FieldType.time, values: [1000, 2000, 3000, 4000] },
|
||||
{ name: 'temperature', type: FieldType.number, values: [1, 3, 5, 7] },
|
||||
],
|
||||
}),
|
||||
toDataFrame({
|
||||
fields: [
|
||||
{ name: 'time', type: FieldType.time, values: [1000, 2000, 3000, 4000] },
|
||||
{ name: 'humidity', type: FieldType.number, values: [1, 3, 5, 7] },
|
||||
],
|
||||
}),
|
||||
toDataFrame({
|
||||
fields: [
|
||||
{ name: 'time', type: FieldType.time, values: [1000, 2000, 3000, 4000] },
|
||||
{ name: 'pressure', type: FieldType.number, values: [1, 3, 5, 7] },
|
||||
],
|
||||
}),
|
||||
];
|
||||
|
||||
const config = hideSeriesConfigFactory(event, existingConfig, data);
|
||||
|
||||
expect(config).toEqual({
|
||||
defaults: {},
|
||||
overrides: [createOverride(['humidity', 'pressure'])],
|
||||
});
|
||||
});
|
||||
|
||||
it('should return existing override if invalid index is passed', () => {
|
||||
const event: GraphNGLegendEvent = {
|
||||
mode: GraphNGLegendEventMode.ToggleSelection,
|
||||
fieldIndex: {
|
||||
frameIndex: 4,
|
||||
fieldIndex: 1,
|
||||
},
|
||||
};
|
||||
|
||||
const existingConfig: FieldConfigSource = {
|
||||
defaults: {},
|
||||
overrides: [createOverride(['temperature'])],
|
||||
};
|
||||
|
||||
const data: DataFrame[] = [
|
||||
toDataFrame({
|
||||
fields: [
|
||||
{ name: 'time', type: FieldType.time, values: [1000, 2000, 3000, 4000] },
|
||||
{ name: 'temperature', type: FieldType.number, values: [1, 3, 5, 7] },
|
||||
],
|
||||
}),
|
||||
toDataFrame({
|
||||
fields: [
|
||||
{ name: 'time', type: FieldType.time, values: [1000, 2000, 3000, 4000] },
|
||||
{ name: 'humidity', type: FieldType.number, values: [1, 3, 5, 7] },
|
||||
],
|
||||
}),
|
||||
];
|
||||
|
||||
const config = hideSeriesConfigFactory(event, existingConfig, data);
|
||||
|
||||
expect(config).toEqual({
|
||||
defaults: {},
|
||||
overrides: [createOverride(['temperature'])],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
const createOverride = (matchers: string[]) => {
|
||||
return {
|
||||
__systemRef: 'hideSeriesFrom',
|
||||
matcher: {
|
||||
id: FieldMatcherID.byNames,
|
||||
options: {
|
||||
mode: ByNamesMatcherMode.exclude,
|
||||
names: matchers,
|
||||
prefix: 'All except:',
|
||||
readOnly: true,
|
||||
},
|
||||
},
|
||||
properties: [
|
||||
{
|
||||
id: 'custom.hideFrom',
|
||||
value: {
|
||||
graph: true,
|
||||
legend: false,
|
||||
tooltip: false,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
};
|
175
public/app/plugins/panel/graph3/hideSeriesConfigFactory.ts
Normal file
175
public/app/plugins/panel/graph3/hideSeriesConfigFactory.ts
Normal file
@ -0,0 +1,175 @@
|
||||
import {
|
||||
ByNamesMatcherMode,
|
||||
DataFrame,
|
||||
DynamicConfigValue,
|
||||
FieldConfigSource,
|
||||
FieldMatcherID,
|
||||
FieldType,
|
||||
getFieldDisplayName,
|
||||
isSystemOverrideWithRef,
|
||||
SystemConfigOverrideRule,
|
||||
} from '@grafana/data';
|
||||
import { GraphNGLegendEvent, GraphNGLegendEventMode } from '@grafana/ui';
|
||||
|
||||
const displayOverrideRef = 'hideSeriesFrom';
|
||||
const isHideSeriesOverride = isSystemOverrideWithRef(displayOverrideRef);
|
||||
|
||||
export const hideSeriesConfigFactory = (
|
||||
event: GraphNGLegendEvent,
|
||||
fieldConfig: FieldConfigSource<any>,
|
||||
data: DataFrame[]
|
||||
): FieldConfigSource<any> => {
|
||||
const { fieldIndex, mode } = event;
|
||||
const { overrides } = fieldConfig;
|
||||
|
||||
const frame = data[fieldIndex.frameIndex];
|
||||
|
||||
if (!frame) {
|
||||
return fieldConfig;
|
||||
}
|
||||
|
||||
const field = frame.fields[fieldIndex.fieldIndex];
|
||||
|
||||
if (!field) {
|
||||
return fieldConfig;
|
||||
}
|
||||
|
||||
const displayName = getFieldDisplayName(field, frame, data);
|
||||
const currentIndex = overrides.findIndex(isHideSeriesOverride);
|
||||
|
||||
if (currentIndex < 0) {
|
||||
if (mode === GraphNGLegendEventMode.ToggleSelection) {
|
||||
const override = createOverride([displayName]);
|
||||
|
||||
return {
|
||||
...fieldConfig,
|
||||
overrides: [override, ...fieldConfig.overrides],
|
||||
};
|
||||
}
|
||||
|
||||
const displayNames = getDisplayNames(data, displayName);
|
||||
const override = createOverride(displayNames);
|
||||
|
||||
return {
|
||||
...fieldConfig,
|
||||
overrides: [override, ...fieldConfig.overrides],
|
||||
};
|
||||
}
|
||||
|
||||
const overridesCopy = Array.from(overrides);
|
||||
const [current] = overridesCopy.splice(currentIndex, 1) as SystemConfigOverrideRule[];
|
||||
|
||||
if (mode === GraphNGLegendEventMode.ToggleSelection) {
|
||||
const existing = getExistingDisplayNames(current);
|
||||
|
||||
if (existing[0] === displayName && existing.length === 1) {
|
||||
return {
|
||||
...fieldConfig,
|
||||
overrides: overridesCopy,
|
||||
};
|
||||
}
|
||||
|
||||
const override = createOverride([displayName]);
|
||||
|
||||
return {
|
||||
...fieldConfig,
|
||||
overrides: [override, ...overridesCopy],
|
||||
};
|
||||
}
|
||||
|
||||
const override = createExtendedOverride(current, displayName);
|
||||
|
||||
if (allFieldsAreExcluded(override, data)) {
|
||||
return {
|
||||
...fieldConfig,
|
||||
overrides: overridesCopy,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...fieldConfig,
|
||||
overrides: [override, ...overridesCopy],
|
||||
};
|
||||
};
|
||||
|
||||
const createExtendedOverride = (current: SystemConfigOverrideRule, displayName: string): SystemConfigOverrideRule => {
|
||||
const property = current.properties.find(p => p.id === 'custom.hideFrom');
|
||||
const existing = getExistingDisplayNames(current);
|
||||
const index = existing.findIndex(name => name === displayName);
|
||||
|
||||
if (index < 0) {
|
||||
existing.push(displayName);
|
||||
} else {
|
||||
existing.splice(index, 1);
|
||||
}
|
||||
|
||||
return createOverride(existing, property);
|
||||
};
|
||||
|
||||
const getExistingDisplayNames = (rule: SystemConfigOverrideRule): string[] => {
|
||||
const names = rule.matcher.options?.names;
|
||||
if (!Array.isArray(names)) {
|
||||
return [];
|
||||
}
|
||||
return names;
|
||||
};
|
||||
|
||||
const createOverride = (names: string[], property?: DynamicConfigValue): SystemConfigOverrideRule => {
|
||||
property = property ?? {
|
||||
id: 'custom.hideFrom',
|
||||
value: {
|
||||
graph: true,
|
||||
legend: false,
|
||||
tooltip: false,
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
__systemRef: displayOverrideRef,
|
||||
matcher: {
|
||||
id: FieldMatcherID.byNames,
|
||||
options: {
|
||||
mode: ByNamesMatcherMode.exclude,
|
||||
names: names,
|
||||
prefix: 'All except:',
|
||||
readOnly: true,
|
||||
},
|
||||
},
|
||||
properties: [
|
||||
{
|
||||
...property,
|
||||
value: {
|
||||
graph: true,
|
||||
legend: false,
|
||||
tooltip: false,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
};
|
||||
|
||||
const allFieldsAreExcluded = (override: SystemConfigOverrideRule, data: DataFrame[]): boolean => {
|
||||
return getExistingDisplayNames(override).length === getDisplayNames(data).length;
|
||||
};
|
||||
|
||||
const getDisplayNames = (data: DataFrame[], excludeName?: string): string[] => {
|
||||
const unique = new Set<string>();
|
||||
|
||||
for (const frame of data) {
|
||||
for (const field of frame.fields) {
|
||||
if (field.type !== FieldType.number) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const name = getFieldDisplayName(field, frame, data);
|
||||
|
||||
if (name === excludeName) {
|
||||
continue;
|
||||
}
|
||||
|
||||
unique.add(name);
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(unique);
|
||||
};
|
@ -15,6 +15,7 @@ import {
|
||||
ScaleDistribution,
|
||||
ScaleDistributionConfig,
|
||||
} from '@grafana/ui';
|
||||
import { SeriesConfigEditor } from './HideSeriesConfigEditor';
|
||||
import { GraphPanel } from './GraphPanel';
|
||||
import { graphPanelChangedHandler } from './migrations';
|
||||
import { Options } from './types';
|
||||
@ -154,6 +155,23 @@ export const plugin = new PanelPlugin<Options, GraphFieldConfig>(GraphPanel)
|
||||
defaultValue: { type: ScaleDistribution.Linear },
|
||||
shouldApply: f => f.type === FieldType.number,
|
||||
process: identityOverrideProcessor,
|
||||
})
|
||||
.addCustomEditor({
|
||||
id: 'hideFrom',
|
||||
name: 'Hide in area',
|
||||
category: ['Series'],
|
||||
path: 'hideFrom',
|
||||
defaultValue: {
|
||||
tooltip: false,
|
||||
graph: false,
|
||||
legend: false,
|
||||
},
|
||||
editor: SeriesConfigEditor,
|
||||
override: SeriesConfigEditor,
|
||||
shouldApply: () => true,
|
||||
hideFromDefaults: true,
|
||||
hideFromOverrides: true,
|
||||
process: value => value,
|
||||
});
|
||||
},
|
||||
})
|
||||
|
Loading…
Reference in New Issue
Block a user