Reviewed By: antonk52 Differential Revision: D49822513 fbshipit-source-id: a9c5c6ddc91ac6e7284af045ec673144d9c64906
877 lines
26 KiB
TypeScript
877 lines
26 KiB
TypeScript
/**
|
|
* 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 './DataTableWithPowerSearchManager';
|
|
import styled from '@emotion/styled';
|
|
import {theme} from '../theme';
|
|
import {tableContextMenuFactory} from './PowerSearchTableContextMenu';
|
|
import {Menu, Switch, InputRef, Typography, Dropdown, Button} from 'antd';
|
|
import {
|
|
CoffeeOutlined,
|
|
SearchOutlined,
|
|
PushpinFilled,
|
|
MenuOutlined,
|
|
} 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 {useLatestRef} from '../../utils/useLatestRef';
|
|
import {
|
|
PowerSearch,
|
|
PowerSearchConfig,
|
|
FieldConfig,
|
|
OperatorConfig,
|
|
SearchExpressionTerm,
|
|
} from '../PowerSearch';
|
|
import {
|
|
dataTablePowerSearchOperatorProcessorConfig,
|
|
dataTablePowerSearchOperators,
|
|
} from './DataTableDefaultPowerSearchOperators';
|
|
|
|
type DataTableBaseProps<T = any> = {
|
|
columns: DataTableColumn<T>[];
|
|
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;
|
|
actionsRight?: React.ReactElement;
|
|
actionsTop?: React.ReactElement;
|
|
onSelect?(record: T | undefined, records: T[]): void;
|
|
onRowStyle?(record: T): CSSProperties | undefined;
|
|
tableManagerRef?: RefObject<DataTableManager<T> | 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<DataSourceVirtualizer | undefined>;
|
|
onCopyRows?(records: T[]): string;
|
|
onContextMenu?: (selection: undefined | T) => React.ReactElement;
|
|
onRenderEmpty?:
|
|
| null
|
|
| ((dataView?: _DataSourceView<T, T[keyof T]>) => React.ReactElement);
|
|
powerSearchInitialState?: SearchExpressionTerm[];
|
|
};
|
|
|
|
export type ItemRenderer<T> = (
|
|
item: T,
|
|
selected: boolean,
|
|
index: number,
|
|
) => React.ReactNode;
|
|
|
|
type DataTableInput<T = any> =
|
|
| {
|
|
dataSource: DataSource<T, T[keyof T]>;
|
|
viewId?: string;
|
|
records?: undefined;
|
|
recordsKey?: undefined;
|
|
}
|
|
| {
|
|
records: readonly T[];
|
|
recordsKey?: keyof T;
|
|
viewId?: string;
|
|
dataSource?: undefined;
|
|
};
|
|
|
|
export type DataTableColumn<T = any> = {
|
|
//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;
|
|
inversed?: boolean;
|
|
sortable?: boolean;
|
|
powerSearchConfig?: OperatorConfig[] | false;
|
|
};
|
|
|
|
export interface TableRowRenderContext<T = any> {
|
|
columns: DataTableColumn<T>[];
|
|
onMouseEnter(
|
|
e: React.MouseEvent<HTMLDivElement>,
|
|
item: T,
|
|
itemId: number,
|
|
): void;
|
|
onMouseDown(
|
|
e: React.MouseEvent<HTMLDivElement>,
|
|
item: T,
|
|
itemId: number,
|
|
): void;
|
|
onRowStyle?(item: T): React.CSSProperties | undefined;
|
|
onContextMenu?(): React.ReactElement;
|
|
}
|
|
|
|
const Searchbar = styled(Layout.Horizontal)({
|
|
backgroundColor: theme.backgroundWash,
|
|
padding: theme.space.small,
|
|
});
|
|
|
|
export type DataTableProps<T> = DataTableInput<T> & DataTableBaseProps<T>;
|
|
|
|
export function DataTable<T extends object>(
|
|
props: DataTableProps<T>,
|
|
): 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<DataSourceVirtualizer | undefined>();
|
|
if (props.virtualizerRef) {
|
|
virtualizerRef = props.virtualizerRef as React.MutableRefObject<
|
|
DataSourceVirtualizer | undefined
|
|
>;
|
|
}
|
|
const [tableState, dispatch] = useReducer(
|
|
dataTableManagerReducer as DataTableReducer<T>,
|
|
undefined,
|
|
() =>
|
|
createInitialState({
|
|
dataSource,
|
|
dataView,
|
|
defaultColumns: props.columns,
|
|
onSelect,
|
|
scope,
|
|
virtualizerRef,
|
|
autoScroll: props.enableAutoScroll,
|
|
enablePersistSettings: props.enablePersistSettings,
|
|
initialSearchExpression: props.powerSearchInitialState,
|
|
}),
|
|
);
|
|
|
|
const stateRef = useRef(tableState);
|
|
stateRef.current = tableState;
|
|
const searchInputRef = useRef<InputRef>(null) as MutableRefObject<InputRef>;
|
|
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<any>).current = tableManager;
|
|
}
|
|
|
|
const {columns, selection, searchExpression, 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 powerSearchConfig: PowerSearchConfig = useMemo(() => {
|
|
const res: PowerSearchConfig = {fields: {}};
|
|
|
|
for (const column of columns) {
|
|
if (column.powerSearchConfig === false) {
|
|
continue;
|
|
}
|
|
const columnFieldConfig: FieldConfig = {
|
|
label: column.title ?? column.key,
|
|
key: column.key,
|
|
// If no power search config provided we treat every input as a string
|
|
operators: column.powerSearchConfig?.reduce((res, operatorConfig) => {
|
|
res[operatorConfig.key] = operatorConfig;
|
|
return res;
|
|
}, {} as Record<string, OperatorConfig>) ?? {
|
|
string_contains: dataTablePowerSearchOperators.string_contains(true),
|
|
string_not_contains:
|
|
dataTablePowerSearchOperators.string_not_contains(true),
|
|
string_matches_exactly:
|
|
dataTablePowerSearchOperators.string_matches_exactly(true),
|
|
string_not_matches_exactly:
|
|
dataTablePowerSearchOperators.string_not_matches_exactly(true),
|
|
},
|
|
};
|
|
res.fields[column.key] = columnFieldConfig;
|
|
}
|
|
|
|
return res;
|
|
}, [columns]);
|
|
|
|
const renderingConfig = useMemo<TableRowRenderContext<T>>(() => {
|
|
let startIndex = 0;
|
|
|
|
return {
|
|
columns: visibleColumns as DataTableColumn<T>[],
|
|
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<T>,
|
|
) {
|
|
return (
|
|
<TableRow
|
|
key={index}
|
|
config={renderContext}
|
|
record={record}
|
|
itemIndex={index}
|
|
highlighted={
|
|
index === selection.current || selection.items.has(index)
|
|
}
|
|
style={onRowStyle?.(record)}
|
|
/>
|
|
);
|
|
},
|
|
[selection, onRowStyle],
|
|
);
|
|
|
|
/**
|
|
* Keyboard / selection handling
|
|
*/
|
|
const onKeyDown = useCallback(
|
|
(e: React.KeyboardEvent<any>) => {
|
|
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 'f':
|
|
if (controlPressed && searchInputRef?.current) {
|
|
searchInputRef?.current.focus();
|
|
}
|
|
break;
|
|
default:
|
|
handled = false;
|
|
}
|
|
if (handled) {
|
|
e.stopPropagation();
|
|
e.preventDefault();
|
|
}
|
|
},
|
|
[dataView, props.scrollable, tableManager],
|
|
);
|
|
|
|
const [setFilter] = useState(() => (tableState: DataManagerState<T>) => {
|
|
const selectedEntry =
|
|
tableState.selection.current >= 0
|
|
? dataView.getEntry(tableState.selection.current)
|
|
: null;
|
|
dataView.setFilter(
|
|
computeDataTableFilter(
|
|
tableState.searchExpression,
|
|
dataTablePowerSearchOperatorProcessorConfig,
|
|
),
|
|
);
|
|
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.searchExpression,
|
|
// 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<any>();
|
|
|
|
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(
|
|
() => (
|
|
<Menu.Item key="toggle side by side">
|
|
<Layout.Horizontal
|
|
gap
|
|
center
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
e.preventDefault();
|
|
}}>
|
|
Side By Side View
|
|
<Switch
|
|
checked={tableState.sideBySide}
|
|
size="small"
|
|
onChange={() => {
|
|
tableManager.toggleSideBySide();
|
|
}}
|
|
/>
|
|
</Layout.Horizontal>
|
|
</Menu.Item>
|
|
),
|
|
[tableManager, tableState.sideBySide],
|
|
);
|
|
|
|
/** Context menu */
|
|
const contexMenu = isUnitTest
|
|
? undefined
|
|
: // eslint-disable-next-line
|
|
useCallback(
|
|
() =>
|
|
tableContextMenuFactory(
|
|
dataView,
|
|
dispatch as any,
|
|
selection,
|
|
tableState.columns,
|
|
visibleColumns,
|
|
onCopyRows,
|
|
onContextMenu,
|
|
props.enableMultiPanels ? sidePanelToggle : undefined,
|
|
),
|
|
[
|
|
dataView,
|
|
selection,
|
|
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<any>).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 = (
|
|
<Layout.Container>
|
|
{props.actionsTop ? <Searchbar gap>{props.actionsTop}</Searchbar> : null}
|
|
{props.enableSearchbar && (
|
|
<Searchbar gap>
|
|
<PowerSearch
|
|
config={powerSearchConfig}
|
|
initialSearchExpression={searchExpression}
|
|
onSearchExpressionChange={(newSearchExpression) => {
|
|
tableManager.setSearchExpression(newSearchExpression);
|
|
}}
|
|
/>
|
|
{contexMenu && (
|
|
<Dropdown overlay={contexMenu} placement="bottomRight">
|
|
<Button type="text" size="small" style={{height: '100%'}}>
|
|
<MenuOutlined />
|
|
</Button>
|
|
</Dropdown>
|
|
)}
|
|
{props.actionsRight}
|
|
</Searchbar>
|
|
)}
|
|
</Layout.Container>
|
|
);
|
|
const columnHeaders = (
|
|
<Layout.Container>
|
|
{props.enableColumnHeaders && (
|
|
<TableHead
|
|
visibleColumns={visibleColumns}
|
|
dispatch={dispatch as any}
|
|
sorting={sorting}
|
|
scrollbarSize={
|
|
props.scrollable
|
|
? 0
|
|
: 15 /* width on MacOS: TODO, determine dynamically */
|
|
}
|
|
isFilterable={false}
|
|
/>
|
|
)}
|
|
</Layout.Container>
|
|
);
|
|
|
|
const emptyRenderer =
|
|
props.onRenderEmpty === undefined
|
|
? createDefaultEmptyRenderer(tableManager)
|
|
: props.onRenderEmpty;
|
|
|
|
let mainSection: JSX.Element;
|
|
if (props.scrollable) {
|
|
const dataSourceRenderer = (
|
|
<DataSourceRendererVirtual<T, TableRowRenderContext<T>>
|
|
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 ? (
|
|
<Layout.Top>
|
|
{header}
|
|
<Layout.ScrollContainer horizontal vertical={false}>
|
|
<Layout.Top>
|
|
{columnHeaders}
|
|
{dataSourceRenderer}
|
|
</Layout.Top>
|
|
</Layout.ScrollContainer>
|
|
</Layout.Top>
|
|
) : (
|
|
<Layout.Top>
|
|
<div>
|
|
{header}
|
|
{columnHeaders}
|
|
</div>
|
|
{dataSourceRenderer}
|
|
</Layout.Top>
|
|
);
|
|
} else {
|
|
mainSection = (
|
|
<Layout.Container>
|
|
{header}
|
|
{columnHeaders}
|
|
<DataSourceRendererStatic<T, TableRowRenderContext<T>>
|
|
dataView={dataView}
|
|
useFixedRowHeight={!tableState.usesWrapping}
|
|
defaultRowHeight={DEFAULT_ROW_HEIGHT}
|
|
context={renderingConfig}
|
|
maxRecords={dataSource.limit}
|
|
itemRenderer={itemRenderer}
|
|
onKeyDown={onKeyDown}
|
|
emptyRenderer={emptyRenderer}
|
|
/>
|
|
</Layout.Container>
|
|
);
|
|
}
|
|
const mainPanel = (
|
|
<Layout.Container grow={props.scrollable} style={{position: 'relative'}}>
|
|
{mainSection}
|
|
{props.enableAutoScroll && (
|
|
<AutoScroller>
|
|
<PushpinFilled
|
|
style={{
|
|
color: tableState.autoScroll ? theme.successColor : undefined,
|
|
}}
|
|
onClick={() => {
|
|
dispatch({type: 'toggleAutoScroll'});
|
|
}}
|
|
/>
|
|
</AutoScroller>
|
|
)}
|
|
{range && !isUnitTest && <RangeFinder>{range}</RangeFinder>}
|
|
</Layout.Container>
|
|
);
|
|
return props.enableMultiPanels && tableState.sideBySide ? (
|
|
//TODO: Make the panels resizable by having a dynamic maxWidth for Layout.Right/Left possibly?
|
|
<Layout.Horizontal style={{height: '100%'}}>
|
|
{mainPanel}
|
|
{<DataTable<T> viewId={'1'} {...props} enableMultiPanels={false} />}
|
|
</Layout.Horizontal>
|
|
) : (
|
|
mainPanel
|
|
);
|
|
}
|
|
|
|
DataTable.defaultProps = {
|
|
scrollable: true,
|
|
enableSearchbar: true,
|
|
enableAutoScroll: false,
|
|
enableHorizontalScroll: true,
|
|
enableColumnHeaders: true,
|
|
enableMultiSelect: true,
|
|
enableContextMenu: true,
|
|
enablePersistSettings: true,
|
|
onRenderEmpty: undefined,
|
|
} as Partial<DataTableProps<any>>;
|
|
|
|
/* eslint-disable react-hooks/rules-of-hooks */
|
|
function normalizeDataSourceInput<T>(
|
|
props: DataTableInput<T>,
|
|
): DataSource<T, T[keyof T] | never> {
|
|
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<T>(
|
|
ds: DataSource<T, T[keyof T] | never>,
|
|
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<T>(dataTableManager?: DataTableManager<T>) {
|
|
return (dataView?: _DataSourceView<T, T[keyof T]>) => (
|
|
<EmptyTable dataView={dataView} dataManager={dataTableManager} />
|
|
);
|
|
}
|
|
|
|
function EmptyTable<T>({
|
|
dataView,
|
|
dataManager,
|
|
}: {
|
|
dataView?: _DataSourceView<T, T[keyof T]>;
|
|
dataManager?: DataTableManager<T>;
|
|
}) {
|
|
const resetFilters = useCallback(() => {
|
|
dataManager?.resetFilters();
|
|
}, [dataManager]);
|
|
return (
|
|
<Layout.Container
|
|
center
|
|
style={{width: '100%', padding: 40, color: theme.textColorSecondary}}>
|
|
{dataView?.size === 0 ? (
|
|
<>
|
|
<CoffeeOutlined style={{fontSize: '2em', margin: 8}} />
|
|
<Typography.Text type="secondary">No records yet</Typography.Text>
|
|
</>
|
|
) : (
|
|
<>
|
|
<SearchOutlined style={{fontSize: '2em', margin: 8}} />
|
|
<Typography.Text type="secondary">
|
|
No records match the current search / filter criteria.
|
|
</Typography.Text>
|
|
<Typography.Text>
|
|
<Typography.Link onClick={resetFilters}>
|
|
Reset filters
|
|
</Typography.Link>
|
|
</Typography.Text>
|
|
</>
|
|
)}
|
|
</Layout.Container>
|
|
);
|
|
}
|
|
|
|
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',
|
|
});
|