Commit ff1149ac by Hugo Häggmark Committed by GitHub

Table: Adds column filtering (#27225)

* Table: Adds column filters

* Refactor: adds filter by value function

* Refactor: some styling and sorting

* Refactor: Moves filterByValue to utils

* Tests: add filterByValue tests

* Refactor: simplifies filteredValues

* Refactor: adds dropshadow

* Refactor: keeps icons together with label and aligns with column alignment

* Refactor: hides clear filter if no filter is active

* Refactor: changes how values in filter are populated

* Refactor: adds filterable field override

* Tests: fixed broken tests

* Refactor: adds FilterList

* Refactor: adds blanks entry for non value labels

* Refactor: using preFilteredRows in filter list

* Refactor: adds filter input

* Refactor: fixes issue found by e2e

* Refactor: changes after PR comments

* Docs: adds documentation for Column filter

* Refactor: moves functions to utils and adds tests

* Refactor: memoizes filter function

* Docs: reverts docs for now
parent aff9e931
......@@ -11,6 +11,11 @@ export interface Props {
*/
includeButtonPress: boolean;
parent: Window | Document;
/**
* https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener. Defaults to false.
*/
useCapture?: boolean;
}
interface State {
......@@ -21,23 +26,24 @@ export class ClickOutsideWrapper extends PureComponent<Props, State> {
static defaultProps = {
includeButtonPress: true,
parent: window,
useCapture: false,
};
state = {
hasEventListener: false,
};
componentDidMount() {
this.props.parent.addEventListener('click', this.onOutsideClick, false);
this.props.parent.addEventListener('click', this.onOutsideClick, this.props.useCapture);
if (this.props.includeButtonPress) {
// Use keyup since keydown already has an eventlistener on window
this.props.parent.addEventListener('keyup', this.onOutsideClick, false);
this.props.parent.addEventListener('keyup', this.onOutsideClick, this.props.useCapture);
}
}
componentWillUnmount() {
this.props.parent.removeEventListener('click', this.onOutsideClick, false);
this.props.parent.removeEventListener('click', this.onOutsideClick, this.props.useCapture);
if (this.props.includeButtonPress) {
this.props.parent.removeEventListener('keyup', this.onOutsideClick, false);
this.props.parent.removeEventListener('keyup', this.onOutsideClick, this.props.useCapture);
}
}
......
import React, { FC, useCallback, useMemo, useRef, useState } from 'react';
import { css, cx } from 'emotion';
import { Field, GrafanaTheme } from '@grafana/data';
import { TableStyles } from './styles';
import { stylesFactory, useStyles } from '../../themes';
import { Icon } from '../Icon/Icon';
import { FilterPopup } from './FilterPopup';
import { Popover } from '..';
interface Props {
column: any;
tableStyles: TableStyles;
field?: Field;
}
export const Filter: FC<Props> = ({ column, field, tableStyles }) => {
const ref = useRef<HTMLDivElement>(null);
const [isPopoverVisible, setPopoverVisible] = useState<boolean>(false);
const styles = useStyles(getStyles);
const filterEnabled = useMemo(() => Boolean(column.filterValue), [column.filterValue]);
const onShowPopover = useCallback(() => setPopoverVisible(true), [setPopoverVisible]);
const onClosePopover = useCallback(() => setPopoverVisible(false), [setPopoverVisible]);
if (!field || !field.config.custom?.filterable) {
return null;
}
return (
<span
className={cx(tableStyles.headerFilter, filterEnabled ? styles.filterIconEnabled : styles.filterIconDisabled)}
ref={ref}
onClick={onShowPopover}
>
<Icon name="filter" />
{isPopoverVisible && ref.current && (
<Popover
content={<FilterPopup column={column} tableStyles={tableStyles} field={field} onClose={onClosePopover} />}
placement="bottom-start"
referenceElement={ref.current}
show
/>
)}
</span>
);
};
const getStyles = stylesFactory((theme: GrafanaTheme) => ({
filterIconEnabled: css`
label: filterIconEnabled;
color: ${theme.colors.textBlue};
`,
filterIconDisabled: css`
label: filterIconDisabled;
color: ${theme.colors.textFaint};
`,
}));
import React, { FC, useCallback, useMemo, useState } from 'react';
import { FixedSizeList as List } from 'react-window';
import { css } from 'emotion';
import { GrafanaTheme, SelectableValue } from '@grafana/data';
import { stylesFactory, useTheme } from '../../themes';
import { Checkbox, Input, Label, VerticalGroup } from '..';
interface Props {
values: SelectableValue[];
options: SelectableValue[];
onChange: (options: SelectableValue[]) => void;
}
const ITEM_HEIGHT = 28;
const MIN_HEIGHT = ITEM_HEIGHT * 5;
export const FilterList: FC<Props> = ({ options, values, onChange }) => {
const theme = useTheme();
const styles = getStyles(theme);
const [searchFilter, setSearchFilter] = useState('');
const items = useMemo(() => options.filter(option => option.label?.indexOf(searchFilter) !== -1), [
options,
searchFilter,
]);
const gutter = parseInt(theme.spacing.sm, 10);
const height = useMemo(() => Math.min(items.length * ITEM_HEIGHT, MIN_HEIGHT) + gutter, [items]);
const onInputChange = useCallback(
(event: React.FormEvent<HTMLInputElement>) => {
setSearchFilter(event.currentTarget.value);
},
[setSearchFilter]
);
const onCheckedChanged = useCallback(
(option: SelectableValue) => (event: React.FormEvent<HTMLInputElement>) => {
const newValues = event.currentTarget.checked
? values.concat(option)
: values.filter(c => c.value !== option.value);
onChange(newValues);
},
[onChange, values]
);
return (
<VerticalGroup spacing="md">
<Input
placeholder="filter values"
className={styles.filterListInput}
onChange={onInputChange}
value={searchFilter}
/>
{!items.length && <Label>No values</Label>}
{items.length && (
<List
height={height}
itemCount={items.length}
itemSize={ITEM_HEIGHT}
width="100%"
className={styles.filterList}
>
{({ index, style }) => {
const option = items[index];
const { value, label } = option;
const isChecked = values.find(s => s.value === value) !== undefined;
return (
<div className={styles.filterListRow} style={style} title={label}>
<Checkbox value={isChecked} label={label} onChange={onCheckedChanged(option)} />
</div>
);
}}
</List>
)}
</VerticalGroup>
);
};
const getStyles = stylesFactory((theme: GrafanaTheme) => ({
filterList: css`
label: filterList;
`,
filterListRow: css`
label: filterListRow;
cursor: pointer;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
padding: ${theme.spacing.xs};
:hover {
background-color: ${theme.colors.bg3};
}
`,
filterListInput: css`
label: filterListInput;
`,
}));
import React, { FC, useCallback, useMemo, useState } from 'react';
import { Field, GrafanaTheme, SelectableValue } from '@grafana/data';
import { css, cx } from 'emotion';
import { TableStyles } from './styles';
import { stylesFactory, useStyles } from '../../themes';
import { Button, ClickOutsideWrapper, HorizontalGroup, Label, VerticalGroup } from '..';
import { FilterList } from './FilterList';
import { calculateUniqueFieldValues, getFilteredOptions, valuesToOptions } from './utils';
interface Props {
column: any;
tableStyles: TableStyles;
onClose: () => void;
field?: Field;
}
export const FilterPopup: FC<Props> = ({ column: { preFilteredRows, filterValue, setFilter }, onClose, field }) => {
const uniqueValues = useMemo(() => calculateUniqueFieldValues(preFilteredRows, field), [preFilteredRows, field]);
const options = useMemo(() => valuesToOptions(uniqueValues), [uniqueValues]);
const filteredOptions = useMemo(() => getFilteredOptions(options, filterValue), [options, filterValue]);
const [values, setValues] = useState<SelectableValue[]>(filteredOptions);
const onCancel = useCallback((event?: React.MouseEvent) => onClose(), [onClose]);
const onFilter = useCallback(
(event: React.MouseEvent) => {
const filtered = values.length ? values : undefined;
setFilter(filtered);
onClose();
},
[setFilter, values, onClose]
);
const onClearFilter = useCallback(
(event: React.MouseEvent) => {
setFilter(undefined);
onClose();
},
[setFilter, onClose]
);
const clearFilterVisible = useMemo(() => filterValue !== undefined, [filterValue]);
const styles = useStyles(getStyles);
return (
<ClickOutsideWrapper onClick={onCancel} useCapture={true}>
<div className={cx(styles.filterContainer)} onClick={stopPropagation}>
<VerticalGroup spacing="lg">
<VerticalGroup spacing="xs">
<Label>Filter by values:</Label>
<div className={cx(styles.listDivider)} />
<FilterList onChange={setValues} values={values} options={options} />
</VerticalGroup>
<HorizontalGroup spacing="lg">
<HorizontalGroup>
<Button size="sm" onClick={onFilter}>
Ok
</Button>
<Button size="sm" variant="secondary" onClick={onCancel}>
Cancel
</Button>
</HorizontalGroup>
{clearFilterVisible && (
<HorizontalGroup>
<Button variant="link" size="sm" onClick={onClearFilter}>
Clear filter
</Button>
</HorizontalGroup>
)}
</HorizontalGroup>
</VerticalGroup>
</div>
</ClickOutsideWrapper>
);
};
const getStyles = stylesFactory((theme: GrafanaTheme) => ({
filterContainer: css`
label: filterContainer;
width: 100%;
min-width: 250px;
height: 100%;
max-height: 400px;
background-color: ${theme.colors.bg1};
border: ${theme.border.width.sm} solid ${theme.colors.border2};
padding: ${theme.spacing.md};
margin: ${theme.spacing.sm} 0;
box-shadow: 0px 0px 20px ${theme.colors.dropdownShadow};
border-radius: ${theme.spacing.xs};
`,
listDivider: css`
label: listDivider;
width: 100%;
border-top: ${theme.border.width.sm} solid ${theme.colors.border2};
padding: ${theme.spacing.xs} ${theme.spacing.md};
`,
}));
const stopPropagation = (event: React.MouseEvent) => {
event.stopPropagation();
};
......@@ -5,6 +5,8 @@ import {
Column,
HeaderGroup,
useAbsoluteLayout,
useFilters,
UseFiltersState,
useResizeColumns,
UseResizeColumnsState,
useSortBy,
......@@ -12,7 +14,7 @@ import {
useTable,
} from 'react-table';
import { FixedSizeList } from 'react-window';
import { getColumns, getTextAlign } from './utils';
import { getColumns, getHeaderAlign } from './utils';
import { useTheme } from '../../themes';
import {
TableColumnResizeActionCallback,
......@@ -24,6 +26,7 @@ import { getTableStyles, TableStyles } from './styles';
import { TableCell } from './TableCell';
import { Icon } from '../Icon/Icon';
import { CustomScrollbar } from '../CustomScrollbar/CustomScrollbar';
import { Filter } from './Filter';
const COLUMN_MIN_WIDTH = 150;
......@@ -42,7 +45,7 @@ export interface Props {
onCellFilterAdded?: TableFilterActionCallback;
}
interface ReactTableInternalState extends UseResizeColumnsState<{}>, UseSortByState<{}> {}
interface ReactTableInternalState extends UseResizeColumnsState<{}>, UseSortByState<{}>, UseFiltersState<{}> {}
function useTableStateReducer(props: Props) {
return useCallback(
......@@ -155,6 +158,7 @@ export const Table: FC<Props> = memo((props: Props) => {
const { getTableProps, headerGroups, rows, prepareRow, totalColumnsWidth } = useTable(
options,
useFilters,
useSortBy,
useAbsoluteLayout,
useResizeColumns
......@@ -225,17 +229,27 @@ function renderHeaderCell(column: any, tableStyles: TableStyles, field?: Field)
}
headerProps.style.position = 'absolute';
headerProps.style.textAlign = getTextAlign(field);
headerProps.style.justifyContent = getHeaderAlign(field);
return (
<div className={tableStyles.headerCell} {...headerProps}>
{column.canSort && (
<div {...column.getSortByToggleProps()} className={tableStyles.headerCellLabel} title={column.render('Header')}>
{column.render('Header')}
{column.isSorted && (column.isSortedDesc ? <Icon name="angle-down" /> : <Icon name="angle-up" />)}
</div>
<>
<div
{...column.getSortByToggleProps()}
className={tableStyles.headerCellLabel}
title={column.render('Header')}
>
<div>{column.render('Header')}</div>
<div>
{column.isSorted && (column.isSortedDesc ? <Icon name="arrow-down" /> : <Icon name="arrow-up" />)}
</div>
</div>
{column.canFilter && <Filter column={column} tableStyles={tableStyles} field={field} />}
</>
)}
{!column.canSort && <div>{column.render('Header')}</div>}
{!column.canSort && column.render('Header')}
{!column.canSort && column.canFilter && <Filter column={column} tableStyles={tableStyles} field={field} />}
{column.canResize && <div {...column.getResizerProps()} className={tableStyles.resizeHandle} />}
</div>
);
......
import { css } from 'emotion';
import { GrafanaTheme } from '@grafana/data';
import { stylesFactory, styleMixins } from '../../themes';
import { styleMixins, stylesFactory } from '../../themes';
import { getScrollbarWidth } from '../../utils';
export interface TableStyles {
......@@ -12,6 +12,7 @@ export interface TableStyles {
thead: string;
headerCell: string;
headerCellLabel: string;
headerFilter: string;
tableCell: string;
tableCellWrapper: string;
tableCellLink: string;
......@@ -56,20 +57,27 @@ export const getTableStyles = stylesFactory(
`,
headerCell: css`
padding: ${padding}px;
cursor: pointer;
overflow: hidden;
white-space: nowrap;
color: ${colors.textBlue};
border-right: 1px solid ${theme.colors.panelBg};
display: flex;
&:last-child {
border-right: none;
}
`,
headerCellLabel: css`
cursor: pointer;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
display: flex;
margin-right: ${theme.spacing.xs};
`,
headerFilter: css`
label: headerFilter;
cursor: pointer;
`,
row: css`
label: row;
......
import { MutableDataFrame, FieldType } from '@grafana/data';
import { getColumns, getTextAlign } from './utils';
import { ArrayVector, Field, FieldType, MutableDataFrame, SelectableValue } from '@grafana/data';
import {
calculateUniqueFieldValues,
filterByValue,
getColumns,
getFilteredOptions,
getTextAlign,
sortOptions,
valuesToOptions,
} from './utils';
function getData() {
const data = new MutableDataFrame({
......@@ -61,4 +69,235 @@ describe('Table utils', () => {
expect(textAlign).toBe('right');
});
});
describe('filterByValue', () => {
it.each`
rows | id | filterValues | expected
${[]} | ${'0'} | ${[{ value: 'a' }]} | ${[]}
${[{ values: { 0: 'a' } }]} | ${'0'} | ${null} | ${[{ values: { 0: 'a' } }]}
${[{ values: { 0: 'a' } }]} | ${'0'} | ${undefined} | ${[{ values: { 0: 'a' } }]}
${[{ values: { 0: 'a' } }]} | ${'1'} | ${[{ value: 'b' }]} | ${[]}
${[{ values: { 0: 'a' } }]} | ${'0'} | ${[{ value: 'a' }]} | ${[{ values: { 0: 'a' } }]}
${[{ values: { 0: 'a' } }, { values: { 1: 'a' } }]} | ${'0'} | ${[{ value: 'a' }]} | ${[{ values: { 0: 'a' } }]}
${[{ values: { 0: 'a' } }, { values: { 0: 'b' } }, { values: { 0: 'c' } }]} | ${'0'} | ${[{ value: 'a' }, { value: 'b' }]} | ${[{ values: { 0: 'a' } }, { values: { 0: 'b' } }]}
`(
"when called with rows: '$rows.toString()', id: '$id' and filterValues: '$filterValues' then result should be '$expected'",
({ rows, id, filterValues, expected }) => {
expect(filterByValue(rows, id, filterValues)).toEqual(expected);
}
);
});
describe('calculateUniqueFieldValues', () => {
describe('when called without field', () => {
it('then it should return an empty object', () => {
const field = undefined;
const rows = [{ id: 0 }];
const result = calculateUniqueFieldValues(rows, field);
expect(result).toEqual({});
});
});
describe('when called with no rows', () => {
it('then it should return an empty object', () => {
const field: Field = {
config: {},
labels: {},
values: new ArrayVector([1]),
name: 'value',
type: FieldType.number,
getLinks: () => [],
state: null,
display: (value: any) => ({
numeric: 1,
percent: 0.01,
color: '',
title: '1.0',
text: '1.0',
}),
parse: (value: any) => '1.0',
};
const rows: any[] = [];
const result = calculateUniqueFieldValues(rows, field);
expect(result).toEqual({});
});
});
describe('when called with rows and field with display processor', () => {
it('then it should return an array with unique values', () => {
const field: Field = {
config: {},
values: new ArrayVector([1, 2, 2, 1, 3, 5, 6]),
name: 'value',
type: FieldType.number,
display: jest.fn((value: any) => ({
numeric: 1,
percent: 0.01,
color: '',
title: `${value}.0`,
text: `${value}.0`,
})),
};
const rows: any[] = [{ id: 0 }, { id: 1 }, { id: 2 }, { id: 3 }, { id: 4 }];
const result = calculateUniqueFieldValues(rows, field);
expect(field.display).toHaveBeenCalledTimes(5);
expect(result).toEqual({
'1.0': 1,
'2.0': 2,
'3.0': 3,
});
});
});
describe('when called with rows and field without display processor', () => {
it('then it should return an array with unique values', () => {
const field: Field = {
config: {},
values: new ArrayVector([1, 2, 2, 1, 3, 5, 6]),
name: 'value',
type: FieldType.number,
};
const rows: any[] = [{ id: 0 }, { id: 1 }, { id: 2 }, { id: 3 }, { id: 4 }];
const result = calculateUniqueFieldValues(rows, field);
expect(result).toEqual({
'1': 1,
'2': 2,
'3': 3,
});
});
describe('when called with rows with blanks and field', () => {
it('then it should return an array with unique values and (Blanks)', () => {
const field: Field = {
config: {},
values: new ArrayVector([1, null, null, 1, 3, 5, 6]),
name: 'value',
type: FieldType.number,
};
const rows: any[] = [{ id: 0 }, { id: 1 }, { id: 2 }, { id: 3 }, { id: 4 }];
const result = calculateUniqueFieldValues(rows, field);
expect(result).toEqual({
'(Blanks)': null,
'1': 1,
'3': 3,
});
});
});
});
});
describe('valuesToOptions', () => {
describe('when called with a record object', () => {
it('then it should return sorted options from that object', () => {
const date = new Date();
const unique = {
string: 'string',
numeric: 1,
date: date,
boolean: true,
};
const result = valuesToOptions(unique);
expect(result).toEqual([
{ label: 'boolean', value: true },
{ label: 'date', value: date },
{ label: 'numeric', value: 1 },
{ label: 'string', value: 'string' },
]);
});
});
});
describe('sortOptions', () => {
it.each`
a | b | expected
${{ label: undefined }} | ${{ label: undefined }} | ${0}
${{ label: undefined }} | ${{ label: 'b' }} | ${-1}
${{ label: 'a' }} | ${{ label: undefined }} | ${1}
${{ label: 'a' }} | ${{ label: 'b' }} | ${-1}
${{ label: 'b' }} | ${{ label: 'a' }} | ${1}
${{ label: 'a' }} | ${{ label: 'a' }} | ${0}
`("when called with a: '$a.toString', b: '$b.toString' then result should be '$expected'", ({ a, b, expected }) => {
expect(sortOptions(a, b)).toEqual(expected);
});
});
describe('getFilteredOptions', () => {
describe('when called without filterValues', () => {
it('then it should return an empty array', () => {
const options = [
{ label: 'a', value: 'a' },
{ label: 'b', value: 'b' },
{ label: 'c', value: 'c' },
];
const filterValues = undefined;
const result = getFilteredOptions(options, filterValues);
expect(result).toEqual([]);
});
});
describe('when called with no options', () => {
it('then it should return an empty array', () => {
const options: SelectableValue[] = [];
const filterValues = [
{ label: 'a', value: 'a' },
{ label: 'b', value: 'b' },
{ label: 'c', value: 'c' },
];
const result = getFilteredOptions(options, filterValues);
expect(result).toEqual(options);
});
});
describe('when called with options and matching filterValues', () => {
it('then it should return an empty array', () => {
const options: SelectableValue[] = [
{ label: 'a', value: 'a' },
{ label: 'b', value: 'b' },
{ label: 'c', value: 'c' },
];
const filterValues = [
{ label: 'a', value: 'a' },
{ label: 'b', value: 'b' },
];
const result = getFilteredOptions(options, filterValues);
expect(result).toEqual([
{ label: 'a', value: 'a' },
{ label: 'b', value: 'b' },
]);
});
});
describe('when called with options and non matching filterValues', () => {
it('then it should return an empty array', () => {
const options: SelectableValue[] = [
{ label: 'a', value: 'a' },
{ label: 'b', value: 'b' },
{ label: 'c', value: 'c' },
];
const filterValues = [{ label: 'q', value: 'q' }];
const result = getFilteredOptions(options, filterValues);
expect(result).toEqual([]);
});
});
});
});
import { TextAlignProperty } from 'csstype';
import { DataFrame, Field, FieldType, getFieldDisplayName } from '@grafana/data';
import { Column } from 'react-table';
import { Column, Row } from 'react-table';
import memoizeOne from 'memoize-one';
import { css, cx } from 'emotion';
import tinycolor from 'tinycolor2';
import { ContentPosition, TextAlignProperty } from 'csstype';
import {
DataFrame,
Field,
FieldType,
formattedValueToString,
getFieldDisplayName,
SelectableValue,
} from '@grafana/data';
import { DefaultCell } from './DefaultCell';
import { BarGaugeCell } from './BarGaugeCell';
import { TableCellDisplayMode, TableCellProps, TableFieldOptions } from './types';
import { css, cx } from 'emotion';
import { withTableStyles } from './withTableStyles';
import tinycolor from 'tinycolor2';
import { JSONViewCell } from './JSONViewCell';
export function getTextAlign(field?: Field): TextAlignProperty {
......@@ -70,6 +79,7 @@ export function getColumns(data: DataFrame, availableWidth: number, columnMinWid
sortType: selectSortType(field.type),
width: fieldTableOptions.width,
minWidth: 50,
filter: memoizeOne(filterByValue),
});
}
......@@ -156,3 +166,92 @@ function getBackgroundColorStyle(props: TableCellProps) {
tableCell: cx(tableStyles.tableCell, extendedStyle),
};
}
export function filterByValue(rows: Row[], id: string, filterValues?: SelectableValue[]) {
if (rows.length === 0) {
return rows;
}
if (!filterValues) {
return rows;
}
return rows.filter(row => {
if (!row.values.hasOwnProperty(id)) {
return false;
}
const value = row.values[id];
return filterValues.find(filter => filter.value === value) !== undefined;
});
}
export function getHeaderAlign(field?: Field): ContentPosition {
const align = getTextAlign(field);
if (align === 'right') {
return 'flex-end';
}
if (align === 'center') {
return align;
}
return 'flex-start';
}
export function calculateUniqueFieldValues(rows: any[], field?: Field) {
if (!field || rows.length === 0) {
return {};
}
const set: Record<string, any> = {};
for (let index = 0; index < rows.length; index++) {
const fieldIndex = parseInt(rows[index].id, 10);
const fieldValue = field.values.get(fieldIndex);
const displayValue = field.display ? field.display(fieldValue) : fieldValue;
const value = field.display ? formattedValueToString(displayValue) : displayValue;
set[value || '(Blanks)'] = fieldValue;
}
return set;
}
export function valuesToOptions(unique: Record<string, any>): SelectableValue[] {
return Object.keys(unique)
.reduce((all, key) => all.concat({ value: unique[key], label: key }), [] as SelectableValue[])
.sort(sortOptions);
}
export function sortOptions(a: SelectableValue, b: SelectableValue): number {
if (a.label === undefined && b.label === undefined) {
return 0;
}
if (a.label === undefined && b.label !== undefined) {
return -1;
}
if (a.label !== undefined && b.label === undefined) {
return 1;
}
if (a.label! < b.label!) {
return -1;
}
if (a.label! > b.label!) {
return 1;
}
return 0;
}
export function getFilteredOptions(options: SelectableValue[], filterValues?: SelectableValue[]): SelectableValue[] {
if (!filterValues) {
return [];
}
return options.filter(option => filterValues.some(filtered => filtered.value === option.value));
}
import { PanelPlugin } from '@grafana/data';
import { TablePanel } from './TablePanel';
import { CustomFieldConfig, Options } from './types';
import { tablePanelChangedHandler, tableMigrationHandler } from './migrations';
import { tableMigrationHandler, tablePanelChangedHandler } from './migrations';
import { TableCellDisplayMode } from '@grafana/ui';
export const plugin = new PanelPlugin<Options, CustomFieldConfig>(TablePanel)
......@@ -49,6 +49,12 @@ export const plugin = new PanelPlugin<Options, CustomFieldConfig>(TablePanel)
{ value: TableCellDisplayMode.JSONView, label: 'JSON View' },
],
},
})
.addBooleanSwitch({
path: 'filterable',
name: 'Column filter',
description: 'Enables/disables field filters in table',
defaultValue: false,
});
},
})
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment