[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
+ :
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},