Introduce column filters

Summary:
Beyond a search across all columns, it is now possible to specific columns for specific values:

* for a row to be visible, all active column filters need to be matched (e.g. both a filter on time and app has to be satisfied)
* if multiple values within a column are filtered for, these are -or-ed.
* if no value at all within a column is checked, even when they are defined, the column won't take part in filtering
* if there is a general search and column filters, a row has to satisfy both

Filters can be preconfigured, pre-configured filters cannot be removed.

Reseting will reset the filters back to their original

Move `useMemoize` to flipper-plugin

Merged the `ui/utils` and `utils` folder inside `flipper-plugin`

Reviewed By: nikoant

Differential Revision: D26450260

fbshipit-source-id: 11693d5d140cea03cad91c1e0f3438d7b129cf29
This commit is contained in:
Michel Weststrate
2021-03-16 14:54:53 -07:00
committed by Facebook GitHub Bot
parent 8aabce477b
commit 11eb19da4c
23 changed files with 529 additions and 93 deletions

View File

@@ -44,6 +44,7 @@ test('Correct top level API exposed', () => {
"styled",
"theme",
"useLogger",
"useMemoize",
"usePlugin",
"useTrackedCallback",
"useValue",

View File

@@ -80,6 +80,8 @@ export {
InteractiveProps as _InteractiveProps,
} from './ui/Interactive';
export {useMemoize} from './utils/useMemoize';
// It's not ideal that this exists in flipper-plugin sources directly,
// but is the least pain for plugin authors.
// Probably we should make sure that testing-library doesn't end up in our final Flipper bundle (which packages flipper-plugin)

View File

@@ -9,14 +9,14 @@
import styled from '@emotion/styled';
import React from 'react';
import LowPassFilter from './utils/LowPassFilter';
import LowPassFilter from '../utils/LowPassFilter';
import {
getDistanceTo,
maybeSnapLeft,
maybeSnapTop,
SNAP_SIZE,
} from './utils/snap';
import type {Rect} from './utils/Rect';
} from '../utils/snap';
import type {Rect} from '../utils/Rect';
const WINDOW_CURSOR_BOUNDARY = 5;

View File

@@ -138,6 +138,7 @@ type SplitLayoutProps = {
* If set, items will be centered over the orthogonal direction, if false (the default) items will be stretched.
*/
center?: boolean;
gap?: Spacing;
children: [React.ReactNode, React.ReactNode];
style?: React.HTMLAttributes<HTMLDivElement>['style'];
};
@@ -191,6 +192,7 @@ Object.keys(Layout).forEach((key) => {
const SandySplitContainer = styled.div<{
grow: 1 | 2;
gap?: Spacing;
center?: boolean;
flexDirection: CSSProperties['flexDirection'];
}>((props) => ({
@@ -199,6 +201,7 @@ const SandySplitContainer = styled.div<{
flex: 1,
flexDirection: props.flexDirection,
alignItems: props.center ? 'center' : 'stretch',
gap: normalizeSpace(props.gap, theme.space.small),
overflow: props.center ? undefined : 'hidden', // only use overflow hidden in container mode, to avoid weird resizing issues
'> :nth-child(1)': {
flex: props.grow === 1 ? splitGrowStyle : splitFixedStyle,

View File

@@ -7,7 +7,7 @@
* @format
*/
import LowPassFilter from '../utils/LowPassFilter';
import LowPassFilter from '../../utils/LowPassFilter';
test('hasFullBuffer', () => {
const lpf = new LowPassFilter();

View File

@@ -0,0 +1,116 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @format
*/
import {useMemo, useState} from 'react';
import styled from '@emotion/styled';
import React from 'react';
import {theme} from '../theme';
import type {DataTableColumn} from './DataTable';
import {Button, Checkbox, Dropdown, Menu, Typography, Input} from 'antd';
import {FilterFilled, MinusCircleOutlined} from '@ant-design/icons';
import {Layout} from '../Layout';
const {Text} = Typography;
export const HeaderButton = styled(Button)({
padding: 4,
backgroundColor: theme.backgroundWash,
borderRadius: 0,
});
export type ColumnFilterHandlers = {
onAddColumnFilter(columnId: string, value: string): void;
onRemoveColumnFilter(columnId: string, index: number): void;
onToggleColumnFilter(columnId: string, index: number): void;
};
export function FilterIcon({
column,
...props
}: {column: DataTableColumn<any>} & ColumnFilterHandlers) {
const [input, setInput] = useState('');
const {filters} = column;
const isActive = useMemo(() => filters?.some((f) => f.enabled), [filters]);
const onAddFilter = (e: React.MouseEvent | React.KeyboardEvent) => {
e.stopPropagation();
props.onAddColumnFilter(column.key, input);
setInput('');
};
const menu = (
<Menu
onMouseDown={(e) => {
e.stopPropagation(); // prevents interaction accidentally with the Interactive component organizing resizng
}}>
<Menu.Item>
<Layout.Right gap>
<Input
placeholder="Filter by value"
value={input}
onChange={(e) => {
e.stopPropagation();
setInput(e.target.value);
}}
onClick={(e) => {
e.stopPropagation();
}}
onPressEnter={onAddFilter}
disabled={false}
/>
<Button onClick={onAddFilter}>Add</Button>
</Layout.Right>
</Menu.Item>
<Menu.Divider />
{filters?.length ? (
filters?.map((filter, index) => (
<Menu.Item key={index}>
<Layout.Right center>
<Checkbox
checked={filter.enabled}
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
props.onToggleColumnFilter(column.key, index);
}}>
{filter.label}
</Checkbox>
{!filter.predefined && (
<MinusCircleOutlined
onClick={(e) => {
e.stopPropagation();
props.onRemoveColumnFilter(column.key, index);
}}
/>
)}
</Layout.Right>
</Menu.Item>
))
) : (
<Text type="secondary" style={{margin: 12}}>
No active filters
</Text>
)}
</Menu>
);
return (
<Dropdown overlay={menu} trigger={['click']}>
<HeaderButton
type="text"
style={{
visibility: isActive ? 'visible' : 'hidden',
color: isActive ? theme.primaryColor : theme.disabledColor,
}}>
<FilterFilled />
</HeaderButton>
</Dropdown>
);
}

View File

@@ -20,7 +20,7 @@ import {TableRow, DEFAULT_ROW_HEIGHT} from './TableRow';
import {DataSource} from '../../state/datasource/DataSource';
import {Layout} from '../Layout';
import {TableHead} from './TableHead';
import {Percentage} from '../utils/widthUtils';
import {Percentage} from '../../utils/widthUtils';
import {DataSourceRenderer, DataSourceVirtualizer} from './DataSourceRenderer';
import {useDataTableManager, TableManager} from './useDataTableManager';
import {TableSearch} from './TableSearch';
@@ -55,6 +55,12 @@ export type DataTableColumn<T = any> = {
wrap?: boolean;
align?: 'left' | 'right' | 'center';
visible?: boolean;
filters?: {
label: string;
value: string;
enabled: boolean;
predefined?: boolean;
}[];
};
export interface RenderContext<T = any> {
@@ -129,6 +135,7 @@ export function DataTable<T extends object>(props: DataTableProps<T>) {
case 'End':
selectItem(() => dataSource.output.length - 1);
break;
case ' ': // yes, that is a space
case 'PageDown':
selectItem((idx) =>
Math.min(
@@ -196,6 +203,9 @@ export function DataTable<T extends object>(props: DataTableProps<T>) {
onColumnToggleVisibility={tableManager.toggleColumnVisibility}
sorting={tableManager.sorting}
onColumnSort={tableManager.sortColumn}
onAddColumnFilter={tableManager.addColumnFilter}
onRemoveColumnFilter={tableManager.removeColumnFilter}
onToggleColumnFilter={tableManager.toggleColumnFilter}
/>
</Layout.Container>
<DataSourceRenderer<T, RenderContext<T>>

View File

@@ -12,7 +12,7 @@ import {
isPercentage,
Percentage,
Width,
} from '../utils/widthUtils';
} from '../../utils/widthUtils';
import {memo, useRef} from 'react';
import {Interactive, InteractiveProps} from '../Interactive';
import styled from '@emotion/styled';
@@ -20,18 +20,14 @@ import React from 'react';
import {theme} from '../theme';
import type {DataTableColumn} from './DataTable';
import {Button, Checkbox, Dropdown, Menu, Typography} from 'antd';
import {Checkbox, Dropdown, Menu, Typography} from 'antd';
import {CaretDownFilled, CaretUpFilled, DownOutlined} from '@ant-design/icons';
import {Layout} from '../Layout';
import {Sorting, OnColumnResize} from './useDataTableManager';
import {ColumnFilterHandlers, FilterIcon, HeaderButton} from './ColumnFilter';
const {Text} = Typography;
const TableHeaderArrow = styled.span({
float: 'right',
});
TableHeaderArrow.displayName = 'TableHead:TableHeaderArrow';
function SortIcons({direction}: {direction?: 'up' | 'down'}) {
return (
<SortIconsContainer direction={direction}>
@@ -56,11 +52,19 @@ const SortIconsContainer = styled.span<{direction?: 'up' | 'down'}>(
display: 'inline-flex',
flexDirection: 'column',
alignItems: 'center',
marginLeft: 4,
position: 'relative',
left: 4,
top: -3,
color: theme.disabledColor,
}),
);
const SettingsButton = styled(HeaderButton)({
position: 'absolute',
right: 0,
top: 0,
});
const TableHeaderColumnInteractive = styled(Interactive)<InteractiveProps>({
overflow: 'hidden',
whiteSpace: 'nowrap',
@@ -69,17 +73,6 @@ const TableHeaderColumnInteractive = styled(Interactive)<InteractiveProps>({
TableHeaderColumnInteractive.displayName =
'TableHead:TableHeaderColumnInteractive';
const TableHeaderColumnContainer = styled(Layout.Horizontal)({
padding: '4px 8px',
':hover': {
backgroundColor: theme.buttonDefaultBackground,
},
[`:hover ${SortIconsContainer}`]: {
visibility: 'visible',
},
});
TableHeaderColumnContainer.displayName = 'TableHead:TableHeaderColumnContainer';
const TableHeadContainer = styled.div<{horizontallyScrollable?: boolean}>({
position: 'relative',
display: 'flex',
@@ -96,32 +89,36 @@ const TableHeadColumnContainer = styled.div<{
flexShrink: props.width === undefined ? 1 : 0,
flexGrow: props.width === undefined ? 1 : 0,
width: props.width === undefined ? '100%' : props.width,
'&:last-of-type': {
marginRight: 20, // space for settings button
},
[`:hover ${SortIconsContainer}`]: {
visibility: 'visible',
},
[`&:hover ${HeaderButton}`]: {
visibility: 'visible !important' as any,
},
padding: '0 4px',
}));
TableHeadColumnContainer.displayName = 'TableHead:TableHeadColumnContainer';
const RIGHT_RESIZABLE = {right: true};
function TableHeadColumn({
id,
title,
width,
column,
isResizable,
isSortable,
onColumnResize,
isSortable: sortable,
onSort,
sorted,
...filterHandlers
}: {
id: string;
width: Width;
isSortable?: boolean;
column: DataTableColumn<any>;
sorted: 'up' | 'down' | undefined;
isResizable: boolean;
onSort: (id: string) => void;
sortOrder: undefined | Sorting;
onColumnResize: OnColumnResize;
title?: string;
}) {
} & ColumnFilterHandlers) {
const ref = useRef<HTMLDivElement | null>(null);
const onResize = (newWidth: number) => {
@@ -132,7 +129,7 @@ function TableHeadColumn({
let normalizedWidth: number | Percentage = newWidth;
// normalise number to a percentage if we were originally passed a percentage
if (isPercentage(width) && ref.current) {
if (isPercentage(column.width) && ref.current) {
const {parentElement} = ref.current;
const parentWidth = parentElement!.clientWidth;
const {childNodes} = parentElement!;
@@ -148,14 +145,19 @@ function TableHeadColumn({
}
}
onColumnResize(id, normalizedWidth);
onColumnResize(column.key, normalizedWidth);
};
let children = (
<TableHeaderColumnContainer center>
<Text strong>{title}</Text>
{isSortable && <SortIcons direction={sorted} />}
</TableHeaderColumnContainer>
<Layout.Right center style={{padding: '0 4px'}}>
<div onClick={() => onSort(column.key)} role="button" tabIndex={0}>
<Text strong>
{column.title ?? <>&nbsp;</>}
<SortIcons direction={sorted} />
</Text>
</div>
<FilterIcon column={column} {...filterHandlers} />
</Layout.Right>
);
if (isResizable) {
@@ -171,11 +173,7 @@ function TableHeadColumn({
}
return (
<TableHeadColumnContainer
width={width}
title={title}
onClick={sortable ? () => onSort(id) : undefined}
ref={ref}>
<TableHeadColumnContainer width={column.width} ref={ref}>
{children}
</TableHeadColumnContainer>
);
@@ -193,18 +191,18 @@ export const TableHead = memo(function TableHead({
onReset: () => void;
sorting: Sorting | undefined;
onColumnSort: (key: string) => void;
}) {
} & ColumnFilterHandlers) {
const menu = (
<Menu style={{minWidth: 200}}>
{columns.map((column) => (
<Menu.Item
key={column.key}
onClick={(e) => {
e.domEvent.stopPropagation();
e.domEvent.preventDefault();
props.onColumnToggleVisibility(column.key);
}}>
<Checkbox checked={column.visible}>
<Menu.Item key={column.key}>
<Checkbox
checked={column.visible}
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
props.onColumnToggleVisibility(column.key);
}}>
{column.title || column.key}
</Checkbox>
</Menu.Item>
@@ -221,19 +219,19 @@ export const TableHead = memo(function TableHead({
{visibleColumns.map((column, i) => (
<TableHeadColumn
key={column.key}
id={column.key}
column={column}
isResizable={i < visibleColumns.length - 1}
width={column.width}
isSortable={true} // might depend in the future on for example .getValue()
sortOrder={props.sorting}
onSort={props.onColumnSort}
onColumnResize={props.onColumnResize}
onAddColumnFilter={props.onAddColumnFilter}
onRemoveColumnFilter={props.onRemoveColumnFilter}
onToggleColumnFilter={props.onToggleColumnFilter}
sorted={
props.sorting?.key === column.key
? props.sorting!.direction
: undefined
}
title={column.title}
/>
))}
<Dropdown overlay={menu} trigger={['click']}>
@@ -244,12 +242,3 @@ export const TableHead = memo(function TableHead({
</TableHeadContainer>
);
});
const SettingsButton = styled(Button)({
padding: 4,
position: 'absolute',
right: 0,
top: 0,
backgroundColor: theme.backgroundWash,
borderRadius: 0,
});

View File

@@ -11,7 +11,7 @@ import React, {memo} from 'react';
import styled from '@emotion/styled';
import {theme} from 'flipper-plugin';
import type {RenderContext} from './DataTable';
import {Width} from '../utils/widthUtils';
import {Width} from '../../utils/widthUtils';
import {pad} from 'lodash';
// heuristic for row estimation, should match any future styling updates

View File

@@ -11,7 +11,7 @@ import React, {createRef} from 'react';
import {DataTable, DataTableColumn} from '../DataTable';
import {render, act} from '@testing-library/react';
import {createDataSource} from '../../../state/datasource/DataSource';
import {TableManager} from '../useDataTableManager';
import {computeDataTableFilter, TableManager} from '../useDataTableManager';
import {Button} from 'antd';
type Todo = {
@@ -277,3 +277,226 @@ test('search', async () => {
expect(elem.length).toBe(3);
}
});
test('compute filters', () => {
const coffee = {
level: 'info',
title: 'Drink coffee',
done: true,
};
const espresso = {
level: 'info',
title: 'Make espresso',
done: false,
};
const meet = {
level: 'error',
title: 'Meet me',
done: false,
};
const data = [coffee, espresso, meet];
// results in empty filter
expect(computeDataTableFilter('', [])).toBeUndefined();
expect(
computeDataTableFilter('', [
{
key: 'title',
filters: [
{
enabled: false,
value: 'coffee',
label: 'coffee',
},
],
},
]),
).toBeUndefined();
{
const filter = computeDataTableFilter('tEsT', [])!;
expect(data.filter(filter)).toEqual([]);
}
{
const filter = computeDataTableFilter('EE', [])!;
expect(data.filter(filter)).toEqual([coffee, meet]);
}
{
const filter = computeDataTableFilter('D', [])!;
expect(data.filter(filter)).toEqual([coffee]);
}
{
const filter = computeDataTableFilter('true', [])!;
expect(data.filter(filter)).toEqual([coffee]);
}
{
const filter = computeDataTableFilter('false', [])!;
expect(data.filter(filter)).toEqual([espresso, meet]);
}
{
const filter = computeDataTableFilter('EE', [
{
key: 'level',
filters: [
{
enabled: true,
value: 'error',
label: 'error',
},
],
},
])!;
expect(data.filter(filter)).toEqual([meet]);
}
{
const filter = computeDataTableFilter('EE', [
{
key: 'level',
filters: [
{
enabled: true,
value: 'info',
label: 'info',
},
{
enabled: true,
value: 'error',
label: 'error',
},
],
},
])!;
expect(data.filter(filter)).toEqual([coffee, meet]);
}
{
const filter = computeDataTableFilter('', [
{
key: 'level',
filters: [
{
enabled: true,
value: 'info',
label: 'info',
},
{
enabled: false,
value: 'error',
label: 'error',
},
],
},
])!;
expect(data.filter(filter)).toEqual([coffee, espresso]);
}
{
const filter = computeDataTableFilter('', [
{
key: 'done',
filters: [
{
enabled: true,
value: 'false',
label: 'Not done',
},
],
},
])!;
expect(data.filter(filter)).toEqual([espresso, meet]);
}
{
// nothing selected anything will not filter anything out for that column
const filter = computeDataTableFilter('', [
{
key: 'level',
filters: [
{
enabled: false,
value: 'info',
label: 'info',
},
{
enabled: false,
value: 'error',
label: 'error',
},
],
},
])!;
expect(filter).toBeUndefined();
}
{
const filter = computeDataTableFilter('', [
{
key: 'level',
filters: [
{
enabled: true,
value: 'info',
label: 'info',
},
{
enabled: true,
value: 'error',
label: 'error',
},
],
},
])!;
expect(data.filter(filter)).toEqual([coffee, espresso, meet]);
}
{
const filter = computeDataTableFilter('', [
{
key: 'level',
filters: [
{
enabled: true,
value: 'info',
label: 'info',
},
],
},
{
key: 'done',
filters: [
{
enabled: true,
value: 'false',
label: 'false,',
},
],
},
])!;
expect(data.filter(filter)).toEqual([espresso]);
}
{
const filter = computeDataTableFilter('nonsense', [
{
key: 'level',
filters: [
{
enabled: true,
value: 'info',
label: 'info',
},
],
},
{
key: 'done',
filters: [
{
enabled: true,
value: 'false',
label: 'false,',
},
],
},
])!;
expect(data.filter(filter)).toEqual([]);
}
});

View File

@@ -8,10 +8,11 @@
*/
import {DataTableColumn} from 'flipper-plugin/src/ui/datatable/DataTable';
import {Percentage} from '../utils/widthUtils';
import {Percentage} from '../../utils/widthUtils';
import produce from 'immer';
import {useCallback, useEffect, useMemo, useState} from 'react';
import {DataSource} from '../../state/datasource/DataSource';
import {useMemoize} from '../../utils/useMemoize';
export type OnColumnResize = (id: string, size: number | Percentage) => void;
export type Sorting = {
@@ -42,29 +43,50 @@ export function useDataTableManager<T extends object>(
[columns],
);
// filter is computed by useMemo to support adding column filters etc here in the future
const currentFilter = useMemo(
function computeFilter() {
if (searchValue === '') {
// unset
return undefined;
}
const addColumnFilter = useCallback((columnId: string, value: string) => {
// TODO: fix typings
setEffectiveColumns(
produce((draft: DataTableColumn<any>[]) => {
const column = draft.find((c) => c.key === columnId)!;
column.filters!.push({
label: value,
value: value.toLowerCase(),
enabled: true,
});
}),
);
}, []);
const searchString = searchValue.toLowerCase();
return function dataTableFilter(item: object) {
return Object.values(item).some((v) =>
String(v).toLowerCase().includes(searchString),
);
};
},
[searchValue],
const removeColumnFilter = useCallback((columnId: string, index: number) => {
// TODO: fix typings
setEffectiveColumns(
produce((draft: DataTableColumn<any>[]) => {
draft.find((c) => c.key === columnId)!.filters?.splice(index, 1);
}),
);
}, []);
const toggleColumnFilter = useCallback((columnId: string, index: number) => {
// TODO: fix typings
setEffectiveColumns(
produce((draft: DataTableColumn<any>[]) => {
const f = draft.find((c) => c.key === columnId)!.filters![index];
f.enabled = !f.enabled;
}),
);
}, []);
// filter is computed by useMemo to support adding column filters etc here in the future
const currentFilter = useMemoize(
computeDataTableFilter,
[searchValue, columns], // possible optimization: we only need the column filters
);
const reset = useCallback(() => {
setEffectiveColumns(computeInitialColumns(defaultColumns));
setSorting(undefined);
setSearchValue('');
dataSource.reset();
// TODO: local storage
}, [dataSource, defaultColumns]);
const resizeColumn = useCallback((id: string, width: number | Percentage) => {
@@ -152,6 +174,10 @@ export function useDataTableManager<T extends object>(
/** current selection, describes the index index in the datasources's current output (not window) */
selection,
selectItem,
/** Changing column filters */
addColumnFilter,
removeColumnFilter,
toggleColumnFilter,
};
}
@@ -160,6 +186,45 @@ function computeInitialColumns(
): DataTableColumn<any>[] {
return columns.map((c) => ({
...c,
filters:
c.filters?.map((f) => ({
...f,
predefined: true,
})) ?? [],
visible: c.visible !== false,
}));
}
export function computeDataTableFilter(
searchValue: string,
columns: DataTableColumn[],
) {
const searchString = searchValue.toLowerCase();
// the columns with an active filter are those that have filters defined,
// with at least one enabled
const filteringColumns = columns.filter((c) =>
c.filters?.some((f) => f.enabled),
);
if (searchValue === '' && !filteringColumns.length) {
// unset
return undefined;
}
return function dataTableFilter(item: any) {
for (const column of filteringColumns) {
if (
!column.filters!.some(
(f) =>
f.enabled &&
String(item[column.key]).toLowerCase().includes(f.value),
)
) {
return false; // there are filters, but none matches
}
}
return Object.values(item).some((v) =>
String(v).toLowerCase().includes(searchString),
);
};
}

View File

@@ -0,0 +1,25 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @format
*/
import {useMemo} from 'react';
/**
* Slight variation on useMemo that encourages to create hoistable memoization functions,
* which encourages reuse and testability by no longer closing over things in the memoization function.
*
* @example
* const metroDevice = useMemoize(
* findMetroDevice,
* [connections.devices],
* );
*/
export function useMemoize<T extends any[], R>(fn: (...args: T) => R, args: T) {
// eslint-disable-next-line
return useMemo(() => fn.apply(null, args), args);
}