Files
flipper/desktop/app/src/plugins/TableNativePlugin.tsx
Michel Weststrate 53c557f923 Move DataInspector to flipper-plugin
Summary:
This diff moves the rest of the DataInspector jungle to flipper-plugin. No actual improvements are made yet, but the code is decoupled from Electron and the legacy theming. For example by using Antd based context menus.

Note that `ManagedDataInspector` is now rebranded `DataInspector`, as that is the only variation that should (and is) used publicly.

For the interactionTracker removal, see next diff.

SearchableDataInspector will be addressed in a next diff

Reviewed By: passy

Differential Revision: D27603125

fbshipit-source-id: 188bd000260e4e4704202ce02c7fc98319f0bc22
2021-04-07 07:55:13 -07:00

511 lines
14 KiB
TypeScript

/**
* 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 {DataInspector} from 'flipper-plugin';
import Panel from '../ui/components/Panel';
import {colors} from '../ui/components/colors';
import styled from '@emotion/styled';
import Text from '../ui/components/Text';
import Toolbar from '../ui/components/Toolbar';
import Spacer from '../ui/components/Toolbar';
import Button from '../ui/components/Button';
import Select from '../ui/components/Select';
import ErrorBlock from '../ui/components/ErrorBlock';
import FlexColumn from '../ui/components/FlexColumn';
import SearchableTable from '../ui/components/searchable/SearchableTable';
import {
TableHighlightedRows,
TableRows,
TableColumnSizes,
TableColumns,
TableColumnOrderVal,
TableBodyRow,
} from '../ui/components/table/types';
import {DetailSidebar} from 'flipper-plugin';
import {FlipperPlugin} from '../plugin';
import textContent from '../utils/textContent';
import createPaste from '../fb-stubs/createPaste';
import {ReactNode} from 'react';
import React from 'react';
import {KeyboardActions} from '../MenuBar';
import {BundledPluginDetails} from 'flipper-plugin-lib';
type ID = string;
type TableMetadata = {
topToolbar?: ToolbarSection;
bottomToolbar?: ToolbarSection;
columns: TableColumns;
columnSizes?: TableColumnSizes;
columnOrder?: Array<TableColumnOrderVal>;
filterableColumns?: Set<string>;
};
type PersistedState = {
rows: TableRows;
datas: {[key: string]: NumberedRowData};
tableMetadata: TableMetadata | null | undefined;
};
type State = {
selectedIds: Array<ID>;
error: string | null | undefined;
};
type RowData = {
id: string;
columns: {[key: string]: any};
sidebar?: Array<SidebarSection>;
};
type NumberedRowData = {
id: string;
columns: {[key: string]: any};
sidebar?: Array<SidebarSection>;
rowNumber: number;
};
type SidebarSection = JsonSection | ToolbarSection;
type JsonSection = {
type: 'json';
title: string;
content: string;
};
type ToolbarSection = {
type: 'toolbar';
items: Array<ToolbarItem>;
};
type ToolbarItem =
| {type: 'link'; destination: string; label: string}
| {
type: 'input';
inputType: 'select';
id: string;
label: string;
options: Array<string>;
value: string;
};
const NonWrappingText = styled(Text)({
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
});
const BooleanValue = styled(NonWrappingText)<{active?: boolean}>((props) => ({
'&::before': {
content: '""',
display: 'inline-block',
width: 8,
height: 8,
borderRadius: 4,
backgroundColor: props.active ? colors.green : colors.red,
marginRight: 5,
marginTop: 1,
},
}));
const Label = styled.span({
fontSize: 12,
color: '#90949c',
fontWeight: 'bold',
textTransform: 'uppercase',
marginLeft: 5,
marginRight: 12,
});
function renderValue({type, value}: {type: string; value: any}) {
switch (type) {
case 'boolean':
return (
<BooleanValue code={true} active={value}>
{value.toString()}
</BooleanValue>
);
default:
return value;
}
}
function buildRow(
rowData: RowData,
previousRowData: RowData | null | undefined,
): TableBodyRow {
if (!rowData.columns) {
throw new Error('defaultBuildRow used with incorrect data format.');
}
const oldColumns =
previousRowData && previousRowData.columns
? Object.keys(previousRowData.columns).reduce(
(map: {[key: string]: {value: any; isFilterable: boolean}}, key) => {
if (key !== 'id') {
let value = null;
if (previousRowData && previousRowData.columns) {
value = previousRowData.columns[key].value;
}
map[key] = {
value,
isFilterable: true,
};
}
return map;
},
{},
)
: {};
const columns = Object.keys(rowData.columns).reduce((map, key) => {
if (rowData.columns && key !== 'id') {
const renderedValue = renderValue(rowData.columns[key]);
map[key] = {
value: renderedValue,
isFilterable: true,
};
}
return map;
}, oldColumns);
return {
columns,
key: rowData.id,
copyText: () => JSON.stringify(rowData),
filterValue: rowData.id,
};
}
function renderToolbar(section: ToolbarSection) {
const toolbarComponents = section.items.map((item, index) => {
switch (item.type) {
case 'link':
return (
<Button href={item.destination} key={index + 1}>
{item.label}
</Button>
);
case 'input':
return (
<>
<Label>{item.label}</Label>
<Select
options={item.options.reduce(
(obj: {[key: string]: string}, item) => {
obj[item] = item;
return obj;
},
{},
)}
selected={item.value}
onChange={() => {}}
/>
</>
);
}
});
return (
<Toolbar key="toolbar">
<Spacer key={0} />
{toolbarComponents}
</Toolbar>
);
}
function renderSidebarForRow(rowData: RowData): ReactNode {
if (!rowData.sidebar) {
return null;
}
if (!Array.isArray(rowData.sidebar)) {
throw new Error('typeof rowData.sidebar is not array as expected: ');
}
return rowData.sidebar.map(renderSidebarSection);
}
function renderSidebarSection(
section: SidebarSection,
index: number,
): ReactNode {
switch (section.type) {
case 'json':
return (
<Panel floating={false} heading={section.title} key={index}>
<DataInspector data={section.content} expandRoot={true} />
</Panel>
);
case 'toolbar':
return renderToolbar(section);
default:
return (
<Panel floating={false} heading={'Details'} key={index}>
<DataInspector data={section} expandRoot={true} />
</Panel>
);
}
}
type IncomingMessage =
| {method: 'updateRows'; data: Array<RowData>}
| {method: 'clearTable'};
export default function createTableNativePlugin(id: string, title: string) {
return class extends FlipperPlugin<State, any, PersistedState> {
static keyboardActions: KeyboardActions = ['clear', 'createPaste'];
static id = id || '';
static title = title || '';
static details: BundledPluginDetails = {
id,
title,
icon: 'apps',
name: id,
pluginType: 'client',
// all hmm...
specVersion: 1,
version: 'auto',
source: '',
main: '',
isBundled: true,
isActivatable: true,
};
static defaultPersistedState: PersistedState = {
rows: [],
datas: {},
tableMetadata: null,
};
static typedPersistedStateReducer = (
persistedState: PersistedState,
message: IncomingMessage,
): Partial<PersistedState> => {
if (message.method === 'updateRows') {
const newRows = [];
const newData: {[key: string]: NumberedRowData} = {};
for (const rowData of message.data.reverse()) {
if (rowData.id == null) {
throw new Error(
`updateRows: row is missing id: ${JSON.stringify(rowData)}`,
);
}
const previousRowData: NumberedRowData | null | undefined =
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 (message.method === 'clearTable') {
return {
...persistedState,
rows: [],
datas: {},
};
} else {
return {};
}
};
static persistedStateReducer(
persistedState: PersistedState,
method: string,
data: Array<RowData> | undefined,
): Partial<PersistedState> {
const methodEnum = method as 'updateRows' | 'clearTable';
const message: IncomingMessage =
methodEnum === 'updateRows'
? {
method: methodEnum,
data: data || [],
}
: {method: methodEnum};
return this.typedPersistedStateReducer(persistedState, message);
}
state = {
selectedIds: [] as Array<ID>,
error: null,
};
init() {
this.getTableMetadata();
}
getTableMetadata = () => {
if (!this.props.persistedState.tableMetadata) {
if (!this.client.isConnected) {
this.setState({error: 'Application disconnected'});
return;
}
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: TableBodyRow) =>
(
(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 <ErrorBlock error={this.state.error} />;
}
if (!this.props.persistedState.tableMetadata) {
return 'Loading...';
}
const {
topToolbar,
bottomToolbar,
columns,
columnSizes,
columnOrder,
} = this.props.persistedState.tableMetadata;
const {rows} = this.props.persistedState;
const topToolbarComponent = topToolbar ? renderToolbar(topToolbar) : null;
const bottomToolbarComponent = bottomToolbar
? renderToolbar(bottomToolbar)
: null;
return (
<FlexColumn grow={true}>
{topToolbarComponent}
<SearchableTable
key={this.constructor.id}
rowLineHeight={28}
floating={false}
multiline={true}
columnSizes={columnSizes}
columnOrder={columnOrder}
columns={columns}
onRowHighlighted={this.onRowHighlighted}
multiHighlight={true}
rows={this.applyMetadataToRows(rows)}
stickyBottom={true}
actions={<Button onClick={this.clear}>Clear Table</Button>}
/>
<DetailSidebar>{this.renderSidebar()}</DetailSidebar>
{bottomToolbarComponent}
</FlexColumn>
);
}
};
}