diff --git a/docs/sources/panels/transformations/types-options.md b/docs/sources/panels/transformations/types-options.md index a4850410067..a58152a8930 100644 --- a/docs/sources/panels/transformations/types-options.md +++ b/docs/sources/panels/transformations/types-options.md @@ -10,6 +10,7 @@ Grafana comes with the following transformations: - [Reduce](#reduce) - [Filter by name](#filter-by-name) - [Filter data by query](#filter-data-by-query) + - [Filter data by value](#filter-data-by-value) - [Organize fields](#organize-fields) - [Outer join](#join-by-field-outer-join) - [Series to rows](#series-to-rows) @@ -276,3 +277,63 @@ Here is the result after applying the Series to rows transformation. | 2020-07-07 10:31:22 | Temperature | 22 | | 2020-07-07 09:30:57 | Humidity | 33 | | 2020-07-07 09:30:05 | Temperature | 19 | + +## Filter by value + + +This transformation allows you to filter your data directly in Grafana and remove some data points from your query result. You have the option to include or exclude data that match one or more conditions you define. The conditions are applied on a selected field. + +The available conditions are: + +- **Regex**: match a regex expression +- **Is Null**: match if the value is null +- **Is Not Null**: match if the value is not null +- **Equal**: match if the value is equal to the specified value +- **Different**: match if the value is different than the specified value +- **Greater**\*: match if the value is greater than the specified value +- **Lower**\*: match if the value is lower than the specified value +- **Greater or equal**\*: match if the value is greater or equal +- **Lower or equal**\*: match if the value is lower or equal +- **Range**\*: match a range between a specified minimum and maximum, min and max included + +\* Those conditions are only available for number fields. + +Consider the following data set: + +| Time | Temperature | Altitude +|---------------------|-------------|---------- +| 2020-07-07 11:34:23 | 32 | 101 +| 2020-07-07 11:34:22 | 28 | 125 +| 2020-07-07 11:34:21 | 26 | 110 +| 2020-07-07 11:34:20 | 23 | 98 +| 2020-07-07 10:32:24 | 31 | 95 +| 2020-07-07 10:31:22 | 20 | 85 +| 2020-07-07 09:30:57 | 19 | 101 + +If you **Include** the data points that have a temperature below 30°C, the configuration will look as follows: + +- Filter Type: `Include` +- Condition: Rows where `Temperature` matches `Lower Than` `100` + +And you will get the following result, where only the temperatures below 30°C are included: + + +| Time | Temperature | Altitude +|---------------------|-------------|---------- +| 2020-07-07 11:34:22 | 28 | 125 +| 2020-07-07 11:34:21 | 26 | 110 +| 2020-07-07 11:34:20 | 23 | 98 +| 2020-07-07 10:31:22 | 20 | 85 +| 2020-07-07 09:30:57 | 19 | 101 + +You can add more than one condition to the filter. For example, you might want to include the data only if the altitude is greater than 100. To do so, add that condition to the following configuration: + +- Filter type: `Include` rows that `Match All` conditions +- Condition 1: Rows where `Temperature` matches `Lower` than `30` +- Condition 2: Rows where `Altitude` matches `Greater` than `100` + +When you have more than one condition, you can choose if you want the action (include / exclude) to be applied on rows that **Match all** conditions or **Match any** of the conditions you added. + +In the example above we chose **Match all** because we wanted to include the rows that have a temperature lower than 30 _AND_ an altitude higher than 100. If we wanted to include the rows that have a temperature lower than 30 _OR_ an altitude higher than 100 instead, then we would select **Match any**. This would include the first row in the original data, which has a temperature of 32°C (does not match the first condition) but an altitude of 101 (which matches the second condition), so it is included. + +Conditions that are invalid or incompletely configured are ignored. diff --git a/e2e/suite1/specs/panelEdit_base.spec.ts b/e2e/suite1/specs/panelEdit_base.spec.ts index 8854912f422..bb4f2a3fc6d 100644 --- a/e2e/suite1/specs/panelEdit_base.spec.ts +++ b/e2e/suite1/specs/panelEdit_base.spec.ts @@ -26,7 +26,7 @@ e2e.scenario({ expect(li.text()).equals('Query1'); // there's already a query so therefore Query + 1 }); e2e.components.QueryTab.content().should('be.visible'); - e2e.components.TransformTab.content().should('not.be.visible'); + e2e.components.TransformTab.content().should('not.exist'); e2e.components.AlertTab.content().should('not.be.visible'); // Bottom pane tabs @@ -37,7 +37,9 @@ e2e.scenario({ e2e.components.Tab.active().within((li: JQuery) => { expect(li.text()).equals('Transform0'); // there's no transform so therefore Transform + 0 }); - e2e.components.TransformTab.content().should('be.visible'); + e2e.components.Transforms.card('Merge') + .scrollIntoView() + .should('be.visible'); e2e.components.QueryTab.content().should('not.be.visible'); e2e.components.AlertTab.content().should('not.be.visible'); @@ -50,7 +52,7 @@ e2e.scenario({ }); e2e.components.AlertTab.content().should('be.visible'); e2e.components.QueryTab.content().should('not.be.visible'); - e2e.components.TransformTab.content().should('not.be.visible'); + e2e.components.TransformTab.content().should('not.exist'); e2e.components.Tab.title('Query') .should('be.visible') diff --git a/packages/grafana-data/src/index.ts b/packages/grafana-data/src/index.ts index 741f9584f22..a7eaa94faf5 100644 --- a/packages/grafana-data/src/index.ts +++ b/packages/grafana-data/src/index.ts @@ -13,4 +13,9 @@ export * from './text'; export * from './valueFormats'; export * from './field'; export * from './events'; +export { + ValueMatcherOptions, + BasicValueMatcherOptions, + RangeValueMatcherOptions, +} from './transformations/matchers/valueMatchers/types'; export { PanelPlugin } from './panel/PanelPlugin'; diff --git a/packages/grafana-data/src/transformations/matchers.ts b/packages/grafana-data/src/transformations/matchers.ts index a2ab1596c67..aa5e6c7c8b2 100644 --- a/packages/grafana-data/src/transformations/matchers.ts +++ b/packages/grafana-data/src/transformations/matchers.ts @@ -9,9 +9,16 @@ import { FrameMatcherInfo, FieldMatcher, FrameMatcher, + ValueMatcherInfo, + ValueMatcher, } from '../types/transformations'; import { Registry } from '../utils/Registry'; +import { getNullValueMatchers } from './matchers/valueMatchers/nullMatchers'; +import { getNumericValueMatchers } from './matchers/valueMatchers/numericMatchers'; +import { getEqualValueMatchers } from './matchers/valueMatchers/equalMatchers'; +import { getRangeValueMatchers } from './matchers/valueMatchers/rangeMatchers'; import { getSimpleFieldMatchers } from './matchers/simpleFieldMatcher'; +import { getRegexValueMatcher } from './matchers/valueMatchers/regexMatchers'; /** * Registry that contains all of the built in field matchers. @@ -38,6 +45,20 @@ export const frameMatchers = new Registry(() => { ]; }); +/** + * Registry that contains all of the built in value matchers. + * @public + */ +export const valueMatchers = new Registry(() => { + return [ + ...getNullValueMatchers(), + ...getNumericValueMatchers(), + ...getEqualValueMatchers(), + ...getRangeValueMatchers(), + ...getRegexValueMatcher(), + ]; +}); + /** * Resolves a field matcher from the registry for given config. * Will throw an error if matcher can not be resolved. @@ -45,6 +66,9 @@ export const frameMatchers = new Registry(() => { */ export function getFieldMatcher(config: MatcherConfig): FieldMatcher { const info = fieldMatchers.get(config.id); + if (!info) { + throw new Error('Unknown field matcher: ' + config.id); + } return info.get(config.options); } @@ -55,5 +79,21 @@ export function getFieldMatcher(config: MatcherConfig): FieldMatcher { */ export function getFrameMatchers(config: MatcherConfig): FrameMatcher { const info = frameMatchers.get(config.id); + if (!info) { + throw new Error('Unknown frame matcher: ' + config.id); + } + return info.get(config.options); +} + +/** + * Resolves a value matcher from the registry for given config. + * Will throw an error if matcher can not be resolved. + * @public + */ +export function getValueMatcher(config: MatcherConfig): ValueMatcher { + const info = valueMatchers.get(config.id); + if (!info) { + throw new Error('Unknown value matcher: ' + config.id); + } return info.get(config.options); } diff --git a/packages/grafana-data/src/transformations/matchers/ids.ts b/packages/grafana-data/src/transformations/matchers/ids.ts index cb6f0184a38..cd4c7888bd5 100644 --- a/packages/grafana-data/src/transformations/matchers/ids.ts +++ b/packages/grafana-data/src/transformations/matchers/ids.ts @@ -37,3 +37,19 @@ export enum FrameMatcherID { byIndex = 'byIndex', byLabel = 'byLabel', } + +/** + * @public + */ +export enum ValueMatcherID { + regex = 'regex', + isNull = 'isNull', + isNotNull = 'isNotNull', + greater = 'greater', + greaterOrEqual = 'greaterOrEqual', + lower = 'lower', + lowerOrEqual = 'lowerOrEqual', + equal = 'equal', + notEqual = 'notEqual', + between = 'between', +} diff --git a/packages/grafana-data/src/transformations/matchers/valueMatchers/equalMatchers.test.ts b/packages/grafana-data/src/transformations/matchers/valueMatchers/equalMatchers.test.ts new file mode 100644 index 00000000000..2aba608f15d --- /dev/null +++ b/packages/grafana-data/src/transformations/matchers/valueMatchers/equalMatchers.test.ts @@ -0,0 +1,108 @@ +import { toDataFrame } from '../../../dataframe'; +import { DataFrame } from '../../../types/dataFrame'; +import { getValueMatcher } from '../../matchers'; +import { ValueMatcherID } from '../ids'; + +describe('value equals to matcher', () => { + const data: DataFrame[] = [ + toDataFrame({ + fields: [ + { + name: 'temp', + values: [23, null, 10, 'asd', '23'], + }, + ], + }), + ]; + + const matcher = getValueMatcher({ + id: ValueMatcherID.equal, + options: { + value: 23, + }, + }); + + it('should match when option value is same', () => { + const frame = data[0]; + const field = frame.fields[0]; + const valueIndex = 0; + + expect(matcher(valueIndex, field, frame, data)).toBeTruthy(); + }); + + it('should not match when option value is different', () => { + const frame = data[0]; + const field = frame.fields[0]; + const valueIndex = 2; + + expect(matcher(valueIndex, field, frame, data)).toBeFalsy(); + }); + + it('should not match when option value is different type', () => { + const frame = data[0]; + const field = frame.fields[0]; + const valueIndex = 3; + + expect(matcher(valueIndex, field, frame, data)).toBeFalsy(); + }); + + it('should match when option value is different type but same', () => { + const frame = data[0]; + const field = frame.fields[0]; + const valueIndex = 4; + + expect(matcher(valueIndex, field, frame, data)).toBeTruthy(); + }); +}); + +describe('value not equals matcher', () => { + const data: DataFrame[] = [ + toDataFrame({ + fields: [ + { + name: 'temp', + values: [23, null, 10, 'asd', '23'], + }, + ], + }), + ]; + + const matcher = getValueMatcher({ + id: ValueMatcherID.notEqual, + options: { + value: 23, + }, + }); + + it('should not match when option value is same', () => { + const frame = data[0]; + const field = frame.fields[0]; + const valueIndex = 0; + + expect(matcher(valueIndex, field, frame, data)).toBeFalsy(); + }); + + it('should match when option value is different', () => { + const frame = data[0]; + const field = frame.fields[0]; + const valueIndex = 2; + + expect(matcher(valueIndex, field, frame, data)).toBeTruthy(); + }); + + it('should match when option value is different type', () => { + const frame = data[0]; + const field = frame.fields[0]; + const valueIndex = 3; + + expect(matcher(valueIndex, field, frame, data)).toBeTruthy(); + }); + + it('should not match when option value is different type but same', () => { + const frame = data[0]; + const field = frame.fields[0]; + const valueIndex = 4; + + expect(matcher(valueIndex, field, frame, data)).toBeFalsy(); + }); +}); diff --git a/packages/grafana-data/src/transformations/matchers/valueMatchers/equalMatchers.ts b/packages/grafana-data/src/transformations/matchers/valueMatchers/equalMatchers.ts new file mode 100644 index 00000000000..7a84cbb4e18 --- /dev/null +++ b/packages/grafana-data/src/transformations/matchers/valueMatchers/equalMatchers.ts @@ -0,0 +1,42 @@ +import { Field } from '../../../types/dataFrame'; +import { ValueMatcherInfo } from '../../../types/transformations'; +import { ValueMatcherID } from '../ids'; +import { BasicValueMatcherOptions } from './types'; + +const isEqualValueMatcher: ValueMatcherInfo = { + id: ValueMatcherID.equal, + name: 'Is equal', + description: 'Match where value for given field is equal to options value.', + get: options => { + return (valueIndex: number, field: Field) => { + const value = field.values.get(valueIndex); + // eslint-disable-next-line eqeqeq + return value == options.value; + }; + }, + getOptionsDisplayText: () => { + return `Matches all rows where field is null.`; + }, + isApplicable: () => true, + getDefaultOptions: () => ({ value: '' }), +}; + +const isNotEqualValueMatcher: ValueMatcherInfo = { + id: ValueMatcherID.notEqual, + name: 'Is not equal', + description: 'Match where value for given field is not equal to options value.', + get: options => { + return (valueIndex: number, field: Field) => { + const value = field.values.get(valueIndex); + // eslint-disable-next-line eqeqeq + return value != options.value; + }; + }, + getOptionsDisplayText: () => { + return `Matches all rows where field is not null.`; + }, + isApplicable: () => true, + getDefaultOptions: () => ({ value: '' }), +}; + +export const getEqualValueMatchers = (): ValueMatcherInfo[] => [isEqualValueMatcher, isNotEqualValueMatcher]; diff --git a/packages/grafana-data/src/transformations/matchers/valueMatchers/nullMatchers.test.ts b/packages/grafana-data/src/transformations/matchers/valueMatchers/nullMatchers.test.ts new file mode 100644 index 00000000000..91b1f06bf49 --- /dev/null +++ b/packages/grafana-data/src/transformations/matchers/valueMatchers/nullMatchers.test.ts @@ -0,0 +1,72 @@ +import { toDataFrame } from '../../../dataframe'; +import { DataFrame } from '../../../types/dataFrame'; +import { getValueMatcher } from '../../matchers'; +import { ValueMatcherID } from '../ids'; + +describe('value null matcher', () => { + const data: DataFrame[] = [ + toDataFrame({ + fields: [ + { + name: 'temp', + values: [23, null, 10], + }, + ], + }), + ]; + + const matcher = getValueMatcher({ + id: ValueMatcherID.isNull, + options: {}, + }); + + it('should match null values', () => { + const frame = data[0]; + const field = frame.fields[0]; + const valueIndex = 1; + + expect(matcher(valueIndex, field, frame, data)).toBeTruthy(); + }); + + it('should not match non-null values', () => { + const frame = data[0]; + const field = frame.fields[0]; + const valueIndex = 0; + + expect(matcher(valueIndex, field, frame, data)).toBeFalsy(); + }); +}); + +describe('value not null matcher', () => { + const data: DataFrame[] = [ + toDataFrame({ + fields: [ + { + name: 'temp', + values: [23, null, 10], + }, + ], + }), + ]; + + const matcher = getValueMatcher({ + id: ValueMatcherID.isNotNull, + options: {}, + }); + + it('should match not null values', () => { + const frame = data[0]; + const field = frame.fields[0]; + const valueIndex = 0; + + expect(matcher(valueIndex, field, frame, data)).toBeTruthy(); + }); + + it('should match non-null values', () => { + const frame = data[0]; + const field = frame.fields[0]; + const valueIndex = 1; + + expect(matcher(valueIndex, field, frame, data)).toBeFalsy(); + }); +}); diff --git a/packages/grafana-data/src/transformations/matchers/valueMatchers/nullMatchers.ts b/packages/grafana-data/src/transformations/matchers/valueMatchers/nullMatchers.ts new file mode 100644 index 00000000000..42ce8b142bf --- /dev/null +++ b/packages/grafana-data/src/transformations/matchers/valueMatchers/nullMatchers.ts @@ -0,0 +1,40 @@ +import { Field } from '../../../types/dataFrame'; +import { ValueMatcherInfo } from '../../../types/transformations'; +import { ValueMatcherID } from '../ids'; +import { ValueMatcherOptions } from './types'; + +const isNullValueMatcher: ValueMatcherInfo = { + id: ValueMatcherID.isNull, + name: 'Is null', + description: 'Match where value for given field is null.', + get: () => { + return (valueIndex: number, field: Field) => { + const value = field.values.get(valueIndex); + return value === null; + }; + }, + getOptionsDisplayText: () => { + return `Matches all rows where field is null.`; + }, + isApplicable: () => true, + getDefaultOptions: () => ({}), +}; + +const isNotNullValueMatcher: ValueMatcherInfo = { + id: ValueMatcherID.isNotNull, + name: 'Is not null', + description: 'Match where value for given field is not null.', + get: () => { + return (valueIndex: number, field: Field) => { + const value = field.values.get(valueIndex); + return value !== null; + }; + }, + getOptionsDisplayText: () => { + return `Matches all rows where field is not null.`; + }, + isApplicable: () => true, + getDefaultOptions: () => ({}), +}; + +export const getNullValueMatchers = (): ValueMatcherInfo[] => [isNullValueMatcher, isNotNullValueMatcher]; diff --git a/packages/grafana-data/src/transformations/matchers/valueMatchers/numericMatchers.test.ts b/packages/grafana-data/src/transformations/matchers/valueMatchers/numericMatchers.test.ts new file mode 100644 index 00000000000..7c20d917890 --- /dev/null +++ b/packages/grafana-data/src/transformations/matchers/valueMatchers/numericMatchers.test.ts @@ -0,0 +1,180 @@ +import { toDataFrame } from '../../../dataframe'; +import { DataFrame } from '../../../types/dataFrame'; +import { getValueMatcher } from '../../matchers'; +import { ValueMatcherID } from '../ids'; + +describe('value greater than matcher', () => { + const data: DataFrame[] = [ + toDataFrame({ + fields: [ + { + name: 'temp', + values: [23, 11, 10], + }, + ], + }), + ]; + + const matcher = getValueMatcher({ + id: ValueMatcherID.greater, + options: { + value: 11, + }, + }); + + it('should match values greater than 11', () => { + const frame = data[0]; + const field = frame.fields[0]; + const valueIndex = 0; + + expect(matcher(valueIndex, field, frame, data)).toBeTruthy(); + }); + + it('should not match values equlas to 11', () => { + const frame = data[0]; + const field = frame.fields[0]; + const valueIndex = 1; + + expect(matcher(valueIndex, field, frame, data)).toBeFalsy(); + }); + + it('should not match values lower than 11', () => { + const frame = data[0]; + const field = frame.fields[0]; + const valueIndex = 2; + + expect(matcher(valueIndex, field, frame, data)).toBeFalsy(); + }); +}); + +describe('value greater than or equal matcher', () => { + const data: DataFrame[] = [ + toDataFrame({ + fields: [ + { + name: 'temp', + values: [23, 11, 10], + }, + ], + }), + ]; + + const matcher = getValueMatcher({ + id: ValueMatcherID.greaterOrEqual, + options: { + value: 11, + }, + }); + + it('should match values greater than 11', () => { + const frame = data[0]; + const field = frame.fields[0]; + const valueIndex = 0; + + expect(matcher(valueIndex, field, frame, data)).toBeTruthy(); + }); + + it('should match values equlas to 11', () => { + const frame = data[0]; + const field = frame.fields[0]; + const valueIndex = 1; + + expect(matcher(valueIndex, field, frame, data)).toBeTruthy(); + }); + + it('should not match values lower than 11', () => { + const frame = data[0]; + const field = frame.fields[0]; + const valueIndex = 2; + + expect(matcher(valueIndex, field, frame, data)).toBeFalsy(); + }); +}); + +describe('value lower than matcher', () => { + const data: DataFrame[] = [ + toDataFrame({ + fields: [ + { + name: 'temp', + values: [23, 11, 10], + }, + ], + }), + ]; + + const matcher = getValueMatcher({ + id: ValueMatcherID.lower, + options: { + value: 11, + }, + }); + + it('should match values lower than 11', () => { + const frame = data[0]; + const field = frame.fields[0]; + const valueIndex = 2; + + expect(matcher(valueIndex, field, frame, data)).toBeTruthy(); + }); + + it('should not match values equal to 11', () => { + const frame = data[0]; + const field = frame.fields[0]; + const valueIndex = 1; + + expect(matcher(valueIndex, field, frame, data)).toBeFalsy(); + }); + + it('should not match values greater than 11', () => { + const frame = data[0]; + const field = frame.fields[0]; + const valueIndex = 0; + + expect(matcher(valueIndex, field, frame, data)).toBeFalsy(); + }); +}); + +describe('value lower than or equal matcher', () => { + const data: DataFrame[] = [ + toDataFrame({ + fields: [ + { + name: 'temp', + values: [23, 11, 10], + }, + ], + }), + ]; + + const matcher = getValueMatcher({ + id: ValueMatcherID.lowerOrEqual, + options: { + value: 11, + }, + }); + + it('should match values lower than 11', () => { + const frame = data[0]; + const field = frame.fields[0]; + const valueIndex = 2; + + expect(matcher(valueIndex, field, frame, data)).toBeTruthy(); + }); + + it('should match values equal to 11', () => { + const frame = data[0]; + const field = frame.fields[0]; + const valueIndex = 1; + + expect(matcher(valueIndex, field, frame, data)).toBeTruthy(); + }); + + it('should not match values greater than 11', () => { + const frame = data[0]; + const field = frame.fields[0]; + const valueIndex = 0; + + expect(matcher(valueIndex, field, frame, data)).toBeFalsy(); + }); +}); diff --git a/packages/grafana-data/src/transformations/matchers/valueMatchers/numericMatchers.ts b/packages/grafana-data/src/transformations/matchers/valueMatchers/numericMatchers.ts new file mode 100644 index 00000000000..0a51d67d4eb --- /dev/null +++ b/packages/grafana-data/src/transformations/matchers/valueMatchers/numericMatchers.ts @@ -0,0 +1,91 @@ +import { Field, FieldType } from '../../../types/dataFrame'; +import { ValueMatcherInfo } from '../../../types/transformations'; +import { ValueMatcherID } from '../ids'; +import { BasicValueMatcherOptions } from './types'; + +const isGreaterValueMatcher: ValueMatcherInfo> = { + id: ValueMatcherID.greater, + name: 'Is greater', + description: 'Match when field value is greater than option.', + get: options => { + return (valueIndex: number, field: Field) => { + const value = field.values.get(valueIndex); + if (isNaN(value)) { + return false; + } + return value > options.value; + }; + }, + getOptionsDisplayText: options => { + return `Matches all rows where field value is greater than: ${options.value}.`; + }, + isApplicable: field => field.type === FieldType.number, + getDefaultOptions: () => ({ value: 0 }), +}; + +const isGreaterOrEqualValueMatcher: ValueMatcherInfo> = { + id: ValueMatcherID.greaterOrEqual, + name: 'Is greater or equal', + description: 'Match when field value is lower or greater than option.', + get: options => { + return (valueIndex: number, field: Field) => { + const value = field.values.get(valueIndex); + if (isNaN(value)) { + return false; + } + return value >= options.value; + }; + }, + getOptionsDisplayText: options => { + return `Matches all rows where field value is lower or greater than: ${options.value}.`; + }, + isApplicable: field => field.type === FieldType.number, + getDefaultOptions: () => ({ value: 0 }), +}; + +const isLowerValueMatcher: ValueMatcherInfo> = { + id: ValueMatcherID.lower, + name: 'Is lower', + description: 'Match when field value is lower than option.', + get: options => { + return (valueIndex: number, field: Field) => { + const value = field.values.get(valueIndex); + if (isNaN(value)) { + return false; + } + return value < options.value; + }; + }, + getOptionsDisplayText: options => { + return `Matches all rows where field value is lower than: ${options.value}.`; + }, + isApplicable: field => field.type === FieldType.number, + getDefaultOptions: () => ({ value: 0 }), +}; + +const isLowerOrEqualValueMatcher: ValueMatcherInfo> = { + id: ValueMatcherID.lowerOrEqual, + name: 'Is lower or equal', + description: 'Match when field value is lower or equal than option.', + get: options => { + return (valueIndex: number, field: Field) => { + const value = field.values.get(valueIndex); + if (isNaN(value)) { + return false; + } + return value <= options.value; + }; + }, + getOptionsDisplayText: options => { + return `Matches all rows where field value is lower or equal than: ${options.value}.`; + }, + isApplicable: field => field.type === FieldType.number, + getDefaultOptions: () => ({ value: 0 }), +}; + +export const getNumericValueMatchers = (): ValueMatcherInfo[] => [ + isGreaterValueMatcher, + isGreaterOrEqualValueMatcher, + isLowerValueMatcher, + isLowerOrEqualValueMatcher, +]; diff --git a/packages/grafana-data/src/transformations/matchers/valueMatchers/rangeMatchers.test.ts b/packages/grafana-data/src/transformations/matchers/valueMatchers/rangeMatchers.test.ts new file mode 100644 index 00000000000..710ed507721 --- /dev/null +++ b/packages/grafana-data/src/transformations/matchers/valueMatchers/rangeMatchers.test.ts @@ -0,0 +1,49 @@ +import { toDataFrame } from '../../../dataframe'; +import { DataFrame } from '../../../types/dataFrame'; +import { getValueMatcher } from '../../matchers'; +import { ValueMatcherID } from '../ids'; + +describe('value between matcher', () => { + const data: DataFrame[] = [ + toDataFrame({ + fields: [ + { + name: 'temp', + values: [23, 11, 10, 25], + }, + ], + }), + ]; + + const matcher = getValueMatcher({ + id: ValueMatcherID.between, + options: { + from: 10, + to: 25, + }, + }); + + it('should match values greater than 10 but lower than 25', () => { + const frame = data[0]; + const field = frame.fields[0]; + const valueIndex = 0; + + expect(matcher(valueIndex, field, frame, data)).toBeTruthy(); + }); + + it('should not match values greater than 25', () => { + const frame = data[0]; + const field = frame.fields[0]; + const valueIndex = 4; + + expect(matcher(valueIndex, field, frame, data)).toBeFalsy(); + }); + + it('should not match values lower than 11', () => { + const frame = data[0]; + const field = frame.fields[0]; + const valueIndex = 2; + + expect(matcher(valueIndex, field, frame, data)).toBeFalsy(); + }); +}); diff --git a/packages/grafana-data/src/transformations/matchers/valueMatchers/rangeMatchers.ts b/packages/grafana-data/src/transformations/matchers/valueMatchers/rangeMatchers.ts new file mode 100644 index 00000000000..adf3443a717 --- /dev/null +++ b/packages/grafana-data/src/transformations/matchers/valueMatchers/rangeMatchers.ts @@ -0,0 +1,26 @@ +import { Field, FieldType } from '../../../types/dataFrame'; +import { ValueMatcherInfo } from '../../../types/transformations'; +import { ValueMatcherID } from '../ids'; +import { RangeValueMatcherOptions } from './types'; + +const isBetweenValueMatcher: ValueMatcherInfo> = { + id: ValueMatcherID.between, + name: 'Is between', + description: 'Match when field value is between given option values.', + get: options => { + return (valueIndex: number, field: Field) => { + const value = field.values.get(valueIndex); + if (isNaN(value)) { + return false; + } + return value > options.from && value < options.to; + }; + }, + getOptionsDisplayText: options => { + return `Matches all rows where field value is between ${options.from} and ${options.to}.`; + }, + isApplicable: field => field.type === FieldType.number, + getDefaultOptions: () => ({ from: 0, to: 100 }), +}; + +export const getRangeValueMatchers = (): ValueMatcherInfo[] => [isBetweenValueMatcher]; diff --git a/packages/grafana-data/src/transformations/matchers/valueMatchers/regexMatchers.test.ts b/packages/grafana-data/src/transformations/matchers/valueMatchers/regexMatchers.test.ts new file mode 100644 index 00000000000..379a6cf13a3 --- /dev/null +++ b/packages/grafana-data/src/transformations/matchers/valueMatchers/regexMatchers.test.ts @@ -0,0 +1,85 @@ +import { toDataFrame } from '../../../dataframe'; +import { DataFrame } from '../../../types/dataFrame'; +import { getValueMatcher } from '../../matchers'; +import { ValueMatcherID } from '../ids'; + +describe('regex value matcher', () => { + const data: DataFrame[] = [ + toDataFrame({ + fields: [ + { + name: 'temp', + values: ['.', 'asdf', 100, '25.5'], + }, + ], + }), + ]; + + describe('option with value .*', () => { + const matcher = getValueMatcher({ + id: ValueMatcherID.regex, + options: { + value: '.*', + }, + }); + + it('should match all values', () => { + const frame = data[0]; + const field = frame.fields[0]; + + for (let i = 0; i < field.values.length; i++) { + expect(matcher(i, field, frame, data)).toBeTruthy(); + } + }); + }); + + describe('option with value \\w+', () => { + const matcher = getValueMatcher({ + id: ValueMatcherID.regex, + options: { + value: '\\w+', + }, + }); + + it('should match wordy values', () => { + const frame = data[0]; + const field = frame.fields[0]; + const valueIndex = 1; + + expect(matcher(valueIndex, field, frame, data)).toBeTruthy(); + }); + + it('should not match non-wordy values', () => { + const frame = data[0]; + const field = frame.fields[0]; + const valueIndex = 0; + + expect(matcher(valueIndex, field, frame, data)).toBeFalsy(); + }); + }); + + describe('option with value \\d+', () => { + const matcher = getValueMatcher({ + id: ValueMatcherID.regex, + options: { + value: '\\d+', + }, + }); + + it('should match numeric values', () => { + const frame = data[0]; + const field = frame.fields[0]; + const valueIndex = 2; + + expect(matcher(valueIndex, field, frame, data)).toBeTruthy(); + }); + + it('should not match non-numeric values', () => { + const frame = data[0]; + const field = frame.fields[0]; + const valueIndex = 1; + + expect(matcher(valueIndex, field, frame, data)).toBeFalsy(); + }); + }); +}); diff --git a/packages/grafana-data/src/transformations/matchers/valueMatchers/regexMatchers.ts b/packages/grafana-data/src/transformations/matchers/valueMatchers/regexMatchers.ts new file mode 100644 index 00000000000..9872fe7c6e2 --- /dev/null +++ b/packages/grafana-data/src/transformations/matchers/valueMatchers/regexMatchers.ts @@ -0,0 +1,25 @@ +import { Field } from '../../../types/dataFrame'; +import { ValueMatcherInfo } from '../../../types/transformations'; +import { ValueMatcherID } from '../ids'; +import { BasicValueMatcherOptions } from './types'; + +const regexValueMatcher: ValueMatcherInfo> = { + id: ValueMatcherID.regex, + name: 'Regex', + description: 'Match when field value is matching regex.', + get: options => { + const regex = new RegExp(options.value); + + return (valueIndex: number, field: Field) => { + const value = field.values.get(valueIndex); + return regex.test(value); + }; + }, + getOptionsDisplayText: options => { + return `Matches all rows where field value is matching regex: ${options.value}`; + }, + isApplicable: () => true, + getDefaultOptions: () => ({ value: '.*' }), +}; + +export const getRegexValueMatcher = (): ValueMatcherInfo[] => [regexValueMatcher]; diff --git a/packages/grafana-data/src/transformations/matchers/valueMatchers/types.ts b/packages/grafana-data/src/transformations/matchers/valueMatchers/types.ts new file mode 100644 index 00000000000..f292129a375 --- /dev/null +++ b/packages/grafana-data/src/transformations/matchers/valueMatchers/types.ts @@ -0,0 +1,23 @@ +/** + * Describes a empty value matcher option. + * @public + */ +export interface ValueMatcherOptions {} + +/** + * Describes a basic value matcher option that has a single value. + * @public + */ +export interface BasicValueMatcherOptions extends ValueMatcherOptions { + value: T; +} + +/** + * Describes a range value matcher option that has a to and a from value to + * be able to match a range. + * @public + */ +export interface RangeValueMatcherOptions extends ValueMatcherOptions { + from: T; + to: T; +} diff --git a/packages/grafana-data/src/transformations/transformers.ts b/packages/grafana-data/src/transformations/transformers.ts index 5c79375e321..d8f0b6edd16 100644 --- a/packages/grafana-data/src/transformations/transformers.ts +++ b/packages/grafana-data/src/transformations/transformers.ts @@ -14,6 +14,7 @@ import { labelsToFieldsTransformer } from './transformers/labelsToFields'; import { ensureColumnsTransformer } from './transformers/ensureColumns'; import { groupByTransformer } from './transformers/groupBy'; import { mergeTransformer } from './transformers/merge'; +import { filterByValueTransformer } from './transformers/filterByValue'; export const standardTransformers = { noopTransformer, @@ -21,6 +22,7 @@ export const standardTransformers = { filterFieldsByNameTransformer, filterFramesTransformer, filterFramesByRefIdTransformer, + filterByValueTransformer, orderFieldsTransformer, organizeFieldsTransformer, reduceTransformer, diff --git a/packages/grafana-data/src/transformations/transformers/filterByValue.test.ts b/packages/grafana-data/src/transformations/transformers/filterByValue.test.ts new file mode 100644 index 00000000000..c8287e03ff7 --- /dev/null +++ b/packages/grafana-data/src/transformations/transformers/filterByValue.test.ts @@ -0,0 +1,219 @@ +import { mockTransformationsRegistry } from '../../utils/tests/mockTransformationsRegistry'; +import { DataTransformerConfig, FieldType, MatcherConfig } from '../../types'; +import { ArrayVector } from '../../vector'; +import { transformDataFrame } from '../transformDataFrame'; +import { toDataFrame } from '../../dataframe/processDataFrame'; +import { + FilterByValueMatch, + filterByValueTransformer, + FilterByValueTransformerOptions, + FilterByValueType, +} from './filterByValue'; +import { DataTransformerID } from './ids'; +import { ValueMatcherID } from '../matchers/ids'; +import { BasicValueMatcherOptions } from '../matchers/valueMatchers/types'; + +const seriesAWithSingleField = toDataFrame({ + name: 'A', + length: 7, + fields: [ + { name: 'time', type: FieldType.time, values: new ArrayVector([1000, 2000, 3000, 4000, 5000, 6000, 7000]) }, + { name: 'numbers', type: FieldType.number, values: new ArrayVector([1, 2, 3, 4, 5, 6, 7]) }, + ], +}); + +describe('FilterByValue transformer', () => { + beforeAll(() => { + mockTransformationsRegistry([filterByValueTransformer]); + }); + + it('should exclude values', async () => { + const lower: MatcherConfig> = { + id: ValueMatcherID.lower, + options: { value: 6 }, + }; + + const cfg: DataTransformerConfig = { + id: DataTransformerID.filterByValue, + options: { + type: FilterByValueType.exclude, + match: FilterByValueMatch.all, + filters: [ + { + fieldName: 'numbers', + config: lower, + }, + ], + }, + }; + + await expect(transformDataFrame([cfg], [seriesAWithSingleField])).toEmitValuesWith(received => { + const processed = received[0]; + + expect(processed.length).toEqual(1); + expect(processed[0].fields).toEqual([ + { + name: 'time', + type: FieldType.time, + values: new ArrayVector([6000, 7000]), + state: { displayName: 'time' }, + config: {}, + }, + { + name: 'numbers', + type: FieldType.number, + values: new ArrayVector([6, 7]), + state: { displayName: 'numbers' }, + config: {}, + }, + ]); + }); + }); + + it('should include values', async () => { + const lowerOrEqual: MatcherConfig> = { + id: ValueMatcherID.lowerOrEqual, + options: { value: 5 }, + }; + + const cfg: DataTransformerConfig = { + id: DataTransformerID.filterByValue, + options: { + type: FilterByValueType.include, + match: FilterByValueMatch.all, + filters: [ + { + fieldName: 'numbers', + config: lowerOrEqual, + }, + ], + }, + }; + + await expect(transformDataFrame([cfg], [seriesAWithSingleField])).toEmitValuesWith(received => { + const processed = received[0]; + + expect(processed.length).toEqual(1); + expect(processed[0].fields).toEqual([ + { + name: 'time', + type: FieldType.time, + values: new ArrayVector([1000, 2000, 3000, 4000, 5000]), + state: { displayName: 'time' }, + config: {}, + }, + { + name: 'numbers', + type: FieldType.number, + values: new ArrayVector([1, 2, 3, 4, 5]), + state: { displayName: 'numbers' }, + config: {}, + }, + ]); + }); + }); + + it('should match any condition', async () => { + const lowerOrEqual: MatcherConfig> = { + id: ValueMatcherID.lowerOrEqual, + options: { value: 4 }, + }; + + const equal: MatcherConfig> = { + id: ValueMatcherID.equal, + options: { value: 7 }, + }; + + const cfg: DataTransformerConfig = { + id: DataTransformerID.filterByValue, + options: { + type: FilterByValueType.include, + match: FilterByValueMatch.any, + filters: [ + { + fieldName: 'numbers', + config: lowerOrEqual, + }, + { + fieldName: 'numbers', + config: equal, + }, + ], + }, + }; + + await expect(transformDataFrame([cfg], [seriesAWithSingleField])).toEmitValuesWith(received => { + const processed = received[0]; + + expect(processed.length).toEqual(1); + expect(processed[0].fields).toEqual([ + { + name: 'time', + type: FieldType.time, + values: new ArrayVector([1000, 2000, 3000, 4000, 7000]), + state: { displayName: 'time' }, + config: {}, + }, + { + name: 'numbers', + type: FieldType.number, + values: new ArrayVector([1, 2, 3, 4, 7]), + state: { displayName: 'numbers' }, + config: {}, + }, + ]); + }); + }); + + it('should match all condition', async () => { + const greaterOrEqual: MatcherConfig> = { + id: ValueMatcherID.greaterOrEqual, + options: { value: 4 }, + }; + + const lowerOrEqual: MatcherConfig> = { + id: ValueMatcherID.lowerOrEqual, + options: { value: 5 }, + }; + + const cfg: DataTransformerConfig = { + id: DataTransformerID.filterByValue, + options: { + type: FilterByValueType.include, + match: FilterByValueMatch.all, + filters: [ + { + fieldName: 'numbers', + config: lowerOrEqual, + }, + { + fieldName: 'numbers', + config: greaterOrEqual, + }, + ], + }, + }; + + await expect(transformDataFrame([cfg], [seriesAWithSingleField])).toEmitValuesWith(received => { + const processed = received[0]; + + expect(processed.length).toEqual(1); + expect(processed[0].fields).toEqual([ + { + name: 'time', + type: FieldType.time, + values: new ArrayVector([4000, 5000]), + state: { displayName: 'time' }, + config: {}, + }, + { + name: 'numbers', + type: FieldType.number, + values: new ArrayVector([4, 5]), + state: { displayName: 'numbers' }, + config: {}, + }, + ]); + }); + }); +}); diff --git a/packages/grafana-data/src/transformations/transformers/filterByValue.ts b/packages/grafana-data/src/transformations/transformers/filterByValue.ts new file mode 100644 index 00000000000..3bb222c849c --- /dev/null +++ b/packages/grafana-data/src/transformations/transformers/filterByValue.ts @@ -0,0 +1,159 @@ +import { map } from 'rxjs/operators'; + +import { noopTransformer } from './noop'; +import { DataTransformerID } from './ids'; +import { DataTransformerInfo, MatcherConfig } from '../../types/transformations'; +import { DataFrame, Field } from '../../types/dataFrame'; +import { getFieldDisplayName } from '../../field/fieldState'; +import { getValueMatcher } from '../matchers'; +import { ArrayVector } from '../../vector/ArrayVector'; + +export enum FilterByValueType { + exclude = 'exclude', + include = 'include', +} + +export enum FilterByValueMatch { + all = 'all', + any = 'any', +} + +export interface FilterByValueFilter { + fieldName: string; + config: MatcherConfig; +} + +export interface FilterByValueTransformerOptions { + filters: FilterByValueFilter[]; + type: FilterByValueType; + match: FilterByValueMatch; +} + +export const filterByValueTransformer: DataTransformerInfo = { + id: DataTransformerID.filterByValue, + name: 'Filter data by values', + description: 'select a subset of results based on values', + defaultOptions: { + filters: [], + type: FilterByValueType.include, + match: FilterByValueMatch.any, + }, + + operator: options => source => { + const filters = options.filters; + const matchAll = options.match === FilterByValueMatch.all; + const include = options.type === FilterByValueType.include; + + if (!Array.isArray(filters) || filters.length === 0) { + return source.pipe(noopTransformer.operator({})); + } + + return source.pipe( + map(data => { + if (!Array.isArray(data) || data.length === 0) { + return data; + } + + const rows = new Set(); + + for (const frame of data) { + const fieldIndexByName = groupFieldIndexByName(frame, data); + const matchers = createFilterValueMatchers(filters, fieldIndexByName); + + for (let index = 0; index < frame.length; index++) { + if (rows.has(index)) { + continue; + } + + let matching = true; + + for (const matcher of matchers) { + const match = matcher(index, frame, data); + + if (!matchAll && match) { + matching = true; + break; + } + + if (matchAll && !match) { + matching = false; + break; + } + + matching = match; + } + + if (matching) { + rows.add(index); + } + } + } + + const processed: DataFrame[] = []; + const frameLength = include ? rows.size : data[0].length - rows.size; + + for (const frame of data) { + const fields: Field[] = []; + + for (const field of frame.fields) { + const buffer = []; + + for (let index = 0; index < frame.length; index++) { + if (include && rows.has(index)) { + buffer.push(field.values.get(index)); + continue; + } + + if (!include && !rows.has(index)) { + buffer.push(field.values.get(index)); + continue; + } + } + + // TODO: what parts needs to be excluded from field. + fields.push({ + ...field, + values: new ArrayVector(buffer), + config: {}, + }); + } + + processed.push({ + ...frame, + fields: fields, + length: frameLength, + }); + } + + return processed; + }) + ); + }, +}; + +const createFilterValueMatchers = ( + filters: FilterByValueFilter[], + fieldIndexByName: Record +): Array<(index: number, frame: DataFrame, data: DataFrame[]) => boolean> => { + const noop = () => false; + + return filters.map(filter => { + const fieldIndex = fieldIndexByName[filter.fieldName] ?? -1; + + if (fieldIndex < 0) { + console.warn(`[FilterByValue] Could not find index for field name: ${filter.fieldName}`); + return noop; + } + + const matcher = getValueMatcher(filter.config); + return (index, frame, data) => matcher(index, frame.fields[fieldIndex], frame, data); + }); +}; + +const groupFieldIndexByName = (frame: DataFrame, data: DataFrame[]): Record => { + return frame.fields.reduce((all: Record, field, fieldIndex) => { + const fieldName = getFieldDisplayName(field, frame, data); + all[fieldName] = fieldIndex; + return all; + }, {}); +}; diff --git a/packages/grafana-data/src/transformations/transformers/ids.ts b/packages/grafana-data/src/transformations/transformers/ids.ts index 78523501e4a..ec1a30e064a 100644 --- a/packages/grafana-data/src/transformations/transformers/ids.ts +++ b/packages/grafana-data/src/transformations/transformers/ids.ts @@ -16,6 +16,7 @@ export enum DataTransformerID { filterFieldsByName = 'filterFieldsByName', filterFrames = 'filterFrames', filterByRefId = 'filterByRefId', + filterByValue = 'filterByValue', noop = 'noop', ensureColumns = 'ensureColumns', groupBy = 'groupBy', diff --git a/packages/grafana-data/src/types/transformations.ts b/packages/grafana-data/src/types/transformations.ts index 386ae810619..eac4f448506 100644 --- a/packages/grafana-data/src/types/transformations.ts +++ b/packages/grafana-data/src/types/transformations.ts @@ -25,8 +25,14 @@ export interface DataTransformerConfig { options: TOptions; } -export type FieldMatcher = (field: Field, frame: DataFrame, allFrames: DataFrame[]) => boolean; export type FrameMatcher = (frame: DataFrame) => boolean; +export type FieldMatcher = (field: Field, frame: DataFrame, allFrames: DataFrame[]) => boolean; + +/** + * Value matcher type to describe the matcher function + * @public + */ +export type ValueMatcher = (valueIndex: number, field: Field, frame: DataFrame, allFrames: DataFrame[]) => boolean; export interface FieldMatcherInfo extends RegistryItemWithOptions { get: (options: TOptions) => FieldMatcher; @@ -36,6 +42,16 @@ export interface FrameMatcherInfo extends RegistryItemWithOption get: (options: TOptions) => FrameMatcher; } +/** + * Registry item to represent all the different valu matchers supported + * in the Grafana platform. + * @public + */ +export interface ValueMatcherInfo extends RegistryItemWithOptions { + get: (options: TOptions) => ValueMatcher; + isApplicable: (field: Field) => boolean; + getDefaultOptions: (field: Field) => TOptions; +} export interface MatcherConfig { id: string; options?: TOptions; diff --git a/packages/grafana-e2e-selectors/src/selectors/components.ts b/packages/grafana-e2e-selectors/src/selectors/components.ts index f7a827923ac..73deb45ab09 100644 --- a/packages/grafana-e2e-selectors/src/selectors/components.ts +++ b/packages/grafana-e2e-selectors/src/selectors/components.ts @@ -109,6 +109,7 @@ export const Components = { transformationEditorDebugger: (name: string) => `Transformation editor debugger ${name}`, }, Transforms: { + card: (name: string) => `New transform ${name}`, Reduce: { modeLabel: 'Transform mode label', calculationsLabel: 'Transform calculations label', diff --git a/packages/grafana-ui/src/components/Forms/RadioButtonGroup/RadioButton.tsx b/packages/grafana-ui/src/components/Forms/RadioButtonGroup/RadioButton.tsx index 5ceccd9e7ce..33acd1859a8 100644 --- a/packages/grafana-ui/src/components/Forms/RadioButtonGroup/RadioButton.tsx +++ b/packages/grafana-ui/src/components/Forms/RadioButtonGroup/RadioButton.tsx @@ -80,9 +80,8 @@ const getRadioButtonStyles = stylesFactory((theme: GrafanaTheme, size: RadioButt background: ${bg}; cursor: pointer; z-index: 1; - flex-grow: ${fullWidth ? 1 : 0}; + flex: ${fullWidth ? `1 0 0` : 0}; text-align: center; - user-select: none; &:hover { diff --git a/packages/grafana-ui/src/components/Forms/RadioButtonGroup/RadioButtonGroup.tsx b/packages/grafana-ui/src/components/Forms/RadioButtonGroup/RadioButtonGroup.tsx index d3c720c9715..aed2703e34f 100644 --- a/packages/grafana-ui/src/components/Forms/RadioButtonGroup/RadioButtonGroup.tsx +++ b/packages/grafana-ui/src/components/Forms/RadioButtonGroup/RadioButtonGroup.tsx @@ -1,5 +1,5 @@ import React, { useCallback, useRef } from 'react'; -import { css } from 'emotion'; +import { css, cx } from 'emotion'; import uniqueId from 'lodash/uniqueId'; import { SelectableValue } from '@grafana/data'; import { RadioButtonSize, RadioButton } from './RadioButton'; @@ -44,6 +44,7 @@ interface RadioButtonGroupProps { onChange?: (value?: T) => void; size?: RadioButtonSize; fullWidth?: boolean; + className?: string; } export function RadioButtonGroup({ @@ -53,6 +54,7 @@ export function RadioButtonGroup({ disabled, disabledOptions, size = 'md', + className, fullWidth = false, }: RadioButtonGroupProps) { const handleOnChange = useCallback( @@ -70,7 +72,7 @@ export function RadioButtonGroup({ const styles = getRadioButtonGroupStyles(); return ( -
+
{options.map((o, i) => { const isItemDisabled = disabledOptions && o.value && disabledOptions.includes(o.value); return ( diff --git a/public/app/core/components/TransformersUI/FilterByValueTransformer/FilterByValueFilterEditor.tsx b/public/app/core/components/TransformersUI/FilterByValueTransformer/FilterByValueFilterEditor.tsx new file mode 100644 index 00000000000..0b14657bf49 --- /dev/null +++ b/public/app/core/components/TransformersUI/FilterByValueTransformer/FilterByValueFilterEditor.tsx @@ -0,0 +1,171 @@ +import React, { useCallback } from 'react'; +import { Button, Select } from '@grafana/ui'; +import { Field, SelectableValue, valueMatchers } from '@grafana/data'; +import { FilterByValueFilter } from '@grafana/data/src/transformations/transformers/filterByValue'; +import { valueMatchersUI } from './ValueMatchers/valueMatchersUI'; + +interface Props { + onDelete: () => void; + onChange: (filter: FilterByValueFilter) => void; + filter: FilterByValueFilter; + fieldsInfo: DataFrameFieldsInfo; +} + +export interface DataFrameFieldsInfo { + fieldsAsOptions: Array>; + fieldByDisplayName: Record; +} + +export const FilterByValueFilterEditor: React.FC = props => { + const { onDelete, onChange, filter, fieldsInfo } = props; + const { fieldsAsOptions, fieldByDisplayName } = fieldsInfo; + const fieldName = getFieldName(filter, fieldsAsOptions) ?? ''; + const field = fieldByDisplayName[fieldName]; + + if (!field) { + return null; + } + + const matcherOptions = getMatcherOptions(field); + const matcherId = getSelectedMatcherId(filter, matcherOptions); + const editor = valueMatchersUI.getIfExists(matcherId); + + if (!editor || !editor.component) { + return null; + } + + const onChangeField = useCallback( + (selectable?: SelectableValue) => { + if (!selectable?.value) { + return; + } + onChange({ + ...filter, + fieldName: selectable.value, + }); + }, + [onChange, filter] + ); + + const onChangeMatcher = useCallback( + (selectable?: SelectableValue) => { + if (!selectable?.value) { + return; + } + + const id = selectable.value; + const options = valueMatchers.get(id).getDefaultOptions(field); + + onChange({ + ...filter, + config: { id, options }, + }); + }, + [onChange, filter, field] + ); + + const onChangeMatcherOptions = useCallback( + options => { + onChange({ + ...filter, + config: { + ...filter.config, + options, + }, + }); + }, + [onChange, filter] + ); + + return ( +
+
+
Field
+ +
+
+
Value
+ +
+
+
+
+ ); +}; + +const getMatcherOptions = (field: Field): Array> => { + const options = []; + + for (const matcher of valueMatchers.list()) { + if (!matcher.isApplicable(field)) { + continue; + } + + const editor = valueMatchersUI.getIfExists(matcher.id); + + if (!editor) { + continue; + } + + options.push({ + value: matcher.id, + label: matcher.name, + description: matcher.description, + }); + } + + return options; +}; + +const getSelectedMatcherId = ( + filter: FilterByValueFilter, + matcherOptions: Array> +): string | undefined => { + const matcher = matcherOptions.find(m => m.value === filter.config.id); + + if (matcher && matcher.value) { + return matcher.value; + } + + if (matcherOptions[0]?.value) { + return matcherOptions[0]?.value; + } + + return; +}; + +const getFieldName = ( + filter: FilterByValueFilter, + fieldOptions: Array> +): string | undefined => { + const fieldName = fieldOptions.find(m => m.value === filter.fieldName); + + if (fieldName && fieldName.value) { + return fieldName.value; + } + + if (fieldOptions[0]?.value) { + return fieldOptions[0]?.value; + } + + return; +}; diff --git a/public/app/core/components/TransformersUI/FilterByValueTransformer/FilterByValueTransformerEditor.tsx b/public/app/core/components/TransformersUI/FilterByValueTransformer/FilterByValueTransformerEditor.tsx new file mode 100644 index 00000000000..2c0dcf22881 --- /dev/null +++ b/public/app/core/components/TransformersUI/FilterByValueTransformer/FilterByValueTransformerEditor.tsx @@ -0,0 +1,180 @@ +import React, { useMemo, useCallback } from 'react'; +import { css } from 'emotion'; +import { + DataTransformerID, + standardTransformers, + TransformerRegistyItem, + TransformerUIProps, + getFieldDisplayName, + DataFrame, + SelectableValue, + FieldType, + ValueMatcherID, + valueMatchers, +} from '@grafana/data'; +import { Button, RadioButtonGroup, stylesFactory } from '@grafana/ui'; +import cloneDeep from 'lodash/cloneDeep'; +import { + FilterByValueFilter, + FilterByValueMatch, + FilterByValueTransformerOptions, + FilterByValueType, +} from '@grafana/data/src/transformations/transformers/filterByValue'; + +import { DataFrameFieldsInfo, FilterByValueFilterEditor } from './FilterByValueFilterEditor'; + +const filterTypes: Array> = [ + { label: 'Include', value: FilterByValueType.include }, + { label: 'Exclude', value: FilterByValueType.exclude }, +]; + +const filterMatch: Array> = [ + { label: 'Match all', value: FilterByValueMatch.all }, + { label: 'Match any', value: FilterByValueMatch.any }, +]; + +export const FilterByValueTransformerEditor: React.FC> = props => { + const { input, options, onChange } = props; + const styles = getEditorStyles(); + const fieldsInfo = useFieldsInfo(input); + + const onAddFilter = useCallback(() => { + const frame = input[0]; + const field = frame.fields.find(f => f.type !== FieldType.time); + + if (!field) { + return; + } + + const filters = cloneDeep(options.filters); + const matcher = valueMatchers.get(ValueMatcherID.greater); + + filters.push({ + fieldName: getFieldDisplayName(field, frame, input), + config: { + id: matcher.id, + options: matcher.getDefaultOptions(field), + }, + }); + onChange({ ...options, filters }); + }, [onChange, options, valueMatchers, input]); + + const onDeleteFilter = useCallback( + (index: number) => { + let filters = cloneDeep(options.filters); + filters.splice(index, 1); + onChange({ ...options, filters }); + }, + [options, onChange] + ); + + const onChangeFilter = useCallback( + (filter: FilterByValueFilter, index: number) => { + let filters = cloneDeep(options.filters); + filters[index] = filter; + onChange({ ...options, filters }); + }, + [options, onChange] + ); + + const onChangeType = useCallback( + (type?: FilterByValueType) => { + onChange({ + ...options, + type: type ?? FilterByValueType.include, + }); + }, + [options, onChange] + ); + + const onChangeMatch = useCallback( + (match?: FilterByValueMatch) => { + onChange({ + ...options, + match: match ?? FilterByValueMatch.all, + }); + }, + [options, onChange] + ); + + return ( +
+
+
Filter type
+
+ +
+
+
+
Conditions
+
+ +
+
+
+ {options.filters.map((filter, idx) => ( + onChangeFilter(filter, idx)} + onDelete={() => onDeleteFilter(idx)} + /> + ))} +
+ +
+
+
+ ); +}; + +export const filterByValueTransformRegistryItem: TransformerRegistyItem = { + id: DataTransformerID.filterByValue, + editor: FilterByValueTransformerEditor, + transformation: standardTransformers.filterByValueTransformer, + name: standardTransformers.filterByValueTransformer.name, + description: + 'Removes rows of the query results using user definied filters. This is useful if you can not filter your data in the data source.', +}; + +const getEditorStyles = stylesFactory(() => ({ + conditions: css` + padding-left: 16px; + `, +})); + +const useFieldsInfo = (data: DataFrame[]): DataFrameFieldsInfo => { + return useMemo(() => { + const meta = { + fieldsAsOptions: [], + fieldByDisplayName: {}, + }; + + if (!Array.isArray(data)) { + return meta; + } + + return data.reduce((meta: DataFrameFieldsInfo, frame) => { + return frame.fields.reduce((meta, field) => { + const fieldName = getFieldDisplayName(field, frame, data); + + if (meta.fieldByDisplayName[fieldName]) { + return meta; + } + + meta.fieldsAsOptions.push({ + label: fieldName, + value: fieldName, + type: field.type, + }); + + meta.fieldByDisplayName[fieldName] = field; + + return meta; + }, meta); + }, meta); + }, [data]); +}; diff --git a/public/app/core/components/TransformersUI/FilterByValueTransformer/ValueMatchers/BasicMatcherEditor.tsx b/public/app/core/components/TransformersUI/FilterByValueTransformer/ValueMatchers/BasicMatcherEditor.tsx new file mode 100644 index 00000000000..6ecf2135374 --- /dev/null +++ b/public/app/core/components/TransformersUI/FilterByValueTransformer/ValueMatchers/BasicMatcherEditor.tsx @@ -0,0 +1,104 @@ +import React, { useCallback, useState } from 'react'; +import { Input } from '@grafana/ui'; +import { ValueMatcherID, BasicValueMatcherOptions } from '@grafana/data'; +import { ValueMatcherEditorConfig, ValueMatcherUIProps, ValueMatcherUIRegistryItem } from './types'; +import { convertToType } from './utils'; + +export function basicMatcherEditor( + config: ValueMatcherEditorConfig +): React.FC>> { + return ({ options, onChange, field }) => { + const { validator, converter = convertToType } = config; + const { value } = options; + const [isInvalid, setInvalid] = useState(!validator(value)); + + const onChangeValue = useCallback( + (event: React.FormEvent) => { + setInvalid(!validator(event.currentTarget.value)); + }, + [setInvalid, validator] + ); + + const onChangeOptions = useCallback( + (event: React.FocusEvent) => { + if (isInvalid) { + return; + } + + const { value } = event.currentTarget; + + onChange({ + ...options, + value: converter(value, field), + }); + }, + [options, onChange, isInvalid, field, converter] + ); + + return ( + + ); + }; +} + +export const getBasicValueMatchersUI = (): Array> => { + return [ + { + name: 'Is greater', + id: ValueMatcherID.greater, + component: basicMatcherEditor({ + validator: value => !isNaN(value), + }), + }, + { + name: 'Is greater or equal', + id: ValueMatcherID.greaterOrEqual, + component: basicMatcherEditor({ + validator: value => !isNaN(value), + }), + }, + { + name: 'Is lower', + id: ValueMatcherID.lower, + component: basicMatcherEditor({ + validator: value => !isNaN(value), + }), + }, + { + name: 'Is lower or equal', + id: ValueMatcherID.lowerOrEqual, + component: basicMatcherEditor({ + validator: value => !isNaN(value), + }), + }, + { + name: 'Is equal', + id: ValueMatcherID.equal, + component: basicMatcherEditor({ + validator: () => true, + }), + }, + { + name: 'Is not equal', + id: ValueMatcherID.notEqual, + component: basicMatcherEditor({ + validator: () => true, + }), + }, + { + name: 'Regex', + id: ValueMatcherID.regex, + component: basicMatcherEditor({ + validator: () => true, + converter: (value: any) => String(value), + }), + }, + ]; +}; diff --git a/public/app/core/components/TransformersUI/FilterByValueTransformer/ValueMatchers/NoopMatcherEditor.tsx b/public/app/core/components/TransformersUI/FilterByValueTransformer/ValueMatchers/NoopMatcherEditor.tsx new file mode 100644 index 00000000000..84119754b0b --- /dev/null +++ b/public/app/core/components/TransformersUI/FilterByValueTransformer/ValueMatchers/NoopMatcherEditor.tsx @@ -0,0 +1,22 @@ +import { ValueMatcherID } from '@grafana/data'; +import React from 'react'; +import { ValueMatcherUIProps, ValueMatcherUIRegistryItem } from './types'; + +export const NoopMatcherEditor: React.FC> = () => { + return null; +}; + +export const getNoopValueMatchersUI = (): Array> => { + return [ + { + name: 'Is null', + id: ValueMatcherID.isNull, + component: NoopMatcherEditor, + }, + { + name: 'Is not null', + id: ValueMatcherID.isNotNull, + component: NoopMatcherEditor, + }, + ]; +}; diff --git a/public/app/core/components/TransformersUI/FilterByValueTransformer/ValueMatchers/RangeMatcherEditor.tsx b/public/app/core/components/TransformersUI/FilterByValueTransformer/ValueMatchers/RangeMatcherEditor.tsx new file mode 100644 index 00000000000..55bc8284a1d --- /dev/null +++ b/public/app/core/components/TransformersUI/FilterByValueTransformer/ValueMatchers/RangeMatcherEditor.tsx @@ -0,0 +1,81 @@ +import React, { useCallback, useState } from 'react'; +import { Input } from '@grafana/ui'; +import { ValueMatcherID, RangeValueMatcherOptions } from '@grafana/data'; +import { ValueMatcherEditorConfig, ValueMatcherUIProps, ValueMatcherUIRegistryItem } from './types'; +import { convertToType } from './utils'; + +type PropNames = 'from' | 'to'; + +export function rangeMatcherEditor( + config: ValueMatcherEditorConfig +): React.FC>> { + return ({ options, onChange, field }) => { + const { validator } = config; + const [isInvalid, setInvalid] = useState({ + from: !validator(options.from), + to: !validator(options.to), + }); + + const onChangeValue = useCallback( + (event: React.FormEvent, prop: PropNames) => { + setInvalid({ + ...isInvalid, + [prop]: !validator(event.currentTarget.value), + }); + }, + [setInvalid, validator, isInvalid] + ); + + const onChangeOptions = useCallback( + (event: React.FocusEvent, prop: PropNames) => { + if (isInvalid[prop]) { + return; + } + + const { value } = event.currentTarget; + + onChange({ + ...options, + [prop]: convertToType(value, field), + }); + }, + [options, onChange, isInvalid, field] + ); + + return ( + <> + onChangeValue(event, 'from')} + onBlur={event => onChangeOptions(event, 'from')} + /> +
and
+ onChangeValue(event, 'to')} + onBlur={event => onChangeOptions(event, 'to')} + /> + + ); + }; +} + +export const getRangeValueMatchersUI = (): Array> => { + return [ + { + name: 'Is between', + id: ValueMatcherID.between, + component: rangeMatcherEditor({ + validator: value => { + return !isNaN(value); + }, + }), + }, + ]; +}; diff --git a/public/app/core/components/TransformersUI/FilterByValueTransformer/ValueMatchers/types.ts b/public/app/core/components/TransformersUI/FilterByValueTransformer/ValueMatchers/types.ts new file mode 100644 index 00000000000..56a7b799a9d --- /dev/null +++ b/public/app/core/components/TransformersUI/FilterByValueTransformer/ValueMatchers/types.ts @@ -0,0 +1,14 @@ +import { Field, RegistryItem } from '@grafana/data'; +export interface ValueMatcherUIRegistryItem extends RegistryItem { + component: React.ComponentType>; +} + +export interface ValueMatcherUIProps { + options: TOptions; + onChange: (options: TOptions) => void; + field: Field; +} +export interface ValueMatcherEditorConfig { + validator: (value: any) => boolean; + converter?: (value: any, field: Field) => any; +} diff --git a/public/app/core/components/TransformersUI/FilterByValueTransformer/ValueMatchers/utils.ts b/public/app/core/components/TransformersUI/FilterByValueTransformer/ValueMatchers/utils.ts new file mode 100644 index 00000000000..f30f590a476 --- /dev/null +++ b/public/app/core/components/TransformersUI/FilterByValueTransformer/ValueMatchers/utils.ts @@ -0,0 +1,34 @@ +import { Field, FieldType } from '@grafana/data'; +import { isString, isUndefined } from 'lodash'; + +export function convertToType(value: any, field: Field): any { + switch (field.type) { + case FieldType.boolean: + if (isUndefined(value)) { + return false; + } + return convertToBool(value); + + case FieldType.number: + if (isNaN(value)) { + return 0; + } + return parseFloat(value); + + case FieldType.string: + if (!value) { + return ''; + } + return String(value); + + default: + return value; + } +} + +const convertToBool = (value: any): boolean => { + if (isString(value)) { + return !(value[0] === 'F' || value[0] === 'f' || value[0] === '0'); + } + return !!value; +}; diff --git a/public/app/core/components/TransformersUI/FilterByValueTransformer/ValueMatchers/valueMatchersUI.ts b/public/app/core/components/TransformersUI/FilterByValueTransformer/ValueMatchers/valueMatchersUI.ts new file mode 100644 index 00000000000..0517333aca6 --- /dev/null +++ b/public/app/core/components/TransformersUI/FilterByValueTransformer/ValueMatchers/valueMatchersUI.ts @@ -0,0 +1,9 @@ +import { Registry } from '@grafana/data'; +import { getBasicValueMatchersUI } from './BasicMatcherEditor'; +import { getNoopValueMatchersUI } from './NoopMatcherEditor'; +import { getRangeValueMatchersUI } from './RangeMatcherEditor'; +import { ValueMatcherUIRegistryItem } from './types'; + +export const valueMatchersUI = new Registry>(() => { + return [...getBasicValueMatchersUI(), ...getNoopValueMatchersUI(), ...getRangeValueMatchersUI()]; +}); diff --git a/public/app/core/utils/standardTransformers.ts b/public/app/core/utils/standardTransformers.ts index 63773a37ab6..25d81984164 100644 --- a/public/app/core/utils/standardTransformers.ts +++ b/public/app/core/utils/standardTransformers.ts @@ -2,6 +2,7 @@ import { TransformerRegistyItem } from '@grafana/data'; import { reduceTransformRegistryItem } from '../components/TransformersUI/ReduceTransformerEditor'; import { filterFieldsByNameTransformRegistryItem } from '../components/TransformersUI/FilterByNameTransformerEditor'; import { filterFramesByRefIdTransformRegistryItem } from '../components/TransformersUI/FilterByRefIdTransformerEditor'; +import { filterByValueTransformRegistryItem } from '../components/TransformersUI/FilterByValueTransformer/FilterByValueTransformerEditor'; import { organizeFieldsTransformRegistryItem } from '../components/TransformersUI/OrganizeFieldsTransformerEditor'; import { seriesToFieldsTransformerRegistryItem } from '../components/TransformersUI/SeriesToFieldsTransformerEditor'; import { calculateFieldTransformRegistryItem } from '../components/TransformersUI/CalculateFieldTransformerEditor'; @@ -16,6 +17,7 @@ export const getStandardTransformers = (): Array> => reduceTransformRegistryItem, filterFieldsByNameTransformRegistryItem, filterFramesByRefIdTransformRegistryItem, + filterByValueTransformRegistryItem, organizeFieldsTransformRegistryItem, seriesToFieldsTransformerRegistryItem, seriesToRowsTransformerRegistryItem,