Support natural sizing
Summary:
Introduced the `scrollable={false}` option to DataTable, that gives the table its natural size, while still having all the other gimmicks of DataTable, like search, filter, etc etc.
To implement this, a non-virtualizing rendering is needed, which is handled by the `StaticDataSourceRenderer`
Also introduced the option to hide the searchbar.
Reviewed By: nikoant
Differential Revision: D28036469
fbshipit-source-id: 633c4f7f3fabfa99efa2839059aaa59b0a407ada
This commit is contained in:
committed by
Facebook GitHub Bot
parent
e423bc7959
commit
d6c74c4e2f
@@ -46,6 +46,7 @@ import {useAssertStableRef} from '../../utils/useAssertStableRef';
|
|||||||
import {Formatter} from '../DataFormatter';
|
import {Formatter} from '../DataFormatter';
|
||||||
import {usePluginInstance} from '../../plugin/PluginContext';
|
import {usePluginInstance} from '../../plugin/PluginContext';
|
||||||
import {debounce} from 'lodash';
|
import {debounce} from 'lodash';
|
||||||
|
import {StaticDataSourceRenderer} from './StaticDataSourceRenderer';
|
||||||
|
|
||||||
interface DataTableProps<T = any> {
|
interface DataTableProps<T = any> {
|
||||||
columns: DataTableColumn<T>[];
|
columns: DataTableColumn<T>[];
|
||||||
@@ -59,6 +60,8 @@ interface DataTableProps<T = any> {
|
|||||||
_testHeight?: number; // exposed for unit testing only
|
_testHeight?: number; // exposed for unit testing only
|
||||||
onCopyRows?(records: T[]): string;
|
onCopyRows?(records: T[]): string;
|
||||||
onContextMenu?: (selection: undefined | T) => React.ReactElement;
|
onContextMenu?: (selection: undefined | T) => React.ReactElement;
|
||||||
|
searchbar?: boolean;
|
||||||
|
scrollable?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
type DataTableInput<T = any> =
|
type DataTableInput<T = any> =
|
||||||
@@ -403,23 +406,34 @@ export function DataTable<T extends object>(
|
|||||||
// eslint-disable-next-line
|
// eslint-disable-next-line
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
const header = (
|
||||||
<Layout.Container grow>
|
<Layout.Container>
|
||||||
|
{props.searchbar !== false && (
|
||||||
|
<TableSearch
|
||||||
|
searchValue={searchValue}
|
||||||
|
useRegex={tableState.useRegex}
|
||||||
|
dispatch={dispatch as any}
|
||||||
|
contextMenu={contexMenu}
|
||||||
|
extraActions={props.extraActions}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<TableHead
|
||||||
|
visibleColumns={visibleColumns}
|
||||||
|
dispatch={dispatch as any}
|
||||||
|
sorting={sorting}
|
||||||
|
scrollbarSize={
|
||||||
|
props.scrollable === false
|
||||||
|
? 0
|
||||||
|
: 15 /* width on MacOS: TODO, determine dynamically */
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Layout.Container>
|
||||||
|
);
|
||||||
|
|
||||||
|
const mainSection =
|
||||||
|
props.scrollable !== false ? (
|
||||||
<Layout.Top>
|
<Layout.Top>
|
||||||
<Layout.Container>
|
{header}
|
||||||
<TableSearch
|
|
||||||
searchValue={searchValue}
|
|
||||||
useRegex={tableState.useRegex}
|
|
||||||
dispatch={dispatch as any}
|
|
||||||
contextMenu={contexMenu}
|
|
||||||
extraActions={props.extraActions}
|
|
||||||
/>
|
|
||||||
<TableHead
|
|
||||||
visibleColumns={visibleColumns}
|
|
||||||
dispatch={dispatch as any}
|
|
||||||
sorting={sorting}
|
|
||||||
/>
|
|
||||||
</Layout.Container>
|
|
||||||
<DataSourceRenderer<T, RenderContext<T>>
|
<DataSourceRenderer<T, RenderContext<T>>
|
||||||
dataSource={dataSource}
|
dataSource={dataSource}
|
||||||
autoScroll={tableState.autoScroll && !dragging.current}
|
autoScroll={tableState.autoScroll && !dragging.current}
|
||||||
@@ -435,6 +449,24 @@ export function DataTable<T extends object>(
|
|||||||
_testHeight={props._testHeight}
|
_testHeight={props._testHeight}
|
||||||
/>
|
/>
|
||||||
</Layout.Top>
|
</Layout.Top>
|
||||||
|
) : (
|
||||||
|
<Layout.Container>
|
||||||
|
{header}
|
||||||
|
<StaticDataSourceRenderer<T, RenderContext<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 !== false}>
|
||||||
|
{mainSection}
|
||||||
{props.autoScroll && (
|
{props.autoScroll && (
|
||||||
<AutoScroller>
|
<AutoScroller>
|
||||||
<PushpinFilled
|
<PushpinFilled
|
||||||
@@ -481,7 +513,7 @@ function syncRecordsToDataSource<T>(ds: DataSource<T>, records: T[]) {
|
|||||||
// TODO: optimize in the case we're only dealing with appends or replacements
|
// TODO: optimize in the case we're only dealing with appends or replacements
|
||||||
records.forEach((r) => ds.append(r));
|
records.forEach((r) => ds.append(r));
|
||||||
const duration = Math.abs(Date.now() - startTime);
|
const duration = Math.abs(Date.now() - startTime);
|
||||||
if (duration > 50 || records.length > 1000) {
|
if (duration > 50 || records.length > 500) {
|
||||||
console.warn(
|
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",
|
"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",
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,111 @@
|
|||||||
|
/**
|
||||||
|
* 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 React, {memo, useCallback, useEffect, useState} from 'react';
|
||||||
|
import {DataSource} from '../../state/DataSource';
|
||||||
|
import {useVirtual} from 'react-virtual';
|
||||||
|
import styled from '@emotion/styled';
|
||||||
|
import {RedrawContext} from './DataSourceRenderer';
|
||||||
|
|
||||||
|
export type DataSourceVirtualizer = ReturnType<typeof useVirtual>;
|
||||||
|
|
||||||
|
type DataSourceProps<T extends object, C> = {
|
||||||
|
/**
|
||||||
|
* The data source to render
|
||||||
|
*/
|
||||||
|
dataSource: DataSource<T, any, any>;
|
||||||
|
/**
|
||||||
|
* additional context that will be passed verbatim to the itemRenderer, so that it can be easily memoized
|
||||||
|
*/
|
||||||
|
context?: C;
|
||||||
|
/**
|
||||||
|
* Takes care of rendering an item
|
||||||
|
* @param item The item as stored in the dataSource
|
||||||
|
* @param index The index of the item being rendered. The index represents the offset in the _visible_ items of the dataSource
|
||||||
|
* @param context The optional context passed into this DataSourceRenderer
|
||||||
|
*/
|
||||||
|
itemRenderer(item: T, index: number, context: C): React.ReactElement;
|
||||||
|
useFixedRowHeight: boolean;
|
||||||
|
defaultRowHeight: number;
|
||||||
|
onKeyDown?: React.KeyboardEventHandler<HTMLDivElement>;
|
||||||
|
onUpdateAutoScroll?(autoScroll: boolean): void;
|
||||||
|
emptyRenderer?(dataSource: DataSource<T>): React.ReactElement;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This component is UI agnostic, and just takes care of rendering all items in the DataSource.
|
||||||
|
* This component does not apply virtualization, so don't use it for large datasets!
|
||||||
|
*/
|
||||||
|
export const StaticDataSourceRenderer: <T extends object, C>(
|
||||||
|
props: DataSourceProps<T, C>,
|
||||||
|
) => React.ReactElement = memo(function StaticDataSourceRenderer({
|
||||||
|
dataSource,
|
||||||
|
useFixedRowHeight,
|
||||||
|
context,
|
||||||
|
itemRenderer,
|
||||||
|
onKeyDown,
|
||||||
|
emptyRenderer,
|
||||||
|
}: DataSourceProps<any, any>) {
|
||||||
|
/**
|
||||||
|
* Virtualization
|
||||||
|
*/
|
||||||
|
// render scheduling
|
||||||
|
const [, setForceUpdate] = useState(0);
|
||||||
|
|
||||||
|
const redraw = useCallback(() => {
|
||||||
|
setForceUpdate((x) => x + 1);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(
|
||||||
|
function subscribeToDataSource() {
|
||||||
|
let unmounted = false;
|
||||||
|
|
||||||
|
dataSource.view.setWindow(0, dataSource.limit);
|
||||||
|
dataSource.view.setListener((_event) => {
|
||||||
|
if (unmounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setForceUpdate((x) => x + 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
unmounted = true;
|
||||||
|
dataSource.view.setListener(undefined);
|
||||||
|
};
|
||||||
|
},
|
||||||
|
[dataSource, setForceUpdate, useFixedRowHeight],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// initial virtualization is incorrect because the parent ref is not yet set, so trigger render after mount
|
||||||
|
setForceUpdate((x) => x + 1);
|
||||||
|
}, [setForceUpdate]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rendering
|
||||||
|
*/
|
||||||
|
const records = dataSource.view.output();
|
||||||
|
if (records.length > 500) {
|
||||||
|
console.warn(
|
||||||
|
"StaticDataSourceRenderer should only be used on small datasets. For large datasets the 'scrollable' flag should enabled on DataTable",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<RedrawContext.Provider value={redraw}>
|
||||||
|
<div onKeyDown={onKeyDown}>
|
||||||
|
{records.length === 0
|
||||||
|
? emptyRenderer?.(dataSource)
|
||||||
|
: records.map((item, index) => (
|
||||||
|
<div key={index}>{itemRenderer(item, index, context)}</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</RedrawContext.Provider>
|
||||||
|
);
|
||||||
|
}) as any;
|
||||||
@@ -104,19 +104,21 @@ const TableHeadColumnContainer = styled.div<{
|
|||||||
}));
|
}));
|
||||||
TableHeadColumnContainer.displayName = 'TableHead:TableHeadColumnContainer';
|
TableHeadColumnContainer.displayName = 'TableHead:TableHeadColumnContainer';
|
||||||
|
|
||||||
const TableHeadContainer = styled.div({
|
const TableHeadContainer = styled.div<{scrollbarSize: number}>(
|
||||||
position: 'relative',
|
({scrollbarSize}) => ({
|
||||||
display: 'flex',
|
position: 'relative',
|
||||||
flexDirection: 'row',
|
display: 'flex',
|
||||||
borderBottom: `1px solid ${theme.dividerColor}`,
|
flexDirection: 'row',
|
||||||
backgroundColor: theme.backgroundWash,
|
borderBottom: `1px solid ${theme.dividerColor}`,
|
||||||
userSelect: 'none',
|
backgroundColor: theme.backgroundWash,
|
||||||
whiteSpace: 'nowrap',
|
userSelect: 'none',
|
||||||
borderLeft: `4px solid ${theme.backgroundWash}`, // space for selection, see TableRow
|
whiteSpace: 'nowrap',
|
||||||
// hardcoded value to correct for the scrollbar in the main container.
|
borderLeft: `4px solid ${theme.backgroundWash}`, // space for selection, see TableRow
|
||||||
// ideally we should measure this instead.
|
// hardcoded value to correct for the scrollbar in the main container.
|
||||||
paddingRight: 15,
|
// ideally we should measure this instead.
|
||||||
});
|
paddingRight: scrollbarSize,
|
||||||
|
}),
|
||||||
|
);
|
||||||
TableHeadContainer.displayName = 'TableHead:TableHeadContainer';
|
TableHeadContainer.displayName = 'TableHead:TableHeadContainer';
|
||||||
|
|
||||||
const RIGHT_RESIZABLE = {right: true};
|
const RIGHT_RESIZABLE = {right: true};
|
||||||
@@ -221,13 +223,15 @@ export const TableHead = memo(function TableHead({
|
|||||||
visibleColumns,
|
visibleColumns,
|
||||||
dispatch,
|
dispatch,
|
||||||
sorting,
|
sorting,
|
||||||
|
scrollbarSize,
|
||||||
}: {
|
}: {
|
||||||
dispatch: DataTableDispatch<any>;
|
dispatch: DataTableDispatch<any>;
|
||||||
visibleColumns: DataTableColumn<any>[];
|
visibleColumns: DataTableColumn<any>[];
|
||||||
sorting: Sorting | undefined;
|
sorting: Sorting | undefined;
|
||||||
|
scrollbarSize: number;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<TableHeadContainer>
|
<TableHeadContainer scrollbarSize={scrollbarSize}>
|
||||||
{visibleColumns.map((column, i) => (
|
{visibleColumns.map((column, i) => (
|
||||||
<TableHeadColumn
|
<TableHeadColumn
|
||||||
key={column.key}
|
key={column.key}
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ TableBodyRowContainer.displayName = 'TableRow:TableBodyRowContainer';
|
|||||||
const TableBodyColumnContainer = styled.div<{
|
const TableBodyColumnContainer = styled.div<{
|
||||||
width: Width;
|
width: Width;
|
||||||
multiline?: boolean;
|
multiline?: boolean;
|
||||||
justifyContent: 'left' | 'right' | 'center' | 'flex-start';
|
justifyContent: 'left' | 'right' | 'center';
|
||||||
}>((props) => ({
|
}>((props) => ({
|
||||||
display: 'block',
|
display: 'block',
|
||||||
flexShrink: props.width === undefined ? 1 : 0,
|
flexShrink: props.width === undefined ? 1 : 0,
|
||||||
@@ -80,6 +80,7 @@ const TableBodyColumnContainer = styled.div<{
|
|||||||
whiteSpace: props.multiline ? 'pre-wrap' : 'nowrap',
|
whiteSpace: props.multiline ? 'pre-wrap' : 'nowrap',
|
||||||
wordWrap: props.multiline ? 'break-word' : 'normal',
|
wordWrap: props.multiline ? 'break-word' : 'normal',
|
||||||
width: props.width,
|
width: props.width,
|
||||||
|
textAlign: props.justifyContent,
|
||||||
justifyContent: props.justifyContent,
|
justifyContent: props.justifyContent,
|
||||||
'&::selection': {
|
'&::selection': {
|
||||||
color: 'inherit',
|
color: 'inherit',
|
||||||
@@ -128,7 +129,7 @@ export const TableRow = memo(function TableRow({
|
|||||||
<TableBodyColumnContainer
|
<TableBodyColumnContainer
|
||||||
key={col.key as string}
|
key={col.key as string}
|
||||||
multiline={col.wrap}
|
multiline={col.wrap}
|
||||||
justifyContent={col.align ? col.align : 'flex-start'}
|
justifyContent={col.align ? col.align : 'left'}
|
||||||
width={col.width}>
|
width={col.width}>
|
||||||
{value}
|
{value}
|
||||||
</TableBodyColumnContainer>
|
</TableBodyColumnContainer>
|
||||||
|
|||||||
@@ -58,12 +58,12 @@ test('update and append', async () => {
|
|||||||
class="css-1k3kr6b-TableBodyRowContainer e1luu51r1"
|
class="css-1k3kr6b-TableBodyRowContainer e1luu51r1"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="css-744e08-TableBodyColumnContainer e1luu51r0"
|
class="css-esqhnb-TableBodyColumnContainer e1luu51r0"
|
||||||
>
|
>
|
||||||
test DataTable
|
test DataTable
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
class="css-744e08-TableBodyColumnContainer e1luu51r0"
|
class="css-esqhnb-TableBodyColumnContainer e1luu51r0"
|
||||||
>
|
>
|
||||||
true
|
true
|
||||||
</div>
|
</div>
|
||||||
@@ -115,12 +115,12 @@ test('column visibility', async () => {
|
|||||||
class="css-1k3kr6b-TableBodyRowContainer e1luu51r1"
|
class="css-1k3kr6b-TableBodyRowContainer e1luu51r1"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="css-744e08-TableBodyColumnContainer e1luu51r0"
|
class="css-esqhnb-TableBodyColumnContainer e1luu51r0"
|
||||||
>
|
>
|
||||||
test DataTable
|
test DataTable
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
class="css-744e08-TableBodyColumnContainer e1luu51r0"
|
class="css-esqhnb-TableBodyColumnContainer e1luu51r0"
|
||||||
>
|
>
|
||||||
true
|
true
|
||||||
</div>
|
</div>
|
||||||
@@ -140,7 +140,7 @@ test('column visibility', async () => {
|
|||||||
class="css-1k3kr6b-TableBodyRowContainer e1luu51r1"
|
class="css-1k3kr6b-TableBodyRowContainer e1luu51r1"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="css-744e08-TableBodyColumnContainer e1luu51r0"
|
class="css-esqhnb-TableBodyColumnContainer e1luu51r0"
|
||||||
>
|
>
|
||||||
test DataTable
|
test DataTable
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -49,12 +49,12 @@ test('update and append', async () => {
|
|||||||
class="css-1k3kr6b-TableBodyRowContainer e1luu51r1"
|
class="css-1k3kr6b-TableBodyRowContainer e1luu51r1"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="css-744e08-TableBodyColumnContainer e1luu51r0"
|
class="css-esqhnb-TableBodyColumnContainer e1luu51r0"
|
||||||
>
|
>
|
||||||
test DataTable
|
test DataTable
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
class="css-744e08-TableBodyColumnContainer e1luu51r0"
|
class="css-esqhnb-TableBodyColumnContainer e1luu51r0"
|
||||||
>
|
>
|
||||||
true
|
true
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user