From 39be769babc14d070634b2c7974174b24403d253 Mon Sep 17 00:00:00 2001 From: Michel Weststrate Date: Wed, 28 Apr 2021 06:32:09 -0700 Subject: [PATCH] DataInspector renames Summary: Two minor renames: * `DataInspector` => `DataInspectorNode` * `ManagedDataInspector` => `DataInspector` This aligns the internal and public name of the component, and better captures the meaning of the original `DataInspector` class. The diff looks quite hefty, but that seems to be a phabricator issue caused by the filename swap; barely a thing changed :) Reviewed By: jknoxville Differential Revision: D28028554 fbshipit-source-id: d3d61fcb50abffaeae4bd1d26966604cece37b03 --- desktop/flipper-plugin/src/index.ts | 4 +- .../src/ui/data-inspector/DataDescription.tsx | 2 +- .../src/ui/data-inspector/DataInspector.tsx | 770 +++--------------- .../ui/data-inspector/DataInspectorNode.tsx | 695 ++++++++++++++++ .../data-inspector/ManagedDataInspector.tsx | 204 ----- .../TimelineDataDescription.tsx | 4 +- .../__tests__/DataInspector.node.tsx | 39 +- 7 files changed, 836 insertions(+), 882 deletions(-) create mode 100644 desktop/flipper-plugin/src/ui/data-inspector/DataInspectorNode.tsx delete mode 100644 desktop/flipper-plugin/src/ui/data-inspector/ManagedDataInspector.tsx diff --git a/desktop/flipper-plugin/src/index.ts b/desktop/flipper-plugin/src/index.ts index 7c55db5a2..d3359178f 100644 --- a/desktop/flipper-plugin/src/index.ts +++ b/desktop/flipper-plugin/src/index.ts @@ -99,13 +99,13 @@ export {HighlightManager} from './ui/Highlight'; export { DataValueExtractor, DataInspectorExpanded, -} from './ui/data-inspector/DataInspector'; +} from './ui/data-inspector/DataInspectorNode'; export { DataDescriptionType, DataDescription, } from './ui/data-inspector/DataDescription'; export {MarkerTimeline} from './ui/MarkerTimeline'; -export {ManagedDataInspector as DataInspector} from './ui/data-inspector/ManagedDataInspector'; +export {DataInspector} from './ui/data-inspector/DataInspector'; export { ElementsInspector, diff --git a/desktop/flipper-plugin/src/ui/data-inspector/DataDescription.tsx b/desktop/flipper-plugin/src/ui/data-inspector/DataDescription.tsx index 8d3f898ed..f8439bbed 100644 --- a/desktop/flipper-plugin/src/ui/data-inspector/DataDescription.tsx +++ b/desktop/flipper-plugin/src/ui/data-inspector/DataDescription.tsx @@ -8,7 +8,7 @@ */ import {Typography, Popover, Input, Select, Checkbox} from 'antd'; -import {DataInspectorSetValue} from './DataInspector'; +import {DataInspectorSetValue} from './DataInspectorNode'; import {PureComponent} from 'react'; import styled from '@emotion/styled'; import {SketchPicker, CompactPicker} from 'react-color'; diff --git a/desktop/flipper-plugin/src/ui/data-inspector/DataInspector.tsx b/desktop/flipper-plugin/src/ui/data-inspector/DataInspector.tsx index 662dfc1ec..57be92ad3 100644 --- a/desktop/flipper-plugin/src/ui/data-inspector/DataInspector.tsx +++ b/desktop/flipper-plugin/src/ui/data-inspector/DataInspector.tsx @@ -7,96 +7,14 @@ * @format */ -import {DataDescription} from './DataDescription'; -import { - memo, - useMemo, - useRef, - useState, - useEffect, - useCallback, - createContext, - useContext, -} from 'react'; -import styled from '@emotion/styled'; -import DataPreview, {DataValueExtractor, InspectorName} from './DataPreview'; -import {getSortedKeys} from './utils'; +import {DataInspectorExpanded, RootDataContext} from './DataInspectorNode'; +import {PureComponent} from 'react'; +import {DataInspectorNode} from './DataInspectorNode'; import React from 'react'; -import {useHighlighter, HighlightManager} from '../Highlight'; -import {Dropdown, Menu, Tooltip} from 'antd'; -import {tryGetFlipperLibImplementation} from '../../plugin/FlipperLib'; -import {safeStringify} from '../../utils/safeStringify'; +import {DataValueExtractor} from './DataPreview'; +import {HighlightProvider, HighlightManager} from '../Highlight'; -export {DataValueExtractor} from './DataPreview'; - -export const RootDataContext = createContext<() => any>(() => ({})); - -const contextMenuTrigger = ['contextMenu' as const]; - -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: '#FC3A4B', -}); -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: '#d2f0ea', -}); - -const Removed = styled.div({ - backgroundColor: '#fbccd2', -}); - -export type DataInspectorSetValue = (path: Array, val: any) => void; - -export type DataInspectorDeleteValue = (path: Array) => 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 = { +export type DataInspectorProps = { /** * Object to inspect. */ @@ -106,39 +24,23 @@ type DataInspectorProps = { * 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; /** * 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. + * Callback when a value is edited. */ - onExpanded?: ((path: string, expanded: boolean) => void) | undefined | null; + setValue?: (path: Array, val: any) => void; /** - * Callback whenever delete action is invoked on current path. + * Callback when a delete action is invoked. */ - onDelete?: DataInspectorDeleteValue | undefined | null; + onDelete?: (path: Array) => void; /** * Render callback that can be used to customize the rendering of object keys. */ @@ -151,572 +53,152 @@ type DataInspectorProps = { * 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. + * Object of all properties that will have tooltips */ - parentAncestry: Array; + tooltips?: Object; /** - * Object of properties that will have tooltips + * Filter nodes by some search text */ - tooltips?: any; + filter?: string; }; -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}; - } +type DataInspectorState = { + expanded: DataInspectorExpanded; + filterExpanded: DataInspectorExpanded; + userExpanded: DataInspectorExpanded; + filter: string; }; -function getRootContextMenu(root: Object) { - const lib = tryGetFlipperLibImplementation(); - return ( - - { - lib?.writeTextToClipboard(safeStringify(root)); - }}> - Copy tree - - - {lib?.isFB && ( - { - lib?.createPaste(safeStringify(root)); - }}> - Create paste from tree - - )} - - ); -} - -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 = Recursive; +const MAX_RESULTS = 50; +const EMPTY_ARRAY: any[] = []; /** - * An expandable data inspector. + * Wrapper around `DataInspector` that handles expanded state. * - * This component is fairly low level. It's likely you're looking for - * [``](#manageddatainspector). + * If you require lower level access to the state then use `DataInspector` + * directly. */ -const DataInspector: React.FC = 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 getRoot = useContext(RootDataContext); +export class DataInspector extends PureComponent< + DataInspectorProps, + DataInspectorState +> { + state = { + expanded: {}, + userExpanded: {}, + filterExpanded: {}, + filter: '', + }; - 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, 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); - setExpanded(path, !isExpanded); - }, [expandedPaths, path, collapsed]); - - const handleDelete = useCallback( - (path: Array) => { - if (!onDelete) { - return; - } - onDelete(path); - }, - [onDelete], - ); - - /** - * RENDERING - */ - if (!res) { + static getDerivedStateFromProps( + nextProps: DataInspectorProps, + currentState: DataInspectorState, + ) { + if (nextProps.filter?.toLowerCase() === currentState.filter) { 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; + if (!nextProps.filter) { + return { + filter: '', + filterExpanded: {}, + // reset expanded when removing filter + expanded: currentState.userExpanded, + }; } - let expandGlyph = ''; - if (isExpandable) { - if (shouldExpand.current) { - expandGlyph = '▼'; - } else { - expandGlyph = '▶'; + const filter = nextProps.filter!.toLowerCase(); + const paths: (number | string)[][] = []; + + function walk(value: any, path: (number | string)[]) { + if (paths.length > MAX_RESULTS) { + return; } - } else { - if (depth !== 0) { - expandGlyph = ' '; + + if (!value) { + return; } - } - - 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 = ( - - ); - - switch (metadata.status) { - case 'added': - propertyNodes.push( - {dataInspectorNode}, - ); - break; - case 'removed': - propertyNodes.push( - {dataInspectorNode}, - ); - break; - default: - propertyNodes.push(dataInspectorNode); - } + if (typeof value !== 'object') { + if (('' + value).toLowerCase().includes(filter!)) { + paths.push(path.slice()); } - } - - propertyNodesContainer = propertyNodes; - } - - if (expandRoot === true) { - return <>{propertyNodesContainer}; - } - - // create name components - const nameElems = []; - if (typeof name !== 'undefined') { - const text = onRenderName - ? onRenderName(path, name, highlighter) - : highlighter.render(name); - nameElems.push( - - {text} - , - ); - nameElems.push(: ); - } - - // create description or preview - let descriptionOrPreview; - if (renderExpanded || !isExpandable) { - descriptionOrPreview = ( - - ); - - descriptionOrPreview = onRenderDescription - ? onRenderDescription(descriptionOrPreview) - : descriptionOrPreview; - } else { - descriptionOrPreview = ( - - ); - } - - descriptionOrPreview = ( - - {nameElems} - {descriptionOrPreview} - - ); - - let wrapperStart; - let wrapperEnd; - if (renderExpanded) { - if (type === 'object') { - wrapperStart = {'{'}; - wrapperEnd = {'}'}; - } - - if (type === 'array') { - wrapperStart = {'['}; - wrapperEnd = {']'}; + } 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(); + }); } } - function getContextMenu() { - const lib = tryGetFlipperLibImplementation(); - return ( - - { - lib?.writeTextToClipboard(safeStringify(getRoot())); - }}> - Copy tree - - {lib?.isFB && ( - { - lib?.createPaste(safeStringify(getRoot())); - }}> - Create paste from tree - - )} - - { - lib?.writeTextToClipboard(safeStringify(data)); - }}> - Copy value - - {!isExpandable && onDelete ? ( - { - handleDelete(path); - }}> - Delete - - ) : null} - - ); + if (filter.length >= 2) { + walk(nextProps.data, []); } + const filterExpanded: Record = {}; + 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, + }, + }); + }; + + // make sure this fn is a stable ref to not invalidate the whole tree on new data + getRootData = () => { + return this.props.data; + }; + + render() { return ( - - - - {expandedPaths && {expandGlyph}} - {descriptionOrPreview} - {wrapperStart} - - - {propertyNodesContainer} - {wrapperEnd} - + + + + + ); - }, - dataInspectorPropsAreEqual, -); - -function shouldBeExpanded( - expanded: DataInspectorExpanded, - pathParts: Array, - 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; diff --git a/desktop/flipper-plugin/src/ui/data-inspector/DataInspectorNode.tsx b/desktop/flipper-plugin/src/ui/data-inspector/DataInspectorNode.tsx new file mode 100644 index 000000000..a87a6cf84 --- /dev/null +++ b/desktop/flipper-plugin/src/ui/data-inspector/DataInspectorNode.tsx @@ -0,0 +1,695 @@ +/** + * 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 './DataDescription'; +import { + memo, + useMemo, + useRef, + useState, + useEffect, + useCallback, + createContext, + useContext, +} from 'react'; +import styled from '@emotion/styled'; +import DataPreview, {DataValueExtractor, InspectorName} from './DataPreview'; +import {getSortedKeys} from './utils'; +import React from 'react'; +import {useHighlighter, HighlightManager} from '../Highlight'; +import {Dropdown, Menu, Tooltip} from 'antd'; +import {tryGetFlipperLibImplementation} from '../../plugin/FlipperLib'; +import {safeStringify} from '../../utils/safeStringify'; + +export {DataValueExtractor} from './DataPreview'; + +export const RootDataContext = createContext<() => any>(() => ({})); + +const contextMenuTrigger = ['contextMenu' as const]; + +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: '#FC3A4B', +}); +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: '#d2f0ea', +}); + +const Removed = styled.div({ + backgroundColor: '#fbccd2', +}); + +export type DataInspectorSetValue = (path: Array, val: any) => void; + +export type DataInspectorDeleteValue = (path: Array) => 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; + /** + * 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, + 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 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}; + } +}; + +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 = Recursive; + +/** + * An expandable data inspector. + * + * This component is fairly low level. It's likely you're looking for + * [``](#manageddatainspector). + */ +export const DataInspectorNode: React.FC = 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 getRoot = useContext(RootDataContext); + + 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, 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); + setExpanded(path, !isExpanded); + }, [expandedPaths, path, collapsed]); + + const handleDelete = useCallback( + (path: Array) => { + 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 = ( + + ); + + switch (metadata.status) { + case 'added': + propertyNodes.push( + {dataInspectorNode}, + ); + break; + case 'removed': + propertyNodes.push( + {dataInspectorNode}, + ); + break; + default: + propertyNodes.push(dataInspectorNode); + } + } + } + + propertyNodesContainer = propertyNodes; + } + + if (expandRoot === true) { + return <>{propertyNodesContainer}; + } + + // create name components + const nameElems = []; + if (typeof name !== 'undefined') { + const text = onRenderName + ? onRenderName(path, name, highlighter) + : highlighter.render(name); + nameElems.push( + + {text} + , + ); + nameElems.push(: ); + } + + // create description or preview + let descriptionOrPreview; + if (renderExpanded || !isExpandable) { + descriptionOrPreview = ( + + ); + + descriptionOrPreview = onRenderDescription + ? onRenderDescription(descriptionOrPreview) + : descriptionOrPreview; + } else { + descriptionOrPreview = ( + + ); + } + + descriptionOrPreview = ( + + {nameElems} + {descriptionOrPreview} + + ); + + let wrapperStart; + let wrapperEnd; + if (renderExpanded) { + if (type === 'object') { + wrapperStart = {'{'}; + wrapperEnd = {'}'}; + } + + if (type === 'array') { + wrapperStart = {'['}; + wrapperEnd = {']'}; + } + } + + function getContextMenu() { + const lib = tryGetFlipperLibImplementation(); + return ( + + { + lib?.writeTextToClipboard(safeStringify(getRoot())); + }}> + Copy tree + + {lib?.isFB && ( + { + lib?.createPaste(safeStringify(getRoot())); + }}> + Create paste from tree + + )} + + { + lib?.writeTextToClipboard(safeStringify(data)); + }}> + Copy value + + {!isExpandable && onDelete ? ( + { + handleDelete(path); + }}> + Delete + + ) : null} + + ); + } + + return ( + + + + {expandedPaths && {expandGlyph}} + {descriptionOrPreview} + {wrapperStart} + + + {propertyNodesContainer} + {wrapperEnd} + + ); + }, + dataInspectorPropsAreEqual, +); + +function shouldBeExpanded( + expanded: DataInspectorExpanded, + pathParts: Array, + 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 + ); +} diff --git a/desktop/flipper-plugin/src/ui/data-inspector/ManagedDataInspector.tsx b/desktop/flipper-plugin/src/ui/data-inspector/ManagedDataInspector.tsx deleted file mode 100644 index 30f4a8a7c..000000000 --- a/desktop/flipper-plugin/src/ui/data-inspector/ManagedDataInspector.tsx +++ /dev/null @@ -1,204 +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, RootDataContext} from './DataInspector'; -import {PureComponent} from 'react'; -import DataInspector from './DataInspector'; -import React from 'react'; -import {DataValueExtractor} from './DataPreview'; -import {HighlightProvider, HighlightManager} from '../Highlight'; - -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, val: any) => void; - /** - * Callback when a delete action is invoked. - */ - onDelete?: (path: Array) => void; - /** - * Render callback that can be used to customize the rendering of object keys. - */ - onRenderName?: ( - path: Array, - 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 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 = {}; - 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, - }, - }); - }; - - // make sure this fn is a stable ref to not invalidate the whole tree on new data - getRootData = () => { - return this.props.data; - }; - - render() { - return ( - - - - - - ); - } -} diff --git a/desktop/flipper-plugin/src/ui/data-inspector/TimelineDataDescription.tsx b/desktop/flipper-plugin/src/ui/data-inspector/TimelineDataDescription.tsx index 84e918613..8b757e7d2 100644 --- a/desktop/flipper-plugin/src/ui/data-inspector/TimelineDataDescription.tsx +++ b/desktop/flipper-plugin/src/ui/data-inspector/TimelineDataDescription.tsx @@ -7,12 +7,12 @@ * @format */ -import {ManagedDataInspector} from './ManagedDataInspector'; import {Component, ReactNode} from 'react'; import React from 'react'; import {MarkerTimeline} from '../MarkerTimeline'; import {Button} from 'antd'; import {presetColors} from './DataDescription'; +import {DataInspector} from './DataInspector'; type TimePoint = { moment: number; @@ -74,7 +74,7 @@ export class TimelineDataDescription extends Component { />
- value.key === this.state.selected, diff --git a/desktop/flipper-plugin/src/ui/data-inspector/__tests__/DataInspector.node.tsx b/desktop/flipper-plugin/src/ui/data-inspector/__tests__/DataInspector.node.tsx index 88f114b63..893a1b903 100644 --- a/desktop/flipper-plugin/src/ui/data-inspector/__tests__/DataInspector.node.tsx +++ b/desktop/flipper-plugin/src/ui/data-inspector/__tests__/DataInspector.node.tsx @@ -10,7 +10,7 @@ import * as React from 'react'; import {render, fireEvent, waitFor, act} from '@testing-library/react'; -import {ManagedDataInspector} from '../ManagedDataInspector'; +import {DataInspector} from '../DataInspector'; import {sleep} from '../../../utils/sleep'; const mocks = { @@ -54,23 +54,19 @@ const json = { }; test('changing collapsed property works', async () => { - const res = render(); + const res = render(); expect(await res.findByText(/is/)).toBeTruthy(); // from expandRoot expect(res.queryAllByText(/cool/).length).toBe(0); - res.rerender( - , - ); + res.rerender(); await res.findByText(/cool/); - res.rerender( - , - ); + res.rerender(); expect(res.queryAllByText(/cool/).length).toBe(0); }); test('can manually collapse properties', async () => { - const res = render(); + const res = render(); await res.findByText(/is/); // previewed as key, like: "data: {is, and}" expect(res.queryAllByText(/awesomely/).length).toBe(0); @@ -101,19 +97,14 @@ test('can manually collapse properties', async () => { test('can filter for data', async () => { const res = render( - , + , ); await res.findByText(/awesomely/); // everything is shown // act here is used to make sure the highlight changes have propagated await act(async () => { res.rerender( - , + , ); await sleep(200); }); @@ -139,12 +130,7 @@ test('can filter for data', async () => { // find by key await act(async () => { res.rerender( - , + , ); await sleep(200); }); @@ -157,12 +143,7 @@ test('can filter for data', async () => { await act(async () => { res.rerender( - , + , ); await sleep(200); }); @@ -181,7 +162,7 @@ test('can render recursive data for data', async () => { json.a.recursive = json; const res = render( - , + , ); await res.findByText(/Recursive/); });