1. /**
    
  2.  * Copyright (c) Meta Platforms, Inc. and affiliates.
    
  3.  *
    
  4.  * This source code is licensed under the MIT license found in the
    
  5.  * LICENSE file in the root directory of this source tree.
    
  6.  *
    
  7.  * @flow
    
  8.  */
    
  9. 
    
  10. import * as React from 'react';
    
  11. import {useTransition, useContext, useRef, useState} from 'react';
    
  12. import {OptionsContext} from '../context';
    
  13. import EditableName from './EditableName';
    
  14. import EditableValue from './EditableValue';
    
  15. import NewArrayValue from './NewArrayValue';
    
  16. import NewKeyValue from './NewKeyValue';
    
  17. import LoadingAnimation from './LoadingAnimation';
    
  18. import ExpandCollapseToggle from './ExpandCollapseToggle';
    
  19. import {alphaSortEntries, getMetaValueLabel} from '../utils';
    
  20. import {meta} from '../../../hydration';
    
  21. import useContextMenu from '../../ContextMenu/useContextMenu';
    
  22. import Store from '../../store';
    
  23. import {parseHookPathForEdit} from './utils';
    
  24. import styles from './KeyValue.css';
    
  25. import Button from 'react-devtools-shared/src/devtools/views/Button';
    
  26. import ButtonIcon from 'react-devtools-shared/src/devtools/views/ButtonIcon';
    
  27. import isArray from 'react-devtools-shared/src/isArray';
    
  28. import {InspectedElementContext} from './InspectedElementContext';
    
  29. import {PROTOCOLS_SUPPORTED_AS_LINKS_IN_KEY_VALUE} from './constants';
    
  30. 
    
  31. import type {InspectedElement} from 'react-devtools-shared/src/frontend/types';
    
  32. import type {Element} from 'react-devtools-shared/src/frontend/types';
    
  33. import type {Element as ReactElement} from 'react';
    
  34. import type {FrontendBridge} from 'react-devtools-shared/src/bridge';
    
  35. 
    
  36. // $FlowFixMe[method-unbinding]
    
  37. const hasOwnProperty = Object.prototype.hasOwnProperty;
    
  38. 
    
  39. type Type = 'props' | 'state' | 'context' | 'hooks';
    
  40. 
    
  41. type KeyValueProps = {
    
  42.   alphaSort: boolean,
    
  43.   bridge: FrontendBridge,
    
  44.   canDeletePaths: boolean,
    
  45.   canEditValues: boolean,
    
  46.   canRenamePaths: boolean,
    
  47.   canRenamePathsAtDepth?: (depth: number) => boolean,
    
  48.   depth: number,
    
  49.   element: Element,
    
  50.   hidden: boolean,
    
  51.   hookID?: ?number,
    
  52.   hookName?: ?string,
    
  53.   inspectedElement: InspectedElement,
    
  54.   isDirectChildOfAnArray?: boolean,
    
  55.   name: string,
    
  56.   path: Array<any>,
    
  57.   pathRoot: Type,
    
  58.   store: Store,
    
  59.   value: any,
    
  60. };
    
  61. 
    
  62. export default function KeyValue({
    
  63.   alphaSort,
    
  64.   bridge,
    
  65.   canDeletePaths,
    
  66.   canEditValues,
    
  67.   canRenamePaths,
    
  68.   canRenamePathsAtDepth,
    
  69.   depth,
    
  70.   element,
    
  71.   inspectedElement,
    
  72.   isDirectChildOfAnArray,
    
  73.   hidden,
    
  74.   hookID,
    
  75.   hookName,
    
  76.   name,
    
  77.   path,
    
  78.   pathRoot,
    
  79.   store,
    
  80.   value,
    
  81. }: KeyValueProps): React.Node {
    
  82.   const {readOnly: readOnlyGlobalFlag} = useContext(OptionsContext);
    
  83.   canDeletePaths = !readOnlyGlobalFlag && canDeletePaths;
    
  84.   canEditValues = !readOnlyGlobalFlag && canEditValues;
    
  85.   canRenamePaths = !readOnlyGlobalFlag && canRenamePaths;
    
  86. 
    
  87.   const {id} = inspectedElement;
    
  88. 
    
  89.   const [isOpen, setIsOpen] = useState<boolean>(false);
    
  90.   const contextMenuTriggerRef = useRef(null);
    
  91. 
    
  92.   const {inspectPaths} = useContext(InspectedElementContext);
    
  93. 
    
  94.   let isInspectable = false;
    
  95.   let isReadOnlyBasedOnMetadata = false;
    
  96.   if (value !== null && typeof value === 'object') {
    
  97.     isInspectable = value[meta.inspectable] && value[meta.size] !== 0;
    
  98.     isReadOnlyBasedOnMetadata = value[meta.readonly];
    
  99.   }
    
  100. 
    
  101.   const [isInspectPathsPending, startInspectPathsTransition] = useTransition();
    
  102.   const toggleIsOpen = () => {
    
  103.     if (isOpen) {
    
  104.       setIsOpen(false);
    
  105.     } else {
    
  106.       setIsOpen(true);
    
  107. 
    
  108.       if (isInspectable) {
    
  109.         startInspectPathsTransition(() => {
    
  110.           inspectPaths([pathRoot, ...path]);
    
  111.         });
    
  112.       }
    
  113.     }
    
  114.   };
    
  115. 
    
  116.   useContextMenu({
    
  117.     data: {
    
  118.       path: [pathRoot, ...path],
    
  119.       type:
    
  120.         value !== null &&
    
  121.         typeof value === 'object' &&
    
  122.         hasOwnProperty.call(value, meta.type)
    
  123.           ? value[meta.type]
    
  124.           : typeof value,
    
  125.     },
    
  126.     id: 'InspectedElement',
    
  127.     ref: contextMenuTriggerRef,
    
  128.   });
    
  129. 
    
  130.   const dataType = typeof value;
    
  131.   const isSimpleType =
    
  132.     dataType === 'number' ||
    
  133.     dataType === 'string' ||
    
  134.     dataType === 'boolean' ||
    
  135.     value == null;
    
  136. 
    
  137.   const style = {
    
  138.     paddingLeft: `${(depth - 1) * 0.75}rem`,
    
  139.   };
    
  140. 
    
  141.   const overrideValue = (newPath: Array<string | number>, newValue: any) => {
    
  142.     if (hookID != null) {
    
  143.       newPath = parseHookPathForEdit(newPath);
    
  144.     }
    
  145. 
    
  146.     const rendererID = store.getRendererIDForElement(id);
    
  147.     if (rendererID !== null) {
    
  148.       bridge.send('overrideValueAtPath', {
    
  149.         hookID,
    
  150.         id,
    
  151.         path: newPath,
    
  152.         rendererID,
    
  153.         type: pathRoot,
    
  154.         value: newValue,
    
  155.       });
    
  156.     }
    
  157.   };
    
  158. 
    
  159.   const deletePath = (pathToDelete: Array<string | number>) => {
    
  160.     if (hookID != null) {
    
  161.       pathToDelete = parseHookPathForEdit(pathToDelete);
    
  162.     }
    
  163. 
    
  164.     const rendererID = store.getRendererIDForElement(id);
    
  165.     if (rendererID !== null) {
    
  166.       bridge.send('deletePath', {
    
  167.         hookID,
    
  168.         id,
    
  169.         path: pathToDelete,
    
  170.         rendererID,
    
  171.         type: pathRoot,
    
  172.       });
    
  173.     }
    
  174.   };
    
  175. 
    
  176.   const renamePath = (
    
  177.     oldPath: Array<string | number>,
    
  178.     newPath: Array<string | number>,
    
  179.   ) => {
    
  180.     if (newPath[newPath.length - 1] === '') {
    
  181.       // Deleting the key suggests an intent to delete the whole path.
    
  182.       if (canDeletePaths) {
    
  183.         deletePath(oldPath);
    
  184.       }
    
  185.     } else {
    
  186.       if (hookID != null) {
    
  187.         oldPath = parseHookPathForEdit(oldPath);
    
  188.         newPath = parseHookPathForEdit(newPath);
    
  189.       }
    
  190. 
    
  191.       const rendererID = store.getRendererIDForElement(id);
    
  192.       if (rendererID !== null) {
    
  193.         bridge.send('renamePath', {
    
  194.           hookID,
    
  195.           id,
    
  196.           newPath,
    
  197.           oldPath,
    
  198.           rendererID,
    
  199.           type: pathRoot,
    
  200.         });
    
  201.       }
    
  202.     }
    
  203.   };
    
  204. 
    
  205.   // TRICKY This is a bit of a hack to account for context and hooks.
    
  206.   // In these cases, paths can be renamed but only at certain depths.
    
  207.   // The special "value" wrapper for context shouldn't be editable.
    
  208.   // Only certain types of hooks should be editable.
    
  209.   let canRenameTheCurrentPath = canRenamePaths;
    
  210.   if (canRenameTheCurrentPath && typeof canRenamePathsAtDepth === 'function') {
    
  211.     canRenameTheCurrentPath = canRenamePathsAtDepth(depth);
    
  212.   }
    
  213. 
    
  214.   let renderedName;
    
  215.   if (isDirectChildOfAnArray) {
    
  216.     if (canDeletePaths) {
    
  217.       renderedName = (
    
  218.         <DeleteToggle name={name} deletePath={deletePath} path={path} />
    
  219.       );
    
  220.     } else {
    
  221.       renderedName = (
    
  222.         <span className={styles.Name}>
    
  223.           {name}
    
  224.           {!!hookName && <span className={styles.HookName}>({hookName})</span>}
    
  225.         </span>
    
  226.       );
    
  227.     }
    
  228.   } else if (canRenameTheCurrentPath) {
    
  229.     renderedName = (
    
  230.       <EditableName
    
  231.         allowEmpty={canDeletePaths}
    
  232.         className={styles.EditableName}
    
  233.         initialValue={name}
    
  234.         overrideName={renamePath}
    
  235.         path={path}
    
  236.       />
    
  237.     );
    
  238.   } else {
    
  239.     renderedName = (
    
  240.       <span className={styles.Name} data-testname="NonEditableName">
    
  241.         {name}
    
  242.         {!!hookName && <span className={styles.HookName}>({hookName})</span>}
    
  243.       </span>
    
  244.     );
    
  245.   }
    
  246. 
    
  247.   let children = null;
    
  248.   if (isSimpleType) {
    
  249.     let displayValue = value;
    
  250.     if (dataType === 'string') {
    
  251.       displayValue = `"${value}"`;
    
  252.     } else if (dataType === 'boolean') {
    
  253.       displayValue = value ? 'true' : 'false';
    
  254.     } else if (value === null) {
    
  255.       displayValue = 'null';
    
  256.     } else if (value === undefined) {
    
  257.       displayValue = 'undefined';
    
  258.     } else if (isNaN(value)) {
    
  259.       displayValue = 'NaN';
    
  260.     }
    
  261. 
    
  262.     let shouldDisplayValueAsLink = false;
    
  263.     if (
    
  264.       dataType === 'string' &&
    
  265.       PROTOCOLS_SUPPORTED_AS_LINKS_IN_KEY_VALUE.some(protocolPrefix =>
    
  266.         value.startsWith(protocolPrefix),
    
  267.       )
    
  268.     ) {
    
  269.       shouldDisplayValueAsLink = true;
    
  270.     }
    
  271. 
    
  272.     children = (
    
  273.       <div
    
  274.         key="root"
    
  275.         className={styles.Item}
    
  276.         hidden={hidden}
    
  277.         ref={contextMenuTriggerRef}
    
  278.         style={style}>
    
  279.         <div className={styles.ExpandCollapseToggleSpacer} />
    
  280.         {renderedName}
    
  281.         <div className={styles.AfterName}>:</div>
    
  282.         {canEditValues ? (
    
  283.           <EditableValue
    
  284.             overrideValue={overrideValue}
    
  285.             path={path}
    
  286.             value={value}
    
  287.           />
    
  288.         ) : shouldDisplayValueAsLink ? (
    
  289.           <a
    
  290.             className={styles.Link}
    
  291.             href={value}
    
  292.             target="_blank"
    
  293.             rel="noopener noreferrer">
    
  294.             {displayValue}
    
  295.           </a>
    
  296.         ) : (
    
  297.           <span className={styles.Value} data-testname="NonEditableValue">
    
  298.             {displayValue}
    
  299.           </span>
    
  300.         )}
    
  301.       </div>
    
  302.     );
    
  303.   } else if (
    
  304.     hasOwnProperty.call(value, meta.type) &&
    
  305.     !hasOwnProperty.call(value, meta.unserializable)
    
  306.   ) {
    
  307.     children = (
    
  308.       <div
    
  309.         key="root"
    
  310.         className={styles.Item}
    
  311.         hidden={hidden}
    
  312.         ref={contextMenuTriggerRef}
    
  313.         style={style}>
    
  314.         {isInspectable ? (
    
  315.           <ExpandCollapseToggle isOpen={isOpen} setIsOpen={toggleIsOpen} />
    
  316.         ) : (
    
  317.           <div className={styles.ExpandCollapseToggleSpacer} />
    
  318.         )}
    
  319.         {renderedName}
    
  320.         <div className={styles.AfterName}>:</div>
    
  321.         <span
    
  322.           className={styles.Value}
    
  323.           onClick={isInspectable ? toggleIsOpen : undefined}>
    
  324.           {getMetaValueLabel(value)}
    
  325.         </span>
    
  326.       </div>
    
  327.     );
    
  328. 
    
  329.     if (isInspectPathsPending) {
    
  330.       children = (
    
  331.         <>
    
  332.           {children}
    
  333.           <div className={styles.Item} style={style}>
    
  334.             <div className={styles.ExpandCollapseToggleSpacer} />
    
  335.             <LoadingAnimation />
    
  336.           </div>
    
  337.         </>
    
  338.       );
    
  339.     }
    
  340.   } else {
    
  341.     if (isArray(value)) {
    
  342.       const hasChildren = value.length > 0 || canEditValues;
    
  343.       const displayName = getMetaValueLabel(value);
    
  344. 
    
  345.       children = value.map((innerValue, index) => (
    
  346.         <KeyValue
    
  347.           key={index}
    
  348.           alphaSort={alphaSort}
    
  349.           bridge={bridge}
    
  350.           canDeletePaths={canDeletePaths && !isReadOnlyBasedOnMetadata}
    
  351.           canEditValues={canEditValues && !isReadOnlyBasedOnMetadata}
    
  352.           canRenamePaths={canRenamePaths && !isReadOnlyBasedOnMetadata}
    
  353.           canRenamePathsAtDepth={canRenamePathsAtDepth}
    
  354.           depth={depth + 1}
    
  355.           element={element}
    
  356.           hookID={hookID}
    
  357.           inspectedElement={inspectedElement}
    
  358.           isDirectChildOfAnArray={true}
    
  359.           hidden={hidden || !isOpen}
    
  360.           name={index}
    
  361.           path={path.concat(index)}
    
  362.           pathRoot={pathRoot}
    
  363.           store={store}
    
  364.           value={value[index]}
    
  365.         />
    
  366.       ));
    
  367. 
    
  368.       if (canEditValues && !isReadOnlyBasedOnMetadata) {
    
  369.         children.push(
    
  370.           <NewArrayValue
    
  371.             key="NewKeyValue"
    
  372.             bridge={bridge}
    
  373.             depth={depth + 1}
    
  374.             hidden={hidden || !isOpen}
    
  375.             hookID={hookID}
    
  376.             index={value.length}
    
  377.             element={element}
    
  378.             inspectedElement={inspectedElement}
    
  379.             path={path}
    
  380.             store={store}
    
  381.             type={pathRoot}
    
  382.           />,
    
  383.         );
    
  384.       }
    
  385. 
    
  386.       children.unshift(
    
  387.         <div
    
  388.           key={`${depth}-root`}
    
  389.           className={styles.Item}
    
  390.           hidden={hidden}
    
  391.           ref={contextMenuTriggerRef}
    
  392.           style={style}>
    
  393.           {hasChildren ? (
    
  394.             <ExpandCollapseToggle isOpen={isOpen} setIsOpen={setIsOpen} />
    
  395.           ) : (
    
  396.             <div className={styles.ExpandCollapseToggleSpacer} />
    
  397.           )}
    
  398.           {renderedName}
    
  399.           <div className={styles.AfterName}>:</div>
    
  400.           <span
    
  401.             className={styles.Value}
    
  402.             onClick={hasChildren ? toggleIsOpen : undefined}>
    
  403.             {displayName}
    
  404.           </span>
    
  405.         </div>,
    
  406.       );
    
  407.     } else {
    
  408.       // TRICKY
    
  409.       // It's important to use Object.entries() rather than Object.keys()
    
  410.       // because of the hidden meta Symbols used for hydration and unserializable values.
    
  411.       const entries = Object.entries(value);
    
  412.       if (alphaSort) {
    
  413.         entries.sort(alphaSortEntries);
    
  414.       }
    
  415. 
    
  416.       const hasChildren = entries.length > 0 || canEditValues;
    
  417.       const displayName = getMetaValueLabel(value);
    
  418. 
    
  419.       children = entries.map(([key, keyValue]): ReactElement<any> => (
    
  420.         <KeyValue
    
  421.           key={key}
    
  422.           alphaSort={alphaSort}
    
  423.           bridge={bridge}
    
  424.           canDeletePaths={canDeletePaths && !isReadOnlyBasedOnMetadata}
    
  425.           canEditValues={canEditValues && !isReadOnlyBasedOnMetadata}
    
  426.           canRenamePaths={canRenamePaths && !isReadOnlyBasedOnMetadata}
    
  427.           canRenamePathsAtDepth={canRenamePathsAtDepth}
    
  428.           depth={depth + 1}
    
  429.           element={element}
    
  430.           hookID={hookID}
    
  431.           inspectedElement={inspectedElement}
    
  432.           hidden={hidden || !isOpen}
    
  433.           name={key}
    
  434.           path={path.concat(key)}
    
  435.           pathRoot={pathRoot}
    
  436.           store={store}
    
  437.           value={keyValue}
    
  438.         />
    
  439.       ));
    
  440. 
    
  441.       if (canEditValues && !isReadOnlyBasedOnMetadata) {
    
  442.         children.push(
    
  443.           <NewKeyValue
    
  444.             key="NewKeyValue"
    
  445.             bridge={bridge}
    
  446.             depth={depth + 1}
    
  447.             element={element}
    
  448.             hidden={hidden || !isOpen}
    
  449.             hookID={hookID}
    
  450.             inspectedElement={inspectedElement}
    
  451.             path={path}
    
  452.             store={store}
    
  453.             type={pathRoot}
    
  454.           />,
    
  455.         );
    
  456.       }
    
  457. 
    
  458.       children.unshift(
    
  459.         <div
    
  460.           key={`${depth}-root`}
    
  461.           className={styles.Item}
    
  462.           hidden={hidden}
    
  463.           ref={contextMenuTriggerRef}
    
  464.           style={style}>
    
  465.           {hasChildren ? (
    
  466.             <ExpandCollapseToggle isOpen={isOpen} setIsOpen={setIsOpen} />
    
  467.           ) : (
    
  468.             <div className={styles.ExpandCollapseToggleSpacer} />
    
  469.           )}
    
  470.           {renderedName}
    
  471.           <div className={styles.AfterName}>:</div>
    
  472.           <span
    
  473.             className={styles.Value}
    
  474.             onClick={hasChildren ? toggleIsOpen : undefined}>
    
  475.             {displayName}
    
  476.           </span>
    
  477.         </div>,
    
  478.       );
    
  479.     }
    
  480.   }
    
  481. 
    
  482.   return children;
    
  483. }
    
  484. 
    
  485. // $FlowFixMe[missing-local-annot]
    
  486. function DeleteToggle({deletePath, name, path}) {
    
  487.   // $FlowFixMe[missing-local-annot]
    
  488.   const handleClick = event => {
    
  489.     event.stopPropagation();
    
  490.     deletePath(path);
    
  491.   };
    
  492. 
    
  493.   return (
    
  494.     <>
    
  495.       <Button
    
  496.         className={styles.DeleteArrayItemButton}
    
  497.         onClick={handleClick}
    
  498.         title="Delete entry">
    
  499.         <ButtonIcon type="delete" />
    
  500.       </Button>
    
  501.       <span className={styles.Name}>{name}</span>
    
  502.     </>
    
  503.   );
    
  504. }