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:
committed by
Facebook GitHub Bot
parent
66774c90c6
commit
d73f6578a7
@@ -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",
|
||||
|
||||
@@ -65,6 +65,8 @@ export {
|
||||
wrapInteractionHandler as _wrapInteractionHandler,
|
||||
} from './ui/Tracked';
|
||||
|
||||
export {DataFormatter} from './ui/DataFormatter';
|
||||
|
||||
export {sleep} from './utils/sleep';
|
||||
export {
|
||||
LogTypes,
|
||||
|
||||
@@ -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',
|
||||
|
||||
121
desktop/flipper-plugin/src/ui/DataFormatter.tsx
Normal file
121
desktop/flipper-plugin/src/ui/DataFormatter.tsx
Normal 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);
|
||||
},
|
||||
};
|
||||
164
desktop/flipper-plugin/src/ui/__tests__/DataFormatter.node.tsx
Normal file
164
desktop/flipper-plugin/src/ui/__tests__/DataFormatter.node.tsx
Normal 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>
|
||||
`);
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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'),
|
||||
);
|
||||
}
|
||||
}}>
|
||||
|
||||
@@ -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>';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
16
desktop/flipper-plugin/src/utils/safeStringify.tsx
Normal file
16
desktop/flipper-plugin/src/utils/safeStringify.tsx
Normal 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 + '>';
|
||||
}
|
||||
}
|
||||
12
desktop/flipper-plugin/src/utils/urlRegex.tsx
Normal file
12
desktop/flipper-plugin/src/utils/urlRegex.tsx
Normal 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]))*)*)/;
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user