Summary: This diff implements the remaining features in the logs plugin: - deeplinking - merging duplicate rows The logs plugin source code has now been reduced from originally `935` to `285` LoC. All optimisation code has been removed from the plugin: * debouncing data processing * pre-rendering (and storing!) all rows Finally applied some further styling tweaks and applied some renames to DataTable / DataSource + types finetuning. Some more will follow. Fixed a emotion warning in unit tests which was pretty annoying. Reviewed By: passy Differential Revision: D26666190 fbshipit-source-id: e45e289b4422ebeb46cad927cfc0cfcc9566834f
442 lines
13 KiB
TypeScript
442 lines
13 KiB
TypeScript
/**
|
|
* 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<SortDirection, undefined>;
|
|
};
|
|
|
|
export type SortDirection = 'asc' | 'desc' | undefined;
|
|
|
|
export interface DataTableManager<T> {
|
|
/** The default columns, but normalized */
|
|
columns: DataTableColumn<T>[];
|
|
/** The effective columns to be rendererd */
|
|
visibleColumns: DataTableColumn<T>[];
|
|
/** 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<number>; current: number};
|
|
|
|
const emptySelection: Selection = {
|
|
items: new Set(),
|
|
current: -1,
|
|
};
|
|
|
|
/**
|
|
* A hook that coordinates filtering, sorting etc for a DataSource
|
|
*/
|
|
export function useDataTableManager<T>(
|
|
dataSource: DataSource<T>,
|
|
defaultColumns: DataTableColumn<T>[],
|
|
onSelect?: (item: T | undefined, items: T[]) => void,
|
|
): DataTableManager<T> {
|
|
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<Selection>(emptySelection);
|
|
|
|
const [sorting, setSorting] = useState<Sorting | undefined>(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<any>[]) => {
|
|
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<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;
|
|
}),
|
|
);
|
|
}, []);
|
|
|
|
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<any>[]) => {
|
|
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<any>[]) => {
|
|
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<any>[],
|
|
): 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),
|
|
);
|
|
};
|
|
}
|
|
|
|
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<number>,
|
|
lowest: number,
|
|
highest: number,
|
|
): ReadonlySet<number> {
|
|
const copy = new Set(base);
|
|
for (let i = lowest; i <= highest; i++) {
|
|
copy.add(i);
|
|
}
|
|
return copy;
|
|
}
|