diff --git a/desktop/flipper-plugin/src/ui/data-table/DataTableWithPowerSearch.tsx b/desktop/flipper-plugin/src/ui/data-table/DataTableWithPowerSearch.tsx new file mode 100644 index 000000000..b3841cce9 --- /dev/null +++ b/desktop/flipper-plugin/src/ui/data-table/DataTableWithPowerSearch.tsx @@ -0,0 +1,858 @@ +/** + * Copyright (c) Meta Platforms, Inc. and 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 React, { + useCallback, + useLayoutEffect, + useMemo, + useRef, + useState, + RefObject, + MutableRefObject, + CSSProperties, + useEffect, + useReducer, +} from 'react'; +import {TableRow, DEFAULT_ROW_HEIGHT} from './TableRow'; +import {Layout} from '../Layout'; +import {TableHead} from './TableHead'; +import {Percentage} from '../../utils/widthUtils'; +import { + DataSourceRendererVirtual, + DataSourceRendererStatic, + DataSourceVirtualizer, +} from '../../data-source/index'; +import { + computeDataTableFilter, + createDataTableManager, + createInitialState, + DataManagerState, + DataTableManager, + dataTableManagerReducer, + DataTableReducer, + getSelectedItem, + getSelectedItems, + savePreferences, +} from './DataTableManager'; +import styled from '@emotion/styled'; +import {theme} from '../theme'; +import {tableContextMenuFactory} from './TableContextMenu'; +import {Menu, Switch, InputRef, Typography} from 'antd'; +import {CoffeeOutlined, SearchOutlined, PushpinFilled} from '@ant-design/icons'; +import {useAssertStableRef} from '../../utils/useAssertStableRef'; +import {Formatter} from '../DataFormatter'; +import {usePluginInstanceMaybe} from '../../plugin/PluginContext'; +import {debounce} from 'lodash'; +import {useInUnitTest} from '../../utils/useInUnitTest'; +import { + createDataSource, + DataSource, + _DataSourceView, +} from 'flipper-plugin-core'; +import {HighlightProvider} from '../Highlight'; +import {useLatestRef} from '../../utils/useLatestRef'; +import {PowerSearch} from '../PowerSearch'; +import {powerSearchExampleConfig} from '../PowerSearch/PowerSearchExampleConfig'; + +type DataTableBaseProps = { + columns: DataTableColumn[]; + enableSearchbar?: boolean; + enableAutoScroll?: boolean; + enableHorizontalScroll?: boolean; + enableColumnHeaders?: boolean; + enableMultiSelect?: boolean; + enableContextMenu?: boolean; + enablePersistSettings?: boolean; + enableMultiPanels?: boolean; + // if set (the default) will grow and become scrollable. Otherwise will use natural size + scrollable?: boolean; + extraActions?: React.ReactElement; + onSelect?(record: T | undefined, records: T[]): void; + onRowStyle?(record: T): CSSProperties | undefined; + tableManagerRef?: RefObject | undefined>; // Actually we want a MutableRefObject, but that is not what React.createRef() returns, and we don't want to put the burden on the plugin dev to cast it... + virtualizerRef?: RefObject; + onCopyRows?(records: T[]): string; + onContextMenu?: (selection: undefined | T) => React.ReactElement; + onRenderEmpty?: + | null + | ((dataView?: _DataSourceView) => React.ReactElement); +}; + +export type ItemRenderer = ( + item: T, + selected: boolean, + index: number, +) => React.ReactNode; + +type DataTableInput = + | { + dataSource: DataSource; + viewId?: string; + records?: undefined; + recordsKey?: undefined; + } + | { + records: readonly T[]; + recordsKey?: keyof T; + viewId?: string; + dataSource?: undefined; + }; + +export type DataTableColumn = { + //this can be a dotted path into a nest objects. e.g foo.bar + key: keyof T & string; + // possible future extension: getValue(row) (and free-form key) to support computed columns + onRender?: (row: T, selected: boolean, index: number) => React.ReactNode; + formatters?: Formatter[] | Formatter; + title?: string; + width?: number | Percentage | undefined; // undefined: use all remaining width + wrap?: boolean; + align?: 'left' | 'right' | 'center'; + visible?: boolean; + filters?: { + label: string; + value: string; + enabled: boolean; + predefined?: boolean; + strict?: boolean; + exact?: boolean; + }[]; + inversed?: boolean; + sortable?: boolean; +}; + +export interface TableRowRenderContext { + columns: DataTableColumn[]; + onMouseEnter( + e: React.MouseEvent, + item: T, + itemId: number, + ): void; + onMouseDown( + e: React.MouseEvent, + item: T, + itemId: number, + ): void; + onRowStyle?(item: T): React.CSSProperties | undefined; + onContextMenu?(): React.ReactElement; +} + +export type DataTableProps = DataTableInput & DataTableBaseProps; + +export function DataTable( + props: DataTableProps, +): React.ReactElement { + const {onRowStyle, onSelect, onCopyRows, onContextMenu} = props; + const dataSource = normalizeDataSourceInput(props); + const dataView = props?.viewId + ? dataSource.getAdditionalView(props.viewId) + : dataSource.view; + useAssertStableRef(dataSource, 'dataSource'); + useAssertStableRef(onRowStyle, 'onRowStyle'); + useAssertStableRef(props.onSelect, 'onRowSelect'); + useAssertStableRef(props.columns, 'columns'); + useAssertStableRef(onCopyRows, 'onCopyRows'); + useAssertStableRef(onContextMenu, 'onContextMenu'); + + const isUnitTest = useInUnitTest(); + + // eslint-disable-next-line + const scope = isUnitTest ? '' : usePluginInstanceMaybe()?.definition.id ?? ''; + let virtualizerRef = useRef(); + if (props.virtualizerRef) { + virtualizerRef = props.virtualizerRef as React.MutableRefObject< + DataSourceVirtualizer | undefined + >; + } + const [tableState, dispatch] = useReducer( + dataTableManagerReducer as DataTableReducer, + undefined, + () => + createInitialState({ + dataSource, + dataView, + defaultColumns: props.columns, + onSelect, + scope, + virtualizerRef, + autoScroll: props.enableAutoScroll, + enablePersistSettings: props.enablePersistSettings, + }), + ); + + const stateRef = useRef(tableState); + stateRef.current = tableState; + const searchInputRef = useRef(null) as MutableRefObject; + const lastOffset = useRef(0); + const dragging = useRef(false); + + const [tableManager] = useState(() => + createDataTableManager(dataView, dispatch, stateRef), + ); + // Make sure this is the main table + if (props.tableManagerRef && !props.viewId) { + (props.tableManagerRef as MutableRefObject).current = tableManager; + } + + const {columns, selection, sorting} = tableState; + + const latestSelectionRef = useLatestRef(selection); + const latestOnSelectRef = useLatestRef(onSelect); + useEffect(() => { + if (dataView) { + const unsubscribe = dataView.addListener((change) => { + if ( + change.type === 'update' && + latestSelectionRef.current.items.has(change.index) + ) { + latestOnSelectRef.current?.( + getSelectedItem(dataView, latestSelectionRef.current), + getSelectedItems(dataView, latestSelectionRef.current), + ); + } + }); + + return unsubscribe; + } + }, [dataView, latestSelectionRef, latestOnSelectRef]); + + const visibleColumns = useMemo( + () => columns.filter((column) => column.visible), + [columns], + ); + + const renderingConfig = useMemo>(() => { + let startIndex = 0; + + return { + columns: visibleColumns as DataTableColumn[], + onMouseEnter(e, _item, index) { + if (dragging.current && e.buttons === 1 && props.enableMultiSelect) { + // by computing range we make sure no intermediate items are missed when scrolling fast + tableManager.addRangeToSelection(startIndex, index); + } + }, + onMouseDown(e, _item, index) { + if (!props.enableMultiSelect && e.buttons > 1) { + tableManager.selectItem(index, false, true); + return; + } + if (!dragging.current) { + if (e.buttons > 1) { + // for right click we only want to add if needed, not deselect + tableManager.addRangeToSelection(index, index, false); + } else if (e.ctrlKey || e.metaKey) { + tableManager.addRangeToSelection(index, index, true); + } else if (e.shiftKey) { + tableManager.selectItem(index, true, true); + } else { + tableManager.selectItem(index, false, true); + } + + dragging.current = true; + startIndex = index; + + function onStopDragSelecting() { + dragging.current = false; + document.removeEventListener('mouseup', onStopDragSelecting); + } + + document.addEventListener('mouseup', onStopDragSelecting); + } + }, + onRowStyle, + onContextMenu: props.enableContextMenu + ? () => { + // using a ref keeps the config stable, so that a new context menu doesn't need + // all rows to be rerendered, but rather shows it conditionally + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + return contextMenuRef.current?.()!; + } + : undefined, + }; + }, [ + visibleColumns, + tableManager, + onRowStyle, + props.enableContextMenu, + props.enableMultiSelect, + ]); + + const itemRenderer = useCallback( + function itemRenderer( + record: T, + index: number, + renderContext: TableRowRenderContext, + ) { + return ( + + ); + }, + [selection, onRowStyle], + ); + + /** + * Keyboard / selection handling + */ + const onKeyDown = useCallback( + (e: React.KeyboardEvent) => { + let handled = true; + const shiftPressed = e.shiftKey; + const outputSize = dataView.size; + const controlPressed = e.ctrlKey; + const windowSize = props.scrollable + ? virtualizerRef.current?.virtualItems.length ?? 0 + : dataView.size; + if (!windowSize) { + return; + } + switch (e.key) { + case 'ArrowUp': + tableManager.selectItem( + (idx) => (idx > 0 ? idx - 1 : 0), + shiftPressed, + ); + break; + case 'ArrowDown': + tableManager.selectItem( + (idx) => (idx < outputSize - 1 ? idx + 1 : idx), + shiftPressed, + ); + break; + case 'Home': + tableManager.selectItem(0, shiftPressed); + break; + case 'End': + tableManager.selectItem(outputSize - 1, shiftPressed); + break; + case ' ': // yes, that is a space + case 'PageDown': + tableManager.selectItem( + (idx) => Math.min(outputSize - 1, idx + windowSize - 1), + shiftPressed, + ); + break; + case 'PageUp': + tableManager.selectItem( + (idx) => Math.max(0, idx - windowSize + 1), + shiftPressed, + ); + break; + case 'Escape': + tableManager.clearSelection(); + break; + case 't': + if (controlPressed) { + tableManager.toggleSearchValue(); + } + break; + case 'H': + tableManager.toggleHighlightSearch(); + break; + case 'f': + if (controlPressed && searchInputRef?.current) { + searchInputRef?.current.focus(); + tableManager.showSearchDropdown(true); + tableManager.setShowNumberedHistory(true); + } + break; + default: + handled = false; + } + if (handled) { + e.stopPropagation(); + e.preventDefault(); + } + }, + [dataView, props.scrollable, tableManager], + ); + + const [setFilter] = useState(() => (tableState: DataManagerState) => { + const selectedEntry = + tableState.selection.current >= 0 + ? dataView.getEntry(tableState.selection.current) + : null; + dataView.setFilter( + computeDataTableFilter( + tableState.searchValue, + tableState.useRegex, + tableState.columns, + ), + ); + dataView.setFilterExpections( + tableState.filterExceptions as T[keyof T][] | undefined, + ); + + // TODO: in the future setFilter effects could be async, at the moment it isn't, + // so we can safely assume the internal state of the dataView is updated with the + // filter changes and try to find the same entry back again + if (selectedEntry) { + const selectionIndex = dataView.getViewIndexOfEntry(selectedEntry); + tableManager.selectItem(selectionIndex, false, false); + // we disable autoScroll as is it can accidentally be annoying if it was never turned off and + // filter causes items to not fill the available space + dispatch({type: 'setAutoScroll', autoScroll: false}); + virtualizerRef.current?.scrollToIndex(selectionIndex, {align: 'center'}); + setTimeout(() => { + virtualizerRef.current?.scrollToIndex(selectionIndex, { + align: 'center', + }); + }, 0); + } + // TODO: could do the same for multiselections, doesn't seem to be requested so far + }); + + const [debouncedSetFilter] = useState(() => { + // we don't want to trigger filter changes too quickly, as they can be pretty expensive + // and would block the user from entering text in the search bar for example + // (and in the future would really benefit from concurrent mode here :)) + // leading is set to true so that an initial filter is immediately applied and a flash of wrong content is prevented + // this also makes clear act faster + return isUnitTest ? setFilter : debounce(setFilter, 250); + }); + + useEffect( + function updateFilter() { + if (!dataView.isFiltered) { + setFilter(tableState); + } else { + debouncedSetFilter(tableState); + } + }, + // Important dep optimization: we don't want to recalc filters if just the width or visibility changes! + // We pass entire state.columns to computeDataTableFilter, but only changes in the filter are a valid cause to compute a new filter function + // eslint-disable-next-line + [ + tableState.searchValue, + tableState.useRegex, + // eslint-disable-next-line react-hooks/exhaustive-deps + ...tableState.columns.map((c) => c.filters), + // eslint-disable-next-line react-hooks/exhaustive-deps + ...tableState.columns.map((c) => c.inversed), + tableState.filterExceptions, + ], + ); + + useEffect( + function updateSorting() { + if (tableState.sorting === undefined) { + dataView.setSortBy(undefined); + dataView.setReversed(false); + } else { + dataView.setSortBy(tableState.sorting.key); + dataView.setReversed(tableState.sorting.direction === 'desc'); + } + }, + [dataView, tableState.sorting], + ); + + const isMounted = useRef(false); + useEffect( + function triggerSelection() { + if (isMounted.current) { + onSelect?.( + getSelectedItem(dataView, tableState.selection), + getSelectedItems(dataView, tableState.selection), + ); + } + isMounted.current = true; + }, + [onSelect, dataView, tableState.selection], + ); + + // The initialScrollPosition is used to both capture the initial px we want to scroll to, + // and whether we performed that scrolling already (if so, it will be 0) + useLayoutEffect( + function scrollSelectionIntoView() { + if (tableState.initialOffset) { + virtualizerRef.current?.scrollToOffset(tableState.initialOffset); + dispatch({ + type: 'appliedInitialScroll', + }); + } else if (selection && selection.current >= 0) { + dispatch({type: 'setAutoScroll', autoScroll: false}); + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + virtualizerRef.current?.scrollToIndex(selection!.current, { + align: 'auto', + }); + } + }, + // initialOffset is relevant for the first run, + // but should not trigger the efffect in general + // eslint-disable-next-line + [selection], + ); + + /** Range finder */ + const [range, setRange] = useState(''); + const hideRange = useRef(); + + const onRangeChange = useCallback( + (start: number, end: number, total: number, offset) => { + setRange(`${start} - ${end} / ${total}`); + lastOffset.current = offset; + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + clearTimeout(hideRange.current!); + hideRange.current = setTimeout(() => { + setRange(''); + }, 1000); + }, + [], + ); + + const onUpdateAutoScroll = useCallback( + (autoScroll: boolean) => { + if (props.enableAutoScroll) { + dispatch({type: 'setAutoScroll', autoScroll}); + } + }, + [props.enableAutoScroll], + ); + + const sidePanelToggle = useMemo( + () => ( + + { + e.stopPropagation(); + e.preventDefault(); + }}> + Side By Side View + { + tableManager.toggleSideBySide(); + }} + /> + + + ), + [tableManager, tableState.sideBySide], + ); + + /** Context menu */ + const contexMenu = isUnitTest + ? undefined + : // eslint-disable-next-line + useCallback( + () => + tableContextMenuFactory( + dataView, + dispatch, + selection, + tableState.highlightSearchSetting, + tableState.filterSearchHistory, + tableState.columns, + visibleColumns, + onCopyRows, + onContextMenu, + props.enableMultiPanels ? sidePanelToggle : undefined, + ), + [ + dataView, + selection, + tableState.highlightSearchSetting, + tableState.filterSearchHistory, + tableState.columns, + visibleColumns, + onCopyRows, + onContextMenu, + props.enableMultiPanels, + sidePanelToggle, + ], + ); + + const contextMenuRef = useRef(contexMenu); + contextMenuRef.current = contexMenu; + + useEffect(function initialSetup() { + return function cleanup() { + // write current prefs to local storage + savePreferences(stateRef.current, lastOffset.current); + // if the component unmounts, we reset the SFRW pipeline to + // avoid wasting resources in the background + dataView.reset(); + if (props.viewId) { + // this is a side panel + dataSource.deleteView(props.viewId); + } + // clean ref && Make sure this is the main table + if (props.tableManagerRef && !props.viewId) { + (props.tableManagerRef as MutableRefObject).current = undefined; + } + }; + // one-time setup and cleanup effect, everything in here is asserted to be stable: + // dataSource, tableManager, tableManagerRef + // eslint-disable-next-line + }, []); + + const header = ( + + {props.enableSearchbar && ( + // + {}} + /> + )} + + ); + const columnHeaders = ( + + {props.enableColumnHeaders && ( + + )} + + ); + + const emptyRenderer = + props.onRenderEmpty === undefined + ? createDefaultEmptyRenderer(tableManager) + : props.onRenderEmpty; + + let mainSection: JSX.Element; + if (props.scrollable) { + const dataSourceRenderer = ( + > + dataView={dataView} + autoScroll={tableState.autoScroll && !dragging.current} + useFixedRowHeight={!tableState.usesWrapping} + defaultRowHeight={DEFAULT_ROW_HEIGHT} + context={renderingConfig} + itemRenderer={itemRenderer} + onKeyDown={onKeyDown} + virtualizerRef={virtualizerRef} + onRangeChange={onRangeChange} + onUpdateAutoScroll={onUpdateAutoScroll} + emptyRenderer={emptyRenderer} + /> + ); + + mainSection = props.enableHorizontalScroll ? ( + + {header} + + + {columnHeaders} + {dataSourceRenderer} + + + + ) : ( + +
+ {header} + {columnHeaders} +
+ {dataSourceRenderer} +
+ ); + } else { + mainSection = ( + + {header} + {columnHeaders} + > + dataView={dataView} + useFixedRowHeight={!tableState.usesWrapping} + defaultRowHeight={DEFAULT_ROW_HEIGHT} + context={renderingConfig} + maxRecords={dataSource.limit} + itemRenderer={itemRenderer} + onKeyDown={onKeyDown} + emptyRenderer={emptyRenderer} + /> + + ); + } + const mainPanel = ( + + + {mainSection} + + {props.enableAutoScroll && ( + + { + dispatch({type: 'toggleAutoScroll'}); + }} + /> + + )} + {range && !isUnitTest && {range}} + + ); + return props.enableMultiPanels && tableState.sideBySide ? ( + //TODO: Make the panels resizable by having a dynamic maxWidth for Layout.Right/Left possibly? + + {mainPanel} + { viewId={'1'} {...props} enableMultiPanels={false} />} + + ) : ( + mainPanel + ); +} + +DataTable.defaultProps = { + scrollable: true, + enableSearchbar: true, + enableAutoScroll: false, + enableHorizontalScroll: true, + enableColumnHeaders: true, + enableMultiSelect: true, + enableContextMenu: true, + enablePersistSettings: true, + onRenderEmpty: undefined, +} as Partial>; + +/* eslint-disable react-hooks/rules-of-hooks */ +function normalizeDataSourceInput( + props: DataTableInput, +): DataSource { + if (props.dataSource) { + return props.dataSource; + } + if (props.records) { + const [dataSource] = useState(() => + createDataSource(props.records, {key: props.recordsKey}), + ); + useEffect(() => { + syncRecordsToDataSource(dataSource, props.records); + }, [dataSource, props.records]); + + return dataSource; + } + throw new Error( + `Either the 'dataSource' or 'records' prop should be provided to DataTable`, + ); +} +/* eslint-enable */ + +function syncRecordsToDataSource( + ds: DataSource, + records: readonly T[], +) { + const startTime = Date.now(); + ds.clear(); + // TODO: optimize in the case we're only dealing with appends or replacements + records.forEach((r) => ds.append(r)); + const duration = Math.abs(Date.now() - startTime); + if (duration > 50 || records.length > 500) { + console.warn( + "The 'records' props is only intended to be used on small datasets. Please use a 'dataSource' instead. See createDataSource for details: https://fbflipper.com/docs/extending/flipper-plugin#createdatasource", + ); + } +} + +function createDefaultEmptyRenderer(dataTableManager?: DataTableManager) { + return (dataView?: _DataSourceView) => ( + + ); +} + +function EmptyTable({ + dataView, + dataManager, +}: { + dataView?: _DataSourceView; + dataManager?: DataTableManager; +}) { + const resetFilters = useCallback(() => { + dataManager?.resetFilters(); + }, [dataManager]); + return ( + + {dataView?.size === 0 ? ( + <> + + No records yet + + ) : ( + <> + + + No records match the current search / filter criteria. + + + + Reset filters + + + + )} + + ); +} + +const RangeFinder = styled.div({ + backgroundColor: theme.backgroundWash, + position: 'absolute', + right: 64, + bottom: 20, + padding: '4px 8px', + color: theme.textColorSecondary, + fontSize: '0.8em', +}); + +const AutoScroller = styled.div({ + backgroundColor: theme.backgroundWash, + position: 'absolute', + right: 40, + bottom: 20, + width: 24, + padding: '4px 8px', + color: theme.textColorSecondary, + fontSize: '0.8em', +});