498 lines
14 KiB
JavaScript
498 lines
14 KiB
JavaScript
/**
|
|
* 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.
|
|
*
|
|
* @flow
|
|
*/
|
|
|
|
import * as React from 'react';
|
|
import {useTransition, useContext, useRef, useState} from 'react';
|
|
import {OptionsContext} from '../context';
|
|
import EditableName from './EditableName';
|
|
import EditableValue from './EditableValue';
|
|
import NewArrayValue from './NewArrayValue';
|
|
import NewKeyValue from './NewKeyValue';
|
|
import LoadingAnimation from './LoadingAnimation';
|
|
import ExpandCollapseToggle from './ExpandCollapseToggle';
|
|
import {alphaSortEntries, getMetaValueLabel} from '../utils';
|
|
import {meta} from '../../../hydration';
|
|
import useContextMenu from '../../ContextMenu/useContextMenu';
|
|
import Store from '../../store';
|
|
import {parseHookPathForEdit} from './utils';
|
|
import styles from './KeyValue.css';
|
|
import Button from 'react-devtools-shared/src/devtools/views/Button';
|
|
import ButtonIcon from 'react-devtools-shared/src/devtools/views/ButtonIcon';
|
|
import isArray from 'react-devtools-shared/src/isArray';
|
|
import {InspectedElementContext} from './InspectedElementContext';
|
|
import {PROTOCOLS_SUPPORTED_AS_LINKS_IN_KEY_VALUE} from './constants';
|
|
|
|
import type {InspectedElement} from './types';
|
|
import type {Element} from 'react-devtools-shared/src/devtools/views/Components/types';
|
|
import type {Element as ReactElement} from 'react';
|
|
import type {FrontendBridge} from 'react-devtools-shared/src/bridge';
|
|
|
|
// $FlowFixMe[method-unbinding]
|
|
const hasOwnProperty = Object.prototype.hasOwnProperty;
|
|
|
|
type Type = 'props' | 'state' | 'context' | 'hooks';
|
|
|
|
type KeyValueProps = {
|
|
alphaSort: boolean,
|
|
bridge: FrontendBridge,
|
|
canDeletePaths: boolean,
|
|
canEditValues: boolean,
|
|
canRenamePaths: boolean,
|
|
canRenamePathsAtDepth?: (depth: number) => boolean,
|
|
depth: number,
|
|
element: Element,
|
|
hidden: boolean,
|
|
hookID?: ?number,
|
|
hookName?: ?string,
|
|
inspectedElement: InspectedElement,
|
|
isDirectChildOfAnArray?: boolean,
|
|
name: string,
|
|
path: Array<any>,
|
|
pathRoot: Type,
|
|
store: Store,
|
|
value: any,
|
|
};
|
|
|
|
export default function KeyValue({
|
|
alphaSort,
|
|
bridge,
|
|
canDeletePaths,
|
|
canEditValues,
|
|
canRenamePaths,
|
|
canRenamePathsAtDepth,
|
|
depth,
|
|
element,
|
|
inspectedElement,
|
|
isDirectChildOfAnArray,
|
|
hidden,
|
|
hookID,
|
|
hookName,
|
|
name,
|
|
path,
|
|
pathRoot,
|
|
store,
|
|
value,
|
|
}: KeyValueProps): React.Node {
|
|
const {readOnly: readOnlyGlobalFlag} = useContext(OptionsContext);
|
|
canDeletePaths = !readOnlyGlobalFlag && canDeletePaths;
|
|
canEditValues = !readOnlyGlobalFlag && canEditValues;
|
|
canRenamePaths = !readOnlyGlobalFlag && canRenamePaths;
|
|
|
|
const {id} = inspectedElement;
|
|
|
|
const [isOpen, setIsOpen] = useState<boolean>(false);
|
|
const contextMenuTriggerRef = useRef(null);
|
|
|
|
const {inspectPaths} = useContext(InspectedElementContext);
|
|
|
|
let isInspectable = false;
|
|
let isReadOnlyBasedOnMetadata = false;
|
|
if (value !== null && typeof value === 'object') {
|
|
isInspectable = value[meta.inspectable] && value[meta.size] !== 0;
|
|
isReadOnlyBasedOnMetadata = value[meta.readonly];
|
|
}
|
|
|
|
const [isInspectPathsPending, startInspectPathsTransition] = useTransition();
|
|
const toggleIsOpen = () => {
|
|
if (isOpen) {
|
|
setIsOpen(false);
|
|
} else {
|
|
setIsOpen(true);
|
|
|
|
if (isInspectable) {
|
|
startInspectPathsTransition(() => {
|
|
inspectPaths([pathRoot, ...path]);
|
|
});
|
|
}
|
|
}
|
|
};
|
|
|
|
useContextMenu({
|
|
data: {
|
|
path: [pathRoot, ...path],
|
|
type:
|
|
value !== null &&
|
|
typeof value === 'object' &&
|
|
hasOwnProperty.call(value, meta.type)
|
|
? value[meta.type]
|
|
: typeof value,
|
|
},
|
|
id: 'InspectedElement',
|
|
ref: contextMenuTriggerRef,
|
|
});
|
|
|
|
const dataType = typeof value;
|
|
const isSimpleType =
|
|
dataType === 'number' ||
|
|
dataType === 'string' ||
|
|
dataType === 'boolean' ||
|
|
value == null;
|
|
|
|
const style = {
|
|
paddingLeft: `${(depth - 1) * 0.75}rem`,
|
|
};
|
|
|
|
const overrideValue = (newPath, newValue) => {
|
|
if (hookID != null) {
|
|
newPath = parseHookPathForEdit(newPath);
|
|
}
|
|
|
|
const rendererID = store.getRendererIDForElement(id);
|
|
if (rendererID !== null) {
|
|
bridge.send('overrideValueAtPath', {
|
|
hookID,
|
|
id,
|
|
path: newPath,
|
|
rendererID,
|
|
type: pathRoot,
|
|
value: newValue,
|
|
});
|
|
}
|
|
};
|
|
|
|
const deletePath = pathToDelete => {
|
|
if (hookID != null) {
|
|
pathToDelete = parseHookPathForEdit(pathToDelete);
|
|
}
|
|
|
|
const rendererID = store.getRendererIDForElement(id);
|
|
if (rendererID !== null) {
|
|
bridge.send('deletePath', {
|
|
hookID,
|
|
id,
|
|
path: pathToDelete,
|
|
rendererID,
|
|
type: pathRoot,
|
|
});
|
|
}
|
|
};
|
|
|
|
const renamePath = (oldPath, newPath) => {
|
|
if (newPath[newPath.length - 1] === '') {
|
|
// Deleting the key suggests an intent to delete the whole path.
|
|
if (canDeletePaths) {
|
|
deletePath(oldPath);
|
|
}
|
|
} else {
|
|
if (hookID != null) {
|
|
oldPath = parseHookPathForEdit(oldPath);
|
|
newPath = parseHookPathForEdit(newPath);
|
|
}
|
|
|
|
const rendererID = store.getRendererIDForElement(id);
|
|
if (rendererID !== null) {
|
|
bridge.send('renamePath', {
|
|
hookID,
|
|
id,
|
|
newPath,
|
|
oldPath,
|
|
rendererID,
|
|
type: pathRoot,
|
|
});
|
|
}
|
|
}
|
|
};
|
|
|
|
// TRICKY This is a bit of a hack to account for context and hooks.
|
|
// In these cases, paths can be renamed but only at certain depths.
|
|
// The special "value" wrapper for context shouldn't be editable.
|
|
// Only certain types of hooks should be editable.
|
|
let canRenameTheCurrentPath = canRenamePaths;
|
|
if (canRenameTheCurrentPath && typeof canRenamePathsAtDepth === 'function') {
|
|
canRenameTheCurrentPath = canRenamePathsAtDepth(depth);
|
|
}
|
|
|
|
let renderedName;
|
|
if (isDirectChildOfAnArray) {
|
|
if (canDeletePaths) {
|
|
renderedName = (
|
|
<DeleteToggle name={name} deletePath={deletePath} path={path} />
|
|
);
|
|
} else {
|
|
renderedName = (
|
|
<span className={styles.Name}>
|
|
{name}
|
|
{!!hookName && <span className={styles.HookName}>({hookName})</span>}
|
|
</span>
|
|
);
|
|
}
|
|
} else if (canRenameTheCurrentPath) {
|
|
renderedName = (
|
|
<EditableName
|
|
allowEmpty={canDeletePaths}
|
|
className={styles.EditableName}
|
|
initialValue={name}
|
|
overrideName={renamePath}
|
|
path={path}
|
|
/>
|
|
);
|
|
} else {
|
|
renderedName = (
|
|
<span className={styles.Name} data-testname="NonEditableName">
|
|
{name}
|
|
{!!hookName && <span className={styles.HookName}>({hookName})</span>}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
let children = null;
|
|
if (isSimpleType) {
|
|
let displayValue = value;
|
|
if (dataType === 'string') {
|
|
displayValue = `"${value}"`;
|
|
} else if (dataType === 'boolean') {
|
|
displayValue = value ? 'true' : 'false';
|
|
} else if (value === null) {
|
|
displayValue = 'null';
|
|
} else if (value === undefined) {
|
|
displayValue = 'undefined';
|
|
}
|
|
|
|
let shouldDisplayValueAsLink = false;
|
|
if (
|
|
dataType === 'string' &&
|
|
PROTOCOLS_SUPPORTED_AS_LINKS_IN_KEY_VALUE.some(protocolPrefix =>
|
|
value.startsWith(protocolPrefix),
|
|
)
|
|
) {
|
|
shouldDisplayValueAsLink = true;
|
|
}
|
|
|
|
children = (
|
|
<div
|
|
key="root"
|
|
className={styles.Item}
|
|
hidden={hidden}
|
|
ref={contextMenuTriggerRef}
|
|
style={style}>
|
|
<div className={styles.ExpandCollapseToggleSpacer} />
|
|
{renderedName}
|
|
<div className={styles.AfterName}>:</div>
|
|
{canEditValues ? (
|
|
<EditableValue
|
|
overrideValue={overrideValue}
|
|
path={path}
|
|
value={value}
|
|
/>
|
|
) : shouldDisplayValueAsLink ? (
|
|
<a
|
|
className={styles.Link}
|
|
href={value}
|
|
target="_blank"
|
|
rel="noopener noreferrer">
|
|
{displayValue}
|
|
</a>
|
|
) : (
|
|
<span className={styles.Value} data-testname="NonEditableValue">
|
|
{displayValue}
|
|
</span>
|
|
)}
|
|
</div>
|
|
);
|
|
} else if (
|
|
hasOwnProperty.call(value, meta.type) &&
|
|
!hasOwnProperty.call(value, meta.unserializable)
|
|
) {
|
|
children = (
|
|
<div
|
|
key="root"
|
|
className={styles.Item}
|
|
hidden={hidden}
|
|
ref={contextMenuTriggerRef}
|
|
style={style}>
|
|
{isInspectable ? (
|
|
<ExpandCollapseToggle isOpen={isOpen} setIsOpen={toggleIsOpen} />
|
|
) : (
|
|
<div className={styles.ExpandCollapseToggleSpacer} />
|
|
)}
|
|
{renderedName}
|
|
<div className={styles.AfterName}>:</div>
|
|
<span
|
|
className={styles.Value}
|
|
onClick={isInspectable ? toggleIsOpen : undefined}>
|
|
{getMetaValueLabel(value)}
|
|
</span>
|
|
</div>
|
|
);
|
|
|
|
if (isInspectPathsPending) {
|
|
children = (
|
|
<>
|
|
{children}
|
|
<div className={styles.Item} style={style}>
|
|
<div className={styles.ExpandCollapseToggleSpacer} />
|
|
<LoadingAnimation />
|
|
</div>
|
|
</>
|
|
);
|
|
}
|
|
} else {
|
|
if (isArray(value)) {
|
|
const hasChildren = value.length > 0 || canEditValues;
|
|
const displayName = getMetaValueLabel(value);
|
|
|
|
children = value.map((innerValue, index) => (
|
|
<KeyValue
|
|
key={index}
|
|
alphaSort={alphaSort}
|
|
bridge={bridge}
|
|
canDeletePaths={canDeletePaths && !isReadOnlyBasedOnMetadata}
|
|
canEditValues={canEditValues && !isReadOnlyBasedOnMetadata}
|
|
canRenamePaths={canRenamePaths && !isReadOnlyBasedOnMetadata}
|
|
canRenamePathsAtDepth={canRenamePathsAtDepth}
|
|
depth={depth + 1}
|
|
element={element}
|
|
hookID={hookID}
|
|
inspectedElement={inspectedElement}
|
|
isDirectChildOfAnArray={true}
|
|
hidden={hidden || !isOpen}
|
|
name={index}
|
|
path={path.concat(index)}
|
|
pathRoot={pathRoot}
|
|
store={store}
|
|
value={value[index]}
|
|
/>
|
|
));
|
|
|
|
if (canEditValues && !isReadOnlyBasedOnMetadata) {
|
|
children.push(
|
|
<NewArrayValue
|
|
key="NewKeyValue"
|
|
bridge={bridge}
|
|
depth={depth + 1}
|
|
hidden={hidden || !isOpen}
|
|
hookID={hookID}
|
|
index={value.length}
|
|
element={element}
|
|
inspectedElement={inspectedElement}
|
|
path={path}
|
|
store={store}
|
|
type={pathRoot}
|
|
/>,
|
|
);
|
|
}
|
|
|
|
children.unshift(
|
|
<div
|
|
key={`${depth}-root`}
|
|
className={styles.Item}
|
|
hidden={hidden}
|
|
ref={contextMenuTriggerRef}
|
|
style={style}>
|
|
{hasChildren ? (
|
|
<ExpandCollapseToggle isOpen={isOpen} setIsOpen={setIsOpen} />
|
|
) : (
|
|
<div className={styles.ExpandCollapseToggleSpacer} />
|
|
)}
|
|
{renderedName}
|
|
<div className={styles.AfterName}>:</div>
|
|
<span
|
|
className={styles.Value}
|
|
onClick={hasChildren ? toggleIsOpen : undefined}>
|
|
{displayName}
|
|
</span>
|
|
</div>,
|
|
);
|
|
} else {
|
|
// TRICKY
|
|
// It's important to use Object.entries() rather than Object.keys()
|
|
// because of the hidden meta Symbols used for hydration and unserializable values.
|
|
const entries = Object.entries(value);
|
|
if (alphaSort) {
|
|
entries.sort(alphaSortEntries);
|
|
}
|
|
|
|
const hasChildren = entries.length > 0 || canEditValues;
|
|
const displayName = getMetaValueLabel(value);
|
|
|
|
children = entries.map<ReactElement<any>>(([key, keyValue]) => (
|
|
<KeyValue
|
|
key={key}
|
|
alphaSort={alphaSort}
|
|
bridge={bridge}
|
|
canDeletePaths={canDeletePaths && !isReadOnlyBasedOnMetadata}
|
|
canEditValues={canEditValues && !isReadOnlyBasedOnMetadata}
|
|
canRenamePaths={canRenamePaths && !isReadOnlyBasedOnMetadata}
|
|
canRenamePathsAtDepth={canRenamePathsAtDepth}
|
|
depth={depth + 1}
|
|
element={element}
|
|
hookID={hookID}
|
|
inspectedElement={inspectedElement}
|
|
hidden={hidden || !isOpen}
|
|
name={key}
|
|
path={path.concat(key)}
|
|
pathRoot={pathRoot}
|
|
store={store}
|
|
value={keyValue}
|
|
/>
|
|
));
|
|
|
|
if (canEditValues && !isReadOnlyBasedOnMetadata) {
|
|
children.push(
|
|
<NewKeyValue
|
|
key="NewKeyValue"
|
|
bridge={bridge}
|
|
depth={depth + 1}
|
|
element={element}
|
|
hidden={hidden || !isOpen}
|
|
hookID={hookID}
|
|
inspectedElement={inspectedElement}
|
|
path={path}
|
|
store={store}
|
|
type={pathRoot}
|
|
/>,
|
|
);
|
|
}
|
|
|
|
children.unshift(
|
|
<div
|
|
key={`${depth}-root`}
|
|
className={styles.Item}
|
|
hidden={hidden}
|
|
ref={contextMenuTriggerRef}
|
|
style={style}>
|
|
{hasChildren ? (
|
|
<ExpandCollapseToggle isOpen={isOpen} setIsOpen={setIsOpen} />
|
|
) : (
|
|
<div className={styles.ExpandCollapseToggleSpacer} />
|
|
)}
|
|
{renderedName}
|
|
<div className={styles.AfterName}>:</div>
|
|
<span
|
|
className={styles.Value}
|
|
onClick={hasChildren ? toggleIsOpen : undefined}>
|
|
{displayName}
|
|
</span>
|
|
</div>,
|
|
);
|
|
}
|
|
}
|
|
|
|
return children;
|
|
}
|
|
|
|
function DeleteToggle({deletePath, name, path}) {
|
|
const handleClick = event => {
|
|
event.stopPropagation();
|
|
deletePath(path);
|
|
};
|
|
|
|
return (
|
|
<>
|
|
<Button
|
|
className={styles.DeleteArrayItemButton}
|
|
onClick={handleClick}
|
|
title="Delete entry">
|
|
<ButtonIcon type="delete" />
|
|
</Button>
|
|
<span className={styles.Name}>{name}</span>
|
|
</>
|
|
);
|
|
}
|