diff --git a/src/plugins/TableNativePlugin.js b/src/plugins/TableNativePlugin.js index 7e2c6ce6c..907cd9fb6 100644 --- a/src/plugins/TableNativePlugin.js +++ b/src/plugins/TableNativePlugin.js @@ -5,14 +5,65 @@ * @format */ -import {ManagedDataInspector, Panel} from 'flipper'; -import {createTablePlugin} from '../createTablePlugin'; -import {colors, styled, Text, Toolbar, Spacer, Button} from 'flipper'; +import { + ManagedDataInspector, + Panel, + colors, + styled, + Text, + Toolbar, + Spacer, + Button, +} from 'flipper'; +import ErrorBlock from '../ui/components/ErrorBlock'; +import FlexColumn from '../ui/components/FlexColumn'; +import DetailSidebar from '../chrome/DetailSidebar'; +import {FlipperPlugin} from '../plugin'; +import SearchableTable from '../ui/components/searchable/SearchableTable'; +import textContent from '../utils/textContent.js'; +import createPaste from '../fb-stubs/createPaste.js'; + +import type {Node} from 'react'; +import type { + TableHighlightedRows, + TableRows, + TableColumnSizes, + TableColumns, + TableColumnOrderVal, + TableBodyRow, +} from 'flipper'; + +type ID = string; + +type TableMetadata = { + columns: TableColumns, + columnSizes?: TableColumnSizes, + columnOrder?: Array, + filterableColumns?: Set, +}; + +type PersistedState = {| + rows: TableRows, + datas: {[key: ID]: NumberedRowData}, + tableMetadata: ?TableMetadata, +|}; + +type State = {| + selectedIds: Array, + error: ?string, +|}; type RowData = { id: string, - columns: {}, - sidebar: Array, + columns: {[key: string]: any}, + sidebar?: Array, +}; + +type NumberedRowData = { + id: string, + columns: {[key: string]: any}, + sidebar?: Array, + rowNumber: number, }; type SidebarSection = JsonSection | ToolbarSection; @@ -59,7 +110,7 @@ function renderValue({type, value}: {type: string, value: any}) { } } -function buildRow(rowData: RowData, previousRowData: ?RowData) { +function buildRow(rowData: RowData, previousRowData: ?RowData): TableBodyRow { if (!rowData.columns) { throw new Error('defaultBuildRow used with incorrect data format.'); } @@ -112,7 +163,7 @@ function renderToolbar(section: ToolbarSection) { ); } -function renderSidebar(rowData: RowData) { +function renderSidebarForRow(rowData: RowData): Node { if (!rowData.sidebar) { throw new Error('renderSidebar used with missing rowData.sidebar'); } @@ -122,7 +173,7 @@ function renderSidebar(rowData: RowData) { return rowData.sidebar.map(renderSidebarSection); } -function renderSidebarSection(section: SidebarSection, index: number) { +function renderSidebarSection(section: SidebarSection, index: number): Node { switch (section.type) { case 'json': return ( @@ -142,11 +193,220 @@ function renderSidebarSection(section: SidebarSection, index: number) { } export default function createTableNativePlugin(id: string, title: string) { - return createTablePlugin({ - method: 'updateRows', - title, - id, - renderSidebar: renderSidebar, - buildRow: buildRow, - }); + return class extends FlipperPlugin { + static keyboardActions = ['clear', 'createPaste']; + static id = id || ''; + static title = title || ''; + + static defaultPersistedState: PersistedState = { + rows: [], + datas: {}, + tableMetadata: null, + }; + + static persistedStateReducer = ( + persistedState: PersistedState, + method: string, + payload: RowData | Array, + ): $Shape => { + if (method === 'updateRows') { + const newRows = []; + const newData = {}; + if (!Array.isArray(payload)) { + throw new Error('updateRows called with non array type'); + } + + for (const rowData of payload.reverse()) { + if (rowData.id == null) { + throw new Error( + `updateRows: row is missing id: ${JSON.stringify(rowData)}`, + ); + } + const previousRowData: ?NumberedRowData = + persistedState.datas[rowData.id]; + const newRow: TableBodyRow = buildRow(rowData, previousRowData); + if (persistedState.datas[rowData.id] == null) { + newData[rowData.id] = { + ...rowData, + rowNumber: persistedState.rows.length + newRows.length, + }; + newRows.push(newRow); + } else { + persistedState.rows = persistedState.rows + .slice(0, persistedState.datas[rowData.id].rowNumber) + .concat( + [newRow], + persistedState.rows.slice( + persistedState.datas[rowData.id].rowNumber + 1, + ), + ); + } + } + return { + ...persistedState, + datas: {...persistedState.datas, ...newData}, + rows: [...persistedState.rows, ...newRows], + }; + } else if (method === 'clearTable') { + return { + ...persistedState, + rows: [], + datas: {}, + }; + } else { + return {}; + } + }; + + state = { + selectedIds: [], + error: null, + }; + + 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), + }, + }); + }) + .catch(e => this.setState({error: e})); + } + }; + + onKeyboardAction = (action: string) => { + if (action === 'clear') { + this.clear(); + } else if (action === 'createPaste') { + this.createPaste(); + } + }; + + clear = () => { + this.props.setPersistedState({ + rows: [], + datas: {}, + }); + this.setState({ + selectedIds: [], + }); + }; + + createPaste = () => { + if (!this.props.persistedState.tableMetadata) { + return; + } + let paste = ''; + const mapFn = row => + ( + (this.props.persistedState.tableMetadata && + Object.keys(this.props.persistedState.tableMetadata.columns)) || + [] + ) + .map(key => textContent(row.columns[key].value)) + .join('\t'); + + if (this.state.selectedIds.length > 0) { + // create paste from selection + paste = this.props.persistedState.rows + .filter(row => this.state.selectedIds.indexOf(row.key) > -1) + .map(mapFn) + .join('\n'); + } else { + // create paste with all rows + paste = this.props.persistedState.rows.map(mapFn).join('\n'); + } + createPaste(paste); + }; + + onRowHighlighted = (keys: TableHighlightedRows) => { + this.setState({ + selectedIds: keys, + }); + }; + + // 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 {selectedIds} = this.state; + const {datas} = this.props.persistedState; + const selectedId = selectedIds.length !== 1 ? null : selectedIds[0]; + + if (selectedId != null) { + return renderSidebarForRow(datas[selectedId]); + } else { + return null; + } + }; + + render() { + if (this.state.error) { + return ; + } + if (!this.props.persistedState.tableMetadata) { + return 'Loading...'; + } + const { + columns, + columnSizes, + columnOrder, + } = this.props.persistedState.tableMetadata; + const {rows} = this.props.persistedState; + + return ( + + Clear Table} + /> + {this.renderSidebar()} + + ); + } + }; }