mirror of https://github.com/grafana/grafana
Transformation: added support for excluding/including rows based on their values. (#26884)
* Adding FilterByValue transformer skeleton * Connecting options with Editor * Improving UI and making deep copy of options on change. * Improving Transformation Editor UI * Implementing Regex filtering * Adding valueFilters.ts and creating filter registry * Connecting the test function * Correcting TypeScript errors * Using FilterInstance instead of simple Filter test function * Adding field.type as filter options * Improving UI. Adding custom placeholder depending on filter. * Implementing a few more filter types * Implementing more filters * Return original data if no filter were processed * Improving UI * Correcting TS errors * Making sure inequality transform are invalid until the filterExpression is not empty * Cleanup in the UI file * Improving UI (highlight invalid fields) * Only show filterType that are supported for the selected field * Adding tests + correction of a filter * Adding transformer test * Adding doc * Cleanup * Typing props for FilterSelectorRow component Co-authored-by: Marcus Andersson <systemvetaren@gmail.com> * Moving rendering in the JSX Co-authored-by: Marcus Andersson <systemvetaren@gmail.com> * Memoizing filterTypeOptions computation Co-authored-by: Marcus Andersson <systemvetaren@gmail.com> * Improve code compactness Co-authored-by: Marcus Andersson <systemvetaren@gmail.com> * Cleanup + solving TS errors * Updating some labels * Wrapping stuff around useMemo and useCallback * Using cloneDeep from lodash * Don't highlight field name input if null * Removing time type fields in selectable options * We want loose equality in this scenario. * Adding `onChange` to useCallback dependencies Co-authored-by: Marcus Andersson <systemvetaren@gmail.com> * Include or exclude matching any or all conditions * Correcting field name matching * Cleanup * Don't highlight the filterExpression input when its empty * Adding Range filter * Updating doc * Correcting TS error * Only showing the Match All/Match Any option if more than one condition * Two inputs for the Range filter instead of one * Improving invalid highlight for Range filter type * Cleanup * Improving labels in UI * Using ButtonSelect to improve UI * editor UI updates. * Updating tests * Adding component for Regex * Improve TS typing * Adding components for the other filter types. * Cleanup * Correct error * Updating valueFilter.test.ts * Updating filterByValue.test.ts * Reverting and removing Range filter * Update docs/sources/panels/transformations.md * starting to implement poc. * added a small poc. * wip * added tests. * added structure for dynamic value matcher editors. * added more support. * added some more value matchers. * removed unused value filters. * added some more matchers. * adding more matchers. * added a range matcher. * fixing some tests. * fixing tests. * remove unused dep. * making the matching a bit more performant. * UX improvements and alignment fixes * fixed delete button. * fixed some spacing in the UI. * added docs for matchers. * adding docs and exposing value matcher types. * will store dateTime as string. * updated docs according to feedback. * moved filter by value in transformation list. * Improved description. * added regex value filter. * added support for regex. * fixing failing tests. Co-authored-by: Marcus Andersson <systemvetaren@gmail.com> Co-authored-by: Marcus Andersson <marcus.andersson@grafana.com> Co-authored-by: Torkel Ödegaard <torkel@grafana.com>pull/29407/head^2
parent
f55818ca70
commit
754aca25c5
@ -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(); |
||||
}); |
||||
}); |
||||
@ -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<BasicValueMatcherOptions> = { |
||||
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<BasicValueMatcherOptions> = { |
||||
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]; |
||||
@ -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(); |
||||
}); |
||||
}); |
||||
@ -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<ValueMatcherOptions> = { |
||||
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<ValueMatcherOptions> = { |
||||
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]; |
||||
@ -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(); |
||||
}); |
||||
}); |
||||
@ -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<BasicValueMatcherOptions<number>> = { |
||||
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<BasicValueMatcherOptions<number>> = { |
||||
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<BasicValueMatcherOptions<number>> = { |
||||
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<BasicValueMatcherOptions<number>> = { |
||||
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, |
||||
]; |
||||
@ -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(); |
||||
}); |
||||
}); |
||||
@ -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<RangeValueMatcherOptions<number>> = { |
||||
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]; |
||||
@ -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(); |
||||
}); |
||||
}); |
||||
}); |
||||
@ -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<BasicValueMatcherOptions<string>> = { |
||||
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]; |
||||
@ -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<T = any> 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<T = any> extends ValueMatcherOptions { |
||||
from: T; |
||||
to: T; |
||||
} |
||||
@ -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<BasicValueMatcherOptions<number>> = { |
||||
id: ValueMatcherID.lower, |
||||
options: { value: 6 }, |
||||
}; |
||||
|
||||
const cfg: DataTransformerConfig<FilterByValueTransformerOptions> = { |
||||
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<BasicValueMatcherOptions<number>> = { |
||||
id: ValueMatcherID.lowerOrEqual, |
||||
options: { value: 5 }, |
||||
}; |
||||
|
||||
const cfg: DataTransformerConfig<FilterByValueTransformerOptions> = { |
||||
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<BasicValueMatcherOptions<number>> = { |
||||
id: ValueMatcherID.lowerOrEqual, |
||||
options: { value: 4 }, |
||||
}; |
||||
|
||||
const equal: MatcherConfig<BasicValueMatcherOptions<number>> = { |
||||
id: ValueMatcherID.equal, |
||||
options: { value: 7 }, |
||||
}; |
||||
|
||||
const cfg: DataTransformerConfig<FilterByValueTransformerOptions> = { |
||||
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<BasicValueMatcherOptions<number>> = { |
||||
id: ValueMatcherID.greaterOrEqual, |
||||
options: { value: 4 }, |
||||
}; |
||||
|
||||
const lowerOrEqual: MatcherConfig<BasicValueMatcherOptions<number>> = { |
||||
id: ValueMatcherID.lowerOrEqual, |
||||
options: { value: 5 }, |
||||
}; |
||||
|
||||
const cfg: DataTransformerConfig<FilterByValueTransformerOptions> = { |
||||
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: {}, |
||||
}, |
||||
]); |
||||
}); |
||||
}); |
||||
}); |
||||
@ -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<FilterByValueTransformerOptions> = { |
||||
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<number>(); |
||||
|
||||
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<string, number> |
||||
): 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<string, number> => { |
||||
return frame.fields.reduce((all: Record<string, number>, field, fieldIndex) => { |
||||
const fieldName = getFieldDisplayName(field, frame, data); |
||||
all[fieldName] = fieldIndex; |
||||
return all; |
||||
}, {}); |
||||
}; |
||||
@ -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<SelectableValue<string>>; |
||||
fieldByDisplayName: Record<string, Field>; |
||||
} |
||||
|
||||
export const FilterByValueFilterEditor: React.FC<Props> = 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<string>) => { |
||||
if (!selectable?.value) { |
||||
return; |
||||
} |
||||
onChange({ |
||||
...filter, |
||||
fieldName: selectable.value, |
||||
}); |
||||
}, |
||||
[onChange, filter] |
||||
); |
||||
|
||||
const onChangeMatcher = useCallback( |
||||
(selectable?: SelectableValue<string>) => { |
||||
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 ( |
||||
<div className="gf-form-inline"> |
||||
<div className="gf-form gf-form-spacing"> |
||||
<div className="gf-form-label width-7">Field</div> |
||||
<Select |
||||
className="min-width-15 max-width-24" |
||||
placeholder="Field Name" |
||||
options={fieldsAsOptions} |
||||
value={filter.fieldName} |
||||
onChange={onChangeField} |
||||
menuPlacement="bottom" |
||||
/> |
||||
</div> |
||||
<div className="gf-form gf-form-spacing"> |
||||
<div className="gf-form-label">Match</div> |
||||
<Select |
||||
className="width-12" |
||||
placeholder="Select test" |
||||
options={matcherOptions} |
||||
value={matcherId} |
||||
onChange={onChangeMatcher} |
||||
menuPlacement="bottom" |
||||
/> |
||||
</div> |
||||
<div className="gf-form gf-form--grow gf-form-spacing"> |
||||
<div className="gf-form-label">Value</div> |
||||
<editor.component field={field} options={filter.config.options ?? {}} onChange={onChangeMatcherOptions} /> |
||||
</div> |
||||
<div className="gf-form"> |
||||
<Button icon="times" onClick={onDelete} variant="secondary" /> |
||||
</div> |
||||
</div> |
||||
); |
||||
}; |
||||
|
||||
const getMatcherOptions = (field: Field): Array<SelectableValue<string>> => { |
||||
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<SelectableValue<string>> |
||||
): 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<SelectableValue<string>> |
||||
): 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; |
||||
}; |
||||
@ -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<SelectableValue<FilterByValueType>> = [ |
||||
{ label: 'Include', value: FilterByValueType.include }, |
||||
{ label: 'Exclude', value: FilterByValueType.exclude }, |
||||
]; |
||||
|
||||
const filterMatch: Array<SelectableValue<FilterByValueMatch>> = [ |
||||
{ label: 'Match all', value: FilterByValueMatch.all }, |
||||
{ label: 'Match any', value: FilterByValueMatch.any }, |
||||
]; |
||||
|
||||
export const FilterByValueTransformerEditor: React.FC<TransformerUIProps<FilterByValueTransformerOptions>> = 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 ( |
||||
<div> |
||||
<div className="gf-form gf-form-inline"> |
||||
<div className="gf-form-label width-8">Filter type</div> |
||||
<div className="width-15"> |
||||
<RadioButtonGroup options={filterTypes} value={options.type} onChange={onChangeType} fullWidth /> |
||||
</div> |
||||
</div> |
||||
<div className="gf-form gf-form-inline"> |
||||
<div className="gf-form-label width-8">Conditions</div> |
||||
<div className="width-15"> |
||||
<RadioButtonGroup options={filterMatch} value={options.match} onChange={onChangeMatch} fullWidth /> |
||||
</div> |
||||
</div> |
||||
<div className={styles.conditions}> |
||||
{options.filters.map((filter, idx) => ( |
||||
<FilterByValueFilterEditor |
||||
key={idx} |
||||
filter={filter} |
||||
fieldsInfo={fieldsInfo} |
||||
onChange={filter => onChangeFilter(filter, idx)} |
||||
onDelete={() => onDeleteFilter(idx)} |
||||
/> |
||||
))} |
||||
<div className="gf-form"> |
||||
<Button icon="plus" size="sm" onClick={onAddFilter} variant="secondary"> |
||||
Add condition |
||||
</Button> |
||||
</div> |
||||
</div> |
||||
</div> |
||||
); |
||||
}; |
||||
|
||||
export const filterByValueTransformRegistryItem: TransformerRegistyItem<FilterByValueTransformerOptions> = { |
||||
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]); |
||||
}; |
||||
@ -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<T = any>( |
||||
config: ValueMatcherEditorConfig |
||||
): React.FC<ValueMatcherUIProps<BasicValueMatcherOptions<T>>> { |
||||
return ({ options, onChange, field }) => { |
||||
const { validator, converter = convertToType } = config; |
||||
const { value } = options; |
||||
const [isInvalid, setInvalid] = useState(!validator(value)); |
||||
|
||||
const onChangeValue = useCallback( |
||||
(event: React.FormEvent<HTMLInputElement>) => { |
||||
setInvalid(!validator(event.currentTarget.value)); |
||||
}, |
||||
[setInvalid, validator] |
||||
); |
||||
|
||||
const onChangeOptions = useCallback( |
||||
(event: React.FocusEvent<HTMLInputElement>) => { |
||||
if (isInvalid) { |
||||
return; |
||||
} |
||||
|
||||
const { value } = event.currentTarget; |
||||
|
||||
onChange({ |
||||
...options, |
||||
value: converter(value, field), |
||||
}); |
||||
}, |
||||
[options, onChange, isInvalid, field, converter] |
||||
); |
||||
|
||||
return ( |
||||
<Input |
||||
className="flex-grow-1" |
||||
invalid={isInvalid} |
||||
defaultValue={String(options.value)} |
||||
placeholder="Value" |
||||
onChange={onChangeValue} |
||||
onBlur={onChangeOptions} |
||||
/> |
||||
); |
||||
}; |
||||
} |
||||
|
||||
export const getBasicValueMatchersUI = (): Array<ValueMatcherUIRegistryItem<BasicValueMatcherOptions>> => { |
||||
return [ |
||||
{ |
||||
name: 'Is greater', |
||||
id: ValueMatcherID.greater, |
||||
component: basicMatcherEditor<number>({ |
||||
validator: value => !isNaN(value), |
||||
}), |
||||
}, |
||||
{ |
||||
name: 'Is greater or equal', |
||||
id: ValueMatcherID.greaterOrEqual, |
||||
component: basicMatcherEditor<number>({ |
||||
validator: value => !isNaN(value), |
||||
}), |
||||
}, |
||||
{ |
||||
name: 'Is lower', |
||||
id: ValueMatcherID.lower, |
||||
component: basicMatcherEditor<number>({ |
||||
validator: value => !isNaN(value), |
||||
}), |
||||
}, |
||||
{ |
||||
name: 'Is lower or equal', |
||||
id: ValueMatcherID.lowerOrEqual, |
||||
component: basicMatcherEditor<number>({ |
||||
validator: value => !isNaN(value), |
||||
}), |
||||
}, |
||||
{ |
||||
name: 'Is equal', |
||||
id: ValueMatcherID.equal, |
||||
component: basicMatcherEditor<any>({ |
||||
validator: () => true, |
||||
}), |
||||
}, |
||||
{ |
||||
name: 'Is not equal', |
||||
id: ValueMatcherID.notEqual, |
||||
component: basicMatcherEditor<any>({ |
||||
validator: () => true, |
||||
}), |
||||
}, |
||||
{ |
||||
name: 'Regex', |
||||
id: ValueMatcherID.regex, |
||||
component: basicMatcherEditor<string>({ |
||||
validator: () => true, |
||||
converter: (value: any) => String(value), |
||||
}), |
||||
}, |
||||
]; |
||||
}; |
||||
@ -0,0 +1,22 @@ |
||||
import { ValueMatcherID } from '@grafana/data'; |
||||
import React from 'react'; |
||||
import { ValueMatcherUIProps, ValueMatcherUIRegistryItem } from './types'; |
||||
|
||||
export const NoopMatcherEditor: React.FC<ValueMatcherUIProps<any>> = () => { |
||||
return null; |
||||
}; |
||||
|
||||
export const getNoopValueMatchersUI = (): Array<ValueMatcherUIRegistryItem<any>> => { |
||||
return [ |
||||
{ |
||||
name: 'Is null', |
||||
id: ValueMatcherID.isNull, |
||||
component: NoopMatcherEditor, |
||||
}, |
||||
{ |
||||
name: 'Is not null', |
||||
id: ValueMatcherID.isNotNull, |
||||
component: NoopMatcherEditor, |
||||
}, |
||||
]; |
||||
}; |
||||
@ -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<T = any>( |
||||
config: ValueMatcherEditorConfig |
||||
): React.FC<ValueMatcherUIProps<RangeValueMatcherOptions<T>>> { |
||||
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<HTMLInputElement>, prop: PropNames) => { |
||||
setInvalid({ |
||||
...isInvalid, |
||||
[prop]: !validator(event.currentTarget.value), |
||||
}); |
||||
}, |
||||
[setInvalid, validator, isInvalid] |
||||
); |
||||
|
||||
const onChangeOptions = useCallback( |
||||
(event: React.FocusEvent<HTMLInputElement>, prop: PropNames) => { |
||||
if (isInvalid[prop]) { |
||||
return; |
||||
} |
||||
|
||||
const { value } = event.currentTarget; |
||||
|
||||
onChange({ |
||||
...options, |
||||
[prop]: convertToType(value, field), |
||||
}); |
||||
}, |
||||
[options, onChange, isInvalid, field] |
||||
); |
||||
|
||||
return ( |
||||
<> |
||||
<Input |
||||
className="flex-grow-1 gf-form-spacing" |
||||
invalid={isInvalid['from']} |
||||
defaultValue={String(options.from)} |
||||
placeholder="From" |
||||
onChange={event => onChangeValue(event, 'from')} |
||||
onBlur={event => onChangeOptions(event, 'from')} |
||||
/> |
||||
<div className="gf-form-label">and</div> |
||||
<Input |
||||
className="flex-grow-1" |
||||
invalid={isInvalid['to']} |
||||
defaultValue={String(options.to)} |
||||
placeholder="To" |
||||
onChange={event => onChangeValue(event, 'to')} |
||||
onBlur={event => onChangeOptions(event, 'to')} |
||||
/> |
||||
</> |
||||
); |
||||
}; |
||||
} |
||||
|
||||
export const getRangeValueMatchersUI = (): Array<ValueMatcherUIRegistryItem<RangeValueMatcherOptions>> => { |
||||
return [ |
||||
{ |
||||
name: 'Is between', |
||||
id: ValueMatcherID.between, |
||||
component: rangeMatcherEditor<number>({ |
||||
validator: value => { |
||||
return !isNaN(value); |
||||
}, |
||||
}), |
||||
}, |
||||
]; |
||||
}; |
||||
@ -0,0 +1,14 @@ |
||||
import { Field, RegistryItem } from '@grafana/data'; |
||||
export interface ValueMatcherUIRegistryItem<TOptions> extends RegistryItem { |
||||
component: React.ComponentType<ValueMatcherUIProps<TOptions>>; |
||||
} |
||||
|
||||
export interface ValueMatcherUIProps<TOptions> { |
||||
options: TOptions; |
||||
onChange: (options: TOptions) => void; |
||||
field: Field; |
||||
} |
||||
export interface ValueMatcherEditorConfig { |
||||
validator: (value: any) => boolean; |
||||
converter?: (value: any, field: Field) => any; |
||||
} |
||||
@ -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; |
||||
}; |
||||
@ -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<ValueMatcherUIRegistryItem<any>>(() => { |
||||
return [...getBasicValueMatchersUI(), ...getNoopValueMatchersUI(), ...getRangeValueMatchersUI()]; |
||||
}); |
||||
Loading…
Reference in new issue