Transformations: Fix XYChart legend toggle with Organize -> Partition (#109771)

This commit is contained in:
Leon Sorokin
2025-08-21 17:56:27 +00:00
committed by GitHub
parent 25c13c55c0
commit c4460dc568
3 changed files with 128 additions and 38 deletions
@@ -50,6 +50,52 @@ describe('Partition by values transformer', () => {
expect(partitioned[1].fields[1].values).toEqual(['China', 'China', 'China']);
});
it('should partition by one field and apply auto-naming to displayName', () => {
const source = [
toDataFrame({
name: 'XYZ',
refId: 'A',
fields: [
{
name: 'model',
type: FieldType.string,
config: { displayName: 'myModel' },
values: ['E1', 'E2', 'C1', 'E3', 'C2', 'C3'],
},
{ name: 'region', type: FieldType.string, values: ['Europe', 'Europe', 'China', 'Europe', 'China', 'China'] },
],
}),
];
const config: PartitionByValuesTransformerOptions = {
fields: ['region'],
keepFields: true,
naming: {
asLabels: false,
},
};
let partitioned = partitionByValuesTransformer.transformer(config, ctx)(source);
expect(partitioned.length).toEqual(2);
expect(partitioned[0].length).toEqual(3);
expect(partitioned[0].name).toEqual('Europe');
expect(partitioned[0].fields[0].name).toEqual('model');
expect(partitioned[0].fields[0].config).toEqual({ displayName: 'Europe myModel' });
expect(partitioned[0].fields[1].name).toEqual('region');
expect(partitioned[0].fields[0].values).toEqual(['E1', 'E2', 'E3']);
expect(partitioned[0].fields[1].values).toEqual(['Europe', 'Europe', 'Europe']);
expect(partitioned[1].length).toEqual(3);
expect(partitioned[1].name).toEqual('China');
expect(partitioned[1].fields[0].name).toEqual('model');
expect(partitioned[1].fields[0].config).toEqual({ displayName: 'China myModel' });
expect(partitioned[1].fields[1].name).toEqual('region');
expect(partitioned[1].fields[0].values).toEqual(['C1', 'C2', 'C3']);
expect(partitioned[1].fields[1].values).toEqual(['China', 'China', 'China']);
});
it('should partition by multiple fields', () => {
const source = [
toDataFrame({
@@ -1,3 +1,4 @@
import { cloneDeep } from 'lodash';
import { map } from 'rxjs';
import {
@@ -7,6 +8,7 @@ import {
getFieldMatcher,
DataTransformContext,
FieldMatcher,
cacheFieldDisplayNames,
} from '@grafana/data';
import { getMatcherConfig, noopTransformer } from '@grafana/data/internal';
import { t } from '@grafana/i18n';
@@ -98,7 +100,7 @@ export const getPartitionByValuesTransformer: () => SynchronousDataTransformerIn
});
// Split a single frame dataset into multiple frames based on values in a set of fields
export function partitionByValues(
function _partitionByValues(
frame: DataFrame,
matcher: FieldMatcher,
options?: PartitionByValuesTransformerOptions
@@ -163,7 +165,7 @@ export function partitionByValues(
return {
name: f.name,
type: f.type,
config: f.config,
config: cloneDeep(f.config),
labels: {
...f.labels,
...fieldLabels,
@@ -174,3 +176,78 @@ export function partitionByValues(
};
});
}
// since this transformation splits one frame into multiple, we end up with duplicate field names across all frames
// this is normally okay since getFieldDisplayName() -> calculateFieldDisplayName() avoids creating duplicate names
// by using other sources of entropy such as refIds, frame names, field labels, and increments.
// however, this does *not* work if a field has been renamed by the user or datasource (config.displayName or config.displayNameFromDS).
// Organize fields transformation or field overrides are common places where this happens.
// in this situation the auto-namer is skipped, and we end up with multiple fields named exactly the same.
// consequently, onToggleSeriesVisibility() (from usePanelContext) does not have a unique field name to use for applying a
// fieldMatcher that controls field.config.hideFrom.viz
// so what we need to do to make this work is either make field.name unique or make field.config.displayName unique.
// since field.name might need to be used for data links and subsequent drill down queries, we cannot overwrite it.
// therefore, the code below [unfortunately] has to modify field.config.displayName by using calculateFieldDisplayName() logic.
// this will have the side-effect of the displayName being a more verbose variant of what the user indicated, except in panels that
// know how to remove common prefixes/suffixes from field names in tooltip and legend rendering (like XYChart)
export function partitionByValues(
frame: DataFrame,
matcher: FieldMatcher,
options?: PartitionByValuesTransformerOptions
) {
// remember original field names, we'll need to restore them later
let fieldNames: Record<string, string> = {};
let frame2 = {
...frame,
fields: frame.fields.map((f) => {
let f2 = f;
let renameTo = f.config.displayNameFromDS ?? f.config.displayName;
if (renameTo) {
f2 = {
...f,
config: {
...f.config,
},
state: {
...f.state,
},
};
fieldNames[renameTo] = f.name;
f2.name = renameTo;
delete f2.config.displayName;
delete f2.config.displayNameFromDS;
delete f2.state?.displayName;
}
return f2;
}),
};
let frames2 = _partitionByValues(frame2, matcher, options);
cacheFieldDisplayNames(frames2);
// restore original field names
frames2.forEach((frame) => {
frame.fields.forEach((field) => {
if (field.name in fieldNames) {
field.name = fieldNames[field.name] ?? field.name;
field.config.displayName = field.state!.displayName!;
}
delete field.state?.displayName;
delete field.state?.multipleFrames;
});
});
return frames2;
}
+3 -36
View File
@@ -62,8 +62,8 @@ export function prepSeries(
let xMatcher = getFieldMatcher(
seriesCfg.x?.matcher ?? {
id: FieldMatcherID.byTypes,
options: new Set(['number', 'time']),
id: FieldMatcherID.byType,
options: 'number',
}
);
let yMatcher = getFieldMatcher(
@@ -123,40 +123,7 @@ export function prepSeries(
// if we match non-excluded y, create series
if (yMatcher(field, frame, frames) && !field.config.custom?.hideFrom?.viz) {
let y = field;
let name = seriesCfg.name?.fixed;
if (name == null) {
// if the displayed field name is likely to have a common prefix or suffix
// (such as those from Partition by values transformation)
const likelyHasCommonParts =
frames.length > 1 && (frame.name != null || Object.keys(y.labels ?? {}).length > 0);
// if the field was explictly (re)named using config.displayName or config.displayNameFromDS
// we still want to retain any frame name prefix or suffix so that autoNameSeries() can
// properly detect + strip common parts across all series...
const { displayName, displayNameFromDS } = y.config;
const hasExplicitName = displayName != null || displayNameFromDS != null;
if (likelyHasCommonParts && hasExplicitName) {
// ...and a hacky way to do this is to temp remove the explicit name, get the auto name, then revert
const stateDisplayName = y.state!.displayName;
// clear config and cache
y.config.displayName = y.config.displayNameFromDS = y.state!.displayName = undefined;
// get default/calculated display name (maybe use calculateFieldDisplayName() here instead?)
name = getFieldDisplayName(y, frame, frames);
// replace original field name with explicit one
name = name.replace(y.name, (displayNameFromDS ?? displayName)!);
// revert
y.config.displayName = displayName;
y.config.displayNameFromDS = displayNameFromDS;
y.state!.displayName = stateDisplayName;
} else {
name = getFieldDisplayName(y, frame, frames);
}
}
let name = seriesCfg.name?.fixed ?? getFieldDisplayName(y, frame, frames);
let ser: XYSeries = {
// these typically come from y field