mirror of https://github.com/grafana/grafana
FieldMatchers: Add match by value (reducer) (#64477)
Co-authored-by: Ryan McKinley <ryantxu@gmail.com>pull/64643/head
parent
75f89e67af
commit
18e3e0ca8d
@ -0,0 +1,24 @@ |
||||
import { ComparisonOperation } from '@grafana/schema'; |
||||
|
||||
import { compareValues } from './compareValues'; |
||||
|
||||
describe('compare values', () => { |
||||
it('simple comparisons', () => { |
||||
expect(compareValues(null, ComparisonOperation.EQ, null)).toEqual(true); |
||||
expect(compareValues(null, ComparisonOperation.NEQ, null)).toEqual(false); |
||||
|
||||
expect(compareValues(1, ComparisonOperation.GT, 2)).toEqual(false); |
||||
expect(compareValues(2, ComparisonOperation.GT, 1)).toEqual(true); |
||||
expect(compareValues(1, ComparisonOperation.GTE, 2)).toEqual(false); |
||||
expect(compareValues(2, ComparisonOperation.GTE, 1)).toEqual(true); |
||||
|
||||
expect(compareValues(1, ComparisonOperation.LT, 2)).toEqual(true); |
||||
expect(compareValues(2, ComparisonOperation.LT, 1)).toEqual(false); |
||||
expect(compareValues(1, ComparisonOperation.LTE, 2)).toEqual(true); |
||||
expect(compareValues(2, ComparisonOperation.LTE, 1)).toEqual(false); |
||||
|
||||
expect(compareValues(1, ComparisonOperation.EQ, 1)).toEqual(true); |
||||
expect(compareValues(1, ComparisonOperation.LTE, 1)).toEqual(true); |
||||
expect(compareValues(1, ComparisonOperation.GTE, 1)).toEqual(true); |
||||
}); |
||||
}); |
||||
@ -0,0 +1,42 @@ |
||||
import { ComparisonOperation } from '@grafana/schema'; |
||||
|
||||
/** |
||||
* Compare two values |
||||
* |
||||
* @internal -- not yet exported in `@grafana/data` |
||||
*/ |
||||
export function compareValues( |
||||
left: string | number | boolean | null | undefined, |
||||
op: ComparisonOperation, |
||||
right: string | number | boolean | null | undefined |
||||
) { |
||||
// Normalize null|undefined values
|
||||
if (left == null || right == null) { |
||||
if (left == null) { |
||||
left = 'null'; |
||||
} |
||||
if (right == null) { |
||||
right = 'null'; |
||||
} |
||||
if (op === ComparisonOperation.GTE || op === ComparisonOperation.LTE) { |
||||
op = ComparisonOperation.EQ; // check for equality
|
||||
} |
||||
} |
||||
|
||||
switch (op) { |
||||
case ComparisonOperation.EQ: |
||||
return `${left}` === `${right}`; |
||||
case ComparisonOperation.NEQ: |
||||
return `${left}` !== `${right}`; |
||||
case ComparisonOperation.GT: |
||||
return left > right; |
||||
case ComparisonOperation.GTE: |
||||
return left >= right; |
||||
case ComparisonOperation.LT: |
||||
return left < right; |
||||
case ComparisonOperation.LTE: |
||||
return left <= right; |
||||
default: |
||||
return false; |
||||
} |
||||
} |
||||
@ -0,0 +1,60 @@ |
||||
import { ComparisonOperation } from '@grafana/schema'; |
||||
|
||||
import { toDataFrame } from '../../dataframe/processDataFrame'; |
||||
import { FieldMatcher } from '../../types'; |
||||
import { DataFrame, FieldType } from '../../types/dataFrame'; |
||||
import { ReducerID } from '../fieldReducer'; |
||||
|
||||
import { fieldValueMatcherInfo } from './fieldValueMatcher'; |
||||
|
||||
function getMatchingFieldNames(matcher: FieldMatcher, frame: DataFrame): string[] { |
||||
return frame.fields.filter((f) => matcher(f, frame, [])).map((f) => f.name); |
||||
} |
||||
|
||||
describe('Field Value Matcher', () => { |
||||
const testFrame = toDataFrame({ |
||||
fields: [ |
||||
{ name: '01', type: FieldType.number, values: [0, 1] }, |
||||
{ name: '02', type: FieldType.number, values: [0, 2] }, |
||||
{ name: '03', type: FieldType.number, values: [0, 3] }, |
||||
{ name: 'null', type: FieldType.number, values: [null, null] }, |
||||
], |
||||
}); |
||||
|
||||
it('match nulls', () => { |
||||
expect( |
||||
getMatchingFieldNames( |
||||
fieldValueMatcherInfo.get({ |
||||
reducer: ReducerID.allIsNull, |
||||
}), |
||||
testFrame |
||||
) |
||||
).toEqual(['null']); |
||||
}); |
||||
|
||||
it('match equals', () => { |
||||
expect( |
||||
getMatchingFieldNames( |
||||
fieldValueMatcherInfo.get({ |
||||
reducer: ReducerID.lastNotNull, |
||||
op: ComparisonOperation.EQ, |
||||
value: 1, |
||||
}), |
||||
testFrame |
||||
) |
||||
).toEqual(['01']); |
||||
}); |
||||
|
||||
it('match equals', () => { |
||||
expect( |
||||
getMatchingFieldNames( |
||||
fieldValueMatcherInfo.get({ |
||||
reducer: ReducerID.lastNotNull, |
||||
op: ComparisonOperation.GTE, |
||||
value: 2, |
||||
}), |
||||
testFrame |
||||
) |
||||
).toEqual(['02', '03']); |
||||
}); |
||||
}); |
||||
@ -0,0 +1,58 @@ |
||||
import { ComparisonOperation } from '@grafana/schema'; |
||||
|
||||
import { Field, DataFrame } from '../../types/dataFrame'; |
||||
import { FieldMatcherInfo } from '../../types/transformations'; |
||||
import { reduceField, ReducerID } from '../fieldReducer'; |
||||
|
||||
import { compareValues } from './compareValues'; |
||||
import { FieldMatcherID } from './ids'; |
||||
|
||||
export interface FieldValueMatcherConfig { |
||||
reducer: ReducerID; |
||||
op?: ComparisonOperation; |
||||
value?: number; // or string?
|
||||
} |
||||
|
||||
// This should move to a utility function on the reducer registry
|
||||
function isBooleanReducer(r: ReducerID) { |
||||
return r === ReducerID.allIsNull || r === ReducerID.allIsZero; |
||||
} |
||||
|
||||
export const fieldValueMatcherInfo: FieldMatcherInfo<FieldValueMatcherConfig> = { |
||||
id: FieldMatcherID.byValue, |
||||
name: 'By value (reducer)', |
||||
description: 'Reduce a field to a single value and test for inclusion', |
||||
|
||||
// This is added to overrides by default
|
||||
defaultOptions: { |
||||
reducer: ReducerID.allIsZero, |
||||
op: ComparisonOperation.GTE, |
||||
value: 0, |
||||
}, |
||||
|
||||
get: (props) => { |
||||
if (!props || !props.reducer) { |
||||
return () => false; |
||||
} |
||||
let { reducer, op, value } = props; |
||||
const isBoolean = isBooleanReducer(reducer); |
||||
if (!op) { |
||||
op = ComparisonOperation.EQ; |
||||
} |
||||
return (field: Field, frame: DataFrame, allFrames: DataFrame[]) => { |
||||
const left = reduceField({ |
||||
field, |
||||
reducers: [reducer], |
||||
})[reducer]; |
||||
|
||||
if (isBoolean) { |
||||
return Boolean(left); // boolean
|
||||
} |
||||
return compareValues(left, op!, value); |
||||
}; |
||||
}, |
||||
|
||||
getOptionsDisplayText: (props) => { |
||||
return `By value (${props.reducer})`; |
||||
}, |
||||
}; |
||||
@ -0,0 +1,110 @@ |
||||
import { css } from '@emotion/css'; |
||||
import React, { useMemo, useCallback } from 'react'; |
||||
|
||||
import { |
||||
FieldMatcherID, |
||||
fieldMatchers, |
||||
FieldValueMatcherConfig, |
||||
fieldReducers, |
||||
ReducerID, |
||||
SelectableValue, |
||||
GrafanaTheme2, |
||||
} from '@grafana/data'; |
||||
import { ComparisonOperation } from '@grafana/schema'; |
||||
|
||||
import { useStyles2 } from '../../themes'; |
||||
import { Input } from '../Input/Input'; |
||||
import { Select } from '../Select/Select'; |
||||
|
||||
import { MatcherUIProps, FieldMatcherUIRegistryItem } from './types'; |
||||
|
||||
type Props = MatcherUIProps<FieldValueMatcherConfig>; |
||||
|
||||
export const comparisonOperationOptions = [ |
||||
{ label: '==', value: ComparisonOperation.EQ }, |
||||
{ label: '!=', value: ComparisonOperation.NEQ }, |
||||
{ label: '>', value: ComparisonOperation.GT }, |
||||
{ label: '>=', value: ComparisonOperation.GTE }, |
||||
{ label: '<', value: ComparisonOperation.LT }, |
||||
{ label: '<=', value: ComparisonOperation.LTE }, |
||||
]; |
||||
|
||||
// This should move to a utility function on the reducer registry
|
||||
function isBooleanReducer(r: ReducerID) { |
||||
return r === ReducerID.allIsNull || r === ReducerID.allIsZero; |
||||
} |
||||
|
||||
export const FieldValueMatcherEditor = ({ options, onChange }: Props) => { |
||||
const styles = useStyles2(getStyles); |
||||
const reducer = useMemo(() => fieldReducers.selectOptions([options?.reducer]), [options?.reducer]); |
||||
|
||||
const onSetReducer = useCallback( |
||||
(selection: SelectableValue<string>) => { |
||||
return onChange({ ...options, reducer: selection.value! as ReducerID }); |
||||
}, |
||||
[options, onChange] |
||||
); |
||||
|
||||
const onChangeOp = useCallback( |
||||
(v: SelectableValue<ComparisonOperation>) => { |
||||
return onChange({ ...options, op: v.value! }); |
||||
}, |
||||
[options, onChange] |
||||
); |
||||
|
||||
const onChangeValue = useCallback( |
||||
(e: React.FormEvent<HTMLInputElement>) => { |
||||
const value = e.currentTarget.valueAsNumber; |
||||
return onChange({ ...options, value }); |
||||
}, |
||||
[options, onChange] |
||||
); |
||||
|
||||
const opts = options ?? {}; |
||||
const isBool = isBooleanReducer(options.reducer); |
||||
|
||||
return ( |
||||
<div className={styles.spot}> |
||||
<Select |
||||
value={reducer.current} |
||||
options={reducer.options} |
||||
onChange={onSetReducer} |
||||
placeholder="Select field reducer" |
||||
/> |
||||
{opts.reducer && !isBool && ( |
||||
<> |
||||
<Select |
||||
value={comparisonOperationOptions.find((v) => v.value === opts.op)} |
||||
options={comparisonOperationOptions} |
||||
onChange={onChangeOp} |
||||
aria-label={'Comparison operator'} |
||||
width={19} |
||||
/> |
||||
|
||||
<Input type="number" value={opts.value} onChange={onChangeValue} /> |
||||
</> |
||||
)} |
||||
</div> |
||||
); |
||||
}; |
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => { |
||||
return { |
||||
spot: css` |
||||
display: flex; |
||||
flex-direction: row; |
||||
align-items: center; |
||||
align-content: flex-end; |
||||
gap: 4px; |
||||
`,
|
||||
}; |
||||
}; |
||||
|
||||
export const fieldValueMatcherItem: FieldMatcherUIRegistryItem<FieldValueMatcherConfig> = { |
||||
id: FieldMatcherID.byValue, |
||||
component: FieldValueMatcherEditor, |
||||
matcher: fieldMatchers.get(FieldMatcherID.byValue), |
||||
name: 'Fields with values', |
||||
description: 'Set properties for fields with reducer condition', |
||||
optionsToLabel: (options) => `${options?.reducer} ${options?.op} ${options?.value}`, |
||||
}; |
||||
Loading…
Reference in new issue