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

@@ -225,6 +225,11 @@ const demos: PreviewProps[] = [
'boolean (false)', 'boolean (false)',
'If set, all children will use their own height, and they will be centered vertically in the layout. If not set, all children will be stretched to the height of the layout.', 'If set, all children will use their own height, and they will be centered vertically in the layout. If not set, all children will be stretched to the height of the layout.',
], ],
[
'gap',
'true / number (0)',
'Set the spacing between children. If just set, theme.space.small will be used.',
],
], ],
demos: { demos: {
'Layout.Top': ( 'Layout.Top': (

View File

@@ -53,7 +53,7 @@ import {getInstance} from '../fb-stubs/Logger';
import {getUser} from '../fb-stubs/user'; import {getUser} from '../fb-stubs/user';
import {SandyRatingButton} from '../chrome/RatingButton'; import {SandyRatingButton} from '../chrome/RatingButton';
import {filterNotifications} from './notification/notificationUtils'; import {filterNotifications} from './notification/notificationUtils';
import {useMemoize} from '../utils/useMemoize'; import {useMemoize} from 'flipper-plugin';
import isProduction from '../utils/isProduction'; import isProduction from '../utils/isProduction';
import NetworkGraph from '../chrome/NetworkGraph'; import NetworkGraph from '../chrome/NetworkGraph';
import FpsGraph from '../chrome/FpsGraph'; import FpsGraph from '../chrome/FpsGraph';

View File

@@ -11,14 +11,13 @@ import React from 'react';
import {Typography} from 'antd'; import {Typography} from 'antd';
import {LeftSidebar, SidebarTitle, InfoIcon} from '../LeftSidebar'; import {LeftSidebar, SidebarTitle, InfoIcon} from '../LeftSidebar';
import {Layout, Link, styled} from '../../ui'; import {Layout, Link, styled} from '../../ui';
import {theme, useValue} from 'flipper-plugin'; import {theme, useValue, useMemoize} from 'flipper-plugin';
import {AppSelector} from './AppSelector'; import {AppSelector} from './AppSelector';
import {useStore} from '../../utils/useStore'; import {useStore} from '../../utils/useStore';
import {PluginList} from './PluginList'; import {PluginList} from './PluginList';
import ScreenCaptureButtons from '../../chrome/ScreenCaptureButtons'; import ScreenCaptureButtons from '../../chrome/ScreenCaptureButtons';
import MetroButton from '../../chrome/MetroButton'; import MetroButton from '../../chrome/MetroButton';
import {BookmarkSection} from './BookmarkSection'; import {BookmarkSection} from './BookmarkSection';
import {useMemoize} from '../../utils/useMemoize';
import Client from '../../Client'; import Client from '../../Client';
import {State} from '../../reducers'; import {State} from '../../reducers';
import BaseDevice from '../../devices/BaseDevice'; import BaseDevice from '../../devices/BaseDevice';

View File

@@ -22,7 +22,7 @@ import {State} from '../../reducers';
// eslint-disable-next-line flipper/no-relative-imports-across-packages // eslint-disable-next-line flipper/no-relative-imports-across-packages
import type {NavigationPlugin} from '../../../../plugins/navigation/index'; import type {NavigationPlugin} from '../../../../plugins/navigation/index';
import {useMemoize} from '../../utils/useMemoize'; import {useMemoize} from 'flipper-plugin';
import styled from '@emotion/styled'; import styled from '@emotion/styled';
const {Text} = Typography; const {Text} = Typography;

View File

@@ -18,7 +18,7 @@ import {
DownloadOutlined, DownloadOutlined,
} from '@ant-design/icons'; } from '@ant-design/icons';
import {Glyph, Layout, styled} from '../../ui'; import {Glyph, Layout, styled} from '../../ui';
import {theme, NUX, Tracked, useValue} from 'flipper-plugin'; import {theme, NUX, Tracked, useValue, useMemoize} from 'flipper-plugin';
import {useDispatch, useStore} from '../../utils/useStore'; import {useDispatch, useStore} from '../../utils/useStore';
import { import {
computePluginLists, computePluginLists,
@@ -29,7 +29,6 @@ import {selectPlugin} from '../../reducers/connections';
import Client from '../../Client'; import Client from '../../Client';
import BaseDevice from '../../devices/BaseDevice'; import BaseDevice from '../../devices/BaseDevice';
import {DownloadablePluginDetails} from 'flipper-plugin-lib'; import {DownloadablePluginDetails} from 'flipper-plugin-lib';
import {useMemoize} from '../../utils/useMemoize';
import MetroDevice from '../../devices/MetroDevice'; import MetroDevice from '../../devices/MetroDevice';
import { import {
DownloadablePluginState, DownloadablePluginState,

View File

@@ -31,7 +31,7 @@ import {
updatePluginBlocklist, updatePluginBlocklist,
} from '../../reducers/notifications'; } from '../../reducers/notifications';
import {filterNotifications} from './notificationUtils'; import {filterNotifications} from './notificationUtils';
import {useMemoize} from '../../utils/useMemoize'; import {useMemoize} from 'flipper-plugin';
import BlocklistSettingButton from './BlocklistSettingButton'; import BlocklistSettingButton from './BlocklistSettingButton';
type NotificationExtra = { type NotificationExtra = {

View File

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

View File

@@ -80,6 +80,8 @@ export {
InteractiveProps as _InteractiveProps, InteractiveProps as _InteractiveProps,
} from './ui/Interactive'; } from './ui/Interactive';
export {useMemoize} from './utils/useMemoize';
// It's not ideal that this exists in flipper-plugin sources directly, // It's not ideal that this exists in flipper-plugin sources directly,
// but is the least pain for plugin authors. // 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) // 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 styled from '@emotion/styled';
import React from 'react'; import React from 'react';
import LowPassFilter from './utils/LowPassFilter'; import LowPassFilter from '../utils/LowPassFilter';
import { import {
getDistanceTo, getDistanceTo,
maybeSnapLeft, maybeSnapLeft,
maybeSnapTop, maybeSnapTop,
SNAP_SIZE, SNAP_SIZE,
} from './utils/snap'; } from '../utils/snap';
import type {Rect} from './utils/Rect'; import type {Rect} from '../utils/Rect';
const WINDOW_CURSOR_BOUNDARY = 5; 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. * If set, items will be centered over the orthogonal direction, if false (the default) items will be stretched.
*/ */
center?: boolean; center?: boolean;
gap?: Spacing;
children: [React.ReactNode, React.ReactNode]; children: [React.ReactNode, React.ReactNode];
style?: React.HTMLAttributes<HTMLDivElement>['style']; style?: React.HTMLAttributes<HTMLDivElement>['style'];
}; };
@@ -191,6 +192,7 @@ Object.keys(Layout).forEach((key) => {
const SandySplitContainer = styled.div<{ const SandySplitContainer = styled.div<{
grow: 1 | 2; grow: 1 | 2;
gap?: Spacing;
center?: boolean; center?: boolean;
flexDirection: CSSProperties['flexDirection']; flexDirection: CSSProperties['flexDirection'];
}>((props) => ({ }>((props) => ({
@@ -199,6 +201,7 @@ const SandySplitContainer = styled.div<{
flex: 1, flex: 1,
flexDirection: props.flexDirection, flexDirection: props.flexDirection,
alignItems: props.center ? 'center' : 'stretch', 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 overflow: props.center ? undefined : 'hidden', // only use overflow hidden in container mode, to avoid weird resizing issues
'> :nth-child(1)': { '> :nth-child(1)': {
flex: props.grow === 1 ? splitGrowStyle : splitFixedStyle, flex: props.grow === 1 ? splitGrowStyle : splitFixedStyle,

View File

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

View File

@@ -12,7 +12,7 @@ import {
isPercentage, isPercentage,
Percentage, Percentage,
Width, Width,
} from '../utils/widthUtils'; } from '../../utils/widthUtils';
import {memo, useRef} from 'react'; import {memo, useRef} from 'react';
import {Interactive, InteractiveProps} from '../Interactive'; import {Interactive, InteractiveProps} from '../Interactive';
import styled from '@emotion/styled'; import styled from '@emotion/styled';
@@ -20,18 +20,14 @@ import React from 'react';
import {theme} from '../theme'; import {theme} from '../theme';
import type {DataTableColumn} from './DataTable'; 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 {CaretDownFilled, CaretUpFilled, DownOutlined} from '@ant-design/icons';
import {Layout} from '../Layout'; import {Layout} from '../Layout';
import {Sorting, OnColumnResize} from './useDataTableManager'; import {Sorting, OnColumnResize} from './useDataTableManager';
import {ColumnFilterHandlers, FilterIcon, HeaderButton} from './ColumnFilter';
const {Text} = Typography; const {Text} = Typography;
const TableHeaderArrow = styled.span({
float: 'right',
});
TableHeaderArrow.displayName = 'TableHead:TableHeaderArrow';
function SortIcons({direction}: {direction?: 'up' | 'down'}) { function SortIcons({direction}: {direction?: 'up' | 'down'}) {
return ( return (
<SortIconsContainer direction={direction}> <SortIconsContainer direction={direction}>
@@ -56,11 +52,19 @@ const SortIconsContainer = styled.span<{direction?: 'up' | 'down'}>(
display: 'inline-flex', display: 'inline-flex',
flexDirection: 'column', flexDirection: 'column',
alignItems: 'center', alignItems: 'center',
marginLeft: 4, position: 'relative',
left: 4,
top: -3,
color: theme.disabledColor, color: theme.disabledColor,
}), }),
); );
const SettingsButton = styled(HeaderButton)({
position: 'absolute',
right: 0,
top: 0,
});
const TableHeaderColumnInteractive = styled(Interactive)<InteractiveProps>({ const TableHeaderColumnInteractive = styled(Interactive)<InteractiveProps>({
overflow: 'hidden', overflow: 'hidden',
whiteSpace: 'nowrap', whiteSpace: 'nowrap',
@@ -69,17 +73,6 @@ const TableHeaderColumnInteractive = styled(Interactive)<InteractiveProps>({
TableHeaderColumnInteractive.displayName = TableHeaderColumnInteractive.displayName =
'TableHead:TableHeaderColumnInteractive'; '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}>({ const TableHeadContainer = styled.div<{horizontallyScrollable?: boolean}>({
position: 'relative', position: 'relative',
display: 'flex', display: 'flex',
@@ -96,32 +89,36 @@ const TableHeadColumnContainer = styled.div<{
flexShrink: props.width === undefined ? 1 : 0, flexShrink: props.width === undefined ? 1 : 0,
flexGrow: props.width === undefined ? 1 : 0, flexGrow: props.width === undefined ? 1 : 0,
width: props.width === undefined ? '100%' : props.width, 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'; TableHeadColumnContainer.displayName = 'TableHead:TableHeadColumnContainer';
const RIGHT_RESIZABLE = {right: true}; const RIGHT_RESIZABLE = {right: true};
function TableHeadColumn({ function TableHeadColumn({
id, column,
title,
width,
isResizable, isResizable,
isSortable,
onColumnResize, onColumnResize,
isSortable: sortable,
onSort, onSort,
sorted, sorted,
...filterHandlers
}: { }: {
id: string; column: DataTableColumn<any>;
width: Width;
isSortable?: boolean;
sorted: 'up' | 'down' | undefined; sorted: 'up' | 'down' | undefined;
isResizable: boolean; isResizable: boolean;
onSort: (id: string) => void; onSort: (id: string) => void;
sortOrder: undefined | Sorting; sortOrder: undefined | Sorting;
onColumnResize: OnColumnResize; onColumnResize: OnColumnResize;
title?: string; } & ColumnFilterHandlers) {
}) {
const ref = useRef<HTMLDivElement | null>(null); const ref = useRef<HTMLDivElement | null>(null);
const onResize = (newWidth: number) => { const onResize = (newWidth: number) => {
@@ -132,7 +129,7 @@ function TableHeadColumn({
let normalizedWidth: number | Percentage = newWidth; let normalizedWidth: number | Percentage = newWidth;
// normalise number to a percentage if we were originally passed a percentage // 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 {parentElement} = ref.current;
const parentWidth = parentElement!.clientWidth; const parentWidth = parentElement!.clientWidth;
const {childNodes} = parentElement!; const {childNodes} = parentElement!;
@@ -148,14 +145,19 @@ function TableHeadColumn({
} }
} }
onColumnResize(id, normalizedWidth); onColumnResize(column.key, normalizedWidth);
}; };
let children = ( let children = (
<TableHeaderColumnContainer center> <Layout.Right center style={{padding: '0 4px'}}>
<Text strong>{title}</Text> <div onClick={() => onSort(column.key)} role="button" tabIndex={0}>
{isSortable && <SortIcons direction={sorted} />} <Text strong>
</TableHeaderColumnContainer> {column.title ?? <>&nbsp;</>}
<SortIcons direction={sorted} />
</Text>
</div>
<FilterIcon column={column} {...filterHandlers} />
</Layout.Right>
); );
if (isResizable) { if (isResizable) {
@@ -171,11 +173,7 @@ function TableHeadColumn({
} }
return ( return (
<TableHeadColumnContainer <TableHeadColumnContainer width={column.width} ref={ref}>
width={width}
title={title}
onClick={sortable ? () => onSort(id) : undefined}
ref={ref}>
{children} {children}
</TableHeadColumnContainer> </TableHeadColumnContainer>
); );
@@ -193,18 +191,18 @@ export const TableHead = memo(function TableHead({
onReset: () => void; onReset: () => void;
sorting: Sorting | undefined; sorting: Sorting | undefined;
onColumnSort: (key: string) => void; onColumnSort: (key: string) => void;
}) { } & ColumnFilterHandlers) {
const menu = ( const menu = (
<Menu style={{minWidth: 200}}> <Menu style={{minWidth: 200}}>
{columns.map((column) => ( {columns.map((column) => (
<Menu.Item <Menu.Item key={column.key}>
key={column.key} <Checkbox
checked={column.visible}
onClick={(e) => { onClick={(e) => {
e.domEvent.stopPropagation(); e.stopPropagation();
e.domEvent.preventDefault(); e.preventDefault();
props.onColumnToggleVisibility(column.key); props.onColumnToggleVisibility(column.key);
}}> }}>
<Checkbox checked={column.visible}>
{column.title || column.key} {column.title || column.key}
</Checkbox> </Checkbox>
</Menu.Item> </Menu.Item>
@@ -221,19 +219,19 @@ export const TableHead = memo(function TableHead({
{visibleColumns.map((column, i) => ( {visibleColumns.map((column, i) => (
<TableHeadColumn <TableHeadColumn
key={column.key} key={column.key}
id={column.key} column={column}
isResizable={i < visibleColumns.length - 1} isResizable={i < visibleColumns.length - 1}
width={column.width}
isSortable={true} // might depend in the future on for example .getValue()
sortOrder={props.sorting} sortOrder={props.sorting}
onSort={props.onColumnSort} onSort={props.onColumnSort}
onColumnResize={props.onColumnResize} onColumnResize={props.onColumnResize}
onAddColumnFilter={props.onAddColumnFilter}
onRemoveColumnFilter={props.onRemoveColumnFilter}
onToggleColumnFilter={props.onToggleColumnFilter}
sorted={ sorted={
props.sorting?.key === column.key props.sorting?.key === column.key
? props.sorting!.direction ? props.sorting!.direction
: undefined : undefined
} }
title={column.title}
/> />
))} ))}
<Dropdown overlay={menu} trigger={['click']}> <Dropdown overlay={menu} trigger={['click']}>
@@ -244,12 +242,3 @@ export const TableHead = memo(function TableHead({
</TableHeadContainer> </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 styled from '@emotion/styled';
import {theme} from 'flipper-plugin'; import {theme} from 'flipper-plugin';
import type {RenderContext} from './DataTable'; import type {RenderContext} from './DataTable';
import {Width} from '../utils/widthUtils'; import {Width} from '../../utils/widthUtils';
import {pad} from 'lodash'; import {pad} from 'lodash';
// heuristic for row estimation, should match any future styling updates // 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 {DataTable, DataTableColumn} from '../DataTable';
import {render, act} from '@testing-library/react'; import {render, act} from '@testing-library/react';
import {createDataSource} from '../../../state/datasource/DataSource'; import {createDataSource} from '../../../state/datasource/DataSource';
import {TableManager} from '../useDataTableManager'; import {computeDataTableFilter, TableManager} from '../useDataTableManager';
import {Button} from 'antd'; import {Button} from 'antd';
type Todo = { type Todo = {
@@ -277,3 +277,226 @@ test('search', async () => {
expect(elem.length).toBe(3); 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 {DataTableColumn} from 'flipper-plugin/src/ui/datatable/DataTable';
import {Percentage} from '../utils/widthUtils'; import {Percentage} from '../../utils/widthUtils';
import produce from 'immer'; import produce from 'immer';
import {useCallback, useEffect, useMemo, useState} from 'react'; import {useCallback, useEffect, useMemo, useState} from 'react';
import {DataSource} from '../../state/datasource/DataSource'; import {DataSource} from '../../state/datasource/DataSource';
import {useMemoize} from '../../utils/useMemoize';
export type OnColumnResize = (id: string, size: number | Percentage) => void; export type OnColumnResize = (id: string, size: number | Percentage) => void;
export type Sorting = { export type Sorting = {
@@ -42,29 +43,50 @@ export function useDataTableManager<T extends object>(
[columns], [columns],
); );
// filter is computed by useMemo to support adding column filters etc here in the future const addColumnFilter = useCallback((columnId: string, value: string) => {
const currentFilter = useMemo( // TODO: fix typings
function computeFilter() { setEffectiveColumns(
if (searchValue === '') { produce((draft: DataTableColumn<any>[]) => {
// unset const column = draft.find((c) => c.key === columnId)!;
return undefined; column.filters!.push({
} label: value,
value: value.toLowerCase(),
const searchString = searchValue.toLowerCase(); enabled: true,
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(() => { const reset = useCallback(() => {
setEffectiveColumns(computeInitialColumns(defaultColumns)); setEffectiveColumns(computeInitialColumns(defaultColumns));
setSorting(undefined); setSorting(undefined);
setSearchValue('');
dataSource.reset(); dataSource.reset();
// TODO: local storage
}, [dataSource, defaultColumns]); }, [dataSource, defaultColumns]);
const resizeColumn = useCallback((id: string, width: number | Percentage) => { 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) */ /** current selection, describes the index index in the datasources's current output (not window) */
selection, selection,
selectItem, selectItem,
/** Changing column filters */
addColumnFilter,
removeColumnFilter,
toggleColumnFilter,
}; };
} }
@@ -160,6 +186,45 @@ function computeInitialColumns(
): DataTableColumn<any>[] { ): DataTableColumn<any>[] {
return columns.map((c) => ({ return columns.map((c) => ({
...c, ...c,
filters:
c.filters?.map((f) => ({
...f,
predefined: true,
})) ?? [],
visible: c.visible !== false, 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

@@ -494,6 +494,30 @@ Utility that wraps React's `useCallback` with tracking capabilities.
The API is similar, except that the first argument describes the interaction handled by the given event handler. The API is similar, except that the first argument describes the interaction handled by the given event handler.
See [Tracked](#tracked) for more info. See [Tracked](#tracked) for more info.
### useMemoize
Slight variation on useMemo that encourages to create hoistable memoization functions,
which encourages reuse and testability by no longer closing over variables that are used by the memoized function, but rather receiving them as arguments so that these functions beome pure.
```javascript
function MyComponent() {
const {findMetroDevice} = props;
const connections = useSomeHook();
const metroDevice = useMemoize(
findMetroDevice,
[connections.devices],
);
// etc
}
export function findMetroDevice(findMetroDevice, deviceList) {
return deviceList.find(findMetroDevice);
}
```
## UI components ## UI components
### Layout.* ### Layout.*