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;
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
|
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,
|
||||
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
|
||||
{...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>
|
||||
);
|
||||
|
@ -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;
|
||||
|
@ -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([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -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<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 { 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,
|
||||
});
|
||||
},
|
||||
})
|
||||
|
Loading…
Reference in New Issue
Block a user