diff --git a/desktop/app/src/NotificationsHub.tsx b/desktop/app/src/NotificationsHub.tsx index b3c0c76ab..e08acb8b8 100644 --- a/desktop/app/src/NotificationsHub.tsx +++ b/desktop/app/src/NotificationsHub.tsx @@ -31,7 +31,7 @@ import { } from './reducers/notifications'; import {selectPlugin} from './reducers/connections'; import {State as StoreState} from './reducers/index'; -import textContent from './utils/textContent'; +import {textContent} from 'flipper-plugin'; import createPaste from './fb-stubs/createPaste'; import {getPluginTitle} from './utils/pluginUtils'; import {getFlipperLib} from 'flipper-plugin'; diff --git a/desktop/app/src/dispatcher/notifications.tsx b/desktop/app/src/dispatcher/notifications.tsx index 2a6eadbd4..1f5861383 100644 --- a/desktop/app/src/dispatcher/notifications.tsx +++ b/desktop/app/src/dispatcher/notifications.tsx @@ -15,7 +15,7 @@ import { updatePluginBlocklist, updateCategoryBlocklist, } from '../reducers/notifications'; -import {textContent} from '../utils/index'; +import {textContent} from 'flipper-plugin'; import {getPluginTitle} from '../utils/pluginUtils'; import {sideEffect} from '../utils/sideEffect'; import {openNotification} from '../sandy-chrome/notification/Notification'; diff --git a/desktop/app/src/index.tsx b/desktop/app/src/index.tsx index 23d10123c..5e1f33bf4 100644 --- a/desktop/app/src/index.tsx +++ b/desktop/app/src/index.tsx @@ -12,7 +12,7 @@ export {keyframes} from '@emotion/css'; export {produce} from 'immer'; export * from './ui/index'; -export {textContent, sleep} from './utils/index'; +export {textContent, sleep} from 'flipper-plugin'; export * from './utils/jsonTypes'; export {default as GK, loadGKs, loadDistilleryGK} from './fb-stubs/GK'; export {default as createPaste} from './fb-stubs/createPaste'; diff --git a/desktop/app/src/ui/components/filter/FilterRow.tsx b/desktop/app/src/ui/components/filter/FilterRow.tsx index 80c2fd44a..45a55d67e 100644 --- a/desktop/app/src/ui/components/filter/FilterRow.tsx +++ b/desktop/app/src/ui/components/filter/FilterRow.tsx @@ -10,7 +10,7 @@ import {Filter} from './types'; import React, {PureComponent} from 'react'; import ContextMenu from '../ContextMenu'; -import textContent from '../../../utils/textContent'; +import {textContent} from 'flipper-plugin'; import styled from '@emotion/styled'; import {colors} from '../colors'; diff --git a/desktop/app/src/ui/components/searchable/SearchableTable.tsx b/desktop/app/src/ui/components/searchable/SearchableTable.tsx index f1cc59370..4a5240210 100644 --- a/desktop/app/src/ui/components/searchable/SearchableTable.tsx +++ b/desktop/app/src/ui/components/searchable/SearchableTable.tsx @@ -12,7 +12,7 @@ import ManagedTable, {ManagedTableProps} from '../table/ManagedTable'; import {TableBodyRow} from '../table/types'; import Searchable, {SearchableProps} from './Searchable'; import React, {PureComponent} from 'react'; -import textContent from '../../../utils/textContent'; +import {textContent} from 'flipper-plugin'; import deepEqual from 'deep-equal'; type Props = { diff --git a/desktop/app/src/ui/components/table/ManagedTable.tsx b/desktop/app/src/ui/components/table/ManagedTable.tsx index 641c59614..3c99bb54b 100644 --- a/desktop/app/src/ui/components/table/ManagedTable.tsx +++ b/desktop/app/src/ui/components/table/ManagedTable.tsx @@ -31,9 +31,8 @@ import createPaste from '../../../fb-stubs/createPaste'; import debounceRender from 'react-debounce-render'; import {debounce} from 'lodash'; import {DEFAULT_ROW_HEIGHT} from './types'; -import textContent from '../../../utils/textContent'; import {notNull} from '../../../utils/typeUtils'; -import {getFlipperLib} from 'flipper-plugin'; +import {getFlipperLib, textContent} from 'flipper-plugin'; const EMPTY_OBJECT = {}; Object.freeze(EMPTY_OBJECT); diff --git a/desktop/app/src/utils/index.tsx b/desktop/app/src/utils/index.tsx index 1d6a71790..6d5c796a3 100644 --- a/desktop/app/src/utils/index.tsx +++ b/desktop/app/src/utils/index.tsx @@ -7,6 +7,5 @@ * @format */ -export {default as textContent} from './textContent'; export {getStringFromErrorLike} from './errors'; export {sleep} from './promiseTimeout'; diff --git a/desktop/flipper-plugin/src/__tests__/api.node.tsx b/desktop/flipper-plugin/src/__tests__/api.node.tsx index b93d87c69..9a1e7eb94 100644 --- a/desktop/flipper-plugin/src/__tests__/api.node.tsx +++ b/desktop/flipper-plugin/src/__tests__/api.node.tsx @@ -57,6 +57,7 @@ test('Correct top level API exposed', () => { "renderReactRoot", "sleep", "styled", + "textContent", "theme", "useLocalStorageState", "useLogger", diff --git a/desktop/flipper-plugin/src/index.ts b/desktop/flipper-plugin/src/index.ts index 8dd008651..7fd1b0561 100644 --- a/desktop/flipper-plugin/src/index.ts +++ b/desktop/flipper-plugin/src/index.ts @@ -132,6 +132,8 @@ export { export {createTablePlugin} from './utils/createTablePlugin'; +export {textContent} from './utils/textContent'; + // It's not ideal that this exists in flipper-plugin sources directly, // 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) diff --git a/desktop/flipper-plugin/src/ui/data-table/TableContextMenu.tsx b/desktop/flipper-plugin/src/ui/data-table/TableContextMenu.tsx index 5a030c9e2..7a9cb068c 100644 --- a/desktop/flipper-plugin/src/ui/data-table/TableContextMenu.tsx +++ b/desktop/flipper-plugin/src/ui/data-table/TableContextMenu.tsx @@ -7,7 +7,7 @@ * @format */ -import {CopyOutlined, FilterOutlined} from '@ant-design/icons'; +import {CopyOutlined, FilterOutlined, TableOutlined} from '@ant-design/icons'; import {Checkbox, Menu} from 'antd'; import { DataTableDispatch, @@ -20,20 +20,21 @@ import {tryGetFlipperLibImplementation} from '../../plugin/FlipperLib'; import {DataTableColumn} from './DataTable'; import {toFirstUpper} from '../../utils/toFirstUpper'; import {DataSource} from '../../data-source/index'; +import {renderColumnValue} from './TableRow'; +import {textContent} from '../../utils/textContent'; const {Item, SubMenu} = Menu; -function defaultOnCopyRows(items: T[]) { - return JSON.stringify(items.length > 1 ? items : items[0], null, 2); -} - export function tableContextMenuFactory( datasource: DataSource, dispatch: DataTableDispatch, selection: Selection, columns: DataTableColumn[], visibleColumns: DataTableColumn[], - onCopyRows: (rows: T[]) => string = defaultOnCopyRows, + onCopyRows: ( + rows: T[], + visibleColumns: DataTableColumn[], + ) => string = defaultOnCopyRows, onContextMenu?: (selection: undefined | T) => React.ReactElement, ) { const lib = tryGetFlipperLibImplementation(); @@ -68,6 +69,61 @@ export function tableContextMenuFactory( ))} + } + disabled={!hasSelection}> + { + const items = getSelectedItems(datasource, selection); + if (items.length) { + lib.writeTextToClipboard(onCopyRows(items, visibleColumns)); + } + }}> + Copy row(s) + + {lib.isFB && ( + { + const items = getSelectedItems(datasource, selection); + if (items.length) { + lib.createPaste(onCopyRows(items, visibleColumns)); + } + }}> + Create paste + + )} + { + const items = getSelectedItems(datasource, selection); + if (items.length) { + lib.writeTextToClipboard(rowsToJson(items)); + } + }}> + Copy row(s) (JSON) + + {lib.isFB && ( + { + const items = getSelectedItems(datasource, selection); + if (items.length) { + lib.createPaste(rowsToJson(items)); + } + }}> + Create paste (JSON) + + )} + + ( ))} - { - const items = getSelectedItems(datasource, selection); - if (items.length) { - lib.writeTextToClipboard(onCopyRows(items)); - } - }}> - Copy row(s) - - {lib.isFB && ( - { - const items = getSelectedItems(datasource, selection); - if (items.length) { - lib.createPaste(onCopyRows(items)); - } - }}> - Create paste - - )} {columns.map((column, idx) => ( @@ -143,3 +175,24 @@ function friendlyColumnTitle(column: DataTableColumn): string { const name = column.title || column.key; return toFirstUpper(name); } + +function defaultOnCopyRows( + items: T[], + visibleColumns: DataTableColumn[], +) { + return ( + visibleColumns.map(friendlyColumnTitle).join('\t') + + '\n' + + items + .map((row, idx) => + visibleColumns + .map((col) => textContent(renderColumnValue(col, row, true, idx))) + .join('\t'), + ) + .join('\n') + ); +} + +function rowsToJson(items: T[]) { + return JSON.stringify(items.length > 1 ? items : items[0], null, 2); +} diff --git a/desktop/flipper-plugin/src/ui/data-table/TableRow.tsx b/desktop/flipper-plugin/src/ui/data-table/TableRow.tsx index 00e804409..86ac104e4 100644 --- a/desktop/flipper-plugin/src/ui/data-table/TableRow.tsx +++ b/desktop/flipper-plugin/src/ui/data-table/TableRow.tsx @@ -10,7 +10,7 @@ import React, {CSSProperties, memo} from 'react'; import styled from '@emotion/styled'; import {theme} from '../theme'; -import type {TableRowRenderContext} from './DataTable'; +import {DataTableColumn, TableRowRenderContext} from './DataTable'; import {Width} from '../../utils/widthUtils'; import {DataFormatter} from '../DataFormatter'; import {Dropdown} from 'antd'; @@ -121,9 +121,12 @@ export const TableRow = memo(function TableRow({ {config.columns .filter((col) => col.visible) .map((col) => { - const value = col.onRender - ? (col as any).onRender(record, highlighted, itemIndex) - : DataFormatter.format((record as any)[col.key], col.formatters); + const value = renderColumnValue( + col, + record, + highlighted, + itemIndex, + ); return ( ({ return row; } }); + +export function renderColumnValue( + col: DataTableColumn, + record: T, + highlighted: boolean, + itemIndex: number, +) { + return col.onRender + ? col.onRender(record, highlighted, itemIndex) + : DataFormatter.format((record as any)[col.key], col.formatters); +} diff --git a/desktop/app/src/utils/textContent.tsx b/desktop/flipper-plugin/src/utils/textContent.tsx similarity index 95% rename from desktop/app/src/utils/textContent.tsx rename to desktop/flipper-plugin/src/utils/textContent.tsx index 889d24c8d..a9628bab2 100644 --- a/desktop/app/src/utils/textContent.tsx +++ b/desktop/flipper-plugin/src/utils/textContent.tsx @@ -21,7 +21,7 @@ function isReactElement(object: any) { * Recursively walks through all children of a React element and returns * the string representation of the leafs concatenated. */ -export default (node: ReactNode): string => { +export function textContent(node: ReactNode): string { let res = ''; const traverse = (node: ReactNode) => { if (typeof node === 'string' || typeof node === 'number') { @@ -44,4 +44,4 @@ export default (node: ReactNode): string => { traverse(node); return res; -}; +} diff --git a/docs/extending/flipper-plugin.mdx b/docs/extending/flipper-plugin.mdx index 679e77b2e..cca46bc13 100644 --- a/docs/extending/flipper-plugin.mdx +++ b/docs/extending/flipper-plugin.mdx @@ -1012,6 +1012,10 @@ Creates a promise that automatically resolves after the specified amount of mill A convenience re-export of `styled` from [emotion](https://emotion.sh/docs/styled). +## textContent + +Given a string or React element, returns a text representation of that element, that is suitable as plain text. + ## TestUtils The object `TestUtils` as exposed from `flipper-plugin` exposes utilities to write unit tests for Sandy plugins.