diff --git a/desktop/flipper-plugin/src/ui/datatable/DataTable.tsx b/desktop/flipper-plugin/src/ui/datatable/DataTable.tsx index 6d13176b3..272013e06 100644 --- a/desktop/flipper-plugin/src/ui/datatable/DataTable.tsx +++ b/desktop/flipper-plugin/src/ui/datatable/DataTable.tsx @@ -38,15 +38,8 @@ interface DataTableProps { autoScroll?: boolean; extraActions?: React.ReactElement; // custom onSearch(text, row) option? - /** - * onSelect event - * @param item currently selected item - * @param index index of the selected item in the datasources' output. - * Note that the index could potentially refer to a different item if rendering is 'behind' and items have shifted - */ - onSelect?(item: T | undefined, index: number): void; + onSelect?(item: T | undefined, items: T[]): void; // multiselect?: true - // onMultiSelect tableManagerRef?: RefObject; _testHeight?: number; // exposed for unit testing only } @@ -70,13 +63,24 @@ export type DataTableColumn = { export interface RenderContext { columns: DataTableColumn[]; - onClick(item: T, itemId: number): void; + onMouseEnter( + e: React.MouseEvent, + item: T, + itemId: number, + ): void; + onMouseDown( + e: React.MouseEvent, + item: T, + itemId: number, + ): void; } -export function DataTable(props: DataTableProps) { +export function DataTable( + props: DataTableProps, +): React.ReactElement { const {dataSource} = props; const virtualizerRef = useRef(); - const tableManager = useDataTableManager( + const tableManager = useDataTableManager( dataSource, props.columns, props.onSelect, @@ -84,16 +88,50 @@ export function DataTable(props: DataTableProps) { if (props.tableManagerRef) { (props.tableManagerRef as MutableRefObject).current = tableManager; } - const {visibleColumns, selectItem, selection} = tableManager; + const { + visibleColumns, + selectItem, + selection, + addRangeToSelection, + addColumnFilter, + getSelectedItem, + getSelectedItems, + } = tableManager; const renderingConfig = useMemo>(() => { + let dragging = false; + let startIndex = 0; return { columns: visibleColumns, - onClick(_, itemIdx) { - selectItem(() => itemIdx); + onMouseEnter(_e, _item, index) { + if (dragging) { + // by computing range we make sure no intermediate items are missed when scrolling fast + addRangeToSelection(startIndex, index); + } + }, + onMouseDown(e, _item, index) { + if (!dragging) { + if (e.ctrlKey || e.metaKey) { + addRangeToSelection(index, index, true); + } else if (e.shiftKey) { + selectItem(index, true); + } else { + selectItem(index); + } + + dragging = true; + startIndex = index; + + function onStopDragSelecting() { + dragging = false; + document.removeEventListener('mouseup', onStopDragSelecting); + } + + document.addEventListener('mouseup', onStopDragSelecting); + } }, }; - }, [visibleColumns, selectItem]); + }, [visibleColumns, selectItem, addRangeToSelection]); const usesWrapping = useMemo( () => tableManager.columns.some((col) => col.wrap), @@ -112,11 +150,13 @@ export function DataTable(props: DataTableProps) { config={renderContext} value={item} itemIndex={index} - highlighted={index === tableManager.selection} + highlighted={ + index === selection.current || selection.items.has(index) + } /> ); }, - [tableManager.selection], + [selection], ); /** @@ -125,34 +165,34 @@ export function DataTable(props: DataTableProps) { const onKeyDown = useCallback( (e: React.KeyboardEvent) => { let handled = true; + const shiftPressed = e.shiftKey; + const outputSize = dataSource.output.length; + const windowSize = virtualizerRef.current!.virtualItems.length; switch (e.key) { case 'ArrowUp': - selectItem((idx) => (idx > 0 ? idx - 1 : 0)); + selectItem((idx) => (idx > 0 ? idx - 1 : 0), shiftPressed); break; case 'ArrowDown': - selectItem((idx) => - idx < dataSource.output.length - 1 ? idx + 1 : idx, + selectItem( + (idx) => (idx < outputSize - 1 ? idx + 1 : idx), + shiftPressed, ); break; case 'Home': - selectItem(() => 0); + selectItem(0, shiftPressed); break; case 'End': - selectItem(() => dataSource.output.length - 1); + selectItem(outputSize - 1, shiftPressed); break; case ' ': // yes, that is a space case 'PageDown': - selectItem((idx) => - Math.min( - dataSource.output.length - 1, - idx + virtualizerRef.current!.virtualItems.length - 1, - ), + selectItem( + (idx) => Math.min(outputSize - 1, idx + windowSize - 1), + shiftPressed, ); break; case 'PageUp': - selectItem((idx) => - Math.max(0, idx - virtualizerRef.current!.virtualItems.length - 1), - ); + selectItem((idx) => Math.max(0, idx - windowSize + 1), shiftPressed); break; default: handled = false; @@ -167,8 +207,8 @@ export function DataTable(props: DataTableProps) { useLayoutEffect( function scrollSelectionIntoView() { - if (selection >= 0) { - virtualizerRef.current?.scrollToIndex(selection, { + if (selection && selection.current >= 0) { + virtualizerRef.current?.scrollToIndex(selection!.current, { align: 'auto', }); } @@ -193,14 +233,16 @@ export function DataTable(props: DataTableProps) { ); /** Context menu */ - const contexMenu = !props._testHeight // don't render context menu in tests - ? // eslint-disable-next-line - useMemoize(tableContextMenuFactory, [ + // TODO: support customizing context menu + const contexMenu = props._testHeight + ? undefined // don't render context menu in tests + : // eslint-disable-next-line + useMemoize(tableContextMenuFactory, [ visibleColumns, - tableManager.addColumnFilter, - ]) - : undefined; - + addColumnFilter, + getSelectedItem, + getSelectedItems as any, + ]); return ( diff --git a/desktop/flipper-plugin/src/ui/datatable/TableContextMenu.tsx b/desktop/flipper-plugin/src/ui/datatable/TableContextMenu.tsx index d040d7c87..f7b5a3e38 100644 --- a/desktop/flipper-plugin/src/ui/datatable/TableContextMenu.tsx +++ b/desktop/flipper-plugin/src/ui/datatable/TableContextMenu.tsx @@ -19,65 +19,87 @@ import {tryGetFlipperLibImplementation} from '../../plugin/FlipperLib'; const {Item, SubMenu} = Menu; export const TableContextMenuContext = createContext< - undefined | ((item: any) => React.ReactElement) + React.ReactElement | undefined >(undefined); export function tableContextMenuFactory( visibleColumns: DataTableColumn[], addColumnFilter: TableManager['addColumnFilter'], + _getSelection: () => T, + getMultiSelection: () => T[], ) { - return function (item: any) { - const lib = tryGetFlipperLibImplementation(); - if (!lib) { - return ( - - Menu not ready - - ); - } + const lib = tryGetFlipperLibImplementation(); + if (!lib) { return ( - }> - {visibleColumns.map((column) => ( - { - addColumnFilter( - column.key, - normalizeCellValue(item[column.key]), - true, - ); - }}> - {column.title || column.key} - - ))} - - }> - {visibleColumns.map((column) => ( - { - lib.writeTextToClipboard(normalizeCellValue(item[column.key])); - }}> - {column.title || column.key} - - ))} - - { - lib.writeTextToClipboard(JSON.stringify(item, null, 2)); - }}> - Copy row - - {lib.isFB && ( - { - lib.createPaste(JSON.stringify(item, null, 2)); - }}> - Create paste - - )} + Menu not ready ); - }; + } + return ( + + }> + {visibleColumns.map((column) => ( + { + const items = getMultiSelection(); + if (items.length) { + items.forEach((item, index) => { + addColumnFilter( + column.key, + normalizeCellValue(item[column.key]), + index === 0, // remove existing filters before adding the first + ); + }); + } + }}> + {column.title || column.key} + + ))} + + }> + {visibleColumns.map((column) => ( + { + const items = getMultiSelection(); + if (items.length) { + lib.writeTextToClipboard( + items + .map((item) => normalizeCellValue(item[column.key])) + .join('\n'), + ); + } + }}> + {column.title || column.key} + + ))} + + { + const items = getMultiSelection(); + if (items.length) { + lib.writeTextToClipboard( + JSON.stringify(items.length > 1 ? items : items[0], null, 2), + ); + } + }}> + Copy row(s) + + {lib.isFB && ( + { + const items = getMultiSelection(); + if (items.length) { + lib.createPaste( + JSON.stringify(items.length > 1 ? items : items[0], null, 2), + ); + } + }}> + Create paste + + )} + + ); } diff --git a/desktop/flipper-plugin/src/ui/datatable/TableHead.tsx b/desktop/flipper-plugin/src/ui/datatable/TableHead.tsx index 08732b628..7a466dc49 100644 --- a/desktop/flipper-plugin/src/ui/datatable/TableHead.tsx +++ b/desktop/flipper-plugin/src/ui/datatable/TableHead.tsx @@ -80,6 +80,7 @@ const TableHeadContainer = styled.div<{horizontallyScrollable?: boolean}>({ borderBottom: `1px solid ${theme.dividerColor}`, backgroundColor: theme.backgroundWash, userSelect: 'none', + borderLeft: `4px solid ${theme.backgroundWash}`, // space for selection, see TableRow }); TableHeadContainer.displayName = 'TableHead:TableHeadContainer'; diff --git a/desktop/flipper-plugin/src/ui/datatable/TableRow.tsx b/desktop/flipper-plugin/src/ui/datatable/TableRow.tsx index 6b282891d..f97bcb143 100644 --- a/desktop/flipper-plugin/src/ui/datatable/TableRow.tsx +++ b/desktop/flipper-plugin/src/ui/datatable/TableRow.tsx @@ -26,18 +26,18 @@ type TableBodyRowContainerProps = { const backgroundColor = (props: TableBodyRowContainerProps) => { if (props.highlighted) { - return theme.primaryColor; + return theme.backgroundTransparentHover; } return undefined; }; const CircleMargin = 4; -const RowContextMenu = styled(DownCircleFilled)({ +const RowContextMenuWrapper = styled.div({ position: 'absolute', - top: CircleMargin, - right: CircleMargin, + top: 0, + right: 0, + paddingRight: CircleMargin, fontSize: DEFAULT_ROW_HEIGHT - CircleMargin * 2, - borderRadius: (DEFAULT_ROW_HEIGHT - CircleMargin * 2) * 0.5, color: theme.primaryColor, cursor: 'pointer', visibility: 'hidden', @@ -48,22 +48,15 @@ const TableBodyRowContainer = styled.div( display: 'flex', flexDirection: 'row', backgroundColor: backgroundColor(props), - color: props.highlighted ? theme.white : theme.textColorPrimary, - '& *': { - color: props.highlighted ? `${theme.white} !important` : undefined, - }, - '& img': { - backgroundColor: props.highlighted - ? `${theme.white} !important` - : undefined, - }, + borderLeft: props.highlighted + ? `4px solid ${theme.primaryColor}` + : `4px solid ${theme.backgroundDefault}`, minHeight: DEFAULT_ROW_HEIGHT, overflow: 'hidden', width: '100%', flexShrink: 0, - [`&:hover ${RowContextMenu}`]: { + [`&:hover ${RowContextMenuWrapper}`]: { visibility: 'visible', - color: props.highlighted ? theme.white : undefined, }, }), ); @@ -85,9 +78,15 @@ const TableBodyColumnContainer = styled.div<{ width: props.width, justifyContent: props.justifyContent, borderBottom: `1px solid ${theme.dividerColor}`, + '&::selection': { + color: 'inherit', + backgroundColor: theme.buttonDefaultBackground, + }, })); TableBodyColumnContainer.displayName = 'TableRow:TableBodyColumnContainer'; +const contextMenuTriggers = ['click' as const, 'contextMenu' as const]; + type Props = { config: RenderContext; highlighted: boolean; @@ -98,14 +97,15 @@ type Props = { export const TableRow = memo(function TableRow(props: Props) { const {config, highlighted, value: row} = props; const menu = useContext(TableContextMenuContext); - return ( { - e.stopPropagation(); - props.config.onClick(props.value, props.itemIndex); + onMouseDown={(e) => { + props.config.onMouseDown(e, props.value, props.itemIndex); + }} + onMouseEnter={(e) => { + props.config.onMouseEnter(e, props.value, props.itemIndex); }}> {config.columns .filter((col) => col.visible) @@ -125,18 +125,26 @@ export const TableRow = memo(function TableRow(props: Props) { ); })} - {menu && ( - - - + {menu && highlighted && ( + + + + + )} ); }); +function stopPropagation(e: React.MouseEvent) { + e.stopPropagation(); +} + export function normalizeCellValue(value: any): string { switch (typeof value) { case 'boolean': 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 7fa8032be..c01023189 100644 --- a/desktop/flipper-plugin/src/ui/datatable/__tests__/DataTable.node.tsx +++ b/desktop/flipper-plugin/src/ui/datatable/__tests__/DataTable.node.tsx @@ -55,15 +55,15 @@ test('update and append', async () => { expect(elem.length).toBe(1); expect(elem[0].parentElement).toMatchInlineSnapshot(`
test DataTable
true
@@ -112,15 +112,15 @@ test('column visibility', async () => { expect(elem.length).toBe(1); expect(elem[0].parentElement).toMatchInlineSnapshot(`
test DataTable
true
@@ -137,10 +137,10 @@ test('column visibility', async () => { expect(elem.length).toBe(1); expect(elem[0].parentElement).toMatchInlineSnapshot(`
test DataTable
@@ -510,3 +510,114 @@ test('compute filters', () => { expect(data.filter(filter)).toEqual([]); } }); + +test('onSelect callback fires, and in order', () => { + const events: any[] = []; + const ds = createTestDataSource(); + const ref = createRef(); + const rendering = render( + { + events.push([item, items]); + }} + />, + ); + + const item1 = { + title: 'item 1', + done: false, + }; + const item2 = { + title: 'item 2', + done: false, + }; + const item3 = { + title: 'item 3', + done: false, + }; + act(() => { + ds.clear(); + ds.append(item1); + ds.append(item2); + ds.append(item3); + ref.current!.selectItem(2); + }); + + expect(events.splice(0)).toEqual([ + [undefined, []], + [item3, [item3]], + ]); + + act(() => { + ref.current!.addRangeToSelection(0, 0); + }); + + expect(events.splice(0)).toEqual([ + [item1, [item1, item3]], // order preserved! + ]); + + rendering.unmount(); +}); + +test('selection always has the latest state', () => { + const events: any[] = []; + const ds = createTestDataSource(); + const ref = createRef(); + const rendering = render( + { + events.push([item, items]); + }} + />, + ); + + const item1 = { + title: 'item 1', + done: false, + }; + const item2 = { + title: 'item 2', + done: false, + }; + const item3 = { + title: 'item 3', + done: false, + }; + act(() => { + ds.clear(); + ds.append(item1); + ds.append(item2); + ds.append(item3); + ref.current!.selectItem(2); + }); + + expect(events.splice(0)).toEqual([ + [undefined, []], + [item3, [item3]], + ]); + + const item3updated = { + title: 'item 3 updated', + done: false, + }; + act(() => { + ds.update(2, item3updated); + }); + act(() => { + ref.current!.addRangeToSelection(0, 0); + }); + + expect(events.splice(0)).toEqual([ + [item1, [item1, item3updated]], // update reflected in callback! + ]); + + rendering.unmount(); +}); diff --git a/desktop/flipper-plugin/src/ui/datatable/__tests__/DataTableManager.node.tsx b/desktop/flipper-plugin/src/ui/datatable/__tests__/DataTableManager.node.tsx new file mode 100644 index 000000000..1f7052c5a --- /dev/null +++ b/desktop/flipper-plugin/src/ui/datatable/__tests__/DataTableManager.node.tsx @@ -0,0 +1,91 @@ +/** + * 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 { + computeAddRangeToSelection, + computeSetSelection, +} from '../useDataTableManager'; + +test('computeSetSelection', () => { + const emptyBase = { + current: -1, + items: new Set(), + }; + + const partialBase = { + current: 7, + items: new Set([2, 3, 8, 9]), + }; + + // set selection + expect(computeSetSelection(emptyBase, 2)).toEqual({ + current: 2, + items: new Set([2]), + }); + + // move selection 2 down + expect(computeSetSelection(partialBase, (x) => x + 2)).toEqual({ + current: 9, + items: new Set([9]), + }); + + // expand selection + expect(computeSetSelection(partialBase, (x) => x + 5, true)).toEqual({ + current: 12, + items: new Set([2, 3, 7, 8, 9, 10, 11, 12]), + }); + + // expand selection backward + expect(computeSetSelection(partialBase, 5, true)).toEqual({ + current: 5, + items: new Set([2, 3, 8, 9, 5, 6, 7]), // n.b. order is irrelevant + }); +}); + +test('computeAddRangeToSelection', () => { + const emptyBase = { + current: -1, + items: new Set(), + }; + + const partialBase = { + current: 7, + items: new Set([2, 3, 8, 9]), + }; + + // add range selection + expect(computeAddRangeToSelection(emptyBase, 23, 25)).toEqual({ + current: 25, + items: new Set([23, 24, 25]), + }); + + // add range selection + expect(computeAddRangeToSelection(partialBase, 23, 25)).toEqual({ + current: 25, + items: new Set([2, 3, 8, 9, 23, 24, 25]), + }); + + // add range backward + expect(computeAddRangeToSelection(partialBase, 25, 23)).toEqual({ + current: 23, + items: new Set([2, 3, 8, 9, 23, 24, 25]), + }); + + // invest selection - toggle off + expect(computeAddRangeToSelection(partialBase, 8, 8, true)).toEqual({ + current: 8, // note: this item is not part of the selection! + items: new Set([2, 3, 9]), + }); + + // invest selection - toggle on + expect(computeAddRangeToSelection(partialBase, 5, 5, true)).toEqual({ + current: 5, + items: new Set([2, 3, 5, 8, 9]), + }); +}); diff --git a/desktop/flipper-plugin/src/ui/datatable/useDataTableManager.tsx b/desktop/flipper-plugin/src/ui/datatable/useDataTableManager.tsx index ad5662b3a..ed9d31758 100644 --- a/desktop/flipper-plugin/src/ui/datatable/useDataTableManager.tsx +++ b/desktop/flipper-plugin/src/ui/datatable/useDataTableManager.tsx @@ -10,7 +10,7 @@ import {DataTableColumn} from 'flipper-plugin/src/ui/datatable/DataTable'; import {Percentage} from '../../utils/widthUtils'; import produce from 'immer'; -import {useCallback, useEffect, useMemo, useState} from 'react'; +import {useCallback, useEffect, useMemo, useRef, useState} from 'react'; import {DataSource} from '../../state/datasource/DataSource'; import {useMemoize} from '../../utils/useMemoize'; @@ -22,20 +22,30 @@ export type Sorting = { export type TableManager = ReturnType; +type Selection = {items: ReadonlySet; current: number}; + +const emptySelection: Selection = { + items: new Set(), + current: -1, +}; + /** * A hook that coordinates filtering, sorting etc for a DataSource */ -export function useDataTableManager( +export function useDataTableManager( dataSource: DataSource, defaultColumns: DataTableColumn[], - onSelect?: (item: T | undefined, index: number) => void, + onSelect?: (item: T | undefined, items: T[]) => void, ) { const [columns, setEffectiveColumns] = useState( computeInitialColumns(defaultColumns), ); // TODO: move selection with shifts with index < selection? // TODO: clear selection if out of range - const [selection, setSelection] = useState(-1); + const [selection, setSelection] = useState(emptySelection); + const selectionRef = useRef(selection); + selectionRef.current = selection; // store last seen selection for fetching it later + const [sorting, setSorting] = useState(undefined); const [searchValue, setSearchValue] = useState(''); const visibleColumns = useMemo( @@ -102,6 +112,7 @@ export function useDataTableManager( setEffectiveColumns(computeInitialColumns(defaultColumns)); setSorting(undefined); setSearchValue(''); + setSelection(emptySelection); dataSource.reset(); }, [dataSource, defaultColumns]); @@ -148,21 +159,6 @@ export function useDataTableManager( ); }, []); - const selectItem = useCallback( - (updater: (currentIndex: number) => number) => { - setSelection((currentIndex) => { - const newIndex = updater(currentIndex); - const item = - newIndex >= 0 && newIndex < dataSource.output.length - ? dataSource.getItem(newIndex) - : undefined; - onSelect?.(item, newIndex); - return newIndex; - }); - }, - [setSelection, onSelect, dataSource], - ); - useEffect( function applyFilter() { dataSource.setFilter(currentFilter); @@ -170,6 +166,65 @@ export function useDataTableManager( [currentFilter, dataSource], ); + /** + * Select an individual item, used by mouse clicks and keyboard navigation + * Set addToSelection if the current selection should be expanded to the given position, + * rather than replacing the current selection. + * + * The nextIndex can be used to compute the new selection by basing relatively to the current selection + */ + const selectItem = useCallback( + ( + nextIndex: number | ((currentIndex: number) => number), + addToSelection?: boolean, + ) => { + setSelection((base) => + computeSetSelection(base, nextIndex, addToSelection), + ); + }, + [], + ); + + /** + * Adds a range of items to the current seleciton (if any) + */ + const addRangeToSelection = useCallback( + (start: number, end: number, allowUnselect?: boolean) => { + setSelection((base) => + computeAddRangeToSelection(base, start, end, allowUnselect), + ); + }, + [], + ); + + // N.B: we really want to have stable refs for these functions, + // to avoid that all context menus need re-render for every selection change, + // hence the selectionRef hack + const getSelectedItem = useCallback(() => { + return selectionRef.current.current < 0 + ? undefined + : dataSource.getItem(selectionRef.current.current); + }, [dataSource]); + + const getSelectedItems = useCallback(() => { + return [...selectionRef.current.items] + .sort() + .map((i) => dataSource.getItem(i)) + .filter(Boolean); + }, [dataSource]); + + useEffect( + function fireSelection() { + if (onSelect) { + const item = getSelectedItem(); + const items = getSelectedItems(); + onSelect(item, items); + } + }, + // selection is intentionally a dep + [onSelect, selection, selection, getSelectedItem, getSelectedItems], + ); + return { /** The default columns, but normalized */ columns, @@ -190,6 +245,9 @@ export function useDataTableManager( /** current selection, describes the index index in the datasources's current output (not window) */ selection, selectItem, + addRangeToSelection, + getSelectedItem, + getSelectedItems, /** Changing column filters */ addColumnFilter, removeColumnFilter, @@ -244,3 +302,73 @@ export function computeDataTableFilter( ); }; } + +export function computeSetSelection( + base: Selection, + nextIndex: number | ((currentIndex: number) => number), + addToSelection?: boolean, +): Selection { + const newIndex = + typeof nextIndex === 'number' ? nextIndex : nextIndex(base.current); + if (newIndex < 0) { + return emptySelection; + } + if (base.current < 0 || !addToSelection) { + return { + current: newIndex, + items: new Set([newIndex]), + }; + } else { + const lowest = Math.min(base.current, newIndex); + const highest = Math.max(base.current, newIndex); + return { + current: newIndex, + items: addIndicesToMultiSelection(base.items, lowest, highest), + }; + } +} + +export function computeAddRangeToSelection( + base: Selection, + start: number, + end: number, + allowUnselect?: boolean, +): Selection { + // special case: unselectiong a single existing item + if (start === end && allowUnselect) { + if (base?.items.has(start)) { + const copy = new Set(base.items); + copy.delete(start); + if (copy.size === 0) { + return emptySelection; + } + return { + items: copy, + current: start, + }; + } + // intentional fall-through + } + + // N.B. start and end can be reverted if selecting backwards + const lowest = Math.min(start, end); + const highest = Math.max(start, end); + const current = end; + + return { + items: addIndicesToMultiSelection(base.items, lowest, highest), + current, + }; +} + +function addIndicesToMultiSelection( + base: ReadonlySet, + lowest: number, + highest: number, +): ReadonlySet { + const copy = new Set(base); + for (let i = lowest; i <= highest; i++) { + copy.add(i); + } + return copy; +}