Introduce multi selection
Summary: Make sure DataTable supports multiselection, which works largely the same as before, with a few changes * shift + click and ctrl + click work as expected * shift + keyboard navigation works as expected * drag selection works as expected * drag selection when dragging accross screens, or Shift icmw with HOME / END / PageUp / PageDown works as expect * text selection stil works as expected The context menu items have been updated as well * filter will filter on all the distinct values in the selection * copying cells will copy all cells of the given column in the selection, separated by newline * copying rows / creating a past will create a json array of the selection Not done yet - Shifting the selection after inserting rows hasn't been implemented yet - I'm not entirely happy with the context menu trigger, maybe a hamburger button in the toolbar will be better Reviewed By: nikoant Differential Revision: D26548228 fbshipit-source-id: 5d1cddd6aad02ce9649d7980ab3a223e222da893
This commit is contained in:
committed by
Facebook GitHub Bot
parent
5c3a8742ef
commit
59a1327261
@@ -38,15 +38,8 @@ interface DataTableProps<T = any> {
|
||||
autoScroll?: boolean;
|
||||
extraActions?: React.ReactElement;
|
||||
// custom onSearch(text, row) option?
|
||||
/**
|
||||
* onSelect event
|
||||
* @param item currently selected item
|
||||
* @param index index of the selected item in the datasources' output.
|
||||
* Note that the index could potentially refer to a different item if rendering is 'behind' and items have shifted
|
||||
*/
|
||||
onSelect?(item: T | undefined, index: number): void;
|
||||
onSelect?(item: T | undefined, items: T[]): void;
|
||||
// multiselect?: true
|
||||
// onMultiSelect
|
||||
tableManagerRef?: RefObject<TableManager>;
|
||||
_testHeight?: number; // exposed for unit testing only
|
||||
}
|
||||
@@ -70,13 +63,24 @@ export type DataTableColumn<T = any> = {
|
||||
|
||||
export interface RenderContext<T = any> {
|
||||
columns: DataTableColumn<T>[];
|
||||
onClick(item: T, itemId: number): void;
|
||||
onMouseEnter(
|
||||
e: React.MouseEvent<HTMLDivElement>,
|
||||
item: T,
|
||||
itemId: number,
|
||||
): void;
|
||||
onMouseDown(
|
||||
e: React.MouseEvent<HTMLDivElement>,
|
||||
item: T,
|
||||
itemId: number,
|
||||
): void;
|
||||
}
|
||||
|
||||
export function DataTable<T extends object>(props: DataTableProps<T>) {
|
||||
export function DataTable<T extends object>(
|
||||
props: DataTableProps<T>,
|
||||
): React.ReactElement {
|
||||
const {dataSource} = props;
|
||||
const virtualizerRef = useRef<DataSourceVirtualizer | undefined>();
|
||||
const tableManager = useDataTableManager<T>(
|
||||
const tableManager = useDataTableManager(
|
||||
dataSource,
|
||||
props.columns,
|
||||
props.onSelect,
|
||||
@@ -84,16 +88,50 @@ export function DataTable<T extends object>(props: DataTableProps<T>) {
|
||||
if (props.tableManagerRef) {
|
||||
(props.tableManagerRef as MutableRefObject<TableManager>).current = tableManager;
|
||||
}
|
||||
const {visibleColumns, selectItem, selection} = tableManager;
|
||||
const {
|
||||
visibleColumns,
|
||||
selectItem,
|
||||
selection,
|
||||
addRangeToSelection,
|
||||
addColumnFilter,
|
||||
getSelectedItem,
|
||||
getSelectedItems,
|
||||
} = tableManager;
|
||||
|
||||
const renderingConfig = useMemo<RenderContext<T>>(() => {
|
||||
let dragging = false;
|
||||
let startIndex = 0;
|
||||
return {
|
||||
columns: visibleColumns,
|
||||
onClick(_, itemIdx) {
|
||||
selectItem(() => itemIdx);
|
||||
onMouseEnter(_e, _item, index) {
|
||||
if (dragging) {
|
||||
// by computing range we make sure no intermediate items are missed when scrolling fast
|
||||
addRangeToSelection(startIndex, index);
|
||||
}
|
||||
},
|
||||
onMouseDown(e, _item, index) {
|
||||
if (!dragging) {
|
||||
if (e.ctrlKey || e.metaKey) {
|
||||
addRangeToSelection(index, index, true);
|
||||
} else if (e.shiftKey) {
|
||||
selectItem(index, true);
|
||||
} else {
|
||||
selectItem(index);
|
||||
}
|
||||
|
||||
dragging = true;
|
||||
startIndex = index;
|
||||
|
||||
function onStopDragSelecting() {
|
||||
dragging = false;
|
||||
document.removeEventListener('mouseup', onStopDragSelecting);
|
||||
}
|
||||
|
||||
document.addEventListener('mouseup', onStopDragSelecting);
|
||||
}
|
||||
},
|
||||
};
|
||||
}, [visibleColumns, selectItem]);
|
||||
}, [visibleColumns, selectItem, addRangeToSelection]);
|
||||
|
||||
const usesWrapping = useMemo(
|
||||
() => tableManager.columns.some((col) => col.wrap),
|
||||
@@ -112,11 +150,13 @@ export function DataTable<T extends object>(props: DataTableProps<T>) {
|
||||
config={renderContext}
|
||||
value={item}
|
||||
itemIndex={index}
|
||||
highlighted={index === tableManager.selection}
|
||||
highlighted={
|
||||
index === selection.current || selection.items.has(index)
|
||||
}
|
||||
/>
|
||||
);
|
||||
},
|
||||
[tableManager.selection],
|
||||
[selection],
|
||||
);
|
||||
|
||||
/**
|
||||
@@ -125,34 +165,34 @@ export function DataTable<T extends object>(props: DataTableProps<T>) {
|
||||
const onKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent<any>) => {
|
||||
let handled = true;
|
||||
const shiftPressed = e.shiftKey;
|
||||
const outputSize = dataSource.output.length;
|
||||
const windowSize = virtualizerRef.current!.virtualItems.length;
|
||||
switch (e.key) {
|
||||
case 'ArrowUp':
|
||||
selectItem((idx) => (idx > 0 ? idx - 1 : 0));
|
||||
selectItem((idx) => (idx > 0 ? idx - 1 : 0), shiftPressed);
|
||||
break;
|
||||
case 'ArrowDown':
|
||||
selectItem((idx) =>
|
||||
idx < dataSource.output.length - 1 ? idx + 1 : idx,
|
||||
selectItem(
|
||||
(idx) => (idx < outputSize - 1 ? idx + 1 : idx),
|
||||
shiftPressed,
|
||||
);
|
||||
break;
|
||||
case 'Home':
|
||||
selectItem(() => 0);
|
||||
selectItem(0, shiftPressed);
|
||||
break;
|
||||
case 'End':
|
||||
selectItem(() => dataSource.output.length - 1);
|
||||
selectItem(outputSize - 1, shiftPressed);
|
||||
break;
|
||||
case ' ': // yes, that is a space
|
||||
case 'PageDown':
|
||||
selectItem((idx) =>
|
||||
Math.min(
|
||||
dataSource.output.length - 1,
|
||||
idx + virtualizerRef.current!.virtualItems.length - 1,
|
||||
),
|
||||
selectItem(
|
||||
(idx) => Math.min(outputSize - 1, idx + windowSize - 1),
|
||||
shiftPressed,
|
||||
);
|
||||
break;
|
||||
case 'PageUp':
|
||||
selectItem((idx) =>
|
||||
Math.max(0, idx - virtualizerRef.current!.virtualItems.length - 1),
|
||||
);
|
||||
selectItem((idx) => Math.max(0, idx - windowSize + 1), shiftPressed);
|
||||
break;
|
||||
default:
|
||||
handled = false;
|
||||
@@ -167,8 +207,8 @@ export function DataTable<T extends object>(props: DataTableProps<T>) {
|
||||
|
||||
useLayoutEffect(
|
||||
function scrollSelectionIntoView() {
|
||||
if (selection >= 0) {
|
||||
virtualizerRef.current?.scrollToIndex(selection, {
|
||||
if (selection && selection.current >= 0) {
|
||||
virtualizerRef.current?.scrollToIndex(selection!.current, {
|
||||
align: 'auto',
|
||||
});
|
||||
}
|
||||
@@ -193,14 +233,16 @@ export function DataTable<T extends object>(props: DataTableProps<T>) {
|
||||
);
|
||||
|
||||
/** Context menu */
|
||||
const contexMenu = !props._testHeight // don't render context menu in tests
|
||||
? // eslint-disable-next-line
|
||||
useMemoize(tableContextMenuFactory, [
|
||||
// TODO: support customizing context menu
|
||||
const contexMenu = props._testHeight
|
||||
? undefined // don't render context menu in tests
|
||||
: // eslint-disable-next-line
|
||||
useMemoize(tableContextMenuFactory, [
|
||||
visibleColumns,
|
||||
tableManager.addColumnFilter,
|
||||
])
|
||||
: undefined;
|
||||
|
||||
addColumnFilter,
|
||||
getSelectedItem,
|
||||
getSelectedItems as any,
|
||||
]);
|
||||
return (
|
||||
<Layout.Container grow>
|
||||
<Layout.Top>
|
||||
|
||||
@@ -19,65 +19,87 @@ import {tryGetFlipperLibImplementation} from '../../plugin/FlipperLib';
|
||||
const {Item, SubMenu} = Menu;
|
||||
|
||||
export const TableContextMenuContext = createContext<
|
||||
undefined | ((item: any) => React.ReactElement)
|
||||
React.ReactElement | undefined
|
||||
>(undefined);
|
||||
|
||||
export function tableContextMenuFactory<T>(
|
||||
visibleColumns: DataTableColumn<T>[],
|
||||
addColumnFilter: TableManager['addColumnFilter'],
|
||||
_getSelection: () => T,
|
||||
getMultiSelection: () => T[],
|
||||
) {
|
||||
return function (item: any) {
|
||||
const lib = tryGetFlipperLibImplementation();
|
||||
if (!lib) {
|
||||
return (
|
||||
<Menu>
|
||||
<Item>Menu not ready</Item>
|
||||
</Menu>
|
||||
);
|
||||
}
|
||||
const lib = tryGetFlipperLibImplementation();
|
||||
if (!lib) {
|
||||
return (
|
||||
<Menu>
|
||||
<SubMenu title="Filter on" icon={<FilterOutlined />}>
|
||||
{visibleColumns.map((column) => (
|
||||
<Item
|
||||
key={column.key}
|
||||
onClick={() => {
|
||||
addColumnFilter(
|
||||
column.key,
|
||||
normalizeCellValue(item[column.key]),
|
||||
true,
|
||||
);
|
||||
}}>
|
||||
{column.title || column.key}
|
||||
</Item>
|
||||
))}
|
||||
</SubMenu>
|
||||
<SubMenu title="Copy cell" icon={<CopyOutlined />}>
|
||||
{visibleColumns.map((column) => (
|
||||
<Item
|
||||
key={column.key}
|
||||
onClick={() => {
|
||||
lib.writeTextToClipboard(normalizeCellValue(item[column.key]));
|
||||
}}>
|
||||
{column.title || column.key}
|
||||
</Item>
|
||||
))}
|
||||
</SubMenu>
|
||||
<Item
|
||||
onClick={() => {
|
||||
lib.writeTextToClipboard(JSON.stringify(item, null, 2));
|
||||
}}>
|
||||
Copy row
|
||||
</Item>
|
||||
{lib.isFB && (
|
||||
<Item
|
||||
onClick={() => {
|
||||
lib.createPaste(JSON.stringify(item, null, 2));
|
||||
}}>
|
||||
Create paste
|
||||
</Item>
|
||||
)}
|
||||
<Item>Menu not ready</Item>
|
||||
</Menu>
|
||||
);
|
||||
};
|
||||
}
|
||||
return (
|
||||
<Menu>
|
||||
<SubMenu title="Filter on same..." icon={<FilterOutlined />}>
|
||||
{visibleColumns.map((column) => (
|
||||
<Item
|
||||
key={column.key}
|
||||
onClick={() => {
|
||||
const items = getMultiSelection();
|
||||
if (items.length) {
|
||||
items.forEach((item, index) => {
|
||||
addColumnFilter(
|
||||
column.key,
|
||||
normalizeCellValue(item[column.key]),
|
||||
index === 0, // remove existing filters before adding the first
|
||||
);
|
||||
});
|
||||
}
|
||||
}}>
|
||||
{column.title || column.key}
|
||||
</Item>
|
||||
))}
|
||||
</SubMenu>
|
||||
<SubMenu title="Copy cell(s)" icon={<CopyOutlined />}>
|
||||
{visibleColumns.map((column) => (
|
||||
<Item
|
||||
key={column.key}
|
||||
onClick={() => {
|
||||
const items = getMultiSelection();
|
||||
if (items.length) {
|
||||
lib.writeTextToClipboard(
|
||||
items
|
||||
.map((item) => normalizeCellValue(item[column.key]))
|
||||
.join('\n'),
|
||||
);
|
||||
}
|
||||
}}>
|
||||
{column.title || column.key}
|
||||
</Item>
|
||||
))}
|
||||
</SubMenu>
|
||||
<Item
|
||||
onClick={() => {
|
||||
const items = getMultiSelection();
|
||||
if (items.length) {
|
||||
lib.writeTextToClipboard(
|
||||
JSON.stringify(items.length > 1 ? items : items[0], null, 2),
|
||||
);
|
||||
}
|
||||
}}>
|
||||
Copy row(s)
|
||||
</Item>
|
||||
{lib.isFB && (
|
||||
<Item
|
||||
onClick={() => {
|
||||
const items = getMultiSelection();
|
||||
if (items.length) {
|
||||
lib.createPaste(
|
||||
JSON.stringify(items.length > 1 ? items : items[0], null, 2),
|
||||
);
|
||||
}
|
||||
}}>
|
||||
Create paste
|
||||
</Item>
|
||||
)}
|
||||
</Menu>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -80,6 +80,7 @@ const TableHeadContainer = styled.div<{horizontallyScrollable?: boolean}>({
|
||||
borderBottom: `1px solid ${theme.dividerColor}`,
|
||||
backgroundColor: theme.backgroundWash,
|
||||
userSelect: 'none',
|
||||
borderLeft: `4px solid ${theme.backgroundWash}`, // space for selection, see TableRow
|
||||
});
|
||||
TableHeadContainer.displayName = 'TableHead:TableHeadContainer';
|
||||
|
||||
|
||||
@@ -26,18 +26,18 @@ type TableBodyRowContainerProps = {
|
||||
|
||||
const backgroundColor = (props: TableBodyRowContainerProps) => {
|
||||
if (props.highlighted) {
|
||||
return theme.primaryColor;
|
||||
return theme.backgroundTransparentHover;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const CircleMargin = 4;
|
||||
const RowContextMenu = styled(DownCircleFilled)({
|
||||
const RowContextMenuWrapper = styled.div({
|
||||
position: 'absolute',
|
||||
top: CircleMargin,
|
||||
right: CircleMargin,
|
||||
top: 0,
|
||||
right: 0,
|
||||
paddingRight: CircleMargin,
|
||||
fontSize: DEFAULT_ROW_HEIGHT - CircleMargin * 2,
|
||||
borderRadius: (DEFAULT_ROW_HEIGHT - CircleMargin * 2) * 0.5,
|
||||
color: theme.primaryColor,
|
||||
cursor: 'pointer',
|
||||
visibility: 'hidden',
|
||||
@@ -48,22 +48,15 @@ const TableBodyRowContainer = styled.div<TableBodyRowContainerProps>(
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
backgroundColor: backgroundColor(props),
|
||||
color: props.highlighted ? theme.white : theme.textColorPrimary,
|
||||
'& *': {
|
||||
color: props.highlighted ? `${theme.white} !important` : undefined,
|
||||
},
|
||||
'& img': {
|
||||
backgroundColor: props.highlighted
|
||||
? `${theme.white} !important`
|
||||
: undefined,
|
||||
},
|
||||
borderLeft: props.highlighted
|
||||
? `4px solid ${theme.primaryColor}`
|
||||
: `4px solid ${theme.backgroundDefault}`,
|
||||
minHeight: DEFAULT_ROW_HEIGHT,
|
||||
overflow: 'hidden',
|
||||
width: '100%',
|
||||
flexShrink: 0,
|
||||
[`&:hover ${RowContextMenu}`]: {
|
||||
[`&:hover ${RowContextMenuWrapper}`]: {
|
||||
visibility: 'visible',
|
||||
color: props.highlighted ? theme.white : undefined,
|
||||
},
|
||||
}),
|
||||
);
|
||||
@@ -85,9 +78,15 @@ const TableBodyColumnContainer = styled.div<{
|
||||
width: props.width,
|
||||
justifyContent: props.justifyContent,
|
||||
borderBottom: `1px solid ${theme.dividerColor}`,
|
||||
'&::selection': {
|
||||
color: 'inherit',
|
||||
backgroundColor: theme.buttonDefaultBackground,
|
||||
},
|
||||
}));
|
||||
TableBodyColumnContainer.displayName = 'TableRow:TableBodyColumnContainer';
|
||||
|
||||
const contextMenuTriggers = ['click' as const, 'contextMenu' as const];
|
||||
|
||||
type Props = {
|
||||
config: RenderContext<any>;
|
||||
highlighted: boolean;
|
||||
@@ -98,14 +97,15 @@ type Props = {
|
||||
export const TableRow = memo(function TableRow(props: Props) {
|
||||
const {config, highlighted, value: row} = props;
|
||||
const menu = useContext(TableContextMenuContext);
|
||||
|
||||
return (
|
||||
<TableBodyRowContainer
|
||||
highlighted={highlighted}
|
||||
data-key={row.key}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
props.config.onClick(props.value, props.itemIndex);
|
||||
onMouseDown={(e) => {
|
||||
props.config.onMouseDown(e, props.value, props.itemIndex);
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
props.config.onMouseEnter(e, props.value, props.itemIndex);
|
||||
}}>
|
||||
{config.columns
|
||||
.filter((col) => col.visible)
|
||||
@@ -125,18 +125,26 @@ export const TableRow = memo(function TableRow(props: Props) {
|
||||
</TableBodyColumnContainer>
|
||||
);
|
||||
})}
|
||||
{menu && (
|
||||
<Dropdown
|
||||
overlay={menu(row)}
|
||||
placement="bottomRight"
|
||||
trigger={['click', 'contextMenu']}>
|
||||
<RowContextMenu />
|
||||
</Dropdown>
|
||||
{menu && highlighted && (
|
||||
<RowContextMenuWrapper
|
||||
onClick={stopPropagation}
|
||||
onMouseDown={stopPropagation}>
|
||||
<Dropdown
|
||||
overlay={menu}
|
||||
placement="bottomRight"
|
||||
trigger={contextMenuTriggers}>
|
||||
<DownCircleFilled />
|
||||
</Dropdown>
|
||||
</RowContextMenuWrapper>
|
||||
)}
|
||||
</TableBodyRowContainer>
|
||||
);
|
||||
});
|
||||
|
||||
function stopPropagation(e: React.MouseEvent<any>) {
|
||||
e.stopPropagation();
|
||||
}
|
||||
|
||||
export function normalizeCellValue(value: any): string {
|
||||
switch (typeof value) {
|
||||
case 'boolean':
|
||||
|
||||
@@ -55,15 +55,15 @@ test('update and append', async () => {
|
||||
expect(elem.length).toBe(1);
|
||||
expect(elem[0].parentElement).toMatchInlineSnapshot(`
|
||||
<div
|
||||
class="css-1rnoidw-TableBodyRowContainer efe0za01"
|
||||
class="css-tihkal-TableBodyRowContainer efe0za01"
|
||||
>
|
||||
<div
|
||||
class="ant-table-cell css-1g4z4wd-TableBodyColumnContainer efe0za00"
|
||||
class="ant-table-cell css-1u65yt0-TableBodyColumnContainer efe0za00"
|
||||
>
|
||||
test DataTable
|
||||
</div>
|
||||
<div
|
||||
class="ant-table-cell css-1g4z4wd-TableBodyColumnContainer efe0za00"
|
||||
class="ant-table-cell css-1u65yt0-TableBodyColumnContainer efe0za00"
|
||||
>
|
||||
true
|
||||
</div>
|
||||
@@ -112,15 +112,15 @@ test('column visibility', async () => {
|
||||
expect(elem.length).toBe(1);
|
||||
expect(elem[0].parentElement).toMatchInlineSnapshot(`
|
||||
<div
|
||||
class="css-1rnoidw-TableBodyRowContainer efe0za01"
|
||||
class="css-tihkal-TableBodyRowContainer efe0za01"
|
||||
>
|
||||
<div
|
||||
class="ant-table-cell css-1g4z4wd-TableBodyColumnContainer efe0za00"
|
||||
class="ant-table-cell css-1u65yt0-TableBodyColumnContainer efe0za00"
|
||||
>
|
||||
test DataTable
|
||||
</div>
|
||||
<div
|
||||
class="ant-table-cell css-1g4z4wd-TableBodyColumnContainer efe0za00"
|
||||
class="ant-table-cell css-1u65yt0-TableBodyColumnContainer efe0za00"
|
||||
>
|
||||
true
|
||||
</div>
|
||||
@@ -137,10 +137,10 @@ test('column visibility', async () => {
|
||||
expect(elem.length).toBe(1);
|
||||
expect(elem[0].parentElement).toMatchInlineSnapshot(`
|
||||
<div
|
||||
class="css-1rnoidw-TableBodyRowContainer efe0za01"
|
||||
class="css-tihkal-TableBodyRowContainer efe0za01"
|
||||
>
|
||||
<div
|
||||
class="ant-table-cell css-1g4z4wd-TableBodyColumnContainer efe0za00"
|
||||
class="ant-table-cell css-1u65yt0-TableBodyColumnContainer efe0za00"
|
||||
>
|
||||
test DataTable
|
||||
</div>
|
||||
@@ -510,3 +510,114 @@ test('compute filters', () => {
|
||||
expect(data.filter(filter)).toEqual([]);
|
||||
}
|
||||
});
|
||||
|
||||
test('onSelect callback fires, and in order', () => {
|
||||
const events: any[] = [];
|
||||
const ds = createTestDataSource();
|
||||
const ref = createRef<TableManager>();
|
||||
const rendering = render(
|
||||
<DataTable
|
||||
dataSource={ds}
|
||||
columns={columns}
|
||||
tableManagerRef={ref}
|
||||
_testHeight={400}
|
||||
onSelect={(item, items) => {
|
||||
events.push([item, items]);
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
const item1 = {
|
||||
title: 'item 1',
|
||||
done: false,
|
||||
};
|
||||
const item2 = {
|
||||
title: 'item 2',
|
||||
done: false,
|
||||
};
|
||||
const item3 = {
|
||||
title: 'item 3',
|
||||
done: false,
|
||||
};
|
||||
act(() => {
|
||||
ds.clear();
|
||||
ds.append(item1);
|
||||
ds.append(item2);
|
||||
ds.append(item3);
|
||||
ref.current!.selectItem(2);
|
||||
});
|
||||
|
||||
expect(events.splice(0)).toEqual([
|
||||
[undefined, []],
|
||||
[item3, [item3]],
|
||||
]);
|
||||
|
||||
act(() => {
|
||||
ref.current!.addRangeToSelection(0, 0);
|
||||
});
|
||||
|
||||
expect(events.splice(0)).toEqual([
|
||||
[item1, [item1, item3]], // order preserved!
|
||||
]);
|
||||
|
||||
rendering.unmount();
|
||||
});
|
||||
|
||||
test('selection always has the latest state', () => {
|
||||
const events: any[] = [];
|
||||
const ds = createTestDataSource();
|
||||
const ref = createRef<TableManager>();
|
||||
const rendering = render(
|
||||
<DataTable
|
||||
dataSource={ds}
|
||||
columns={columns}
|
||||
tableManagerRef={ref}
|
||||
_testHeight={400}
|
||||
onSelect={(item, items) => {
|
||||
events.push([item, items]);
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
const item1 = {
|
||||
title: 'item 1',
|
||||
done: false,
|
||||
};
|
||||
const item2 = {
|
||||
title: 'item 2',
|
||||
done: false,
|
||||
};
|
||||
const item3 = {
|
||||
title: 'item 3',
|
||||
done: false,
|
||||
};
|
||||
act(() => {
|
||||
ds.clear();
|
||||
ds.append(item1);
|
||||
ds.append(item2);
|
||||
ds.append(item3);
|
||||
ref.current!.selectItem(2);
|
||||
});
|
||||
|
||||
expect(events.splice(0)).toEqual([
|
||||
[undefined, []],
|
||||
[item3, [item3]],
|
||||
]);
|
||||
|
||||
const item3updated = {
|
||||
title: 'item 3 updated',
|
||||
done: false,
|
||||
};
|
||||
act(() => {
|
||||
ds.update(2, item3updated);
|
||||
});
|
||||
act(() => {
|
||||
ref.current!.addRangeToSelection(0, 0);
|
||||
});
|
||||
|
||||
expect(events.splice(0)).toEqual([
|
||||
[item1, [item1, item3updated]], // update reflected in callback!
|
||||
]);
|
||||
|
||||
rendering.unmount();
|
||||
});
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* 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 {
|
||||
computeAddRangeToSelection,
|
||||
computeSetSelection,
|
||||
} from '../useDataTableManager';
|
||||
|
||||
test('computeSetSelection', () => {
|
||||
const emptyBase = {
|
||||
current: -1,
|
||||
items: new Set<number>(),
|
||||
};
|
||||
|
||||
const partialBase = {
|
||||
current: 7,
|
||||
items: new Set([2, 3, 8, 9]),
|
||||
};
|
||||
|
||||
// set selection
|
||||
expect(computeSetSelection(emptyBase, 2)).toEqual({
|
||||
current: 2,
|
||||
items: new Set([2]),
|
||||
});
|
||||
|
||||
// move selection 2 down
|
||||
expect(computeSetSelection(partialBase, (x) => x + 2)).toEqual({
|
||||
current: 9,
|
||||
items: new Set([9]),
|
||||
});
|
||||
|
||||
// expand selection
|
||||
expect(computeSetSelection(partialBase, (x) => x + 5, true)).toEqual({
|
||||
current: 12,
|
||||
items: new Set([2, 3, 7, 8, 9, 10, 11, 12]),
|
||||
});
|
||||
|
||||
// expand selection backward
|
||||
expect(computeSetSelection(partialBase, 5, true)).toEqual({
|
||||
current: 5,
|
||||
items: new Set([2, 3, 8, 9, 5, 6, 7]), // n.b. order is irrelevant
|
||||
});
|
||||
});
|
||||
|
||||
test('computeAddRangeToSelection', () => {
|
||||
const emptyBase = {
|
||||
current: -1,
|
||||
items: new Set<number>(),
|
||||
};
|
||||
|
||||
const partialBase = {
|
||||
current: 7,
|
||||
items: new Set([2, 3, 8, 9]),
|
||||
};
|
||||
|
||||
// add range selection
|
||||
expect(computeAddRangeToSelection(emptyBase, 23, 25)).toEqual({
|
||||
current: 25,
|
||||
items: new Set([23, 24, 25]),
|
||||
});
|
||||
|
||||
// add range selection
|
||||
expect(computeAddRangeToSelection(partialBase, 23, 25)).toEqual({
|
||||
current: 25,
|
||||
items: new Set([2, 3, 8, 9, 23, 24, 25]),
|
||||
});
|
||||
|
||||
// add range backward
|
||||
expect(computeAddRangeToSelection(partialBase, 25, 23)).toEqual({
|
||||
current: 23,
|
||||
items: new Set([2, 3, 8, 9, 23, 24, 25]),
|
||||
});
|
||||
|
||||
// invest selection - toggle off
|
||||
expect(computeAddRangeToSelection(partialBase, 8, 8, true)).toEqual({
|
||||
current: 8, // note: this item is not part of the selection!
|
||||
items: new Set([2, 3, 9]),
|
||||
});
|
||||
|
||||
// invest selection - toggle on
|
||||
expect(computeAddRangeToSelection(partialBase, 5, 5, true)).toEqual({
|
||||
current: 5,
|
||||
items: new Set([2, 3, 5, 8, 9]),
|
||||
});
|
||||
});
|
||||
@@ -10,7 +10,7 @@
|
||||
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 {useCallback, useEffect, useMemo, useRef, useState} from 'react';
|
||||
import {DataSource} from '../../state/datasource/DataSource';
|
||||
import {useMemoize} from '../../utils/useMemoize';
|
||||
|
||||
@@ -22,20 +22,30 @@ export type Sorting = {
|
||||
|
||||
export type TableManager = ReturnType<typeof useDataTableManager>;
|
||||
|
||||
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 extends object>(
|
||||
export function useDataTableManager<T>(
|
||||
dataSource: DataSource<T>,
|
||||
defaultColumns: DataTableColumn<T>[],
|
||||
onSelect?: (item: T | undefined, index: number) => void,
|
||||
onSelect?: (item: T | undefined, items: T[]) => void,
|
||||
) {
|
||||
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(-1);
|
||||
const [selection, setSelection] = useState<Selection>(emptySelection);
|
||||
const selectionRef = useRef(selection);
|
||||
selectionRef.current = selection; // store last seen selection for fetching it later
|
||||
|
||||
const [sorting, setSorting] = useState<Sorting | undefined>(undefined);
|
||||
const [searchValue, setSearchValue] = useState('');
|
||||
const visibleColumns = useMemo(
|
||||
@@ -102,6 +112,7 @@ export function useDataTableManager<T extends object>(
|
||||
setEffectiveColumns(computeInitialColumns(defaultColumns));
|
||||
setSorting(undefined);
|
||||
setSearchValue('');
|
||||
setSelection(emptySelection);
|
||||
dataSource.reset();
|
||||
}, [dataSource, defaultColumns]);
|
||||
|
||||
@@ -148,21 +159,6 @@ export function useDataTableManager<T extends object>(
|
||||
);
|
||||
}, []);
|
||||
|
||||
const selectItem = useCallback(
|
||||
(updater: (currentIndex: number) => number) => {
|
||||
setSelection((currentIndex) => {
|
||||
const newIndex = updater(currentIndex);
|
||||
const item =
|
||||
newIndex >= 0 && newIndex < dataSource.output.length
|
||||
? dataSource.getItem(newIndex)
|
||||
: undefined;
|
||||
onSelect?.(item, newIndex);
|
||||
return newIndex;
|
||||
});
|
||||
},
|
||||
[setSelection, onSelect, dataSource],
|
||||
);
|
||||
|
||||
useEffect(
|
||||
function applyFilter() {
|
||||
dataSource.setFilter(currentFilter);
|
||||
@@ -170,6 +166,65 @@ export function useDataTableManager<T extends object>(
|
||||
[currentFilter, dataSource],
|
||||
);
|
||||
|
||||
/**
|
||||
* 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),
|
||||
);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
// N.B: we really want to have stable refs for these functions,
|
||||
// to avoid that all context menus need re-render for every selection change,
|
||||
// hence the selectionRef hack
|
||||
const getSelectedItem = useCallback(() => {
|
||||
return selectionRef.current.current < 0
|
||||
? undefined
|
||||
: dataSource.getItem(selectionRef.current.current);
|
||||
}, [dataSource]);
|
||||
|
||||
const getSelectedItems = useCallback(() => {
|
||||
return [...selectionRef.current.items]
|
||||
.sort()
|
||||
.map((i) => dataSource.getItem(i))
|
||||
.filter(Boolean);
|
||||
}, [dataSource]);
|
||||
|
||||
useEffect(
|
||||
function fireSelection() {
|
||||
if (onSelect) {
|
||||
const item = getSelectedItem();
|
||||
const items = getSelectedItems();
|
||||
onSelect(item, items);
|
||||
}
|
||||
},
|
||||
// selection is intentionally a dep
|
||||
[onSelect, selection, selection, getSelectedItem, getSelectedItems],
|
||||
);
|
||||
|
||||
return {
|
||||
/** The default columns, but normalized */
|
||||
columns,
|
||||
@@ -190,6 +245,9 @@ export function useDataTableManager<T extends object>(
|
||||
/** current selection, describes the index index in the datasources's current output (not window) */
|
||||
selection,
|
||||
selectItem,
|
||||
addRangeToSelection,
|
||||
getSelectedItem,
|
||||
getSelectedItems,
|
||||
/** Changing column filters */
|
||||
addColumnFilter,
|
||||
removeColumnFilter,
|
||||
@@ -244,3 +302,73 @@ export function computeDataTableFilter(
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
export function computeSetSelection(
|
||||
base: Selection,
|
||||
nextIndex: number | ((currentIndex: number) => number),
|
||||
addToSelection?: boolean,
|
||||
): Selection {
|
||||
const newIndex =
|
||||
typeof nextIndex === 'number' ? nextIndex : nextIndex(base.current);
|
||||
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 existing item
|
||||
if (start === end && allowUnselect) {
|
||||
if (base?.items.has(start)) {
|
||||
const copy = new Set(base.items);
|
||||
copy.delete(start);
|
||||
if (copy.size === 0) {
|
||||
return emptySelection;
|
||||
}
|
||||
return {
|
||||
items: copy,
|
||||
current: start,
|
||||
};
|
||||
}
|
||||
// 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