Implement context menu

Summary:
Re-introduced context menu to DataTable, due to popular demand.

Originally it wasn't there to better align with ant design principles, but in an app like Flipper it makes just too much sense to have it

See e.g. https://fb.workplace.com/groups/flippersupport/permalink/1138285579985432/

changelog: Restored context menu in data tables

Reviewed By: passy

Differential Revision: D28996137

fbshipit-source-id: 16ef4c90997c9313efa62da7576fd453a7853761
This commit is contained in:
Michel Weststrate
2021-06-10 12:55:56 -07:00
committed by Facebook GitHub Bot
parent abc9785e0e
commit 7e4df00138
8 changed files with 50 additions and 18 deletions

View File

@@ -169,6 +169,8 @@ DataList.defaultProps = {
enableSearchbar: false, enableSearchbar: false,
enableColumnHeaders: false, enableColumnHeaders: false,
enableArrow: true, enableArrow: true,
enableContextMenu: false,
enableMultiSelect: false,
}; };
const DataListItem = memo( const DataListItem = memo(

View File

@@ -32,7 +32,7 @@ export {DataValueExtractor} from './DataPreview';
export const RootDataContext = createContext<() => any>(() => ({})); export const RootDataContext = createContext<() => any>(() => ({}));
const contextMenuTrigger = ['contextMenu' as const]; export const contextMenuTrigger = ['contextMenu' as const];
const BaseContainer = styled.div<{depth?: number; disabled?: boolean}>( const BaseContainer = styled.div<{depth?: number; disabled?: boolean}>(
(props) => ({ (props) => ({

View File

@@ -59,6 +59,7 @@ interface DataTableBaseProps<T = any> {
enableAutoScroll?: boolean; enableAutoScroll?: boolean;
enableColumnHeaders?: boolean; enableColumnHeaders?: boolean;
enableMultiSelect?: boolean; enableMultiSelect?: boolean;
enableContextMenu?: boolean;
// if set (the default) will grow and become scrollable. Otherwise will use natural size // if set (the default) will grow and become scrollable. Otherwise will use natural size
scrollable?: boolean; scrollable?: boolean;
extraActions?: React.ReactElement; extraActions?: React.ReactElement;
@@ -119,6 +120,7 @@ export interface TableRowRenderContext<T = any> {
itemId: number, itemId: number,
): void; ): void;
onRowStyle?(item: T): React.CSSProperties | undefined; onRowStyle?(item: T): React.CSSProperties | undefined;
onContextMenu?(): React.ReactElement;
} }
export type DataTableProps<T> = DataTableInput<T> & DataTableBaseProps<T>; export type DataTableProps<T> = DataTableInput<T> & DataTableBaseProps<T>;
@@ -185,7 +187,10 @@ export function DataTable<T extends object>(
}, },
onMouseDown(e, _item, index) { onMouseDown(e, _item, index) {
if (!dragging.current) { if (!dragging.current) {
if (e.ctrlKey || e.metaKey) { if (e.buttons > 1) {
// for right click we only want to add if needed, not deselect
tableManager.addRangeToSelection(index, index, false);
} else if (e.ctrlKey || e.metaKey) {
tableManager.addRangeToSelection(index, index, true); tableManager.addRangeToSelection(index, index, true);
} else if (e.shiftKey) { } else if (e.shiftKey) {
tableManager.selectItem(index, true, true); tableManager.selectItem(index, true, true);
@@ -205,8 +210,15 @@ export function DataTable<T extends object>(
} }
}, },
onRowStyle, onRowStyle,
onContextMenu: props.enableContextMenu
? () => {
// using a ref keeps the config stable, so that a new context menu doesn't need
// all rows to be rerendered, but rather shows it conditionally
return contextMenuRef.current?.()!;
}
: undefined,
}; };
}, [visibleColumns, tableManager, onRowStyle]); }, [visibleColumns, tableManager, onRowStyle, props.enableContextMenu]);
const itemRenderer = useCallback( const itemRenderer = useCallback(
function itemRenderer( function itemRenderer(
@@ -415,6 +427,9 @@ export function DataTable<T extends object>(
], ],
); );
const contextMenuRef = useRef(contexMenu);
contextMenuRef.current = contexMenu;
useEffect(function initialSetup() { useEffect(function initialSetup() {
return function cleanup() { return function cleanup() {
// write current prefs to local storage // write current prefs to local storage
@@ -519,7 +534,8 @@ DataTable.defaultProps = {
enableSearchbar: true, enableSearchbar: true,
enableAutoScroll: false, enableAutoScroll: false,
enableColumnHeaders: true, enableColumnHeaders: true,
eanbleMultiSelect: true, enableMultiSelect: true,
enableContextMenu: true,
onRenderEmpty: emptyRenderer, onRenderEmpty: emptyRenderer,
} as Partial<DataTableProps<any>>; } as Partial<DataTableProps<any>>;

View File

@@ -45,19 +45,19 @@ export function tableContextMenuFactory<T>(
); );
} }
const hasSelection = selection.items.size > 0 ?? false; const hasSelection = selection.items.size > 0 ?? false;
return ( return (
<Menu> <Menu>
{onContextMenu {onContextMenu
? onContextMenu(getSelectedItem(datasource, selection)) ? onContextMenu(getSelectedItem(datasource, selection))
: null} : null}
<SubMenu <SubMenu
key="filter same"
title="Filter on same" title="Filter on same"
icon={<FilterOutlined />} icon={<FilterOutlined />}
disabled={!hasSelection}> disabled={!hasSelection}>
{visibleColumns.map((column) => ( {visibleColumns.map((column, idx) => (
<Item <Item
key={column.key} key={column.key ?? idx}
onClick={() => { onClick={() => {
dispatch({ dispatch({
type: 'setColumnFilterFromSelection', type: 'setColumnFilterFromSelection',
@@ -69,12 +69,13 @@ export function tableContextMenuFactory<T>(
))} ))}
</SubMenu> </SubMenu>
<SubMenu <SubMenu
key="copy cells"
title="Copy cell(s)" title="Copy cell(s)"
icon={<CopyOutlined />} icon={<CopyOutlined />}
disabled={!hasSelection}> disabled={!hasSelection}>
{visibleColumns.map((column) => ( {visibleColumns.map((column, idx) => (
<Item <Item
key={column.key} key={column.key ?? idx}
onClick={() => { onClick={() => {
const items = getSelectedItems(datasource, selection); const items = getSelectedItems(datasource, selection);
if (items.length) { if (items.length) {
@@ -88,6 +89,7 @@ export function tableContextMenuFactory<T>(
))} ))}
</SubMenu> </SubMenu>
<Item <Item
key="copyToClipboard"
disabled={!hasSelection} disabled={!hasSelection}
onClick={() => { onClick={() => {
const items = getSelectedItems(datasource, selection); const items = getSelectedItems(datasource, selection);
@@ -99,6 +101,7 @@ export function tableContextMenuFactory<T>(
</Item> </Item>
{lib.isFB && ( {lib.isFB && (
<Item <Item
key="createPaste"
disabled={!hasSelection} disabled={!hasSelection}
onClick={() => { onClick={() => {
const items = getSelectedItems(datasource, selection); const items = getSelectedItems(datasource, selection);
@@ -110,9 +113,9 @@ export function tableContextMenuFactory<T>(
</Item> </Item>
)} )}
<Menu.Divider /> <Menu.Divider />
<SubMenu title="Visible columns"> <SubMenu title="Visible columns" key="visible columns">
{columns.map((column) => ( {columns.map((column, idx) => (
<Menu.Item key={column.key}> <Menu.Item key={column.key ?? idx}>
<Checkbox <Checkbox
checked={column.visible} checked={column.visible}
onClick={(e) => { onClick={(e) => {

View File

@@ -13,6 +13,8 @@ import {theme} from '../theme';
import type {TableRowRenderContext} from './DataTable'; import type {TableRowRenderContext} from './DataTable';
import {Width} from '../../utils/widthUtils'; import {Width} from '../../utils/widthUtils';
import {DataFormatter} from '../DataFormatter'; import {DataFormatter} from '../DataFormatter';
import {Dropdown} from 'antd';
import {contextMenuTrigger} from '../data-inspector/DataInspectorNode';
// heuristic for row estimation, should match any future styling updates // heuristic for row estimation, should match any future styling updates
export const DEFAULT_ROW_HEIGHT = 24; export const DEFAULT_ROW_HEIGHT = 24;
@@ -106,7 +108,7 @@ export const TableRow = memo(function TableRow<T>({
highlighted, highlighted,
config, config,
}: TableRowProps<T>) { }: TableRowProps<T>) {
return ( const row = (
<TableBodyRowContainer <TableBodyRowContainer
highlighted={highlighted} highlighted={highlighted}
onMouseDown={(e) => { onMouseDown={(e) => {
@@ -135,4 +137,13 @@ export const TableRow = memo(function TableRow<T>({
})} })}
</TableBodyRowContainer> </TableBodyRowContainer>
); );
if (config.onContextMenu) {
return (
<Dropdown overlay={config.onContextMenu} trigger={contextMenuTrigger}>
{row}
</Dropdown>
);
} else {
return row;
}
}); });

View File

@@ -50,7 +50,7 @@ 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-1k3kr6b-TableBodyRowContainer e1luu51r1" class="ant-dropdown-trigger css-1k3kr6b-TableBodyRowContainer e1luu51r1"
> >
<div <div
class="css-9bipfg-TableBodyColumnContainer e1luu51r0" class="css-9bipfg-TableBodyColumnContainer e1luu51r0"
@@ -104,7 +104,7 @@ 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-1k3kr6b-TableBodyRowContainer e1luu51r1" class="ant-dropdown-trigger css-1k3kr6b-TableBodyRowContainer e1luu51r1"
> >
<div <div
class="css-9bipfg-TableBodyColumnContainer e1luu51r0" class="css-9bipfg-TableBodyColumnContainer e1luu51r0"
@@ -131,7 +131,7 @@ 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-1k3kr6b-TableBodyRowContainer e1luu51r1" class="ant-dropdown-trigger css-1k3kr6b-TableBodyRowContainer e1luu51r1"
> >
<div <div
class="css-9bipfg-TableBodyColumnContainer e1luu51r0" class="css-9bipfg-TableBodyColumnContainer e1luu51r0"

View File

@@ -44,7 +44,7 @@ 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-1k3kr6b-TableBodyRowContainer e1luu51r1" class="ant-dropdown-trigger css-1k3kr6b-TableBodyRowContainer e1luu51r1"
> >
<div <div
class="css-9bipfg-TableBodyColumnContainer e1luu51r0" class="css-9bipfg-TableBodyColumnContainer e1luu51r0"

View File

@@ -116,7 +116,7 @@ test('It can render rows', async () => {
expect((await renderer.findByText('unique-string')).parentElement) expect((await renderer.findByText('unique-string')).parentElement)
.toMatchInlineSnapshot(` .toMatchInlineSnapshot(`
<div <div
class="css-1k3kr6b-TableBodyRowContainer e1luu51r1" class="ant-dropdown-trigger css-1k3kr6b-TableBodyRowContainer e1luu51r1"
> >
<div <div
class="css-1vr131n-TableBodyColumnContainer e1luu51r0" class="css-1vr131n-TableBodyColumnContainer e1luu51r0"