/** * 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 {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 {DataSource} from '../../state/datasource/DataSource'; import {useMemoize} from '../../utils/useMemoize'; export type OnColumnResize = (id: string, size: number | Percentage) => void; export type Sorting = { key: string; direction: Exclude; }; export type SortDirection = 'asc' | 'desc' | undefined; export interface DataTableManager { /** The default columns, but normalized */ columns: DataTableColumn[]; /** The effective columns to be rendererd */ visibleColumns: DataTableColumn[]; /** The currently applicable sorting, if any */ sorting: Sorting | undefined; /** Reset the current table preferences, including column widths an visibility, back to the default */ reset(): void; /** Resizes the column with the given key to the given width */ resizeColumn(column: string, width: number | Percentage): void; /** Sort by the given column. This toggles statefully between ascending, descending, none (insertion order of the data source) */ sortColumn(column: string, direction: SortDirection): void; /** Show / hide the given column */ toggleColumnVisibility(column: string): void; /** Active search value */ setSearchValue(value: string): void; /** current selection, describes the index index in the datasources's current output (not window) */ selection: Selection; selectItem( nextIndex: number | ((currentIndex: number) => number), addToSelection?: boolean, ): void; addRangeToSelection( start: number, end: number, allowUnselect?: boolean, ): void; clearSelection(): void; getSelectedItem(): T | undefined; getSelectedItems(): readonly T[]; /** Changing column filters */ addColumnFilter(column: string, value: string, disableOthers?: boolean): void; removeColumnFilter(column: string, index: number): void; toggleColumnFilter(column: string, index: number): void; setColumnFilterFromSelection(column: string): void; } 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( dataSource: DataSource, defaultColumns: DataTableColumn[], onSelect?: (item: T | undefined, items: T[]) => void, ): DataTableManager { 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(emptySelection); const [sorting, setSorting] = useState(undefined); const [searchValue, setSearchValue] = useState(''); const visibleColumns = useMemo( () => columns.filter((column) => column.visible), [columns], ); /** * 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), ); }, [], ); const clearSelection = useCallback(() => { setSelection(emptySelection); }, []); const getSelectedItem = useCallback(() => { return selection.current < 0 ? undefined : dataSource.getItem(selection.current); }, [dataSource, selection]); const getSelectedItems = useCallback(() => { return [...selection.items] .sort() .map((i) => dataSource.getItem(i)) .filter(Boolean) as any[]; }, [dataSource, selection]); useEffect( function fireSelection() { if (onSelect) { const item = getSelectedItem(); const items = getSelectedItems(); onSelect(item, items); } }, // selection is intentionally a dep [onSelect, selection, selection, getSelectedItem, getSelectedItems], ); /** * Filtering */ const addColumnFilter = useCallback( (columnId: string, value: string, disableOthers = false) => { // TODO: fix typings setEffectiveColumns( produce((draft: DataTableColumn[]) => { const column = draft.find((c) => c.key === columnId)!; const filterValue = value.toLowerCase(); const existing = column.filters!.find((c) => c.value === filterValue); if (existing) { existing.enabled = true; } else { column.filters!.push({ label: value, value: filterValue, enabled: true, }); } if (disableOthers) { column.filters!.forEach((c) => { if (c.value !== filterValue) { c.enabled = false; } }); } }), ); }, [], ); 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; }), ); }, []); const setColumnFilterFromSelection = useCallback( (columnId: string) => { const items = getSelectedItems(); if (items.length) { items.forEach((item, index) => { addColumnFilter( columnId, item[columnId], index === 0, // remove existing filters before adding the first ); }); } }, [getSelectedItems, addColumnFilter], ); // 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(''); setSelection(emptySelection); dataSource.reset(); }, [dataSource, defaultColumns]); const resizeColumn = useCallback((id: string, width: number | Percentage) => { setEffectiveColumns( // TODO: fix typing of produce produce((columns: DataTableColumn[]) => { const col = columns.find((c) => c.key === id)!; col.width = width; }), ); }, []); const sortColumn = useCallback( (key: string, direction: SortDirection) => { if (direction === undefined) { // remove sorting setSorting(undefined); dataSource.setSortBy(undefined); dataSource.setReversed(false); } else { // update sorting // TODO: make sure that setting both doesn't rebuild output twice! if (!sorting || sorting.key !== key) { dataSource.setSortBy(key as any); } if (!sorting || sorting.direction !== direction) { dataSource.setReversed(direction === 'desc'); } setSorting({key, direction}); } }, [dataSource, sorting], ); const toggleColumnVisibility = useCallback((id: string) => { setEffectiveColumns( // TODO: fix typing of produce produce((columns: DataTableColumn[]) => { const col = columns.find((c) => c.key === id)!; col.visible = !col.visible; }), ); }, []); useEffect( function applyFilter() { dataSource.setFilter(currentFilter); }, [currentFilter, dataSource], ); // if the component unmounts, we reset the SFRW pipeline to // avoid wasting resources in the background useEffect(() => () => dataSource.reset(), [dataSource]); return { /** The default columns, but normalized */ columns, /** The effective columns to be rendererd */ visibleColumns, /** The currently applicable sorting, if any */ sorting, /** Reset the current table preferences, including column widths an visibility, back to the default */ reset, /** Resizes the column with the given key to the given width */ resizeColumn, /** Sort by the given column. This toggles statefully between ascending, descending, none (insertion order of the data source) */ sortColumn, /** Show / hide the given column */ toggleColumnVisibility, /** Active search value */ setSearchValue, /** current selection, describes the index index in the datasources's current output (not window) */ selection, selectItem, addRangeToSelection, clearSelection, getSelectedItem, getSelectedItems, /** Changing column filters */ addColumnFilter, removeColumnFilter, toggleColumnFilter, setColumnFilterFromSelection, }; } function computeInitialColumns( columns: DataTableColumn[], ): 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), ); }; } export function computeSetSelection( base: Selection, nextIndex: number | ((currentIndex: number) => number), addToSelection?: boolean, ): Selection { const newIndex = typeof nextIndex === 'number' ? nextIndex : nextIndex(base.current); // special case: toggle existing selection off if (!addToSelection && base.items.size === 1 && base.current === newIndex) { return emptySelection; } 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 item with the selection if (start === end && allowUnselect) { if (base?.items.has(start)) { const copy = new Set(base.items); copy.delete(start); const current = [...copy]; if (current.length === 0) { return emptySelection; } return { items: copy, current: current[current.length - 1], // back to the last selected one }; } // 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; }