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:
committed by
Facebook GitHub Bot
parent
8aabce477b
commit
11eb19da4c
@@ -225,6 +225,11 @@ const demos: PreviewProps[] = [
|
||||
'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.',
|
||||
],
|
||||
[
|
||||
'gap',
|
||||
'true / number (0)',
|
||||
'Set the spacing between children. If just set, theme.space.small will be used.',
|
||||
],
|
||||
],
|
||||
demos: {
|
||||
'Layout.Top': (
|
||||
|
||||
@@ -53,7 +53,7 @@ import {getInstance} from '../fb-stubs/Logger';
|
||||
import {getUser} from '../fb-stubs/user';
|
||||
import {SandyRatingButton} from '../chrome/RatingButton';
|
||||
import {filterNotifications} from './notification/notificationUtils';
|
||||
import {useMemoize} from '../utils/useMemoize';
|
||||
import {useMemoize} from 'flipper-plugin';
|
||||
import isProduction from '../utils/isProduction';
|
||||
import NetworkGraph from '../chrome/NetworkGraph';
|
||||
import FpsGraph from '../chrome/FpsGraph';
|
||||
|
||||
@@ -11,14 +11,13 @@ import React from 'react';
|
||||
import {Typography} from 'antd';
|
||||
import {LeftSidebar, SidebarTitle, InfoIcon} from '../LeftSidebar';
|
||||
import {Layout, Link, styled} from '../../ui';
|
||||
import {theme, useValue} from 'flipper-plugin';
|
||||
import {theme, useValue, useMemoize} from 'flipper-plugin';
|
||||
import {AppSelector} from './AppSelector';
|
||||
import {useStore} from '../../utils/useStore';
|
||||
import {PluginList} from './PluginList';
|
||||
import ScreenCaptureButtons from '../../chrome/ScreenCaptureButtons';
|
||||
import MetroButton from '../../chrome/MetroButton';
|
||||
import {BookmarkSection} from './BookmarkSection';
|
||||
import {useMemoize} from '../../utils/useMemoize';
|
||||
import Client from '../../Client';
|
||||
import {State} from '../../reducers';
|
||||
import BaseDevice from '../../devices/BaseDevice';
|
||||
|
||||
@@ -22,7 +22,7 @@ import {State} from '../../reducers';
|
||||
|
||||
// eslint-disable-next-line flipper/no-relative-imports-across-packages
|
||||
import type {NavigationPlugin} from '../../../../plugins/navigation/index';
|
||||
import {useMemoize} from '../../utils/useMemoize';
|
||||
import {useMemoize} from 'flipper-plugin';
|
||||
import styled from '@emotion/styled';
|
||||
|
||||
const {Text} = Typography;
|
||||
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
DownloadOutlined,
|
||||
} from '@ant-design/icons';
|
||||
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 {
|
||||
computePluginLists,
|
||||
@@ -29,7 +29,6 @@ import {selectPlugin} from '../../reducers/connections';
|
||||
import Client from '../../Client';
|
||||
import BaseDevice from '../../devices/BaseDevice';
|
||||
import {DownloadablePluginDetails} from 'flipper-plugin-lib';
|
||||
import {useMemoize} from '../../utils/useMemoize';
|
||||
import MetroDevice from '../../devices/MetroDevice';
|
||||
import {
|
||||
DownloadablePluginState,
|
||||
|
||||
@@ -31,7 +31,7 @@ import {
|
||||
updatePluginBlocklist,
|
||||
} from '../../reducers/notifications';
|
||||
import {filterNotifications} from './notificationUtils';
|
||||
import {useMemoize} from '../../utils/useMemoize';
|
||||
import {useMemoize} from 'flipper-plugin';
|
||||
import BlocklistSettingButton from './BlocklistSettingButton';
|
||||
|
||||
type NotificationExtra = {
|
||||
|
||||
@@ -44,6 +44,7 @@ test('Correct top level API exposed', () => {
|
||||
"styled",
|
||||
"theme",
|
||||
"useLogger",
|
||||
"useMemoize",
|
||||
"usePlugin",
|
||||
"useTrackedCallback",
|
||||
"useValue",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
* @format
|
||||
*/
|
||||
|
||||
import LowPassFilter from '../utils/LowPassFilter';
|
||||
import LowPassFilter from '../../utils/LowPassFilter';
|
||||
|
||||
test('hasFullBuffer', () => {
|
||||
const lpf = new LowPassFilter();
|
||||
|
||||
116
desktop/flipper-plugin/src/ui/datatable/ColumnFilter.tsx
Normal file
116
desktop/flipper-plugin/src/ui/datatable/ColumnFilter.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>>
|
||||
|
||||
@@ -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 ?? <> </>}
|
||||
<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,
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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([]);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
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
|
||||
|
||||
### Layout.*
|
||||
|
||||
Reference in New Issue
Block a user