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;
|
autoScroll?: boolean;
|
||||||
extraActions?: React.ReactElement;
|
extraActions?: React.ReactElement;
|
||||||
// custom onSearch(text, row) option?
|
// custom onSearch(text, row) option?
|
||||||
/**
|
onSelect?(item: T | undefined, items: T[]): void;
|
||||||
* 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;
|
|
||||||
// multiselect?: true
|
// multiselect?: true
|
||||||
// onMultiSelect
|
|
||||||
tableManagerRef?: RefObject<TableManager>;
|
tableManagerRef?: RefObject<TableManager>;
|
||||||
_testHeight?: number; // exposed for unit testing only
|
_testHeight?: number; // exposed for unit testing only
|
||||||
}
|
}
|
||||||
@@ -70,13 +63,24 @@ export type DataTableColumn<T = any> = {
|
|||||||
|
|
||||||
export interface RenderContext<T = any> {
|
export interface RenderContext<T = any> {
|
||||||
columns: DataTableColumn<T>[];
|
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 {dataSource} = props;
|
||||||
const virtualizerRef = useRef<DataSourceVirtualizer | undefined>();
|
const virtualizerRef = useRef<DataSourceVirtualizer | undefined>();
|
||||||
const tableManager = useDataTableManager<T>(
|
const tableManager = useDataTableManager(
|
||||||
dataSource,
|
dataSource,
|
||||||
props.columns,
|
props.columns,
|
||||||
props.onSelect,
|
props.onSelect,
|
||||||
@@ -84,16 +88,50 @@ export function DataTable<T extends object>(props: DataTableProps<T>) {
|
|||||||
if (props.tableManagerRef) {
|
if (props.tableManagerRef) {
|
||||||
(props.tableManagerRef as MutableRefObject<TableManager>).current = tableManager;
|
(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>>(() => {
|
const renderingConfig = useMemo<RenderContext<T>>(() => {
|
||||||
|
let dragging = false;
|
||||||
|
let startIndex = 0;
|
||||||
return {
|
return {
|
||||||
columns: visibleColumns,
|
columns: visibleColumns,
|
||||||
onClick(_, itemIdx) {
|
onMouseEnter(_e, _item, index) {
|
||||||
selectItem(() => itemIdx);
|
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(
|
const usesWrapping = useMemo(
|
||||||
() => tableManager.columns.some((col) => col.wrap),
|
() => tableManager.columns.some((col) => col.wrap),
|
||||||
@@ -112,11 +150,13 @@ export function DataTable<T extends object>(props: DataTableProps<T>) {
|
|||||||
config={renderContext}
|
config={renderContext}
|
||||||
value={item}
|
value={item}
|
||||||
itemIndex={index}
|
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(
|
const onKeyDown = useCallback(
|
||||||
(e: React.KeyboardEvent<any>) => {
|
(e: React.KeyboardEvent<any>) => {
|
||||||
let handled = true;
|
let handled = true;
|
||||||
|
const shiftPressed = e.shiftKey;
|
||||||
|
const outputSize = dataSource.output.length;
|
||||||
|
const windowSize = virtualizerRef.current!.virtualItems.length;
|
||||||
switch (e.key) {
|
switch (e.key) {
|
||||||
case 'ArrowUp':
|
case 'ArrowUp':
|
||||||
selectItem((idx) => (idx > 0 ? idx - 1 : 0));
|
selectItem((idx) => (idx > 0 ? idx - 1 : 0), shiftPressed);
|
||||||
break;
|
break;
|
||||||
case 'ArrowDown':
|
case 'ArrowDown':
|
||||||
selectItem((idx) =>
|
selectItem(
|
||||||
idx < dataSource.output.length - 1 ? idx + 1 : idx,
|
(idx) => (idx < outputSize - 1 ? idx + 1 : idx),
|
||||||
|
shiftPressed,
|
||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
case 'Home':
|
case 'Home':
|
||||||
selectItem(() => 0);
|
selectItem(0, shiftPressed);
|
||||||
break;
|
break;
|
||||||
case 'End':
|
case 'End':
|
||||||
selectItem(() => dataSource.output.length - 1);
|
selectItem(outputSize - 1, shiftPressed);
|
||||||
break;
|
break;
|
||||||
case ' ': // yes, that is a space
|
case ' ': // yes, that is a space
|
||||||
case 'PageDown':
|
case 'PageDown':
|
||||||
selectItem((idx) =>
|
selectItem(
|
||||||
Math.min(
|
(idx) => Math.min(outputSize - 1, idx + windowSize - 1),
|
||||||
dataSource.output.length - 1,
|
shiftPressed,
|
||||||
idx + virtualizerRef.current!.virtualItems.length - 1,
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
case 'PageUp':
|
case 'PageUp':
|
||||||
selectItem((idx) =>
|
selectItem((idx) => Math.max(0, idx - windowSize + 1), shiftPressed);
|
||||||
Math.max(0, idx - virtualizerRef.current!.virtualItems.length - 1),
|
|
||||||
);
|
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
handled = false;
|
handled = false;
|
||||||
@@ -167,8 +207,8 @@ export function DataTable<T extends object>(props: DataTableProps<T>) {
|
|||||||
|
|
||||||
useLayoutEffect(
|
useLayoutEffect(
|
||||||
function scrollSelectionIntoView() {
|
function scrollSelectionIntoView() {
|
||||||
if (selection >= 0) {
|
if (selection && selection.current >= 0) {
|
||||||
virtualizerRef.current?.scrollToIndex(selection, {
|
virtualizerRef.current?.scrollToIndex(selection!.current, {
|
||||||
align: 'auto',
|
align: 'auto',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -193,14 +233,16 @@ export function DataTable<T extends object>(props: DataTableProps<T>) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
/** Context menu */
|
/** Context menu */
|
||||||
const contexMenu = !props._testHeight // don't render context menu in tests
|
// TODO: support customizing context menu
|
||||||
? // eslint-disable-next-line
|
const contexMenu = props._testHeight
|
||||||
|
? undefined // don't render context menu in tests
|
||||||
|
: // eslint-disable-next-line
|
||||||
useMemoize(tableContextMenuFactory, [
|
useMemoize(tableContextMenuFactory, [
|
||||||
visibleColumns,
|
visibleColumns,
|
||||||
tableManager.addColumnFilter,
|
addColumnFilter,
|
||||||
])
|
getSelectedItem,
|
||||||
: undefined;
|
getSelectedItems as any,
|
||||||
|
]);
|
||||||
return (
|
return (
|
||||||
<Layout.Container grow>
|
<Layout.Container grow>
|
||||||
<Layout.Top>
|
<Layout.Top>
|
||||||
|
|||||||
@@ -19,14 +19,15 @@ import {tryGetFlipperLibImplementation} from '../../plugin/FlipperLib';
|
|||||||
const {Item, SubMenu} = Menu;
|
const {Item, SubMenu} = Menu;
|
||||||
|
|
||||||
export const TableContextMenuContext = createContext<
|
export const TableContextMenuContext = createContext<
|
||||||
undefined | ((item: any) => React.ReactElement)
|
React.ReactElement | undefined
|
||||||
>(undefined);
|
>(undefined);
|
||||||
|
|
||||||
export function tableContextMenuFactory<T>(
|
export function tableContextMenuFactory<T>(
|
||||||
visibleColumns: DataTableColumn<T>[],
|
visibleColumns: DataTableColumn<T>[],
|
||||||
addColumnFilter: TableManager['addColumnFilter'],
|
addColumnFilter: TableManager['addColumnFilter'],
|
||||||
|
_getSelection: () => T,
|
||||||
|
getMultiSelection: () => T[],
|
||||||
) {
|
) {
|
||||||
return function (item: any) {
|
|
||||||
const lib = tryGetFlipperLibImplementation();
|
const lib = tryGetFlipperLibImplementation();
|
||||||
if (!lib) {
|
if (!lib) {
|
||||||
return (
|
return (
|
||||||
@@ -37,27 +38,39 @@ export function tableContextMenuFactory<T>(
|
|||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<Menu>
|
<Menu>
|
||||||
<SubMenu title="Filter on" icon={<FilterOutlined />}>
|
<SubMenu title="Filter on same..." icon={<FilterOutlined />}>
|
||||||
{visibleColumns.map((column) => (
|
{visibleColumns.map((column) => (
|
||||||
<Item
|
<Item
|
||||||
key={column.key}
|
key={column.key}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
const items = getMultiSelection();
|
||||||
|
if (items.length) {
|
||||||
|
items.forEach((item, index) => {
|
||||||
addColumnFilter(
|
addColumnFilter(
|
||||||
column.key,
|
column.key,
|
||||||
normalizeCellValue(item[column.key]),
|
normalizeCellValue(item[column.key]),
|
||||||
true,
|
index === 0, // remove existing filters before adding the first
|
||||||
);
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
}}>
|
}}>
|
||||||
{column.title || column.key}
|
{column.title || column.key}
|
||||||
</Item>
|
</Item>
|
||||||
))}
|
))}
|
||||||
</SubMenu>
|
</SubMenu>
|
||||||
<SubMenu title="Copy cell" icon={<CopyOutlined />}>
|
<SubMenu title="Copy cell(s)" icon={<CopyOutlined />}>
|
||||||
{visibleColumns.map((column) => (
|
{visibleColumns.map((column) => (
|
||||||
<Item
|
<Item
|
||||||
key={column.key}
|
key={column.key}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
lib.writeTextToClipboard(normalizeCellValue(item[column.key]));
|
const items = getMultiSelection();
|
||||||
|
if (items.length) {
|
||||||
|
lib.writeTextToClipboard(
|
||||||
|
items
|
||||||
|
.map((item) => normalizeCellValue(item[column.key]))
|
||||||
|
.join('\n'),
|
||||||
|
);
|
||||||
|
}
|
||||||
}}>
|
}}>
|
||||||
{column.title || column.key}
|
{column.title || column.key}
|
||||||
</Item>
|
</Item>
|
||||||
@@ -65,19 +78,28 @@ export function tableContextMenuFactory<T>(
|
|||||||
</SubMenu>
|
</SubMenu>
|
||||||
<Item
|
<Item
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
lib.writeTextToClipboard(JSON.stringify(item, null, 2));
|
const items = getMultiSelection();
|
||||||
|
if (items.length) {
|
||||||
|
lib.writeTextToClipboard(
|
||||||
|
JSON.stringify(items.length > 1 ? items : items[0], null, 2),
|
||||||
|
);
|
||||||
|
}
|
||||||
}}>
|
}}>
|
||||||
Copy row
|
Copy row(s)
|
||||||
</Item>
|
</Item>
|
||||||
{lib.isFB && (
|
{lib.isFB && (
|
||||||
<Item
|
<Item
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
lib.createPaste(JSON.stringify(item, null, 2));
|
const items = getMultiSelection();
|
||||||
|
if (items.length) {
|
||||||
|
lib.createPaste(
|
||||||
|
JSON.stringify(items.length > 1 ? items : items[0], null, 2),
|
||||||
|
);
|
||||||
|
}
|
||||||
}}>
|
}}>
|
||||||
Create paste
|
Create paste
|
||||||
</Item>
|
</Item>
|
||||||
)}
|
)}
|
||||||
</Menu>
|
</Menu>
|
||||||
);
|
);
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -80,6 +80,7 @@ const TableHeadContainer = styled.div<{horizontallyScrollable?: boolean}>({
|
|||||||
borderBottom: `1px solid ${theme.dividerColor}`,
|
borderBottom: `1px solid ${theme.dividerColor}`,
|
||||||
backgroundColor: theme.backgroundWash,
|
backgroundColor: theme.backgroundWash,
|
||||||
userSelect: 'none',
|
userSelect: 'none',
|
||||||
|
borderLeft: `4px solid ${theme.backgroundWash}`, // space for selection, see TableRow
|
||||||
});
|
});
|
||||||
TableHeadContainer.displayName = 'TableHead:TableHeadContainer';
|
TableHeadContainer.displayName = 'TableHead:TableHeadContainer';
|
||||||
|
|
||||||
|
|||||||
@@ -26,18 +26,18 @@ type TableBodyRowContainerProps = {
|
|||||||
|
|
||||||
const backgroundColor = (props: TableBodyRowContainerProps) => {
|
const backgroundColor = (props: TableBodyRowContainerProps) => {
|
||||||
if (props.highlighted) {
|
if (props.highlighted) {
|
||||||
return theme.primaryColor;
|
return theme.backgroundTransparentHover;
|
||||||
}
|
}
|
||||||
return undefined;
|
return undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
const CircleMargin = 4;
|
const CircleMargin = 4;
|
||||||
const RowContextMenu = styled(DownCircleFilled)({
|
const RowContextMenuWrapper = styled.div({
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
top: CircleMargin,
|
top: 0,
|
||||||
right: CircleMargin,
|
right: 0,
|
||||||
|
paddingRight: CircleMargin,
|
||||||
fontSize: DEFAULT_ROW_HEIGHT - CircleMargin * 2,
|
fontSize: DEFAULT_ROW_HEIGHT - CircleMargin * 2,
|
||||||
borderRadius: (DEFAULT_ROW_HEIGHT - CircleMargin * 2) * 0.5,
|
|
||||||
color: theme.primaryColor,
|
color: theme.primaryColor,
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
visibility: 'hidden',
|
visibility: 'hidden',
|
||||||
@@ -48,22 +48,15 @@ const TableBodyRowContainer = styled.div<TableBodyRowContainerProps>(
|
|||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
backgroundColor: backgroundColor(props),
|
backgroundColor: backgroundColor(props),
|
||||||
color: props.highlighted ? theme.white : theme.textColorPrimary,
|
borderLeft: props.highlighted
|
||||||
'& *': {
|
? `4px solid ${theme.primaryColor}`
|
||||||
color: props.highlighted ? `${theme.white} !important` : undefined,
|
: `4px solid ${theme.backgroundDefault}`,
|
||||||
},
|
|
||||||
'& img': {
|
|
||||||
backgroundColor: props.highlighted
|
|
||||||
? `${theme.white} !important`
|
|
||||||
: undefined,
|
|
||||||
},
|
|
||||||
minHeight: DEFAULT_ROW_HEIGHT,
|
minHeight: DEFAULT_ROW_HEIGHT,
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
width: '100%',
|
width: '100%',
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
[`&:hover ${RowContextMenu}`]: {
|
[`&:hover ${RowContextMenuWrapper}`]: {
|
||||||
visibility: 'visible',
|
visibility: 'visible',
|
||||||
color: props.highlighted ? theme.white : undefined,
|
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -85,9 +78,15 @@ const TableBodyColumnContainer = styled.div<{
|
|||||||
width: props.width,
|
width: props.width,
|
||||||
justifyContent: props.justifyContent,
|
justifyContent: props.justifyContent,
|
||||||
borderBottom: `1px solid ${theme.dividerColor}`,
|
borderBottom: `1px solid ${theme.dividerColor}`,
|
||||||
|
'&::selection': {
|
||||||
|
color: 'inherit',
|
||||||
|
backgroundColor: theme.buttonDefaultBackground,
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
TableBodyColumnContainer.displayName = 'TableRow:TableBodyColumnContainer';
|
TableBodyColumnContainer.displayName = 'TableRow:TableBodyColumnContainer';
|
||||||
|
|
||||||
|
const contextMenuTriggers = ['click' as const, 'contextMenu' as const];
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
config: RenderContext<any>;
|
config: RenderContext<any>;
|
||||||
highlighted: boolean;
|
highlighted: boolean;
|
||||||
@@ -98,14 +97,15 @@ type Props = {
|
|||||||
export const TableRow = memo(function TableRow(props: Props) {
|
export const TableRow = memo(function TableRow(props: Props) {
|
||||||
const {config, highlighted, value: row} = props;
|
const {config, highlighted, value: row} = props;
|
||||||
const menu = useContext(TableContextMenuContext);
|
const menu = useContext(TableContextMenuContext);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TableBodyRowContainer
|
<TableBodyRowContainer
|
||||||
highlighted={highlighted}
|
highlighted={highlighted}
|
||||||
data-key={row.key}
|
data-key={row.key}
|
||||||
onClick={(e) => {
|
onMouseDown={(e) => {
|
||||||
e.stopPropagation();
|
props.config.onMouseDown(e, props.value, props.itemIndex);
|
||||||
props.config.onClick(props.value, props.itemIndex);
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
props.config.onMouseEnter(e, props.value, props.itemIndex);
|
||||||
}}>
|
}}>
|
||||||
{config.columns
|
{config.columns
|
||||||
.filter((col) => col.visible)
|
.filter((col) => col.visible)
|
||||||
@@ -125,18 +125,26 @@ export const TableRow = memo(function TableRow(props: Props) {
|
|||||||
</TableBodyColumnContainer>
|
</TableBodyColumnContainer>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
{menu && (
|
{menu && highlighted && (
|
||||||
|
<RowContextMenuWrapper
|
||||||
|
onClick={stopPropagation}
|
||||||
|
onMouseDown={stopPropagation}>
|
||||||
<Dropdown
|
<Dropdown
|
||||||
overlay={menu(row)}
|
overlay={menu}
|
||||||
placement="bottomRight"
|
placement="bottomRight"
|
||||||
trigger={['click', 'contextMenu']}>
|
trigger={contextMenuTriggers}>
|
||||||
<RowContextMenu />
|
<DownCircleFilled />
|
||||||
</Dropdown>
|
</Dropdown>
|
||||||
|
</RowContextMenuWrapper>
|
||||||
)}
|
)}
|
||||||
</TableBodyRowContainer>
|
</TableBodyRowContainer>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function stopPropagation(e: React.MouseEvent<any>) {
|
||||||
|
e.stopPropagation();
|
||||||
|
}
|
||||||
|
|
||||||
export function normalizeCellValue(value: any): string {
|
export function normalizeCellValue(value: any): string {
|
||||||
switch (typeof value) {
|
switch (typeof value) {
|
||||||
case 'boolean':
|
case 'boolean':
|
||||||
|
|||||||
@@ -55,15 +55,15 @@ test('update and append', async () => {
|
|||||||
expect(elem.length).toBe(1);
|
expect(elem.length).toBe(1);
|
||||||
expect(elem[0].parentElement).toMatchInlineSnapshot(`
|
expect(elem[0].parentElement).toMatchInlineSnapshot(`
|
||||||
<div
|
<div
|
||||||
class="css-1rnoidw-TableBodyRowContainer efe0za01"
|
class="css-tihkal-TableBodyRowContainer efe0za01"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="ant-table-cell css-1g4z4wd-TableBodyColumnContainer efe0za00"
|
class="ant-table-cell css-1u65yt0-TableBodyColumnContainer efe0za00"
|
||||||
>
|
>
|
||||||
test DataTable
|
test DataTable
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
class="ant-table-cell css-1g4z4wd-TableBodyColumnContainer efe0za00"
|
class="ant-table-cell css-1u65yt0-TableBodyColumnContainer efe0za00"
|
||||||
>
|
>
|
||||||
true
|
true
|
||||||
</div>
|
</div>
|
||||||
@@ -112,15 +112,15 @@ test('column visibility', async () => {
|
|||||||
expect(elem.length).toBe(1);
|
expect(elem.length).toBe(1);
|
||||||
expect(elem[0].parentElement).toMatchInlineSnapshot(`
|
expect(elem[0].parentElement).toMatchInlineSnapshot(`
|
||||||
<div
|
<div
|
||||||
class="css-1rnoidw-TableBodyRowContainer efe0za01"
|
class="css-tihkal-TableBodyRowContainer efe0za01"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="ant-table-cell css-1g4z4wd-TableBodyColumnContainer efe0za00"
|
class="ant-table-cell css-1u65yt0-TableBodyColumnContainer efe0za00"
|
||||||
>
|
>
|
||||||
test DataTable
|
test DataTable
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
class="ant-table-cell css-1g4z4wd-TableBodyColumnContainer efe0za00"
|
class="ant-table-cell css-1u65yt0-TableBodyColumnContainer efe0za00"
|
||||||
>
|
>
|
||||||
true
|
true
|
||||||
</div>
|
</div>
|
||||||
@@ -137,10 +137,10 @@ test('column visibility', async () => {
|
|||||||
expect(elem.length).toBe(1);
|
expect(elem.length).toBe(1);
|
||||||
expect(elem[0].parentElement).toMatchInlineSnapshot(`
|
expect(elem[0].parentElement).toMatchInlineSnapshot(`
|
||||||
<div
|
<div
|
||||||
class="css-1rnoidw-TableBodyRowContainer efe0za01"
|
class="css-tihkal-TableBodyRowContainer efe0za01"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="ant-table-cell css-1g4z4wd-TableBodyColumnContainer efe0za00"
|
class="ant-table-cell css-1u65yt0-TableBodyColumnContainer efe0za00"
|
||||||
>
|
>
|
||||||
test DataTable
|
test DataTable
|
||||||
</div>
|
</div>
|
||||||
@@ -510,3 +510,114 @@ test('compute filters', () => {
|
|||||||
expect(data.filter(filter)).toEqual([]);
|
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 {DataTableColumn} from 'flipper-plugin/src/ui/datatable/DataTable';
|
||||||
import {Percentage} from '../../utils/widthUtils';
|
import {Percentage} from '../../utils/widthUtils';
|
||||||
import produce from 'immer';
|
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 {DataSource} from '../../state/datasource/DataSource';
|
||||||
import {useMemoize} from '../../utils/useMemoize';
|
import {useMemoize} from '../../utils/useMemoize';
|
||||||
|
|
||||||
@@ -22,20 +22,30 @@ export type Sorting = {
|
|||||||
|
|
||||||
export type TableManager = ReturnType<typeof useDataTableManager>;
|
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
|
* A hook that coordinates filtering, sorting etc for a DataSource
|
||||||
*/
|
*/
|
||||||
export function useDataTableManager<T extends object>(
|
export function useDataTableManager<T>(
|
||||||
dataSource: DataSource<T>,
|
dataSource: DataSource<T>,
|
||||||
defaultColumns: DataTableColumn<T>[],
|
defaultColumns: DataTableColumn<T>[],
|
||||||
onSelect?: (item: T | undefined, index: number) => void,
|
onSelect?: (item: T | undefined, items: T[]) => void,
|
||||||
) {
|
) {
|
||||||
const [columns, setEffectiveColumns] = useState(
|
const [columns, setEffectiveColumns] = useState(
|
||||||
computeInitialColumns(defaultColumns),
|
computeInitialColumns(defaultColumns),
|
||||||
);
|
);
|
||||||
// TODO: move selection with shifts with index < selection?
|
// TODO: move selection with shifts with index < selection?
|
||||||
// TODO: clear selection if out of range
|
// 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 [sorting, setSorting] = useState<Sorting | undefined>(undefined);
|
||||||
const [searchValue, setSearchValue] = useState('');
|
const [searchValue, setSearchValue] = useState('');
|
||||||
const visibleColumns = useMemo(
|
const visibleColumns = useMemo(
|
||||||
@@ -102,6 +112,7 @@ export function useDataTableManager<T extends object>(
|
|||||||
setEffectiveColumns(computeInitialColumns(defaultColumns));
|
setEffectiveColumns(computeInitialColumns(defaultColumns));
|
||||||
setSorting(undefined);
|
setSorting(undefined);
|
||||||
setSearchValue('');
|
setSearchValue('');
|
||||||
|
setSelection(emptySelection);
|
||||||
dataSource.reset();
|
dataSource.reset();
|
||||||
}, [dataSource, defaultColumns]);
|
}, [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(
|
useEffect(
|
||||||
function applyFilter() {
|
function applyFilter() {
|
||||||
dataSource.setFilter(currentFilter);
|
dataSource.setFilter(currentFilter);
|
||||||
@@ -170,6 +166,65 @@ export function useDataTableManager<T extends object>(
|
|||||||
[currentFilter, dataSource],
|
[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 {
|
return {
|
||||||
/** The default columns, but normalized */
|
/** The default columns, but normalized */
|
||||||
columns,
|
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) */
|
/** current selection, describes the index index in the datasources's current output (not window) */
|
||||||
selection,
|
selection,
|
||||||
selectItem,
|
selectItem,
|
||||||
|
addRangeToSelection,
|
||||||
|
getSelectedItem,
|
||||||
|
getSelectedItems,
|
||||||
/** Changing column filters */
|
/** Changing column filters */
|
||||||
addColumnFilter,
|
addColumnFilter,
|
||||||
removeColumnFilter,
|
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