mirror of https://github.com/grafana/grafana
Transformations: Add Group to Nested Tables Transformation (#79952)
* Stub group to subframe transformation
* Get proper field grouping
* Mostly working but fields not displaying 😭
* Fix display processing in nested tables
* Modularize and start merging groupBy and groupToSubrame
* Get this working
* Prettier
* Typing things
* More types
* Add option for showing subframe table headers
* Prettier
* Get tests going
* Update tests
* Fix naming and add icons
* Betterer fix
* Prettier
* Fix CSS object syntax
* Prettier
* Stub alert for calcs with grouping, start renaming
* Add logic to show warning message for calculations
* Add calc warning
* Renaming and feature flag
* Rename images
* Prettier
* Fix tests
* Update feature toggle
* Fix error showing extra blank row
* minor code cleanup
---------
Co-authored-by: nmarrs <nathanielmarrs@gmail.com>
pull/82096/head
parent
26bc87b60e
commit
756cd3c28b
@ -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<GroupToNestedTableTransformerOptions> = { |
||||
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<GroupByTransformerOptions> = { |
||||
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); |
||||
}); |
||||
}); |
||||
}); |
@ -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<string, GroupByFieldOptions>; |
||||
} |
||||
|
||||
interface FieldMap { |
||||
[key: string]: Field; |
||||
} |
||||
|
||||
export const groupToNestedTable: DataTransformerInfo<GroupToNestedTableTransformerOptions> = { |
||||
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<string, unknown[]> = {}; |
||||
|
||||
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<string, FieldMap>, |
||||
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; |
||||
} |
|
@ -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<GroupToNestedTableTransformerOptions>) => { |
||||
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 ( |
||||
<Stack direction="column"> |
||||
{showCalcAlert && ( |
||||
<Alert title="Calculations will not have an effect if no fields are being grouped on." severity="warning" /> |
||||
)} |
||||
<div> |
||||
{fieldNames.map((key) => ( |
||||
<GroupByFieldConfiguration |
||||
onConfigChange={onConfigChange(key)} |
||||
fieldName={key} |
||||
config={options.fields[key]} |
||||
key={key} |
||||
/> |
||||
))} |
||||
</div> |
||||
<Field |
||||
label="Show field names in nested tables" |
||||
description="If enabled nested tables will show field names as a table header" |
||||
> |
||||
<Switch value={showHeaders} onChange={onShowFieldNamesChange} /> |
||||
</Field> |
||||
</Stack> |
||||
); |
||||
}; |
||||
|
||||
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<GroupByOperationID | null>) => { |
||||
onConfigChange({ |
||||
aggregations: config?.aggregations ?? [], |
||||
operation: value?.value ?? null, |
||||
}); |
||||
}, |
||||
[config, onConfigChange] |
||||
); |
||||
|
||||
return ( |
||||
<InlineField className={styles.label} label={fieldName} grow shrink> |
||||
<Stack gap={0.5} direction="row" wrap={false}> |
||||
<div className={styles.operation}> |
||||
<Select options={options} value={config?.operation} placeholder="Ignored" onChange={onChange} isClearable /> |
||||
</div> |
||||
|
||||
{config?.operation === GroupByOperationID.aggregate && ( |
||||
<StatsPicker |
||||
className={styles.aggregations} |
||||
placeholder="Select Stats" |
||||
allowMultiple |
||||
stats={config.aggregations} |
||||
onChange={(stats) => { |
||||
// eslint-disable-next-line
|
||||
onConfigChange({ ...config, aggregations: stats as ReducerID[] }); |
||||
}} |
||||
/> |
||||
)} |
||||
</Stack> |
||||
</InlineField> |
||||
); |
||||
}; |
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => { |
||||
return { |
||||
label: css({ |
||||
minWidth: theme.spacing(32), |
||||
}), |
||||
operation: css({ |
||||
flexShrink: 0, |
||||
height: '100%', |
||||
width: theme.spacing(24), |
||||
}), |
||||
aggregations: css({ |
||||
flexGrow: 1, |
||||
}), |
||||
}; |
||||
}; |
||||
|
||||
export const groupToNestedTableTransformRegistryItem: TransformerRegistryItem<GroupByTransformerOptions> = { |
||||
id: DataTransformerID.groupToNestedTable, |
||||
editor: GroupToNestedTableTransformerEditor, |
||||
transformation: standardTransformers.groupToNestedTable, |
||||
name: standardTransformers.groupToNestedTable.name, |
||||
description: standardTransformers.groupToNestedTable.description, |
||||
categories: new Set([ |
||||
TransformerCategory.Combine, |
||||
TransformerCategory.CalculateNewFields, |
||||
TransformerCategory.Reformat, |
||||
]), |
||||
}; |
After Width: | Height: | Size: 6.3 KiB |
After Width: | Height: | Size: 6.2 KiB |
After Width: | Height: | Size: 6.3 KiB |
Loading…
Reference in new issue