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