diff --git a/src/Client.js b/src/Client.js index 19cc47139..aeb0fb67c 100644 --- a/src/Client.js +++ b/src/Client.js @@ -18,6 +18,8 @@ import {ReactiveSocket, PartialResponder} from 'rsocket-core'; import {performance} from 'perf_hooks'; import {reportPluginFailures} from './utils/metrics'; import {default as isProduction} from './utils/isProduction.js'; +import {registerPlugins} from './reducers/plugins'; +import createTableNativePlugin from './plugins/TableNativePlugin'; const EventEmitter = (require('events'): any); const invariant = require('invariant'); @@ -175,6 +177,21 @@ export default class Client extends EventEmitter { data => data.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; } diff --git a/src/__tests__/createTablePlugin.node.js b/src/__tests__/createTablePlugin.node.js index bc1150967..53b25c89c 100644 --- a/src/__tests__/createTablePlugin.node.js +++ b/src/__tests__/createTablePlugin.node.js @@ -8,42 +8,57 @@ import {createTablePlugin} from '../createTablePlugin.js'; import {FlipperPlugin} from '../plugin.js'; -const PROPS = { +const KNOWN_METADATA_PROPS = { method: 'method', resetMethod: 'resetMethod', columns: {}, columnSizes: {}, 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', () => { - const tablePlugin = createTablePlugin({...PROPS}); + const tablePlugin = createTablePlugin({...KNOWN_METADATA_PROPS}); expect(tablePlugin.prototype).toBeInstanceOf(FlipperPlugin); }); test('persistedStateReducer is resetting data', () => { const resetMethod = 'resetMethod'; - const tablePlugin = createTablePlugin({...PROPS, resetMethod}); + const tablePlugin = createTablePlugin({...KNOWN_METADATA_PROPS, resetMethod}); - // $FlowFixMe persistedStateReducer exists for createTablePlugin - const {rows, datas} = tablePlugin.persistedStateReducer( - { - datas: {'1': {id: '1'}}, - rows: [ - { - key: '1', - columns: { - id: { - value: '1', - }, + const ps = { + datas: {'1': {id: '1', rowNumber: 0}}, + rows: [ + { + key: '1', + columns: { + 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(rows).toEqual([]); @@ -51,18 +66,44 @@ test('persistedStateReducer is resetting data', () => { test('persistedStateReducer is adding data', () => { const method = 'method'; - const tablePlugin = createTablePlugin({...PROPS, method}); + const tablePlugin = createTablePlugin({...KNOWN_METADATA_PROPS, method}); const id = '1'; - // $FlowFixMe persistedStateReducer exists for createTablePlugin - const {rows, datas} = tablePlugin.persistedStateReducer( - { - datas: {}, - rows: [], - }, - method, - {id}, - ); + const ps = { + datas: {}, + rows: [], + tableMetadata: null, + }; + + if (!tablePlugin.persistedStateReducer) { + 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(Object.keys(datas)).toEqual([id]); diff --git a/src/chrome/PluginDebugger.js b/src/chrome/PluginDebugger.js index 3a8cb80df..db22f64c0 100644 --- a/src/chrome/PluginDebugger.js +++ b/src/chrome/PluginDebugger.js @@ -171,7 +171,11 @@ class PluginDebugger extends Component { // bundled plugins are loaded from the defaultPlugins directory within // Flipper's package. 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 => rows.push( diff --git a/src/createTablePlugin.js b/src/createTablePlugin.js index 5045fece7..22e6939fe 100644 --- a/src/createTablePlugin.js +++ b/src/createTablePlugin.js @@ -10,6 +10,9 @@ import type { TableRows, TableColumnSizes, TableColumns, + TableColumnOrder, + TableColumnOrderVal, + TableBodyRow, } from 'flipper'; import FlexColumn from './ui/components/FlexColumn'; @@ -24,20 +27,45 @@ type ID = string; type RowData = { id: ID, + columns?: {[key: string]: any}, + details?: Object, +}; + +type Numbered = { + ...T, + id: ID, + rowNumber: number, +}; + +type TableMetadata = { + columns: TableColumns, + columnSizes?: TableColumnSizes, + columnOrder?: Array, + filterableColumns?: Set, }; type Props = {| method: string, resetMethod?: string, - columns: TableColumns, - columnSizes: TableColumnSizes, + ... + | {| + columns: TableColumns, + columnSizes?: TableColumnSizes, + columnOrder?: TableColumnOrder, + filterableColumns?: Set, + |} + | {| + id: string, + title: string, + |}, renderSidebar: (row: T) => any, - buildRow: (row: T) => any, + buildRow: (row: T, previousData: ?T) => TableBodyRow, |}; type PersistedState = {| rows: TableRows, - datas: {[key: ID]: T}, + datas: {[key: ID]: Numbered}, + tableMetadata: ?TableMetadata, |}; 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 * 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 * data provided. This is useful when connecting to Flipper for this first time, or reconnecting to * the client in an unknown state. @@ -60,17 +91,27 @@ type State = {| export function createTablePlugin(props: Props) { return class extends FlipperPlugin> { static keyboardActions = ['clear', 'createPaste']; + static id = props.id || ''; + static title = props.title || ''; static defaultPersistedState: PersistedState = { rows: [], datas: {}, + tableMetadata: props.columns + ? { + columns: props.columns, + columnSizes: props.columnSizes, + columnOrder: props.columnOrder, + filterableColumns: props.filterableColumns, + } + : null, }; static persistedStateReducer = ( persistedState: PersistedState, method: string, payload: T | Array, - ): $Shape> => { + ): $Shape> => { if (method === props.method) { const newRows = []; const newData = {}; @@ -80,17 +121,33 @@ export function createTablePlugin(props: Props) { if (data.id == null) { console.warn('The data sent did not contain an ID.', data); } + const previousRowData: ?Numbered = persistedState.datas[data.id]; + const newRow = props.buildRow(data, previousRowData); if (persistedState.datas[data.id] == null) { - newData[data.id] = data; - newRows.push(props.buildRow(data)); + newData[data.id] = { + ...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 { + ...persistedState, datas: {...persistedState.datas, ...newData}, rows: [...persistedState.rows, ...newRows], }; } else if (method === props.resetMethod) { return { + ...persistedState, rows: [], datas: {}, }; @@ -103,6 +160,23 @@ export function createTablePlugin(props: Props) { 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) => { if (action === 'clear') { this.clear(); @@ -122,9 +196,16 @@ export function createTablePlugin(props: Props) { }; createPaste = () => { + if (!this.props.persistedState.tableMetadata) { + return; + } let paste = ''; 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)) .join('\t'); @@ -147,8 +228,36 @@ export function createTablePlugin(props: Props) { }); }; + // 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 = () => { - const {renderSidebar} = props; + const renderSidebar = props.renderSidebar; const {selectedIds} = this.state; const {datas} = this.props.persistedState; const selectedId = selectedIds.length !== 1 ? null : selectedIds[0]; @@ -161,7 +270,14 @@ export function createTablePlugin(props: Props) { }; 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; return ( @@ -172,10 +288,11 @@ export function createTablePlugin(props: Props) { floating={false} multiline={true} columnSizes={columnSizes} + columnOrder={columnOrder} columns={columns} onRowHighlighted={this.onRowHighlighted} multiHighlight={true} - rows={rows} + rows={this.applyMetadataToRows(rows)} stickyBottom={true} actions={} /> diff --git a/src/plugins/TableNativePlugin.js b/src/plugins/TableNativePlugin.js new file mode 100644 index 000000000..207055eea --- /dev/null +++ b/src/plugins/TableNativePlugin.js @@ -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 ( + + + + ); +} + +export default function createTableNativePlugin(id: string, title: string) { + return createTablePlugin({ + method: 'updateRows', + title, + id, + renderSidebar: renderSidebar, + buildRow: buildRow, + }); +} diff --git a/src/ui/components/table/TableRow.js b/src/ui/components/table/TableRow.js index ff421ed71..1efe45546 100644 --- a/src/ui/components/table/TableRow.js +++ b/src/ui/components/table/TableRow.js @@ -130,21 +130,16 @@ export default class TableRow extends React.PureComponent { {columnKeys.map(key => { const col = row.columns[key]; - if (col == null) { - throw new Error( - `Trying to access column "${key}" which does not exist on row. Make sure buildRow is returning a valid row.`, - ); - } - const isFilterable = col.isFilterable || false; - const value = col ? col.value : ''; - const title = col ? col.title : ''; + const isFilterable = col?.isFilterable || false; + const value = col?.value; + const title = col?.title; return ( {isFilterable && onAddFilter != null ? ( diff --git a/src/ui/components/table/types.js b/src/ui/components/table/types.js index 1d686b301..d77c6333a 100644 --- a/src/ui/components/table/types.js +++ b/src/ui/components/table/types.js @@ -11,7 +11,7 @@ export const MINIMUM_COLUMN_WIDTH = 100; export const DEFAULT_COLUMN_WIDTH = 200; export const DEFAULT_ROW_HEIGHT = 23; -type TableColumnOrderVal = { +export type TableColumnOrderVal = { key: string, visible: boolean, }; diff --git a/src/ui/index.js b/src/ui/index.js index 63ed90b94..8b3d65498 100644 --- a/src/ui/index.js +++ b/src/ui/index.js @@ -34,6 +34,7 @@ export type { TableHighlightedRows, TableRowSortOrder, TableColumnOrder, + TableColumnOrderVal, TableColumnSizes, } from './components/table/types.js'; export {default as ManagedTable} from './components/table/ManagedTable.js';