From 18e3e0ca8d4e9ccd068e3549b230945af01b625a Mon Sep 17 00:00:00 2001 From: Leon Sorokin Date: Fri, 10 Mar 2023 17:17:29 -0600 Subject: [PATCH] FieldMatchers: Add match by value (reducer) (#64477) Co-authored-by: Ryan McKinley --- .betterer.results | 3 + .../src/transformations/matchers.ts | 4 + .../matchers/compareValues.test.ts | 24 ++++ .../transformations/matchers/compareValues.ts | 42 +++++++ .../matchers/fieldValueMatcher.test.ts | 60 ++++++++++ .../matchers/fieldValueMatcher.ts | 58 +++++++++ .../src/transformations/matchers/ids.ts | 1 + .../grafana-schema/src/common/common.gen.ts | 22 +++- .../grafana-schema/src/common/mudball.cue | 4 +- packages/grafana-schema/src/common/table.cue | 3 + .../MatchersUI/FieldValueMatcher.tsx | 110 ++++++++++++++++++ .../components/MatchersUI/fieldMatchersUI.ts | 2 + .../PanelEditor/getFieldOverrideElements.tsx | 9 +- .../panel/geomap/editor/StyleRuleEditor.tsx | 17 +-- .../geomap/layers/data/geojsonDynamic.ts | 3 +- .../panel/geomap/layers/data/geojsonLayer.ts | 3 +- public/app/plugins/panel/geomap/types.ts | 10 +- .../checkFeatureMatchesStyleRule.test.ts | 2 +- .../utils/checkFeatureMatchesStyleRule.ts | 21 +--- 19 files changed, 349 insertions(+), 49 deletions(-) create mode 100644 packages/grafana-data/src/transformations/matchers/compareValues.test.ts create mode 100644 packages/grafana-data/src/transformations/matchers/compareValues.ts create mode 100644 packages/grafana-data/src/transformations/matchers/fieldValueMatcher.test.ts create mode 100644 packages/grafana-data/src/transformations/matchers/fieldValueMatcher.ts create mode 100644 packages/grafana-ui/src/components/MatchersUI/FieldValueMatcher.tsx diff --git a/.betterer.results b/.betterer.results index e06e9ab5725..1d1a561745a 100644 --- a/.betterer.results +++ b/.betterer.results @@ -1162,6 +1162,9 @@ exports[`better eslint`] = { "packages/grafana-ui/src/components/Logs/logParser.ts:5381": [ [0, 0, 0, "Do not use any type assertions.", "0"] ], + "packages/grafana-ui/src/components/MatchersUI/FieldValueMatcher.tsx:5381": [ + [0, 0, 0, "Do not use any type assertions.", "0"] + ], "packages/grafana-ui/src/components/MatchersUI/fieldMatchersUI.ts:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"] ], diff --git a/packages/grafana-data/src/transformations/matchers.ts b/packages/grafana-data/src/transformations/matchers.ts index 603fdcbca2d..3ad60ca07cc 100644 --- a/packages/grafana-data/src/transformations/matchers.ts +++ b/packages/grafana-data/src/transformations/matchers.ts @@ -11,6 +11,7 @@ import { import { Registry } from '../utils/Registry'; import { getFieldTypeMatchers } from './matchers/fieldTypeMatcher'; +import { fieldValueMatcherInfo } from './matchers/fieldValueMatcher'; import { getFieldNameMatchers, getFrameNameMatchers } from './matchers/nameMatcher'; import { getFieldPredicateMatchers, getFramePredicateMatchers } from './matchers/predicates'; import { getRefIdMatchers } from './matchers/refIdMatcher'; @@ -21,6 +22,8 @@ import { getNumericValueMatchers } from './matchers/valueMatchers/numericMatcher import { getRangeValueMatchers } from './matchers/valueMatchers/rangeMatchers'; import { getRegexValueMatcher } from './matchers/valueMatchers/regexMatchers'; +export { type FieldValueMatcherConfig } from './matchers/fieldValueMatcher'; + /** * Registry that contains all of the built in field matchers. * @public @@ -31,6 +34,7 @@ export const fieldMatchers = new Registry(() => { ...getFieldTypeMatchers(), // by type ...getFieldNameMatchers(), // by name ...getSimpleFieldMatchers(), // first + fieldValueMatcherInfo, // reduce field (all null/zero) ]; }); diff --git a/packages/grafana-data/src/transformations/matchers/compareValues.test.ts b/packages/grafana-data/src/transformations/matchers/compareValues.test.ts new file mode 100644 index 00000000000..84f37016839 --- /dev/null +++ b/packages/grafana-data/src/transformations/matchers/compareValues.test.ts @@ -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); + }); +}); diff --git a/packages/grafana-data/src/transformations/matchers/compareValues.ts b/packages/grafana-data/src/transformations/matchers/compareValues.ts new file mode 100644 index 00000000000..9acdcc2323e --- /dev/null +++ b/packages/grafana-data/src/transformations/matchers/compareValues.ts @@ -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; + } +} diff --git a/packages/grafana-data/src/transformations/matchers/fieldValueMatcher.test.ts b/packages/grafana-data/src/transformations/matchers/fieldValueMatcher.test.ts new file mode 100644 index 00000000000..90dac69896f --- /dev/null +++ b/packages/grafana-data/src/transformations/matchers/fieldValueMatcher.test.ts @@ -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']); + }); +}); diff --git a/packages/grafana-data/src/transformations/matchers/fieldValueMatcher.ts b/packages/grafana-data/src/transformations/matchers/fieldValueMatcher.ts new file mode 100644 index 00000000000..5557414b241 --- /dev/null +++ b/packages/grafana-data/src/transformations/matchers/fieldValueMatcher.ts @@ -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 = { + 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})`; + }, +}; diff --git a/packages/grafana-data/src/transformations/matchers/ids.ts b/packages/grafana-data/src/transformations/matchers/ids.ts index f833d34db2a..f81400ec5af 100644 --- a/packages/grafana-data/src/transformations/matchers/ids.ts +++ b/packages/grafana-data/src/transformations/matchers/ids.ts @@ -24,6 +24,7 @@ export enum FieldMatcherID { byRegexp = 'byRegexp', byRegexpOrNames = 'byRegexpOrNames', byFrameRefID = 'byFrameRefID', + byValue = 'byValue', // byIndex = 'byIndex', // byLabel = 'byLabel', } diff --git a/packages/grafana-schema/src/common/common.gen.ts b/packages/grafana-schema/src/common/common.gen.ts index 04b419b58e0..ff85b3aef75 100644 --- a/packages/grafana-schema/src/common/common.gen.ts +++ b/packages/grafana-schema/src/common/common.gen.ts @@ -724,6 +724,15 @@ export interface TableColoredBackgroundCellOptions { type: TableCellDisplayMode.ColorBackground; } +/** + * Height of a table cell + */ +export enum TableCellHeight { + Lg = 'lg', + Md = 'md', + Sm = 'sm', +} + /** * Table cell options. Each cell has a display mode * and other potential options for that display. @@ -790,12 +799,15 @@ export enum LogsDedupStrategy { } /** - * Height of a table cell + * Compare two values */ -export enum TableCellHeight { - Lg = 'lg', - Md = 'md', - Sm = 'sm', +export enum ComparisonOperation { + EQ = 'eq', + GT = 'gt', + GTE = 'gte', + LT = 'lt', + LTE = 'lte', + NEQ = 'neq', } /** diff --git a/packages/grafana-schema/src/common/mudball.cue b/packages/grafana-schema/src/common/mudball.cue index 9286e5c5783..89c8db36dd5 100644 --- a/packages/grafana-schema/src/common/mudball.cue +++ b/packages/grafana-schema/src/common/mudball.cue @@ -255,5 +255,5 @@ Labels: { [string]: string } @cuetsy(kind="interface") -// Height of a table cell -TableCellHeight: "sm" | "md" | "lg" @cuetsy(kind="enum") +// Compare two values +ComparisonOperation: "eq" | "neq" | "lt" | "lte" | "gt" | "gte" @cuetsy(kind="enum",memberNames="EQ|NEQ|LT|LTE|GT|GTE") diff --git a/packages/grafana-schema/src/common/table.cue b/packages/grafana-schema/src/common/table.cue index 1522e61ed87..2f45cf0d4eb 100644 --- a/packages/grafana-schema/src/common/table.cue +++ b/packages/grafana-schema/src/common/table.cue @@ -67,6 +67,9 @@ TableColoredBackgroundCellOptions: { mode?: TableCellBackgroundDisplayMode } @cuetsy(kind="interface") +// Height of a table cell +TableCellHeight: "sm" | "md" | "lg" @cuetsy(kind="enum") + // Table cell options. Each cell has a display mode // and other potential options for that display. TableCellOptions: TableAutoCellOptions | TableSparklineCellOptions | TableBarGaugeCellOptions | TableColoredBackgroundCellOptions | TableColorTextCellOptions | TableImageCellOptions | TableJsonViewCellOptions @cuetsy(kind="type") diff --git a/packages/grafana-ui/src/components/MatchersUI/FieldValueMatcher.tsx b/packages/grafana-ui/src/components/MatchersUI/FieldValueMatcher.tsx new file mode 100644 index 00000000000..18439eec734 --- /dev/null +++ b/packages/grafana-ui/src/components/MatchersUI/FieldValueMatcher.tsx @@ -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; + +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) => { + return onChange({ ...options, reducer: selection.value! as ReducerID }); + }, + [options, onChange] + ); + + const onChangeOp = useCallback( + (v: SelectableValue) => { + return onChange({ ...options, op: v.value! }); + }, + [options, onChange] + ); + + const onChangeValue = useCallback( + (e: React.FormEvent) => { + const value = e.currentTarget.valueAsNumber; + return onChange({ ...options, value }); + }, + [options, onChange] + ); + + const opts = options ?? {}; + const isBool = isBooleanReducer(options.reducer); + + return ( +
+ v.value === opts.op)} + options={comparisonOperationOptions} + onChange={onChangeOp} + aria-label={'Comparison operator'} + width={19} + /> + + + + )} +
+ ); +}; + +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 = { + 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}`, +}; diff --git a/packages/grafana-ui/src/components/MatchersUI/fieldMatchersUI.ts b/packages/grafana-ui/src/components/MatchersUI/fieldMatchersUI.ts index 72c22e193c5..fe105151e5d 100644 --- a/packages/grafana-ui/src/components/MatchersUI/fieldMatchersUI.ts +++ b/packages/grafana-ui/src/components/MatchersUI/fieldMatchersUI.ts @@ -4,6 +4,7 @@ import { fieldNameByRegexMatcherItem } from './FieldNameByRegexMatcherEditor'; import { fieldNameMatcherItem } from './FieldNameMatcherEditor'; import { fieldNamesMatcherItem } from './FieldNamesMatcherEditor'; import { fieldTypeMatcherItem } from './FieldTypeMatcherEditor'; +import { fieldValueMatcherItem } from './FieldValueMatcher'; import { fieldsByFrameRefIdItem } from './FieldsByFrameRefIdMatcher'; import { FieldMatcherUIRegistryItem } from './types'; @@ -13,4 +14,5 @@ export const fieldMatchersUI = new Registry>(() fieldTypeMatcherItem, fieldsByFrameRefIdItem, fieldNamesMatcherItem, + fieldValueMatcherItem, ]); diff --git a/public/app/features/dashboard/components/PanelEditor/getFieldOverrideElements.tsx b/public/app/features/dashboard/components/PanelEditor/getFieldOverrideElements.tsx index 0da94a6fafd..93b40a518f5 100644 --- a/public/app/features/dashboard/components/PanelEditor/getFieldOverrideElements.tsx +++ b/public/app/features/dashboard/components/PanelEditor/getFieldOverrideElements.tsx @@ -10,6 +10,7 @@ import { DynamicConfigValue, ConfigOverrideRule, GrafanaTheme2, + fieldMatchers, } from '@grafana/data'; import { fieldMatchersUI, useStyles2, ValuePicker } from '@grafana/ui'; import { getDataLinksVariableSuggestions } from 'app/features/panel/panellinks/link_srv'; @@ -46,13 +47,19 @@ export function getFieldOverrideCategories( }; const onOverrideAdd = (value: SelectableValue) => { + const info = fieldMatchers.get(value.value!); + if (!info) { + return; + } + props.onFieldConfigsChange({ ...currentFieldConfig, overrides: [ ...currentFieldConfig.overrides, { matcher: { - id: value.value!, + id: info.id, + options: info.defaultOptions, }, properties: [], }, diff --git a/public/app/plugins/panel/geomap/editor/StyleRuleEditor.tsx b/public/app/plugins/panel/geomap/editor/StyleRuleEditor.tsx index 1f4294db808..d395569d2b7 100644 --- a/public/app/plugins/panel/geomap/editor/StyleRuleEditor.tsx +++ b/public/app/plugins/panel/geomap/editor/StyleRuleEditor.tsx @@ -5,12 +5,14 @@ import { useObservable } from 'react-use'; import { Observable } from 'rxjs'; import { GrafanaTheme2, SelectableValue, StandardEditorProps, StandardEditorsRegistryItem } from '@grafana/data'; +import { ComparisonOperation } from '@grafana/schema'; import { Button, InlineField, InlineFieldRow, Select, useStyles2 } from '@grafana/ui'; +import { comparisonOperationOptions } from '@grafana/ui/src/components/MatchersUI/FieldValueMatcher'; import { NumberInput } from 'app/core/components/OptionsUI/NumberInput'; import { DEFAULT_STYLE_RULE } from '../layers/data/geojsonLayer'; import { defaultStyleConfig, StyleConfig } from '../style/types'; -import { ComparisonOperation, FeatureStyleConfig } from '../types'; +import { FeatureStyleConfig } from '../types'; import { getUniqueFeatureValues, LayerContentInfo } from '../utils/getFeatures'; import { getSelectionInfo } from '../utils/selection'; @@ -21,15 +23,6 @@ export interface StyleRuleEditorSettings { layerInfo: Observable; } -const comparators = [ - { label: '==', value: ComparisonOperation.EQ }, - { label: '!=', value: ComparisonOperation.NEQ }, - { label: '>', value: ComparisonOperation.GT }, - { label: '>=', value: ComparisonOperation.GTE }, - { label: '<', value: ComparisonOperation.LT }, - { label: '<=', value: ComparisonOperation.LTE }, -]; - type Props = StandardEditorProps; export const StyleRuleEditor = ({ value, onChange, item, context }: Props) => { @@ -148,8 +141,8 @@ export const StyleRuleEditor = ({ value, onChange, item, context }: Props) => {