mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
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
This commit is contained in:
parent
aff9e931ce
commit
ff1149ac39
@ -11,6 +11,11 @@ export interface Props {
|
|||||||
*/
|
*/
|
||||||
includeButtonPress: boolean;
|
includeButtonPress: boolean;
|
||||||
parent: Window | Document;
|
parent: Window | Document;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener. Defaults to false.
|
||||||
|
*/
|
||||||
|
useCapture?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface State {
|
interface State {
|
||||||
@ -21,23 +26,24 @@ export class ClickOutsideWrapper extends PureComponent<Props, State> {
|
|||||||
static defaultProps = {
|
static defaultProps = {
|
||||||
includeButtonPress: true,
|
includeButtonPress: true,
|
||||||
parent: window,
|
parent: window,
|
||||||
|
useCapture: false,
|
||||||
};
|
};
|
||||||
state = {
|
state = {
|
||||||
hasEventListener: false,
|
hasEventListener: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
componentDidMount() {
|
componentDidMount() {
|
||||||
this.props.parent.addEventListener('click', this.onOutsideClick, false);
|
this.props.parent.addEventListener('click', this.onOutsideClick, this.props.useCapture);
|
||||||
if (this.props.includeButtonPress) {
|
if (this.props.includeButtonPress) {
|
||||||
// Use keyup since keydown already has an eventlistener on window
|
// 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() {
|
componentWillUnmount() {
|
||||||
this.props.parent.removeEventListener('click', this.onOutsideClick, false);
|
this.props.parent.removeEventListener('click', this.onOutsideClick, this.props.useCapture);
|
||||||
if (this.props.includeButtonPress) {
|
if (this.props.includeButtonPress) {
|
||||||
this.props.parent.removeEventListener('keyup', this.onOutsideClick, false);
|
this.props.parent.removeEventListener('keyup', this.onOutsideClick, this.props.useCapture);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
57
packages/grafana-ui/src/components/Table/Filter.tsx
Normal file
57
packages/grafana-ui/src/components/Table/Filter.tsx
Normal file
@ -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<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};
|
||||||
|
`,
|
||||||
|
}));
|
99
packages/grafana-ui/src/components/Table/FilterList.tsx
Normal file
99
packages/grafana-ui/src/components/Table/FilterList.tsx
Normal file
@ -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<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;
|
||||||
|
`,
|
||||||
|
}));
|
103
packages/grafana-ui/src/components/Table/FilterPopup.tsx
Normal file
103
packages/grafana-ui/src/components/Table/FilterPopup.tsx
Normal file
@ -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<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,
|
Column,
|
||||||
HeaderGroup,
|
HeaderGroup,
|
||||||
useAbsoluteLayout,
|
useAbsoluteLayout,
|
||||||
|
useFilters,
|
||||||
|
UseFiltersState,
|
||||||
useResizeColumns,
|
useResizeColumns,
|
||||||
UseResizeColumnsState,
|
UseResizeColumnsState,
|
||||||
useSortBy,
|
useSortBy,
|
||||||
@ -12,7 +14,7 @@ import {
|
|||||||
useTable,
|
useTable,
|
||||||
} from 'react-table';
|
} from 'react-table';
|
||||||
import { FixedSizeList } from 'react-window';
|
import { FixedSizeList } from 'react-window';
|
||||||
import { getColumns, getTextAlign } from './utils';
|
import { getColumns, getHeaderAlign } from './utils';
|
||||||
import { useTheme } from '../../themes';
|
import { useTheme } from '../../themes';
|
||||||
import {
|
import {
|
||||||
TableColumnResizeActionCallback,
|
TableColumnResizeActionCallback,
|
||||||
@ -24,6 +26,7 @@ import { getTableStyles, TableStyles } from './styles';
|
|||||||
import { TableCell } from './TableCell';
|
import { TableCell } from './TableCell';
|
||||||
import { Icon } from '../Icon/Icon';
|
import { Icon } from '../Icon/Icon';
|
||||||
import { CustomScrollbar } from '../CustomScrollbar/CustomScrollbar';
|
import { CustomScrollbar } from '../CustomScrollbar/CustomScrollbar';
|
||||||
|
import { Filter } from './Filter';
|
||||||
|
|
||||||
const COLUMN_MIN_WIDTH = 150;
|
const COLUMN_MIN_WIDTH = 150;
|
||||||
|
|
||||||
@ -42,7 +45,7 @@ export interface Props {
|
|||||||
onCellFilterAdded?: TableFilterActionCallback;
|
onCellFilterAdded?: TableFilterActionCallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ReactTableInternalState extends UseResizeColumnsState<{}>, UseSortByState<{}> {}
|
interface ReactTableInternalState extends UseResizeColumnsState<{}>, UseSortByState<{}>, UseFiltersState<{}> {}
|
||||||
|
|
||||||
function useTableStateReducer(props: Props) {
|
function useTableStateReducer(props: Props) {
|
||||||
return useCallback(
|
return useCallback(
|
||||||
@ -155,6 +158,7 @@ export const Table: FC<Props> = memo((props: Props) => {
|
|||||||
|
|
||||||
const { getTableProps, headerGroups, rows, prepareRow, totalColumnsWidth } = useTable(
|
const { getTableProps, headerGroups, rows, prepareRow, totalColumnsWidth } = useTable(
|
||||||
options,
|
options,
|
||||||
|
useFilters,
|
||||||
useSortBy,
|
useSortBy,
|
||||||
useAbsoluteLayout,
|
useAbsoluteLayout,
|
||||||
useResizeColumns
|
useResizeColumns
|
||||||
@ -225,17 +229,27 @@ function renderHeaderCell(column: any, tableStyles: TableStyles, field?: Field)
|
|||||||
}
|
}
|
||||||
|
|
||||||
headerProps.style.position = 'absolute';
|
headerProps.style.position = 'absolute';
|
||||||
headerProps.style.textAlign = getTextAlign(field);
|
headerProps.style.justifyContent = getHeaderAlign(field);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={tableStyles.headerCell} {...headerProps}>
|
<div className={tableStyles.headerCell} {...headerProps}>
|
||||||
{column.canSort && (
|
{column.canSort && (
|
||||||
<div {...column.getSortByToggleProps()} className={tableStyles.headerCellLabel} title={column.render('Header')}>
|
<>
|
||||||
{column.render('Header')}
|
<div
|
||||||
{column.isSorted && (column.isSortedDesc ? <Icon name="angle-down" /> : <Icon name="angle-up" />)}
|
{...column.getSortByToggleProps()}
|
||||||
</div>
|
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} />}
|
{column.canResize && <div {...column.getResizerProps()} className={tableStyles.resizeHandle} />}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import { css } from 'emotion';
|
import { css } from 'emotion';
|
||||||
import { GrafanaTheme } from '@grafana/data';
|
import { GrafanaTheme } from '@grafana/data';
|
||||||
import { stylesFactory, styleMixins } from '../../themes';
|
import { styleMixins, stylesFactory } from '../../themes';
|
||||||
import { getScrollbarWidth } from '../../utils';
|
import { getScrollbarWidth } from '../../utils';
|
||||||
|
|
||||||
export interface TableStyles {
|
export interface TableStyles {
|
||||||
@ -12,6 +12,7 @@ export interface TableStyles {
|
|||||||
thead: string;
|
thead: string;
|
||||||
headerCell: string;
|
headerCell: string;
|
||||||
headerCellLabel: string;
|
headerCellLabel: string;
|
||||||
|
headerFilter: string;
|
||||||
tableCell: string;
|
tableCell: string;
|
||||||
tableCellWrapper: string;
|
tableCellWrapper: string;
|
||||||
tableCellLink: string;
|
tableCellLink: string;
|
||||||
@ -56,20 +57,27 @@ export const getTableStyles = stylesFactory(
|
|||||||
`,
|
`,
|
||||||
headerCell: css`
|
headerCell: css`
|
||||||
padding: ${padding}px;
|
padding: ${padding}px;
|
||||||
cursor: pointer;
|
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
color: ${colors.textBlue};
|
color: ${colors.textBlue};
|
||||||
border-right: 1px solid ${theme.colors.panelBg};
|
border-right: 1px solid ${theme.colors.panelBg};
|
||||||
|
display: flex;
|
||||||
|
|
||||||
&:last-child {
|
&:last-child {
|
||||||
border-right: none;
|
border-right: none;
|
||||||
}
|
}
|
||||||
`,
|
`,
|
||||||
headerCellLabel: css`
|
headerCellLabel: css`
|
||||||
|
cursor: pointer;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
|
display: flex;
|
||||||
|
margin-right: ${theme.spacing.xs};
|
||||||
|
`,
|
||||||
|
headerFilter: css`
|
||||||
|
label: headerFilter;
|
||||||
|
cursor: pointer;
|
||||||
`,
|
`,
|
||||||
row: css`
|
row: css`
|
||||||
label: row;
|
label: row;
|
||||||
|
@ -1,5 +1,13 @@
|
|||||||
import { MutableDataFrame, FieldType } from '@grafana/data';
|
import { ArrayVector, Field, FieldType, MutableDataFrame, SelectableValue } from '@grafana/data';
|
||||||
import { getColumns, getTextAlign } from './utils';
|
import {
|
||||||
|
calculateUniqueFieldValues,
|
||||||
|
filterByValue,
|
||||||
|
getColumns,
|
||||||
|
getFilteredOptions,
|
||||||
|
getTextAlign,
|
||||||
|
sortOptions,
|
||||||
|
valuesToOptions,
|
||||||
|
} from './utils';
|
||||||
|
|
||||||
function getData() {
|
function getData() {
|
||||||
const data = new MutableDataFrame({
|
const data = new MutableDataFrame({
|
||||||
@ -61,4 +69,235 @@ describe('Table utils', () => {
|
|||||||
expect(textAlign).toBe('right');
|
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([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
@ -1,12 +1,21 @@
|
|||||||
import { TextAlignProperty } from 'csstype';
|
import { Column, Row } from 'react-table';
|
||||||
import { DataFrame, Field, FieldType, getFieldDisplayName } from '@grafana/data';
|
import memoizeOne from 'memoize-one';
|
||||||
import { Column } from 'react-table';
|
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 { DefaultCell } from './DefaultCell';
|
||||||
import { BarGaugeCell } from './BarGaugeCell';
|
import { BarGaugeCell } from './BarGaugeCell';
|
||||||
import { TableCellDisplayMode, TableCellProps, TableFieldOptions } from './types';
|
import { TableCellDisplayMode, TableCellProps, TableFieldOptions } from './types';
|
||||||
import { css, cx } from 'emotion';
|
|
||||||
import { withTableStyles } from './withTableStyles';
|
import { withTableStyles } from './withTableStyles';
|
||||||
import tinycolor from 'tinycolor2';
|
|
||||||
import { JSONViewCell } from './JSONViewCell';
|
import { JSONViewCell } from './JSONViewCell';
|
||||||
|
|
||||||
export function getTextAlign(field?: Field): TextAlignProperty {
|
export function getTextAlign(field?: Field): TextAlignProperty {
|
||||||
@ -70,6 +79,7 @@ export function getColumns(data: DataFrame, availableWidth: number, columnMinWid
|
|||||||
sortType: selectSortType(field.type),
|
sortType: selectSortType(field.type),
|
||||||
width: fieldTableOptions.width,
|
width: fieldTableOptions.width,
|
||||||
minWidth: 50,
|
minWidth: 50,
|
||||||
|
filter: memoizeOne(filterByValue),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -156,3 +166,92 @@ function getBackgroundColorStyle(props: TableCellProps) {
|
|||||||
tableCell: cx(tableStyles.tableCell, extendedStyle),
|
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));
|
||||||
|
}
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import { PanelPlugin } from '@grafana/data';
|
import { PanelPlugin } from '@grafana/data';
|
||||||
import { TablePanel } from './TablePanel';
|
import { TablePanel } from './TablePanel';
|
||||||
import { CustomFieldConfig, Options } from './types';
|
import { CustomFieldConfig, Options } from './types';
|
||||||
import { tablePanelChangedHandler, tableMigrationHandler } from './migrations';
|
import { tableMigrationHandler, tablePanelChangedHandler } from './migrations';
|
||||||
import { TableCellDisplayMode } from '@grafana/ui';
|
import { TableCellDisplayMode } from '@grafana/ui';
|
||||||
|
|
||||||
export const plugin = new PanelPlugin<Options, CustomFieldConfig>(TablePanel)
|
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' },
|
{ value: TableCellDisplayMode.JSONView, label: 'JSON View' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
})
|
||||||
|
.addBooleanSwitch({
|
||||||
|
path: 'filterable',
|
||||||
|
name: 'Column filter',
|
||||||
|
description: 'Enables/disables field filters in table',
|
||||||
|
defaultValue: false,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
Loading…
Reference in New Issue
Block a user