From d73f6578a70ab0233fd1ab5e437586b3516a84ee Mon Sep 17 00:00:00 2001 From: Michel Weststrate Date: Tue, 16 Mar 2021 14:54:53 -0700 Subject: [PATCH] Support linkify-ing urls Summary: Changelog: The new logs plugin will linkify urls and pretty print json-like messages This implements one of our top papercuts (see linked task), and the WP request over here: https://fb.workplace.com/groups/flipperfyi/permalink/902949260471370/. Partially addresses https://github.com/facebook/flipper/issues/1162 https://github.com/facebook/flipper/issues/1010 https://github.com/facebook/flipper/issues/2029 Reviewed By: nikoant Differential Revision: D26947007 fbshipit-source-id: be0fdb476765905ae6b63bd8799c9c6093014de3 --- .../flipper-plugin/src/__tests__/api.node.tsx | 1 + desktop/flipper-plugin/src/index.ts | 2 + .../state/__tests__/datasource-perf.node.tsx | 4 +- .../flipper-plugin/src/ui/DataFormatter.tsx | 121 +++++++++++++ .../src/ui/__tests__/DataFormatter.node.tsx | 164 ++++++++++++++++++ .../src/ui/datatable/DataTable.tsx | 2 + .../src/ui/datatable/TableContextMenu.tsx | 5 +- .../src/ui/datatable/TableRow.tsx | 38 +--- .../ui/datatable/__tests__/DataTable.node.tsx | 10 +- .../src/utils/safeStringify.tsx | 16 ++ desktop/flipper-plugin/src/utils/urlRegex.tsx | 12 ++ docs/extending/flipper-plugin.mdx | 1 + react-native/ReactNativeFlipperExample/App.js | 4 +- 13 files changed, 337 insertions(+), 43 deletions(-) create mode 100644 desktop/flipper-plugin/src/ui/DataFormatter.tsx create mode 100644 desktop/flipper-plugin/src/ui/__tests__/DataFormatter.node.tsx create mode 100644 desktop/flipper-plugin/src/utils/safeStringify.tsx create mode 100644 desktop/flipper-plugin/src/utils/urlRegex.tsx diff --git a/desktop/flipper-plugin/src/__tests__/api.node.tsx b/desktop/flipper-plugin/src/__tests__/api.node.tsx index 861141be2..d2efdbdd2 100644 --- a/desktop/flipper-plugin/src/__tests__/api.node.tsx +++ b/desktop/flipper-plugin/src/__tests__/api.node.tsx @@ -28,6 +28,7 @@ test('Correct top level API exposed', () => { // Note, all `exposedAPIs` should be documented in `flipper-plugin.mdx` expect(exposedAPIs.sort()).toMatchInlineSnapshot(` Array [ + "DataFormatter", "DataSource", "DataTable", "Layout", diff --git a/desktop/flipper-plugin/src/index.ts b/desktop/flipper-plugin/src/index.ts index 03b38fa55..c78bfe313 100644 --- a/desktop/flipper-plugin/src/index.ts +++ b/desktop/flipper-plugin/src/index.ts @@ -65,6 +65,8 @@ export { wrapInteractionHandler as _wrapInteractionHandler, } from './ui/Tracked'; +export {DataFormatter} from './ui/DataFormatter'; + export {sleep} from './utils/sleep'; export { LogTypes, diff --git a/desktop/flipper-plugin/src/state/__tests__/datasource-perf.node.tsx b/desktop/flipper-plugin/src/state/__tests__/datasource-perf.node.tsx index fa96a606e..4e967baa2 100644 --- a/desktop/flipper-plugin/src/state/__tests__/datasource-perf.node.tsx +++ b/desktop/flipper-plugin/src/state/__tests__/datasource-perf.node.tsx @@ -36,7 +36,9 @@ type DataSourceish = DataSource & FakeDataSource; // NOTE: this run in jest, which is not optimal for perf, but should give some idea // make sure to use the `yarn watch` script in desktop root, so that the garbage collector is exposed -test('run perf test', () => { + +// By default skipped to not slow down each and every test run +test.skip('run perf test', () => { if (!global.gc) { console.warn( 'Warning: garbage collector not available, skipping this test', diff --git a/desktop/flipper-plugin/src/ui/DataFormatter.tsx b/desktop/flipper-plugin/src/ui/DataFormatter.tsx new file mode 100644 index 000000000..5f9f960a9 --- /dev/null +++ b/desktop/flipper-plugin/src/ui/DataFormatter.tsx @@ -0,0 +1,121 @@ +/** + * 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 {Typography} from 'antd'; +import {pad} from 'lodash'; +import React, {createElement, Fragment, isValidElement} from 'react'; +import {safeStringify} from '../utils/safeStringify'; +import {urlRegex} from '../utils/urlRegex'; + +/** + * A Formatter is used to render an arbitrarily value to React. If a formatter returns 'undefined' + * it is considered a 'miss' and the next formatter will be tried, eventually falling back to the default formatter. + * + * In case further processing by the default formatter is to be avoided, make sure a string is returned from any custom formatter. + */ +export type Formatter = (value: any) => string | React.ReactElement | any; + +export const DataFormatter = { + defaultFormatter(value: any) { + if (isValidElement(value)) { + return value; + } + switch (typeof value) { + case 'boolean': + return value ? 'true' : 'false'; + case 'number': + return '' + value; + case 'undefined': + return ''; + case 'string': + return value; + case 'object': { + if (value === null) return ''; + if (value instanceof Date) { + return ( + value.toTimeString().split(' ')[0] + + '.' + + pad('' + value.getMilliseconds(), 3) + ); + } + if (value instanceof Map) { + return safeStringify(Array.from(value.entries())); + } + if (value instanceof Set) { + return safeStringify(Array.from(value.values())); + } + return safeStringify(value); + } + default: + return ''; + } + }, + + /** + * Formatter that will automatically create links for any urls inside the data + */ + linkify(value: any) { + if (typeof value === 'string' && urlRegex.test(value)) { + return createElement( + Fragment, + undefined, + // spreading children avoids the need for keys and reconciles by index + ...value.split(urlRegex).map((part, index) => + // odd items are the links + index % 2 === 1 ? ( + {part} + ) : ( + part + ), + ), + ); + } + return value; + }, + + prettyPrintJson(value: any) { + if (typeof value === 'string' && value.length >= 2) { + const last = value.length - 1; + // kinda looks like json + + if ( + (value[0] === '{' && value[last] === '}') || + (value[0] === '[' && value[last] === ']') + ) { + try { + value = JSON.parse(value); + } catch (e) { + // intentional fall through, can't parse this 'json' + } + } + } + if (typeof value === 'object' && value !== null) { + try { + // Note: we don't need to be inserted
's in the output, but assume the text container uses + // white-space: pre-wrap (or pre) + return JSON.stringify(value, null, 2); + } catch (e) { + // intentional fall through, can't pretty print this 'json' + } + } + return value; + }, + + format(value: any, formatters?: Formatter[] | Formatter): any { + let res = value; + if (Array.isArray(formatters)) { + for (const formatter of formatters) { + res = formatter(res); + } + } else if (formatters) { + res = formatters(res); + } + return DataFormatter.defaultFormatter(res); + }, +}; diff --git a/desktop/flipper-plugin/src/ui/__tests__/DataFormatter.node.tsx b/desktop/flipper-plugin/src/ui/__tests__/DataFormatter.node.tsx new file mode 100644 index 000000000..5f465bd06 --- /dev/null +++ b/desktop/flipper-plugin/src/ui/__tests__/DataFormatter.node.tsx @@ -0,0 +1,164 @@ +/** + * 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 {DataFormatter} from '../DataFormatter'; + +test('default formatter', () => { + expect(DataFormatter.format(true)).toMatchInlineSnapshot(`"true"`); + expect(DataFormatter.format(false)).toMatchInlineSnapshot(`"false"`); + expect(DataFormatter.format(3)).toMatchInlineSnapshot(`"3"`); + expect(DataFormatter.format(null)).toMatchInlineSnapshot(`""`); + expect(DataFormatter.format(undefined)).toMatchInlineSnapshot(`""`); + expect( + DataFormatter.format(new Date(2020, 2, 3, 5, 8, 4, 244654)), + ).toMatchInlineSnapshot(`"05:12:08.654"`); + expect(DataFormatter.format('test')).toMatchInlineSnapshot(`"test"`); + + expect(DataFormatter.format({hello: 'world'})).toMatchInlineSnapshot(` + "{ + \\"hello\\": \\"world\\" + }" + `); + expect(DataFormatter.format({hello: ['world']})).toMatchInlineSnapshot(` + "{ + \\"hello\\": [ + \\"world\\" + ] + }" + `); + expect(DataFormatter.format(new Map([['hello', 'world']]))) + .toMatchInlineSnapshot(` + "[ + [ + \\"hello\\", + \\"world\\" + ] + ]" + `); + expect(DataFormatter.format(new Set([['hello', 'world']]))) + .toMatchInlineSnapshot(` + "[ + [ + \\"hello\\", + \\"world\\" + ] + ]" + `); + + const unserializable: any = {}; + unserializable.x = unserializable; + expect(DataFormatter.format(unserializable)).toMatchInlineSnapshot(` + " starting at object with constructor 'Object' + --- property 'x' closes the circle>" + `); + + // make sure we preserve newlines + expect(DataFormatter.format('Test 123\n\t\t345\n\t\t67')) + .toMatchInlineSnapshot(` + "Test 123 + 345 + 67" + `); +}); + +test('linkify formatter', () => { + const linkify = (value: any) => + DataFormatter.format(value, DataFormatter.linkify); + + // verify fallback + expect(linkify({hello: 'world'})).toMatchInlineSnapshot(` + "{ + \\"hello\\": \\"world\\" + }" + `); + expect(linkify('hi there!')).toMatchInlineSnapshot(`"hi there!"`); + expect(linkify('https://www.google.com')).toMatchInlineSnapshot(` + + + + https://www.google.com + + + + `); + expect(linkify('www.google.com')).toMatchInlineSnapshot(`"www.google.com"`); + expect(linkify('stuff.google.com')).toMatchInlineSnapshot( + `"stuff.google.com"`, + ); + expect(linkify('test https://www.google.com test')).toMatchInlineSnapshot(` + + test + + https://www.google.com + + test + + `); + expect(linkify('https://www.google.com test http://fb.com')) + .toMatchInlineSnapshot(` + + + + https://www.google.com + + test + + http://fb.com + + + + `); + expect(linkify('fb.com')).toMatchInlineSnapshot(`"fb.com"`); +}); + +test('linkify formatter', () => { + const jsonify = (value: any) => + DataFormatter.format(value, DataFormatter.prettyPrintJson); + + expect(jsonify({hello: 'world'})).toMatchInlineSnapshot(` + "{ + \\"hello\\": \\"world\\" + }" + `); + expect(jsonify([{hello: 'world'}])).toMatchInlineSnapshot(` + "[ + { + \\"hello\\": \\"world\\" + } + ]" + `); + // linkify json! + expect( + DataFormatter.format({hello: 'http://facebook.com'}, [ + DataFormatter.prettyPrintJson, + DataFormatter.linkify, + ]), + ).toMatchInlineSnapshot(` + + { + "hello": " + + http://facebook.com + + " + } + + `); +}); diff --git a/desktop/flipper-plugin/src/ui/datatable/DataTable.tsx b/desktop/flipper-plugin/src/ui/datatable/DataTable.tsx index 4ffd00446..dab35e601 100644 --- a/desktop/flipper-plugin/src/ui/datatable/DataTable.tsx +++ b/desktop/flipper-plugin/src/ui/datatable/DataTable.tsx @@ -45,6 +45,7 @@ import {Typography} from 'antd'; import {CoffeeOutlined, SearchOutlined} from '@ant-design/icons'; import {useAssertStableRef} from '../../utils/useAssertStableRef'; import {TrackingScopeContext} from 'flipper-plugin/src/ui/Tracked'; +import {Formatter} from '../DataFormatter'; interface DataTableProps { columns: DataTableColumn[]; @@ -62,6 +63,7 @@ export type DataTableColumn = { key: keyof T & string; // possible future extension: getValue(row) (and free-form key) to support computed columns onRender?: (row: T) => React.ReactNode; + formatters?: Formatter[] | Formatter; title?: string; width?: number | Percentage | undefined; // undefined: use all remaining width wrap?: boolean; diff --git a/desktop/flipper-plugin/src/ui/datatable/TableContextMenu.tsx b/desktop/flipper-plugin/src/ui/datatable/TableContextMenu.tsx index 659c6a29c..aef7284d7 100644 --- a/desktop/flipper-plugin/src/ui/datatable/TableContextMenu.tsx +++ b/desktop/flipper-plugin/src/ui/datatable/TableContextMenu.tsx @@ -15,7 +15,6 @@ import { Selection, } from './DataTableManager'; import React from 'react'; -import {normalizeCellValue} from './TableRow'; import {tryGetFlipperLibImplementation} from '../../plugin/FlipperLib'; import {DataTableColumn} from './DataTable'; import {DataSource} from '../../state/DataSource'; @@ -69,9 +68,7 @@ export function tableContextMenuFactory( const items = getSelectedItems(datasource, selection); if (items.length) { lib.writeTextToClipboard( - items - .map((item) => normalizeCellValue(item[column.key])) - .join('\n'), + items.map((item) => '' + item[column.key]).join('\n'), ); } }}> diff --git a/desktop/flipper-plugin/src/ui/datatable/TableRow.tsx b/desktop/flipper-plugin/src/ui/datatable/TableRow.tsx index fbaba0a5c..39cd483fe 100644 --- a/desktop/flipper-plugin/src/ui/datatable/TableRow.tsx +++ b/desktop/flipper-plugin/src/ui/datatable/TableRow.tsx @@ -12,7 +12,7 @@ import styled from '@emotion/styled'; import {theme} from 'flipper-plugin'; import type {RenderContext} from './DataTable'; import {Width} from '../../utils/widthUtils'; -import {pad} from 'lodash'; +import {DataFormatter} from '../DataFormatter'; // heuristic for row estimation, should match any future styling updates export const DEFAULT_ROW_HEIGHT = 24; @@ -69,14 +69,15 @@ const TableBodyColumnContainer = styled.div<{ multiline?: boolean; justifyContent: 'left' | 'right' | 'center' | 'flex-start'; }>((props) => ({ - display: 'flex', + display: 'block', flexShrink: props.width === undefined ? 1 : 0, flexGrow: props.width === undefined ? 1 : 0, overflow: 'hidden', padding: `0 ${theme.space.small}px`, borderBottom: `1px solid ${theme.dividerColor}`, verticalAlign: 'top', - whiteSpace: props.multiline ? 'normal' : 'nowrap', + // pre-wrap preserves explicit newlines and whitespace, and wraps as well when needed + whiteSpace: props.multiline ? 'pre-wrap' : 'nowrap', wordWrap: props.multiline ? 'break-word' : 'normal', width: props.width, justifyContent: props.justifyContent, @@ -84,6 +85,9 @@ const TableBodyColumnContainer = styled.div<{ color: 'inherit', backgroundColor: theme.buttonDefaultBackground, }, + '& p': { + margin: 0, + }, })); TableBodyColumnContainer.displayName = 'TableRow:TableBodyColumnContainer'; @@ -118,7 +122,7 @@ export const TableRow = memo(function TableRow({ .map((col) => { const value = (col as any).onRender ? (col as any).onRender(record) - : normalizeCellValue((record as any)[col.key]); + : DataFormatter.format((record as any)[col.key], col.formatters); return ( ); }); - -export function normalizeCellValue(value: any): string { - switch (typeof value) { - case 'boolean': - return value ? 'true' : 'false'; - case 'number': - return '' + value; - case 'undefined': - return ''; - case 'string': - return value; - case 'object': { - if (value === null) return ''; - if (value instanceof Date) { - return ( - value.toTimeString().split(' ')[0] + - '.' + - pad('' + value.getMilliseconds(), 3) - ); - } - return JSON.stringify(value, null, 2); - } - default: - return ''; - } -} diff --git a/desktop/flipper-plugin/src/ui/datatable/__tests__/DataTable.node.tsx b/desktop/flipper-plugin/src/ui/datatable/__tests__/DataTable.node.tsx index 806b7095b..89ea5bef8 100644 --- a/desktop/flipper-plugin/src/ui/datatable/__tests__/DataTable.node.tsx +++ b/desktop/flipper-plugin/src/ui/datatable/__tests__/DataTable.node.tsx @@ -58,12 +58,12 @@ test('update and append', async () => { class="css-1b7miqb-TableBodyRowContainer efe0za01" >
test DataTable
true
@@ -115,12 +115,12 @@ test('column visibility', async () => { class="css-1b7miqb-TableBodyRowContainer efe0za01" >
test DataTable
true
@@ -140,7 +140,7 @@ test('column visibility', async () => { class="css-1b7miqb-TableBodyRowContainer efe0za01" >
test DataTable
diff --git a/desktop/flipper-plugin/src/utils/safeStringify.tsx b/desktop/flipper-plugin/src/utils/safeStringify.tsx new file mode 100644 index 000000000..6b79a8167 --- /dev/null +++ b/desktop/flipper-plugin/src/utils/safeStringify.tsx @@ -0,0 +1,16 @@ +/** + * 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 + */ + +export function safeStringify(value: any) { + try { + return JSON.stringify(value, null, 2); + } catch (e) { + return ''; + } +} diff --git a/desktop/flipper-plugin/src/utils/urlRegex.tsx b/desktop/flipper-plugin/src/utils/urlRegex.tsx new file mode 100644 index 000000000..6d639d5bd --- /dev/null +++ b/desktop/flipper-plugin/src/utils/urlRegex.tsx @@ -0,0 +1,12 @@ +/** + * 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 + */ + +// Source: https://github.com/sindresorhus/linkify-urls/blob/e0cf2a2d51dc8f5f95d93f97ecb1cc804cc9e6be/index.js#L5 +// n.b. no /g flag, as that makes the regex stateful! Which is not needed for splitting +export const urlRegex = /((? Flipper Style Guide` inside the Flipper application for more details. ### DataTable +### DataFormatter Coming soon. diff --git a/react-native/ReactNativeFlipperExample/App.js b/react-native/ReactNativeFlipperExample/App.js index f6b753c61..f2530a493 100644 --- a/react-native/ReactNativeFlipperExample/App.js +++ b/react-native/ReactNativeFlipperExample/App.js @@ -104,9 +104,7 @@ const App: () => React$Node = () => { fetch(API, {headers: {accept: 'application/json'}}) .then((res) => res.json()) .then((data) => { - console.log( - 'Got status: ' + JSON.stringify(data, null, 2), - ); + console.log(data.status); setNpmStatus(data.status.description); }) .catch((e) => {