diff --git a/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md b/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md index e54f1e1e81c..ccb309d8722 100644 --- a/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md +++ b/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md @@ -87,6 +87,7 @@ Some features are enabled by default. You can disable these feature by setting t | `canvasPanelPanZoom` | Allow pan and zoom in canvas panel | | `regressionTransformation` | Enables regression analysis transformation | | `alertingPreviewUpgrade` | Show Unified Alerting preview and upgrade page in legacy alerting | +| `groupToNestedTableTransformation` | Enables the group to nested table transformation | ## Experimental feature toggles diff --git a/packages/grafana-data/src/field/fieldOverrides.ts b/packages/grafana-data/src/field/fieldOverrides.ts index dcbd4fb97b3..4047a589d96 100644 --- a/packages/grafana-data/src/field/fieldOverrides.ts +++ b/packages/grafana-data/src/field/fieldOverrides.ts @@ -206,6 +206,13 @@ export function applyFieldOverrides(options: ApplyFieldOverrideOptions): DataFra for (const nestedFrames of field.values) { for (let nfIndex = 0; nfIndex < nestedFrames.length; nfIndex++) { for (const valueField of nestedFrames[nfIndex].fields) { + // Get display processor for nested fields + valueField.display = getDisplayProcessor({ + field: valueField, + theme: options.theme, + timeZone: options.timeZone, + }); + valueField.state = { scopedVars: { __dataContext: { diff --git a/packages/grafana-data/src/transformations/transformers.ts b/packages/grafana-data/src/transformations/transformers.ts index 4086a5613fe..3344a7fb450 100644 --- a/packages/grafana-data/src/transformations/transformers.ts +++ b/packages/grafana-data/src/transformations/transformers.ts @@ -9,6 +9,7 @@ import { filterByValueTransformer } from './transformers/filterByValue'; import { formatStringTransformer } from './transformers/formatString'; import { formatTimeTransformer } from './transformers/formatTime'; import { groupByTransformer } from './transformers/groupBy'; +import { groupToNestedTable } from './transformers/groupToNestedTable'; import { groupingToMatrixTransformer } from './transformers/groupingToMatrix'; import { histogramTransformer } from './transformers/histogram'; import { joinByFieldTransformer } from './transformers/joinByField'; @@ -53,4 +54,5 @@ export const standardTransformers = { convertFieldTypeTransformer, groupingToMatrixTransformer, limitTransformer, + groupToNestedTable, }; diff --git a/packages/grafana-data/src/transformations/transformers/groupBy.ts b/packages/grafana-data/src/transformations/transformers/groupBy.ts index 3fd95db316b..fd5e28aa7e6 100644 --- a/packages/grafana-data/src/transformations/transformers/groupBy.ts +++ b/packages/grafana-data/src/transformations/transformers/groupBy.ts @@ -22,6 +22,10 @@ export interface GroupByTransformerOptions { fields: Record; } +interface FieldMap { + [key: string]: Field; +} + export const groupByTransformer: DataTransformerInfo = { id: DataTransformerID.groupBy, name: 'Group by', @@ -75,64 +79,19 @@ export const groupByTransformer: DataTransformerInfo const processed: DataFrame[] = []; for (const frame of data) { - const groupByFields: Field[] = []; - - for (const field of frame.fields) { - if (shouldGroupOnField(field, options)) { - groupByFields.push(field); - } - } - + // Create a list of fields to group on + // If there are none we skip the rest + const groupByFields: Field[] = frame.fields.filter((field) => shouldGroupOnField(field, options)); if (groupByFields.length === 0) { - continue; // No group by field in this frame, ignore the frame + continue; } // Group the values by fields and groups so we can get all values for a // group for a given field. - const valuesByGroupKey = new Map>(); - for (let rowIndex = 0; rowIndex < frame.length; rowIndex++) { - const groupKey = String(groupByFields.map((field) => field.values[rowIndex])); - const valuesByField = valuesByGroupKey.get(groupKey) ?? {}; + const valuesByGroupKey = groupValuesByKey(frame, groupByFields); - if (!valuesByGroupKey.has(groupKey)) { - valuesByGroupKey.set(groupKey, valuesByField); - } - - for (let field of frame.fields) { - const fieldName = getFieldDisplayName(field); - - if (!valuesByField[fieldName]) { - valuesByField[fieldName] = { - name: fieldName, - type: field.type, - config: { ...field.config }, - values: [], - }; - } - - valuesByField[fieldName].values.push(field.values[rowIndex]); - } - } - - const fields: Field[] = []; - - for (const field of groupByFields) { - const values: unknown[] = []; - const fieldName = getFieldDisplayName(field); - - valuesByGroupKey.forEach((value) => { - values.push(value[fieldName].values[0]); - }); - - fields.push({ - name: field.name, - type: field.type, - config: { - ...field.config, - }, - values: values, - }); - } + // Add the grouped fields to the resulting fields of the transformation + const fields: Field[] = createGroupedFields(groupByFields, valuesByGroupKey); // Then for each calculations configured, compute and add a new field (column) for (const field of frame.fields) { @@ -197,7 +156,7 @@ const shouldCalculateField = (field: Field, options: GroupByTransformerOptions): ); }; -const detectFieldType = (aggregation: string, sourceField: Field, targetField: Field): FieldType => { +function detectFieldType(aggregation: string, sourceField: Field, targetField: Field): FieldType { switch (aggregation) { case ReducerID.allIsNull: return FieldType.boolean; @@ -209,4 +168,75 @@ const detectFieldType = (aggregation: string, sourceField: Field, targetField: F default: return guessFieldTypeForField(targetField) ?? FieldType.string; } -}; +} + +/** + * Groups values together by key. This will create a mapping of strings + * to _FieldMaps_ that will then be used to group values on. + * + * @param frame + * The dataframe containing the data to group. + * @param groupByFields + * An array of fields to group on. + */ +export function groupValuesByKey(frame: DataFrame, groupByFields: Field[]) { + const valuesByGroupKey = new Map(); + + for (let rowIndex = 0; rowIndex < frame.length; rowIndex++) { + const groupKey = String(groupByFields.map((field) => field.values[rowIndex])); + const valuesByField = valuesByGroupKey.get(groupKey) ?? {}; + + if (!valuesByGroupKey.has(groupKey)) { + valuesByGroupKey.set(groupKey, valuesByField); + } + + for (let field of frame.fields) { + const fieldName = getFieldDisplayName(field); + + if (!valuesByField[fieldName]) { + valuesByField[fieldName] = { + name: fieldName, + type: field.type, + config: { ...field.config }, + values: [], + }; + } + + valuesByField[fieldName].values.push(field.values[rowIndex]); + } + } + + return valuesByGroupKey; +} + +/** + * Create new fields which will be used to display grouped values. + * + * @param groupByFields + * @param valuesByGroupKey + * @returns + * Returns an array of fields that have been grouped. + */ +export function createGroupedFields(groupByFields: Field[], valuesByGroupKey: Map): Field[] { + const fields: Field[] = []; + + for (const field of groupByFields) { + const values: unknown[] = []; + const fieldName = getFieldDisplayName(field); + + valuesByGroupKey.forEach((value) => { + values.push(value[fieldName].values[0]); + }); + + fields.push({ + name: field.name, + type: field.type, + config: { + ...field.config, + }, + values, + }); + } + + return fields; +} diff --git a/packages/grafana-data/src/transformations/transformers/groupToNestedTable.test.ts b/packages/grafana-data/src/transformations/transformers/groupToNestedTable.test.ts new file mode 100644 index 00000000000..d36a0ce1adf --- /dev/null +++ b/packages/grafana-data/src/transformations/transformers/groupToNestedTable.test.ts @@ -0,0 +1,243 @@ +import { toDataFrame } from '../../dataframe/processDataFrame'; +import { DataTransformerConfig, Field, FieldType } from '../../types'; +import { mockTransformationsRegistry } from '../../utils/tests/mockTransformationsRegistry'; +import { ReducerID } from '../fieldReducer'; +import { transformDataFrame } from '../transformDataFrame'; + +import { GroupByOperationID, GroupByTransformerOptions } from './groupBy'; +import { groupToNestedTable, GroupToNestedTableTransformerOptions } from './groupToNestedTable'; +import { DataTransformerID } from './ids'; + +describe('GroupToSubframe transformer', () => { + beforeAll(() => { + mockTransformationsRegistry([groupToNestedTable]); + }); + + it('should group values by message and place values in subframe', async () => { + const testSeries = toDataFrame({ + name: 'A', + fields: [ + { name: 'time', type: FieldType.time, values: [3000, 4000, 5000, 6000, 7000, 8000] }, + { name: 'message', type: FieldType.string, values: ['one', 'two', 'two', 'three', 'three', 'three'] }, + { name: 'values', type: FieldType.string, values: [1, 2, 2, 3, 3, 3] }, + ], + }); + + const cfg: DataTransformerConfig = { + id: DataTransformerID.groupToNestedTable, + options: { + fields: { + message: { + operation: GroupByOperationID.groupBy, + aggregations: [], + }, + }, + }, + }; + + await expect(transformDataFrame([cfg], [testSeries])).toEmitValuesWith((received) => { + const result = received[0]; + const expected: Field[] = [ + { + name: 'message', + type: FieldType.string, + config: {}, + values: ['one', 'two', 'three'], + }, + { + name: 'Nested frames', + type: FieldType.nestedFrames, + config: {}, + values: [ + [ + { + meta: { custom: { noHeader: false } }, + length: 1, + fields: [ + { name: 'time', type: 'time', config: {}, values: [3000] }, + { name: 'values', type: 'string', config: {}, values: [1] }, + ], + }, + ], + [ + { + meta: { custom: { noHeader: false } }, + length: 2, + fields: [ + { + name: 'time', + type: 'time', + config: {}, + values: [4000, 5000], + }, + { + name: 'values', + type: 'string', + config: {}, + values: [2, 2], + }, + ], + }, + ], + [ + { + meta: { custom: { noHeader: false } }, + length: 3, + fields: [ + { + name: 'time', + type: 'time', + config: {}, + values: [6000, 7000, 8000], + }, + { + name: 'values', + type: 'string', + config: {}, + values: [3, 3, 3], + }, + ], + }, + ], + ], + }, + ]; + + expect(result[0].fields).toEqual(expected); + }); + }); + + it('should group by message, compute a few calculations for each group of values, and place other values in a subframe', async () => { + const testSeries = toDataFrame({ + name: 'A', + fields: [ + { name: 'time', type: FieldType.time, values: [3000, 4000, 5000, 6000, 7000, 8000] }, + { name: 'message', type: FieldType.string, values: ['one', 'two', 'two', 'three', 'three', 'three'] }, + { name: 'values', type: FieldType.number, values: [1, 2, 2, 3, 3, 3] }, + { name: 'intVal', type: FieldType.number, values: [1, 2, 3, 4, 5, 6] }, + { name: 'floatVal', type: FieldType.number, values: [1.1, 2.3, 3.6, 4.8, 5.7, 6.9] }, + ], + }); + + const cfg: DataTransformerConfig = { + id: DataTransformerID.groupToNestedTable, + options: { + fields: { + message: { + operation: GroupByOperationID.groupBy, + aggregations: [], + }, + values: { + operation: GroupByOperationID.aggregate, + aggregations: [ReducerID.sum], + }, + }, + }, + }; + + await expect(transformDataFrame([cfg], [testSeries])).toEmitValuesWith((received) => { + const result = received[0]; + const expected: Field[] = [ + { + name: 'message', + type: FieldType.string, + config: {}, + values: ['one', 'two', 'three'], + }, + { + name: 'values (sum)', + values: [1, 4, 9], + type: FieldType.number, + config: {}, + }, + { + config: {}, + name: 'Nested frames', + type: FieldType.nestedFrames, + values: [ + [ + { + meta: { custom: { noHeader: false } }, + length: 1, + fields: [ + { + name: 'time', + type: 'time', + config: {}, + values: [3000], + }, + { + name: 'intVal', + type: 'number', + config: {}, + values: [1], + }, + { + name: 'floatVal', + type: 'number', + config: {}, + values: [1.1], + }, + ], + }, + ], + [ + { + meta: { custom: { noHeader: false } }, + length: 2, + fields: [ + { + name: 'time', + type: 'time', + config: {}, + values: [4000, 5000], + }, + { + name: 'intVal', + type: 'number', + config: {}, + values: [2, 3], + }, + { + name: 'floatVal', + type: 'number', + config: {}, + values: [2.3, 3.6], + }, + ], + }, + ], + [ + { + meta: { custom: { noHeader: false } }, + length: 3, + fields: [ + { + name: 'time', + type: 'time', + config: {}, + values: [6000, 7000, 8000], + }, + { + name: 'intVal', + type: 'number', + config: {}, + values: [4, 5, 6], + }, + { + name: 'floatVal', + type: 'number', + config: {}, + values: [4.8, 5.7, 6.9], + }, + ], + }, + ], + ], + }, + ]; + + expect(result[0].fields).toEqual(expected); + }); + }); +}); diff --git a/packages/grafana-data/src/transformations/transformers/groupToNestedTable.ts b/packages/grafana-data/src/transformations/transformers/groupToNestedTable.ts new file mode 100644 index 00000000000..146f86a6e46 --- /dev/null +++ b/packages/grafana-data/src/transformations/transformers/groupToNestedTable.ts @@ -0,0 +1,235 @@ +import { map } from 'rxjs/operators'; + +import { guessFieldTypeForField } from '../../dataframe/processDataFrame'; +import { getFieldDisplayName } from '../../field/fieldState'; +import { DataFrame, Field, FieldType } from '../../types/dataFrame'; +import { DataTransformerInfo } from '../../types/transformations'; +import { ReducerID, reduceField } from '../fieldReducer'; + +import { GroupByFieldOptions, createGroupedFields, groupValuesByKey } from './groupBy'; +import { DataTransformerID } from './ids'; + +export const SHOW_NESTED_HEADERS_DEFAULT = true; + +export enum GroupByOperationID { + aggregate = 'aggregate', + groupBy = 'groupby', +} + +export interface GroupToNestedTableTransformerOptions { + showSubframeHeaders?: boolean; + fields: Record; +} + +interface FieldMap { + [key: string]: Field; +} + +export const groupToNestedTable: DataTransformerInfo = { + id: DataTransformerID.groupToNestedTable, + name: 'Group to nested tables', + description: 'Group data by a field value and create nested tables with the grouped data', + defaultOptions: { + showSubframeHeaders: SHOW_NESTED_HEADERS_DEFAULT, + fields: {}, + }, + + /** + * Return a modified copy of the series. If the transform is not or should not + * be applied, just return the input series + */ + operator: (options) => (source) => + source.pipe( + map((data) => { + const hasValidConfig = Object.keys(options.fields).find( + (name) => options.fields[name].operation === GroupByOperationID.groupBy + ); + if (!hasValidConfig) { + return data; + } + + const processed: DataFrame[] = []; + + for (const frame of data) { + // Create a list of fields to group on + // If there are none we skip the rest + const groupByFields: Field[] = frame.fields.filter((field) => shouldGroupOnField(field, options)); + if (groupByFields.length === 0) { + continue; + } + + // Group the values by fields and groups so we can get all values for a + // group for a given field. + const valuesByGroupKey = groupValuesByKey(frame, groupByFields); + + // Add the grouped fields to the resulting fields of the transformation + const fields: Field[] = createGroupedFields(groupByFields, valuesByGroupKey); + + // Group data into sub frames so they will display as tables + const subFrames: DataFrame[][] = groupToSubframes(valuesByGroupKey, options); + + // Then for each calculations configured, compute and add a new field (column) + for (let i = 0; i < frame.fields.length; i++) { + const field = frame.fields[i]; + + if (!shouldCalculateField(field, options)) { + continue; + } + + const fieldName = getFieldDisplayName(field); + const aggregations = options.fields[fieldName].aggregations; + const valuesByAggregation: Record = {}; + + valuesByGroupKey.forEach((value) => { + const fieldWithValuesForGroup = value[fieldName]; + const results = reduceField({ + field: fieldWithValuesForGroup, + reducers: aggregations, + }); + + for (const aggregation of aggregations) { + if (!Array.isArray(valuesByAggregation[aggregation])) { + valuesByAggregation[aggregation] = []; + } + valuesByAggregation[aggregation].push(results[aggregation]); + } + }); + + for (const aggregation of aggregations) { + const aggregationField: Field = { + name: `${fieldName} (${aggregation})`, + values: valuesByAggregation[aggregation], + type: FieldType.other, + config: {}, + }; + + aggregationField.type = detectFieldType(aggregation, field, aggregationField); + fields.push(aggregationField); + } + } + + fields.push({ + config: {}, + name: 'Nested frames', + type: FieldType.nestedFrames, + values: subFrames, + }); + + processed.push({ + fields, + length: valuesByGroupKey.size, + }); + } + + return processed; + }) + ), +}; + +/** + * Given the appropriate data, create a sub-frame + * which can then be displayed in a sub-table. + */ +function createSubframe(fields: Field[], frameLength: number, options: GroupToNestedTableTransformerOptions) { + const showHeaders = + options.showSubframeHeaders === undefined ? SHOW_NESTED_HEADERS_DEFAULT : options.showSubframeHeaders; + + return { + meta: { custom: { noHeader: !showHeaders } }, + length: frameLength, + fields, + }; +} + +/** + * Determines whether a field should be grouped on. + * + * @returns boolean + * This will return _true_ if a field should be grouped on and _false_ if it should not. + */ +const shouldGroupOnField = (field: Field, options: GroupToNestedTableTransformerOptions): boolean => { + const fieldName = getFieldDisplayName(field); + return options?.fields[fieldName]?.operation === GroupByOperationID.groupBy; +}; + +/** + * Determines whether field aggregations should be calculated + * @returns boolean + * This will return _true_ if a field should be calculated and _false_ if it should not. + */ +const shouldCalculateField = (field: Field, options: GroupToNestedTableTransformerOptions): boolean => { + const fieldName = getFieldDisplayName(field); + return ( + options?.fields[fieldName]?.operation === GroupByOperationID.aggregate && + Array.isArray(options?.fields[fieldName].aggregations) && + options?.fields[fieldName].aggregations.length > 0 + ); +}; + +/** + * Detect the type of field given the relevant aggregation. + */ +const detectFieldType = (aggregation: string, sourceField: Field, targetField: Field): FieldType => { + switch (aggregation) { + case ReducerID.allIsNull: + return FieldType.boolean; + case ReducerID.last: + case ReducerID.lastNotNull: + case ReducerID.first: + case ReducerID.firstNotNull: + return sourceField.type; + default: + return guessFieldTypeForField(targetField) ?? FieldType.string; + } +}; + +/** + * Group values into subframes so that they'll be displayed + * inside of a subtable. + * + * @param valuesByGroupKey + * A mapping of group keys to their respective grouped values. + * @param options + * Transformation options, which are used to find ungrouped/unaggregated fields. + * @returns + */ +function groupToSubframes( + valuesByGroupKey: Map, + options: GroupToNestedTableTransformerOptions +): DataFrame[][] { + const subFrames: DataFrame[][] = []; + + // Construct a subframe of any fields + // that aren't being group on or reduced + for (const [, value] of valuesByGroupKey) { + const nestedFields: Field[] = []; + + for (const [fieldName, field] of Object.entries(value)) { + const fieldOpts = options.fields[fieldName]; + + if (fieldOpts === undefined) { + nestedFields.push(field); + } + // Depending on the configuration form state all of the following are possible + else if ( + fieldOpts.aggregations === undefined || + (fieldOpts.operation === GroupByOperationID.aggregate && fieldOpts.aggregations.length === 0) || + fieldOpts.operation === null || + fieldOpts.operation === undefined + ) { + nestedFields.push(field); + } + } + + // If there are any values in the subfields + // push a new subframe with the fields + // otherwise push an empty frame + if (nestedFields.length > 0) { + subFrames.push([createSubframe(nestedFields, nestedFields[0].values.length, options)]); + } else { + subFrames.push([createSubframe([], 0, options)]); + } + } + + return subFrames; +} diff --git a/packages/grafana-data/src/transformations/transformers/ids.ts b/packages/grafana-data/src/transformations/transformers/ids.ts index 4d5f7e2dd9e..06ec0cd6277 100644 --- a/packages/grafana-data/src/transformations/transformers/ids.ts +++ b/packages/grafana-data/src/transformations/transformers/ids.ts @@ -40,4 +40,5 @@ export enum DataTransformerID { formatTime = 'formatTime', formatString = 'formatString', regression = 'regression', + groupToNestedTable = 'groupToNestedTable', } diff --git a/packages/grafana-data/src/types/featureToggles.gen.ts b/packages/grafana-data/src/types/featureToggles.gen.ts index 8f5a8d9517c..6e886b9946a 100644 --- a/packages/grafana-data/src/types/featureToggles.gen.ts +++ b/packages/grafana-data/src/types/featureToggles.gen.ts @@ -173,4 +173,5 @@ export interface FeatureToggles { alertingSaveStatePeriodic?: boolean; promQLScope?: boolean; nodeGraphDotLayout?: boolean; + groupToNestedTableTransformation?: boolean; } diff --git a/pkg/services/featuremgmt/registry.go b/pkg/services/featuremgmt/registry.go index 39fb0b2907f..85bab6072a7 100644 --- a/pkg/services/featuremgmt/registry.go +++ b/pkg/services/featuremgmt/registry.go @@ -1310,5 +1310,13 @@ var ( Owner: grafanaObservabilityTracesAndProfilingSquad, Created: time.Date(2024, time.January, 2, 12, 0, 0, 0, time.UTC), }, + { + Name: "groupToNestedTableTransformation", + Description: "Enables the group to nested table transformation", + Stage: FeatureStagePublicPreview, + FrontendOnly: true, + Owner: grafanaDatavizSquad, + Created: time.Date(2024, time.February, 5, 12, 0, 0, 0, time.UTC), + }, } ) diff --git a/pkg/services/featuremgmt/toggles_gen.csv b/pkg/services/featuremgmt/toggles_gen.csv index 025ca9a8c5f..0c28678d94f 100644 --- a/pkg/services/featuremgmt/toggles_gen.csv +++ b/pkg/services/featuremgmt/toggles_gen.csv @@ -154,3 +154,4 @@ onPremToCloudMigrations,experimental,@grafana/grafana-operator-experience-squad, alertingSaveStatePeriodic,privatePreview,@grafana/alerting-squad,2024-01-22,false,false,false promQLScope,experimental,@grafana/observability-metrics,2024-01-29,false,false,false nodeGraphDotLayout,experimental,@grafana/observability-traces-and-profiling,2024-01-02,false,false,true +groupToNestedTableTransformation,preview,@grafana/dataviz-squad,2024-02-05,false,false,true diff --git a/pkg/services/featuremgmt/toggles_gen.go b/pkg/services/featuremgmt/toggles_gen.go index 91b0db67bb7..78711cfd750 100644 --- a/pkg/services/featuremgmt/toggles_gen.go +++ b/pkg/services/featuremgmt/toggles_gen.go @@ -626,4 +626,8 @@ const ( // FlagNodeGraphDotLayout // Changed the layout algorithm for the node graph FlagNodeGraphDotLayout = "nodeGraphDotLayout" + + // FlagGroupToNestedTableTransformation + // Enables the group to nested table transformation + FlagGroupToNestedTableTransformation = "groupToNestedTableTransformation" ) diff --git a/public/app/features/transformers/editors/GroupByTransformerEditor.tsx b/public/app/features/transformers/editors/GroupByTransformerEditor.tsx index 0966edf266e..50177ea8912 100644 --- a/public/app/features/transformers/editors/GroupByTransformerEditor.tsx +++ b/public/app/features/transformers/editors/GroupByTransformerEditor.tsx @@ -16,7 +16,7 @@ import { GroupByOperationID, GroupByTransformerOptions, } from '@grafana/data/src/transformations/transformers/groupBy'; -import { useTheme2, Select, StatsPicker, InlineField, Stack } from '@grafana/ui'; +import { useTheme2, Select, StatsPicker, InlineField, Stack, Alert } from '@grafana/ui'; import { getTransformationContent } from '../docs/getTransformationContent'; import { useAllFieldNamesFromDataFrames } from '../utils'; @@ -49,8 +49,28 @@ export const GroupByTransformerEditor = ({ [onChange] ); + // See if there's both an aggregation and grouping field configured + // for calculations. If not we display a warning because there + // needs to be a grouping for the calculation to have effect + let hasGrouping, + hasAggregation = false; + + for (const field of Object.values(options.fields)) { + if (field.aggregations.length > 0 && field.operation !== null) { + hasAggregation = true; + } + if (field.operation === GroupByOperationID.groupBy) { + hasGrouping = true; + } + } + + const showCalcAlert = hasAggregation && !hasGrouping; + return ( -
+ + {showCalcAlert && ( + + )} {fieldNames.map((key) => ( ))} -
+ ); }; diff --git a/public/app/features/transformers/editors/GroupToNestedTableTransformerEditor.tsx b/public/app/features/transformers/editors/GroupToNestedTableTransformerEditor.tsx new file mode 100644 index 00000000000..2811235c460 --- /dev/null +++ b/public/app/features/transformers/editors/GroupToNestedTableTransformerEditor.tsx @@ -0,0 +1,185 @@ +import { css } from '@emotion/css'; +import React, { useCallback } from 'react'; + +import { + DataTransformerID, + ReducerID, + SelectableValue, + standardTransformers, + TransformerRegistryItem, + TransformerUIProps, + TransformerCategory, + GrafanaTheme2, +} from '@grafana/data'; +import { + GroupByFieldOptions, + GroupByOperationID, + GroupByTransformerOptions, +} from '@grafana/data/src/transformations/transformers/groupBy'; +import { + GroupToNestedTableTransformerOptions, + SHOW_NESTED_HEADERS_DEFAULT, +} from '@grafana/data/src/transformations/transformers/groupToNestedTable'; +import { Stack } from '@grafana/experimental'; +import { useTheme2, Select, StatsPicker, InlineField, Field, Switch, Alert } from '@grafana/ui'; + +import { useAllFieldNamesFromDataFrames } from '../utils'; + +interface FieldProps { + fieldName: string; + config?: GroupByFieldOptions; + onConfigChange: (config: GroupByFieldOptions) => void; +} + +export const GroupToNestedTableTransformerEditor = ({ + input, + options, + onChange, +}: TransformerUIProps) => { + const fieldNames = useAllFieldNamesFromDataFrames(input); + const showHeaders = + options.showSubframeHeaders === undefined ? SHOW_NESTED_HEADERS_DEFAULT : options.showSubframeHeaders; + + const onConfigChange = useCallback( + (fieldName: string) => (config: GroupByFieldOptions) => { + onChange({ + ...options, + fields: { + ...options.fields, + [fieldName]: config, + }, + }); + }, + // Adding options to the dependency array causes infinite loop here. + // eslint-disable-next-line react-hooks/exhaustive-deps + [onChange] + ); + + const onShowFieldNamesChange = useCallback( + () => { + const showSubframeHeaders = + options.showSubframeHeaders === undefined ? !SHOW_NESTED_HEADERS_DEFAULT : !options.showSubframeHeaders; + + onChange({ + showSubframeHeaders, + fields: { + ...options.fields, + }, + }); + }, + // Adding options to the dependency array causes infinite loop here. + // eslint-disable-next-line react-hooks/exhaustive-deps + [onChange] + ); + + // See if there's both an aggregation and grouping field configured + // for calculations. If not we display a warning because there + // needs to be a grouping for the calculation to have effect + let hasGrouping, + hasAggregation = false; + for (const field of Object.values(options.fields)) { + if (field.aggregations.length > 0 && field.operation !== null) { + hasAggregation = true; + } + if (field.operation === GroupByOperationID.groupBy) { + hasGrouping = true; + } + } + const showCalcAlert = hasAggregation && !hasGrouping; + + return ( + + {showCalcAlert && ( + + )} +
+ {fieldNames.map((key) => ( + + ))} +
+ + + +
+ ); +}; + +const options = [ + { label: 'Group by', value: GroupByOperationID.groupBy }, + { label: 'Calculate', value: GroupByOperationID.aggregate }, +]; + +export const GroupByFieldConfiguration = ({ fieldName, config, onConfigChange }: FieldProps) => { + const theme = useTheme2(); + const styles = getStyles(theme); + + const onChange = useCallback( + (value: SelectableValue) => { + onConfigChange({ + aggregations: config?.aggregations ?? [], + operation: value?.value ?? null, + }); + }, + [config, onConfigChange] + ); + + return ( + + +
+