/** * 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 {MenuTemplate} from '../ContextMenu'; import {Component} 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 deepEqual from 'deep-equal'; import React from 'react'; import {TooltipOptions} from '../TooltipProvider'; 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 nameTooltipOptions: TooltipOptions = { position: 'toLeft', showTail: true, }; export type DataInspectorSetValue = (path: Array, val: any) => 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. */ path: 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?: ((expanded: DataInspectorExpanded) => void) | undefined | null; /** * 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. */ ancestry: 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}; } }; const rootContextMenuCache: WeakMap< Object, Array > = new WeakMap(); function getRootContextMenu( data: Object, ): Array { const cached = rootContextMenuCache.get(data); if (cached != null) { return cached; } const stringValue = JSON.stringify(data, null, 2); const menu: Array = [ { 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, isExpanded: boolean, ) { if (isExpanded) { return true; } 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; } /** * An expandable data inspector. * * This component is fairly low level. It's likely you're looking for * [``](). */ export default class DataInspector extends Component { static defaultProps: { expanded: DataInspectorExpanded; depth: number; path: Array; ancestry: Array; } = { expanded: {}, depth: 0, path: [], ancestry: [], }; interaction: (name: string, data?: any) => void; constructor(props: DataInspectorProps) { super(props); this.interaction = reportInteraction('DataInspector', props.path.join(':')); } static isExpandable(data: any) { return ( typeof data === 'object' && data !== null && Object.keys(data).length > 0 ); } shouldComponentUpdate(nextProps: DataInspectorProps) { const {props} = this; // check if any expanded paths effect this subtree if (nextProps.expanded !== props.expanded) { const path = nextProps.path.join('.'); 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 true; } } } // basic equality checks for the rest return ( nextProps.data !== props.data || nextProps.diff !== props.diff || nextProps.name !== props.name || nextProps.depth !== props.depth || !deepEqual(nextProps.path, props.path) || nextProps.onExpanded !== props.onExpanded || nextProps.setValue !== props.setValue ); } isExpanded(pathParts: Array) { const {expanded} = this.props; // if we 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 (this.props.collapsed === true) { return false; } // by default all items are expanded return true; } setExpanded(pathParts: Array, isExpanded: boolean) { const {expanded, onExpanded} = this.props; if (!onExpanded || !expanded) { return; } const path = pathParts.join('.'); onExpanded({ ...expanded, [path]: isExpanded, }); } handleClick = () => { const isExpanded = this.isExpanded(this.props.path); this.interaction(isExpanded ? 'collapsed' : 'expanded'); this.setExpanded(this.props.path, !isExpanded); }; extractValue = (data: any, depth: number) => { let res; const {extractValue} = this.props; if (extractValue) { res = extractValue(data, depth); } if (!res) { res = defaultValueExtractor(data, depth); } return res; }; render(): any { const { data, diff, depth, expanded: expandedPaths, expandRoot, extractValue, name, onExpanded, path, ancestry, collapsed, tooltips, } = this.props; // 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 let {setValue} = this.props; const res = this.extractValue(data, depth); const resDiff = this.extractValue(diff, depth); let type; let value; let extra; if (res) { if (!res.mutable) { setValue = null; } ({type, value, extra} = res); } else { return null; } if (ancestry.includes(value)) { return Recursive; } const isExpandable = DataInspector.isExpandable(value); const isExpanded = isExpandable && (resDiff != null ? isComponentExpanded( value, resDiff.type, resDiff.value, expandRoot === true || this.isExpanded(path), ) : expandRoot === true || this.isExpanded(path)); let expandGlyph = ''; if (isExpandable) { if (isExpanded) { expandGlyph = '▼'; } else { expandGlyph = '▶'; } } else { if (depth !== 0) { expandGlyph = ' '; } } let propertyNodesContainer = null; if (isExpandable && isExpanded) { const propertyNodes = []; // ancestry of children, including its owner object const childAncestry = ancestry.concat([value]); const diffValue = diff && resDiff ? resDiff.value : null; const keys = getSortedKeys({...value, ...diffValue}); const Added = styled.div({ backgroundColor: colors.tealTint70, }); const Removed = styled.div({ backgroundColor: colors.cherryTint70, }); for (const key of keys) { const diffMetadataArr = diffMetadataExtractor(value, key, diffValue); for (const metadata of diffMetadataArr) { 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') { nameElems.push( {name} , ); nameElems.push(: ); } // create description or preview let descriptionOrPreview; if (isExpanded || !isExpandable) { descriptionOrPreview = ( ); } else { descriptionOrPreview = ( ); } descriptionOrPreview = ( {nameElems} {descriptionOrPreview} ); let wrapperStart; let wrapperEnd; if (isExpanded) { if (type === 'object') { wrapperStart = {'{'}; wrapperEnd = {'}'}; } if (type === 'array') { wrapperStart = {'['}; wrapperEnd = {']'}; } } const contextMenuItems: MenuTemplate = []; if (isExpandable) { contextMenuItems.push( { label: isExpanded ? 'Collapse' : 'Expand', click: this.handleClick, }, { type: 'separator', }, ); } contextMenuItems.push( { label: 'Copy', click: () => clipboard.writeText((window.getSelection() || '').toString()), }, { label: 'Copy value', click: () => clipboard.writeText(JSON.stringify(data, null, 2)), }, ); return ( {expandedPaths && {expandGlyph}} {descriptionOrPreview} {wrapperStart} {propertyNodesContainer} {wrapperEnd} ); } }