Table Panel: Filter column values with operators or expressions (#79853)

* Select column values using comparison operators

* Select column values using expressions

* Capitalize operator labels

* Update docs/sources/panels-visualizations/visualizations/table/index.md

Co-authored-by: Isabel <76437239+imatwawana@users.noreply.github.com>

* Use $ instead of v to represent a variable

* Define operators as a map string of objects

* Fix typo

---------

Co-authored-by: Isabel <76437239+imatwawana@users.noreply.github.com>
This commit is contained in:
Alvaro Huarte
2024-02-14 16:36:30 +01:00
committed by GitHub
parent 7d21eb0631
commit 06b5875c3c
3 changed files with 113 additions and 8 deletions

View File

@@ -2,9 +2,9 @@ import { css, cx } from '@emotion/css';
import React, { useCallback, useMemo, useState } from 'react';
import { FixedSizeList as List } from 'react-window';
import { GrafanaTheme2, SelectableValue } from '@grafana/data';
import { GrafanaTheme2, formattedValueToString, getValueFormat, SelectableValue } from '@grafana/data';
import { Checkbox, FilterInput, Label, VerticalGroup } from '..';
import { ButtonSelect, Checkbox, FilterInput, HorizontalGroup, Label, VerticalGroup } from '..';
import { useStyles2, useTheme2 } from '../../themes';
interface Props {
@@ -12,23 +12,104 @@ interface Props {
options: SelectableValue[];
onChange: (options: SelectableValue[]) => void;
caseSensitive?: boolean;
showOperators?: boolean;
}
const ITEM_HEIGHT = 28;
const MIN_HEIGHT = ITEM_HEIGHT * 5;
export const FilterList = ({ options, values, caseSensitive, onChange }: Props) => {
const operatorSelectableValues: { [key: string]: SelectableValue<string> } = {
Contains: { label: 'Contains', value: 'Contains', description: 'Contains' },
'=': { label: '=', value: '=', description: 'Equals' },
'!=': { label: '!=', value: '!=', description: 'Not equals' },
'>': { label: '>', value: '>', description: 'Greater' },
'>=': { label: '>=', value: '>=', description: 'Greater or Equal' },
'<': { label: '<', value: '<', description: 'Less' },
'<=': { label: '<=', value: '<=', description: 'Less or Equal' },
Expression: {
label: 'Expression',
value: 'Expression',
description: 'Bool Expression (Char $ represents the column value in the expression, e.g. "$ >= 10 && $ <= 12")',
},
};
const OPERATORS = Object.values(operatorSelectableValues);
const REGEX_OPERATOR = operatorSelectableValues['Contains'];
const XPR_OPERATOR = operatorSelectableValues['Expression'];
const comparableValue = (value: string): string | number | Date | boolean => {
value = value.trim().replace(/\\/g, '');
// Does it look like a Date (Starting with pattern YYYY-MM-DD* or YYYY/MM/DD*)?
if (/^(\d{4}-\d{2}-\d{2}|\d{4}\/\d{2}\/\d{2})/.test(value)) {
const date = new Date(value);
if (!isNaN(date.getTime())) {
const fmt = getValueFormat('dateTimeAsIso');
return formattedValueToString(fmt(date.getTime()));
}
}
// Does it look like a Number?
const num = parseFloat(value);
if (!isNaN(num)) {
return num;
}
// Does it look like a Bool?
const lvalue = value.toLowerCase();
if (lvalue === 'true' || lvalue === 'false') {
return lvalue === 'true';
}
// Anything else
return value;
};
export const FilterList = ({ options, values, caseSensitive, showOperators, onChange }: Props) => {
const [operator, setOperator] = useState<SelectableValue<string>>(REGEX_OPERATOR);
const [searchFilter, setSearchFilter] = useState('');
const regex = useMemo(() => new RegExp(searchFilter, caseSensitive ? undefined : 'i'), [searchFilter, caseSensitive]);
const items = useMemo(
() =>
options.filter((option) => {
if (option.label === undefined) {
if (!showOperators || !searchFilter || operator.value === REGEX_OPERATOR.value) {
if (option.label === undefined) {
return false;
}
return regex.test(option.label);
} else if (operator.value === XPR_OPERATOR.value) {
if (option.value === undefined) {
return false;
}
try {
const xpr = searchFilter.replace(/\\/g, '');
const fnc = new Function('$', `'use strict'; return ${xpr};`);
const val = comparableValue(option.value);
return fnc(val);
} catch (_) {}
return false;
} else {
if (option.value === undefined) {
return false;
}
const value1 = comparableValue(option.value);
const value2 = comparableValue(searchFilter);
switch (operator.value) {
case '=':
return value1 === value2;
case '!=':
return value1 !== value2;
case '>':
return value1 > value2;
case '>=':
return value1 >= value2;
case '<':
return value1 < value2;
case '<=':
return value1 <= value2;
}
return false;
}
return regex.test(option.label);
}),
[options, regex]
[options, regex, showOperators, operator, searchFilter]
);
const selectedItems = useMemo(() => items.filter((item) => values.includes(item)), [items, values]);
@@ -77,7 +158,19 @@ export const FilterList = ({ options, values, caseSensitive, onChange }: Props)
return (
<VerticalGroup spacing="md">
<FilterInput placeholder="Filter values" onChange={setSearchFilter} value={searchFilter} />
{!showOperators && <FilterInput placeholder="Filter values" onChange={setSearchFilter} value={searchFilter} />}
{showOperators && (
<HorizontalGroup>
<ButtonSelect<string>
variant="canvas"
options={OPERATORS}
onChange={setOperator}
value={operator}
tooltip={operator.description}
/>
<FilterInput placeholder="Filter values" onChange={setSearchFilter} value={searchFilter} />
</HorizontalGroup>
)}
{!items.length && <Label>No values</Label>}
{items.length && (
<List

View File

@@ -67,7 +67,13 @@ export const FilterPopup = ({ column: { preFilteredRows, filterValue, setFilter
/>
</HorizontalGroup>
<div className={cx(styles.listDivider)} />
<FilterList onChange={setValues} values={values} options={options} caseSensitive={matchCase} />
<FilterList
onChange={setValues}
values={values}
options={options}
caseSensitive={matchCase}
showOperators={true}
/>
</VerticalGroup>
<HorizontalGroup spacing="lg">
<HorizontalGroup>