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
This commit is contained in:
committed by
Facebook GitHub Bot
parent
9030a98c6e
commit
53c557f923
@@ -95,15 +95,14 @@ export {
|
||||
export {
|
||||
DataValueExtractor,
|
||||
DataInspectorExpanded,
|
||||
} from './ui/components/data-inspector/DataInspector';
|
||||
export {default as DataInspector} from './ui/components/data-inspector/DataInspector';
|
||||
export {default as ManagedDataInspector} from './ui/components/data-inspector/ManagedDataInspector';
|
||||
export {default as SearchableDataInspector} from './ui/components/data-inspector/SearchableDataInspector';
|
||||
export {
|
||||
_DataDescription as DataDescription,
|
||||
DataDescriptionType,
|
||||
DataDescription,
|
||||
DataInspector,
|
||||
MarkerTimeline,
|
||||
} from 'flipper-plugin';
|
||||
export {_HighlightManager as HighlightManager} from 'flipper-plugin';
|
||||
export {DataInspector as ManagedDataInspector} from 'flipper-plugin';
|
||||
export {default as SearchableDataInspector} from './ui/components/data-inspector/SearchableDataInspector';
|
||||
export {HighlightManager} from 'flipper-plugin';
|
||||
export {default as Tabs} from './ui/components/Tabs';
|
||||
export {default as Tab} from './ui/components/Tab';
|
||||
export {default as Input} from './ui/components/Input';
|
||||
@@ -160,7 +159,6 @@ export {default as VerticalRule} from './ui/components/VerticalRule';
|
||||
export {default as Label} from './ui/components/Label';
|
||||
export {default as Heading} from './ui/components/Heading';
|
||||
export {Filter} from './ui/components/filter/types';
|
||||
export {default as MarkerTimeline} from './ui/components/MarkerTimeline';
|
||||
export {default as StackTrace} from './ui/components/StackTrace';
|
||||
export {
|
||||
SearchBox,
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
* @format
|
||||
*/
|
||||
|
||||
import ManagedDataInspector from '../ui/components/data-inspector/ManagedDataInspector';
|
||||
import {DataInspector} from 'flipper-plugin';
|
||||
import Panel from '../ui/components/Panel';
|
||||
import {colors} from '../ui/components/colors';
|
||||
import styled from '@emotion/styled';
|
||||
@@ -232,7 +232,7 @@ function renderSidebarSection(
|
||||
case 'json':
|
||||
return (
|
||||
<Panel floating={false} heading={section.title} key={index}>
|
||||
<ManagedDataInspector data={section.content} expandRoot={true} />
|
||||
<DataInspector data={section.content} expandRoot={true} />
|
||||
</Panel>
|
||||
);
|
||||
case 'toolbar':
|
||||
@@ -240,7 +240,7 @@ function renderSidebarSection(
|
||||
default:
|
||||
return (
|
||||
<Panel floating={false} heading={'Details'} key={index}>
|
||||
<ManagedDataInspector data={section} expandRoot={true} />
|
||||
<DataInspector data={section} expandRoot={true} />
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -39,6 +39,7 @@ type Props<C> = {
|
||||
* to show menu items.
|
||||
*
|
||||
* Separators can be added by `{type: 'separator'}`
|
||||
* @depreacted https://ant.design/components/dropdown/#components-dropdown-demo-context-menu
|
||||
*/
|
||||
export default forwardRef(function ContextMenu<C>(
|
||||
{items, buildItems, component, children, ...otherProps}: Props<C>,
|
||||
|
||||
@@ -28,6 +28,7 @@ export const ContextMenuContext = createContext<ContextMenuManager | undefined>(
|
||||
/**
|
||||
* Flipper's root is already wrapped with this component, so plugins should not
|
||||
* need to use this. ContextMenu is what you probably want to use.
|
||||
* @deprecated use https://ant.design/components/dropdown/#components-dropdown-demo-context-menu
|
||||
*/
|
||||
const ContextMenuProvider: React.FC<{}> = memo(function ContextMenuProvider({
|
||||
children,
|
||||
|
||||
@@ -1,242 +0,0 @@
|
||||
/**
|
||||
* 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 {Component} from 'react';
|
||||
import styled from '@emotion/styled';
|
||||
import Text from './Text';
|
||||
import FlexRow from './FlexRow';
|
||||
import {colors} from './colors';
|
||||
import React from 'react';
|
||||
|
||||
type DataPoint = {
|
||||
time: number;
|
||||
color?: string;
|
||||
label: string;
|
||||
key: string;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
onClick?: (keys: Array<string>) => void;
|
||||
selected?: string | null | undefined;
|
||||
points: DataPoint[];
|
||||
lineHeight: number;
|
||||
maxGap: number;
|
||||
};
|
||||
|
||||
type MouseEventHandler = (
|
||||
event: React.MouseEvent<HTMLDivElement, MouseEvent>,
|
||||
) => void;
|
||||
|
||||
const Markers = styled.div<{totalTime: number}>((props) => ({
|
||||
position: 'relative',
|
||||
margin: 10,
|
||||
height: props.totalTime,
|
||||
'::before': {
|
||||
content: '""',
|
||||
width: 1,
|
||||
borderLeft: `1px dotted ${colors.light30}`,
|
||||
position: 'absolute',
|
||||
top: 5,
|
||||
bottom: 20,
|
||||
left: 5,
|
||||
},
|
||||
}));
|
||||
Markers.displayName = 'MarkerTimeline:Markers';
|
||||
|
||||
const Point = styled(FlexRow)<{
|
||||
positionY: number;
|
||||
onClick: MouseEventHandler | undefined;
|
||||
number: number | undefined;
|
||||
threadColor: string;
|
||||
selected: boolean;
|
||||
cut: boolean;
|
||||
}>((props) => ({
|
||||
position: 'absolute',
|
||||
top: props.positionY,
|
||||
left: 0,
|
||||
right: 10,
|
||||
cursor: props.onClick ? 'pointer' : 'default',
|
||||
borderRadius: 3,
|
||||
alignItems: 'flex-start',
|
||||
lineHeight: '16px',
|
||||
':hover': {
|
||||
background: `linear-gradient(to top, rgba(255,255,255,0) 0, #ffffff 10px)`,
|
||||
paddingBottom: 5,
|
||||
zIndex: 2,
|
||||
'> span': {
|
||||
whiteSpace: 'initial',
|
||||
},
|
||||
},
|
||||
'::before': {
|
||||
position: 'relative',
|
||||
textAlign: 'center',
|
||||
fontSize: 8,
|
||||
fontWeight: 500,
|
||||
content: props.number ? `'${props.number}'` : '""',
|
||||
display: 'inline-block',
|
||||
width: 9,
|
||||
height: 9,
|
||||
flexShrink: 0,
|
||||
color: 'rgba(0,0,0,0.4)',
|
||||
lineHeight: '9px',
|
||||
borderRadius: '999em',
|
||||
border: '1px solid rgba(0,0,0,0.2)',
|
||||
backgroundColor: props.threadColor,
|
||||
marginRight: 6,
|
||||
zIndex: 3,
|
||||
boxShadow: props.selected
|
||||
? `0 0 0 2px ${colors.macOSTitleBarIconSelected}`
|
||||
: undefined,
|
||||
},
|
||||
'::after': {
|
||||
content: props.cut ? '""' : undefined,
|
||||
position: 'absolute',
|
||||
width: 11,
|
||||
top: -20,
|
||||
left: 0,
|
||||
height: 2,
|
||||
background: colors.white,
|
||||
borderTop: `1px solid ${colors.light30}`,
|
||||
borderBottom: `1px solid ${colors.light30}`,
|
||||
transform: `skewY(-10deg)`,
|
||||
},
|
||||
}));
|
||||
Point.displayName = 'MakerTimeline:Point';
|
||||
|
||||
const Time = styled.span({
|
||||
color: colors.light30,
|
||||
fontWeight: 300,
|
||||
marginRight: 4,
|
||||
marginTop: -2,
|
||||
});
|
||||
Time.displayName = 'MakerTimeline:Time';
|
||||
|
||||
const Code = styled(Text)({
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
marginTop: -1,
|
||||
});
|
||||
Code.displayName = 'MakerTimeline:Code';
|
||||
|
||||
type TimePoint = {
|
||||
timestamp: number;
|
||||
markerNames: Array<string>;
|
||||
markerKeys: Array<string>;
|
||||
isCut: boolean;
|
||||
positionY: number;
|
||||
color: string;
|
||||
};
|
||||
|
||||
type State = {
|
||||
timePoints: Array<TimePoint>;
|
||||
};
|
||||
|
||||
export default class MarkerTimeline extends Component<Props, State> {
|
||||
static defaultProps = {
|
||||
lineHeight: 22,
|
||||
maxGap: 100,
|
||||
};
|
||||
|
||||
static getDerivedStateFromProps(props: Props) {
|
||||
const sortedMarkers: [number, DataPoint[]][] = Array.from(
|
||||
props.points
|
||||
.reduce((acc: Map<number, DataPoint[]>, cv: DataPoint) => {
|
||||
const list = acc.get(cv.time);
|
||||
if (list) {
|
||||
list.push(cv);
|
||||
} else {
|
||||
acc.set(cv.time, [cv]);
|
||||
}
|
||||
return acc;
|
||||
}, new Map())
|
||||
.entries(),
|
||||
).sort((a, b) => a[0] - b[0]);
|
||||
|
||||
const smallestGap = sortedMarkers.reduce((acc, cv, i, arr) => {
|
||||
if (i > 0) {
|
||||
return Math.min(acc, cv[0] - arr[i - 1][0]);
|
||||
} else {
|
||||
return acc;
|
||||
}
|
||||
}, Infinity);
|
||||
|
||||
let positionY = 0;
|
||||
const timePoints: Array<TimePoint> = [];
|
||||
|
||||
for (let i = 0; i < sortedMarkers.length; i++) {
|
||||
const [timestamp, points] = sortedMarkers[i];
|
||||
let isCut = false;
|
||||
const color = sortedMarkers[i][1][0].color || colors.white;
|
||||
|
||||
if (i > 0) {
|
||||
const relativeTimestamp = timestamp - sortedMarkers[i - 1][0];
|
||||
const gap = (relativeTimestamp / smallestGap) * props.lineHeight;
|
||||
if (gap > props.maxGap) {
|
||||
positionY += props.maxGap;
|
||||
isCut = true;
|
||||
} else {
|
||||
positionY += gap;
|
||||
}
|
||||
}
|
||||
|
||||
timePoints.push({
|
||||
timestamp,
|
||||
markerNames: points.map((p) => p.label),
|
||||
markerKeys: points.map((p) => p.key),
|
||||
positionY,
|
||||
isCut,
|
||||
color,
|
||||
});
|
||||
}
|
||||
|
||||
return {timePoints};
|
||||
}
|
||||
|
||||
state: State = {
|
||||
timePoints: [],
|
||||
};
|
||||
|
||||
render() {
|
||||
const {timePoints} = this.state;
|
||||
const {onClick} = this.props;
|
||||
|
||||
if (!this.props.points || this.props.points.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Markers
|
||||
totalTime={
|
||||
timePoints[timePoints.length - 1].positionY + this.props.lineHeight
|
||||
}>
|
||||
{timePoints.map((p: TimePoint, i: number) => {
|
||||
return (
|
||||
<Point
|
||||
key={i}
|
||||
threadColor={p.color}
|
||||
cut={p.isCut}
|
||||
positionY={p.positionY}
|
||||
onClick={onClick ? () => onClick(p.markerKeys) : undefined}
|
||||
selected={
|
||||
this.props.selected
|
||||
? p.markerKeys.includes(this.props.selected)
|
||||
: false
|
||||
}
|
||||
number={
|
||||
p.markerNames.length > 1 ? p.markerNames.length : undefined
|
||||
}>
|
||||
<Time>{p.timestamp}ms</Time>{' '}
|
||||
<Code code>{p.markerNames.join(', ')}</Code>
|
||||
</Point>
|
||||
);
|
||||
})}
|
||||
</Markers>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -65,6 +65,9 @@ type Props = {
|
||||
forceOpts?: Opts;
|
||||
};
|
||||
|
||||
/**
|
||||
* @deprecated use Popover from antd
|
||||
*/
|
||||
export default class Popover extends PureComponent<Props> {
|
||||
_ref?: Element | null;
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ import {PopoverContext} from './PopoverProvider';
|
||||
* UI framework.
|
||||
* I don't recommend using this, as it will likely be removed in future.
|
||||
* Must be nested under a PopoverProvider at some level, usually it is at the top level app so you shouldn't need to add it.
|
||||
* @deprecated use Popover from Antd
|
||||
*/
|
||||
export default function Popover2(props: {
|
||||
id: string;
|
||||
|
||||
@@ -1,89 +0,0 @@
|
||||
/**
|
||||
* 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 MarkerTimeline from '../MarkerTimeline';
|
||||
|
||||
test('merges points with same timestamp', () => {
|
||||
const points = [
|
||||
{key: 'marker1', label: 'marker1', time: 41},
|
||||
{key: 'marker2', label: 'marker2', time: 41},
|
||||
];
|
||||
|
||||
const {timePoints} = MarkerTimeline.getDerivedStateFromProps({
|
||||
lineHeight: 22,
|
||||
maxGap: 100,
|
||||
points,
|
||||
});
|
||||
expect(timePoints[0].markerNames).toContain('marker1');
|
||||
expect(timePoints[0].markerNames).toContain('marker2');
|
||||
});
|
||||
|
||||
test('sorts points', () => {
|
||||
const {timePoints} = MarkerTimeline.getDerivedStateFromProps({
|
||||
lineHeight: 22,
|
||||
maxGap: 100,
|
||||
points: [
|
||||
{key: 'marker1', label: 'marker1', time: 20},
|
||||
{key: 'marker2', label: 'marker2', time: -50},
|
||||
],
|
||||
});
|
||||
expect(timePoints[0].timestamp).toBe(-50);
|
||||
expect(timePoints[1].timestamp).toBe(20);
|
||||
});
|
||||
|
||||
test('handles negative timestamps', () => {
|
||||
const points = [{label: 'preStartPoint', key: 'preStartPoint', time: -50}];
|
||||
|
||||
const {timePoints} = MarkerTimeline.getDerivedStateFromProps({
|
||||
lineHeight: 22,
|
||||
maxGap: 100,
|
||||
points,
|
||||
});
|
||||
expect(timePoints[0].timestamp).toBe(-50);
|
||||
});
|
||||
|
||||
test('no points', () => {
|
||||
const {timePoints} = MarkerTimeline.getDerivedStateFromProps({
|
||||
lineHeight: 22,
|
||||
maxGap: 100,
|
||||
points: [],
|
||||
});
|
||||
expect(timePoints).toMatchSnapshot();
|
||||
});
|
||||
|
||||
test('handles single point', () => {
|
||||
const points = [{key: '1', label: 'single point', time: 0}];
|
||||
|
||||
const {timePoints} = MarkerTimeline.getDerivedStateFromProps({
|
||||
lineHeight: 22,
|
||||
maxGap: 100,
|
||||
points,
|
||||
});
|
||||
expect(timePoints).toMatchSnapshot();
|
||||
});
|
||||
|
||||
test('cuts long gaps', () => {
|
||||
const points = [
|
||||
{key: '1', label: 'single point', time: 1},
|
||||
{key: '2', label: 'single point', time: 1000},
|
||||
{key: '3', label: 'single point', time: 1001},
|
||||
];
|
||||
|
||||
const MAX_GAP = 100;
|
||||
|
||||
const {timePoints} = MarkerTimeline.getDerivedStateFromProps({
|
||||
lineHeight: 22,
|
||||
maxGap: MAX_GAP,
|
||||
points,
|
||||
});
|
||||
|
||||
expect(timePoints[0].isCut).toBe(false);
|
||||
expect(timePoints[1].isCut).toBe(true);
|
||||
expect(timePoints[1].positionY).toBe(timePoints[0].positionY + MAX_GAP);
|
||||
});
|
||||
@@ -1,20 +0,0 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`handles single point 1`] = `
|
||||
Array [
|
||||
Object {
|
||||
"color": "#ffffff",
|
||||
"isCut": false,
|
||||
"markerKeys": Array [
|
||||
"1",
|
||||
],
|
||||
"markerNames": Array [
|
||||
"single point",
|
||||
],
|
||||
"positionY": 0,
|
||||
"timestamp": 0,
|
||||
},
|
||||
]
|
||||
`;
|
||||
|
||||
exports[`no points 1`] = `Array []`;
|
||||
@@ -13,7 +13,7 @@ import {colors} from './colors';
|
||||
import ManagedTable from './table/ManagedTable';
|
||||
import FlexColumn from './FlexColumn';
|
||||
import Text from './Text';
|
||||
import ManagedDataInspector from './data-inspector/ManagedDataInspector';
|
||||
import {DataInspector} from 'flipper-plugin';
|
||||
import Input from './Input';
|
||||
import View from './View';
|
||||
import styled from '@emotion/styled';
|
||||
@@ -160,7 +160,7 @@ export class Console extends Component<Props, State> {
|
||||
columns: {
|
||||
command: {
|
||||
value: result.isSuccess ? (
|
||||
<ManagedDataInspector
|
||||
<DataInspector
|
||||
data={result.value}
|
||||
expandRoot={true}
|
||||
collapsed={true}
|
||||
|
||||
@@ -1,742 +0,0 @@
|
||||
/**
|
||||
* 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 {_DataDescription} from 'flipper-plugin';
|
||||
import {MenuTemplate} from '../ContextMenu';
|
||||
import {memo, useMemo, useRef, useState, useEffect, useCallback} from 'react';
|
||||
import ContextMenu from '../ContextMenu';
|
||||
import Tooltip from '../Tooltip';
|
||||
import styled from '@emotion/styled';
|
||||
import createPaste from '../../../fb-stubs/createPaste';
|
||||
import {reportInteraction} from '../../../utils/InteractionTracker';
|
||||
import DataPreview, {DataValueExtractor, InspectorName} from './DataPreview';
|
||||
import {getSortedKeys} from './utils';
|
||||
import {colors} from '../colors';
|
||||
import {clipboard} from 'electron';
|
||||
import React from 'react';
|
||||
import {TooltipOptions} from '../TooltipProvider';
|
||||
import {
|
||||
_useHighlighter as useHighlighter,
|
||||
_HighlightManager,
|
||||
} from 'flipper-plugin';
|
||||
|
||||
export {DataValueExtractor} from './DataPreview';
|
||||
|
||||
const BaseContainer = styled.div<{depth?: number; disabled?: boolean}>(
|
||||
(props) => ({
|
||||
fontFamily: 'Menlo, monospace',
|
||||
fontSize: 11,
|
||||
lineHeight: '17px',
|
||||
filter: props.disabled ? 'grayscale(100%)' : '',
|
||||
margin: props.depth === 0 ? '7.5px 0' : '0',
|
||||
paddingLeft: 10,
|
||||
userSelect: 'text',
|
||||
}),
|
||||
);
|
||||
BaseContainer.displayName = 'DataInspector:BaseContainer';
|
||||
|
||||
const RecursiveBaseWrapper = styled.span({
|
||||
color: colors.red,
|
||||
});
|
||||
RecursiveBaseWrapper.displayName = 'DataInspector:RecursiveBaseWrapper';
|
||||
|
||||
const Wrapper = styled.span({
|
||||
color: '#555',
|
||||
});
|
||||
Wrapper.displayName = 'DataInspector:Wrapper';
|
||||
|
||||
const PropertyContainer = styled.span({
|
||||
paddingTop: '2px',
|
||||
});
|
||||
PropertyContainer.displayName = 'DataInspector:PropertyContainer';
|
||||
|
||||
const ExpandControl = styled.span({
|
||||
color: '#6e6e6e',
|
||||
fontSize: 10,
|
||||
marginLeft: -11,
|
||||
marginRight: 5,
|
||||
whiteSpace: 'pre',
|
||||
});
|
||||
ExpandControl.displayName = 'DataInspector:ExpandControl';
|
||||
|
||||
const Added = styled.div({
|
||||
backgroundColor: colors.tealTint70,
|
||||
});
|
||||
|
||||
const Removed = styled.div({
|
||||
backgroundColor: colors.cherryTint70,
|
||||
});
|
||||
|
||||
const nameTooltipOptions: TooltipOptions = {
|
||||
position: 'toLeft',
|
||||
showTail: true,
|
||||
};
|
||||
|
||||
export type DataInspectorSetValue = (path: Array<string>, val: any) => void;
|
||||
|
||||
export type DataInspectorDeleteValue = (path: Array<string>) => void;
|
||||
|
||||
export type DataInspectorExpanded = {
|
||||
[key: string]: boolean;
|
||||
};
|
||||
|
||||
export type DiffMetadataExtractor = (
|
||||
data: any,
|
||||
diff: any,
|
||||
key: string,
|
||||
) => Array<{
|
||||
data: any;
|
||||
diff?: any;
|
||||
status?: 'added' | 'removed';
|
||||
}>;
|
||||
|
||||
type DataInspectorProps = {
|
||||
/**
|
||||
* Object to inspect.
|
||||
*/
|
||||
data: any;
|
||||
/**
|
||||
* Object to compare with the provided `data` property.
|
||||
* Differences will be styled accordingly in the UI.
|
||||
*/
|
||||
diff?: any;
|
||||
/**
|
||||
* Current name of this value.
|
||||
*/
|
||||
name?: string;
|
||||
/**
|
||||
* Current depth.
|
||||
*/
|
||||
depth: number;
|
||||
/**
|
||||
* An array containing the current location of the data relative to its root.
|
||||
*/
|
||||
parentPath: Array<string>;
|
||||
/**
|
||||
* Whether to expand the root by default.
|
||||
*/
|
||||
expandRoot?: boolean;
|
||||
/**
|
||||
* An array of paths that are currently expanded.
|
||||
*/
|
||||
expanded: DataInspectorExpanded;
|
||||
/**
|
||||
* An optional callback that will explode a value into its type and value.
|
||||
* Useful for inspecting serialised data.
|
||||
*/
|
||||
extractValue?: DataValueExtractor;
|
||||
/**
|
||||
* Callback whenever the current expanded paths is changed.
|
||||
*/
|
||||
onExpanded?: ((path: string, expanded: boolean) => void) | undefined | null;
|
||||
/**
|
||||
* Callback whenever delete action is invoked on current path.
|
||||
*/
|
||||
onDelete?: DataInspectorDeleteValue | undefined | null;
|
||||
/**
|
||||
* Render callback that can be used to customize the rendering of object keys.
|
||||
*/
|
||||
onRenderName?: (
|
||||
path: Array<string>,
|
||||
name: string,
|
||||
highlighter: _HighlightManager,
|
||||
) => React.ReactElement;
|
||||
/**
|
||||
* Render callback that can be used to customize the rendering of object values.
|
||||
*/
|
||||
onRenderDescription?: (description: React.ReactElement) => React.ReactElement;
|
||||
/**
|
||||
* Callback when a value is edited.
|
||||
*/
|
||||
setValue?: DataInspectorSetValue | undefined | null;
|
||||
/**
|
||||
* Whether all objects and arrays should be collapsed by default.
|
||||
*/
|
||||
collapsed?: boolean;
|
||||
/**
|
||||
* Ancestry of parent objects, used to avoid recursive objects.
|
||||
*/
|
||||
parentAncestry: Array<Object>;
|
||||
/**
|
||||
* Object of properties that will have tooltips
|
||||
*/
|
||||
tooltips?: any;
|
||||
};
|
||||
|
||||
const defaultValueExtractor: DataValueExtractor = (value: any) => {
|
||||
const type = typeof value;
|
||||
|
||||
if (type === 'number') {
|
||||
return {mutable: true, type: 'number', value};
|
||||
}
|
||||
|
||||
if (type === 'string') {
|
||||
return {mutable: true, type: 'string', value};
|
||||
}
|
||||
|
||||
if (type === 'boolean') {
|
||||
return {mutable: true, type: 'boolean', value};
|
||||
}
|
||||
|
||||
if (type === 'undefined') {
|
||||
return {mutable: true, type: 'undefined', value};
|
||||
}
|
||||
|
||||
if (value === null) {
|
||||
return {mutable: true, type: 'null', value};
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
return {mutable: true, type: 'array', value};
|
||||
}
|
||||
|
||||
if (Object.prototype.toString.call(value) === '[object Date]') {
|
||||
return {mutable: true, type: 'date', value};
|
||||
}
|
||||
|
||||
if (type === 'object') {
|
||||
return {mutable: true, type: 'object', value};
|
||||
}
|
||||
};
|
||||
|
||||
const rootContextMenuCache: WeakMap<
|
||||
Object,
|
||||
Array<Electron.MenuItemConstructorOptions>
|
||||
> = new WeakMap();
|
||||
|
||||
function getRootContextMenu(
|
||||
data: Object,
|
||||
): Array<Electron.MenuItemConstructorOptions> {
|
||||
const cached = rootContextMenuCache.get(data);
|
||||
if (cached != null) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
let stringValue: string;
|
||||
try {
|
||||
stringValue = JSON.stringify(data, null, 2);
|
||||
} catch (e) {
|
||||
stringValue = '<circular structure>';
|
||||
}
|
||||
const menu: Array<Electron.MenuItemConstructorOptions> = [
|
||||
{
|
||||
label: 'Copy entire tree',
|
||||
click: () => clipboard.writeText(stringValue),
|
||||
},
|
||||
{
|
||||
type: 'separator',
|
||||
},
|
||||
{
|
||||
label: 'Create paste',
|
||||
click: () => {
|
||||
createPaste(stringValue);
|
||||
},
|
||||
},
|
||||
];
|
||||
if (typeof data === 'object' && data !== null) {
|
||||
rootContextMenuCache.set(data, menu);
|
||||
} else {
|
||||
console.error(
|
||||
'[data-inspector] Ignoring unsupported data type for cache: ',
|
||||
data,
|
||||
typeof data,
|
||||
);
|
||||
}
|
||||
return menu;
|
||||
}
|
||||
|
||||
function isPureObject(obj: Object) {
|
||||
return (
|
||||
obj !== null &&
|
||||
Object.prototype.toString.call(obj) !== '[object Date]' &&
|
||||
typeof obj === 'object'
|
||||
);
|
||||
}
|
||||
|
||||
const diffMetadataExtractor: DiffMetadataExtractor = (
|
||||
data: any,
|
||||
key: string,
|
||||
diff?: any,
|
||||
) => {
|
||||
if (diff == null) {
|
||||
return [{data: data[key]}];
|
||||
}
|
||||
|
||||
const val = data[key];
|
||||
const diffVal = diff[key];
|
||||
if (!data.hasOwnProperty(key)) {
|
||||
return [{data: diffVal, status: 'removed'}];
|
||||
}
|
||||
if (!diff.hasOwnProperty(key)) {
|
||||
return [{data: val, status: 'added'}];
|
||||
}
|
||||
|
||||
if (isPureObject(diffVal) && isPureObject(val)) {
|
||||
return [{data: val, diff: diffVal}];
|
||||
}
|
||||
|
||||
if (diffVal !== val) {
|
||||
// Check if there's a difference between the original value and
|
||||
// the value from the diff prop
|
||||
// The property name still exists, but the values may be different.
|
||||
return [
|
||||
{data: val, status: 'added'},
|
||||
{data: diffVal, status: 'removed'},
|
||||
];
|
||||
}
|
||||
|
||||
return Object.prototype.hasOwnProperty.call(data, key) ? [{data: val}] : [];
|
||||
};
|
||||
|
||||
function isComponentExpanded(data: any, diffType: string, diffValue: any) {
|
||||
if (diffValue == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (diffType === 'object') {
|
||||
const sortedDataValues = Object.keys(data)
|
||||
.sort()
|
||||
.map((key) => data[key]);
|
||||
const sortedDiffValues = Object.keys(diffValue)
|
||||
.sort()
|
||||
.map((key) => diffValue[key]);
|
||||
if (JSON.stringify(sortedDataValues) !== JSON.stringify(sortedDiffValues)) {
|
||||
return true;
|
||||
}
|
||||
} else {
|
||||
if (data !== diffValue) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
const recursiveMarker = <RecursiveBaseWrapper>Recursive</RecursiveBaseWrapper>;
|
||||
|
||||
/**
|
||||
* An expandable data inspector.
|
||||
*
|
||||
* This component is fairly low level. It's likely you're looking for
|
||||
* [`<ManagedDataInspector>`](#manageddatainspector).
|
||||
*/
|
||||
const DataInspector: React.FC<DataInspectorProps> = memo(
|
||||
function DataInspectorImpl({
|
||||
data,
|
||||
depth,
|
||||
diff,
|
||||
expandRoot,
|
||||
parentPath,
|
||||
onExpanded,
|
||||
onDelete,
|
||||
onRenderName,
|
||||
onRenderDescription,
|
||||
extractValue: extractValueProp,
|
||||
expanded: expandedPaths,
|
||||
name,
|
||||
parentAncestry,
|
||||
collapsed,
|
||||
tooltips,
|
||||
setValue: setValueProp,
|
||||
}) {
|
||||
const highlighter = useHighlighter();
|
||||
|
||||
const shouldExpand = useRef(false);
|
||||
const expandHandle = useRef(undefined as any);
|
||||
const [renderExpanded, setRenderExpanded] = useState(false);
|
||||
const path = useMemo(
|
||||
() => (name === undefined ? parentPath : parentPath.concat([name])),
|
||||
[parentPath, name],
|
||||
);
|
||||
|
||||
const extractValue = useCallback(
|
||||
(data: any, depth: number, path: string[]) => {
|
||||
let res;
|
||||
if (extractValueProp) {
|
||||
res = extractValueProp(data, depth, path);
|
||||
}
|
||||
if (!res) {
|
||||
res = defaultValueExtractor(data, depth, path);
|
||||
}
|
||||
return res;
|
||||
},
|
||||
[extractValueProp],
|
||||
);
|
||||
|
||||
const res = useMemo(() => extractValue(data, depth, path), [
|
||||
extractValue,
|
||||
data,
|
||||
depth,
|
||||
path,
|
||||
]);
|
||||
const resDiff = useMemo(() => extractValue(diff, depth, path), [
|
||||
extractValue,
|
||||
diff,
|
||||
depth,
|
||||
path,
|
||||
]);
|
||||
const ancestry = useMemo(
|
||||
() => (res ? parentAncestry!.concat([res.value]) : []),
|
||||
[parentAncestry, res?.value],
|
||||
);
|
||||
|
||||
let isExpandable = false;
|
||||
if (!res) {
|
||||
shouldExpand.current = false;
|
||||
} else {
|
||||
isExpandable = isValueExpandable(res.value);
|
||||
}
|
||||
|
||||
if (isExpandable) {
|
||||
if (
|
||||
expandRoot === true ||
|
||||
shouldBeExpanded(expandedPaths, path, collapsed)
|
||||
) {
|
||||
shouldExpand.current = true;
|
||||
} else if (resDiff) {
|
||||
shouldExpand.current = isComponentExpanded(
|
||||
res!.value,
|
||||
resDiff.type,
|
||||
resDiff.value,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!shouldExpand.current) {
|
||||
setRenderExpanded(false);
|
||||
} else {
|
||||
expandHandle.current = requestIdleCallback(() => {
|
||||
setRenderExpanded(true);
|
||||
});
|
||||
}
|
||||
return () => {
|
||||
cancelIdleCallback(expandHandle.current);
|
||||
};
|
||||
}, [shouldExpand.current]);
|
||||
|
||||
const setExpanded = useCallback(
|
||||
(pathParts: Array<string>, isExpanded: boolean) => {
|
||||
if (!onExpanded || !expandedPaths) {
|
||||
return;
|
||||
}
|
||||
const path = pathParts.join('.');
|
||||
onExpanded(path, isExpanded);
|
||||
},
|
||||
[onExpanded, expandedPaths],
|
||||
);
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
cancelIdleCallback(expandHandle.current);
|
||||
const isExpanded = shouldBeExpanded(expandedPaths, path, collapsed);
|
||||
reportInteraction('DataInspector', path.join(':'))(
|
||||
isExpanded ? 'collapsed' : 'expanded',
|
||||
undefined,
|
||||
);
|
||||
setExpanded(path, !isExpanded);
|
||||
}, [expandedPaths, path, collapsed]);
|
||||
|
||||
const handleDelete = useCallback(
|
||||
(path: Array<string>) => {
|
||||
if (!onDelete) {
|
||||
return;
|
||||
}
|
||||
onDelete(path);
|
||||
},
|
||||
[onDelete],
|
||||
);
|
||||
|
||||
/**
|
||||
* RENDERING
|
||||
*/
|
||||
if (!res) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// the data inspector makes values read only when setValue isn't set so we just need to set it
|
||||
// to null and the readOnly status will be propagated to all children
|
||||
const setValue = res.mutable ? setValueProp : null;
|
||||
const {value, type, extra} = res;
|
||||
|
||||
if (parentAncestry!.includes(value)) {
|
||||
return recursiveMarker;
|
||||
}
|
||||
|
||||
let expandGlyph = '';
|
||||
if (isExpandable) {
|
||||
if (shouldExpand.current) {
|
||||
expandGlyph = '▼';
|
||||
} else {
|
||||
expandGlyph = '▶';
|
||||
}
|
||||
} else {
|
||||
if (depth !== 0) {
|
||||
expandGlyph = ' ';
|
||||
}
|
||||
}
|
||||
|
||||
let propertyNodesContainer = null;
|
||||
if (isExpandable && renderExpanded) {
|
||||
const propertyNodes = [];
|
||||
|
||||
const diffValue = diff && resDiff ? resDiff.value : null;
|
||||
|
||||
const keys = getSortedKeys({...value, ...diffValue});
|
||||
|
||||
for (const key of keys) {
|
||||
const diffMetadataArr = diffMetadataExtractor(value, key, diffValue);
|
||||
for (const [index, metadata] of diffMetadataArr.entries()) {
|
||||
const metaKey = key + index;
|
||||
const dataInspectorNode = (
|
||||
<DataInspector
|
||||
parentAncestry={ancestry}
|
||||
extractValue={extractValue}
|
||||
setValue={setValue}
|
||||
expanded={expandedPaths}
|
||||
collapsed={collapsed}
|
||||
onExpanded={onExpanded}
|
||||
onDelete={onDelete}
|
||||
onRenderName={onRenderName}
|
||||
onRenderDescription={onRenderDescription}
|
||||
parentPath={path}
|
||||
depth={depth + 1}
|
||||
key={metaKey}
|
||||
name={key}
|
||||
data={metadata.data}
|
||||
diff={metadata.diff}
|
||||
tooltips={tooltips}
|
||||
/>
|
||||
);
|
||||
|
||||
switch (metadata.status) {
|
||||
case 'added':
|
||||
propertyNodes.push(
|
||||
<Added key={metaKey}>{dataInspectorNode}</Added>,
|
||||
);
|
||||
break;
|
||||
case 'removed':
|
||||
propertyNodes.push(
|
||||
<Removed key={metaKey}>{dataInspectorNode}</Removed>,
|
||||
);
|
||||
break;
|
||||
default:
|
||||
propertyNodes.push(dataInspectorNode);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
propertyNodesContainer = propertyNodes;
|
||||
}
|
||||
|
||||
if (expandRoot === true) {
|
||||
return (
|
||||
<ContextMenu component="span" items={getRootContextMenu(data)}>
|
||||
{propertyNodesContainer}
|
||||
</ContextMenu>
|
||||
);
|
||||
}
|
||||
|
||||
// create name components
|
||||
const nameElems = [];
|
||||
if (typeof name !== 'undefined') {
|
||||
const text = onRenderName
|
||||
? onRenderName(path, name, highlighter)
|
||||
: highlighter.render(name);
|
||||
nameElems.push(
|
||||
<Tooltip
|
||||
title={tooltips != null && tooltips[name]}
|
||||
key="name"
|
||||
options={nameTooltipOptions}>
|
||||
<InspectorName>{text}</InspectorName>
|
||||
</Tooltip>,
|
||||
);
|
||||
nameElems.push(<span key="sep">: </span>);
|
||||
}
|
||||
|
||||
// create description or preview
|
||||
let descriptionOrPreview;
|
||||
if (renderExpanded || !isExpandable) {
|
||||
descriptionOrPreview = (
|
||||
<_DataDescription
|
||||
path={path}
|
||||
setValue={setValue}
|
||||
type={type}
|
||||
value={value}
|
||||
extra={extra}
|
||||
/>
|
||||
);
|
||||
|
||||
descriptionOrPreview = onRenderDescription
|
||||
? onRenderDescription(descriptionOrPreview)
|
||||
: descriptionOrPreview;
|
||||
} else {
|
||||
descriptionOrPreview = (
|
||||
<DataPreview
|
||||
path={path}
|
||||
type={type}
|
||||
value={value}
|
||||
extractValue={extractValue}
|
||||
depth={depth}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
descriptionOrPreview = (
|
||||
<span>
|
||||
{nameElems}
|
||||
{descriptionOrPreview}
|
||||
</span>
|
||||
);
|
||||
|
||||
let wrapperStart;
|
||||
let wrapperEnd;
|
||||
if (renderExpanded) {
|
||||
if (type === 'object') {
|
||||
wrapperStart = <Wrapper>{'{'}</Wrapper>;
|
||||
wrapperEnd = <Wrapper>{'}'}</Wrapper>;
|
||||
}
|
||||
|
||||
if (type === 'array') {
|
||||
wrapperStart = <Wrapper>{'['}</Wrapper>;
|
||||
wrapperEnd = <Wrapper>{']'}</Wrapper>;
|
||||
}
|
||||
}
|
||||
|
||||
const contextMenuItems: MenuTemplate = [];
|
||||
|
||||
if (isExpandable) {
|
||||
contextMenuItems.push(
|
||||
{
|
||||
label: shouldExpand.current ? 'Collapse' : 'Expand',
|
||||
click: handleClick,
|
||||
},
|
||||
{
|
||||
type: 'separator',
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
contextMenuItems.push(
|
||||
{
|
||||
label: 'Copy',
|
||||
click: () =>
|
||||
clipboard.writeText((window.getSelection() || '').toString()),
|
||||
},
|
||||
{
|
||||
label: 'Copy value',
|
||||
click: () => clipboard.writeText(JSON.stringify(data, null, 2)),
|
||||
},
|
||||
);
|
||||
|
||||
if (!isExpandable && onDelete) {
|
||||
contextMenuItems.push({
|
||||
label: 'Delete',
|
||||
click: () => handleDelete(path),
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<BaseContainer
|
||||
depth={depth}
|
||||
disabled={!!setValueProp && !!setValue === false}>
|
||||
<ContextMenu component="span" items={contextMenuItems}>
|
||||
<PropertyContainer onClick={isExpandable ? handleClick : undefined}>
|
||||
{expandedPaths && <ExpandControl>{expandGlyph}</ExpandControl>}
|
||||
{descriptionOrPreview}
|
||||
{wrapperStart}
|
||||
</PropertyContainer>
|
||||
</ContextMenu>
|
||||
{propertyNodesContainer}
|
||||
{wrapperEnd}
|
||||
</BaseContainer>
|
||||
);
|
||||
},
|
||||
dataInspectorPropsAreEqual,
|
||||
);
|
||||
|
||||
function shouldBeExpanded(
|
||||
expanded: DataInspectorExpanded,
|
||||
pathParts: Array<string>,
|
||||
collapsed?: boolean,
|
||||
) {
|
||||
// if we have no expanded object then expand everything
|
||||
if (expanded == null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const path = pathParts.join('.');
|
||||
|
||||
// check if there's a setting for this path
|
||||
if (Object.prototype.hasOwnProperty.call(expanded, path)) {
|
||||
return expanded[path];
|
||||
}
|
||||
|
||||
// check if all paths are collapsed
|
||||
if (collapsed === true) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// by default all items are expanded
|
||||
return true;
|
||||
}
|
||||
|
||||
function dataInspectorPropsAreEqual(
|
||||
props: DataInspectorProps,
|
||||
nextProps: DataInspectorProps,
|
||||
) {
|
||||
// Optimization: it would be much faster to not pass the expanded tree
|
||||
// down the tree, but rather introduce an ExpandStateManager, and subscribe per node
|
||||
|
||||
// check if any expanded paths effect this subtree
|
||||
if (nextProps.expanded !== props.expanded) {
|
||||
const path = !nextProps.name
|
||||
? '' // root
|
||||
: !nextProps.parentPath.length
|
||||
? nextProps.name // root element
|
||||
: nextProps.parentPath.join('.') + '.' + nextProps.name;
|
||||
|
||||
// we are being collapsed
|
||||
if (props.expanded[path] !== nextProps.expanded[path]) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// one of our children was expande
|
||||
for (const key in nextProps.expanded) {
|
||||
if (key.startsWith(path) === false) {
|
||||
// this key doesn't effect us
|
||||
continue;
|
||||
}
|
||||
|
||||
if (nextProps.expanded[key] !== props.expanded[key]) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// basic equality checks for the rest
|
||||
return (
|
||||
nextProps.data === props.data &&
|
||||
nextProps.diff === props.diff &&
|
||||
nextProps.name === props.name &&
|
||||
nextProps.depth === props.depth &&
|
||||
nextProps.parentPath === props.parentPath &&
|
||||
nextProps.onExpanded === props.onExpanded &&
|
||||
nextProps.onDelete === props.onDelete &&
|
||||
nextProps.setValue === props.setValue &&
|
||||
nextProps.collapsed === props.collapsed &&
|
||||
nextProps.expandRoot === props.expandRoot
|
||||
);
|
||||
}
|
||||
|
||||
function isValueExpandable(data: any) {
|
||||
return (
|
||||
typeof data === 'object' && data !== null && Object.keys(data).length > 0
|
||||
);
|
||||
}
|
||||
|
||||
export default DataInspector;
|
||||
@@ -1,132 +0,0 @@
|
||||
/**
|
||||
* 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 {DataDescriptionType, _DataDescription} from 'flipper-plugin';
|
||||
import styled from '@emotion/styled';
|
||||
import {getSortedKeys} from './utils';
|
||||
import {PureComponent} from 'react';
|
||||
import React from 'react';
|
||||
import {colors} from '../colors';
|
||||
|
||||
export type DataValueExtractor = (
|
||||
value: any,
|
||||
depth: number,
|
||||
path: string[],
|
||||
) =>
|
||||
| {
|
||||
mutable: boolean;
|
||||
type: DataDescriptionType;
|
||||
value: any;
|
||||
extra?: any;
|
||||
}
|
||||
| undefined
|
||||
| null;
|
||||
|
||||
export const InspectorName = styled.span({
|
||||
color: colors.grapeDark1,
|
||||
});
|
||||
InspectorName.displayName = 'DataInspector:InspectorName';
|
||||
|
||||
const PreviewContainer = styled.span({
|
||||
fontStyle: 'italic',
|
||||
});
|
||||
PreviewContainer.displayName = 'DataPreview:PreviewContainer';
|
||||
|
||||
function intersperse(arr: Array<any>, sep: string) {
|
||||
if (arr.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return arr.slice(1).reduce(
|
||||
(xs: any, x: any) => {
|
||||
return xs.concat([sep, x]);
|
||||
},
|
||||
[arr[0]],
|
||||
);
|
||||
}
|
||||
|
||||
export default class DataPreview extends PureComponent<{
|
||||
path: string[];
|
||||
type: string;
|
||||
value: any;
|
||||
depth: number;
|
||||
extractValue: DataValueExtractor;
|
||||
maxProperties: number;
|
||||
}> {
|
||||
static defaultProps = {
|
||||
maxProperties: 5,
|
||||
};
|
||||
|
||||
render() {
|
||||
const {depth, extractValue, path, type, value} = this.props;
|
||||
|
||||
if (type === 'array') {
|
||||
return (
|
||||
<PreviewContainer>
|
||||
{'['}
|
||||
{intersperse(
|
||||
value.map((element: any, index: number) => {
|
||||
const res = extractValue(element, depth + 1, path);
|
||||
if (!res) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const {type, value} = res;
|
||||
return (
|
||||
<_DataDescription
|
||||
key={index}
|
||||
type={type}
|
||||
value={value}
|
||||
setValue={null}
|
||||
/>
|
||||
);
|
||||
}),
|
||||
', ',
|
||||
)}
|
||||
{']'}
|
||||
</PreviewContainer>
|
||||
);
|
||||
} else if (type === 'date') {
|
||||
return <span>{value.toString()}</span>;
|
||||
} else if (type === 'object') {
|
||||
const propertyNodes = [];
|
||||
|
||||
const keys = getSortedKeys(value);
|
||||
let i = 0;
|
||||
for (const key of keys) {
|
||||
let ellipsis;
|
||||
i++;
|
||||
if (i >= this.props.maxProperties) {
|
||||
ellipsis = <span key={'ellipsis'}>…</span>;
|
||||
}
|
||||
|
||||
propertyNodes.push(
|
||||
<span key={key}>
|
||||
<InspectorName>{key}</InspectorName>
|
||||
{ellipsis}
|
||||
</span>,
|
||||
);
|
||||
|
||||
if (ellipsis) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<PreviewContainer>
|
||||
{'{'}
|
||||
{intersperse(propertyNodes, ', ')}
|
||||
{'}'}
|
||||
</PreviewContainer>
|
||||
);
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,197 +0,0 @@
|
||||
/**
|
||||
* 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 {DataInspectorExpanded} from './DataInspector';
|
||||
import {PureComponent} from 'react';
|
||||
import DataInspector from './DataInspector';
|
||||
import React from 'react';
|
||||
import {DataValueExtractor} from './DataPreview';
|
||||
import {_HighlightProvider, _HighlightManager} from 'flipper-plugin';
|
||||
|
||||
export type ManagedDataInspectorProps = {
|
||||
/**
|
||||
* Object to inspect.
|
||||
*/
|
||||
data: any;
|
||||
/**
|
||||
* Object to compare with the provided `data` property.
|
||||
* Differences will be styled accordingly in the UI.
|
||||
*/
|
||||
diff?: any;
|
||||
/**
|
||||
* Whether to expand the root by default.
|
||||
*/
|
||||
expandRoot?: boolean;
|
||||
/**
|
||||
* An optional callback that will explode a value into its type and value.
|
||||
* Useful for inspecting serialised data.
|
||||
*/
|
||||
extractValue?: DataValueExtractor;
|
||||
/**
|
||||
* Callback when a value is edited.
|
||||
*/
|
||||
setValue?: (path: Array<string>, val: any) => void;
|
||||
/**
|
||||
* Callback when a delete action is invoked.
|
||||
*/
|
||||
onDelete?: (path: Array<string>) => void;
|
||||
/**
|
||||
* Render callback that can be used to customize the rendering of object keys.
|
||||
*/
|
||||
onRenderName?: (
|
||||
path: Array<string>,
|
||||
name: string,
|
||||
highlighter: _HighlightManager,
|
||||
) => React.ReactElement;
|
||||
/**
|
||||
* Render callback that can be used to customize the rendering of object values.
|
||||
*/
|
||||
onRenderDescription?: (description: React.ReactElement) => React.ReactElement;
|
||||
/**
|
||||
* Whether all objects and arrays should be collapsed by default.
|
||||
*/
|
||||
collapsed?: boolean;
|
||||
/**
|
||||
* Object of all properties that will have tooltips
|
||||
*/
|
||||
tooltips?: Object;
|
||||
/**
|
||||
* Filter nodes by some search text
|
||||
*/
|
||||
filter?: string;
|
||||
};
|
||||
|
||||
type ManagedDataInspectorState = {
|
||||
expanded: DataInspectorExpanded;
|
||||
filterExpanded: DataInspectorExpanded;
|
||||
userExpanded: DataInspectorExpanded;
|
||||
filter: string;
|
||||
};
|
||||
|
||||
const MAX_RESULTS = 50;
|
||||
const EMPTY_ARRAY: any[] = [];
|
||||
|
||||
/**
|
||||
* Wrapper around `DataInspector` that handles expanded state.
|
||||
*
|
||||
* If you require lower level access to the state then use `DataInspector`
|
||||
* directly.
|
||||
*/
|
||||
export default class ManagedDataInspector extends PureComponent<
|
||||
ManagedDataInspectorProps,
|
||||
ManagedDataInspectorState
|
||||
> {
|
||||
state = {
|
||||
expanded: {},
|
||||
userExpanded: {},
|
||||
filterExpanded: {},
|
||||
filter: '',
|
||||
};
|
||||
|
||||
static getDerivedStateFromProps(
|
||||
nextProps: ManagedDataInspectorProps,
|
||||
currentState: ManagedDataInspectorState,
|
||||
) {
|
||||
if (nextProps.filter?.toLowerCase() === currentState.filter) {
|
||||
return null;
|
||||
}
|
||||
if (!nextProps.filter) {
|
||||
return {
|
||||
filter: '',
|
||||
filterExpanded: {},
|
||||
// reset expanded when removing filter
|
||||
expanded: currentState.userExpanded,
|
||||
};
|
||||
}
|
||||
|
||||
const filter = nextProps.filter!.toLowerCase();
|
||||
const paths: (number | string)[][] = [];
|
||||
|
||||
function walk(value: any, path: (number | string)[]) {
|
||||
if (paths.length > MAX_RESULTS) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
if (typeof value !== 'object') {
|
||||
if (('' + value).toLowerCase().includes(filter!)) {
|
||||
paths.push(path.slice());
|
||||
}
|
||||
} else if (Array.isArray(value)) {
|
||||
value.forEach((value, index) => {
|
||||
path.push(index);
|
||||
walk(value, path);
|
||||
path.pop();
|
||||
});
|
||||
} else {
|
||||
// a plain object
|
||||
Object.keys(value).forEach((key) => {
|
||||
path.push(key);
|
||||
walk(key, path); // is the key interesting?
|
||||
walk(value[key], path);
|
||||
path.pop();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (filter.length >= 2) {
|
||||
walk(nextProps.data, []);
|
||||
}
|
||||
const filterExpanded: Record<string, boolean> = {};
|
||||
paths.forEach((path) => {
|
||||
for (let i = 1; i < path.length; i++)
|
||||
filterExpanded[path.slice(0, i).join('.')] = true;
|
||||
});
|
||||
|
||||
return {
|
||||
filterExpanded,
|
||||
expanded: {...currentState.userExpanded, ...filterExpanded},
|
||||
filter,
|
||||
};
|
||||
}
|
||||
|
||||
onExpanded = (path: string, isExpanded: boolean) => {
|
||||
this.setState({
|
||||
userExpanded: {
|
||||
...this.state.userExpanded,
|
||||
[path]: isExpanded,
|
||||
},
|
||||
expanded: {
|
||||
...this.state.expanded,
|
||||
[path]: isExpanded,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
render() {
|
||||
return (
|
||||
<_HighlightProvider text={this.props.filter}>
|
||||
<DataInspector
|
||||
data={this.props.data}
|
||||
diff={this.props.diff}
|
||||
extractValue={this.props.extractValue}
|
||||
setValue={this.props.setValue}
|
||||
expanded={this.state.expanded}
|
||||
onExpanded={this.onExpanded}
|
||||
onDelete={this.props.onDelete}
|
||||
onRenderName={this.props.onRenderName}
|
||||
onRenderDescription={this.props.onRenderDescription}
|
||||
expandRoot={this.props.expandRoot}
|
||||
collapsed={this.props.filter ? true : this.props.collapsed}
|
||||
tooltips={this.props.tooltips}
|
||||
parentPath={EMPTY_ARRAY}
|
||||
depth={0}
|
||||
parentAncestry={EMPTY_ARRAY}
|
||||
/>
|
||||
</_HighlightProvider>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -10,11 +10,11 @@
|
||||
import React from 'react';
|
||||
import debounceRender from 'react-debounce-render';
|
||||
|
||||
import ManagedDataInspector, {
|
||||
ManagedDataInspectorProps,
|
||||
} from './ManagedDataInspector';
|
||||
import {DataInspector} from 'flipper-plugin';
|
||||
import Searchable, {SearchableProps} from '../searchable/Searchable';
|
||||
|
||||
type ManagedDataInspectorProps = any; // TODO!
|
||||
|
||||
type Props = ManagedDataInspectorProps & SearchableProps;
|
||||
|
||||
function filter(data: any, searchTerm: string): any {
|
||||
@@ -35,10 +35,7 @@ function filter(data: any, searchTerm: string): any {
|
||||
// Naive shallow filters matching wrapper for ManagedDataInspector
|
||||
function SearchableDataInspector(props: Props) {
|
||||
return (
|
||||
<ManagedDataInspector
|
||||
{...props}
|
||||
data={filter(props.data, props.searchTerm)}
|
||||
/>
|
||||
<DataInspector {...props} data={filter(props.data, props.searchTerm)} />
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,88 +0,0 @@
|
||||
/**
|
||||
* 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 ManagedDataInspector from './ManagedDataInspector';
|
||||
import {Component, ReactNode} from 'react';
|
||||
import {colors} from '../colors';
|
||||
import React from 'react';
|
||||
import MarkerTimeline from '../MarkerTimeline';
|
||||
import Button from '../Button';
|
||||
|
||||
type TimePoint = {
|
||||
moment: number;
|
||||
display: string;
|
||||
color: string;
|
||||
key: string;
|
||||
properties: {[key: string]: string};
|
||||
};
|
||||
|
||||
type Timeline = {
|
||||
time: TimePoint[];
|
||||
current: string;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
canSetCurrent?: boolean;
|
||||
timeline: Timeline;
|
||||
onClick: (selected: string) => void;
|
||||
};
|
||||
|
||||
type State = {
|
||||
selected: string;
|
||||
};
|
||||
|
||||
export default class TimelineDataDescription extends Component<Props, State> {
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
this.state = {selected: props.timeline.current};
|
||||
}
|
||||
|
||||
render(): ReactNode {
|
||||
const moments = Object.values(this.props.timeline.time);
|
||||
const firstMoment = moments[0].moment;
|
||||
const points = moments.map((value) => ({
|
||||
label: value.display,
|
||||
time: value.moment - firstMoment,
|
||||
color:
|
||||
Object.entries(colors).find(([k, _]) => k === value.color)?.[1] ??
|
||||
value.color,
|
||||
key: value.key,
|
||||
}));
|
||||
return (
|
||||
<>
|
||||
{this.props.canSetCurrent && (
|
||||
<div>
|
||||
<Button
|
||||
onClick={() => this.props.onClick(this.state.selected)}
|
||||
disabled={this.state.selected === this.props.timeline.current}>
|
||||
Set as current
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<MarkerTimeline
|
||||
points={points}
|
||||
onClick={(ids) => this.setState({selected: ids[0]})}
|
||||
maxGap={50}
|
||||
selected={this.state.selected}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<ManagedDataInspector
|
||||
data={
|
||||
this.props.timeline.time.find(
|
||||
(value) => value.key === this.state.selected,
|
||||
)?.properties ?? {}
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,187 +0,0 @@
|
||||
/**
|
||||
* 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 * as React from 'react';
|
||||
import {render, fireEvent, waitFor, act} from '@testing-library/react';
|
||||
|
||||
import ManagedDataInspector from '../ManagedDataInspector';
|
||||
import {sleep} from '../../../../utils';
|
||||
|
||||
const mocks = {
|
||||
requestIdleCallback(fn: Function) {
|
||||
return setTimeout(fn, 1);
|
||||
},
|
||||
cancelIdleCallback(handle: any) {
|
||||
clearTimeout(handle);
|
||||
},
|
||||
};
|
||||
|
||||
beforeAll(() => {
|
||||
Object.keys(mocks).forEach((key) => {
|
||||
// @ts-ignore
|
||||
if (!global[key]) {
|
||||
// @ts-ignore
|
||||
global[key] = mocks[key];
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
Object.keys(mocks).forEach((key) => {
|
||||
// @ts-ignore
|
||||
if (global[key] === mocks[key]) {
|
||||
// @ts-ignore
|
||||
delete global[key];
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const json = {
|
||||
data: {
|
||||
is: {
|
||||
awesomely: 'cool',
|
||||
},
|
||||
and: {
|
||||
also: 'json',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
test('changing collapsed property works', async () => {
|
||||
const res = render(<ManagedDataInspector data={json} collapsed expandRoot />);
|
||||
expect(await res.findByText(/is/)).toBeTruthy(); // from expandRoot
|
||||
expect(res.queryAllByText(/cool/).length).toBe(0);
|
||||
|
||||
res.rerender(
|
||||
<ManagedDataInspector data={json} collapsed={false} expandRoot />,
|
||||
);
|
||||
await res.findByText(/cool/);
|
||||
|
||||
res.rerender(
|
||||
<ManagedDataInspector data={json} collapsed={true} expandRoot />,
|
||||
);
|
||||
expect(res.queryAllByText(/cool/).length).toBe(0);
|
||||
});
|
||||
|
||||
test('can manually collapse properties', async () => {
|
||||
const res = render(<ManagedDataInspector data={json} collapsed expandRoot />);
|
||||
|
||||
await res.findByText(/is/); // previewed as key, like: "data: {is, and}"
|
||||
expect(res.queryAllByText(/awesomely/).length).toBe(0);
|
||||
|
||||
// expand twice
|
||||
fireEvent.click(await res.findByText(/data/));
|
||||
await res.findByText(/awesomely/);
|
||||
expect(res.queryAllByText(/cool/).length).toBe(0);
|
||||
|
||||
fireEvent.click(await res.findByText(/is/));
|
||||
await res.findByText(/cool/);
|
||||
expect(res.queryAllByText(/json/).length).toBe(0); // this node is not shown
|
||||
|
||||
// collapsing everything again
|
||||
fireEvent.click(await res.findByText(/data/));
|
||||
await waitFor(() => {
|
||||
expect(res.queryByText(/awesomely/)).toBeNull();
|
||||
});
|
||||
|
||||
// expand everything again, expanded paths will have been remembered
|
||||
fireEvent.click(await res.findByText(/data/));
|
||||
await res.findByText(/is/);
|
||||
await res.findByText(/awesomely/);
|
||||
await waitFor(() => {
|
||||
expect(res.queryByText(/json/)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
test('can filter for data', async () => {
|
||||
const res = render(
|
||||
<ManagedDataInspector data={json} collapsed={false} expandRoot />,
|
||||
);
|
||||
await res.findByText(/awesomely/); // everything is shown
|
||||
|
||||
// act here is used to make sure the highlight changes have propagated
|
||||
await act(async () => {
|
||||
res.rerender(
|
||||
<ManagedDataInspector
|
||||
data={json}
|
||||
collapsed={false}
|
||||
expandRoot
|
||||
filter="sOn"
|
||||
/>,
|
||||
);
|
||||
await sleep(200);
|
||||
});
|
||||
|
||||
const element = await res.findByText(/son/); // N.B. search for 'son', as the text was split up
|
||||
// snapshot to make sure the hilighiting did it's job
|
||||
expect(element.parentElement).toMatchInlineSnapshot(`
|
||||
<span>
|
||||
"j
|
||||
<span
|
||||
class="css-zr1u3c-Highlighted eiud9hg0"
|
||||
>
|
||||
son
|
||||
</span>
|
||||
"
|
||||
</span>
|
||||
`);
|
||||
// hides the other part of the tree
|
||||
await waitFor(() => {
|
||||
expect(res.queryByText(/cool/)).toBeNull();
|
||||
});
|
||||
|
||||
// find by key
|
||||
await act(async () => {
|
||||
res.rerender(
|
||||
<ManagedDataInspector
|
||||
data={json}
|
||||
collapsed={false}
|
||||
expandRoot
|
||||
filter="somel"
|
||||
/>,
|
||||
);
|
||||
await sleep(200);
|
||||
});
|
||||
|
||||
await res.findByText(/cool/);
|
||||
// hides the other part of the tree
|
||||
await waitFor(() => {
|
||||
expect(res.queryByText(/json/)).toBeNull();
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
res.rerender(
|
||||
<ManagedDataInspector
|
||||
data={json}
|
||||
collapsed={false}
|
||||
expandRoot
|
||||
filter=""
|
||||
/>,
|
||||
);
|
||||
await sleep(200);
|
||||
});
|
||||
|
||||
// everything visible again
|
||||
await res.findByText(/awesomely/);
|
||||
await res.findByText(/json/);
|
||||
});
|
||||
|
||||
test('can render recursive data for data', async () => {
|
||||
const json = {
|
||||
a: {
|
||||
recursive: undefined as any,
|
||||
},
|
||||
};
|
||||
json.a.recursive = json;
|
||||
|
||||
const res = render(
|
||||
<ManagedDataInspector data={json} collapsed={false} expandRoot />,
|
||||
);
|
||||
await res.findByText(/Recursive/);
|
||||
});
|
||||
@@ -1,14 +0,0 @@
|
||||
/**
|
||||
* 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 naturalCompare from 'string-natural-compare';
|
||||
|
||||
export function getSortedKeys(obj: Object): Array<string> {
|
||||
return Object.keys(obj).sort(naturalCompare);
|
||||
}
|
||||
@@ -12,7 +12,7 @@ import {PluginClient} from '../../../plugin';
|
||||
import Client from '../../../Client';
|
||||
import {Logger} from '../../../fb-interfaces/Logger';
|
||||
import Panel from '../Panel';
|
||||
import ManagedDataInspector from '../data-inspector/ManagedDataInspector';
|
||||
import {DataInspector} from 'flipper-plugin';
|
||||
import {Component} from 'react';
|
||||
import {Console} from '../console';
|
||||
import GK from '../../../fb-stubs/GK';
|
||||
@@ -64,7 +64,7 @@ class InspectorSidebarSection extends Component<InspectorSidebarSectionProps> {
|
||||
const {id} = this.props;
|
||||
return (
|
||||
<Panel heading={id} floating={false} grow={false}>
|
||||
<ManagedDataInspector
|
||||
<DataInspector
|
||||
data={this.props.data}
|
||||
setValue={this.props.onValueChanged ? this.setValue : undefined}
|
||||
extractValue={this.extractValue}
|
||||
|
||||
@@ -43,12 +43,8 @@ export {ManagedTableProps_immutable} from './components/table/ManagedTable_immut
|
||||
export {Value} from './components/table/TypeBasedValueRenderer';
|
||||
export {renderValue} from './components/table/TypeBasedValueRenderer';
|
||||
|
||||
export {
|
||||
DataValueExtractor,
|
||||
DataInspectorExpanded,
|
||||
} from './components/data-inspector/DataInspector';
|
||||
export {default as DataInspector} from './components/data-inspector/DataInspector';
|
||||
export {default as ManagedDataInspector} from './components/data-inspector/ManagedDataInspector';
|
||||
export {DataValueExtractor, DataInspectorExpanded} from 'flipper-plugin';
|
||||
export {DataInspector as ManagedDataInspector} from 'flipper-plugin';
|
||||
export {default as SearchableDataInspector} from './components/data-inspector/SearchableDataInspector';
|
||||
|
||||
// tabs
|
||||
@@ -130,8 +126,6 @@ export {default as Heading} from './components/Heading';
|
||||
// filters
|
||||
export {Filter} from './components/filter/types';
|
||||
|
||||
export {default as MarkerTimeline} from './components/MarkerTimeline';
|
||||
|
||||
export {default as StackTrace} from './components/StackTrace';
|
||||
|
||||
export {
|
||||
|
||||
Reference in New Issue
Block a user