Introduce context menu
Summary: Introduced a context menu for DataTable with some default options. Opted to put it under a visible hovered dropdown instead of on right-click, since this better alings with Ant's design guides (we don't have context clicks anywhere else I think), but if it isn't convincing we can still change it. Included some default actions, to set up quick filters, and to copy values. For copying rows, implemented it to by default take the JSON from a row, rather than space separated values like in ManagedTable, as many existing plugins customize the onCopy handler to just do that, so it seemed like a better default since it is a richer format. If there are good use cases for the previous behavior, we'll probably find out after the old release :) Introduced utility to copy text to clipboard in FlipperLib, but decoupled it from Electron. Didn't include multi select yet, that will be done in a next diff. Reviewed By: nikoant Differential Revision: D26513161 fbshipit-source-id: b2b1b20b0a6f4ada9de2566bf6b02171f722c4aa
This commit is contained in:
committed by
Facebook GitHub Bot
parent
11eb19da4c
commit
5c3a8742ef
@@ -35,9 +35,12 @@ import {processMessagesLater} from './utils/messageQueue';
|
||||
import {emitBytesReceived} from './dispatcher/tracking';
|
||||
import {debounce} from 'lodash';
|
||||
import {batch} from 'react-redux';
|
||||
import {createState, _SandyPluginInstance} from 'flipper-plugin';
|
||||
import {
|
||||
createState,
|
||||
_SandyPluginInstance,
|
||||
_getFlipperLibImplementation,
|
||||
} from 'flipper-plugin';
|
||||
import {flipperMessagesClientPlugin} from './utils/self-inspection/plugins/FlipperMessagesClientPlugin';
|
||||
import {getFlipperLibImplementation} from './utils/flipperLibImplementation';
|
||||
import {freeze} from 'immer';
|
||||
import GK from './fb-stubs/GK';
|
||||
import {message} from 'antd';
|
||||
@@ -254,7 +257,7 @@ export default class Client extends EventEmitter {
|
||||
this.sandyPluginStates.set(
|
||||
plugin.id,
|
||||
new _SandyPluginInstance(
|
||||
getFlipperLibImplementation(),
|
||||
_getFlipperLibImplementation(),
|
||||
plugin,
|
||||
this,
|
||||
initialStates[pluginId],
|
||||
@@ -303,7 +306,7 @@ export default class Client extends EventEmitter {
|
||||
// TODO: needs to be wrapped in error tracking T68955280
|
||||
this.sandyPluginStates.set(
|
||||
plugin.id,
|
||||
new _SandyPluginInstance(getFlipperLibImplementation(), plugin, this),
|
||||
new _SandyPluginInstance(_getFlipperLibImplementation(), plugin, this),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,13 +16,13 @@ import {
|
||||
DeviceLogListener,
|
||||
Idler,
|
||||
createState,
|
||||
_getFlipperLibImplementation,
|
||||
} from 'flipper-plugin';
|
||||
import {
|
||||
DevicePluginDefinition,
|
||||
DevicePluginMap,
|
||||
FlipperDevicePlugin,
|
||||
} from '../plugin';
|
||||
import {getFlipperLibImplementation} from '../utils/flipperLibImplementation';
|
||||
import {DeviceSpec, OS as PluginOS, PluginDetails} from 'flipper-plugin-lib';
|
||||
|
||||
export type DeviceShell = {
|
||||
@@ -238,7 +238,7 @@ export default class BaseDevice {
|
||||
this.sandyPluginStates.set(
|
||||
plugin.id,
|
||||
new _SandyDevicePluginInstance(
|
||||
getFlipperLibImplementation(),
|
||||
_getFlipperLibImplementation(),
|
||||
plugin,
|
||||
this,
|
||||
initialState,
|
||||
|
||||
@@ -7,14 +7,14 @@
|
||||
* @format
|
||||
*/
|
||||
|
||||
import type {FlipperLib} from 'flipper-plugin';
|
||||
import {_setFlipperLibImplementation} from 'flipper-plugin';
|
||||
import type {Logger} from '../fb-interfaces/Logger';
|
||||
import type {Store} from '../reducers';
|
||||
import createPaste from '../fb-stubs/createPaste';
|
||||
import GK from '../fb-stubs/GK';
|
||||
import type BaseDevice from '../devices/BaseDevice';
|
||||
|
||||
let flipperLibInstance: FlipperLib | undefined;
|
||||
import {clipboard} from 'electron';
|
||||
import constants from '../fb-stubs/constants';
|
||||
|
||||
export function initializeFlipperLibImplementation(
|
||||
store: Store,
|
||||
@@ -22,7 +22,8 @@ export function initializeFlipperLibImplementation(
|
||||
) {
|
||||
// late require to avoid cyclic dependency
|
||||
const {addSandyPluginEntries} = require('../MenuBar');
|
||||
flipperLibInstance = {
|
||||
_setFlipperLibImplementation({
|
||||
isFB: !constants.IS_PUBLIC_BUILD,
|
||||
logger,
|
||||
enableMenuEntries(entries) {
|
||||
addSandyPluginEntries(entries);
|
||||
@@ -67,16 +68,8 @@ export function initializeFlipperLibImplementation(
|
||||
},
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function getFlipperLibImplementation(): FlipperLib {
|
||||
if (!flipperLibInstance) {
|
||||
throw new Error('Flipper lib not instantiated');
|
||||
}
|
||||
return flipperLibInstance;
|
||||
}
|
||||
|
||||
export function setFlipperLibImplementation(impl: FlipperLib) {
|
||||
flipperLibInstance = impl;
|
||||
writeTextToClipboard(text: string) {
|
||||
clipboard.writeText(text);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -35,7 +35,11 @@ export {
|
||||
} from './plugin/PluginContext';
|
||||
export {createState, useValue, Atom} from './state/atom';
|
||||
export {batch} from './state/batch';
|
||||
export {FlipperLib} from './plugin/FlipperLib';
|
||||
export {
|
||||
FlipperLib,
|
||||
getFlipperLibImplementation as _getFlipperLibImplementation,
|
||||
setFlipperLibImplementation as _setFlipperLibImplementation,
|
||||
} from './plugin/FlipperLib';
|
||||
export {
|
||||
MenuEntry,
|
||||
NormalizedMenuEntry,
|
||||
|
||||
@@ -16,6 +16,7 @@ import {RealFlipperClient} from './Plugin';
|
||||
* This interface exposes all global methods for which an implementation will be provided by Flipper itself
|
||||
*/
|
||||
export interface FlipperLib {
|
||||
isFB: boolean;
|
||||
logger: Logger;
|
||||
enableMenuEntries(menuEntries: NormalizedMenuEntry[]): void;
|
||||
createPaste(input: string): Promise<string | undefined>;
|
||||
@@ -31,4 +32,22 @@ export interface FlipperLib {
|
||||
pluginId: string,
|
||||
deeplink: unknown,
|
||||
): void;
|
||||
writeTextToClipboard(text: string): void;
|
||||
}
|
||||
|
||||
let flipperLibInstance: FlipperLib | undefined;
|
||||
|
||||
export function tryGetFlipperLibImplementation(): FlipperLib | undefined {
|
||||
return flipperLibInstance;
|
||||
}
|
||||
|
||||
export function getFlipperLibImplementation(): FlipperLib {
|
||||
if (!flipperLibInstance) {
|
||||
throw new Error('Flipper lib not instantiated');
|
||||
}
|
||||
return flipperLibInstance;
|
||||
}
|
||||
|
||||
export function setFlipperLibImplementation(impl: FlipperLib) {
|
||||
flipperLibInstance = impl;
|
||||
}
|
||||
|
||||
@@ -359,6 +359,7 @@ export function renderDevicePlugin<Module extends FlipperDevicePluginModule>(
|
||||
|
||||
export function createMockFlipperLib(options?: StartPluginOptions): FlipperLib {
|
||||
return {
|
||||
isFB: false,
|
||||
logger: stubLogger,
|
||||
enableMenuEntries: jest.fn(),
|
||||
createPaste: jest.fn(),
|
||||
@@ -367,6 +368,7 @@ export function createMockFlipperLib(options?: StartPluginOptions): FlipperLib {
|
||||
},
|
||||
selectPlugin: jest.fn(),
|
||||
isPluginAvailable: jest.fn().mockImplementation(() => false),
|
||||
writeTextToClipboard: jest.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -26,6 +26,11 @@ import {useDataTableManager, TableManager} from './useDataTableManager';
|
||||
import {TableSearch} from './TableSearch';
|
||||
import styled from '@emotion/styled';
|
||||
import {theme} from '../theme';
|
||||
import {
|
||||
tableContextMenuFactory,
|
||||
TableContextMenuContext,
|
||||
} from './TableContextMenu';
|
||||
import {useMemoize} from '../../utils/useMemoize';
|
||||
|
||||
interface DataTableProps<T = any> {
|
||||
columns: DataTableColumn<T>[];
|
||||
@@ -187,6 +192,15 @@ 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, [
|
||||
visibleColumns,
|
||||
tableManager.addColumnFilter,
|
||||
])
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<Layout.Container grow>
|
||||
<Layout.Top>
|
||||
@@ -208,6 +222,7 @@ export function DataTable<T extends object>(props: DataTableProps<T>) {
|
||||
onToggleColumnFilter={tableManager.toggleColumnFilter}
|
||||
/>
|
||||
</Layout.Container>
|
||||
<TableContextMenuContext.Provider value={contexMenu}>
|
||||
<DataSourceRenderer<T, RenderContext<T>>
|
||||
dataSource={dataSource}
|
||||
autoScroll={props.autoScroll}
|
||||
@@ -220,6 +235,7 @@ export function DataTable<T extends object>(props: DataTableProps<T>) {
|
||||
onRangeChange={onRangeChange}
|
||||
_testHeight={props._testHeight}
|
||||
/>
|
||||
</TableContextMenuContext.Provider>
|
||||
</Layout.Top>
|
||||
{range && <RangeFinder>{range}</RangeFinder>}
|
||||
</Layout.Container>
|
||||
|
||||
83
desktop/flipper-plugin/src/ui/datatable/TableContextMenu.tsx
Normal file
83
desktop/flipper-plugin/src/ui/datatable/TableContextMenu.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* 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 {CopyOutlined, FilterOutlined} from '@ant-design/icons';
|
||||
import {Menu} from 'antd';
|
||||
import {DataTableColumn} from './DataTable';
|
||||
import {TableManager} from './useDataTableManager';
|
||||
import React from 'react';
|
||||
import {createContext} from 'react';
|
||||
import {normalizeCellValue} from './TableRow';
|
||||
import {tryGetFlipperLibImplementation} from '../../plugin/FlipperLib';
|
||||
|
||||
const {Item, SubMenu} = Menu;
|
||||
|
||||
export const TableContextMenuContext = createContext<
|
||||
undefined | ((item: any) => React.ReactElement)
|
||||
>(undefined);
|
||||
|
||||
export function tableContextMenuFactory<T>(
|
||||
visibleColumns: DataTableColumn<T>[],
|
||||
addColumnFilter: TableManager['addColumnFilter'],
|
||||
) {
|
||||
return function (item: any) {
|
||||
const lib = tryGetFlipperLibImplementation();
|
||||
if (!lib) {
|
||||
return (
|
||||
<Menu>
|
||||
<Item>Menu not ready</Item>
|
||||
</Menu>
|
||||
);
|
||||
}
|
||||
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>
|
||||
)}
|
||||
</Menu>
|
||||
);
|
||||
};
|
||||
}
|
||||
@@ -7,12 +7,15 @@
|
||||
* @format
|
||||
*/
|
||||
|
||||
import React, {memo} from 'react';
|
||||
import React, {memo, useContext} from 'react';
|
||||
import styled from '@emotion/styled';
|
||||
import {theme} from 'flipper-plugin';
|
||||
import type {RenderContext} from './DataTable';
|
||||
import {Width} from '../../utils/widthUtils';
|
||||
import {pad} from 'lodash';
|
||||
import {DownCircleFilled} from '@ant-design/icons';
|
||||
import {Dropdown} from 'antd';
|
||||
import {TableContextMenuContext} from './TableContextMenu';
|
||||
|
||||
// heuristic for row estimation, should match any future styling updates
|
||||
export const DEFAULT_ROW_HEIGHT = 24;
|
||||
@@ -28,6 +31,18 @@ const backgroundColor = (props: TableBodyRowContainerProps) => {
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const CircleMargin = 4;
|
||||
const RowContextMenu = styled(DownCircleFilled)({
|
||||
position: 'absolute',
|
||||
top: CircleMargin,
|
||||
right: CircleMargin,
|
||||
fontSize: DEFAULT_ROW_HEIGHT - CircleMargin * 2,
|
||||
borderRadius: (DEFAULT_ROW_HEIGHT - CircleMargin * 2) * 0.5,
|
||||
color: theme.primaryColor,
|
||||
cursor: 'pointer',
|
||||
visibility: 'hidden',
|
||||
});
|
||||
|
||||
const TableBodyRowContainer = styled.div<TableBodyRowContainerProps>(
|
||||
(props) => ({
|
||||
display: 'flex',
|
||||
@@ -46,6 +61,10 @@ const TableBodyRowContainer = styled.div<TableBodyRowContainerProps>(
|
||||
overflow: 'hidden',
|
||||
width: '100%',
|
||||
flexShrink: 0,
|
||||
[`&:hover ${RowContextMenu}`]: {
|
||||
visibility: 'visible',
|
||||
color: props.highlighted ? theme.white : undefined,
|
||||
},
|
||||
}),
|
||||
);
|
||||
TableBodyRowContainer.displayName = 'TableRow:TableBodyRowContainer';
|
||||
@@ -78,6 +97,8 @@ type Props = {
|
||||
|
||||
export const TableRow = memo(function TableRow(props: Props) {
|
||||
const {config, highlighted, value: row} = props;
|
||||
const menu = useContext(TableContextMenuContext);
|
||||
|
||||
return (
|
||||
<TableBodyRowContainer
|
||||
highlighted={highlighted}
|
||||
@@ -89,19 +110,9 @@ export const TableRow = memo(function TableRow(props: Props) {
|
||||
{config.columns
|
||||
.filter((col) => col.visible)
|
||||
.map((col) => {
|
||||
let value = (col as any).onRender
|
||||
const value = (col as any).onRender
|
||||
? (col as any).onRender(row)
|
||||
: (row as any)[col.key] ?? '';
|
||||
if (typeof value === 'boolean') {
|
||||
value = value ? 'true' : 'false';
|
||||
}
|
||||
|
||||
if (value instanceof Date) {
|
||||
value =
|
||||
value.toTimeString().split(' ')[0] +
|
||||
'.' +
|
||||
pad('' + value.getMilliseconds(), 3);
|
||||
}
|
||||
: normalizeCellValue((row as any)[col.key]);
|
||||
|
||||
return (
|
||||
<TableBodyColumnContainer
|
||||
@@ -114,6 +125,40 @@ export const TableRow = memo(function TableRow(props: Props) {
|
||||
</TableBodyColumnContainer>
|
||||
);
|
||||
})}
|
||||
{menu && (
|
||||
<Dropdown
|
||||
overlay={menu(row)}
|
||||
placement="bottomRight"
|
||||
trigger={['click', 'contextMenu']}>
|
||||
<RowContextMenu />
|
||||
</Dropdown>
|
||||
)}
|
||||
</TableBodyRowContainer>
|
||||
);
|
||||
});
|
||||
|
||||
export function normalizeCellValue(value: any): string {
|
||||
switch (typeof value) {
|
||||
case 'boolean':
|
||||
return value ? 'true' : 'false';
|
||||
case 'number':
|
||||
return '' + value;
|
||||
case 'undefined':
|
||||
return '';
|
||||
case 'string':
|
||||
return value;
|
||||
case 'object': {
|
||||
if (value === null) return '';
|
||||
if (value instanceof Date) {
|
||||
return (
|
||||
value.toTimeString().split(' ')[0] +
|
||||
'.' +
|
||||
pad('' + value.getMilliseconds(), 3)
|
||||
);
|
||||
}
|
||||
return JSON.stringify(value, null, 2);
|
||||
}
|
||||
default:
|
||||
return '<unrenderable value>';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,14 +43,19 @@ test('update and append', async () => {
|
||||
const ds = createTestDataSource();
|
||||
const ref = createRef<TableManager>();
|
||||
const rendering = render(
|
||||
<DataTable dataSource={ds} columns={columns} tableManagerRef={ref} />,
|
||||
<DataTable
|
||||
dataSource={ds}
|
||||
columns={columns}
|
||||
tableManagerRef={ref}
|
||||
_testHeight={400}
|
||||
/>,
|
||||
);
|
||||
{
|
||||
const elem = await rendering.findAllByText('test DataTable');
|
||||
expect(elem.length).toBe(1);
|
||||
expect(elem[0].parentElement).toMatchInlineSnapshot(`
|
||||
<div
|
||||
class="css-4f2ebr-TableBodyRowContainer efe0za01"
|
||||
class="css-1rnoidw-TableBodyRowContainer efe0za01"
|
||||
>
|
||||
<div
|
||||
class="ant-table-cell css-1g4z4wd-TableBodyColumnContainer efe0za00"
|
||||
@@ -95,14 +100,19 @@ test('column visibility', async () => {
|
||||
const ds = createTestDataSource();
|
||||
const ref = createRef<TableManager>();
|
||||
const rendering = render(
|
||||
<DataTable dataSource={ds} columns={columns} tableManagerRef={ref} />,
|
||||
<DataTable
|
||||
dataSource={ds}
|
||||
columns={columns}
|
||||
tableManagerRef={ref}
|
||||
_testHeight={400}
|
||||
/>,
|
||||
);
|
||||
{
|
||||
const elem = await rendering.findAllByText('test DataTable');
|
||||
expect(elem.length).toBe(1);
|
||||
expect(elem[0].parentElement).toMatchInlineSnapshot(`
|
||||
<div
|
||||
class="css-4f2ebr-TableBodyRowContainer efe0za01"
|
||||
class="css-1rnoidw-TableBodyRowContainer efe0za01"
|
||||
>
|
||||
<div
|
||||
class="ant-table-cell css-1g4z4wd-TableBodyColumnContainer efe0za00"
|
||||
@@ -127,7 +137,7 @@ test('column visibility', async () => {
|
||||
expect(elem.length).toBe(1);
|
||||
expect(elem[0].parentElement).toMatchInlineSnapshot(`
|
||||
<div
|
||||
class="css-4f2ebr-TableBodyRowContainer efe0za01"
|
||||
class="css-1rnoidw-TableBodyRowContainer efe0za01"
|
||||
>
|
||||
<div
|
||||
class="ant-table-cell css-1g4z4wd-TableBodyColumnContainer efe0za00"
|
||||
|
||||
@@ -43,19 +43,35 @@ export function useDataTableManager<T extends object>(
|
||||
[columns],
|
||||
);
|
||||
|
||||
const addColumnFilter = useCallback((columnId: string, value: string) => {
|
||||
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: value.toLowerCase(),
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user