mirror of
https://github.com/facebook/react.git
synced 2025-11-01 09:12:30 +00:00
36c04348d7
Currently you can jump to definition of a function by right clicking through the context menu. However, it's pretty difficult to discover. This makes the functions clickable to jump to definition - like links. This uses the same styling as we do for links (which are btw only clickable if they're not editable). Including cursor: pointer. I added a background on hover which follows the same pattern as the owners list. I also dropped the ƒ prefix when displaying functions. This is a cute short cut and there's precedence in how Chrome prints functions in the console *if* the function's toString would've had a function prefix like if it was a function declaration or expression. It does not do this for arrow functions or object methods. Elsewhere in the JS ecosystem this isn't really used anywhere. It invites more questions than it answers. The parenthesis and curlies are enough. There's no ambiguity here since strings have quotations. It looks better with just its object method form. Keeping it simple seems best. To my eyes this flows better because I'm used to looking at function syntax but not weird "f"s. Before: <img width="433" alt="Screenshot 2024-08-20 at 11 55 09 PM" src="https://github.com/user-attachments/assets/9dd50da6-598f-4291-9e24-1cdc7200dc9e"> After: <img width="388" alt="Screenshot 2024-08-20 at 11 46 01 PM" src="https://github.com/user-attachments/assets/dd988e14-412e-4deb-8c8c-26a54be8331f"> After (Hover): <img width="389" alt="Screenshot 2024-08-20 at 11 46 31 PM" src="https://github.com/user-attachments/assets/6fb4ebed-5dc1-448a-8e4d-b6d4f3903329">
574 lines
17 KiB
JavaScript
574 lines
17 KiB
JavaScript
/**
|
|
* Copyright (c) Meta Platforms, Inc. and 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, useMemo} 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 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 KeyValueContextMenuContainer from './KeyValueContextMenuContainer';
|
|
import {ContextMenuContext} from '../context';
|
|
|
|
import type {ContextMenuContextType} from '../context';
|
|
import type {InspectedElement} from 'react-devtools-shared/src/frontend/types';
|
|
import type {Element} from 'react-devtools-shared/src/frontend/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 fullPath = useMemo(() => [pathRoot, ...path], [pathRoot, path]);
|
|
|
|
const [isOpen, setIsOpen] = useState<boolean>(false);
|
|
const contextMenuTriggerRef = useRef(null);
|
|
|
|
const {inspectPaths} = useContext(InspectedElementContext);
|
|
const {viewAttributeSourceFunction} =
|
|
useContext<ContextMenuContextType>(ContextMenuContext);
|
|
|
|
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]);
|
|
});
|
|
}
|
|
}
|
|
};
|
|
|
|
const dataType = typeof value;
|
|
const isSimpleType =
|
|
dataType === 'number' ||
|
|
dataType === 'string' ||
|
|
dataType === 'boolean' ||
|
|
value == null;
|
|
|
|
const pathType =
|
|
value !== null &&
|
|
typeof value === 'object' &&
|
|
hasOwnProperty.call(value, meta.type)
|
|
? value[meta.type]
|
|
: typeof value;
|
|
const pathIsFunction = pathType === 'function';
|
|
|
|
const style = {
|
|
paddingLeft: `${(depth - 1) * 0.75}rem`,
|
|
};
|
|
|
|
const overrideValue = (newPath: Array<string | number>, newValue: any) => {
|
|
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: Array<string | number>) => {
|
|
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: Array<string | number>,
|
|
newPath: Array<string | number>,
|
|
) => {
|
|
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';
|
|
} else if (isNaN(value)) {
|
|
displayValue = 'NaN';
|
|
}
|
|
|
|
let shouldDisplayValueAsLink = false;
|
|
if (
|
|
dataType === 'string' &&
|
|
PROTOCOLS_SUPPORTED_AS_LINKS_IN_KEY_VALUE.some(protocolPrefix =>
|
|
value.startsWith(protocolPrefix),
|
|
)
|
|
) {
|
|
shouldDisplayValueAsLink = true;
|
|
}
|
|
|
|
children = (
|
|
<KeyValueContextMenuContainer
|
|
key="root"
|
|
anchorElementRef={contextMenuTriggerRef}
|
|
attributeSourceCanBeInspected={false}
|
|
canBeCopiedToClipboard={true}
|
|
store={store}
|
|
bridge={bridge}
|
|
id={id}
|
|
path={fullPath}>
|
|
<div
|
|
data-testname="KeyValue"
|
|
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>
|
|
</KeyValueContextMenuContainer>
|
|
);
|
|
} else if (pathIsFunction && viewAttributeSourceFunction != null) {
|
|
children = (
|
|
<KeyValueContextMenuContainer
|
|
key="root"
|
|
anchorElementRef={contextMenuTriggerRef}
|
|
attributeSourceCanBeInspected={true}
|
|
canBeCopiedToClipboard={false}
|
|
store={store}
|
|
bridge={bridge}
|
|
id={id}
|
|
path={fullPath}>
|
|
<div
|
|
data-testname="KeyValue"
|
|
className={styles.Item}
|
|
hidden={hidden}
|
|
ref={contextMenuTriggerRef}
|
|
style={style}>
|
|
<div className={styles.ExpandCollapseToggleSpacer} />
|
|
{renderedName}
|
|
<div className={styles.AfterName}>:</div>
|
|
<span
|
|
className={styles.Link}
|
|
onClick={() => {
|
|
viewAttributeSourceFunction(id, fullPath);
|
|
}}>
|
|
{getMetaValueLabel(value)}
|
|
</span>
|
|
</div>
|
|
</KeyValueContextMenuContainer>
|
|
);
|
|
} else if (
|
|
hasOwnProperty.call(value, meta.type) &&
|
|
!hasOwnProperty.call(value, meta.unserializable)
|
|
) {
|
|
children = (
|
|
<KeyValueContextMenuContainer
|
|
key="root"
|
|
anchorElementRef={contextMenuTriggerRef}
|
|
attributeSourceCanBeInspected={false}
|
|
canBeCopiedToClipboard={true}
|
|
store={store}
|
|
bridge={bridge}
|
|
id={id}
|
|
path={fullPath}>
|
|
<div
|
|
data-testname="KeyValue"
|
|
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>
|
|
</KeyValueContextMenuContainer>
|
|
);
|
|
|
|
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(
|
|
<KeyValueContextMenuContainer
|
|
key={`${depth}-root`}
|
|
anchorElementRef={contextMenuTriggerRef}
|
|
attributeSourceCanBeInspected={pathIsFunction}
|
|
canBeCopiedToClipboard={!pathIsFunction}
|
|
store={store}
|
|
bridge={bridge}
|
|
id={id}
|
|
path={fullPath}>
|
|
<div
|
|
data-testname="KeyValue"
|
|
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>
|
|
</KeyValueContextMenuContainer>,
|
|
);
|
|
} 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(([key, keyValue]): ReactElement<any> => (
|
|
<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(
|
|
<KeyValueContextMenuContainer
|
|
key={`${depth}-root`}
|
|
anchorElementRef={contextMenuTriggerRef}
|
|
attributeSourceCanBeInspected={pathIsFunction}
|
|
canBeCopiedToClipboard={!pathIsFunction}
|
|
store={store}
|
|
bridge={bridge}
|
|
id={id}
|
|
path={fullPath}>
|
|
<div
|
|
data-testname="KeyValue"
|
|
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>
|
|
</KeyValueContextMenuContainer>,
|
|
);
|
|
}
|
|
}
|
|
|
|
return children;
|
|
}
|
|
|
|
// $FlowFixMe[missing-local-annot]
|
|
function DeleteToggle({deletePath, name, path}) {
|
|
// $FlowFixMe[missing-local-annot]
|
|
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>
|
|
</>
|
|
);
|
|
}
|