From 11eb19da4cc95b147a7a103b0eb79352f23b18b6 Mon Sep 17 00:00:00 2001 From: Michel Weststrate Date: Tue, 16 Mar 2021 14:54:53 -0700 Subject: [PATCH] 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 --- .../src/sandy-chrome/DesignComponentDemos.tsx | 5 + desktop/app/src/sandy-chrome/LeftRail.tsx | 2 +- .../sandy-chrome/appinspect/AppInspect.tsx | 3 +- .../appinspect/BookmarkSection.tsx | 2 +- .../sandy-chrome/appinspect/PluginList.tsx | 3 +- .../notification/Notification.tsx | 2 +- .../flipper-plugin/src/__tests__/api.node.tsx | 1 + desktop/flipper-plugin/src/index.ts | 2 + desktop/flipper-plugin/src/ui/Interactive.tsx | 6 +- desktop/flipper-plugin/src/ui/Layout.tsx | 3 + .../src/ui/__tests__/LowPassFilter.node.tsx | 2 +- .../src/ui/datatable/ColumnFilter.tsx | 116 +++++++++ .../src/ui/datatable/DataTable.tsx | 12 +- .../src/ui/datatable/TableHead.tsx | 113 ++++----- .../src/ui/datatable/TableRow.tsx | 2 +- .../ui/datatable/__tests__/DataTable.node.tsx | 225 +++++++++++++++++- .../src/ui/datatable/useDataTableManager.tsx | 99 ++++++-- .../src/{ui => }/utils/LowPassFilter.tsx | 0 .../src/{ui => }/utils/Rect.tsx | 0 .../src/{ui => }/utils/snap.tsx | 0 .../src/utils/useMemoize.tsx | 0 .../src/{ui => }/utils/widthUtils.tsx | 0 docs/extending/flipper-plugin.mdx | 24 ++ 23 files changed, 529 insertions(+), 93 deletions(-) create mode 100644 desktop/flipper-plugin/src/ui/datatable/ColumnFilter.tsx rename desktop/flipper-plugin/src/{ui => }/utils/LowPassFilter.tsx (100%) rename desktop/flipper-plugin/src/{ui => }/utils/Rect.tsx (100%) rename desktop/flipper-plugin/src/{ui => }/utils/snap.tsx (100%) rename desktop/{app => flipper-plugin}/src/utils/useMemoize.tsx (100%) rename desktop/flipper-plugin/src/{ui => }/utils/widthUtils.tsx (100%) diff --git a/desktop/app/src/sandy-chrome/DesignComponentDemos.tsx b/desktop/app/src/sandy-chrome/DesignComponentDemos.tsx index 23bce3f21..565082d51 100644 --- a/desktop/app/src/sandy-chrome/DesignComponentDemos.tsx +++ b/desktop/app/src/sandy-chrome/DesignComponentDemos.tsx @@ -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': ( diff --git a/desktop/app/src/sandy-chrome/LeftRail.tsx b/desktop/app/src/sandy-chrome/LeftRail.tsx index d73731df9..77f2454d9 100644 --- a/desktop/app/src/sandy-chrome/LeftRail.tsx +++ b/desktop/app/src/sandy-chrome/LeftRail.tsx @@ -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'; diff --git a/desktop/app/src/sandy-chrome/appinspect/AppInspect.tsx b/desktop/app/src/sandy-chrome/appinspect/AppInspect.tsx index d3167551b..434ecf965 100644 --- a/desktop/app/src/sandy-chrome/appinspect/AppInspect.tsx +++ b/desktop/app/src/sandy-chrome/appinspect/AppInspect.tsx @@ -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'; diff --git a/desktop/app/src/sandy-chrome/appinspect/BookmarkSection.tsx b/desktop/app/src/sandy-chrome/appinspect/BookmarkSection.tsx index 4a172756a..1d158a988 100644 --- a/desktop/app/src/sandy-chrome/appinspect/BookmarkSection.tsx +++ b/desktop/app/src/sandy-chrome/appinspect/BookmarkSection.tsx @@ -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; diff --git a/desktop/app/src/sandy-chrome/appinspect/PluginList.tsx b/desktop/app/src/sandy-chrome/appinspect/PluginList.tsx index 1acec1632..7114905c7 100644 --- a/desktop/app/src/sandy-chrome/appinspect/PluginList.tsx +++ b/desktop/app/src/sandy-chrome/appinspect/PluginList.tsx @@ -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, diff --git a/desktop/app/src/sandy-chrome/notification/Notification.tsx b/desktop/app/src/sandy-chrome/notification/Notification.tsx index b84bc8ae3..6c1d9c9c8 100644 --- a/desktop/app/src/sandy-chrome/notification/Notification.tsx +++ b/desktop/app/src/sandy-chrome/notification/Notification.tsx @@ -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 = { diff --git a/desktop/flipper-plugin/src/__tests__/api.node.tsx b/desktop/flipper-plugin/src/__tests__/api.node.tsx index 0309e212d..59dc0f10b 100644 --- a/desktop/flipper-plugin/src/__tests__/api.node.tsx +++ b/desktop/flipper-plugin/src/__tests__/api.node.tsx @@ -44,6 +44,7 @@ test('Correct top level API exposed', () => { "styled", "theme", "useLogger", + "useMemoize", "usePlugin", "useTrackedCallback", "useValue", diff --git a/desktop/flipper-plugin/src/index.ts b/desktop/flipper-plugin/src/index.ts index f42ec4cb8..6a7bb1957 100644 --- a/desktop/flipper-plugin/src/index.ts +++ b/desktop/flipper-plugin/src/index.ts @@ -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) diff --git a/desktop/flipper-plugin/src/ui/Interactive.tsx b/desktop/flipper-plugin/src/ui/Interactive.tsx index 78698cebf..e4660f909 100644 --- a/desktop/flipper-plugin/src/ui/Interactive.tsx +++ b/desktop/flipper-plugin/src/ui/Interactive.tsx @@ -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; diff --git a/desktop/flipper-plugin/src/ui/Layout.tsx b/desktop/flipper-plugin/src/ui/Layout.tsx index 1b82e0982..f2d43b7c4 100644 --- a/desktop/flipper-plugin/src/ui/Layout.tsx +++ b/desktop/flipper-plugin/src/ui/Layout.tsx @@ -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['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, diff --git a/desktop/flipper-plugin/src/ui/__tests__/LowPassFilter.node.tsx b/desktop/flipper-plugin/src/ui/__tests__/LowPassFilter.node.tsx index a897b3fe6..80476e13a 100644 --- a/desktop/flipper-plugin/src/ui/__tests__/LowPassFilter.node.tsx +++ b/desktop/flipper-plugin/src/ui/__tests__/LowPassFilter.node.tsx @@ -7,7 +7,7 @@ * @format */ -import LowPassFilter from '../utils/LowPassFilter'; +import LowPassFilter from '../../utils/LowPassFilter'; test('hasFullBuffer', () => { const lpf = new LowPassFilter(); diff --git a/desktop/flipper-plugin/src/ui/datatable/ColumnFilter.tsx b/desktop/flipper-plugin/src/ui/datatable/ColumnFilter.tsx new file mode 100644 index 000000000..282832df7 --- /dev/null +++ b/desktop/flipper-plugin/src/ui/datatable/ColumnFilter.tsx @@ -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} & 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 = ( + { + e.stopPropagation(); // prevents interaction accidentally with the Interactive component organizing resizng + }}> + + + { + e.stopPropagation(); + setInput(e.target.value); + }} + onClick={(e) => { + e.stopPropagation(); + }} + onPressEnter={onAddFilter} + disabled={false} + /> + + + + + {filters?.length ? ( + filters?.map((filter, index) => ( + + + { + e.stopPropagation(); + e.preventDefault(); + props.onToggleColumnFilter(column.key, index); + }}> + {filter.label} + + {!filter.predefined && ( + { + e.stopPropagation(); + props.onRemoveColumnFilter(column.key, index); + }} + /> + )} + + + )) + ) : ( + + No active filters + + )} + + ); + + return ( + + + + + + ); +} diff --git a/desktop/flipper-plugin/src/ui/datatable/DataTable.tsx b/desktop/flipper-plugin/src/ui/datatable/DataTable.tsx index cc9bd116b..507c527ff 100644 --- a/desktop/flipper-plugin/src/ui/datatable/DataTable.tsx +++ b/desktop/flipper-plugin/src/ui/datatable/DataTable.tsx @@ -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 = { wrap?: boolean; align?: 'left' | 'right' | 'center'; visible?: boolean; + filters?: { + label: string; + value: string; + enabled: boolean; + predefined?: boolean; + }[]; }; export interface RenderContext { @@ -129,6 +135,7 @@ export function DataTable(props: DataTableProps) { 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(props: DataTableProps) { onColumnToggleVisibility={tableManager.toggleColumnVisibility} sorting={tableManager.sorting} onColumnSort={tableManager.sortColumn} + onAddColumnFilter={tableManager.addColumnFilter} + onRemoveColumnFilter={tableManager.removeColumnFilter} + onToggleColumnFilter={tableManager.toggleColumnFilter} /> > diff --git a/desktop/flipper-plugin/src/ui/datatable/TableHead.tsx b/desktop/flipper-plugin/src/ui/datatable/TableHead.tsx index 33cd95fbc..08732b628 100644 --- a/desktop/flipper-plugin/src/ui/datatable/TableHead.tsx +++ b/desktop/flipper-plugin/src/ui/datatable/TableHead.tsx @@ -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 ( @@ -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)({ overflow: 'hidden', whiteSpace: 'nowrap', @@ -69,17 +73,6 @@ const TableHeaderColumnInteractive = styled(Interactive)({ 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; sorted: 'up' | 'down' | undefined; isResizable: boolean; onSort: (id: string) => void; sortOrder: undefined | Sorting; onColumnResize: OnColumnResize; - title?: string; -}) { +} & ColumnFilterHandlers) { const ref = useRef(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 = ( - - {title} - {isSortable && } - + +
onSort(column.key)} role="button" tabIndex={0}> + + {column.title ?? <> } + + +
+ +
); if (isResizable) { @@ -171,11 +173,7 @@ function TableHeadColumn({ } return ( - onSort(id) : undefined} - ref={ref}> + {children} ); @@ -193,18 +191,18 @@ export const TableHead = memo(function TableHead({ onReset: () => void; sorting: Sorting | undefined; onColumnSort: (key: string) => void; -}) { +} & ColumnFilterHandlers) { const menu = ( {columns.map((column) => ( - { - e.domEvent.stopPropagation(); - e.domEvent.preventDefault(); - props.onColumnToggleVisibility(column.key); - }}> - + + { + e.stopPropagation(); + e.preventDefault(); + props.onColumnToggleVisibility(column.key); + }}> {column.title || column.key} @@ -221,19 +219,19 @@ export const TableHead = memo(function TableHead({ {visibleColumns.map((column, i) => ( ))} @@ -244,12 +242,3 @@ export const TableHead = memo(function TableHead({ ); }); - -const SettingsButton = styled(Button)({ - padding: 4, - position: 'absolute', - right: 0, - top: 0, - backgroundColor: theme.backgroundWash, - borderRadius: 0, -}); diff --git a/desktop/flipper-plugin/src/ui/datatable/TableRow.tsx b/desktop/flipper-plugin/src/ui/datatable/TableRow.tsx index f2326f762..a93b0eed7 100644 --- a/desktop/flipper-plugin/src/ui/datatable/TableRow.tsx +++ b/desktop/flipper-plugin/src/ui/datatable/TableRow.tsx @@ -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 diff --git a/desktop/flipper-plugin/src/ui/datatable/__tests__/DataTable.node.tsx b/desktop/flipper-plugin/src/ui/datatable/__tests__/DataTable.node.tsx index 72aa5afc9..112dc560e 100644 --- a/desktop/flipper-plugin/src/ui/datatable/__tests__/DataTable.node.tsx +++ b/desktop/flipper-plugin/src/ui/datatable/__tests__/DataTable.node.tsx @@ -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([]); + } +}); diff --git a/desktop/flipper-plugin/src/ui/datatable/useDataTableManager.tsx b/desktop/flipper-plugin/src/ui/datatable/useDataTableManager.tsx index d40b62070..1b017fd33 100644 --- a/desktop/flipper-plugin/src/ui/datatable/useDataTableManager.tsx +++ b/desktop/flipper-plugin/src/ui/datatable/useDataTableManager.tsx @@ -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( [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[]) => { + 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[]) => { + draft.find((c) => c.key === columnId)!.filters?.splice(index, 1); + }), + ); + }, []); + + const toggleColumnFilter = useCallback((columnId: string, index: number) => { + // TODO: fix typings + setEffectiveColumns( + produce((draft: DataTableColumn[]) => { + 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( /** 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[] { 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), + ); + }; +} diff --git a/desktop/flipper-plugin/src/ui/utils/LowPassFilter.tsx b/desktop/flipper-plugin/src/utils/LowPassFilter.tsx similarity index 100% rename from desktop/flipper-plugin/src/ui/utils/LowPassFilter.tsx rename to desktop/flipper-plugin/src/utils/LowPassFilter.tsx diff --git a/desktop/flipper-plugin/src/ui/utils/Rect.tsx b/desktop/flipper-plugin/src/utils/Rect.tsx similarity index 100% rename from desktop/flipper-plugin/src/ui/utils/Rect.tsx rename to desktop/flipper-plugin/src/utils/Rect.tsx diff --git a/desktop/flipper-plugin/src/ui/utils/snap.tsx b/desktop/flipper-plugin/src/utils/snap.tsx similarity index 100% rename from desktop/flipper-plugin/src/ui/utils/snap.tsx rename to desktop/flipper-plugin/src/utils/snap.tsx diff --git a/desktop/app/src/utils/useMemoize.tsx b/desktop/flipper-plugin/src/utils/useMemoize.tsx similarity index 100% rename from desktop/app/src/utils/useMemoize.tsx rename to desktop/flipper-plugin/src/utils/useMemoize.tsx diff --git a/desktop/flipper-plugin/src/ui/utils/widthUtils.tsx b/desktop/flipper-plugin/src/utils/widthUtils.tsx similarity index 100% rename from desktop/flipper-plugin/src/ui/utils/widthUtils.tsx rename to desktop/flipper-plugin/src/utils/widthUtils.tsx diff --git a/docs/extending/flipper-plugin.mdx b/docs/extending/flipper-plugin.mdx index 2a73d5515..334033830 100644 --- a/docs/extending/flipper-plugin.mdx +++ b/docs/extending/flipper-plugin.mdx @@ -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.*