Compare commits

...

21 Commits

Author SHA1 Message Date
Kristina Durivage
74dfaa4b7c Add tests for transformation row header
Some checks failed
CodeQL checks / Detect whether code changed (push) Has been cancelled
CodeQL checks / Analyze (actions) (push) Has been cancelled
CodeQL checks / Analyze (go) (push) Has been cancelled
CodeQL checks / Analyze (javascript) (push) Has been cancelled
2025-10-07 16:17:47 -05:00
Kristina Durivage
96b2ed367e Add refID tests to affected transformations 2025-10-07 12:44:53 -05:00
Kristina Durivage
09b7d424d4 Merge branch 'main' into kristina/static-transform-refIds
Some checks failed
CodeQL checks / Detect whether code changed (push) Has been cancelled
CodeQL checks / Analyze (actions) (push) Has been cancelled
CodeQL checks / Analyze (go) (push) Has been cancelled
CodeQL checks / Analyze (javascript) (push) Has been cancelled
2025-10-07 10:07:52 -05:00
Kristina Durivage
7622a81158 display generated refID if exists, but do not persist it
Some checks failed
CodeQL checks / Detect whether code changed (push) Has been cancelled
CodeQL checks / Analyze (actions) (push) Has been cancelled
CodeQL checks / Analyze (go) (push) Has been cancelled
CodeQL checks / Analyze (javascript) (push) Has been cancelled
2025-10-05 19:42:49 -05:00
Kristina Durivage
7454997166 Merge branch 'main' into kristina/static-transform-refIds
Some checks failed
CodeQL checks / Detect whether code changed (push) Has been cancelled
CodeQL checks / Analyze (actions) (push) Has been cancelled
CodeQL checks / Analyze (go) (push) Has been cancelled
CodeQL checks / Analyze (javascript) (push) Has been cancelled
2025-10-05 09:09:19 -05:00
Kristina Durivage
caa1202d54 More cleanup
Some checks failed
CodeQL checks / Detect whether code changed (push) Has been cancelled
CodeQL checks / Analyze (actions) (push) Has been cancelled
CodeQL checks / Analyze (go) (push) Has been cancelled
CodeQL checks / Analyze (javascript) (push) Has been cancelled
2025-09-28 18:10:34 -05:00
Kristina Durivage
1410180aed Do not populate new RefIDs every time, show other validation errors 2025-09-28 17:32:37 -05:00
Kristina Durivage
23e97f9a4a Merge branch 'main' into kristina/static-transform-refIds
Some checks failed
CodeQL checks / Detect whether code changed (push) Has been cancelled
CodeQL checks / Analyze (actions) (push) Has been cancelled
CodeQL checks / Analyze (go) (push) Has been cancelled
CodeQL checks / Analyze (javascript) (push) Has been cancelled
2025-09-28 11:47:21 -05:00
Kristina Durivage
0db9227da8 Do not generate new refIDs 2025-09-28 11:46:51 -05:00
Kristina Durivage
84deb76b21 Merge branch 'main' into kristina/static-transform-refIds
Some checks failed
CodeQL checks / Detect whether code changed (push) Has been cancelled
CodeQL checks / Analyze (actions) (push) Has been cancelled
CodeQL checks / Analyze (go) (push) Has been cancelled
CodeQL checks / Analyze (javascript) (push) Has been cancelled
2025-09-26 14:50:14 -05:00
Kristina Durivage
84126470de Merge branch 'main' into kristina/static-transform-refIds
Some checks failed
CodeQL checks / Detect whether code changed (push) Has been cancelled
CodeQL checks / Analyze (actions) (push) Has been cancelled
CodeQL checks / Analyze (go) (push) Has been cancelled
CodeQL checks / Analyze (javascript) (push) Has been cancelled
2025-09-25 13:12:03 -05:00
Kristina Durivage
3644e00d7f Fix typing
Some checks failed
CodeQL checks / Detect whether code changed (push) Has been cancelled
CodeQL checks / Analyze (actions) (push) Has been cancelled
CodeQL checks / Analyze (go) (push) Has been cancelled
CodeQL checks / Analyze (javascript) (push) Has been cancelled
2025-09-24 20:15:59 -05:00
Kristina Durivage
705646c8f9 Handle old dashboards with transformation filters 2025-09-24 16:29:42 -05:00
Kristina Durivage
0c25bc2b27 Fix translation
Some checks failed
CodeQL checks / Detect whether code changed (push) Has been cancelled
CodeQL checks / Analyze (actions) (push) Has been cancelled
CodeQL checks / Analyze (go) (push) Has been cancelled
CodeQL checks / Analyze (javascript) (push) Has been cancelled
2025-09-24 12:46:23 -05:00
Kristina Durivage
c368196e35 following instructions.. 2025-09-24 11:36:32 -05:00
Kristina Durivage
c1da8e2dff Clean up last bit… 2025-09-24 11:28:37 -05:00
Kristina Durivage
883fb2de83 Merge branch 'main' into kristina/static-transform-refIds 2025-09-24 11:13:57 -05:00
Kristina Durivage
86faef83c2 cleanup
Some checks failed
CodeQL checks / Detect whether code changed (push) Has been cancelled
CodeQL checks / Analyze (actions) (push) Has been cancelled
CodeQL checks / Analyze (go) (push) Has been cancelled
CodeQL checks / Analyze (javascript) (push) Has been cancelled
2025-09-23 16:06:20 -05:00
Kristina Durivage
ddca5c0a0a Merge branch 'main' of https://github.com/grafana/grafana into kristina/static-transform-refIds 2025-09-23 14:36:10 -05:00
Kristina Durivage
8f7ddd3c4c First draft of it working
Some checks failed
CodeQL checks / Detect whether code changed (push) Has been cancelled
CodeQL checks / Analyze (actions) (push) Has been cancelled
CodeQL checks / Analyze (go) (push) Has been cancelled
CodeQL checks / Analyze (javascript) (push) Has been cancelled
2025-09-22 20:15:46 -05:00
Kristina Durivage
141885acae WIP 2025-09-19 18:21:45 -05:00
30 changed files with 532 additions and 67 deletions

View File

@@ -127,6 +127,8 @@ DataTopic: "series" | "annotations" | "alertStates" @cog(kind="enum",memberNames
DataTransformerConfig: {
// Unique identifier of transformer
id: string
// Unique identifier of the instance of the transformer
refId?: string
// Disabled transformations are skipped
disabled?: bool
// Optional frame matcher. When missing it will be applied to all results

View File

@@ -516,6 +516,8 @@ lineage: schemas: [{
#DataTransformerConfig: {
// Unique identifier of transformer
id: string
// Unique identifier of the instance of the transformer
refId?: string
// Disabled transformations are skipped
disabled?: bool
// Optional frame matcher. When missing it will be applied to all results

View File

@@ -516,6 +516,8 @@ lineage: schemas: [{
#DataTransformerConfig: {
// Unique identifier of transformer
id: string
// Unique identifier of the instance of the transformer
refId?: string
// Disabled transformations are skipped
disabled?: bool
// Optional frame matcher. When missing it will be applied to all results

View File

@@ -131,6 +131,8 @@ DataTopic: "series" | "annotations" | "alertStates" @cog(kind="enum",memberNames
DataTransformerConfig: {
// Unique identifier of transformer
id: string
// Unique identifier of the instance of the transformer
refId?: string
// Disabled transformations are skipped
disabled?: bool
// Optional frame matcher. When missing it will be applied to all results

View File

@@ -225,6 +225,8 @@ func NewDashboardTransformationKind() *DashboardTransformationKind {
type DashboardDataTransformerConfig struct {
// Unique identifier of transformer
Id string `json:"id"`
// Unique identifier of the instance of the transformer
RefId *string `json:"refId,omitempty"`
// Disabled transformations are skipped
Disabled *bool `json:"disabled,omitempty"`
// Optional frame matcher. When missing it will be applied to all results

View File

@@ -1707,6 +1707,13 @@ func schema_pkg_apis_dashboard_v2beta1_DashboardDataTransformerConfig(ref common
Format: "",
},
},
"refId": {
SchemaProps: spec.SchemaProps{
Description: "Unique identifier of the instance of the transformer",
Type: []string{"string"},
Format: "",
},
},
"disabled": {
SchemaProps: spec.SchemaProps{
Description: "Disabled transformations are skipped",

View File

@@ -512,6 +512,8 @@ lineage: schemas: [{
#DataTransformerConfig: {
// Unique identifier of transformer
id: string
// Unique identifier of the instance of the transformer
refId?: string
// Disabled transformations are skipped
disabled?: bool
// Optional frame matcher. When missing it will be applied to all results

View File

@@ -268,6 +268,7 @@ export { fuzzySearch } from './utils/fuzzySearch';
// Transformations
export { standardTransformers } from './transformations/transformers';
export { getTransformationDynamicRefId } from './transformations/transformers/utils';
export {
fieldMatchers,
frameMatchers,

View File

@@ -23,7 +23,7 @@ const getOperator =
}
const defaultOptions = info.transformation.defaultOptions ?? {};
const options = { ...defaultOptions, ...config.options };
const options = { ...defaultOptions, refId: config.refId, ...config.options };
// when running within Scenes, we can skip var interpolation, since it's already handled upstream
const isScenes = window.__grafanaSceneContext != null;

View File

@@ -14,6 +14,7 @@ describe('JOIN Transformer', () => {
describe('outer join', () => {
const everySecondSeries = toDataFrame({
refId: 'A',
name: 'even',
fields: [
{ name: 'time', type: FieldType.time, values: [3000, 4000, 5000, 6000] },
@@ -23,6 +24,7 @@ describe('JOIN Transformer', () => {
});
const everyOtherSecondSeries = toDataFrame({
refId: 'B',
name: 'odd',
fields: [
{ name: 'time', type: FieldType.time, values: [1000, 3000, 5000, 7000] },
@@ -31,18 +33,20 @@ describe('JOIN Transformer', () => {
],
});
it('joins by time field', async () => {
it('joins by time field with defined refId', async () => {
const cfg: DataTransformerConfig<JoinByFieldOptions> = {
id: DataTransformerID.seriesToColumns,
options: {
byField: 'time',
},
refId: 'test',
};
await expect(transformDataFrame([cfg], [everySecondSeries, everyOtherSecondSeries])).toEmitValuesWith(
(received) => {
const data = received[0];
const filtered = data[0];
expect(filtered.refId).toBe('test');
expect(filtered.fields).toMatchInlineSnapshot(`
[
{
@@ -133,7 +137,7 @@ describe('JOIN Transformer', () => {
);
});
it('joins by temperature field', async () => {
it('joins by temperature field with dynamic refId', async () => {
const cfg: DataTransformerConfig<JoinByFieldOptions> = {
id: DataTransformerID.seriesToColumns,
options: {
@@ -145,6 +149,7 @@ describe('JOIN Transformer', () => {
(received) => {
const data = received[0];
const filtered = data[0];
expect(filtered.refId).toBe('joinByField-A-B');
expect(filtered.fields).toMatchInlineSnapshot(`
[
{

View File

@@ -7,6 +7,7 @@ import { FieldMatcherID } from '../matchers/ids';
import { DataTransformerID } from './ids';
import { joinDataFrames } from './joinDataFrames';
import { getTransformationDynamicRefId } from './utils';
export enum JoinMode {
outer = 'outer', // best for time series, non duplicated join on values
@@ -17,6 +18,7 @@ export enum JoinMode {
export interface JoinByFieldOptions {
byField?: string; // empty will pick the field automatically
mode?: JoinMode;
refId?: string;
}
export const joinByFieldTransformer: SynchronousDataTransformerInfo<JoinByFieldOptions> = {
@@ -42,11 +44,13 @@ export const joinByFieldTransformer: SynchronousDataTransformerInfo<JoinByFieldO
}
const joined = joinDataFrames({ frames: data, joinBy, mode: options.mode });
if (joined) {
joined.refId = `${DataTransformerID.joinByField}-${data.map((frame) => frame.refId).join('-')}`;
joined.refId = options.refId ?? getTransformationDynamicRefId(DataTransformerID.joinByField, data);
return [joined];
}
}
return data;
};
},
usesDynamicRefId: true,
};

View File

@@ -32,8 +32,9 @@ describe('Merge multiple to single', () => {
});
});
it('combine two series into one', async () => {
it('combine two series into one with dynamic refId', async () => {
const seriesA = toDataFrame({
refId: 'A',
name: 'A',
fields: [
{ name: 'Time', type: FieldType.time, values: [1000] },
@@ -42,6 +43,7 @@ describe('Merge multiple to single', () => {
});
const seriesB = toDataFrame({
refId: 'B',
name: 'B',
fields: [
{ name: 'Time', type: FieldType.time, values: [2000] },
@@ -55,7 +57,7 @@ describe('Merge multiple to single', () => {
createField('Time', FieldType.time, [1000, 2000]),
createField('Temp', FieldType.number, [1, -1]),
];
expect(result[0].refId).toBe('merge-A-B');
expect(unwrap(result[0].fields)).toEqual(expected);
});
});
@@ -77,13 +79,15 @@ describe('Merge multiple to single', () => {
],
});
await expect(transformDataFrame([cfg], [seriesA, seriesB])).toEmitValuesWith((received) => {
const config = { ...cfg, refId: 'test' };
await expect(transformDataFrame([config], [seriesA, seriesB])).toEmitValuesWith((received) => {
const result = received[0];
const expected: Field[] = [
createField('Time', FieldType.time, [100, 150, 200, 100, 125, 126]),
createField('Temp', FieldType.number, [1, 4, 5, -1, 2, 3]),
];
expect(result[0].refId).toBe('test');
expect(unwrap(result[0].fields)).toEqual(expected);
});
});

View File

@@ -6,13 +6,16 @@ import { DataFrame, Field } from '../../types/dataFrame';
import { DataTransformerInfo, TransformationApplicabilityLevels } from '../../types/transformations';
import { DataTransformerID } from './ids';
import { getTransformationDynamicRefId } from './utils';
interface ValuePointer {
key: string;
index: number;
}
export interface MergeTransformerOptions {}
export interface MergeTransformerOptions {
refId?: string;
}
export const mergeTransformer: DataTransformerInfo<MergeTransformerOptions> = {
id: DataTransformerID.merge,
@@ -44,7 +47,7 @@ export const mergeTransformer: DataTransformerInfo<MergeTransformerOptions> = {
const fieldIndexByName: Record<string, Record<number, number>> = {};
const fieldNamesForKey: string[] = [];
const dataFrame = new MutableDataFrame({
refId: `${DataTransformerID.merge}-${data.map((frame) => frame.refId).join('-')}`,
refId: options.refId ?? getTransformationDynamicRefId(DataTransformerID.merge, data),
fields: [],
});
@@ -122,6 +125,7 @@ export const mergeTransformer: DataTransformerInfo<MergeTransformerOptions> = {
return [dataFrame];
})
),
usesDynamicRefId: true,
};
const copyFieldStructure = (field: Field): Field => {

View File

@@ -11,6 +11,7 @@ import { DataTransformerID } from './ids';
import { reduceFields, reduceTransformer, ReduceTransformerMode, ReduceTransformerOptions } from './reduce';
const seriesAWithSingleField = toDataFrame({
refId: 'A',
name: 'A',
fields: [
{ name: 'time', type: FieldType.time, values: [3000, 4000, 5000, 6000] },
@@ -19,6 +20,7 @@ const seriesAWithSingleField = toDataFrame({
});
const seriesAWithMultipleFields = toDataFrame({
refId: 'B',
name: 'A',
fields: [
{ name: 'time', type: FieldType.time, values: [3000, 4000, 5000, 6000] },
@@ -28,6 +30,7 @@ const seriesAWithMultipleFields = toDataFrame({
});
const seriesAWithAllNulls = toDataFrame({
refId: 'C',
name: 'A',
fields: [
{ name: 'time', type: FieldType.time, values: [3000, 4000, 5000, 6000] },
@@ -36,6 +39,7 @@ const seriesAWithAllNulls = toDataFrame({
});
const seriesBWithSingleField = toDataFrame({
refId: 'D',
name: 'B',
fields: [
{ name: 'time', type: FieldType.time, values: [1000, 3000, 5000, 7000] },
@@ -44,6 +48,7 @@ const seriesBWithSingleField = toDataFrame({
});
const seriesBWithMultipleFields = toDataFrame({
refId: 'E',
name: 'B',
fields: [
{ name: 'time', type: FieldType.time, values: [1000, 3000, 5000, 7000] },
@@ -53,6 +58,7 @@ const seriesBWithMultipleFields = toDataFrame({
});
const seriesBWithAllNulls = toDataFrame({
refId: 'F',
name: 'B',
fields: [
{ name: 'time', type: FieldType.time, values: [3000, 4000, 5000, 6000] },
@@ -65,7 +71,7 @@ describe('Reducer Transformer', () => {
mockTransformationsRegistry([reduceTransformer]);
});
it('reduces multiple data frames with many fields', async () => {
it('reduces multiple data frames with many fields and dynamic refId', async () => {
const cfg = {
id: DataTransformerID.reduce,
options: {
@@ -135,6 +141,7 @@ describe('Reducer Transformer', () => {
},
];
expect(processed[0].refId).toBe('reduce-B-E');
expect(processed.length).toEqual(1);
expect(processed[0].length).toEqual(4);
expect(processed[0].fields).toEqual(expected);
@@ -142,12 +149,13 @@ describe('Reducer Transformer', () => {
);
});
it('reduces multiple data frames with single field', async () => {
it('reduces multiple data frames with single field and static refId', async () => {
const cfg = {
id: DataTransformerID.reduce,
options: {
reducers: [ReducerID.first, ReducerID.min, ReducerID.max, ReducerID.last],
},
refId: 'test',
};
await expect(transformDataFrame([cfg], [seriesAWithSingleField, seriesBWithSingleField])).toEmitValuesWith(
@@ -187,6 +195,7 @@ describe('Reducer Transformer', () => {
];
expect(processed.length).toEqual(1);
expect(processed[0].refId).toBe('test');
expect(processed[0].length).toEqual(2);
expect(processed[0].fields).toEqual(expected);
}

View File

@@ -10,6 +10,7 @@ import { getFieldMatcher } from '../matchers';
import { alwaysFieldMatcher, notTimeFieldMatcher } from '../matchers/predicates';
import { DataTransformerID } from './ids';
import { getTransformationDynamicRefId } from './utils';
export enum ReduceTransformerMode {
SeriesToRows = 'seriesToRows', // default
@@ -22,6 +23,7 @@ export interface ReduceTransformerOptions {
mode?: ReduceTransformerMode;
includeTimeField?: boolean;
labelsToFields?: boolean;
refId?: string;
}
export const reduceTransformer: DataTransformerInfo<ReduceTransformerOptions> = {
@@ -57,10 +59,16 @@ export const reduceTransformer: DataTransformerInfo<ReduceTransformerOptions> =
// Add a row for each series
const res = reduceSeriesToRows(data, matcher, options.reducers, options.labelsToFields);
return res
? [{ ...res, refId: `${DataTransformerID.reduce}-${data.map((frame) => frame.refId).join('-')}` }]
? [
{
...res,
refId: options.refId ?? getTransformationDynamicRefId(DataTransformerID.reduce, data),
},
]
: [];
})
),
usesDynamicRefId: true,
};
/**

View File

@@ -39,10 +39,11 @@ describe('Series to rows', () => {
});
});
it('combine two series into one', async () => {
it('combine two series into one with static refId', async () => {
const cfg: DataTransformerConfig<SeriesToRowsTransformerOptions> = {
id: DataTransformerID.seriesToRows,
options: {},
refId: 'test',
};
const seriesA = toDataFrame({
@@ -69,18 +70,19 @@ describe('Series to rows', () => {
createField('Metric', FieldType.string, ['B', 'A']),
createField('Value', FieldType.number, [-1, 1]),
];
expect(result[0].refId).toBe('test');
expect(unwrap(result[0].fields)).toEqual(expected);
});
});
it('combine two series with multiple values into one', async () => {
it('combine two series with multiple values into one with dynamic refid', async () => {
const cfg: DataTransformerConfig<SeriesToRowsTransformerOptions> = {
id: DataTransformerID.seriesToRows,
options: {},
};
const seriesA = toDataFrame({
refId: 'A',
name: 'A',
fields: [
{ name: 'Time', type: FieldType.time, values: [100, 150, 200] },
@@ -89,6 +91,7 @@ describe('Series to rows', () => {
});
const seriesB = toDataFrame({
refId: 'B',
name: 'B',
fields: [
{ name: 'Time', type: FieldType.time, values: [100, 125, 126] },
@@ -104,7 +107,7 @@ describe('Series to rows', () => {
createField('Metric', FieldType.string, ['A', 'A', 'B', 'B', 'A', 'B']),
createField('Value', FieldType.number, [5, 4, 3, 2, 1, -1]),
];
expect(result[0].refId).toBe('seriesToRows-A-B');
expect(unwrap(result[0].fields)).toEqual(expected);
});
});

View File

@@ -15,8 +15,11 @@ import {
import { DataTransformerInfo } from '../../types/transformations';
import { DataTransformerID } from './ids';
import { getTransformationDynamicRefId } from './utils';
export interface SeriesToRowsTransformerOptions {}
export interface SeriesToRowsTransformerOptions {
refId?: string;
}
export const seriesToRowsTransformer: DataTransformerInfo<SeriesToRowsTransformerOptions> = {
id: DataTransformerID.seriesToRows,
@@ -38,7 +41,7 @@ export const seriesToRowsTransformer: DataTransformerInfo<SeriesToRowsTransforme
const timeFieldByIndex: Record<number, number> = {};
const targetFields = new Set<string>();
const dataFrame = new MutableDataFrame({
refId: `${DataTransformerID.seriesToRows}-${data.map((frame) => frame.refId).join('-')}`,
refId: options.refId ?? getTransformationDynamicRefId(DataTransformerID.seriesToRows, data),
fields: [],
});
const metricField: Field = {
@@ -90,6 +93,7 @@ export const seriesToRowsTransformer: DataTransformerInfo<SeriesToRowsTransforme
return [sortDataFrame(dataFrame, 0, true)];
})
),
usesDynamicRefId: true,
};
const copyFieldStructure = (field: Field, name: string): Field => {

View File

@@ -33,3 +33,7 @@ export function getSpecialValue(specialValue: SpecialValue) {
return '';
}
}
export const getTransformationDynamicRefId = (transformationId: string, data: DataFrame[]) => {
return `${transformationId}-${data.map((frame) => frame.refId).join('-')}`;
};

View File

@@ -55,6 +55,10 @@ export interface DataTransformerInfo<TOptions = any> extends RegistryItemWithOpt
* This way descriptions can be tailored relative to the underlying data.
*/
isApplicableDescription?: string | ((data: DataFrame[]) => string);
/**
* Does the transformation generate a dataframe and thus generates a refID automatically based on incoming data
*/
usesDynamicRefId?: boolean;
}
/**

View File

@@ -746,6 +746,10 @@ export interface DataTransformerConfig {
* Valid options depend on the transformer id
*/
options: unknown;
/**
* Unique identifier of the instance of the transformer
*/
refId?: string;
/**
* Where to pull DataFrames from as input to transformation
*/

View File

@@ -176,6 +176,8 @@ export const defaultTransformationKind = (): TransformationKind => ({
export interface DataTransformerConfig {
// Unique identifier of transformer
id: string;
// Unique identifier of the instance of the transformer
refId?: string;
// Disabled transformations are skipped
disabled?: boolean;
// Optional frame matcher. When missing it will be applied to all results

View File

@@ -312,6 +312,8 @@ const DashboardLinkPlacement = "inControlsMenu"
type DataTransformerConfig struct {
// Unique identifier of transformer
Id string `json:"id"`
// Unique identifier of the instance of the transformer
RefId *string `json:"refId,omitempty"`
// Disabled transformations are skipped
Disabled *bool `json:"disabled,omitempty"`
// Optional frame matcher. When missing it will be applied to all results

View File

@@ -10,6 +10,7 @@ import {
getFrameMatchers,
transformDataFrame,
DataFrame,
getTransformationDynamicRefId,
} from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
import { t } from '@grafana/i18n';
@@ -26,6 +27,7 @@ import { PluginStateInfo } from 'app/features/plugins/components/PluginStateInfo
import { TransformationEditor } from './TransformationEditor';
import { TransformationEditorHelpDisplay } from './TransformationEditorHelpDisplay';
import { TransformationFilter } from './TransformationFilter';
import { TransformationOperationRowHeader } from './TransformationOperationRowHeader';
import { TransformationData } from './TransformationsEditor';
import { TransformationsEditorTransformation } from './types';
@@ -60,6 +62,8 @@ export const TransformationOperationRow = ({
// output of previous transformation
const [prevOutput, setPrevOutput] = useState<DataFrame[]>([]);
const dynamicRefId = getTransformationDynamicRefId(uiConfig.id, data.series);
const onDisableToggle = useCallback(
(index: number) => {
const current = configs[index].transformation;
@@ -157,6 +161,20 @@ export const TransformationOperationRow = ({
};
}, [index, data, configs]);
const renderHeader = () => {
return (
<TransformationOperationRowHeader
index={index}
transformation={configs[index].transformation}
transformations={configs.map((config) => config.transformation)}
transformationTypeName={`${index + 1} - ${uiConfig.name}`}
disabled
onChange={onChange}
dynamicRefId={uiConfig.transformation.usesDynamicRefId ? dynamicRefId : undefined}
/>
);
};
const renderActions = () => {
return (
<>
@@ -228,10 +246,9 @@ export const TransformationOperationRow = ({
<QueryOperationRow
id={id}
index={index}
// eslint-disable-next-line @grafana/i18n/no-untranslated-strings
title={`${index + 1} - ${uiConfig.name}`}
draggable
actions={renderActions}
headerElement={renderHeader}
disabled={disabled}
expanderMessages={{
close: 'Collapse transformation row',
@@ -247,7 +264,6 @@ export const TransformationOperationRow = ({
onChange={onChange}
/>
)}
<TransformationEditor
input={input}
output={output}

View File

@@ -0,0 +1,129 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { DataTransformerConfig, DataTransformerID } from '@grafana/data';
import { LabelsToFieldsMode, LabelsToFieldsOptions, MergeTransformerOptions } from '@grafana/data/internal';
import { TransformationOperationRowHeader } from './TransformationOperationRowHeader';
const mergeTransform: DataTransformerConfig<MergeTransformerOptions> = {
id: DataTransformerID.merge,
options: {},
};
const labelsToFieldsTransform: DataTransformerConfig<LabelsToFieldsOptions> = {
id: DataTransformerID.labelsToFields,
options: {
mode: LabelsToFieldsMode.Rows,
},
};
const labelsToFieldsRefId = { ...labelsToFieldsTransform, refId: 'test' };
describe('TransformationOperationRowHeader', () => {
it('renders the modal with the title and empty refId for ', () => {
const { unmount } = render(
<TransformationOperationRowHeader
index={0}
transformation={mergeTransform}
transformations={[mergeTransform, labelsToFieldsTransform]}
transformationTypeName="1 - Labels to fields"
onChange={() => {}}
/>
);
// Check if the modal title is rendered with the correct text
expect(screen.getByText('1 - Labels to fields')).toBeInTheDocument();
expect(screen.getByText('(Auto)')).toBeInTheDocument();
// Unmount the component to clean up
unmount();
});
it('calls onChange when the the refId is changed', async () => {
const mockOnChange = jest.fn();
const { unmount } = render(
<TransformationOperationRowHeader
index={0}
transformation={mergeTransform}
transformations={[mergeTransform, labelsToFieldsTransform]}
transformationTypeName="1 - Labels to fields"
onChange={mockOnChange}
dynamicRefId=""
/>
);
// Find and click the modal's close button
const beforeEditField = screen.getByTestId('transformation-refid-div');
await userEvent.click(beforeEditField);
const refIdInput = screen.getByTestId('transformation-refid-input');
await userEvent.click(refIdInput);
await userEvent.type(refIdInput, 'test refid');
// blur the field
await userEvent.click(document.body);
expect(mockOnChange).toHaveBeenCalledWith(0, { id: 'merge', options: {}, refId: 'test refid' });
unmount();
});
it('shows an error message if the refID is already used', async () => {
const mockOnChange = jest.fn();
const { unmount } = render(
<TransformationOperationRowHeader
index={0}
transformation={mergeTransform}
transformations={[mergeTransform, labelsToFieldsRefId]}
transformationTypeName="1 - Labels to fields"
onChange={mockOnChange}
dynamicRefId=""
/>
);
// Find and click the modal's close button
const beforeEditField = screen.getByTestId('transformation-refid-div');
await userEvent.click(beforeEditField);
const refIdInput = screen.getByTestId('transformation-refid-input');
await userEvent.click(refIdInput);
await userEvent.type(refIdInput, 'test');
expect(screen.getByText('Transformation name already exists')).toBeInTheDocument();
// blur the field
await userEvent.click(document.body);
expect(mockOnChange).not.toHaveBeenCalled();
unmount();
});
it('displays the dynamic id if provided', async () => {
const { unmount } = render(
<TransformationOperationRowHeader
index={0}
transformation={mergeTransform}
transformations={[mergeTransform, labelsToFieldsTransform]}
transformationTypeName="1 - Labels to fields"
onChange={() => {}}
dynamicRefId="test-A-B"
/>
);
expect(screen.getByText('test-A-B')).toBeInTheDocument();
unmount();
});
it('displays the static id if provided, even if dynamic ref id is also provided', async () => {
const { unmount } = render(
<TransformationOperationRowHeader
index={0}
transformation={labelsToFieldsRefId}
transformations={[labelsToFieldsRefId, mergeTransform]}
transformationTypeName="1 - Labels to fields"
onChange={() => {}}
dynamicRefId="refId"
/>
);
expect(screen.getByText('test')).toBeInTheDocument();
unmount();
});
});

View File

@@ -0,0 +1,204 @@
import { css, cx } from '@emotion/css';
import * as React from 'react';
import { useState } from 'react';
import { useToggle } from 'react-use';
import { GrafanaTheme2, DataTransformerConfig } from '@grafana/data';
import { t } from '@grafana/i18n';
import { FieldValidationMessage, Icon, Input, useStyles2 } from '@grafana/ui';
export interface Props {
index: number;
transformation: DataTransformerConfig;
transformations: DataTransformerConfig[];
transformationTypeName: string;
disabled?: boolean;
onChange: (index: number, config: DataTransformerConfig) => void;
dynamicRefId?: string;
}
export const TransformationOperationRowHeader = (props: Props) => {
const { index, transformation, transformations, onChange, disabled, transformationTypeName, dynamicRefId } = props;
const styles = useStyles2(getStyles);
const [isRefIdEditing, toggleIsRefIdEditing] = useToggle(false);
const [isStaticRefId, setIsStaticRefId] = useState(transformation.refId !== undefined);
const [validationError, setValidationError] = useState<string | null>(null);
const onEndEditRefId = (newRefId: string) => {
const trimmedNewRefId = newRefId.trim();
toggleIsRefIdEditing(false);
// Ignore change if invalid
if (validationError) {
setValidationError(null);
return;
}
if (transformation.refId !== trimmedNewRefId && trimmedNewRefId !== '') {
setIsStaticRefId(true);
onChange(index, {
...transformation,
refId: trimmedNewRefId,
});
} else if (trimmedNewRefId === '') {
// if it was previously custom and is now empty, it is being cleared out and we want to save it as undefined
if (isStaticRefId) {
onChange(index, {
...transformation,
refId: undefined,
});
}
// either way, if it is empty, we want it to display as not static
setIsStaticRefId(false);
}
};
const onInputChange = (event: React.SyntheticEvent<HTMLInputElement>) => {
const newRefId = event.currentTarget.value.trim();
for (const otherTransformation of transformations) {
if (otherTransformation !== transformation && newRefId === otherTransformation.refId) {
setValidationError('Transformation name already exists');
return;
}
}
if (validationError) {
setValidationError(null);
}
};
const onEditRefIdBlur = (event: React.SyntheticEvent<HTMLInputElement>) => {
onEndEditRefId(event.currentTarget.value.trim());
};
const onKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
if (event.key === 'Enter') {
onEndEditRefId(event.currentTarget.value);
}
};
const onFocus = (event: React.FocusEvent<HTMLInputElement>) => {
event.target.select();
};
return (
<div className={styles.wrapper}>
{!isRefIdEditing && (
<button
className={styles.refIdWrapper}
title={t(
'dashboard.transformation-operation-row.transformation-editor-row-header.edit-refId',
'Edit transformation name'
)}
onClick={() => toggleIsRefIdEditing()}
data-testid="transformation-refid-div"
type="button"
>
<span className={cx(styles.refIdStyle, !isStaticRefId && styles.placeholderText)}>
{transformation.refId ||
dynamicRefId ||
t(
'dashboard.transformation-operation-row.transformation-editor-row-header.edit-refId-placeholder',
'(Auto)'
)}
</span>
<Icon name="pen" className={styles.refIdEditIcon} size="sm" />
</button>
)}
{isRefIdEditing && (
<>
<Input
type="text"
defaultValue={transformation.refId}
onBlur={onEditRefIdBlur}
autoFocus
onKeyDown={onKeyDown}
onFocus={onFocus}
invalid={validationError !== null}
onChange={onInputChange}
className={styles.refIdInput}
data-testid="transformation-refid-input"
/>
{validationError && <FieldValidationMessage horizontal>{validationError}</FieldValidationMessage>}
</>
)}
<div>
<div className={cx(styles.title, disabled && styles.disabled)}>{transformationTypeName}</div>
</div>
</div>
);
};
const getStyles = (theme: GrafanaTheme2) => {
return {
wrapper: css({
label: 'Wrapper',
display: 'flex',
alignItems: 'center',
overflow: 'hidden',
}),
refIdWrapper: css({
display: 'flex',
cursor: 'pointer',
border: '1px solid transparent',
borderRadius: theme.shape.radius.default,
alignItems: 'center',
margin: 0,
background: 'transparent',
overflow: 'hidden',
'&:hover': {
background: theme.colors.action.hover,
border: `1px dashed ${theme.colors.border.strong}`,
},
'&:focus': {
border: `2px solid ${theme.colors.primary.border}`,
},
'&:hover, &:focus': {
'.transformation-refid-edit-icon': {
visibility: 'visible',
},
},
}),
refIdStyle: css({
fontWeight: theme.typography.fontWeightMedium,
color: theme.colors.primary.text,
cursor: 'pointer',
overflow: 'hidden',
}),
refIdEditIcon: cx(
css({
marginLeft: theme.spacing(1),
visibility: 'hidden',
}),
'transformation-refid-edit-icon'
),
refIdInput: css({
maxWidth: '300px',
margin: '-4px 0',
}),
placeholderText: css({
fontWeight: theme.typography.fontWeightRegular,
fontSize: theme.typography.bodySmall.fontSize,
color: theme.colors.text.secondary,
alignItems: 'center',
fontStyle: 'italic',
textOverflow: 'ellipsis',
}),
title: css({
fontWeight: theme.typography.fontWeightBold,
color: theme.colors.text.link,
marginLeft: theme.spacing(0.5),
overflow: 'hidden',
textOverflow: 'ellipsis',
}),
disabled: css({
color: theme.colors.text.disabled,
}),
};
};

View File

@@ -276,6 +276,16 @@ class UnThemedTransformationsEditor extends React.PureComponent<TransformationsE
renderTransformationEditors = () => {
const { data, transformations } = this.state;
const transformationNoRefIdIdxs = transformations
.map((t, i) => {
return t.refId === undefined ? i : undefined;
})
.filter((idx) => idx !== undefined);
transformationNoRefIdIdxs.forEach((tIdx, i) => {
transformations[tIdx].refId = `T-${i}`;
});
return (
<DragDropContext onDragEnd={this.onDragEnd}>
<Droppable droppableId="transformations-list" direction="vertical">

View File

@@ -2,5 +2,6 @@ import { DataTransformerConfig } from '@grafana/data';
export interface TransformationsEditorTransformation {
transformation: DataTransformerConfig;
refId?: string;
id: string;
}

View File

@@ -3,58 +3,62 @@ import { toDataFrame, FieldType, DataFrame } from '@grafana/data';
import { joinByLabels } from './joinByLabels';
describe('Join by labels', () => {
it('Simple join', () => {
const input = [
toDataFrame({
fields: [
{ name: 'Time', type: FieldType.time, values: [1, 2] },
{
name: 'Value',
type: FieldType.number,
config: {
displayNameFromDS: '111',
},
values: [10, 200],
labels: { what: 'Temp', cluster: 'A', job: 'J1' },
const input = [
toDataFrame({
refId: 'A',
fields: [
{ name: 'Time', type: FieldType.time, values: [1, 2] },
{
name: 'Value',
type: FieldType.number,
config: {
displayNameFromDS: '111',
},
],
}),
toDataFrame({
fields: [
{ name: 'Time', type: FieldType.time, values: [1, 2] },
{
name: 'Value',
type: FieldType.number,
config: {
displayNameFromDS: '222',
},
values: [10, 200],
labels: { what: 'Temp', cluster: 'B', job: 'J1' },
values: [10, 200],
labels: { what: 'Temp', cluster: 'A', job: 'J1' },
},
],
}),
toDataFrame({
refId: 'B',
fields: [
{ name: 'Time', type: FieldType.time, values: [1, 2] },
{
name: 'Value',
type: FieldType.number,
config: {
displayNameFromDS: '222',
},
],
}),
toDataFrame({
fields: [
{ name: 'Time', type: FieldType.time, values: [22, 28] },
{
name: 'Value',
type: FieldType.number,
config: {
displayNameFromDS: '333',
},
values: [22, 77],
labels: { what: 'Speed', cluster: 'B', job: 'J1' },
values: [10, 200],
labels: { what: 'Temp', cluster: 'B', job: 'J1' },
},
],
}),
toDataFrame({
refId: 'C',
fields: [
{ name: 'Time', type: FieldType.time, values: [22, 28] },
{
name: 'Value',
type: FieldType.number,
config: {
displayNameFromDS: '333',
},
],
}),
];
values: [22, 77],
labels: { what: 'Speed', cluster: 'B', job: 'J1' },
},
],
}),
];
it('Simple join with dynamic refId', () => {
const result = joinByLabels(
{
value: 'what',
},
input
);
expect(result.refId).toBe('joinByLabels-A-B-C');
expect(result.fields[result.fields.length - 1].config).toMatchInlineSnapshot(`{}`);
expect(toRowsSnapshow(result)).toMatchInlineSnapshot(`
{
@@ -94,6 +98,17 @@ describe('Join by labels', () => {
`);
});
it('Simple join with static refId', () => {
const result = joinByLabels(
{
value: 'what',
refId: 'test',
},
input
);
expect(result.refId).toBe('test');
});
it('Error handling (no labels)', () => {
const input = [
toDataFrame({

View File

@@ -1,6 +1,13 @@
import { map } from 'rxjs/operators';
import { DataFrame, DataTransformerID, Field, FieldType, SynchronousDataTransformerInfo } from '@grafana/data';
import {
DataFrame,
DataTransformerID,
Field,
FieldType,
getTransformationDynamicRefId,
SynchronousDataTransformerInfo,
} from '@grafana/data';
import { t } from '@grafana/i18n';
import { getDistinctLabels } from '../utils';
@@ -8,6 +15,7 @@ import { getDistinctLabels } from '../utils';
export interface JoinByLabelsTransformOptions {
value: string; // something must be defined
join?: string[];
refId?: string;
}
export const getJoinByLabelsTransformer: () => SynchronousDataTransformerInfo<JoinByLabelsTransformOptions> = () => ({
@@ -30,6 +38,7 @@ export const getJoinByLabelsTransformer: () => SynchronousDataTransformerInfo<Jo
return [joinByLabels(options, data)];
};
},
usesDynamicRefId: true,
});
interface JoinValues {
@@ -111,7 +120,7 @@ export function joinByLabels(options: JoinByLabelsTransformOptions, data: DataFr
const frame: DataFrame = {
fields: [],
length: nameValues[0].length,
refId: `${DataTransformerID.joinByLabels}-${data.map((frame) => frame.refId).join('-')}`,
refId: options.refId ?? getTransformationDynamicRefId(DataTransformerID.joinByLabels, data),
};
for (let i = 0; i < join.length; i++) {
frame.fields.push({

View File

@@ -5447,7 +5447,11 @@
"title-remove": "Remove",
"title-show-transform-help": "Show transform help"
},
"title-delete": "Delete {{name}}?"
"title-delete": "Delete {{name}}?",
"transformation-editor-row-header": {
"edit-refId": "Edit transformation name",
"edit-refId-placeholder": "(Auto)"
}
},
"transformation-picker": {
"info": "Transformations allow you to join, calculate, re-order, hide, and rename your query results before they are visualized.",