in desktop/flipper-plugin/src/ui/data-table/DataTable.tsx [133:612]
export function DataTable<T extends object>(
props: DataTableProps<T>,
): React.ReactElement {
const {onRowStyle, onSelect, onCopyRows, onContextMenu} = props;
const dataSource = normalizeDataSourceInput(props);
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 ?? '';
const virtualizerRef = useRef<DataSourceVirtualizer | undefined>();
const [tableState, dispatch] = useReducer(
dataTableManagerReducer as DataTableReducer<T>,
undefined,
() =>
createInitialState({
dataSource,
defaultColumns: props.columns,
onSelect,
scope,
virtualizerRef,
autoScroll: props.enableAutoScroll,
enablePersistSettings: props.enablePersistSettings,
}),
);
const stateRef = useRef(tableState);
stateRef.current = tableState;
const lastOffset = useRef(0);
const dragging = useRef(false);
const [tableManager] = useState(() =>
createDataTableManager(dataSource, dispatch, stateRef),
);
if (props.tableManagerRef) {
(props.tableManagerRef as MutableRefObject<any>).current = tableManager;
}
const {columns, selection, searchValue, sorting} = tableState;
const visibleColumns = useMemo(
() => columns.filter((column) => column.visible),
[columns],
);
const renderingConfig = useMemo<TableRowRenderContext<T>>(() => {
let startIndex = 0;
return {
columns: visibleColumns,
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
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 = dataSource.view.size;
const windowSize = props.scrollable
? virtualizerRef.current?.virtualItems.length ?? 0
: dataSource.view.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 'Control':
tableManager.setSelectedSearchRecord();
break;
default:
handled = false;
}
if (handled) {
e.stopPropagation();
e.preventDefault();
}
},
[dataSource, tableManager, props.scrollable],
);
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 :))
const setFilter = (
search: string,
useRegex: boolean,
columns: DataTableColumn<T>[],
) => {
dataSource.view.setFilter(
computeDataTableFilter(search, useRegex, columns),
);
};
return isUnitTest ? setFilter : debounce(setFilter, 250);
});
useEffect(
function updateFilter() {
debouncedSetFilter(
tableState.searchValue,
tableState.useRegex,
tableState.columns,
);
},
// 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),
],
);
useEffect(
function updateSorting() {
if (tableState.sorting === undefined) {
dataSource.view.setSortBy(undefined);
dataSource.view.setReversed(false);
} else {
dataSource.view.setSortBy(tableState.sorting.key);
dataSource.view.setReversed(tableState.sorting.direction === 'desc');
}
},
[dataSource, tableState.sorting],
);
const isMounted = useRef(false);
useEffect(
function triggerSelection() {
if (isMounted.current) {
onSelect?.(
getSelectedItem(dataSource, tableState.selection),
getSelectedItems(dataSource, tableState.selection),
);
}
isMounted.current = true;
},
[onSelect, dataSource, 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});
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;
clearTimeout(hideRange.current!);
hideRange.current = setTimeout(() => {
setRange('');
}, 1000);
},
[],
);
const onUpdateAutoScroll = useCallback(
(autoScroll: boolean) => {
if (props.enableAutoScroll) {
dispatch({type: 'setAutoScroll', autoScroll});
}
},
[props.enableAutoScroll],
);
/** Context menu */
const contexMenu = isUnitTest
? undefined
: // eslint-disable-next-line
useCallback(
() =>
tableContextMenuFactory(
dataSource,
dispatch,
selection,
tableState.columns,
visibleColumns,
onCopyRows,
onContextMenu,
),
[
dataSource,
dispatch,
selection,
tableState.columns,
visibleColumns,
onCopyRows,
onContextMenu,
],
);
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
dataSource.view.reset();
// clean ref
if (props.tableManagerRef) {
(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
}, []);
useEffect(
function findMappedIndex() {
// Hardcoded delay to give dataSource.view time to update, otherwise
// the entries we loop over here won't be the list of unfiltered records
// the user sees, so there won't be a match found
const delay = 300;
if (tableState.selectedSearchRecord) {
const timer = setTimeout(() => {
for (let i = 0; i < dataSource.view.size; i++) {
if (dataSource.view.get(i) === tableState.selectedSearchRecord) {
tableManager.clearSelectedSearchRecord();
tableManager.selectItem(i, false, true);
break;
}
}
}, delay);
return () => clearTimeout(timer);
}
},
[dataSource, selection, tableManager, tableState.selectedSearchRecord],
);
const header = (
<Layout.Container>
{props.enableSearchbar && (
<TableSearch
searchValue={searchValue}
useRegex={tableState.useRegex}
dispatch={dispatch as any}
contextMenu={props.enableContextMenu ? contexMenu : undefined}
extraActions={props.extraActions}
/>
)}
</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 */
}
/>
)}
</Layout.Container>
);
const emptyRenderer =
props.onRenderEmpty === undefined
? props.onRenderEmpty
: props.onRenderEmpty;
let mainSection: JSX.Element;
if (props.scrollable) {
const dataSourceRenderer = (
<DataSourceRendererVirtual<T, TableRowRenderContext<T>>
dataSource={dataSource}
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>>
dataSource={dataSource}
useFixedRowHeight={!tableState.usesWrapping}
defaultRowHeight={DEFAULT_ROW_HEIGHT}
context={renderingConfig}
itemRenderer={itemRenderer}
onKeyDown={onKeyDown}
emptyRenderer={emptyRenderer}
/>
</Layout.Container>
);
}
return (
<Layout.Container grow={props.scrollable}>
{mainSection}
{props.enableAutoScroll && (
<AutoScroller>
<PushpinFilled
style={{
color: tableState.autoScroll ? theme.successColor : undefined,
}}
onClick={() => {
dispatch({type: 'toggleAutoScroll'});
}}
/>
</AutoScroller>
)}
{range && !isUnitTest && <RangeFinder>{range}</RangeFinder>}
</Layout.Container>
);
}