TableNG: Filter ui/ux improvements (#102985)
* feat(table-ng): filter ui/ux improvements
This commit is contained in:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user