Add DataTable wirh PowerSearch integrated
Summary: Doc: https://docs.google.com/document/d/1miofxds9DJgWScj0zFyBbdpRH5Rj0T9FqiCapof5-vU/edit#heading=h.pg8svtdjlx7 Reviewed By: lblasa Differential Revision: D49225985 fbshipit-source-id: ea121c88f4f2275bb15b116858951a8bd2f43cc3
This commit is contained in:
committed by
Facebook GitHub Bot
parent
af73cbb669
commit
f897ab9487
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* 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 {OperatorConfig} from '../PowerSearch';
|
||||
|
||||
export type PowerSearchOperatorProcessor = (
|
||||
powerSearchOperatorConfig: OperatorConfig,
|
||||
value: any,
|
||||
) => boolean;
|
||||
|
||||
export type PowerSearchOperatorProcessorConfig = {
|
||||
[key: string]: PowerSearchOperatorProcessor;
|
||||
};
|
||||
@@ -39,10 +39,10 @@ import {
|
||||
getSelectedItem,
|
||||
getSelectedItems,
|
||||
savePreferences,
|
||||
} from './DataTableManager';
|
||||
} from './DataTableWithPowerSearchManager';
|
||||
import styled from '@emotion/styled';
|
||||
import {theme} from '../theme';
|
||||
import {tableContextMenuFactory} from './TableContextMenu';
|
||||
import {tableContextMenuFactory} from './PowerSearchTableContextMenu';
|
||||
import {Menu, Switch, InputRef, Typography} from 'antd';
|
||||
import {CoffeeOutlined, SearchOutlined, PushpinFilled} from '@ant-design/icons';
|
||||
import {useAssertStableRef} from '../../utils/useAssertStableRef';
|
||||
@@ -55,7 +55,6 @@ import {
|
||||
DataSource,
|
||||
_DataSourceView,
|
||||
} from 'flipper-plugin-core';
|
||||
import {HighlightProvider} from '../Highlight';
|
||||
import {useLatestRef} from '../../utils/useLatestRef';
|
||||
import {PowerSearch, OperatorConfig} from '../PowerSearch';
|
||||
import {powerSearchExampleConfig} from '../PowerSearch/PowerSearchExampleConfig';
|
||||
@@ -193,7 +192,7 @@ export function DataTable<T extends object>(
|
||||
(props.tableManagerRef as MutableRefObject<any>).current = tableManager;
|
||||
}
|
||||
|
||||
const {columns, selection, sorting} = tableState;
|
||||
const {columns, selection, searchExpression, sorting} = tableState;
|
||||
|
||||
const latestSelectionRef = useLatestRef(selection);
|
||||
const latestOnSelectRef = useLatestRef(onSelect);
|
||||
@@ -349,19 +348,9 @@ export function DataTable<T extends object>(
|
||||
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:
|
||||
@@ -380,13 +369,7 @@ export function DataTable<T extends object>(
|
||||
tableState.selection.current >= 0
|
||||
? dataView.getEntry(tableState.selection.current)
|
||||
: null;
|
||||
dataView.setFilter(
|
||||
computeDataTableFilter(
|
||||
tableState.searchValue,
|
||||
tableState.useRegex,
|
||||
tableState.columns,
|
||||
),
|
||||
);
|
||||
dataView.setFilter(computeDataTableFilter(tableState.searchExpression, {}));
|
||||
dataView.setFilterExpections(
|
||||
tableState.filterExceptions as T[keyof T][] | undefined,
|
||||
);
|
||||
@@ -431,8 +414,7 @@ export function DataTable<T extends object>(
|
||||
// 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,
|
||||
tableState.searchExpression,
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
...tableState.columns.map((c) => c.filters),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
@@ -549,10 +531,8 @@ export function DataTable<T extends object>(
|
||||
() =>
|
||||
tableContextMenuFactory(
|
||||
dataView,
|
||||
dispatch,
|
||||
dispatch as any,
|
||||
selection,
|
||||
tableState.highlightSearchSetting,
|
||||
tableState.filterSearchHistory,
|
||||
tableState.columns,
|
||||
visibleColumns,
|
||||
onCopyRows,
|
||||
@@ -562,8 +542,6 @@ export function DataTable<T extends object>(
|
||||
[
|
||||
dataView,
|
||||
selection,
|
||||
tableState.highlightSearchSetting,
|
||||
tableState.filterSearchHistory,
|
||||
tableState.columns,
|
||||
visibleColumns,
|
||||
onCopyRows,
|
||||
@@ -600,21 +578,12 @@ export function DataTable<T extends object>(
|
||||
const header = (
|
||||
<Layout.Container>
|
||||
{props.enableSearchbar && (
|
||||
// <TableSearch
|
||||
// searchValue={searchValue}
|
||||
// useRegex={tableState.useRegex}
|
||||
// filterSearchHistory={tableState.filterSearchHistory}
|
||||
// showHistory={tableState.showSearchHistory}
|
||||
// showNumbered={tableState.showNumberedHistory}
|
||||
// dispatch={dispatch as any}
|
||||
// searchHistory={tableState.searchHistory}
|
||||
// contextMenu={props.enableContextMenu ? contexMenu : undefined}
|
||||
// extraActions={!props.viewId ? props.extraActions : undefined}
|
||||
// searchInputRef={searchInputRef}
|
||||
// />
|
||||
<PowerSearch
|
||||
config={powerSearchExampleConfig}
|
||||
onSearchExpressionChange={() => {}}
|
||||
initialSearchExpression={searchExpression}
|
||||
onSearchExpressionChange={(newSearchExpression) => {
|
||||
tableManager.setSearchExpression(newSearchExpression);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Layout.Container>
|
||||
@@ -698,18 +667,6 @@ export function DataTable<T extends object>(
|
||||
}
|
||||
const mainPanel = (
|
||||
<Layout.Container grow={props.scrollable} style={{position: 'relative'}}>
|
||||
<HighlightProvider
|
||||
text={
|
||||
tableState.highlightSearchSetting.highlightEnabled
|
||||
? tableState.searchValue
|
||||
: ''
|
||||
}
|
||||
highlightColor={
|
||||
tableState.highlightSearchSetting.color ||
|
||||
theme.searchHighlightBackground.yellow
|
||||
}>
|
||||
{mainSection}
|
||||
</HighlightProvider>
|
||||
{props.enableAutoScroll && (
|
||||
<AutoScroller>
|
||||
<PushpinFilled
|
||||
|
||||
@@ -0,0 +1,599 @@
|
||||
/**
|
||||
* 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 type {DataTableColumn} from './DataTable';
|
||||
import {Percentage} from '../../utils/widthUtils';
|
||||
import {MutableRefObject, Reducer, RefObject} from 'react';
|
||||
import {DataSourceVirtualizer} from '../../data-source/index';
|
||||
import produce, {castDraft, immerable, original} from 'immer';
|
||||
import {DataSource, getFlipperLib, _DataSourceView} from 'flipper-plugin-core';
|
||||
import {SearchExpressionTerm} from '../PowerSearch';
|
||||
import {PowerSearchOperatorProcessorConfig} from './DataTableDefaultPowerSearchOperators';
|
||||
|
||||
export type OnColumnResize = (id: string, size: number | Percentage) => void;
|
||||
export type Sorting<T = any> = {
|
||||
key: keyof T;
|
||||
direction: Exclude<SortDirection, undefined>;
|
||||
};
|
||||
export type SearchHighlightSetting = {
|
||||
highlightEnabled: boolean;
|
||||
color: string;
|
||||
};
|
||||
|
||||
export type SortDirection = 'asc' | 'desc' | undefined;
|
||||
|
||||
export type Selection = {items: ReadonlySet<number>; current: number};
|
||||
|
||||
const emptySelection: Selection = {
|
||||
items: new Set(),
|
||||
current: -1,
|
||||
};
|
||||
|
||||
type PersistedState = {
|
||||
/** Active search value */
|
||||
searchExpression?: SearchExpressionTerm[];
|
||||
/** current selection, describes the index index in the datasources's current output (not window!) */
|
||||
selection: {current: number; items: number[]};
|
||||
/** The currently applicable sorting, if any */
|
||||
sorting: Sorting | undefined;
|
||||
/** The default columns, but normalized */
|
||||
columns: Pick<
|
||||
DataTableColumn,
|
||||
'key' | 'width' | 'filters' | 'visible' | 'inversed'
|
||||
>[];
|
||||
scrollOffset: number;
|
||||
autoScroll: boolean;
|
||||
};
|
||||
|
||||
type Action<Name extends string, Args = {}> = {type: Name} & Args;
|
||||
|
||||
type DataManagerActions<T> =
|
||||
/** Reset the current table preferences, including column widths an visibility, back to the default */
|
||||
| Action<'reset'>
|
||||
/** Disable the current column filters */
|
||||
| Action<'resetFilters'>
|
||||
/** Resizes the column with the given key to the given width */
|
||||
| Action<'resizeColumn', {column: keyof T; width: number | Percentage}>
|
||||
/** Sort by the given column. This toggles statefully between ascending, descending, none (insertion order of the data source) */
|
||||
| Action<'sortColumn', {column: keyof T; direction: SortDirection}>
|
||||
/** Show / hide the given column */
|
||||
| Action<'toggleColumnVisibility', {column: keyof T}>
|
||||
| Action<'setSearchExpression', {searchExpression?: SearchExpressionTerm[]}>
|
||||
| Action<
|
||||
'selectItem',
|
||||
{
|
||||
nextIndex: number | ((currentIndex: number) => number);
|
||||
addToSelection?: boolean;
|
||||
allowUnselect?: boolean;
|
||||
}
|
||||
>
|
||||
| Action<
|
||||
'selectItemById',
|
||||
{
|
||||
id: string;
|
||||
addToSelection?: boolean;
|
||||
}
|
||||
>
|
||||
| Action<
|
||||
'addRangeToSelection',
|
||||
{
|
||||
start: number;
|
||||
end: number;
|
||||
allowUnselect?: boolean;
|
||||
}
|
||||
>
|
||||
| Action<'clearSelection', {}>
|
||||
| Action<'setFilterExceptions', {exceptions: string[] | undefined}>
|
||||
| Action<'appliedInitialScroll'>
|
||||
| Action<'toggleAutoScroll'>
|
||||
| Action<'setAutoScroll', {autoScroll: boolean}>
|
||||
| Action<'toggleSideBySide'>
|
||||
| Action<'showSearchDropdown', {show: boolean}>
|
||||
| Action<'setShowNumberedHistory', {showNumberedHistory: boolean}>;
|
||||
|
||||
type DataManagerConfig<T> = {
|
||||
dataSource: DataSource<T, T[keyof T]>;
|
||||
dataView: _DataSourceView<T, T[keyof T]>;
|
||||
defaultColumns: DataTableColumn<T>[];
|
||||
scope: string;
|
||||
onSelect: undefined | ((item: T | undefined, items: T[]) => void);
|
||||
virtualizerRef: MutableRefObject<DataSourceVirtualizer | undefined>;
|
||||
autoScroll?: boolean;
|
||||
enablePersistSettings?: boolean;
|
||||
};
|
||||
|
||||
export type DataManagerState<T> = {
|
||||
config: DataManagerConfig<T>;
|
||||
usesWrapping: boolean;
|
||||
storageKey: string;
|
||||
initialOffset: number;
|
||||
columns: DataTableColumn[];
|
||||
sorting: Sorting<T> | undefined;
|
||||
selection: Selection;
|
||||
autoScroll: boolean;
|
||||
searchExpression?: SearchExpressionTerm[];
|
||||
filterExceptions: string[] | undefined;
|
||||
sideBySide: boolean;
|
||||
};
|
||||
|
||||
export type DataTableReducer<T> = Reducer<
|
||||
DataManagerState<T>,
|
||||
DataManagerActions<T>
|
||||
>;
|
||||
export type DataTableDispatch<T = any> = React.Dispatch<DataManagerActions<T>>;
|
||||
|
||||
export const dataTableManagerReducer = produce<
|
||||
DataManagerState<any>,
|
||||
[DataManagerActions<any>]
|
||||
>(function (draft, action) {
|
||||
const config = original(draft.config)!;
|
||||
switch (action.type) {
|
||||
case 'reset': {
|
||||
draft.columns = computeInitialColumns(config.defaultColumns);
|
||||
draft.sorting = undefined;
|
||||
draft.searchExpression = undefined;
|
||||
draft.selection = castDraft(emptySelection);
|
||||
draft.filterExceptions = undefined;
|
||||
break;
|
||||
}
|
||||
case 'resetFilters': {
|
||||
draft.columns.forEach((c) =>
|
||||
c.filters?.forEach((f) => (f.enabled = false)),
|
||||
);
|
||||
draft.searchExpression = undefined;
|
||||
draft.filterExceptions = undefined;
|
||||
break;
|
||||
}
|
||||
case 'resizeColumn': {
|
||||
const {column, width} = action;
|
||||
const col = draft.columns.find((c) => c.key === column)!;
|
||||
col.width = width;
|
||||
break;
|
||||
}
|
||||
case 'sortColumn': {
|
||||
const {column, direction} = action;
|
||||
if (direction === undefined) {
|
||||
draft.sorting = undefined;
|
||||
} else {
|
||||
draft.sorting = {key: column, direction};
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'toggleColumnVisibility': {
|
||||
const {column} = action;
|
||||
const col = draft.columns.find((c) => c.key === column)!;
|
||||
col.visible = !col.visible;
|
||||
break;
|
||||
}
|
||||
case 'setSearchExpression': {
|
||||
draft.searchExpression = action.searchExpression;
|
||||
draft.filterExceptions = undefined;
|
||||
break;
|
||||
}
|
||||
case 'selectItem': {
|
||||
const {nextIndex, addToSelection, allowUnselect} = action;
|
||||
draft.selection = castDraft(
|
||||
computeSetSelection(
|
||||
draft.selection,
|
||||
nextIndex,
|
||||
addToSelection,
|
||||
allowUnselect,
|
||||
),
|
||||
);
|
||||
break;
|
||||
}
|
||||
case 'selectItemById': {
|
||||
const {id, addToSelection} = action;
|
||||
// TODO: fix that this doesn't jumpt selection if items are shifted! sorting is swapped etc
|
||||
const idx = config.dataSource.getIndexOfKey(id);
|
||||
if (idx !== -1) {
|
||||
draft.selection = castDraft(
|
||||
computeSetSelection(draft.selection, idx, addToSelection),
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'addRangeToSelection': {
|
||||
const {start, end, allowUnselect} = action;
|
||||
draft.selection = castDraft(
|
||||
computeAddRangeToSelection(draft.selection, start, end, allowUnselect),
|
||||
);
|
||||
break;
|
||||
}
|
||||
case 'clearSelection': {
|
||||
draft.selection = castDraft(emptySelection);
|
||||
break;
|
||||
}
|
||||
case 'appliedInitialScroll': {
|
||||
draft.initialOffset = 0;
|
||||
break;
|
||||
}
|
||||
case 'toggleAutoScroll': {
|
||||
draft.autoScroll = !draft.autoScroll;
|
||||
break;
|
||||
}
|
||||
case 'setAutoScroll': {
|
||||
draft.autoScroll = action.autoScroll;
|
||||
break;
|
||||
}
|
||||
case 'toggleSideBySide': {
|
||||
draft.sideBySide = !draft.sideBySide;
|
||||
break;
|
||||
}
|
||||
case 'setFilterExceptions': {
|
||||
draft.filterExceptions = action.exceptions;
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
throw new Error('Unknown action ' + (action as any).type);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Public only imperative convienience API for DataTable
|
||||
*/
|
||||
export type DataTableManager<T> = {
|
||||
reset(): void;
|
||||
resetFilters(): void;
|
||||
selectItem(
|
||||
index: number | ((currentSelection: number) => number),
|
||||
addToSelection?: boolean,
|
||||
allowUnselect?: boolean,
|
||||
): void;
|
||||
addRangeToSelection(
|
||||
start: number,
|
||||
end: number,
|
||||
allowUnselect?: boolean,
|
||||
): void;
|
||||
selectItemById(id: string, addToSelection?: boolean): void;
|
||||
clearSelection(): void;
|
||||
getSelectedItem(): T | undefined;
|
||||
getSelectedItems(): readonly T[];
|
||||
toggleColumnVisibility(column: keyof T): void;
|
||||
sortColumn(column: keyof T, direction?: SortDirection): void;
|
||||
setSearchExpression(searchExpression: SearchExpressionTerm[]): void;
|
||||
dataView: _DataSourceView<T, T[keyof T]>;
|
||||
stateRef: RefObject<Readonly<DataManagerState<T>>>;
|
||||
toggleSideBySide(): void;
|
||||
setFilterExceptions(exceptions: string[] | undefined): void;
|
||||
};
|
||||
|
||||
export function createDataTableManager<T>(
|
||||
dataView: _DataSourceView<T, T[keyof T]>,
|
||||
dispatch: DataTableDispatch<T>,
|
||||
stateRef: MutableRefObject<DataManagerState<T>>,
|
||||
): DataTableManager<T> {
|
||||
return {
|
||||
reset() {
|
||||
dispatch({type: 'reset'});
|
||||
},
|
||||
resetFilters() {
|
||||
dispatch({type: 'resetFilters'});
|
||||
},
|
||||
selectItem(index: number, addToSelection = false, allowUnselect = false) {
|
||||
dispatch({
|
||||
type: 'selectItem',
|
||||
nextIndex: index,
|
||||
addToSelection,
|
||||
allowUnselect,
|
||||
});
|
||||
},
|
||||
selectItemById(id, addToSelection = false) {
|
||||
dispatch({type: 'selectItemById', id, addToSelection});
|
||||
},
|
||||
addRangeToSelection(start, end, allowUnselect = false) {
|
||||
dispatch({type: 'addRangeToSelection', start, end, allowUnselect});
|
||||
},
|
||||
clearSelection() {
|
||||
dispatch({type: 'clearSelection'});
|
||||
},
|
||||
getSelectedItem() {
|
||||
return getSelectedItem(dataView, stateRef.current.selection);
|
||||
},
|
||||
getSelectedItems() {
|
||||
return getSelectedItems(dataView, stateRef.current.selection);
|
||||
},
|
||||
toggleColumnVisibility(column) {
|
||||
dispatch({type: 'toggleColumnVisibility', column});
|
||||
},
|
||||
sortColumn(column, direction) {
|
||||
dispatch({type: 'sortColumn', column, direction});
|
||||
},
|
||||
setSearchExpression(searchExpression) {
|
||||
getFlipperLib().logger.track('usage', 'data-table:power-search:search');
|
||||
dispatch({type: 'setSearchExpression', searchExpression});
|
||||
},
|
||||
toggleSideBySide() {
|
||||
dispatch({type: 'toggleSideBySide'});
|
||||
},
|
||||
setFilterExceptions(exceptions: string[] | undefined) {
|
||||
dispatch({type: 'setFilterExceptions', exceptions});
|
||||
},
|
||||
dataView,
|
||||
stateRef,
|
||||
};
|
||||
}
|
||||
|
||||
export function createInitialState<T>(
|
||||
config: DataManagerConfig<T>,
|
||||
): DataManagerState<T> {
|
||||
// by default a table is considered to be identical if plugins, and default column names are the same
|
||||
const storageKey = `${config.scope}:DataTable:${config.defaultColumns
|
||||
.map((c) => c.key)
|
||||
.join(',')}`;
|
||||
const prefs = config.enablePersistSettings
|
||||
? loadStateFromStorage(storageKey)
|
||||
: undefined;
|
||||
let initialColumns = computeInitialColumns(config.defaultColumns);
|
||||
if (prefs) {
|
||||
// merge prefs with the default column config
|
||||
initialColumns = produce(initialColumns, (draft) => {
|
||||
prefs.columns.forEach((pref) => {
|
||||
const existing = draft.find((c) => c.key === pref.key);
|
||||
if (existing) {
|
||||
Object.assign(existing, pref);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const res: DataManagerState<T> = {
|
||||
config,
|
||||
storageKey,
|
||||
initialOffset: prefs?.scrollOffset ?? 0,
|
||||
usesWrapping: config.defaultColumns.some((col) => col.wrap),
|
||||
columns: initialColumns,
|
||||
sorting: prefs?.sorting,
|
||||
selection: prefs?.selection
|
||||
? {
|
||||
current: prefs!.selection.current,
|
||||
items: new Set(prefs!.selection.items),
|
||||
}
|
||||
: emptySelection,
|
||||
searchExpression: prefs?.searchExpression,
|
||||
filterExceptions: undefined,
|
||||
autoScroll: prefs?.autoScroll ?? config.autoScroll ?? false,
|
||||
sideBySide: false,
|
||||
};
|
||||
// @ts-ignore
|
||||
res.config[immerable] = false; // optimization: never proxy anything in config
|
||||
Object.freeze(res.config);
|
||||
return res;
|
||||
}
|
||||
|
||||
export function getSelectedItem<T>(
|
||||
dataView: _DataSourceView<T, T[keyof T]>,
|
||||
selection: Selection,
|
||||
): T | undefined {
|
||||
return selection.current < 0 ? undefined : dataView.get(selection.current);
|
||||
}
|
||||
|
||||
export function getSelectedItems<T>(
|
||||
dataView: _DataSourceView<T, T[keyof T]>,
|
||||
selection: Selection,
|
||||
): T[] {
|
||||
return [...selection.items]
|
||||
.sort((a, b) => a - b) // https://stackoverflow.com/a/15765283/1983583
|
||||
.map((i) => dataView.get(i))
|
||||
.filter(Boolean) as any[];
|
||||
}
|
||||
|
||||
export function savePreferences(
|
||||
state: DataManagerState<any>,
|
||||
scrollOffset: number,
|
||||
) {
|
||||
if (!state.config.scope || !state.config.enablePersistSettings) {
|
||||
return;
|
||||
}
|
||||
const prefs: PersistedState = {
|
||||
searchExpression: state.searchExpression,
|
||||
selection: {
|
||||
current: state.selection.current,
|
||||
items: Array.from(state.selection.items),
|
||||
},
|
||||
sorting: state.sorting,
|
||||
columns: state.columns.map((c) => ({
|
||||
key: c.key,
|
||||
width: c.width,
|
||||
visible: c.visible,
|
||||
})),
|
||||
scrollOffset,
|
||||
autoScroll: state.autoScroll,
|
||||
};
|
||||
localStorage.setItem(state.storageKey, JSON.stringify(prefs));
|
||||
}
|
||||
|
||||
function loadStateFromStorage(storageKey: string): PersistedState | undefined {
|
||||
if (!storageKey) {
|
||||
return undefined;
|
||||
}
|
||||
const state = localStorage.getItem(storageKey);
|
||||
if (!state) {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
return JSON.parse(state) as PersistedState;
|
||||
} catch (e) {
|
||||
// forget about this state
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function computeInitialColumns(
|
||||
columns: DataTableColumn<any>[],
|
||||
): DataTableColumn<any>[] {
|
||||
const visibleColumnCount = columns.filter((c) => c.visible !== false).length;
|
||||
const columnsWithoutWidth = columns.filter(
|
||||
(c) => c.visible !== false && c.width === undefined,
|
||||
).length;
|
||||
|
||||
return columns.map((c) => ({
|
||||
...c,
|
||||
width:
|
||||
c.width ??
|
||||
// if the width is not set, and there are multiple columns with unset widths,
|
||||
// there will be multiple columns ith the same flex weight (1), meaning that
|
||||
// they will all resize a best fits in a specifc row.
|
||||
// To address that we distribute space equally
|
||||
// (this need further fine tuning in the future as with a subset of fixed columns width can become >100%)
|
||||
(columnsWithoutWidth > 1
|
||||
? `${Math.floor(100 / visibleColumnCount)}%`
|
||||
: undefined),
|
||||
filters:
|
||||
c.filters?.map((f) => ({
|
||||
...f,
|
||||
predefined: true,
|
||||
})) ?? [],
|
||||
visible: c.visible !== false,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* A somewhat primitive and unsafe way to access nested fields an object.
|
||||
* @param obj keys should only be strings
|
||||
* @param keyPath dotted string path, e.g foo.bar
|
||||
* @returns value at the key path
|
||||
*/
|
||||
|
||||
export function getValueAtPath(obj: Record<string, any>, keyPath: string): any {
|
||||
let res = obj;
|
||||
for (const key of keyPath.split('.')) {
|
||||
if (res == null) {
|
||||
return null;
|
||||
} else {
|
||||
res = res[key];
|
||||
}
|
||||
}
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
export function computeDataTableFilter(
|
||||
searchExpression: SearchExpressionTerm[] | undefined,
|
||||
powerSearchProcessors: PowerSearchOperatorProcessorConfig,
|
||||
) {
|
||||
return function dataTableFilter(item: any) {
|
||||
if (!searchExpression) {
|
||||
return true;
|
||||
}
|
||||
return searchExpression.some((searchTerm) => {
|
||||
const value = getValueAtPath(item, searchTerm.field.key);
|
||||
if (!value) {
|
||||
console.warn(
|
||||
'computeDataTableFilter -> value at searchTerm.field.key is not recognized',
|
||||
searchTerm,
|
||||
item,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
const processor = powerSearchProcessors[searchTerm.operator.key];
|
||||
if (!processor) {
|
||||
console.warn(
|
||||
'computeDataTableFilter -> processor at searchTerm.operator.key is not recognized',
|
||||
searchTerm,
|
||||
powerSearchProcessors,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
return processor(searchTerm.operator, value);
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export function safeCreateRegExp(source: string): RegExp | undefined {
|
||||
try {
|
||||
return new RegExp(source);
|
||||
} catch (_e) {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export function computeSetSelection(
|
||||
base: Selection,
|
||||
nextIndex: number | ((currentIndex: number) => number),
|
||||
addToSelection?: boolean,
|
||||
allowUnselect?: boolean,
|
||||
): Selection {
|
||||
const newIndex =
|
||||
typeof nextIndex === 'number' ? nextIndex : nextIndex(base.current);
|
||||
// special case: toggle existing selection off
|
||||
if (
|
||||
!addToSelection &&
|
||||
allowUnselect &&
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
/**
|
||||
* 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 {CopyOutlined, FilterOutlined, TableOutlined} from '@ant-design/icons';
|
||||
import {Checkbox, Menu} from 'antd';
|
||||
import {
|
||||
DataTableDispatch,
|
||||
getSelectedItem,
|
||||
getSelectedItems,
|
||||
getValueAtPath,
|
||||
Selection,
|
||||
} from './DataTableManager';
|
||||
import React from 'react';
|
||||
import {
|
||||
_tryGetFlipperLibImplementation,
|
||||
_DataSourceView,
|
||||
} from 'flipper-plugin-core';
|
||||
import {DataTableColumn} from './DataTable';
|
||||
import {toFirstUpper} from '../../utils/toFirstUpper';
|
||||
import {renderColumnValue} from './TableRow';
|
||||
import {textContent} from '../../utils/textContent';
|
||||
|
||||
const {Item, SubMenu} = Menu;
|
||||
|
||||
export function tableContextMenuFactory<T extends object>(
|
||||
dataView: _DataSourceView<T, T[keyof T]>,
|
||||
dispatch: DataTableDispatch<T>,
|
||||
selection: Selection,
|
||||
columns: DataTableColumn<T>[],
|
||||
visibleColumns: DataTableColumn<T>[],
|
||||
onCopyRows: (
|
||||
rows: T[],
|
||||
visibleColumns: DataTableColumn<T>[],
|
||||
) => string = defaultOnCopyRows,
|
||||
onContextMenu?: (selection: undefined | T) => React.ReactElement,
|
||||
sideBySideOption?: React.ReactElement,
|
||||
) {
|
||||
const lib = _tryGetFlipperLibImplementation();
|
||||
if (!lib) {
|
||||
return (
|
||||
<Menu>
|
||||
<Item>Menu not ready</Item>
|
||||
</Menu>
|
||||
);
|
||||
}
|
||||
const hasSelection = selection.items.size > 0 ?? false;
|
||||
return (
|
||||
<Menu>
|
||||
{onContextMenu
|
||||
? onContextMenu(getSelectedItem(dataView, selection))
|
||||
: null}
|
||||
<SubMenu
|
||||
key="filter same"
|
||||
title="Filter on same"
|
||||
icon={<FilterOutlined />}
|
||||
disabled={!hasSelection}>
|
||||
{visibleColumns.map((column, idx) => (
|
||||
<Item
|
||||
key={column.key ?? idx}
|
||||
onClick={() => {
|
||||
dispatch({
|
||||
type: 'setColumnFilterFromSelection',
|
||||
column: column.key,
|
||||
});
|
||||
}}>
|
||||
{friendlyColumnTitle(column)}
|
||||
</Item>
|
||||
))}
|
||||
</SubMenu>
|
||||
<SubMenu
|
||||
key="copy rows"
|
||||
title="Copy row(s)"
|
||||
icon={<TableOutlined />}
|
||||
disabled={!hasSelection}>
|
||||
<Item
|
||||
key="copyToClipboard"
|
||||
disabled={!hasSelection}
|
||||
onClick={() => {
|
||||
const items = getSelectedItems(dataView, selection);
|
||||
if (items.length) {
|
||||
lib.writeTextToClipboard(onCopyRows(items, visibleColumns));
|
||||
}
|
||||
}}>
|
||||
Copy row(s)
|
||||
</Item>
|
||||
{lib.isFB && (
|
||||
<Item
|
||||
key="createPaste"
|
||||
disabled={!hasSelection}
|
||||
onClick={() => {
|
||||
const items = getSelectedItems(dataView, selection);
|
||||
if (items.length) {
|
||||
lib.createPaste(onCopyRows(items, visibleColumns));
|
||||
}
|
||||
}}>
|
||||
Create paste
|
||||
</Item>
|
||||
)}
|
||||
<Item
|
||||
key="copyToClipboardJSON"
|
||||
disabled={!hasSelection}
|
||||
onClick={() => {
|
||||
const items = getSelectedItems(dataView, selection);
|
||||
if (items.length) {
|
||||
lib.writeTextToClipboard(rowsToJson(items));
|
||||
}
|
||||
}}>
|
||||
Copy row(s) (JSON)
|
||||
</Item>
|
||||
{lib.isFB && (
|
||||
<Item
|
||||
key="createPasteJSON"
|
||||
disabled={!hasSelection}
|
||||
onClick={() => {
|
||||
const items = getSelectedItems(dataView, selection);
|
||||
if (items.length) {
|
||||
lib.createPaste(rowsToJson(items));
|
||||
}
|
||||
}}>
|
||||
Create paste (JSON)
|
||||
</Item>
|
||||
)}
|
||||
</SubMenu>
|
||||
|
||||
<SubMenu
|
||||
key="copy cells"
|
||||
title="Copy cell(s)"
|
||||
icon={<CopyOutlined />}
|
||||
disabled={!hasSelection}>
|
||||
{visibleColumns.map((column, idx) => (
|
||||
<Item
|
||||
key={'copy cell' + (column.key ?? idx)}
|
||||
onClick={() => {
|
||||
const items = getSelectedItems(dataView, selection);
|
||||
if (items.length) {
|
||||
lib.writeTextToClipboard(
|
||||
items
|
||||
.map((item) => '' + getValueAtPath(item, column.key))
|
||||
.join('\n'),
|
||||
);
|
||||
}
|
||||
}}>
|
||||
{friendlyColumnTitle(column)}
|
||||
</Item>
|
||||
))}
|
||||
</SubMenu>
|
||||
<Menu.Divider />
|
||||
<SubMenu title="Visible columns" key="visible columns">
|
||||
{columns.map((column, idx) => (
|
||||
<Menu.Item key={'visible column ' + (column.key ?? idx)}>
|
||||
<Checkbox
|
||||
checked={column.visible}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
dispatch({type: 'toggleColumnVisibility', column: column.key});
|
||||
}}>
|
||||
{friendlyColumnTitle(column)}
|
||||
</Checkbox>
|
||||
</Menu.Item>
|
||||
))}
|
||||
</SubMenu>
|
||||
<Menu.Item
|
||||
key="resetFilters"
|
||||
onClick={() => {
|
||||
dispatch({type: 'resetFilters'});
|
||||
}}>
|
||||
Reset filters
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
key="reset"
|
||||
onClick={() => {
|
||||
dispatch({type: 'reset'});
|
||||
}}>
|
||||
Reset view
|
||||
</Menu.Item>
|
||||
|
||||
<SubMenu title="Search Options" key="search options">
|
||||
<Menu.Item
|
||||
key="clear history"
|
||||
onClick={() => {
|
||||
dispatch({type: 'clearSearchHistory'});
|
||||
}}>
|
||||
Clear search history
|
||||
</Menu.Item>
|
||||
</SubMenu>
|
||||
{sideBySideOption}
|
||||
</Menu>
|
||||
);
|
||||
}
|
||||
|
||||
function friendlyColumnTitle(column: DataTableColumn<any>): string {
|
||||
const name = column.title || column.key;
|
||||
return toFirstUpper(name);
|
||||
}
|
||||
|
||||
function defaultOnCopyRows<T extends object>(
|
||||
items: T[],
|
||||
visibleColumns: DataTableColumn<T>[],
|
||||
) {
|
||||
return (
|
||||
visibleColumns.map(friendlyColumnTitle).join('\t') +
|
||||
'\n' +
|
||||
items
|
||||
.map((row, idx) =>
|
||||
visibleColumns
|
||||
.map((col) => textContent(renderColumnValue(col, row, true, idx)))
|
||||
.join('\t'),
|
||||
)
|
||||
.join('\n')
|
||||
);
|
||||
}
|
||||
|
||||
function rowsToJson<T>(items: T[]) {
|
||||
return JSON.stringify(items.length > 1 ? items : items[0], null, 2);
|
||||
}
|
||||
Reference in New Issue
Block a user