diff --git a/packages/grafana-ui/src/components/ClickOutsideWrapper/ClickOutsideWrapper.tsx b/packages/grafana-ui/src/components/ClickOutsideWrapper/ClickOutsideWrapper.tsx index 2b69c038654..abd35792d46 100644 --- a/packages/grafana-ui/src/components/ClickOutsideWrapper/ClickOutsideWrapper.tsx +++ b/packages/grafana-ui/src/components/ClickOutsideWrapper/ClickOutsideWrapper.tsx @@ -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 { 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); } } diff --git a/packages/grafana-ui/src/components/Table/Filter.tsx b/packages/grafana-ui/src/components/Table/Filter.tsx new file mode 100644 index 00000000000..0bc613ee7ab --- /dev/null +++ b/packages/grafana-ui/src/components/Table/Filter.tsx @@ -0,0 +1,57 @@ +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 = ({ column, field, tableStyles }) => { + const ref = useRef(null); + const [isPopoverVisible, setPopoverVisible] = useState(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 ( + + + {isPopoverVisible && ref.current && ( + } + placement="bottom-start" + referenceElement={ref.current} + show + /> + )} + + ); +}; + +const getStyles = stylesFactory((theme: GrafanaTheme) => ({ + filterIconEnabled: css` + label: filterIconEnabled; + color: ${theme.colors.textBlue}; + `, + filterIconDisabled: css` + label: filterIconDisabled; + color: ${theme.colors.textFaint}; + `, +})); diff --git a/packages/grafana-ui/src/components/Table/FilterList.tsx b/packages/grafana-ui/src/components/Table/FilterList.tsx new file mode 100644 index 00000000000..2439bb8c9ae --- /dev/null +++ b/packages/grafana-ui/src/components/Table/FilterList.tsx @@ -0,0 +1,99 @@ +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 = ({ 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) => { + setSearchFilter(event.currentTarget.value); + }, + [setSearchFilter] + ); + + const onCheckedChanged = useCallback( + (option: SelectableValue) => (event: React.FormEvent) => { + const newValues = event.currentTarget.checked + ? values.concat(option) + : values.filter(c => c.value !== option.value); + + onChange(newValues); + }, + [onChange, values] + ); + + return ( + + + {!items.length && } + {items.length && ( + + {({ index, style }) => { + const option = items[index]; + const { value, label } = option; + const isChecked = values.find(s => s.value === value) !== undefined; + + return ( +
+ +
+ ); + }} +
+ )} +
+ ); +}; + +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; + `, +})); diff --git a/packages/grafana-ui/src/components/Table/FilterPopup.tsx b/packages/grafana-ui/src/components/Table/FilterPopup.tsx new file mode 100644 index 00000000000..4d134d62f26 --- /dev/null +++ b/packages/grafana-ui/src/components/Table/FilterPopup.tsx @@ -0,0 +1,103 @@ +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 = ({ 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(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 ( + +
+ + + +
+ + + + + + + + {clearFilterVisible && ( + + + + )} + + +
+ + ); +}; + +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(); +}; diff --git a/packages/grafana-ui/src/components/Table/Table.tsx b/packages/grafana-ui/src/components/Table/Table.tsx index 7273da50de1..b46786631da 100644 --- a/packages/grafana-ui/src/components/Table/Table.tsx +++ b/packages/grafana-ui/src/components/Table/Table.tsx @@ -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 = 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 (
{column.canSort && ( -
- {column.render('Header')} - {column.isSorted && (column.isSortedDesc ? : )} -
+ <> +
+
{column.render('Header')}
+
+ {column.isSorted && (column.isSortedDesc ? : )} +
+
+ {column.canFilter && } + )} - {!column.canSort &&
{column.render('Header')}
} + {!column.canSort && column.render('Header')} + {!column.canSort && column.canFilter && } {column.canResize &&
}
); diff --git a/packages/grafana-ui/src/components/Table/styles.ts b/packages/grafana-ui/src/components/Table/styles.ts index fc336f692cd..8617145c0f4 100644 --- a/packages/grafana-ui/src/components/Table/styles.ts +++ b/packages/grafana-ui/src/components/Table/styles.ts @@ -1,6 +1,6 @@ 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; diff --git a/packages/grafana-ui/src/components/Table/utils.test.ts b/packages/grafana-ui/src/components/Table/utils.test.ts index 705eabe0550..5a7353b4ace 100644 --- a/packages/grafana-ui/src/components/Table/utils.test.ts +++ b/packages/grafana-ui/src/components/Table/utils.test.ts @@ -1,5 +1,13 @@ -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([]); + }); + }); + }); }); diff --git a/packages/grafana-ui/src/components/Table/utils.ts b/packages/grafana-ui/src/components/Table/utils.ts index 580bad1666a..0e0e3beae50 100644 --- a/packages/grafana-ui/src/components/Table/utils.ts +++ b/packages/grafana-ui/src/components/Table/utils.ts @@ -1,12 +1,21 @@ -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 = {}; + + 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): 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)); +} diff --git a/public/app/plugins/panel/table/module.tsx b/public/app/plugins/panel/table/module.tsx index 44e11f1529f..274f7935187 100644 --- a/public/app/plugins/panel/table/module.tsx +++ b/public/app/plugins/panel/table/module.tsx @@ -1,7 +1,7 @@ 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(TablePanel) @@ -49,6 +49,12 @@ export const plugin = new PanelPlugin(TablePanel) { value: TableCellDisplayMode.JSONView, label: 'JSON View' }, ], }, + }) + .addBooleanSwitch({ + path: 'filterable', + name: 'Column filter', + description: 'Enables/disables field filters in table', + defaultValue: false, }); }, })