@samitouri / QOS-React-2 / commits / 709baf1fec

[DevTools] Support for adding props | Improved state/props value editing (#16700)

* Extracted sanitizeForParse * Added canAddEntries flag to InspectedElementTree * Added EditableKey component. * Added support to add an additional entry. * Added support to add more complex data structures in the EditableValue component. Added support to change the dataType of the value that is being changed. * Fixed flow error. * Removed unneeded fragment. * Renamed EditableKey -> EditableName * Removed unneeded dependency * Removed problematic props to state hook. * Prettified changes. * Removed unused import. * Fixed shouldStringify check. * Removed testing props from EditableProps. * Made some inline tweaks

Hristo Kanchev committed Sep 10, 2019 at 22:30 UTC 709baf1fecdbc8982aa70957a7bd620a929cea68
11 files changed +307 -118
packages/react-devtools-shared/src/devtools/utils.js
+43
@@ -82,3 +82,46 @@ export function printStore(store: Store, includeWeight: boolean = false) {
82
83 return snapshotLines.join('\n');
84 }
85 +
86 +// We use JSON.parse to parse string values
87 +// e.g. 'foo' is not valid JSON but it is a valid string
88 +// so this method replaces e.g. 'foo' with "foo"
89 +export function sanitizeForParse(value: any) {
90 + if (typeof value === 'string') {
91 + if (
92 + value.length >= 2 &&
93 + value.charAt(0) === "'" &&
94 + value.charAt(value.length - 1) === "'"
95 + ) {
96 + return '"' + value.substr(1, value.length - 2) + '"';
97 + }
98 + }
99 + return value;
100 +}
101 +
102 +export function smartParse(value: any) {
103 + switch (value) {
104 + case 'Infinity':
105 + return Infinity;
106 + case 'NaN':
107 + return NaN;
108 + case 'undefined':
109 + return undefined;
110 + default:
111 + return JSON.parse(sanitizeForParse(value));
112 + }
113 +}
114 +
115 +export function smartStringify(value: any) {
116 + if (typeof value === 'number') {
117 + if (Number.isNaN(value)) {
118 + return 'NaN';
119 + } else if (!Number.isFinite(value)) {
120 + return 'Infinity';
121 + }
122 + } else if (value === undefined) {
123 + return 'undefined';
124 + }
125 +
126 + return JSON.stringify(value);
127 +}
packages/react-devtools-shared/src/devtools/views/Components/EditableName.css new
+9
@@ -0,0 +1,9 @@
1 +.Input {
2 + flex: 0 1 auto;
3 + padding: 1px;
4 + box-shadow: 0px 1px 3px transparent;
5 +}
6 +.Input:focus {
7 + color: var(--color-text);
8 + box-shadow: 0px 1px 3px var(--color-shadow);
9 +}
\ No newline at end of file
packages/react-devtools-shared/src/devtools/views/Components/EditableName.js new
+78
@@ -0,0 +1,78 @@
1 +/**
2 + * Copyright (c) Facebook, Inc. and its 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 React, {useCallback, useState} from 'react';
11 +import AutoSizeInput from './NativeStyleEditor/AutoSizeInput';
12 +import styles from './EditableName.css';
13 +
14 +type OverrideNameFn = (path: Array<string | number>, value: any) => void;
15 +
16 +type EditableNameProps = {|
17 + autoFocus?: boolean,
18 + initialValue?: string,
19 + overrideNameFn: OverrideNameFn,
20 +|};
21 +
22 +export default function EditableName({
23 + autoFocus = false,
24 + initialValue = '',
25 + overrideNameFn,
26 +}: EditableNameProps) {
27 + const [editableName, setEditableName] = useState(initialValue);
28 + const [isValid, setIsValid] = useState(false);
29 +
30 + const handleChange = useCallback(
31 + ({target}) => {
32 + const value = target.value.trim();
33 +
34 + if (value) {
35 + setIsValid(true);
36 + } else {
37 + setIsValid(false);
38 + }
39 +
40 + setEditableName(value);
41 + },
42 + [overrideNameFn],
43 + );
44 +
45 + const handleKeyDown = useCallback(
46 + event => {
47 + // Prevent keydown events from e.g. change selected element in the tree
48 + event.stopPropagation();
49 +
50 + switch (event.key) {
51 + case 'Enter':
52 + case 'Tab':
53 + if (isValid) {
54 + overrideNameFn(editableName);
55 + }
56 + break;
57 + case 'Escape':
58 + setEditableName(initialValue);
59 + break;
60 + default:
61 + break;
62 + }
63 + },
64 + [editableName, setEditableName, isValid, initialValue, overrideNameFn],
65 + );
66 +
67 + return (
68 + <AutoSizeInput
69 + autoFocus={autoFocus}
70 + className={styles.Input}
71 + onChange={handleChange}
72 + onKeyDown={handleKeyDown}
73 + placeholder="new prop"
74 + type="text"
75 + value={editableName}
76 + />
77 + );
78 +}
packages/react-devtools-shared/src/devtools/views/Components/EditableValue.css
+17 -1
@@ -19,7 +19,23 @@
19 font-family: var(--font-family-monospace);
20 font-size: var(--font-size-monospace-normal);
21 }
22 -.Input:focus {
22 +
23 +.Invalid {
24 + flex: 1 1;
25 + background: none;
26 + border: 1px solid transparent;
27 + color: var(--color-attribute-editable-value);
28 + border-radius: 0.125rem;
29 + font-family: var(--font-family-monospace);
30 + font-size: var(--font-size-monospace-normal);
31 + background-color: var(--color-background-invalid);
32 + color: var(--color-text-invalid);
33 +
34 + --color-border: var(--color-text-invalid);
35 +}
36 +
37 +.Input:focus,
38 +.Invalid:focus {
39 background-color: var(--color-button-background-focus);
40 outline: none;
41 }
packages/react-devtools-shared/src/devtools/views/Components/EditableValue.js
+46 -100
@@ -7,143 +7,89 @@
7 * @flow
8 */
9
10 -import React, {Fragment, useCallback, useRef, useState} from 'react';
10 +import React, {Fragment, useCallback, useRef} from 'react';
11 import Button from '../Button';
12 import ButtonIcon from '../ButtonIcon';
13 import styles from './EditableValue.css';
14 +import {useEditableValue} from '../hooks';
15
16 type OverrideValueFn = (path: Array<string | number>, value: any) => void;
17
18 type EditableValueProps = {|
19 dataType: string,
20 + initialValue: any,
21 overrideValueFn: OverrideValueFn,
22 path: Array<string | number>,
21 - value: any,
23 |};
24
25 export default function EditableValue({
26 dataType,
27 + initialValue,
28 overrideValueFn,
29 path,
28 - value,
30 }: EditableValueProps) {
30 - const [hasPendingChanges, setHasPendingChanges] = useState(false);
31 - const [editableValue, setEditableValue] = useState(value);
31 const inputRef = useRef<HTMLInputElement | null>(null);
33 -
34 - if (hasPendingChanges && editableValue === value) {
35 - setHasPendingChanges(false);
36 - }
37 -
38 - const handleChange = useCallback(
39 - ({target}) => {
40 - if (dataType === 'boolean') {
41 - setEditableValue(target.checked);
42 - overrideValueFn(path, target.checked);
43 - } else {
44 - setEditableValue(target.value);
45 - }
46 - setHasPendingChanges(true);
47 - },
48 - [dataType, overrideValueFn, path],
49 - );
50 -
51 - const handleReset = useCallback(
52 - () => {
53 - setEditableValue(value);
54 - setHasPendingChanges(false);
55 -
56 - if (inputRef.current !== null) {
57 - inputRef.current.focus();
58 - }
59 - },
60 - [value],
61 - );
32 + const {
33 + editableValue,
34 + hasPendingChanges,
35 + isValid,
36 + parsedValue,
37 + reset,
38 + update,
39 + } = useEditableValue(initialValue);
40 +
41 + const handleChange = useCallback(({target}) => update(target.value), [
42 + update,
43 + ]);
44
45 const handleKeyDown = useCallback(
46 event => {
47 // Prevent keydown events from e.g. change selected element in the tree
48 event.stopPropagation();
49
68 - const {key} = event;
69 -
70 - if (key === 'Enter') {
71 - if (dataType === 'number') {
72 - const parsedValue = parseFloat(editableValue);
73 - if (!Number.isNaN(parsedValue)) {
50 + switch (event.key) {
51 + case 'Enter':
52 + if (isValid && hasPendingChanges) {
53 overrideValueFn(path, parsedValue);
54 }
76 - } else {
77 - overrideValueFn(path, editableValue);
78 - }
79 -
80 - // Don't reset the pending change flag here.
81 - // The inspected fiber won't be updated until after the next "inspectElement" message.
82 - // We'll reset that flag during a subsequent render.
83 - } else if (key === 'Escape') {
84 - setEditableValue(value);
85 - setHasPendingChanges(false);
55 + break;
56 + case 'Escape':
57 + reset();
58 + break;
59 + default:
60 + break;
61 }
62 },
88 - [editableValue, dataType, overrideValueFn, path, value],
63 + [hasPendingChanges, isValid, overrideValueFn, parsedValue, reset],
64 );
65
91 - // Render different input types based on the dataType
92 - let type = 'text';
93 - if (dataType === 'boolean') {
94 - type = 'checkbox';
95 - } else if (dataType === 'number') {
96 - type = 'number';
97 - }
98 -
99 - let inputValue = value == null ? '' : value;
100 - if (hasPendingChanges) {
101 - inputValue = editableValue == null ? '' : editableValue;
102 - }
103 -
66 let placeholder = '';
105 - if (value === null) {
106 - placeholder = '(null)';
107 - } else if (value === undefined) {
67 + if (editableValue === undefined) {
68 placeholder = '(undefined)';
109 - } else if (dataType === 'string') {
110 - placeholder = '(string)';
69 + } else {
70 + placeholder = 'Enter valid JSON';
71 }
72
73 return (
74 <Fragment>
115 - {dataType === 'boolean' && (
116 - <label className={styles.CheckboxLabel}>
117 - <input
118 - checked={inputValue}
119 - className={styles.Checkbox}
120 - onChange={handleChange}
121 - onKeyDown={handleKeyDown}
122 - ref={inputRef}
123 - type={type}
124 - />
125 - </label>
126 - )}
127 - {dataType !== 'boolean' && (
128 - <input
129 - className={styles.Input}
130 - onChange={handleChange}
131 - onKeyDown={handleKeyDown}
132 - placeholder={placeholder}
133 - ref={inputRef}
134 - type={type}
135 - value={inputValue}
136 - />
75 + <input
76 + autoComplete="new-password"
77 + className={isValid ? styles.Input : styles.Invalid}
78 + onChange={handleChange}
79 + onKeyDown={handleKeyDown}
80 + placeholder={placeholder}
81 + ref={inputRef}
82 + type="text"
83 + value={editableValue}
84 + />
85 + {hasPendingChanges && (
86 + <Button
87 + className={styles.ResetButton}
88 + onClick={reset}
89 + title="Reset value">
90 + <ButtonIcon type="undo" />
91 + </Button>
92 )}
138 - {hasPendingChanges &&
139 - dataType !== 'boolean' && (
140 - <Button
141 - className={styles.ResetButton}
142 - onClick={handleReset}
143 - title="Reset value">
144 - <ButtonIcon type="undo" />
145 - </Button>
146 - )}
93 </Fragment>
94 );
95 }
packages/react-devtools-shared/src/devtools/views/Components/InspectedElementTree.css
+7
@@ -46,3 +46,10 @@
46 font-style: italic;
47 padding-left: 0.75rem;
48 }
49 +
50 +.AddEntry {
51 + padding-left: 1rem;
52 + white-space: nowrap;
53 + display: flex;
54 + align-items: center;
55 +}
packages/react-devtools-shared/src/devtools/views/Components/InspectedElementTree.js
+39 -2
@@ -8,10 +8,12 @@
8 */
9
10 import {copy} from 'clipboard-js';
11 -import React, {useCallback} from 'react';
11 +import React, {useCallback, useState} from 'react';
12 import Button from '../Button';
13 import ButtonIcon from '../ButtonIcon';
14 import KeyValue from './KeyValue';
15 +import EditableName from './EditableName';
16 +import EditableValue from './EditableValue';
17 import {alphaSortEntries, serializeDataForCopy} from '../utils';
18 import styles from './InspectedElementTree.css';
19
@@ -25,6 +27,7 @@ type Props = {|
27 label: string,
28 overrideValueFn?: ?OverrideValueFn,
29 showWhenEmpty?: boolean,
30 + canAddEntries?: boolean,
31 |};
32
33 export default function InspectedElementTree({
@@ -32,6 +35,7 @@ export default function InspectedElementTree({
35 inspectPath,
36 label,
37 overrideValueFn,
38 + canAddEntries = false,
39 showWhenEmpty = false,
40 }: Props) {
41 const entries = data != null ? Object.entries(data) : null;
@@ -39,6 +43,9 @@ export default function InspectedElementTree({
43 entries.sort(alphaSortEntries);
44 }
45
46 + const [newPropKey, setNewPropKey] = useState<number>(0);
47 + const [newPropName, setNewPropName] = useState<string>('');
48 +
49 const isEmpty = entries === null || entries.length === 0;
50
51 const handleCopy = useCallback(
@@ -46,7 +53,23 @@ export default function InspectedElementTree({
53 [data],
54 );
55
49 - if (isEmpty && !showWhenEmpty) {
56 + const handleNewEntryValue = useCallback(
57 + (name, value) => {
58 + if (!newPropName) {
59 + return;
60 + }
61 +
62 + setNewPropName('');
63 + setNewPropKey(key => key + 1);
64 +
65 + if (typeof overrideValueFn === 'function') {
66 + overrideValueFn(name, value);
67 + }
68 + },
69 + [newPropName, overrideValueFn],
70 + );
71 +
72 + if (isEmpty && !showWhenEmpty && !canAddEntries) {
73 return null;
74 } else {
75 return (
@@ -73,6 +96,20 @@ export default function InspectedElementTree({
96 value={value}
97 />
98 ))}
99 + {canAddEntries && (
100 + <div className={styles.AddEntry} key={newPropKey}>
101 + <EditableName
102 + autoFocus={newPropKey > 0}
103 + overrideNameFn={setNewPropName}
104 + />
105 + :&nbsp;
106 + <EditableValue
107 + initialValue={''}
108 + overrideValueFn={handleNewEntryValue}
109 + path={[newPropName]}
110 + />
111 + </div>
112 + )}
113 </div>
114 );
115 }
packages/react-devtools-shared/src/devtools/views/Components/KeyValue.js
+1 -1
@@ -105,7 +105,7 @@ export default function KeyValue({
105 dataType={dataType}
106 overrideValueFn={((overrideValueFn: any): OverrideValueFn)}
107 path={path}
108 - value={value}
108 + initialValue={value}
109 />
110 ) : (
111 <span className={styles.Value}>{displayValue}</span>
packages/react-devtools-shared/src/devtools/views/Components/NativeStyleEditor/StyleEditor.js
+1 -13
@@ -19,6 +19,7 @@ import ButtonIcon from '../../ButtonIcon';
19 import {serializeDataForCopy} from '../../utils';
20 import AutoSizeInput from './AutoSizeInput';
21 import styles from './StyleEditor.css';
22 +import {sanitizeForParse} from '../../../utils';
23
24 import type {Style} from './types';
25
@@ -290,16 +291,3 @@ function Field({
291 />
292 );
293 }
293 -
294 -// We use JSON.parse to parse string values
295 -// e.g. 'foo' is not valid JSON but it is a valid string
296 -// so this method replaces e.g. 'foo' with "foo"
297 -function sanitizeForParse(value: any) {
298 - if (typeof value === 'string') {
299 - if (value.charAt(0) === "'" && value.charAt(value.length - 1) === "'") {
300 - return '"' + value.substr(1, value.length - 2) + '"';
301 - }
302 - }
303 -
304 - return value;
305 -}
packages/react-devtools-shared/src/devtools/views/Components/SelectedElement.js
+1
@@ -358,6 +358,7 @@ function InspectedElementView({
358 inspectPath={inspectPropsPath}
359 overrideValueFn={overridePropsFn}
360 showWhenEmpty={true}
361 + canAddEntries={true}
362 />
363 {type === ElementTypeSuspense ? (
364 <InspectedElementTree
packages/react-devtools-shared/src/devtools/views/hooks.js
+65 -1
@@ -8,11 +8,75 @@
8 */
9
10 import throttle from 'lodash.throttle';
11 -import {useCallback, useEffect, useLayoutEffect, useState} from 'react';
11 +import {
12 + useCallback,
13 + useEffect,
14 + useLayoutEffect,
15 + useMemo,
16 + useState,
17 +} from 'react';
18 +import {unstable_batchedUpdates as batchedUpdates} from 'react-dom';
19 import {
20 localStorageGetItem,
21 localStorageSetItem,
22 } from 'react-devtools-shared/src/storage';
23 +import {sanitizeForParse, smartParse, smartStringify} from '../utils';
24 +
25 +type EditableValue = {|
26 + editableValue: any,
27 + hasPendingChanges: boolean,
28 + isValid: boolean,
29 + parsedValue: any,
30 + reset: () => void,
31 + update: (newValue: any) => void,
32 +|};
33 +
34 +// Convenience hook for working with an editable value that is validated via JSON.parse.
35 +export function useEditableValue(
36 + initialValue: any,
37 + initialIsValid?: boolean = true,
38 +): EditableValue {
39 + const [editableValue, setEditableValue] = useState(() =>
40 + smartStringify(initialValue),
41 + );
42 + const [parsedValue, setParsedValue] = useState(initialValue);
43 + const [isValid, setIsValid] = useState(initialIsValid);
44 +
45 + const reset = useCallback(() => {
46 + setEditableValue(smartStringify(initialValue));
47 + setParsedValue(initialValue);
48 + setIsValid(initialIsValid);
49 + }, []);
50 +
51 + const update = useCallback(newValue => {
52 + let isNewValueValid = false;
53 + let newParsedValue;
54 + try {
55 + newParsedValue = smartParse(newValue);
56 + isNewValueValid = true;
57 + } catch (error) {}
58 +
59 + batchedUpdates(() => {
60 + setEditableValue(sanitizeForParse(newValue));
61 + if (isNewValueValid) {
62 + setParsedValue(newParsedValue);
63 + }
64 + setIsValid(isNewValueValid);
65 + });
66 + }, []);
67 +
68 + return useMemo(
69 + () => ({
70 + editableValue,
71 + hasPendingChanges: smartStringify(initialValue) !== editableValue,
72 + isValid,
73 + parsedValue,
74 + reset,
75 + update,
76 + }),
77 + [editableValue, initialValue, isValid, parsedValue],
78 + );
79 +}
80
81 export function useIsOverflowing(
82 containerRef: {current: HTMLDivElement | null},