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
This commit is contained in:
Michel Weststrate
2021-03-16 14:54:53 -07:00
committed by Facebook GitHub Bot
parent 66774c90c6
commit d73f6578a7
13 changed files with 337 additions and 43 deletions

View File

@@ -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",

View File

@@ -65,6 +65,8 @@ export {
wrapInteractionHandler as _wrapInteractionHandler,
} from './ui/Tracked';
export {DataFormatter} from './ui/DataFormatter';
export {sleep} from './utils/sleep';
export {
LogTypes,

View File

@@ -36,7 +36,9 @@ type DataSourceish = DataSource<Todo> & FakeDataSource<Todo>;
// 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',

View File

@@ -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 '<unrenderable value>';
}
},
/**
* 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 ? (
<Typography.Link href={part}>{part}</Typography.Link>
) : (
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 <br/>'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);
},
};

View File

@@ -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(`
"<Failed to serialize: TypeError: Converting circular structure to JSON
--> 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(`
<React.Fragment>
<ForwardRef(Link)
href="https://www.google.com"
>
https://www.google.com
</ForwardRef(Link)>
</React.Fragment>
`);
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(`
<React.Fragment>
test
<ForwardRef(Link)
href="https://www.google.com"
>
https://www.google.com
</ForwardRef(Link)>
test
</React.Fragment>
`);
expect(linkify('https://www.google.com test http://fb.com'))
.toMatchInlineSnapshot(`
<React.Fragment>
<ForwardRef(Link)
href="https://www.google.com"
>
https://www.google.com
</ForwardRef(Link)>
test
<ForwardRef(Link)
href="http://fb.com"
>
http://fb.com
</ForwardRef(Link)>
</React.Fragment>
`);
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(`
<React.Fragment>
{
"hello": "
<ForwardRef(Link)
href="http://facebook.com"
>
http://facebook.com
</ForwardRef(Link)>
"
}
</React.Fragment>
`);
});

View File

@@ -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<T = any> {
columns: DataTableColumn<T>[];
@@ -62,6 +63,7 @@ export type DataTableColumn<T = any> = {
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;

View File

@@ -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<T>(
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'),
);
}
}}>

View File

@@ -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 (
<TableBodyColumnContainer
@@ -133,29 +137,3 @@ export const TableRow = memo(function TableRow({
</TableBodyRowContainer>
);
});
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 '<unrenderable value>';
}
}

View File

@@ -58,12 +58,12 @@ test('update and append', async () => {
class="css-1b7miqb-TableBodyRowContainer efe0za01"
>
<div
class="css-bqa56k-TableBodyColumnContainer efe0za00"
class="css-1xxqqu6-TableBodyColumnContainer efe0za00"
>
test DataTable
</div>
<div
class="css-bqa56k-TableBodyColumnContainer efe0za00"
class="css-1xxqqu6-TableBodyColumnContainer efe0za00"
>
true
</div>
@@ -115,12 +115,12 @@ test('column visibility', async () => {
class="css-1b7miqb-TableBodyRowContainer efe0za01"
>
<div
class="css-bqa56k-TableBodyColumnContainer efe0za00"
class="css-1xxqqu6-TableBodyColumnContainer efe0za00"
>
test DataTable
</div>
<div
class="css-bqa56k-TableBodyColumnContainer efe0za00"
class="css-1xxqqu6-TableBodyColumnContainer efe0za00"
>
true
</div>
@@ -140,7 +140,7 @@ test('column visibility', async () => {
class="css-1b7miqb-TableBodyRowContainer efe0za01"
>
<div
class="css-bqa56k-TableBodyColumnContainer efe0za00"
class="css-1xxqqu6-TableBodyColumnContainer efe0za00"
>
test DataTable
</div>

View File

@@ -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 '<Failed to serialize: ' + e + '>';
}
}

View File

@@ -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 = /((?<!\+)(?:https?(?::\/\/))(?:www\.)?(?:[a-zA-Z\d-_.]+(?:(?:\.|@)[a-zA-Z\d]{2,})|localhost)(?:(?:[-a-zA-Z\d:%_+.~#*$!?&//=@]*)(?:[,](?![\s]))*)*)/;

View File

@@ -526,6 +526,7 @@ Layout elements can be used to organize the screen layout.
See `View > Flipper Style Guide` inside the Flipper application for more details.
### DataTable
### DataFormatter
Coming soon.

View File

@@ -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) => {