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:
Michel Weststrate
2021-04-28 12:27:31 -07:00
committed by Facebook GitHub Bot
parent cf2405a466
commit 05bf55419f
11 changed files with 422 additions and 43 deletions

View File

@@ -62,6 +62,8 @@ type State = {
* An optional resetMethod argument can be provided which will replace the current rows with the * 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 * data provided. This is useful when connecting to Flipper for this first time, or reconnecting to
* the client in an unknown state. * the client in an unknown state.
*
* @deprecated use createTablePlugin from flipper-plugin instead
*/ */
export function createTablePlugin<T extends RowData>(props: Props<T>) { export function createTablePlugin<T extends RowData>(props: Props<T>) {
return class extends FlipperPlugin<State, any, PersistedState<T>> { return class extends FlipperPlugin<State, any, PersistedState<T>> {

View File

@@ -49,6 +49,7 @@ test('Correct top level API exposed', () => {
"batch", "batch",
"createDataSource", "createDataSource",
"createState", "createState",
"createTablePlugin",
"produce", "produce",
"renderReactRoot", "renderReactRoot",
"sleep", "sleep",

View File

@@ -120,6 +120,8 @@ export {
} from './ui/elements-inspector/ElementsInspector'; } from './ui/elements-inspector/ElementsInspector';
export {useMemoize} from './utils/useMemoize'; export {useMemoize} from './utils/useMemoize';
export {createTablePlugin} from './utils/createTablePlugin';
// It's not ideal that this exists in flipper-plugin sources directly, // It's not ideal that this exists in flipper-plugin sources directly,
// but is the least pain for plugin authors. // 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) // Probably we should make sure that testing-library doesn't end up in our final Flipper bundle (which packages flipper-plugin)

View File

@@ -12,7 +12,7 @@ import {BasePluginInstance, BasePluginClient} from './PluginBase';
import {FlipperLib} from './FlipperLib'; import {FlipperLib} from './FlipperLib';
import {RealFlipperDevice} from './DevicePlugin'; import {RealFlipperDevice} from './DevicePlugin';
import {batched} from '../state/batch'; import {batched} from '../state/batch';
import {Atom, createState} from '../state/atom'; import {Atom, createState, ReadOnlyAtom} from '../state/atom';
type EventsContract = Record<string, any>; type EventsContract = Record<string, any>;
type MethodsContract = Record<string, (params: any) => Promise<any>>; type MethodsContract = Record<string, (params: any) => Promise<any>>;
@@ -40,6 +40,7 @@ export interface PluginClient<
readonly appName: string; readonly appName: string;
readonly isConnected: boolean; 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. * 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() { get appName() {
return realClient.query.app; return realClient.query.app;
}, },
connected: self.connected,
get isConnected() { get isConnected() {
return self.connected.get(); return self.connected.get();
}, },

View File

@@ -13,13 +13,16 @@ import {Persistable, registerStorageAtom} from '../plugin/PluginBase';
enableMapSet(); enableMapSet();
export type Atom<T> = { export interface ReadOnlyAtom<T> {
get(): T; get(): T;
set(newValue: T): void;
update(recipe: (draft: Draft<T>) => void): void;
subscribe(listener: (value: T, prevValue: T) => void): () => void; subscribe(listener: (value: T, prevValue: T) => void): () => void;
unsubscribe(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 { class AtomValue<T> implements Atom<T>, Persistable {
value: T; value: T;
@@ -93,9 +96,15 @@ export function createState(
return atom; return atom;
} }
export function useValue<T>(atom: Atom<T>): T; export function useValue<T>(atom: ReadOnlyAtom<T>): T;
export function useValue<T>(atom: Atom<T> | undefined, defaultValue: T): T; export function useValue<T>(
export function useValue<T>(atom: Atom<T> | undefined, defaultValue?: T): T { atom: ReadOnlyAtom<T> | undefined,
defaultValue: T,
): T;
export function useValue<T>(
atom: ReadOnlyAtom<T> | undefined,
defaultValue?: T,
): T {
const [localValue, setLocalValue] = useState<T>( const [localValue, setLocalValue] = useState<T>(
atom ? atom.get() : defaultValue!, atom ? atom.get() : defaultValue!,
); );

View File

@@ -19,6 +19,7 @@ import React from 'react';
import {tryGetFlipperLibImplementation} from '../../plugin/FlipperLib'; import {tryGetFlipperLibImplementation} from '../../plugin/FlipperLib';
import {DataTableColumn} from './DataTable'; import {DataTableColumn} from './DataTable';
import {DataSource} from '../../state/DataSource'; import {DataSource} from '../../state/DataSource';
import {toFirstUpper} from '../../utils/toFirstUpper';
const {Item, SubMenu} = Menu; const {Item, SubMenu} = Menu;
@@ -137,5 +138,5 @@ export function tableContextMenuFactory<T>(
function friendlyColumnTitle(column: DataTableColumn<any>): string { function friendlyColumnTitle(column: DataTableColumn<any>): string {
const name = column.title || column.key; const name = column.title || column.key;
return name[0].toUpperCase() + name.substr(1); return toFirstUpper(name);
} }

View File

@@ -25,6 +25,7 @@ import {CaretDownFilled, CaretUpFilled} from '@ant-design/icons';
import {Layout} from '../Layout'; import {Layout} from '../Layout';
import {Sorting, SortDirection, DataTableDispatch} from './DataTableManager'; import {Sorting, SortDirection, DataTableDispatch} from './DataTableManager';
import {FilterButton, FilterIcon} from './ColumnFilter'; import {FilterButton, FilterIcon} from './ColumnFilter';
import {toFirstUpper} from '../../utils/toFirstUpper';
const {Text} = Typography; const {Text} = Typography;
@@ -187,7 +188,13 @@ function TableHeadColumn({
role="button" role="button"
tabIndex={0}> tabIndex={0}>
<Text type="secondary"> <Text type="secondary">
{column.title ?? <>&nbsp;</>} {column.title === undefined ? (
toFirstUpper(column.key)
) : column.title === '' ? (
<>&nbsp;</>
) : (
column.title
)}
<SortIcons <SortIcons
direction={sorted} direction={sorted}
onSort={(dir) => onSort={(dir) =>

View File

@@ -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'},
]);
});

View 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};
}

View 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);
}

View File

@@ -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. A key that uniquely identifies this plugin instance, captures the current device/client/plugin combination.
#### `connected`
#### `isConnected` #### `isConnected`
Returns whether there is currently an active connection. This is true if: 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 2. The client is still connected
3. The plugin is currently selected by the user _or_ the plugin is running in the background. 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 ### Events listeners
#### `onMessage` #### `onMessage`
@@ -892,6 +896,10 @@ See `View > Flipper Style Guide` inside the Flipper application for more details
## Utilities ## 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 ### batch
Usage: `batch(() => { /* state updates */ })` Usage: `batch(() => { /* state updates */ })`