diff --git a/conf/defaults.ini b/conf/defaults.ini index f6de3fe15b1..52b9a4f2ed3 100644 --- a/conf/defaults.ini +++ b/conf/defaults.ini @@ -682,3 +682,7 @@ app_tls_skip_verify_insecure = false [enterprise] license_path = + +[feature_toggles] +# enable features, separated by spaces +enable = diff --git a/packages/grafana-data/src/utils/transformers/filter.ts b/packages/grafana-data/src/utils/transformers/filter.ts index 02083e3d52b..7837c03d233 100644 --- a/packages/grafana-data/src/utils/transformers/filter.ts +++ b/packages/grafana-data/src/utils/transformers/filter.ts @@ -1,4 +1,5 @@ -import { DataTransformerInfo, NoopDataTransformer } from './transformers'; +import { DataTransformerInfo } from './transformers'; +import { noopTransformer } from './noop'; import { DataFrame, Field } from '../../types/dataFrame'; import { FieldMatcherID } from '../matchers/ids'; import { DataTransformerID } from './ids'; @@ -23,7 +24,7 @@ export const filterFieldsTransformer: DataTransformerInfo = { */ transformer: (options: FilterOptions) => { if (!options.include && !options.exclude) { - return NoopDataTransformer; + return noopTransformer.transformer({}); } const include = options.include ? getFieldMatcher(options.include) : null; @@ -75,7 +76,7 @@ export const filterFramesTransformer: DataTransformerInfo = { */ transformer: (options: FilterOptions) => { if (!options.include && !options.exclude) { - return NoopDataTransformer; + return noopTransformer.transformer({}); } const include = options.include ? getFrameMatchers(options.include) : null; diff --git a/packages/grafana-data/src/utils/transformers/filterByName.test.ts b/packages/grafana-data/src/utils/transformers/filterByName.test.ts new file mode 100644 index 00000000000..75525065412 --- /dev/null +++ b/packages/grafana-data/src/utils/transformers/filterByName.test.ts @@ -0,0 +1,66 @@ +import { toDataFrame, transformDataFrame } from '../index'; +import { FieldType } from '../../index'; +import { DataTransformerID } from './ids'; + +export const seriesWithNamesToMatch = toDataFrame({ + fields: [ + { name: 'startsWithA', type: FieldType.time, values: [1000, 2000] }, + { name: 'B', type: FieldType.boolean, values: [true, false] }, + { name: 'startsWithC', type: FieldType.string, values: ['a', 'b'] }, + { name: 'D', type: FieldType.number, values: [1, 2] }, + ], +}); + +describe('filterByName transformer', () => { + it('returns original series if no options provided', () => { + const cfg = { + id: DataTransformerID.filterFields, + options: {}, + }; + + const filtered = transformDataFrame([cfg], [seriesWithNamesToMatch])[0]; + expect(filtered.fields.length).toBe(4); + }); + + describe('respects', () => { + it('inclusion', () => { + const cfg = { + id: DataTransformerID.filterFieldsByName, + options: { + include: '/^(startsWith)/', + }, + }; + + const filtered = transformDataFrame([cfg], [seriesWithNamesToMatch])[0]; + expect(filtered.fields.length).toBe(2); + expect(filtered.fields[0].name).toBe('startsWithA'); + }); + + it('exclusion', () => { + const cfg = { + id: DataTransformerID.filterFieldsByName, + options: { + exclude: '/^(startsWith)/', + }, + }; + + const filtered = transformDataFrame([cfg], [seriesWithNamesToMatch])[0]; + expect(filtered.fields.length).toBe(2); + expect(filtered.fields[0].name).toBe('B'); + }); + + it('inclusion and exclusion', () => { + const cfg = { + id: DataTransformerID.filterFieldsByName, + options: { + exclude: '/^(startsWith)/', + include: `/^(B)$/`, + }, + }; + + const filtered = transformDataFrame([cfg], [seriesWithNamesToMatch])[0]; + expect(filtered.fields.length).toBe(1); + expect(filtered.fields[0].name).toBe('B'); + }); + }); +}); diff --git a/packages/grafana-data/src/utils/transformers/filterByName.ts b/packages/grafana-data/src/utils/transformers/filterByName.ts new file mode 100644 index 00000000000..1d72116dc72 --- /dev/null +++ b/packages/grafana-data/src/utils/transformers/filterByName.ts @@ -0,0 +1,38 @@ +import { DataTransformerInfo } from './transformers'; +import { FieldMatcherID } from '../matchers/ids'; +import { DataTransformerID } from './ids'; +import { filterFieldsTransformer, FilterOptions } from './filter'; + +export interface FilterFieldsByNameTransformerOptions { + include?: string; + exclude?: string; +} + +export const filterFieldsByNameTransformer: DataTransformerInfo = { + id: DataTransformerID.filterFieldsByName, + name: 'Filter fields by name', + description: 'select a subset of fields', + defaultOptions: {}, + + /** + * Return a modified copy of the series. If the transform is not or should not + * be applied, just return the input series + */ + transformer: (options: FilterFieldsByNameTransformerOptions) => { + const filterOptions: FilterOptions = {}; + if (options.include) { + filterOptions.include = { + id: FieldMatcherID.byName, + options: options.include, + }; + } + if (options.exclude) { + filterOptions.exclude = { + id: FieldMatcherID.byName, + options: options.exclude, + }; + } + + return filterFieldsTransformer.transformer(filterOptions); + }, +}; diff --git a/packages/grafana-data/src/utils/transformers/ids.ts b/packages/grafana-data/src/utils/transformers/ids.ts index 32fab351371..c088186fc0a 100644 --- a/packages/grafana-data/src/utils/transformers/ids.ts +++ b/packages/grafana-data/src/utils/transformers/ids.ts @@ -5,5 +5,7 @@ export enum DataTransformerID { reduce = 'reduce', // Run calculations on fields filterFields = 'filterFields', // Pick some fields (keep all frames) + filterFieldsByName = 'filterFieldsByName', // Pick fields with name matching regex (keep all frames) filterFrames = 'filterFrames', // Pick some frames (keep all fields) + noop = 'noop', // Does nothing to the dataframe } diff --git a/packages/grafana-data/src/utils/transformers/noop.ts b/packages/grafana-data/src/utils/transformers/noop.ts new file mode 100644 index 00000000000..96bded6b89a --- /dev/null +++ b/packages/grafana-data/src/utils/transformers/noop.ts @@ -0,0 +1,23 @@ +import { DataTransformerInfo } from './transformers'; +import { DataTransformerID } from './ids'; +import { DataFrame } from '../../types/dataFrame'; + +export interface NoopTransformerOptions { + include?: string; + exclude?: string; +} + +export const noopTransformer: DataTransformerInfo = { + id: DataTransformerID.noop, + name: 'noop', + description: 'No-operation transformer', + defaultOptions: {}, + + /** + * Return a modified copy of the series. If the transform is not or should not + * be applied, just return the input series + */ + transformer: (options: NoopTransformerOptions) => { + return (data: DataFrame[]) => data; + }, +}; diff --git a/packages/grafana-data/src/utils/transformers/reduce.ts b/packages/grafana-data/src/utils/transformers/reduce.ts index a70ecd13bbf..a30a9c0ef12 100644 --- a/packages/grafana-data/src/utils/transformers/reduce.ts +++ b/packages/grafana-data/src/utils/transformers/reduce.ts @@ -8,26 +8,26 @@ import { KeyValue } from '../../types/data'; import { ArrayVector } from '../vector'; import { guessFieldTypeForField } from '../processDataFrame'; -export interface ReduceOptions { - reducers: string[]; +export interface ReduceTransformerOptions { + reducers: ReducerID[]; fields?: MatcherConfig; // Assume all fields } -export const reduceTransformer: DataTransformerInfo = { +export const reduceTransformer: DataTransformerInfo = { id: DataTransformerID.reduce, name: 'Reducer', description: 'Return a DataFrame with the reduction results', defaultOptions: { - calcs: [ReducerID.min, ReducerID.max, ReducerID.mean, ReducerID.last], + reducers: [ReducerID.min, ReducerID.max, ReducerID.mean, ReducerID.last], }, /** * Return a modified copy of the series. If the transform is not or should not * be applied, just return the input series */ - transformer: (options: ReduceOptions) => { + transformer: (options: ReduceTransformerOptions) => { const matcher = options.fields ? getFieldMatcher(options.fields) : alwaysFieldMatcher; - const calculators = fieldReducers.list(options.reducers); + const calculators = options.reducers && options.reducers.length ? fieldReducers.list(options.reducers) : []; const reducers = calculators.map(c => c.id); return (data: DataFrame[]) => { diff --git a/packages/grafana-data/src/utils/transformers/transformers.ts b/packages/grafana-data/src/utils/transformers/transformers.ts index 9776ace625a..04cee6e7634 100644 --- a/packages/grafana-data/src/utils/transformers/transformers.ts +++ b/packages/grafana-data/src/utils/transformers/transformers.ts @@ -15,9 +15,6 @@ export interface DataTransformerConfig { options: TOptions; } -// Transformer that does nothing -export const NoopDataTransformer = (data: DataFrame[]) => data; - /** * Apply configured transformations to the input data */ @@ -49,8 +46,10 @@ export function transformDataFrame(options: DataTransformerConfig[], data: DataF // Initalize the Registry import { appendTransformer, AppendOptions } from './append'; -import { reduceTransformer, ReduceOptions } from './reduce'; +import { reduceTransformer, ReduceTransformerOptions } from './reduce'; import { filterFieldsTransformer, filterFramesTransformer } from './filter'; +import { filterFieldsByNameTransformer, FilterFieldsByNameTransformerOptions } from './filterByName'; +import { noopTransformer } from './noop'; /** * Registry of transformation options that can be driven by @@ -69,14 +68,18 @@ class TransformerRegistry extends Registry { return appendTransformer.transformer(options || appendTransformer.defaultOptions)(data)[0]; } - reduce(data: DataFrame[], options: ReduceOptions): DataFrame[] { + reduce(data: DataFrame[], options: ReduceTransformerOptions): DataFrame[] { return reduceTransformer.transformer(options)(data); } } export const dataTransformers = new TransformerRegistry(() => [ + noopTransformer, filterFieldsTransformer, + filterFieldsByNameTransformer, filterFramesTransformer, appendTransformer, reduceTransformer, ]); + +export { ReduceTransformerOptions, FilterFieldsByNameTransformerOptions }; diff --git a/packages/grafana-runtime/src/config.ts b/packages/grafana-runtime/src/config.ts index 0c46072c3ea..f329549ff6a 100644 --- a/packages/grafana-runtime/src/config.ts +++ b/packages/grafana-runtime/src/config.ts @@ -10,6 +10,9 @@ export interface BuildInfo { hasUpdate: boolean; } +interface FeatureToggles { + transformations: boolean; +} export class GrafanaBootConfig { datasources: { [str: string]: DataSourceInstanceSettings } = {}; panels: { [key: string]: PanelPluginMeta } = {}; @@ -41,6 +44,9 @@ export class GrafanaBootConfig { disableSanitizeHtml = false; theme: GrafanaTheme; pluginsToPreload: string[] = []; + featureToggles: FeatureToggles = { + transformations: false, + }; constructor(options: GrafanaBootConfig) { this.theme = options.bootData.user.lightTheme ? getTheme(GrafanaThemeType.Light) : getTheme(GrafanaThemeType.Dark); diff --git a/packages/grafana-ui/src/components/AlphaNotice/AlphaNotice.tsx b/packages/grafana-ui/src/components/AlphaNotice/AlphaNotice.tsx new file mode 100644 index 00000000000..5951c5446d8 --- /dev/null +++ b/packages/grafana-ui/src/components/AlphaNotice/AlphaNotice.tsx @@ -0,0 +1,44 @@ +import React, { FC, useContext } from 'react'; +import { css, cx } from 'emotion'; +import { PluginState, ThemeContext } from '../../index'; +import { Tooltip } from '../index'; + +interface Props { + state?: PluginState; + text?: JSX.Element; + className?: string; +} + +export const AlphaNotice: FC = ({ state, text, className }) => { + const tooltipContent = text || ( +
+
Alpha Feature
+

This feature is a work in progress and updates may include breaking changes.

+
+ ); + + const theme = useContext(ThemeContext); + + const styles = cx( + className, + css` + background: linear-gradient(to bottom, ${theme.colors.blueBase}, ${theme.colors.blueShade}); + color: ${theme.colors.gray7}; + white-space: nowrap; + border-radius: 3px; + text-shadow: none; + font-size: 13px; + padding: 4px 8px; + cursor: help; + display: inline-block; + ` + ); + + return ( + +
+ {state} +
+
+ ); +}; diff --git a/public/app/core/components/JSONFormatter/JSONFormatter.tsx b/packages/grafana-ui/src/components/JSONFormatter/JSONFormatter.tsx similarity index 79% rename from public/app/core/components/JSONFormatter/JSONFormatter.tsx rename to packages/grafana-ui/src/components/JSONFormatter/JSONFormatter.tsx index 66f17444d1f..0e405beb5a8 100644 --- a/public/app/core/components/JSONFormatter/JSONFormatter.tsx +++ b/packages/grafana-ui/src/components/JSONFormatter/JSONFormatter.tsx @@ -1,5 +1,5 @@ -import React, { PureComponent, createRef } from 'react'; -import { JsonExplorer } from 'app/core/core'; // We have made some monkey-patching of json-formatter-js so we can't switch right now +import React, { PureComponent, createRef } from 'react'; +import { JsonExplorer } from './json_explorer/json_explorer'; // We have made some monkey-patching of json-formatter-js so we can't switch right now interface Props { className?: string; @@ -31,10 +31,13 @@ export class JSONFormatter extends PureComponent { const { json, config, open, onDidRender } = this.props; const wrapperEl = this.wrapperRef.current; const formatter = new JsonExplorer(json, open, config); + // @ts-ignore const hasChildren: boolean = wrapperEl.hasChildNodes(); if (hasChildren) { + // @ts-ignore wrapperEl.replaceChild(formatter.render(), wrapperEl.lastChild); } else { + // @ts-ignore wrapperEl.appendChild(formatter.render()); } diff --git a/public/app/core/components/json_explorer/helpers.ts b/packages/grafana-ui/src/components/JSONFormatter/json_explorer/helpers.ts similarity index 100% rename from public/app/core/components/json_explorer/helpers.ts rename to packages/grafana-ui/src/components/JSONFormatter/json_explorer/helpers.ts diff --git a/public/app/core/components/json_explorer/json_explorer.ts b/packages/grafana-ui/src/components/JSONFormatter/json_explorer/json_explorer.ts similarity index 97% rename from public/app/core/components/json_explorer/json_explorer.ts rename to packages/grafana-ui/src/components/JSONFormatter/json_explorer/json_explorer.ts index f17f0f7ad7f..ccb01037df3 100644 --- a/public/app/core/components/json_explorer/json_explorer.ts +++ b/packages/grafana-ui/src/components/JSONFormatter/json_explorer/json_explorer.ts @@ -28,7 +28,6 @@ export interface JsonExplorerConfig { const _defaultConfig: JsonExplorerConfig = { animateOpen: true, animateClose: true, - theme: null, }; /** @@ -39,10 +38,10 @@ const _defaultConfig: JsonExplorerConfig = { */ export class JsonExplorer { // Hold the open state after the toggler is used - private _isOpen: boolean = null; + private _isOpen: boolean | null = null; // A reference to the element that we render to - private element: Element; + private element: Element | null = null; private skipChildren = false; @@ -366,7 +365,7 @@ export class JsonExplorer { * Animated option is used when user triggers this via a click */ appendChildren(animated = false) { - const children = this.element.querySelector(`div.${cssClass('children')}`); + const children = this.element && this.element.querySelector(`div.${cssClass('children')}`); if (!children || this.isEmpty) { return; @@ -404,7 +403,8 @@ export class JsonExplorer { * Animated option is used when user triggers this via a click */ removeChildren(animated = false) { - const childrenElement = this.element.querySelector(`div.${cssClass('children')}`) as HTMLDivElement; + const childrenElement = + this.element && (this.element.querySelector(`div.${cssClass('children')}`) as HTMLDivElement); if (animated) { let childrenRemoved = 0; diff --git a/packages/grafana-ui/src/components/PanelOptionsGroup/PanelOptionsGroup.tsx b/packages/grafana-ui/src/components/PanelOptionsGroup/PanelOptionsGroup.tsx index 8516760d6f3..fb641cd1dcb 100644 --- a/packages/grafana-ui/src/components/PanelOptionsGroup/PanelOptionsGroup.tsx +++ b/packages/grafana-ui/src/components/PanelOptionsGroup/PanelOptionsGroup.tsx @@ -2,7 +2,7 @@ import React, { FunctionComponent } from 'react'; interface Props { - title?: string; + title?: string | JSX.Element; onClose?: () => void; children: JSX.Element | JSX.Element[] | boolean; onAdd?: () => void; diff --git a/packages/grafana-ui/src/components/TransformersUI/FilterByNameTransformerEditor.tsx b/packages/grafana-ui/src/components/TransformersUI/FilterByNameTransformerEditor.tsx new file mode 100644 index 00000000000..b44c130b9b9 --- /dev/null +++ b/packages/grafana-ui/src/components/TransformersUI/FilterByNameTransformerEditor.tsx @@ -0,0 +1,163 @@ +import React, { useContext } from 'react'; +import { FilterFieldsByNameTransformerOptions, DataTransformerID, dataTransformers, KeyValue } from '@grafana/data'; +import { TransformerUIProps, TransformerUIRegistyItem } from './types'; +import { ThemeContext } from '../../themes/ThemeContext'; +import { css, cx } from 'emotion'; +import { InlineList } from '../List/InlineList'; + +interface FilterByNameTransformerEditorProps extends TransformerUIProps {} + +interface FilterByNameTransformerEditorState { + include: string; + options: FieldNameInfo[]; + selected: string[]; +} + +interface FieldNameInfo { + name: string; + count: number; +} +export class FilterByNameTransformerEditor extends React.PureComponent< + FilterByNameTransformerEditorProps, + FilterByNameTransformerEditorState +> { + constructor(props: FilterByNameTransformerEditorProps) { + super(props); + this.state = { + include: props.options.include || '', + options: [], + selected: [], + }; + } + + componentDidMount() { + this.initOptions(); + } + + private initOptions() { + const { input, options } = this.props; + const configuredOptions = options.include ? options.include.split('|') : []; + + const allNames: FieldNameInfo[] = []; + const byName: KeyValue = {}; + for (const frame of input) { + for (const field of frame.fields) { + let v = byName[field.name]; + if (!v) { + v = byName[field.name] = { + name: field.name, + count: 0, + }; + allNames.push(v); + } + v.count++; + } + } + + if (configuredOptions.length) { + const options: FieldNameInfo[] = []; + const selected: FieldNameInfo[] = []; + for (const v of allNames) { + if (configuredOptions.includes(v.name)) { + selected.push(v); + } + options.push(v); + } + + this.setState({ + options, + selected: selected.map(s => s.name), + }); + } else { + this.setState({ options: allNames, selected: [] }); + } + } + + onFieldToggle = (fieldName: string) => { + const { selected } = this.state; + if (selected.indexOf(fieldName) > -1) { + this.onChange(selected.filter(s => s !== fieldName)); + } else { + this.onChange([...selected, fieldName]); + } + }; + + onChange = (selected: string[]) => { + this.setState({ selected }); + this.props.onChange({ + ...this.props.options, + include: selected.join('|'), + }); + }; + + render() { + const { options, selected } = this.state; + return ( + <> + { + const label = `${o.name}${o.count > 1 ? ' (' + o.count + ')' : ''}`; + return ( + + { + this.onFieldToggle(o.name); + }} + label={label} + selected={selected.indexOf(o.name) > -1} + /> + + ); + }} + /> + + ); + } +} + +interface FilterPillProps { + selected: boolean; + label: string; + onClick: React.MouseEventHandler; +} +const FilterPill: React.FC = ({ label, selected, onClick }) => { + const theme = useContext(ThemeContext); + return ( +
+ {selected && ( + + )} + {label} +
+ ); +}; + +export const filterFieldsByNameTransformRegistryItem: TransformerUIRegistyItem = { + id: DataTransformerID.filterFieldsByName, + component: FilterByNameTransformerEditor, + transformer: dataTransformers.get(DataTransformerID.filterFieldsByName), + name: 'Filter by name', + description: 'UI for filter by name transformation', +}; diff --git a/packages/grafana-ui/src/components/TransformersUI/ReduceTransformerEditor.tsx b/packages/grafana-ui/src/components/TransformersUI/ReduceTransformerEditor.tsx new file mode 100644 index 00000000000..d12e9f86e46 --- /dev/null +++ b/packages/grafana-ui/src/components/TransformersUI/ReduceTransformerEditor.tsx @@ -0,0 +1,35 @@ +import React from 'react'; +import { StatsPicker } from '../StatsPicker/StatsPicker'; +import { ReduceTransformerOptions, DataTransformerID, ReducerID } from '@grafana/data'; +import { TransformerUIRegistyItem, TransformerUIProps } from './types'; +import { dataTransformers } from '@grafana/data'; + +// TODO: Minimal implementation, needs some <3 +export const ReduceTransformerEditor: React.FC> = ({ + options, + onChange, + input, +}) => { + return ( + { + onChange({ + ...options, + reducers: stats as ReducerID[], + }); + }} + /> + ); +}; + +export const reduceTransformRegistryItem: TransformerUIRegistyItem = { + id: DataTransformerID.reduce, + component: ReduceTransformerEditor, + transformer: dataTransformers.get(DataTransformerID.reduce), + name: 'Reduce', + description: 'UI for reduce transformation', +}; diff --git a/packages/grafana-ui/src/components/TransformersUI/TransformationRow.tsx b/packages/grafana-ui/src/components/TransformersUI/TransformationRow.tsx new file mode 100644 index 00000000000..a78afd9ec91 --- /dev/null +++ b/packages/grafana-ui/src/components/TransformersUI/TransformationRow.tsx @@ -0,0 +1,85 @@ +import React, { useContext, useState } from 'react'; +import { ThemeContext } from '../../themes/ThemeContext'; +import { css } from 'emotion'; +import { DataFrame } from '@grafana/data'; +import { JSONFormatter } from '../JSONFormatter/JSONFormatter'; +import { GrafanaTheme } from '../../types/theme'; + +interface TransformationRowProps { + name: string; + description: string; + editor?: JSX.Element; + onRemove: () => void; + input: DataFrame[]; +} + +const getStyles = (theme: GrafanaTheme) => ({ + title: css` + display: flex; + padding: 4px 8px 4px 8px; + position: relative; + height: 35px; + background: ${theme.colors.textFaint}; + border-radius: 4px 4px 0 0; + flex-wrap: nowrap; + justify-content: space-between; + align-items: center; + `, + name: css` + font-weight: ${theme.typography.weight.semibold}; + color: ${theme.colors.blue}; + `, + iconRow: css` + display: flex; + `, + icon: css` + background: transparent; + border: none; + box-shadow: none; + cursor: pointer; + color: ${theme.colors.textWeak}; + margin-left: ${theme.spacing.sm}; + &:hover { + color: ${theme.colors.text}; + } + `, + editor: css` + border: 2px dashed ${theme.colors.textFaint}; + border-top: none; + border-radius: 0 0 4px 4px; + padding: 8px; + `, +}); + +export const TransformationRow = ({ onRemove, editor, name, input }: TransformationRowProps) => { + const theme = useContext(ThemeContext); + const [viewDebug, setViewDebug] = useState(false); + const styles = getStyles(theme); + return ( +
+
+
{name}
+
+
setViewDebug(!viewDebug)} className={styles.icon}> + +
+
+ +
+
+
+
+ {editor} + {viewDebug && ( +
+ +
+ )} +
+
+ ); +}; diff --git a/packages/grafana-ui/src/components/TransformersUI/TransformationsEditor.tsx b/packages/grafana-ui/src/components/TransformersUI/TransformationsEditor.tsx new file mode 100644 index 00000000000..151d7df2c40 --- /dev/null +++ b/packages/grafana-ui/src/components/TransformersUI/TransformationsEditor.tsx @@ -0,0 +1,127 @@ +import { DataTransformerID, DataTransformerConfig, DataFrame, transformDataFrame } from '@grafana/data'; +import { Select } from '../Select/Select'; +import { transformersUIRegistry } from './transformers'; +import React from 'react'; +import { TransformationRow } from './TransformationRow'; +import { Button } from '../Button/Button'; +import { css } from 'emotion'; + +interface TransformationsEditorState { + updateCounter: number; +} + +interface TransformationsEditorProps { + onChange: (transformations: DataTransformerConfig[]) => void; + transformations: DataTransformerConfig[]; + getCurrentData: (applyTransformations?: boolean) => DataFrame[]; +} + +export class TransformationsEditor extends React.PureComponent { + state = { updateCounter: 0 }; + + onTransformationAdd = () => { + const { transformations, onChange } = this.props; + onChange([ + ...transformations, + { + id: DataTransformerID.noop, + options: {}, + }, + ]); + this.setState({ updateCounter: this.state.updateCounter + 1 }); + }; + + onTransformationChange = (idx: number, config: DataTransformerConfig) => { + const { transformations, onChange } = this.props; + transformations[idx] = config; + onChange(transformations); + this.setState({ updateCounter: this.state.updateCounter + 1 }); + }; + + onTransformationRemove = (idx: number) => { + const { transformations, onChange } = this.props; + transformations.splice(idx, 1); + onChange(transformations); + this.setState({ updateCounter: this.state.updateCounter + 1 }); + }; + + renderTransformationEditors = () => { + const { transformations, getCurrentData } = this.props; + const hasTransformations = transformations.length > 0; + const preTransformData = getCurrentData(false); + + if (!hasTransformations) { + return undefined; + } + + const availableTransformers = transformersUIRegistry.list().map(t => { + return { + value: t.transformer.id, + label: t.transformer.name, + }; + }); + + return ( + <> + {transformations.map((t, i) => { + let editor, input; + if (t.id === DataTransformerID.noop) { + return ( +