Introduce createTablePlugin
Summary: This diff exposes the createTablePlugin from flipper-plugin, so that createTablePlugin based plugins can be converted to Sandy as well Reviewed By: jknoxville Differential Revision: D28031227 fbshipit-source-id: 8e9c82da08a83fddab740b46be9917b6a1023117
This commit is contained in:
committed by
Facebook GitHub Bot
parent
cf2405a466
commit
05bf55419f
@@ -62,6 +62,8 @@ type State = {
|
||||
* An optional resetMethod argument can be provided which will replace the current rows with the
|
||||
* data provided. This is useful when connecting to Flipper for this first time, or reconnecting to
|
||||
* the client in an unknown state.
|
||||
*
|
||||
* @deprecated use createTablePlugin from flipper-plugin instead
|
||||
*/
|
||||
export function createTablePlugin<T extends RowData>(props: Props<T>) {
|
||||
return class extends FlipperPlugin<State, any, PersistedState<T>> {
|
||||
|
||||
@@ -49,6 +49,7 @@ test('Correct top level API exposed', () => {
|
||||
"batch",
|
||||
"createDataSource",
|
||||
"createState",
|
||||
"createTablePlugin",
|
||||
"produce",
|
||||
"renderReactRoot",
|
||||
"sleep",
|
||||
|
||||
@@ -120,6 +120,8 @@ export {
|
||||
} from './ui/elements-inspector/ElementsInspector';
|
||||
export {useMemoize} from './utils/useMemoize';
|
||||
|
||||
export {createTablePlugin} from './utils/createTablePlugin';
|
||||
|
||||
// It's not ideal that this exists in flipper-plugin sources directly,
|
||||
// but is the least pain for plugin authors.
|
||||
// Probably we should make sure that testing-library doesn't end up in our final Flipper bundle (which packages flipper-plugin)
|
||||
|
||||
@@ -12,7 +12,7 @@ import {BasePluginInstance, BasePluginClient} from './PluginBase';
|
||||
import {FlipperLib} from './FlipperLib';
|
||||
import {RealFlipperDevice} from './DevicePlugin';
|
||||
import {batched} from '../state/batch';
|
||||
import {Atom, createState} from '../state/atom';
|
||||
import {Atom, createState, ReadOnlyAtom} from '../state/atom';
|
||||
|
||||
type EventsContract = Record<string, any>;
|
||||
type MethodsContract = Record<string, (params: any) => Promise<any>>;
|
||||
@@ -40,6 +40,7 @@ export interface PluginClient<
|
||||
readonly appName: string;
|
||||
|
||||
readonly isConnected: boolean;
|
||||
readonly connected: ReadOnlyAtom<boolean>;
|
||||
|
||||
/**
|
||||
* the onConnect event is fired whenever the plugin is connected to it's counter part on the device.
|
||||
@@ -169,6 +170,7 @@ export class SandyPluginInstance extends BasePluginInstance {
|
||||
get appName() {
|
||||
return realClient.query.app;
|
||||
},
|
||||
connected: self.connected,
|
||||
get isConnected() {
|
||||
return self.connected.get();
|
||||
},
|
||||
|
||||
@@ -13,13 +13,16 @@ import {Persistable, registerStorageAtom} from '../plugin/PluginBase';
|
||||
|
||||
enableMapSet();
|
||||
|
||||
export type Atom<T> = {
|
||||
export interface ReadOnlyAtom<T> {
|
||||
get(): T;
|
||||
set(newValue: T): void;
|
||||
update(recipe: (draft: Draft<T>) => void): void;
|
||||
subscribe(listener: (value: T, prevValue: T) => void): () => void;
|
||||
unsubscribe(listener: (value: T, prevValue: T) => void): void;
|
||||
};
|
||||
}
|
||||
|
||||
export interface Atom<T> extends ReadOnlyAtom<T> {
|
||||
set(newValue: T): void;
|
||||
update(recipe: (draft: Draft<T>) => void): void;
|
||||
}
|
||||
|
||||
class AtomValue<T> implements Atom<T>, Persistable {
|
||||
value: T;
|
||||
@@ -93,9 +96,15 @@ export function createState(
|
||||
return atom;
|
||||
}
|
||||
|
||||
export function useValue<T>(atom: Atom<T>): T;
|
||||
export function useValue<T>(atom: Atom<T> | undefined, defaultValue: T): T;
|
||||
export function useValue<T>(atom: Atom<T> | undefined, defaultValue?: T): T {
|
||||
export function useValue<T>(atom: ReadOnlyAtom<T>): T;
|
||||
export function useValue<T>(
|
||||
atom: ReadOnlyAtom<T> | undefined,
|
||||
defaultValue: T,
|
||||
): T;
|
||||
export function useValue<T>(
|
||||
atom: ReadOnlyAtom<T> | undefined,
|
||||
defaultValue?: T,
|
||||
): T {
|
||||
const [localValue, setLocalValue] = useState<T>(
|
||||
atom ? atom.get() : defaultValue!,
|
||||
);
|
||||
|
||||
@@ -19,6 +19,7 @@ import React from 'react';
|
||||
import {tryGetFlipperLibImplementation} from '../../plugin/FlipperLib';
|
||||
import {DataTableColumn} from './DataTable';
|
||||
import {DataSource} from '../../state/DataSource';
|
||||
import {toFirstUpper} from '../../utils/toFirstUpper';
|
||||
|
||||
const {Item, SubMenu} = Menu;
|
||||
|
||||
@@ -137,5 +138,5 @@ export function tableContextMenuFactory<T>(
|
||||
|
||||
function friendlyColumnTitle(column: DataTableColumn<any>): string {
|
||||
const name = column.title || column.key;
|
||||
return name[0].toUpperCase() + name.substr(1);
|
||||
return toFirstUpper(name);
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ import {CaretDownFilled, CaretUpFilled} from '@ant-design/icons';
|
||||
import {Layout} from '../Layout';
|
||||
import {Sorting, SortDirection, DataTableDispatch} from './DataTableManager';
|
||||
import {FilterButton, FilterIcon} from './ColumnFilter';
|
||||
import {toFirstUpper} from '../../utils/toFirstUpper';
|
||||
|
||||
const {Text} = Typography;
|
||||
|
||||
@@ -187,7 +188,13 @@ function TableHeadColumn({
|
||||
role="button"
|
||||
tabIndex={0}>
|
||||
<Text type="secondary">
|
||||
{column.title ?? <> </>}
|
||||
{column.title === undefined ? (
|
||||
toFirstUpper(column.key)
|
||||
) : column.title === '' ? (
|
||||
<> </>
|
||||
) : (
|
||||
column.title
|
||||
)}
|
||||
<SortIcons
|
||||
direction={sorted}
|
||||
onSort={(dir) =>
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* 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 {createTablePlugin} from '../createTablePlugin';
|
||||
import {startPlugin} from '../../test-utils/test-utils';
|
||||
|
||||
const PROPS = {
|
||||
method: 'method',
|
||||
resetMethod: 'resetMethod',
|
||||
columns: [],
|
||||
renderSidebar: (_row: RowData) => {},
|
||||
};
|
||||
|
||||
type RowData = {
|
||||
id: string;
|
||||
value?: string;
|
||||
};
|
||||
|
||||
test('createTablePlugin returns FlipperPlugin', () => {
|
||||
const tablePlugin = createTablePlugin(PROPS);
|
||||
const p = startPlugin(tablePlugin);
|
||||
expect(Object.keys(p.instance)).toMatchInlineSnapshot(`
|
||||
Array [
|
||||
"selection",
|
||||
"rows",
|
||||
"clear",
|
||||
"tableManagerRef",
|
||||
"connected",
|
||||
"isPaused",
|
||||
"resumePause",
|
||||
]
|
||||
`);
|
||||
});
|
||||
|
||||
test('createTablePlugin can add and reset data', () => {
|
||||
const resetMethod = 'resetMethod';
|
||||
const tablePlugin = createTablePlugin({...PROPS, resetMethod});
|
||||
|
||||
const initialSnapshot = [{id: '1'}];
|
||||
|
||||
const p = startPlugin(tablePlugin, {initialState: {rows: initialSnapshot}});
|
||||
|
||||
expect(p.instance.rows.records()).toEqual(initialSnapshot);
|
||||
|
||||
p.sendEvent('method', {id: '2'});
|
||||
expect(p.instance.rows.records()).toEqual([{id: '1'}, {id: '2'}]);
|
||||
expect(p.exportState()).toEqual({rows: [{id: '1'}, {id: '2'}]});
|
||||
|
||||
p.sendEvent('resetMethod', {});
|
||||
expect(p.instance.rows.records()).toEqual([]);
|
||||
});
|
||||
|
||||
test('createTablePlugin can add and reset data', () => {
|
||||
const resetMethod = 'resetMethod';
|
||||
const tablePlugin = createTablePlugin({...PROPS, resetMethod});
|
||||
|
||||
const initialSnapshot = [{id: '1'}];
|
||||
|
||||
const p = startPlugin(tablePlugin, {initialState: {rows: initialSnapshot}});
|
||||
|
||||
expect(p.instance.rows.records()).toEqual(initialSnapshot);
|
||||
|
||||
p.sendEvent('method', {id: '2'});
|
||||
expect(p.instance.rows.records()).toEqual([{id: '1'}, {id: '2'}]);
|
||||
expect(p.exportState()).toEqual({rows: [{id: '1'}, {id: '2'}]});
|
||||
|
||||
// without key, this will append
|
||||
p.sendEvent('method', {id: '1'});
|
||||
expect(p.instance.rows.records()).toEqual([{id: '1'}, {id: '2'}, {id: '1'}]);
|
||||
|
||||
p.sendEvent('resetMethod', {});
|
||||
expect(p.instance.rows.records()).toEqual([]);
|
||||
});
|
||||
|
||||
test('createTablePlugin can upsert data if key is set', () => {
|
||||
const tablePlugin = createTablePlugin({...PROPS, key: 'id'});
|
||||
|
||||
const initialSnapshot = [{id: '1'}];
|
||||
|
||||
const p = startPlugin(tablePlugin, {initialState: {rows: initialSnapshot}});
|
||||
|
||||
expect(p.instance.rows.records()).toEqual(initialSnapshot);
|
||||
|
||||
p.sendEvent('method', {id: '2'});
|
||||
expect(p.instance.rows.records()).toEqual([{id: '1'}, {id: '2'}]);
|
||||
expect(p.exportState()).toEqual({rows: [{id: '1'}, {id: '2'}]});
|
||||
|
||||
// key set, so we can upsert
|
||||
p.sendEvent('method', {id: '1', value: 'hi'});
|
||||
expect(p.instance.rows.records()).toEqual([
|
||||
{id: '1', value: 'hi'},
|
||||
{id: '2'},
|
||||
]);
|
||||
});
|
||||
229
desktop/flipper-plugin/src/utils/createTablePlugin.tsx
Normal file
229
desktop/flipper-plugin/src/utils/createTablePlugin.tsx
Normal file
@@ -0,0 +1,229 @@
|
||||
/**
|
||||
* 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 {
|
||||
DeleteOutlined,
|
||||
PauseCircleOutlined,
|
||||
PlayCircleOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import {Button, notification, Typography} from 'antd';
|
||||
import React, {createRef, useCallback} from 'react';
|
||||
import {PluginClient} from '../plugin/Plugin';
|
||||
import {usePlugin} from '../plugin/PluginContext';
|
||||
import {createState, useValue} from '../state/atom';
|
||||
import {createDataSource, DataSource} from '../state/DataSource';
|
||||
import {DataInspector} from '../ui/data-inspector/DataInspector';
|
||||
import {DataTable, DataTableColumn} from '../ui/data-table/DataTable';
|
||||
import {DataTableManager} from '../ui/data-table/DataTableManager';
|
||||
import {DetailSidebar} from '../ui/DetailSidebar';
|
||||
import {Layout} from '../ui/Layout';
|
||||
import {Panel} from '../ui/Panel';
|
||||
|
||||
function defaultRenderSidebar<T>(record: T) {
|
||||
return (
|
||||
<Panel title="Payload" collapsible={false} pad>
|
||||
<DataInspector data={record} expandRoot />
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
||||
type PluginResult<Raw, Row> = {
|
||||
plugin(
|
||||
client: PluginClient<Record<string, Raw | {}>>,
|
||||
): {
|
||||
rows: DataSource<Row>;
|
||||
};
|
||||
Component(): React.ReactElement;
|
||||
};
|
||||
|
||||
/**
|
||||
* createTablePlugin creates a Plugin class which handles fetching data from the client and
|
||||
* displaying in in a table. The table handles selection of items and rendering a sidebar where
|
||||
* more detailed information can be presented about the selected row.
|
||||
*
|
||||
* The plugin expects the be able to subscribe to the `method` argument and recieve either an array
|
||||
* of data objects or a single data object. Each data object represents a row in the table which is
|
||||
* build by calling the `buildRow` function argument.
|
||||
*
|
||||
* An optional resetMethod argument can be provided which will replace the current rows with the
|
||||
* data provided. This is useful when connecting to Flipper for this first time, or reconnecting to
|
||||
* the client in an unknown state.
|
||||
*/
|
||||
export function createTablePlugin<Row extends object>(props: {
|
||||
method: string;
|
||||
resetMethod?: string;
|
||||
columns: DataTableColumn<Row>[];
|
||||
renderSidebar?: (record: Row) => any;
|
||||
key?: keyof Row;
|
||||
}): PluginResult<Row, Row>;
|
||||
export function createTablePlugin<
|
||||
Raw extends object,
|
||||
Row extends object = Raw
|
||||
>(props: {
|
||||
buildRow: (record: Raw) => Row;
|
||||
method: string;
|
||||
resetMethod?: string;
|
||||
columns: DataTableColumn<Row>[];
|
||||
renderSidebar?: (record: Row) => any;
|
||||
key?: keyof Raw;
|
||||
}): PluginResult<Raw, Row>;
|
||||
export function createTablePlugin<
|
||||
Raw extends object,
|
||||
Method extends string,
|
||||
ResetMethod extends string,
|
||||
Row extends object = Raw
|
||||
>(props: {
|
||||
method: Method;
|
||||
resetMethod?: ResetMethod;
|
||||
columns: DataTableColumn<Row>[];
|
||||
renderSidebar?: (record: Row) => any;
|
||||
buildRow?: (record: Raw) => Row;
|
||||
key?: keyof Raw;
|
||||
}) {
|
||||
function plugin(
|
||||
client: PluginClient<Record<Method, Raw> & Record<ResetMethod, {}>, {}>,
|
||||
) {
|
||||
const rows = createDataSource<Row>([], {
|
||||
persist: 'rows',
|
||||
key: props.key,
|
||||
});
|
||||
const selection = createState<undefined | Row>(undefined);
|
||||
const isPaused = createState(false);
|
||||
const tableManagerRef = createRef<undefined | DataTableManager<Row>>();
|
||||
|
||||
client.onMessage(props.method, (event) => {
|
||||
if (isPaused.get()) {
|
||||
return;
|
||||
}
|
||||
const record = props.buildRow
|
||||
? props.buildRow(event)
|
||||
: ((event as any) as Row);
|
||||
if (props.key) {
|
||||
rows.upsert(record);
|
||||
} else {
|
||||
rows.append(record);
|
||||
}
|
||||
});
|
||||
|
||||
if (props.resetMethod) {
|
||||
client.onMessage(props.resetMethod, () => {
|
||||
clear();
|
||||
});
|
||||
}
|
||||
|
||||
// help plugin authors with finding out what the events and data shape is from the plugin
|
||||
const unhandledMessagesSeen = new Set<string>();
|
||||
client.onUnhandledMessage((message, params) => {
|
||||
if (unhandledMessagesSeen.has(message)) {
|
||||
return;
|
||||
}
|
||||
unhandledMessagesSeen.add(message);
|
||||
notification.warn({
|
||||
message: 'Unhandled message: ' + message,
|
||||
description: (
|
||||
<Typography.Paragraph>
|
||||
<pre>{JSON.stringify(params, null, 2)}</pre>
|
||||
</Typography.Paragraph>
|
||||
),
|
||||
});
|
||||
});
|
||||
|
||||
client.addMenuEntry(
|
||||
{
|
||||
action: 'clear',
|
||||
handler: clear,
|
||||
},
|
||||
{
|
||||
action: 'createPaste',
|
||||
handler: createPaste,
|
||||
},
|
||||
{
|
||||
action: 'goToBottom',
|
||||
handler: goToBottom,
|
||||
},
|
||||
);
|
||||
|
||||
function clear() {
|
||||
rows.clear();
|
||||
tableManagerRef.current?.clearSelection();
|
||||
}
|
||||
|
||||
function createPaste() {
|
||||
let selection = tableManagerRef.current?.getSelectedItems();
|
||||
if (!selection?.length) {
|
||||
selection = rows.view.output(0, rows.view.size);
|
||||
}
|
||||
if (selection?.length) {
|
||||
client.createPaste(JSON.stringify(selection, null, 2));
|
||||
}
|
||||
}
|
||||
|
||||
function goToBottom() {
|
||||
tableManagerRef?.current?.selectItem(rows.view.size - 1);
|
||||
}
|
||||
|
||||
return {
|
||||
selection,
|
||||
rows,
|
||||
clear,
|
||||
tableManagerRef,
|
||||
connected: client.connected,
|
||||
isPaused,
|
||||
resumePause() {
|
||||
isPaused.update((v) => !v);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function Component() {
|
||||
const instance = usePlugin(plugin);
|
||||
const paused = useValue(instance.isPaused);
|
||||
const selection = useValue(instance.selection);
|
||||
const connected = useValue(instance.connected);
|
||||
|
||||
const handleSelect = useCallback((v) => instance.selection.set(v), [
|
||||
instance,
|
||||
]);
|
||||
|
||||
return (
|
||||
<Layout.Container grow>
|
||||
<DataTable<Row>
|
||||
columns={props.columns}
|
||||
dataSource={instance.rows}
|
||||
tableManagerRef={instance.tableManagerRef}
|
||||
autoScroll
|
||||
onSelect={handleSelect}
|
||||
extraActions={
|
||||
connected ? (
|
||||
<>
|
||||
<Button
|
||||
title={`Click to ${paused ? 'resume' : 'pause'} the stream`}
|
||||
danger={paused}
|
||||
onClick={instance.resumePause}>
|
||||
{paused ? <PlayCircleOutlined /> : <PauseCircleOutlined />}
|
||||
</Button>
|
||||
<Button title="Clear records" onClick={instance.clear}>
|
||||
<DeleteOutlined />
|
||||
</Button>
|
||||
</>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
<DetailSidebar>
|
||||
{selection
|
||||
? props.renderSidebar?.(selection) ??
|
||||
defaultRenderSidebar(selection)
|
||||
: null}
|
||||
</DetailSidebar>
|
||||
</Layout.Container>
|
||||
);
|
||||
}
|
||||
|
||||
return {plugin, Component};
|
||||
}
|
||||
18
desktop/flipper-plugin/src/utils/toFirstUpper.tsx
Normal file
18
desktop/flipper-plugin/src/utils/toFirstUpper.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
|
||||
export function toFirstUpper(name: string): string {
|
||||
if (!name) {
|
||||
return name;
|
||||
}
|
||||
if (name.length === 1) {
|
||||
return name.toUpperCase();
|
||||
}
|
||||
return name[0].toUpperCase() + name.substr(1);
|
||||
}
|
||||
@@ -54,6 +54,7 @@ A string that uniquely identifies the current application, is based on a combina
|
||||
|
||||
A key that uniquely identifies this plugin instance, captures the current device/client/plugin combination.
|
||||
|
||||
#### `connected`
|
||||
#### `isConnected`
|
||||
|
||||
Returns whether there is currently an active connection. This is true if:
|
||||
@@ -61,6 +62,9 @@ Returns whether there is currently an active connection. This is true if:
|
||||
2. The client is still connected
|
||||
3. The plugin is currently selected by the user _or_ the plugin is running in the background.
|
||||
|
||||
The `connected` field provides the atom, that can be used in combination with `useValue` to subscribe to future updates in a component.
|
||||
In contrast, `isConnected` returns a boolean that merely captures the current state.
|
||||
|
||||
### Events listeners
|
||||
|
||||
#### `onMessage`
|
||||
@@ -892,6 +896,10 @@ See `View > Flipper Style Guide` inside the Flipper application for more details
|
||||
|
||||
## Utilities
|
||||
|
||||
### createTablePlugin
|
||||
|
||||
Utility to create a plugin that consists of a master table and details json view with minimal effort. See [../tutorial/js-table.mdx](Showing a table) for more details.
|
||||
|
||||
### batch
|
||||
|
||||
Usage: `batch(() => { /* state updates */ })`
|
||||
|
||||
Reference in New Issue
Block a user