JS side of Native Plugins

Summary:
Native plugins are plugins that can be written in mobile code alone (java/objc), provided they conform to a template, currently table is the only implemented template.

This adds support to flipper for handling them.

Reviewed By: danielbuechele

Differential Revision: D14502188

fbshipit-source-id: a96be9b06de1cecf7977c4ef2fd05b168f7f1330
This commit is contained in:
John Knox
2019-03-22 07:04:59 -07:00
committed by Facebook Github Bot
parent ba0cdf641d
commit 57a24769e8
8 changed files with 296 additions and 52 deletions

View File

@@ -18,6 +18,8 @@ import {ReactiveSocket, PartialResponder} from 'rsocket-core';
import {performance} from 'perf_hooks'; import {performance} from 'perf_hooks';
import {reportPluginFailures} from './utils/metrics'; import {reportPluginFailures} from './utils/metrics';
import {default as isProduction} from './utils/isProduction.js'; import {default as isProduction} from './utils/isProduction.js';
import {registerPlugins} from './reducers/plugins';
import createTableNativePlugin from './plugins/TableNativePlugin';
const EventEmitter = (require('events'): any); const EventEmitter = (require('events'): any);
const invariant = require('invariant'); const invariant = require('invariant');
@@ -175,6 +177,21 @@ export default class Client extends EventEmitter {
data => data.plugins, data => data.plugins,
); );
this.plugins = plugins; this.plugins = plugins;
const nativeplugins = plugins
.map(plugin => /_nativeplugin_([^_]+)_([^_]+)/.exec(plugin))
.filter(Boolean)
.map(([id, type, title]) => {
// TODO put this in another component, and make the "types" registerable
switch (type) {
case 'Table':
return createTableNativePlugin(id, title);
default: {
return null;
}
}
})
.filter(Boolean);
this.store.dispatch(registerPlugins(nativeplugins));
return plugins; return plugins;
} }

View File

@@ -8,42 +8,57 @@
import {createTablePlugin} from '../createTablePlugin.js'; import {createTablePlugin} from '../createTablePlugin.js';
import {FlipperPlugin} from '../plugin.js'; import {FlipperPlugin} from '../plugin.js';
const PROPS = { const KNOWN_METADATA_PROPS = {
method: 'method', method: 'method',
resetMethod: 'resetMethod', resetMethod: 'resetMethod',
columns: {}, columns: {},
columnSizes: {}, columnSizes: {},
renderSidebar: () => {}, renderSidebar: () => {},
buildRow: () => {}, buildRow: () => {
return {columns: {}, key: 'someKey'};
},
};
const DYNAMIC_METADATA_PROPS = {
method: 'method',
resetMethod: 'resetMethod',
id: 'testytest',
title: 'TestPlugin',
renderSidebar: () => {},
buildRow: () => {
return {columns: {}, key: 'someKey'};
},
}; };
test('createTablePlugin returns FlipperPlugin', () => { test('createTablePlugin returns FlipperPlugin', () => {
const tablePlugin = createTablePlugin({...PROPS}); const tablePlugin = createTablePlugin({...KNOWN_METADATA_PROPS});
expect(tablePlugin.prototype).toBeInstanceOf(FlipperPlugin); expect(tablePlugin.prototype).toBeInstanceOf(FlipperPlugin);
}); });
test('persistedStateReducer is resetting data', () => { test('persistedStateReducer is resetting data', () => {
const resetMethod = 'resetMethod'; const resetMethod = 'resetMethod';
const tablePlugin = createTablePlugin({...PROPS, resetMethod}); const tablePlugin = createTablePlugin({...KNOWN_METADATA_PROPS, resetMethod});
// $FlowFixMe persistedStateReducer exists for createTablePlugin const ps = {
const {rows, datas} = tablePlugin.persistedStateReducer( datas: {'1': {id: '1', rowNumber: 0}},
{ rows: [
datas: {'1': {id: '1'}}, {
rows: [ key: '1',
{ columns: {
key: '1', id: {
columns: { value: '1',
id: {
value: '1',
},
}, },
}, },
], },
}, ],
resetMethod, tableMetadata: null,
{}, };
);
if (!tablePlugin.persistedStateReducer) {
expect(tablePlugin.persistedStateReducer).toBeDefined();
return;
}
const {rows, datas} = tablePlugin.persistedStateReducer(ps, resetMethod, {});
expect(datas).toEqual({}); expect(datas).toEqual({});
expect(rows).toEqual([]); expect(rows).toEqual([]);
@@ -51,18 +66,44 @@ test('persistedStateReducer is resetting data', () => {
test('persistedStateReducer is adding data', () => { test('persistedStateReducer is adding data', () => {
const method = 'method'; const method = 'method';
const tablePlugin = createTablePlugin({...PROPS, method}); const tablePlugin = createTablePlugin({...KNOWN_METADATA_PROPS, method});
const id = '1'; const id = '1';
// $FlowFixMe persistedStateReducer exists for createTablePlugin const ps = {
const {rows, datas} = tablePlugin.persistedStateReducer( datas: {},
{ rows: [],
datas: {}, tableMetadata: null,
rows: [], };
},
method, if (!tablePlugin.persistedStateReducer) {
{id}, expect(tablePlugin.persistedStateReducer).toBeDefined();
); return;
}
const {rows, datas} = tablePlugin.persistedStateReducer(ps, method, {id});
expect(rows.length).toBe(1);
expect(Object.keys(datas)).toEqual([id]);
});
test('dyn persistedStateReducer is adding data', () => {
const method = 'method';
const tablePlugin = createTablePlugin({...DYNAMIC_METADATA_PROPS, method});
const id = '1';
const ps = {
datas: {},
rows: [],
tableMetadata: null,
};
if (!tablePlugin.persistedStateReducer) {
expect(tablePlugin.persistedStateReducer).toBeDefined();
return;
}
const {rows, datas} = tablePlugin.persistedStateReducer(ps, method, {
id,
columns: {},
});
expect(rows.length).toBe(1); expect(rows.length).toBe(1);
expect(Object.keys(datas)).toEqual([id]); expect(Object.keys(datas)).toEqual([id]);

View File

@@ -171,7 +171,11 @@ class PluginDebugger extends Component<Props> {
// bundled plugins are loaded from the defaultPlugins directory within // bundled plugins are loaded from the defaultPlugins directory within
// Flipper's package. // Flipper's package.
const externalPluginPath = (p: PluginDefinition) => const externalPluginPath = (p: PluginDefinition) =>
p.out.startsWith('./defaultPlugins/') ? null : p.entry; p.out
? p.out.startsWith('./defaultPlugins/')
? null
: p.entry
: 'Native Plugin';
this.props.gatekeepedPlugins.forEach(plugin => this.props.gatekeepedPlugins.forEach(plugin =>
rows.push( rows.push(

View File

@@ -10,6 +10,9 @@ import type {
TableRows, TableRows,
TableColumnSizes, TableColumnSizes,
TableColumns, TableColumns,
TableColumnOrder,
TableColumnOrderVal,
TableBodyRow,
} from 'flipper'; } from 'flipper';
import FlexColumn from './ui/components/FlexColumn'; import FlexColumn from './ui/components/FlexColumn';
@@ -24,20 +27,45 @@ type ID = string;
type RowData = { type RowData = {
id: ID, id: ID,
columns?: {[key: string]: any},
details?: Object,
};
type Numbered<T> = {
...T,
id: ID,
rowNumber: number,
};
type TableMetadata = {
columns: TableColumns,
columnSizes?: TableColumnSizes,
columnOrder?: Array<TableColumnOrderVal>,
filterableColumns?: Set<string>,
}; };
type Props<T> = {| type Props<T> = {|
method: string, method: string,
resetMethod?: string, resetMethod?: string,
columns: TableColumns, ...
columnSizes: TableColumnSizes, | {|
columns: TableColumns,
columnSizes?: TableColumnSizes,
columnOrder?: TableColumnOrder,
filterableColumns?: Set<string>,
|}
| {|
id: string,
title: string,
|},
renderSidebar: (row: T) => any, renderSidebar: (row: T) => any,
buildRow: (row: T) => any, buildRow: (row: T, previousData: ?T) => TableBodyRow,
|}; |};
type PersistedState<T> = {| type PersistedState<T> = {|
rows: TableRows, rows: TableRows,
datas: {[key: ID]: T}, datas: {[key: ID]: Numbered<T>},
tableMetadata: ?TableMetadata,
|}; |};
type State = {| type State = {|
@@ -53,6 +81,9 @@ type State = {|
* of data objects or a single data object. Each data object represents a row in the table which is * 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. * build by calling the `buildRow` function argument.
* *
* The component can be constructed directly with the table metadata in props,
or if omitted, will call the mobile plugin to dynamically determine the table metadata.
*
* 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.
@@ -60,17 +91,27 @@ type State = {|
export function createTablePlugin<T: RowData>(props: Props<T>) { export function createTablePlugin<T: RowData>(props: Props<T>) {
return class extends FlipperPlugin<State, *, PersistedState<T>> { return class extends FlipperPlugin<State, *, PersistedState<T>> {
static keyboardActions = ['clear', 'createPaste']; static keyboardActions = ['clear', 'createPaste'];
static id = props.id || '';
static title = props.title || '';
static defaultPersistedState: PersistedState<T> = { static defaultPersistedState: PersistedState<T> = {
rows: [], rows: [],
datas: {}, datas: {},
tableMetadata: props.columns
? {
columns: props.columns,
columnSizes: props.columnSizes,
columnOrder: props.columnOrder,
filterableColumns: props.filterableColumns,
}
: null,
}; };
static persistedStateReducer = ( static persistedStateReducer = (
persistedState: PersistedState<T>, persistedState: PersistedState<T>,
method: string, method: string,
payload: T | Array<T>, payload: T | Array<T>,
): $Shape<PersistedState<RowData>> => { ): $Shape<PersistedState<T>> => {
if (method === props.method) { if (method === props.method) {
const newRows = []; const newRows = [];
const newData = {}; const newData = {};
@@ -80,17 +121,33 @@ export function createTablePlugin<T: RowData>(props: Props<T>) {
if (data.id == null) { if (data.id == null) {
console.warn('The data sent did not contain an ID.', data); console.warn('The data sent did not contain an ID.', data);
} }
const previousRowData: ?Numbered<T> = persistedState.datas[data.id];
const newRow = props.buildRow(data, previousRowData);
if (persistedState.datas[data.id] == null) { if (persistedState.datas[data.id] == null) {
newData[data.id] = data; newData[data.id] = {
newRows.push(props.buildRow(data)); ...data,
rowNumber: persistedState.rows.length + newRows.length,
};
newRows.push(newRow);
} else {
persistedState.rows = persistedState.rows
.slice(0, persistedState.datas[data.id].rowNumber)
.concat(
[newRow],
persistedState.rows.slice(
persistedState.datas[data.id].rowNumber + 1,
),
);
} }
} }
return { return {
...persistedState,
datas: {...persistedState.datas, ...newData}, datas: {...persistedState.datas, ...newData},
rows: [...persistedState.rows, ...newRows], rows: [...persistedState.rows, ...newRows],
}; };
} else if (method === props.resetMethod) { } else if (method === props.resetMethod) {
return { return {
...persistedState,
rows: [], rows: [],
datas: {}, datas: {},
}; };
@@ -103,6 +160,23 @@ export function createTablePlugin<T: RowData>(props: Props<T>) {
selectedIds: [], selectedIds: [],
}; };
init() {
this.getTableMetadata();
}
getTableMetadata = () => {
if (!this.props.persistedState.tableMetadata) {
this.client.call('getMetadata').then(metadata => {
this.props.setPersistedState({
tableMetadata: {
...metadata,
filterableColumns: new Set(metadata.filterableColumns),
},
});
});
}
};
onKeyboardAction = (action: string) => { onKeyboardAction = (action: string) => {
if (action === 'clear') { if (action === 'clear') {
this.clear(); this.clear();
@@ -122,9 +196,16 @@ export function createTablePlugin<T: RowData>(props: Props<T>) {
}; };
createPaste = () => { createPaste = () => {
if (!this.props.persistedState.tableMetadata) {
return;
}
let paste = ''; let paste = '';
const mapFn = row => const mapFn = row =>
Object.keys(props.columns) (
(this.props.persistedState.tableMetadata &&
Object.keys(this.props.persistedState.tableMetadata.columns)) ||
[]
)
.map(key => textContent(row.columns[key].value)) .map(key => textContent(row.columns[key].value))
.join('\t'); .join('\t');
@@ -147,8 +228,36 @@ export function createTablePlugin<T: RowData>(props: Props<T>) {
}); });
}; };
// We don't necessarily have the table metadata at the time when buildRow
// is being used. This includes presentation layer info like which
// columns should be filterable. This does a pass over the built rows and
// applies that presentation layer information.
applyMetadataToRows(rows: TableRows): TableRows {
if (!this.props.persistedState.tableMetadata) {
console.error(
'applyMetadataToRows called without tableMetadata present',
);
return rows;
}
return rows.map(r => {
return {
...r,
columns: Object.keys(r.columns).reduce((map, columnName) => {
map[columnName].isFilterable =
this.props.persistedState.tableMetadata &&
this.props.persistedState.tableMetadata.filterableColumns
? this.props.persistedState.tableMetadata.filterableColumns.has(
columnName,
)
: false;
return map;
}, r.columns),
};
});
}
renderSidebar = () => { renderSidebar = () => {
const {renderSidebar} = props; const renderSidebar = props.renderSidebar;
const {selectedIds} = this.state; const {selectedIds} = this.state;
const {datas} = this.props.persistedState; const {datas} = this.props.persistedState;
const selectedId = selectedIds.length !== 1 ? null : selectedIds[0]; const selectedId = selectedIds.length !== 1 ? null : selectedIds[0];
@@ -161,7 +270,14 @@ export function createTablePlugin<T: RowData>(props: Props<T>) {
}; };
render() { render() {
const {columns, columnSizes} = props; if (!this.props.persistedState.tableMetadata) {
return 'Loading...';
}
const {
columns,
columnSizes,
columnOrder,
} = this.props.persistedState.tableMetadata;
const {rows} = this.props.persistedState; const {rows} = this.props.persistedState;
return ( return (
@@ -172,10 +288,11 @@ export function createTablePlugin<T: RowData>(props: Props<T>) {
floating={false} floating={false}
multiline={true} multiline={true}
columnSizes={columnSizes} columnSizes={columnSizes}
columnOrder={columnOrder}
columns={columns} columns={columns}
onRowHighlighted={this.onRowHighlighted} onRowHighlighted={this.onRowHighlighted}
multiHighlight={true} multiHighlight={true}
rows={rows} rows={this.applyMetadataToRows(rows)}
stickyBottom={true} stickyBottom={true}
actions={<Button onClick={this.clear}>Clear Table</Button>} actions={<Button onClick={this.clear}>Clear Table</Button>}
/> />

View File

@@ -0,0 +1,69 @@
/**
* Copyright 2018-present Facebook.
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
* @format
*/
import {ManagedDataInspector, Panel} from 'flipper';
import {createTablePlugin} from '../createTablePlugin';
type RowData = {
id: string,
columns: {},
details: {},
};
function buildRow(rowData: RowData, previousRowData: ?RowData) {
if (!rowData.columns) {
throw new Error('defaultBuildRow used with incorrect data format.');
}
const oldColumns =
previousRowData && previousRowData.columns
? Object.keys(previousRowData.columns).reduce((map, key) => {
if (key !== previousRowData?.id) {
map[key] = {
value: (previousRowData?.columns || {})[key].value,
isFilterable: true,
};
}
return map;
}, {})
: {};
const columns = Object.keys(rowData.columns).reduce((map, key) => {
if (key !== rowData.id) {
map[key] = {
value: rowData.columns && rowData.columns[key].value,
isFilterable: true,
};
}
return map;
}, oldColumns);
return {
columns,
key: rowData.id,
copyText: JSON.stringify(rowData),
filterValue: rowData.id,
};
}
function renderSidebar(rowData: RowData) {
if (!rowData.details) {
throw new Error('defaultRenderSidebar used with incorrect data format.');
}
return (
<Panel floating={false} heading={'Details'}>
<ManagedDataInspector data={rowData.details} expandRoot={true} />
</Panel>
);
}
export default function createTableNativePlugin(id: string, title: string) {
return createTablePlugin({
method: 'updateRows',
title,
id,
renderSidebar: renderSidebar,
buildRow: buildRow,
});
}

View File

@@ -130,21 +130,16 @@ export default class TableRow extends React.PureComponent<Props> {
{columnKeys.map(key => { {columnKeys.map(key => {
const col = row.columns[key]; const col = row.columns[key];
if (col == null) { const isFilterable = col?.isFilterable || false;
throw new Error( const value = col?.value;
`Trying to access column "${key}" which does not exist on row. Make sure buildRow is returning a valid row.`, const title = col?.title;
);
}
const isFilterable = col.isFilterable || false;
const value = col ? col.value : '';
const title = col ? col.title : '';
return ( return (
<TableBodyColumnContainer <TableBodyColumnContainer
key={key} key={key}
title={title} title={title}
multiline={multiline} multiline={multiline}
justifyContent={col.align || 'flex-start'} justifyContent={col?.align || 'flex-start'}
width={normaliseColumnWidth(columnSizes[key])}> width={normaliseColumnWidth(columnSizes[key])}>
{isFilterable && onAddFilter != null ? ( {isFilterable && onAddFilter != null ? (
<FilterRow addFilter={onAddFilter} filterKey={key}> <FilterRow addFilter={onAddFilter} filterKey={key}>

View File

@@ -11,7 +11,7 @@ export const MINIMUM_COLUMN_WIDTH = 100;
export const DEFAULT_COLUMN_WIDTH = 200; export const DEFAULT_COLUMN_WIDTH = 200;
export const DEFAULT_ROW_HEIGHT = 23; export const DEFAULT_ROW_HEIGHT = 23;
type TableColumnOrderVal = { export type TableColumnOrderVal = {
key: string, key: string,
visible: boolean, visible: boolean,
}; };

View File

@@ -34,6 +34,7 @@ export type {
TableHighlightedRows, TableHighlightedRows,
TableRowSortOrder, TableRowSortOrder,
TableColumnOrder, TableColumnOrder,
TableColumnOrderVal,
TableColumnSizes, TableColumnSizes,
} from './components/table/types.js'; } from './components/table/types.js';
export {default as ManagedTable} from './components/table/ManagedTable.js'; export {default as ManagedTable} from './components/table/ManagedTable.js';