Store preferences and scrolling, refactor to useReducer
Reviewed By: priteshrnandgaonkar Differential Revision: D26848266 fbshipit-source-id: 738d52556b9fb65ec5b5de7c727467227167b9b9
This commit is contained in:
committed by
Facebook GitHub Bot
parent
55981b5259
commit
a610c821d3
@@ -78,7 +78,7 @@ export {Idler} from './utils/Idler';
|
|||||||
export {createDataSource, DataSource} from './state/datasource/DataSource';
|
export {createDataSource, DataSource} from './state/datasource/DataSource';
|
||||||
|
|
||||||
export {DataTable, DataTableColumn} from './ui/datatable/DataTable';
|
export {DataTable, DataTableColumn} from './ui/datatable/DataTable';
|
||||||
export {DataTableManager} from './ui/datatable/useDataTableManager';
|
export {DataTableManager} from './ui/datatable/DataTableManager';
|
||||||
|
|
||||||
export {
|
export {
|
||||||
Interactive as _Interactive,
|
Interactive as _Interactive,
|
||||||
|
|||||||
@@ -297,6 +297,7 @@ export class DataSource<
|
|||||||
setFilter(filter: undefined | ((value: T) => boolean)) {
|
setFilter(filter: undefined | ((value: T) => boolean)) {
|
||||||
if (this.filter !== filter) {
|
if (this.filter !== filter) {
|
||||||
this.filter = filter;
|
this.filter = filter;
|
||||||
|
// TODO: this needs debouncing!
|
||||||
this.rebuildOutput();
|
this.rebuildOutput();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -203,11 +203,11 @@ const SandySplitContainer = styled.div<{
|
|||||||
alignItems: props.center ? 'center' : 'stretch',
|
alignItems: props.center ? 'center' : 'stretch',
|
||||||
gap: normalizeSpace(props.gap, theme.space.small),
|
gap: normalizeSpace(props.gap, theme.space.small),
|
||||||
overflow: props.center ? undefined : 'hidden', // only use overflow hidden in container mode, to avoid weird resizing issues
|
overflow: props.center ? undefined : 'hidden', // only use overflow hidden in container mode, to avoid weird resizing issues
|
||||||
'>:nth-of-type(1)': {
|
'>:nth-child(1)': {
|
||||||
flex: props.grow === 1 ? splitGrowStyle : splitFixedStyle,
|
flex: props.grow === 1 ? splitGrowStyle : splitFixedStyle,
|
||||||
minWidth: props.grow === 1 ? 0 : undefined,
|
minWidth: props.grow === 1 ? 0 : undefined,
|
||||||
},
|
},
|
||||||
'>:nth-of-type(2)': {
|
'>:nth-child(2)': {
|
||||||
flex: props.grow === 2 ? splitGrowStyle : splitFixedStyle,
|
flex: props.grow === 2 ? splitGrowStyle : splitFixedStyle,
|
||||||
minWidth: props.grow === 2 ? 0 : undefined,
|
minWidth: props.grow === 2 ? 0 : undefined,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ export function resetGlobalInteractionReporter() {
|
|||||||
|
|
||||||
const DEFAULT_SCOPE = 'Flipper';
|
const DEFAULT_SCOPE = 'Flipper';
|
||||||
|
|
||||||
const TrackingScopeContext = createContext(DEFAULT_SCOPE);
|
export const TrackingScopeContext = createContext(DEFAULT_SCOPE);
|
||||||
|
|
||||||
export function TrackingScope({
|
export function TrackingScope({
|
||||||
scope,
|
scope,
|
||||||
|
|||||||
@@ -11,32 +11,37 @@ import {useMemo, useState} from 'react';
|
|||||||
import styled from '@emotion/styled';
|
import styled from '@emotion/styled';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import {Button, Checkbox, Dropdown, Menu, Typography, Input} from 'antd';
|
import {Button, Checkbox, Dropdown, Menu, Typography, Input} from 'antd';
|
||||||
import {FilterFilled, MinusCircleOutlined} from '@ant-design/icons';
|
import {
|
||||||
|
FilterFilled,
|
||||||
|
MinusCircleOutlined,
|
||||||
|
PlusCircleOutlined,
|
||||||
|
} from '@ant-design/icons';
|
||||||
|
|
||||||
import {theme} from '../theme';
|
import {theme} from '../theme';
|
||||||
import type {DataTableColumn} from './DataTable';
|
import type {DataTableColumn} from './DataTable';
|
||||||
import {Layout} from '../Layout';
|
import {Layout} from '../Layout';
|
||||||
|
import type {DataTableDispatch} from './DataTableManager';
|
||||||
|
|
||||||
const {Text} = Typography;
|
const {Text} = Typography;
|
||||||
|
|
||||||
export type ColumnFilterHandlers = {
|
|
||||||
onAddColumnFilter(columnId: string, value: string): void;
|
|
||||||
onRemoveColumnFilter(columnId: string, index: number): void;
|
|
||||||
onToggleColumnFilter(columnId: string, index: number): void;
|
|
||||||
onSetColumnFilterFromSelection(columnId: string): void;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function FilterIcon({
|
export function FilterIcon({
|
||||||
column,
|
column,
|
||||||
...props
|
dispatch,
|
||||||
}: {column: DataTableColumn<any>} & ColumnFilterHandlers) {
|
}: {
|
||||||
|
column: DataTableColumn<any>;
|
||||||
|
dispatch: DataTableDispatch;
|
||||||
|
}) {
|
||||||
const [input, setInput] = useState('');
|
const [input, setInput] = useState('');
|
||||||
const {filters} = column;
|
const {filters} = column;
|
||||||
const isActive = useMemo(() => filters?.some((f) => f.enabled), [filters]);
|
const isActive = useMemo(() => filters?.some((f) => f.enabled), [filters]);
|
||||||
|
|
||||||
const onAddFilter = (e: React.MouseEvent | React.KeyboardEvent) => {
|
const onAddFilter = (e: React.MouseEvent | React.KeyboardEvent) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
props.onAddColumnFilter(column.key, input);
|
dispatch({
|
||||||
|
type: 'addColumnFilter',
|
||||||
|
column: column.key,
|
||||||
|
value: input,
|
||||||
|
});
|
||||||
setInput('');
|
setInput('');
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -60,7 +65,13 @@ export function FilterIcon({
|
|||||||
onPressEnter={onAddFilter}
|
onPressEnter={onAddFilter}
|
||||||
disabled={false}
|
disabled={false}
|
||||||
/>
|
/>
|
||||||
<Button onClick={onAddFilter}>Add</Button>
|
<Button
|
||||||
|
onClick={onAddFilter}
|
||||||
|
title="Add filter"
|
||||||
|
type="ghost"
|
||||||
|
style={{padding: '4px 8px'}}>
|
||||||
|
<PlusCircleOutlined />
|
||||||
|
</Button>
|
||||||
</Layout.Right>
|
</Layout.Right>
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
<Menu.Divider />
|
<Menu.Divider />
|
||||||
@@ -73,7 +84,11 @@ export function FilterIcon({
|
|||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
props.onToggleColumnFilter(column.key, index);
|
dispatch({
|
||||||
|
type: 'toggleColumnFilter',
|
||||||
|
column: column.key,
|
||||||
|
index,
|
||||||
|
});
|
||||||
}}>
|
}}>
|
||||||
{filter.label}
|
{filter.label}
|
||||||
</Checkbox>
|
</Checkbox>
|
||||||
@@ -81,7 +96,11 @@ export function FilterIcon({
|
|||||||
<MinusCircleOutlined
|
<MinusCircleOutlined
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
props.onRemoveColumnFilter(column.key, index);
|
dispatch({
|
||||||
|
type: 'removeColumnFilter',
|
||||||
|
column: column.key,
|
||||||
|
index,
|
||||||
|
});
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -89,52 +108,68 @@ export function FilterIcon({
|
|||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
<Text type="secondary" style={{margin: 12}}>
|
<Menu.Item disabled>
|
||||||
No active filters
|
<Text type="secondary" style={{margin: 12}}>
|
||||||
</Text>
|
No active filters
|
||||||
|
</Text>
|
||||||
|
</Menu.Item>
|
||||||
)}
|
)}
|
||||||
<Menu.Divider />
|
<Menu.Divider />
|
||||||
<div style={{textAlign: 'right'}}>
|
<Menu.Item disabled>
|
||||||
<Button
|
<div style={{textAlign: 'right'}}>
|
||||||
type="link"
|
<Button
|
||||||
style={{fontWeight: 'unset'}}
|
type="link"
|
||||||
onClick={() => {
|
style={{fontWeight: 'unset'}}
|
||||||
props.onSetColumnFilterFromSelection(column.key);
|
onClick={() => {
|
||||||
}}>
|
dispatch({
|
||||||
From selection
|
type: 'setColumnFilterFromSelection',
|
||||||
</Button>
|
column: column.key,
|
||||||
<Button
|
});
|
||||||
type="link"
|
}}>
|
||||||
style={{fontWeight: 'unset'}}
|
From selection
|
||||||
onClick={() => {
|
</Button>
|
||||||
filters?.forEach((f, index) => {
|
<Button
|
||||||
if (!f.enabled) props.onToggleColumnFilter(column.key, index);
|
type="link"
|
||||||
});
|
style={{fontWeight: 'unset'}}
|
||||||
}}>
|
onClick={() => {
|
||||||
All
|
filters?.forEach((f, index) => {
|
||||||
</Button>
|
if (!f.enabled) {
|
||||||
<Button
|
dispatch({
|
||||||
type="link"
|
type: 'toggleColumnFilter',
|
||||||
style={{fontWeight: 'unset'}}
|
column: column.key,
|
||||||
onClick={() => {
|
index,
|
||||||
filters?.forEach((f, index) => {
|
});
|
||||||
if (f.enabled) props.onToggleColumnFilter(column.key, index);
|
}
|
||||||
});
|
});
|
||||||
}}>
|
}}>
|
||||||
None
|
All
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
<Button
|
||||||
|
type="link"
|
||||||
|
style={{fontWeight: 'unset'}}
|
||||||
|
onClick={() => {
|
||||||
|
filters?.forEach((f, index) => {
|
||||||
|
if (f.enabled)
|
||||||
|
dispatch({
|
||||||
|
type: 'toggleColumnFilter',
|
||||||
|
column: column.key,
|
||||||
|
index,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}}>
|
||||||
|
None
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Menu.Item>
|
||||||
</Menu>
|
</Menu>
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<Dropdown overlay={menu} trigger={['click']}>
|
||||||
<Dropdown overlay={menu} trigger={['click']}>
|
<FilterButton isActive={isActive}>
|
||||||
<FilterButton isActive={isActive}>
|
<FilterFilled />
|
||||||
<FilterFilled />
|
</FilterButton>
|
||||||
</FilterButton>
|
</Dropdown>
|
||||||
</Dropdown>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -55,7 +55,12 @@ type DataSourceProps<T extends object, C> = {
|
|||||||
defaultRowHeight: number;
|
defaultRowHeight: number;
|
||||||
onKeyDown?: React.KeyboardEventHandler<HTMLDivElement>;
|
onKeyDown?: React.KeyboardEventHandler<HTMLDivElement>;
|
||||||
virtualizerRef?: MutableRefObject<DataSourceVirtualizer | undefined>;
|
virtualizerRef?: MutableRefObject<DataSourceVirtualizer | undefined>;
|
||||||
onRangeChange?(start: number, end: number, total: number): void;
|
onRangeChange?(
|
||||||
|
start: number,
|
||||||
|
end: number,
|
||||||
|
total: number,
|
||||||
|
offset: number,
|
||||||
|
): void;
|
||||||
emptyRenderer?(dataSource: DataSource<T>): React.ReactElement;
|
emptyRenderer?(dataSource: DataSource<T>): React.ReactElement;
|
||||||
_testHeight?: number; // exposed for unit testing only
|
_testHeight?: number; // exposed for unit testing only
|
||||||
};
|
};
|
||||||
@@ -181,7 +186,12 @@ export const DataSourceRenderer: <T extends object, C>(
|
|||||||
const start = virtualizer.virtualItems[0]?.index ?? 0;
|
const start = virtualizer.virtualItems[0]?.index ?? 0;
|
||||||
const end = start + virtualizer.virtualItems.length;
|
const end = start + virtualizer.virtualItems.length;
|
||||||
if (start !== dataSource.windowStart && !followOutput.current) {
|
if (start !== dataSource.windowStart && !followOutput.current) {
|
||||||
onRangeChange?.(start, end, dataSource.output.length);
|
onRangeChange?.(
|
||||||
|
start,
|
||||||
|
end,
|
||||||
|
dataSource.output.length,
|
||||||
|
parentRef.current?.scrollTop ?? 0,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
dataSource.setWindow(start, end);
|
dataSource.setWindow(start, end);
|
||||||
});
|
});
|
||||||
@@ -208,7 +218,7 @@ export const DataSourceRenderer: <T extends object, C>(
|
|||||||
} else {
|
} else {
|
||||||
followOutput.current = true;
|
followOutput.current = true;
|
||||||
}
|
}
|
||||||
}, [autoScroll]);
|
}, [autoScroll, parentRef]);
|
||||||
|
|
||||||
useLayoutEffect(function scrollToEnd() {
|
useLayoutEffect(function scrollToEnd() {
|
||||||
if (followOutput.current) {
|
if (followOutput.current) {
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ import React, {
|
|||||||
MutableRefObject,
|
MutableRefObject,
|
||||||
CSSProperties,
|
CSSProperties,
|
||||||
useEffect,
|
useEffect,
|
||||||
|
useContext,
|
||||||
|
useReducer,
|
||||||
} from 'react';
|
} from 'react';
|
||||||
import {TableRow, DEFAULT_ROW_HEIGHT} from './TableRow';
|
import {TableRow, DEFAULT_ROW_HEIGHT} from './TableRow';
|
||||||
import {DataSource} from '../../state/datasource/DataSource';
|
import {DataSource} from '../../state/datasource/DataSource';
|
||||||
@@ -24,7 +26,17 @@ import {Layout} from '../Layout';
|
|||||||
import {TableHead} from './TableHead';
|
import {TableHead} from './TableHead';
|
||||||
import {Percentage} from '../../utils/widthUtils';
|
import {Percentage} from '../../utils/widthUtils';
|
||||||
import {DataSourceRenderer, DataSourceVirtualizer} from './DataSourceRenderer';
|
import {DataSourceRenderer, DataSourceVirtualizer} from './DataSourceRenderer';
|
||||||
import {useDataTableManager, DataTableManager} from './useDataTableManager';
|
import {
|
||||||
|
computeDataTableFilter,
|
||||||
|
createDataTableManager,
|
||||||
|
createInitialState,
|
||||||
|
DataTableManager,
|
||||||
|
dataTableManagerReducer,
|
||||||
|
DataTableReducer,
|
||||||
|
getSelectedItem,
|
||||||
|
getSelectedItems,
|
||||||
|
savePreferences,
|
||||||
|
} from './DataTableManager';
|
||||||
import {TableSearch} from './TableSearch';
|
import {TableSearch} from './TableSearch';
|
||||||
import styled from '@emotion/styled';
|
import styled from '@emotion/styled';
|
||||||
import {theme} from '../theme';
|
import {theme} from '../theme';
|
||||||
@@ -32,6 +44,7 @@ import {tableContextMenuFactory} from './TableContextMenu';
|
|||||||
import {Typography} from 'antd';
|
import {Typography} from 'antd';
|
||||||
import {CoffeeOutlined, SearchOutlined} from '@ant-design/icons';
|
import {CoffeeOutlined, SearchOutlined} from '@ant-design/icons';
|
||||||
import {useAssertStableRef} from '../../utils/useAssertStableRef';
|
import {useAssertStableRef} from '../../utils/useAssertStableRef';
|
||||||
|
import {TrackingScopeContext} from 'flipper-plugin/src/ui/Tracked';
|
||||||
|
|
||||||
interface DataTableProps<T = any> {
|
interface DataTableProps<T = any> {
|
||||||
columns: DataTableColumn<T>[];
|
columns: DataTableColumn<T>[];
|
||||||
@@ -79,30 +92,44 @@ export interface RenderContext<T = any> {
|
|||||||
export function DataTable<T extends object>(
|
export function DataTable<T extends object>(
|
||||||
props: DataTableProps<T>,
|
props: DataTableProps<T>,
|
||||||
): React.ReactElement {
|
): React.ReactElement {
|
||||||
const {dataSource, onRowStyle} = props;
|
const {dataSource, onRowStyle, onSelect} = props;
|
||||||
useAssertStableRef(dataSource, 'dataSource');
|
useAssertStableRef(dataSource, 'dataSource');
|
||||||
useAssertStableRef(onRowStyle, 'onRowStyle');
|
useAssertStableRef(onRowStyle, 'onRowStyle');
|
||||||
useAssertStableRef(props.onSelect, 'onRowSelect');
|
useAssertStableRef(props.onSelect, 'onRowSelect');
|
||||||
useAssertStableRef(props.columns, 'columns');
|
useAssertStableRef(props.columns, 'columns');
|
||||||
useAssertStableRef(props._testHeight, '_testHeight');
|
useAssertStableRef(props._testHeight, '_testHeight');
|
||||||
|
|
||||||
|
// lint disabled for conditional inclusion of a hook (_testHeight is asserted to be stable)
|
||||||
|
// eslint-disable-next-line
|
||||||
|
const scope = props._testHeight ? "" : useContext(TrackingScopeContext); // TODO + plugin id
|
||||||
const virtualizerRef = useRef<DataSourceVirtualizer | undefined>();
|
const virtualizerRef = useRef<DataSourceVirtualizer | undefined>();
|
||||||
const tableManager = useDataTableManager(
|
const [state, dispatch] = useReducer(
|
||||||
dataSource,
|
dataTableManagerReducer as DataTableReducer<T>,
|
||||||
props.columns,
|
undefined,
|
||||||
props.onSelect,
|
() =>
|
||||||
|
createInitialState({
|
||||||
|
dataSource,
|
||||||
|
defaultColumns: props.columns,
|
||||||
|
onSelect,
|
||||||
|
scope,
|
||||||
|
virtualizerRef,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const stateRef = useRef(state);
|
||||||
|
stateRef.current = state;
|
||||||
|
const lastOffset = useRef(0);
|
||||||
|
|
||||||
|
const [tableManager] = useState(() =>
|
||||||
|
createDataTableManager(dataSource, dispatch, stateRef),
|
||||||
|
);
|
||||||
|
|
||||||
|
const {columns, selection, searchValue, sorting} = state;
|
||||||
|
|
||||||
|
const visibleColumns = useMemo(
|
||||||
|
() => columns.filter((column) => column.visible),
|
||||||
|
[columns],
|
||||||
);
|
);
|
||||||
if (props.tableManagerRef) {
|
|
||||||
(props.tableManagerRef as MutableRefObject<
|
|
||||||
DataTableManager<T>
|
|
||||||
>).current = tableManager;
|
|
||||||
}
|
|
||||||
const {
|
|
||||||
visibleColumns,
|
|
||||||
selectItem,
|
|
||||||
selection,
|
|
||||||
addRangeToSelection,
|
|
||||||
} = tableManager;
|
|
||||||
|
|
||||||
const renderingConfig = useMemo<RenderContext<T>>(() => {
|
const renderingConfig = useMemo<RenderContext<T>>(() => {
|
||||||
let dragging = false;
|
let dragging = false;
|
||||||
@@ -112,17 +139,17 @@ export function DataTable<T extends object>(
|
|||||||
onMouseEnter(_e, _item, index) {
|
onMouseEnter(_e, _item, index) {
|
||||||
if (dragging) {
|
if (dragging) {
|
||||||
// by computing range we make sure no intermediate items are missed when scrolling fast
|
// by computing range we make sure no intermediate items are missed when scrolling fast
|
||||||
addRangeToSelection(startIndex, index);
|
tableManager.addRangeToSelection(startIndex, index);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onMouseDown(e, _item, index) {
|
onMouseDown(e, _item, index) {
|
||||||
if (!dragging) {
|
if (!dragging) {
|
||||||
if (e.ctrlKey || e.metaKey) {
|
if (e.ctrlKey || e.metaKey) {
|
||||||
addRangeToSelection(index, index, true);
|
tableManager.addRangeToSelection(index, index, true);
|
||||||
} else if (e.shiftKey) {
|
} else if (e.shiftKey) {
|
||||||
selectItem(index, true);
|
tableManager.selectItem(index, true);
|
||||||
} else {
|
} else {
|
||||||
selectItem(index);
|
tableManager.selectItem(index);
|
||||||
}
|
}
|
||||||
|
|
||||||
dragging = true;
|
dragging = true;
|
||||||
@@ -137,12 +164,7 @@ export function DataTable<T extends object>(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}, [visibleColumns, selectItem, addRangeToSelection]);
|
}, [visibleColumns, tableManager]);
|
||||||
|
|
||||||
const usesWrapping = useMemo(
|
|
||||||
() => tableManager.columns.some((col) => col.wrap),
|
|
||||||
[tableManager.columns],
|
|
||||||
);
|
|
||||||
|
|
||||||
const itemRenderer = useCallback(
|
const itemRenderer = useCallback(
|
||||||
function itemRenderer(
|
function itemRenderer(
|
||||||
@@ -177,29 +199,35 @@ export function DataTable<T extends object>(
|
|||||||
const windowSize = virtualizerRef.current!.virtualItems.length;
|
const windowSize = virtualizerRef.current!.virtualItems.length;
|
||||||
switch (e.key) {
|
switch (e.key) {
|
||||||
case 'ArrowUp':
|
case 'ArrowUp':
|
||||||
selectItem((idx) => (idx > 0 ? idx - 1 : 0), shiftPressed);
|
tableManager.selectItem(
|
||||||
|
(idx) => (idx > 0 ? idx - 1 : 0),
|
||||||
|
shiftPressed,
|
||||||
|
);
|
||||||
break;
|
break;
|
||||||
case 'ArrowDown':
|
case 'ArrowDown':
|
||||||
selectItem(
|
tableManager.selectItem(
|
||||||
(idx) => (idx < outputSize - 1 ? idx + 1 : idx),
|
(idx) => (idx < outputSize - 1 ? idx + 1 : idx),
|
||||||
shiftPressed,
|
shiftPressed,
|
||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
case 'Home':
|
case 'Home':
|
||||||
selectItem(0, shiftPressed);
|
tableManager.selectItem(0, shiftPressed);
|
||||||
break;
|
break;
|
||||||
case 'End':
|
case 'End':
|
||||||
selectItem(outputSize - 1, shiftPressed);
|
tableManager.selectItem(outputSize - 1, shiftPressed);
|
||||||
break;
|
break;
|
||||||
case ' ': // yes, that is a space
|
case ' ': // yes, that is a space
|
||||||
case 'PageDown':
|
case 'PageDown':
|
||||||
selectItem(
|
tableManager.selectItem(
|
||||||
(idx) => Math.min(outputSize - 1, idx + windowSize - 1),
|
(idx) => Math.min(outputSize - 1, idx + windowSize - 1),
|
||||||
shiftPressed,
|
shiftPressed,
|
||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
case 'PageUp':
|
case 'PageUp':
|
||||||
selectItem((idx) => Math.max(0, idx - windowSize + 1), shiftPressed);
|
tableManager.selectItem(
|
||||||
|
(idx) => Math.max(0, idx - windowSize + 1),
|
||||||
|
shiftPressed,
|
||||||
|
);
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
handled = false;
|
handled = false;
|
||||||
@@ -209,17 +237,63 @@ export function DataTable<T extends object>(
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[selectItem, dataSource],
|
[dataSource, tableManager],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
useEffect(
|
||||||
|
function updateFilter() {
|
||||||
|
dataSource.setFilter(
|
||||||
|
computeDataTableFilter(state.searchValue, state.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
|
||||||
|
[state.searchValue, ...state.columns.map((c) => c.filters)],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(
|
||||||
|
function updateSorting() {
|
||||||
|
if (state.sorting === undefined) {
|
||||||
|
dataSource.setSortBy(undefined);
|
||||||
|
dataSource.setReversed(false);
|
||||||
|
} else {
|
||||||
|
dataSource.setSortBy(state.sorting.key);
|
||||||
|
dataSource.setReversed(state.sorting.direction === 'desc');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[dataSource, state.sorting],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(
|
||||||
|
function triggerSelection() {
|
||||||
|
onSelect?.(
|
||||||
|
getSelectedItem(dataSource, state.selection),
|
||||||
|
getSelectedItems(dataSource, state.selection),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[onSelect, dataSource, state.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)
|
||||||
|
// const initialScrollPosition = useRef(scrollOffset.current);
|
||||||
useLayoutEffect(
|
useLayoutEffect(
|
||||||
function scrollSelectionIntoView() {
|
function scrollSelectionIntoView() {
|
||||||
if (selection && selection.current >= 0) {
|
if (state.initialOffset) {
|
||||||
|
virtualizerRef.current?.scrollToOffset(state.initialOffset);
|
||||||
|
dispatch({
|
||||||
|
type: 'appliedInitialScroll',
|
||||||
|
});
|
||||||
|
} else if (selection && selection.current >= 0) {
|
||||||
virtualizerRef.current?.scrollToIndex(selection!.current, {
|
virtualizerRef.current?.scrollToIndex(selection!.current, {
|
||||||
align: 'auto',
|
align: 'auto',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
// initialOffset is relevant for the first run,
|
||||||
|
// but should not trigger the efffect in general
|
||||||
|
// eslint-disable-next-line
|
||||||
[selection],
|
[selection],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -228,9 +302,10 @@ export function DataTable<T extends object>(
|
|||||||
const hideRange = useRef<NodeJS.Timeout>();
|
const hideRange = useRef<NodeJS.Timeout>();
|
||||||
|
|
||||||
const onRangeChange = useCallback(
|
const onRangeChange = useCallback(
|
||||||
(start: number, end: number, total: number) => {
|
(start: number, end: number, total: number, offset) => {
|
||||||
// TODO: figure out if we don't trigger this callback to often hurting perf
|
// TODO: figure out if we don't trigger this callback to often hurting perf
|
||||||
setRange(`${start} - ${end} / ${total}`);
|
setRange(`${start} - ${end} / ${total}`);
|
||||||
|
lastOffset.current = offset;
|
||||||
clearTimeout(hideRange.current!);
|
clearTimeout(hideRange.current!);
|
||||||
hideRange.current = setTimeout(() => {
|
hideRange.current = setTimeout(() => {
|
||||||
setRange('');
|
setRange('');
|
||||||
@@ -240,53 +315,62 @@ export function DataTable<T extends object>(
|
|||||||
);
|
);
|
||||||
|
|
||||||
/** Context menu */
|
/** Context menu */
|
||||||
// TODO: support customizing context menu
|
|
||||||
const contexMenu = props._testHeight
|
const contexMenu = props._testHeight
|
||||||
? undefined // don't render context menu in tests
|
? undefined
|
||||||
: tableContextMenuFactory(tableManager);
|
: // eslint-disable-next-line
|
||||||
|
useCallback(
|
||||||
|
() =>
|
||||||
|
tableContextMenuFactory(
|
||||||
|
dataSource,
|
||||||
|
dispatch,
|
||||||
|
selection,
|
||||||
|
state.columns,
|
||||||
|
visibleColumns,
|
||||||
|
),
|
||||||
|
[dataSource, dispatch, selection, state.columns, visibleColumns],
|
||||||
|
);
|
||||||
|
|
||||||
const emptyRenderer = useCallback((dataSource: DataSource<T>) => {
|
useEffect(function initialSetup() {
|
||||||
return <EmptyTable dataSource={dataSource} />;
|
if (props.tableManagerRef) {
|
||||||
|
(props.tableManagerRef as MutableRefObject<any>).current = tableManager;
|
||||||
|
}
|
||||||
|
|
||||||
|
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.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 cleanup() {
|
|
||||||
return () => {
|
|
||||||
if (props.tableManagerRef) {
|
|
||||||
(props.tableManagerRef as MutableRefObject<undefined>).current = undefined;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
},
|
|
||||||
[props.tableManagerRef],
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Layout.Container grow>
|
<Layout.Container grow>
|
||||||
<Layout.Top>
|
<Layout.Top>
|
||||||
<Layout.Container>
|
<Layout.Container>
|
||||||
<TableSearch
|
<TableSearch
|
||||||
onSearch={tableManager.setSearchValue}
|
searchValue={searchValue}
|
||||||
extraActions={props.extraActions}
|
dispatch={dispatch as any}
|
||||||
contextMenu={contexMenu}
|
contextMenu={contexMenu}
|
||||||
|
extraActions={props.extraActions}
|
||||||
/>
|
/>
|
||||||
<TableHead
|
<TableHead
|
||||||
visibleColumns={tableManager.visibleColumns}
|
visibleColumns={visibleColumns}
|
||||||
onColumnResize={tableManager.resizeColumn}
|
dispatch={dispatch as any}
|
||||||
onReset={tableManager.reset}
|
sorting={sorting}
|
||||||
sorting={tableManager.sorting}
|
|
||||||
onColumnSort={tableManager.sortColumn}
|
|
||||||
onAddColumnFilter={tableManager.addColumnFilter}
|
|
||||||
onRemoveColumnFilter={tableManager.removeColumnFilter}
|
|
||||||
onToggleColumnFilter={tableManager.toggleColumnFilter}
|
|
||||||
onSetColumnFilterFromSelection={
|
|
||||||
tableManager.setColumnFilterFromSelection
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
</Layout.Container>
|
</Layout.Container>
|
||||||
<DataSourceRenderer<T, RenderContext<T>>
|
<DataSourceRenderer<T, RenderContext<T>>
|
||||||
dataSource={dataSource}
|
dataSource={dataSource}
|
||||||
autoScroll={props.autoScroll}
|
autoScroll={props.autoScroll}
|
||||||
useFixedRowHeight={!usesWrapping}
|
useFixedRowHeight={!state.usesWrapping}
|
||||||
defaultRowHeight={DEFAULT_ROW_HEIGHT}
|
defaultRowHeight={DEFAULT_ROW_HEIGHT}
|
||||||
context={renderingConfig}
|
context={renderingConfig}
|
||||||
itemRenderer={itemRenderer}
|
itemRenderer={itemRenderer}
|
||||||
@@ -302,6 +386,10 @@ export function DataTable<T extends object>(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function emptyRenderer(dataSource: DataSource<any>) {
|
||||||
|
return <EmptyTable dataSource={dataSource} />;
|
||||||
|
}
|
||||||
|
|
||||||
function EmptyTable({dataSource}: {dataSource: DataSource<any>}) {
|
function EmptyTable({dataSource}: {dataSource: DataSource<any>}) {
|
||||||
return (
|
return (
|
||||||
<Layout.Container
|
<Layout.Container
|
||||||
|
|||||||
520
desktop/flipper-plugin/src/ui/datatable/DataTableManager.tsx
Normal file
520
desktop/flipper-plugin/src/ui/datatable/DataTableManager.tsx
Normal file
@@ -0,0 +1,520 @@
|
|||||||
|
/**
|
||||||
|
* 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 type {DataTableColumn} from './DataTable';
|
||||||
|
import {Percentage} from '../../utils/widthUtils';
|
||||||
|
import {MutableRefObject, Reducer} from 'react';
|
||||||
|
import {DataSource} from '../../state/datasource/DataSource';
|
||||||
|
import {DataSourceVirtualizer} from './DataSourceRenderer';
|
||||||
|
import produce, {immerable, original} from 'immer';
|
||||||
|
|
||||||
|
export type OnColumnResize = (id: string, size: number | Percentage) => void;
|
||||||
|
export type Sorting<T = any> = {
|
||||||
|
key: keyof T;
|
||||||
|
direction: Exclude<SortDirection, undefined>;
|
||||||
|
};
|
||||||
|
|
||||||
|
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 */
|
||||||
|
search: string;
|
||||||
|
/** 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'>[];
|
||||||
|
scrollOffset: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
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'>
|
||||||
|
/** 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<'setSearchValue', {value: string}>
|
||||||
|
| Action<
|
||||||
|
'selectItem',
|
||||||
|
{
|
||||||
|
nextIndex: number | ((currentIndex: number) => number);
|
||||||
|
addToSelection?: boolean;
|
||||||
|
}
|
||||||
|
>
|
||||||
|
| Action<
|
||||||
|
'addRangeToSelection',
|
||||||
|
{
|
||||||
|
start: number;
|
||||||
|
end: number;
|
||||||
|
allowUnselect?: boolean;
|
||||||
|
}
|
||||||
|
>
|
||||||
|
| Action<'clearSelection', {}>
|
||||||
|
/** Changing column filters */
|
||||||
|
| Action<
|
||||||
|
'addColumnFilter',
|
||||||
|
{column: keyof T; value: string; disableOthers?: boolean}
|
||||||
|
>
|
||||||
|
| Action<'removeColumnFilter', {column: keyof T; index: number}>
|
||||||
|
| Action<'toggleColumnFilter', {column: keyof T; index: number}>
|
||||||
|
| Action<'setColumnFilterFromSelection', {column: keyof T}>
|
||||||
|
| Action<'appliedInitialScroll'>;
|
||||||
|
|
||||||
|
type DataManagerConfig<T> = {
|
||||||
|
dataSource: DataSource<T>;
|
||||||
|
defaultColumns: DataTableColumn<T>[];
|
||||||
|
scope: string;
|
||||||
|
onSelect: undefined | ((item: T | undefined, items: T[]) => void);
|
||||||
|
virtualizerRef: MutableRefObject<DataSourceVirtualizer | undefined>;
|
||||||
|
};
|
||||||
|
|
||||||
|
type DataManagerState<T> = {
|
||||||
|
config: DataManagerConfig<T>;
|
||||||
|
usesWrapping: boolean;
|
||||||
|
storageKey: string;
|
||||||
|
initialOffset: number;
|
||||||
|
columns: DataTableColumn[];
|
||||||
|
sorting: Sorting<T> | undefined;
|
||||||
|
selection: Selection;
|
||||||
|
searchValue: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DataTableReducer<T> = Reducer<
|
||||||
|
DataManagerState<T>,
|
||||||
|
DataManagerActions<T>
|
||||||
|
>;
|
||||||
|
export type DataTableDispatch<T = any> = React.Dispatch<DataManagerActions<T>>;
|
||||||
|
|
||||||
|
// TODO: make argu inference correct
|
||||||
|
export const dataTableManagerReducer = produce(function <T>(
|
||||||
|
draft: DataManagerState<T>,
|
||||||
|
action: DataManagerActions<T>,
|
||||||
|
) {
|
||||||
|
const config = original(draft.config)!;
|
||||||
|
switch (action.type) {
|
||||||
|
case 'reset': {
|
||||||
|
draft.columns = computeInitialColumns(config.defaultColumns);
|
||||||
|
draft.sorting = undefined;
|
||||||
|
draft.searchValue = '';
|
||||||
|
draft.selection = emptySelection;
|
||||||
|
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 'setSearchValue': {
|
||||||
|
draft.searchValue = action.value;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'selectItem': {
|
||||||
|
const {nextIndex, addToSelection} = action;
|
||||||
|
draft.selection = computeSetSelection(
|
||||||
|
draft.selection,
|
||||||
|
nextIndex,
|
||||||
|
addToSelection,
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'addRangeToSelection': {
|
||||||
|
const {start, end, allowUnselect} = action;
|
||||||
|
draft.selection = computeAddRangeToSelection(
|
||||||
|
draft.selection,
|
||||||
|
start,
|
||||||
|
end,
|
||||||
|
allowUnselect,
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'clearSelection': {
|
||||||
|
draft.selection = emptySelection;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'addColumnFilter': {
|
||||||
|
addColumnFilter(
|
||||||
|
draft.columns,
|
||||||
|
action.column,
|
||||||
|
action.value,
|
||||||
|
action.disableOthers,
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'removeColumnFilter': {
|
||||||
|
draft.columns
|
||||||
|
.find((c) => c.key === action.column)!
|
||||||
|
.filters?.splice(action.index, 1);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'toggleColumnFilter': {
|
||||||
|
const f = draft.columns.find((c) => c.key === action.column)!.filters![
|
||||||
|
action.index
|
||||||
|
];
|
||||||
|
f.enabled = !f.enabled;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'setColumnFilterFromSelection': {
|
||||||
|
const items = getSelectedItems(config.dataSource, draft.selection);
|
||||||
|
items.forEach((item, index) => {
|
||||||
|
addColumnFilter(
|
||||||
|
draft.columns,
|
||||||
|
action.column,
|
||||||
|
(item as any)[action.column],
|
||||||
|
index === 0, // remove existing filters before adding the first
|
||||||
|
);
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'appliedInitialScroll': {
|
||||||
|
draft.initialOffset = 0;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
default: {
|
||||||
|
throw new Error('Unknown action ' + (action as any).type);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}) as any; // TODO: remove
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Public only imperative convienience API for DataTable
|
||||||
|
*/
|
||||||
|
export type DataTableManager<T> = {
|
||||||
|
reset(): void;
|
||||||
|
selectItem(
|
||||||
|
index: number | ((currentSelection: number) => number),
|
||||||
|
addToSelection?: boolean,
|
||||||
|
): void;
|
||||||
|
addRangeToSelection(
|
||||||
|
start: number,
|
||||||
|
end: number,
|
||||||
|
allowUnselect?: boolean,
|
||||||
|
): void;
|
||||||
|
clearSelection(): void;
|
||||||
|
getSelectedItem(): T | undefined;
|
||||||
|
getSelectedItems(): readonly T[];
|
||||||
|
toggleColumnVisibility(column: keyof T): void;
|
||||||
|
sortColumn(column: keyof T, direction?: SortDirection): void;
|
||||||
|
setSearchValue(value: string): void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function createDataTableManager<T>(
|
||||||
|
dataSource: DataSource<T>,
|
||||||
|
dispatch: DataTableDispatch<T>,
|
||||||
|
stateRef: MutableRefObject<DataManagerState<T>>,
|
||||||
|
): DataTableManager<T> {
|
||||||
|
return {
|
||||||
|
reset() {
|
||||||
|
dispatch({type: 'reset'});
|
||||||
|
},
|
||||||
|
selectItem(index: number, addToSelection = false) {
|
||||||
|
dispatch({type: 'selectItem', nextIndex: index, addToSelection});
|
||||||
|
},
|
||||||
|
addRangeToSelection(start, end, allowUnselect = false) {
|
||||||
|
dispatch({type: 'addRangeToSelection', start, end, allowUnselect});
|
||||||
|
},
|
||||||
|
clearSelection() {
|
||||||
|
dispatch({type: 'clearSelection'});
|
||||||
|
},
|
||||||
|
getSelectedItem() {
|
||||||
|
return getSelectedItem(dataSource, stateRef.current.selection);
|
||||||
|
},
|
||||||
|
getSelectedItems() {
|
||||||
|
return getSelectedItems(dataSource, stateRef.current.selection);
|
||||||
|
},
|
||||||
|
toggleColumnVisibility(column) {
|
||||||
|
dispatch({type: 'toggleColumnVisibility', column});
|
||||||
|
},
|
||||||
|
sortColumn(column, direction) {
|
||||||
|
dispatch({type: 'sortColumn', column, direction});
|
||||||
|
},
|
||||||
|
setSearchValue(value) {
|
||||||
|
dispatch({type: 'setSearchValue', value});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createInitialState<T>(
|
||||||
|
config: DataManagerConfig<T>,
|
||||||
|
): DataManagerState<T> {
|
||||||
|
const storageKey = `${config.scope}:DataTable:${config.defaultColumns
|
||||||
|
.map((c) => c.key)
|
||||||
|
.join(',')}`;
|
||||||
|
const prefs = loadStateFromStorage(storageKey);
|
||||||
|
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,
|
||||||
|
searchValue: prefs?.search ?? '',
|
||||||
|
};
|
||||||
|
// @ts-ignore
|
||||||
|
res.config[immerable] = false; // optimization: never proxy anything in config
|
||||||
|
Object.freeze(res.config);
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
function addColumnFilter<T>(
|
||||||
|
columns: DataTableColumn<T>[],
|
||||||
|
columnId: keyof T,
|
||||||
|
value: string,
|
||||||
|
disableOthers: boolean = false,
|
||||||
|
): void {
|
||||||
|
const column = columns.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;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSelectedItem<T>(
|
||||||
|
dataSource: DataSource<T>,
|
||||||
|
selection: Selection,
|
||||||
|
): T | undefined {
|
||||||
|
return selection.current < 0
|
||||||
|
? undefined
|
||||||
|
: dataSource.getItem(selection.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSelectedItems<T>(
|
||||||
|
dataSource: DataSource<T>,
|
||||||
|
selection: Selection,
|
||||||
|
): T[] {
|
||||||
|
return [...selection.items]
|
||||||
|
.sort()
|
||||||
|
.map((i) => dataSource.getItem(i))
|
||||||
|
.filter(Boolean) as any[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function savePreferences(
|
||||||
|
state: DataManagerState<any>,
|
||||||
|
scrollOffset: number,
|
||||||
|
) {
|
||||||
|
if (!state.config.scope) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const prefs: PersistedState = {
|
||||||
|
search: state.searchValue,
|
||||||
|
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,
|
||||||
|
filters: c.filters,
|
||||||
|
visible: c.visible,
|
||||||
|
})),
|
||||||
|
scrollOffset,
|
||||||
|
};
|
||||||
|
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>[] {
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -9,15 +9,26 @@
|
|||||||
|
|
||||||
import {CopyOutlined, FilterOutlined} from '@ant-design/icons';
|
import {CopyOutlined, FilterOutlined} from '@ant-design/icons';
|
||||||
import {Checkbox, Menu} from 'antd';
|
import {Checkbox, Menu} from 'antd';
|
||||||
import {DataTableManager} from './useDataTableManager';
|
import {
|
||||||
|
DataTableDispatch,
|
||||||
|
getSelectedItems,
|
||||||
|
Selection,
|
||||||
|
} from './DataTableManager';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import {normalizeCellValue} from './TableRow';
|
import {normalizeCellValue} from './TableRow';
|
||||||
import {tryGetFlipperLibImplementation} from '../../plugin/FlipperLib';
|
import {tryGetFlipperLibImplementation} from '../../plugin/FlipperLib';
|
||||||
import {DataTableColumn} from './DataTable';
|
import {DataTableColumn} from './DataTable';
|
||||||
|
import {DataSource} from '../../state/datasource/DataSource';
|
||||||
|
|
||||||
const {Item, SubMenu} = Menu;
|
const {Item, SubMenu} = Menu;
|
||||||
|
|
||||||
export function tableContextMenuFactory(tableManager: DataTableManager<any>) {
|
export function tableContextMenuFactory<T>(
|
||||||
|
datasource: DataSource<T>,
|
||||||
|
dispatch: DataTableDispatch<T>,
|
||||||
|
selection: Selection,
|
||||||
|
columns: DataTableColumn<T>[],
|
||||||
|
visibleColumns: DataTableColumn<T>[],
|
||||||
|
) {
|
||||||
const lib = tryGetFlipperLibImplementation();
|
const lib = tryGetFlipperLibImplementation();
|
||||||
if (!lib) {
|
if (!lib) {
|
||||||
return (
|
return (
|
||||||
@@ -26,18 +37,22 @@ export function tableContextMenuFactory(tableManager: DataTableManager<any>) {
|
|||||||
</Menu>
|
</Menu>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const hasSelection = tableManager.selection?.items.size > 0 ?? false;
|
const hasSelection = selection.items.size > 0 ?? false;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Menu>
|
<Menu>
|
||||||
<SubMenu
|
<SubMenu
|
||||||
title="Filter on same"
|
title="Filter on same"
|
||||||
icon={<FilterOutlined />}
|
icon={<FilterOutlined />}
|
||||||
disabled={!hasSelection}>
|
disabled={!hasSelection}>
|
||||||
{tableManager.visibleColumns.map((column) => (
|
{visibleColumns.map((column) => (
|
||||||
<Item
|
<Item
|
||||||
key={column.key}
|
key={column.key}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
tableManager.setColumnFilterFromSelection(column.key);
|
dispatch({
|
||||||
|
type: 'setColumnFilterFromSelection',
|
||||||
|
column: column.key,
|
||||||
|
});
|
||||||
}}>
|
}}>
|
||||||
{friendlyColumnTitle(column)}
|
{friendlyColumnTitle(column)}
|
||||||
</Item>
|
</Item>
|
||||||
@@ -47,11 +62,11 @@ export function tableContextMenuFactory(tableManager: DataTableManager<any>) {
|
|||||||
title="Copy cell(s)"
|
title="Copy cell(s)"
|
||||||
icon={<CopyOutlined />}
|
icon={<CopyOutlined />}
|
||||||
disabled={!hasSelection}>
|
disabled={!hasSelection}>
|
||||||
{tableManager.visibleColumns.map((column) => (
|
{visibleColumns.map((column) => (
|
||||||
<Item
|
<Item
|
||||||
key={column.key}
|
key={column.key}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const items = tableManager.getSelectedItems();
|
const items = getSelectedItems(datasource, selection);
|
||||||
if (items.length) {
|
if (items.length) {
|
||||||
lib.writeTextToClipboard(
|
lib.writeTextToClipboard(
|
||||||
items
|
items
|
||||||
@@ -67,7 +82,7 @@ export function tableContextMenuFactory(tableManager: DataTableManager<any>) {
|
|||||||
<Item
|
<Item
|
||||||
disabled={!hasSelection}
|
disabled={!hasSelection}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const items = tableManager.getSelectedItems();
|
const items = getSelectedItems(datasource, selection);
|
||||||
if (items.length) {
|
if (items.length) {
|
||||||
lib.writeTextToClipboard(
|
lib.writeTextToClipboard(
|
||||||
JSON.stringify(items.length > 1 ? items : items[0], null, 2),
|
JSON.stringify(items.length > 1 ? items : items[0], null, 2),
|
||||||
@@ -80,7 +95,7 @@ export function tableContextMenuFactory(tableManager: DataTableManager<any>) {
|
|||||||
<Item
|
<Item
|
||||||
disabled={!hasSelection}
|
disabled={!hasSelection}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const items = tableManager.getSelectedItems();
|
const items = getSelectedItems(datasource, selection);
|
||||||
if (items.length) {
|
if (items.length) {
|
||||||
lib.createPaste(
|
lib.createPaste(
|
||||||
JSON.stringify(items.length > 1 ? items : items[0], null, 2),
|
JSON.stringify(items.length > 1 ? items : items[0], null, 2),
|
||||||
@@ -92,21 +107,25 @@ export function tableContextMenuFactory(tableManager: DataTableManager<any>) {
|
|||||||
)}
|
)}
|
||||||
<Menu.Divider />
|
<Menu.Divider />
|
||||||
<SubMenu title="Visible columns">
|
<SubMenu title="Visible columns">
|
||||||
{tableManager.columns.map((column) => (
|
{columns.map((column) => (
|
||||||
<Menu.Item key={column.key}>
|
<Menu.Item key={column.key}>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={column.visible}
|
checked={column.visible}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
tableManager.toggleColumnVisibility(column.key);
|
dispatch({type: 'toggleColumnVisibility', column: column.key});
|
||||||
}}>
|
}}>
|
||||||
{friendlyColumnTitle(column)}
|
{friendlyColumnTitle(column)}
|
||||||
</Checkbox>
|
</Checkbox>
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
))}
|
))}
|
||||||
</SubMenu>
|
</SubMenu>
|
||||||
<Menu.Item key="reset" onClick={tableManager.reset}>
|
<Menu.Item
|
||||||
|
key="reset"
|
||||||
|
onClick={() => {
|
||||||
|
dispatch({type: 'reset'});
|
||||||
|
}}>
|
||||||
Reset view
|
Reset view
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
</Menu>
|
</Menu>
|
||||||
|
|||||||
@@ -23,8 +23,8 @@ import type {DataTableColumn} from './DataTable';
|
|||||||
import {Typography} from 'antd';
|
import {Typography} from 'antd';
|
||||||
import {CaretDownFilled, CaretUpFilled} from '@ant-design/icons';
|
import {CaretDownFilled, CaretUpFilled} from '@ant-design/icons';
|
||||||
import {Layout} from '../Layout';
|
import {Layout} from '../Layout';
|
||||||
import {Sorting, OnColumnResize, SortDirection} from './useDataTableManager';
|
import {Sorting, SortDirection, DataTableDispatch} from './DataTableManager';
|
||||||
import {ColumnFilterHandlers, FilterButton, FilterIcon} from './ColumnFilter';
|
import {FilterButton, FilterIcon} from './ColumnFilter';
|
||||||
|
|
||||||
const {Text} = Typography;
|
const {Text} = Typography;
|
||||||
|
|
||||||
@@ -120,18 +120,14 @@ const RIGHT_RESIZABLE = {right: true};
|
|||||||
function TableHeadColumn({
|
function TableHeadColumn({
|
||||||
column,
|
column,
|
||||||
isResizable,
|
isResizable,
|
||||||
onColumnResize,
|
|
||||||
onSort,
|
|
||||||
sorted,
|
sorted,
|
||||||
...filterHandlers
|
dispatch,
|
||||||
}: {
|
}: {
|
||||||
column: DataTableColumn<any>;
|
column: DataTableColumn<any>;
|
||||||
sorted: SortDirection;
|
sorted: SortDirection;
|
||||||
isResizable: boolean;
|
isResizable: boolean;
|
||||||
onSort: (id: string, direction: SortDirection) => void;
|
dispatch: DataTableDispatch;
|
||||||
sortOrder: undefined | Sorting;
|
}) {
|
||||||
onColumnResize: OnColumnResize;
|
|
||||||
} & ColumnFilterHandlers) {
|
|
||||||
const ref = useRef<HTMLDivElement | null>(null);
|
const ref = useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
const onResize = (newWidth: number) => {
|
const onResize = (newWidth: number) => {
|
||||||
@@ -158,7 +154,11 @@ function TableHeadColumn({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onColumnResize(column.key, normalizedWidth);
|
dispatch({
|
||||||
|
type: 'resizeColumn',
|
||||||
|
column: column.key,
|
||||||
|
width: normalizedWidth,
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
let children = (
|
let children = (
|
||||||
@@ -172,7 +172,11 @@ function TableHeadColumn({
|
|||||||
: sorted === 'asc'
|
: sorted === 'asc'
|
||||||
? 'desc'
|
? 'desc'
|
||||||
: undefined;
|
: undefined;
|
||||||
onSort(column.key, newDirection);
|
dispatch({
|
||||||
|
type: 'sortColumn',
|
||||||
|
column: column.key,
|
||||||
|
direction: newDirection,
|
||||||
|
});
|
||||||
}}
|
}}
|
||||||
role="button"
|
role="button"
|
||||||
tabIndex={0}>
|
tabIndex={0}>
|
||||||
@@ -180,11 +184,13 @@ function TableHeadColumn({
|
|||||||
{column.title ?? <> </>}
|
{column.title ?? <> </>}
|
||||||
<SortIcons
|
<SortIcons
|
||||||
direction={sorted}
|
direction={sorted}
|
||||||
onSort={(dir) => onSort(column.key, dir)}
|
onSort={(dir) =>
|
||||||
|
dispatch({type: 'sortColumn', column: column.key, direction: dir})
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</Text>
|
</Text>
|
||||||
</div>
|
</div>
|
||||||
<FilterIcon column={column} {...filterHandlers} />
|
<FilterIcon column={column} dispatch={dispatch} />
|
||||||
</Layout.Right>
|
</Layout.Right>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -209,14 +215,13 @@ function TableHeadColumn({
|
|||||||
|
|
||||||
export const TableHead = memo(function TableHead({
|
export const TableHead = memo(function TableHead({
|
||||||
visibleColumns,
|
visibleColumns,
|
||||||
...props
|
dispatch,
|
||||||
|
sorting,
|
||||||
}: {
|
}: {
|
||||||
|
dispatch: DataTableDispatch<any>;
|
||||||
visibleColumns: DataTableColumn<any>[];
|
visibleColumns: DataTableColumn<any>[];
|
||||||
onColumnResize: OnColumnResize;
|
|
||||||
onReset: () => void;
|
|
||||||
sorting: Sorting | undefined;
|
sorting: Sorting | undefined;
|
||||||
onColumnSort: (key: string, direction: SortDirection) => void;
|
}) {
|
||||||
} & ColumnFilterHandlers) {
|
|
||||||
return (
|
return (
|
||||||
<TableHeadContainer>
|
<TableHeadContainer>
|
||||||
{visibleColumns.map((column, i) => (
|
{visibleColumns.map((column, i) => (
|
||||||
@@ -224,18 +229,8 @@ export const TableHead = memo(function TableHead({
|
|||||||
key={column.key}
|
key={column.key}
|
||||||
column={column}
|
column={column}
|
||||||
isResizable={i < visibleColumns.length - 1}
|
isResizable={i < visibleColumns.length - 1}
|
||||||
sortOrder={props.sorting}
|
dispatch={dispatch}
|
||||||
onSort={props.onColumnSort}
|
sorted={sorting?.key === column.key ? sorting!.direction : undefined}
|
||||||
onColumnResize={props.onColumnResize}
|
|
||||||
onAddColumnFilter={props.onAddColumnFilter}
|
|
||||||
onRemoveColumnFilter={props.onRemoveColumnFilter}
|
|
||||||
onToggleColumnFilter={props.onToggleColumnFilter}
|
|
||||||
onSetColumnFilterFromSelection={props.onSetColumnFilterFromSelection}
|
|
||||||
sorted={
|
|
||||||
props.sorting?.key === column.key
|
|
||||||
? props.sorting!.direction
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</TableHeadContainer>
|
</TableHeadContainer>
|
||||||
|
|||||||
@@ -9,39 +9,39 @@
|
|||||||
|
|
||||||
import {MenuOutlined} from '@ant-design/icons';
|
import {MenuOutlined} from '@ant-design/icons';
|
||||||
import {Button, Dropdown, Input} from 'antd';
|
import {Button, Dropdown, Input} from 'antd';
|
||||||
import React, {memo, useState} from 'react';
|
import React, {memo, useCallback} from 'react';
|
||||||
import styled from '@emotion/styled';
|
import styled from '@emotion/styled';
|
||||||
|
|
||||||
import {Layout} from '../Layout';
|
import {Layout} from '../Layout';
|
||||||
import {theme} from '../theme';
|
import {theme} from '../theme';
|
||||||
import {debounce} from 'lodash';
|
import type {DataTableDispatch} from './DataTableManager';
|
||||||
import {useAssertStableRef} from '../../utils/useAssertStableRef';
|
|
||||||
|
|
||||||
export const TableSearch = memo(function TableSearch({
|
export const TableSearch = memo(function TableSearch({
|
||||||
onSearch,
|
searchValue,
|
||||||
|
dispatch,
|
||||||
extraActions,
|
extraActions,
|
||||||
contextMenu,
|
contextMenu,
|
||||||
}: {
|
}: {
|
||||||
onSearch(value: string): void;
|
searchValue: string;
|
||||||
|
dispatch: DataTableDispatch<any>;
|
||||||
extraActions?: React.ReactElement;
|
extraActions?: React.ReactElement;
|
||||||
hasSelection?: boolean;
|
contextMenu: undefined | (() => JSX.Element);
|
||||||
contextMenu?: React.ReactElement;
|
|
||||||
}) {
|
}) {
|
||||||
useAssertStableRef(onSearch, 'onSearch');
|
const onSearch = useCallback(
|
||||||
const [search, setSearch] = useState('');
|
(value: string) => {
|
||||||
const [performSearch] = useState(() =>
|
dispatch({type: 'setSearchValue', value});
|
||||||
debounce(onSearch, 200, {leading: true}),
|
},
|
||||||
|
[dispatch],
|
||||||
);
|
);
|
||||||
return (
|
return (
|
||||||
<Searchbar gap>
|
<Searchbar gap>
|
||||||
<Input.Search
|
<Input.Search
|
||||||
allowClear
|
allowClear
|
||||||
placeholder="Search..."
|
placeholder="Search..."
|
||||||
onSearch={performSearch}
|
onSearch={onSearch}
|
||||||
value={search}
|
value={searchValue}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setSearch(e.target.value);
|
onSearch(e.target.value);
|
||||||
performSearch(e.target.value);
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{extraActions}
|
{extraActions}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import React, {createRef} from 'react';
|
|||||||
import {DataTable, DataTableColumn} from '../DataTable';
|
import {DataTable, DataTableColumn} from '../DataTable';
|
||||||
import {render, act} from '@testing-library/react';
|
import {render, act} from '@testing-library/react';
|
||||||
import {createDataSource} from '../../../state/datasource/DataSource';
|
import {createDataSource} from '../../../state/datasource/DataSource';
|
||||||
import {computeDataTableFilter, DataTableManager} from '../useDataTableManager';
|
import {computeDataTableFilter, DataTableManager} from '../DataTableManager';
|
||||||
import {Button} from 'antd';
|
import {Button} from 'antd';
|
||||||
|
|
||||||
type Todo = {
|
type Todo = {
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
import {
|
import {
|
||||||
computeAddRangeToSelection,
|
computeAddRangeToSelection,
|
||||||
computeSetSelection,
|
computeSetSelection,
|
||||||
} from '../useDataTableManager';
|
} from '../DataTableManager';
|
||||||
|
|
||||||
test('computeSetSelection', () => {
|
test('computeSetSelection', () => {
|
||||||
const emptyBase = {
|
const emptyBase = {
|
||||||
|
|||||||
@@ -1,441 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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;
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user