TableNG: Filter ui/ux improvements (#102985)

* feat(table-ng): filter ui/ux improvements
This commit is contained in:
Ihor Yeromin
2025-03-27 18:27:58 +01:00
committed by GitHub
parent 4d61022c8c
commit a6edd1765d
6 changed files with 78 additions and 96 deletions
-4
View File
@@ -671,10 +671,6 @@ exports[`better eslint`] = {
[0, 0, 0, "Unexpected any. Specify a different type.", "1"],
[0, 0, 0, "Unexpected any. Specify a different type.", "2"]
],
"packages/grafana-ui/src/components/Table/TableNG/Filter/FilterList.tsx:5381": [
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "0"],
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "1"]
],
"packages/grafana-ui/src/components/Table/TableNG/Filter/FilterPopup.tsx:5381": [
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "0"],
[0, 0, 0, "Unexpected any. Specify a different type.", "1"],
@@ -1,44 +1,28 @@
import { css, cx } from '@emotion/css';
import { css } from '@emotion/css';
import { useCallback, useMemo } from 'react';
import * as React from 'react';
import { FixedSizeList as List, ListChildComponentProps } from 'react-window';
import { GrafanaTheme2, formattedValueToString, getValueFormat, SelectableValue } from '@grafana/data';
import { ButtonSelect, Checkbox, FilterInput, Label, Stack } from '../../..';
import { Checkbox, Label, Stack } from '../../..';
import { useStyles2, useTheme2 } from '../../../../themes';
import { Trans } from '../../../../utils/i18n';
import { operatorSelectableValues } from './FilterPopup';
interface Props {
values: SelectableValue[];
options: SelectableValue[];
onChange: (options: SelectableValue[]) => void;
caseSensitive?: boolean;
showOperators?: boolean;
searchFilter: string;
setSearchFilter: (value: string) => void;
operator: SelectableValue<string>;
setOperator: (item: SelectableValue<string>) => void;
}
const ITEM_HEIGHT = 28;
const MIN_HEIGHT = ITEM_HEIGHT * 5;
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);
export const REGEX_OPERATOR = operatorSelectableValues['Contains'];
const XPR_OPERATOR = operatorSelectableValues['Expression'];
@@ -67,22 +51,12 @@ const comparableValue = (value: string): string | number | Date | boolean => {
return value;
};
export const FilterList = ({
options,
values,
caseSensitive,
showOperators,
onChange,
searchFilter,
setSearchFilter,
operator,
setOperator,
}: Props) => {
export const FilterList = ({ options, values, caseSensitive, onChange, searchFilter, operator }: Props) => {
const regex = useMemo(() => new RegExp(searchFilter, caseSensitive ? undefined : 'i'), [searchFilter, caseSensitive]);
const items = useMemo(
() =>
options.filter((option) => {
if (!showOperators || !searchFilter || operator.value === REGEX_OPERATOR.value) {
if (!searchFilter || operator.value === REGEX_OPERATOR.value) {
if (option.label === undefined) {
return false;
}
@@ -123,7 +97,7 @@ export const FilterList = ({
return false;
}
}),
[options, regex, showOperators, operator, searchFilter]
[options, regex, operator, searchFilter]
);
const selectedItems = useMemo(() => items.filter((item) => values.includes(item)), [items, values]);
@@ -171,20 +145,7 @@ export const FilterList = ({
}, [onChange, values, items, selectedItems]);
return (
<Stack direction="column" gap={0.25}>
{!showOperators && <FilterInput placeholder="Filter values" onChange={setSearchFilter} value={searchFilter} />}
{showOperators && (
<Stack direction="row" gap={0}>
<ButtonSelect
variant="canvas"
options={OPERATORS}
onChange={setOperator}
value={operator}
tooltip={operator.description}
/>
<FilterInput placeholder="Filter values" onChange={setSearchFilter} value={searchFilter} />
</Stack>
)}
<Stack direction="column">
{items.length > 0 ? (
<>
<List
@@ -197,18 +158,15 @@ export const FilterList = ({
>
{ItemRenderer}
</List>
<Stack direction="column" gap={0.25}>
<div className={cx(styles.selectDivider)} />
<div className={cx(styles.filterListRow)}>
<Checkbox
value={selectCheckValue}
indeterminate={selectCheckIndeterminate}
label={selectCheckLabel}
description={selectCheckDescription}
onChange={onSelectChanged}
/>
</div>
</Stack>
<div className={styles.filterListRow}>
<Checkbox
value={selectCheckValue}
indeterminate={selectCheckIndeterminate}
label={selectCheckLabel}
description={selectCheckDescription}
onChange={onSelectChanged}
/>
</div>
</>
) : (
<Label className={styles.noValuesLabel}>
@@ -243,6 +201,9 @@ function ItemRenderer({ index, style, data: { onCheckedChanged, items, values, c
const getStyles = (theme: GrafanaTheme2) => ({
filterList: css({
label: 'filterList',
backgroundColor: theme.components.input.background,
border: `1px solid ${theme.colors.border.medium}`,
borderRadius: theme.shape.radius.default,
}),
filterListRow: css({
label: 'filterListRow',
@@ -1,9 +1,9 @@
import { css, cx } from '@emotion/css';
import { css } from '@emotion/css';
import React, { useCallback, useMemo, useState } from 'react';
import { Field, GrafanaTheme2, SelectableValue } from '@grafana/data';
import { Button, ClickOutsideWrapper, IconButton, Label, Stack } from '../../..';
import { Button, ButtonSelect, ClickOutsideWrapper, FilterInput, Label, Stack } from '../../..';
import { useStyles2, useTheme2 } from '../../../../themes';
import { Trans } from '../../../../utils/i18n';
import { FilterType } from '../types';
@@ -11,6 +11,22 @@ import { FilterType } from '../types';
import { FilterList } from './FilterList';
import { calculateUniqueFieldValues, getFilteredOptions, valuesToOptions } from './utils';
export 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);
interface Props {
name: string;
rows: any[];
@@ -86,35 +102,42 @@ export const FilterPopup = ({
<ClickOutsideWrapper onClick={onCancel} useCapture={true}>
{/* This is just blocking click events from bubbeling and should not have a keyboard interaction. */}
{/* eslint-disable-next-line jsx-a11y/no-static-element-interactions, jsx-a11y/click-events-have-key-events */}
<div className={cx(styles.filterContainer)} onClick={stopPropagation}>
<Stack direction="column" gap={3}>
<Stack direction="column" gap={0.5}>
<Stack justifyContent="space-between" alignItems="center">
<Label className={styles.label}>
<Trans i18nKey="grafana-ui.table.filter-popup-heading">Filter by values:</Trans>
</Label>
<IconButton
name="text-fields"
tooltip="Match case"
style={{ color: matchCase ? theme.colors.text.link : theme.colors.text.disabled }}
onClick={() => {
setMatchCase((s) => !s);
}}
/>
</Stack>
<div className={cx(styles.listDivider)} />
<FilterList
onChange={setValues}
values={values}
options={options}
caseSensitive={matchCase}
showOperators={true}
searchFilter={searchFilter}
setSearchFilter={setSearchFilter}
operator={operator}
setOperator={setOperator}
<div className={styles.filterContainer} onClick={stopPropagation}>
<Stack direction="column">
<Stack alignItems="center">
{field && <Label className={styles.label}>{field.config.displayName || field.name}</Label>}
<ButtonSelect
variant="canvas"
options={OPERATORS}
onChange={setOperator}
value={operator}
tooltip={operator.description}
/>
</Stack>
<div className={styles.listDivider} />
<Stack gap={1}>
<FilterInput placeholder="Filter values" onChange={setSearchFilter} value={searchFilter} />
<Button
variant="secondary"
style={{ color: matchCase ? theme.colors.text.link : theme.colors.text.disabled }}
onClick={() => {
setMatchCase((s) => !s);
}}
icon={'text-fields'}
/>
</Stack>
<FilterList
onChange={setValues}
values={values}
options={options}
caseSensitive={matchCase}
searchFilter={searchFilter}
operator={operator}
/>
<Stack gap={3}>
<Stack>
<Button size="sm" onClick={onFilter}>
@@ -144,7 +167,6 @@ const getStyles = (theme: GrafanaTheme2) => ({
width: '100%',
minWidth: '250px',
height: '100%',
maxHeight: '400px',
backgroundColor: theme.colors.background.primary,
border: `1px solid ${theme.colors.border.weak}`,
padding: theme.spacing(2),
@@ -155,7 +177,6 @@ const getStyles = (theme: GrafanaTheme2) => ({
label: 'listDivider',
width: '100%',
borderTop: `1px solid ${theme.colors.border.medium}`,
padding: theme.spacing(0.5, 2),
}),
label: css({
marginBottom: 0,
@@ -365,8 +365,9 @@ export function TableNG(props: TableNGProps) {
headerCellRefs,
isCountRowsSet,
osContext,
rows,
// INFO: sortedRows is for correct row indexing for cell background coloring
rows: sortedRows,
sortedRows,
setContextMenuProps,
setFilter,
setIsInspecting,
@@ -567,6 +568,7 @@ export function mapFrameToDataGrid({
isCountRowsSet,
osContext,
rows,
sortedRows,
setContextMenuProps,
setFilter,
setIsInspecting,
@@ -662,7 +664,7 @@ export function mapFrameToDataGrid({
fieldOptions.cellOptions.applyToRow
) {
rowBg = (rowIndex: number): CellColors => {
const display = field.display!(field.values.get(rows[rowIndex].__index));
const display = field.display!(field.values.get(sortedRows[rowIndex].__index));
const colors = getCellColors(theme, fieldOptions.cellOptions, display);
return colors;
};
@@ -105,6 +105,7 @@ const sortColumnsRef = { current: [] };
const mockOptions = {
osContext: null,
rows: [],
sortedRows: [],
setContextMenuProps: () => {},
setFilter: () => {},
setIsInspecting: () => {},
@@ -479,6 +479,7 @@ export interface MapFrameToGridOptions extends TableNGProps {
isCountRowsSet: boolean;
osContext: OffscreenCanvasRenderingContext2D | null;
rows: TableRow[];
sortedRows: TableRow[];
setContextMenuProps: (props: { value: string; top?: number; left?: number; mode?: TableCellInspectorMode }) => void;
setFilter: React.Dispatch<React.SetStateAction<FilterType>>;
setIsInspecting: (isInspecting: boolean) => void;