Updated editable hooks UI and added limited undo feature
Brian Vaughn committed
Feb 21, 2019 at 13:24 UTC
6f118497e8f979061c27aa2fd7f10cf2b8b61b9b
7 files changed
+150
-29
shells/dev/app/EditableProps/EditableProps.js
+80
-6
@@ -1,16 +1,61 @@
1
// @flow
2
3
-import React, { createContext, Component, Fragment } from 'react';
3
+import React, {
4
+ createContext,
5
+ Component,
6
+ Fragment,
7
+ useCallback,
8
+ useDebugValue,
9
+ useEffect,
10
+ useReducer,
11
+ useState,
12
+} from 'react';
13
import styles from './EditableProps.css';
14
6
-type StatefulFunctionProps = {| count: number |};
15
+const initialData = { foo: 'FOO', bar: 'BAR' };
16
8
-function StatefulFunction({ count }: StatefulFunctionProps) {
9
- return <li>Count: {count}</li>;
17
+function reducer(state, action) {
18
+ switch (action.type) {
19
+ case 'swap':
20
+ return { foo: state.bar, bar: state.foo };
21
+ default:
22
+ throw new Error();
23
+ }
24
+}
25
+
26
+type StatefulFunctionProps = {| name: string |};
27
+
28
+function StatefulFunction({ name }: StatefulFunctionProps) {
29
+ const [count, updateCount] = useState(0);
30
+ const debouncedCount = useDebounce(count, 1000);
31
+ const handleUpdateCountClick = useCallback(() => updateCount(count + 1), [
32
+ count,
33
+ ]);
34
+
35
+ const [data, dispatch] = useReducer(reducer, initialData);
36
+ const handleUpdateReducerClick = useCallback(
37
+ () => dispatch({ type: 'swap' }),
38
+ []
39
+ );
40
+
41
+ return (
42
+ <Fragment>
43
+ <li>Name: {name}</li>
44
+ <li>
45
+ <button onClick={handleUpdateCountClick}>
46
+ Debounced count: {debouncedCount}
47
+ </button>
48
+ </li>
49
+ <li>
50
+ <button onClick={handleUpdateReducerClick}>
51
+ Reducer state: {JSON.stringify(data, null, 2)}
52
+ </button>
53
+ </li>
54
+ </Fragment>
55
+ );
56
}
57
58
const BoolContext = createContext(true);
13
-// $FlowFixMe Flow does not yet know about Context.displayName
59
BoolContext.displayName = 'BoolContext';
60
61
type Props = {| name: string, toggle: boolean |};
@@ -50,8 +95,37 @@ export default function EditableProps() {
95
<div className={styles.Header}>Editable props</div>
96
<ul>
97
<StatefulClass name="Brian" toggle={true} />
53
- <StatefulFunction count={1} />
98
+ <StatefulFunction name="Brian" />
99
</ul>
100
</div>
101
);
102
}
103
+
104
+// Below copied from https://usehooks.com/
105
+function useDebounce(value, delay) {
106
+ // State and setters for debounced value
107
+ const [debouncedValue, setDebouncedValue] = useState(value);
108
+
109
+ // Show the value in DevTools
110
+ useDebugValue(debouncedValue);
111
+
112
+ useEffect(
113
+ () => {
114
+ // Update debounced value after delay
115
+ const handler = setTimeout(() => {
116
+ setDebouncedValue(value);
117
+ }, delay);
118
+
119
+ // Cancel the timeout if value changes (also on delay change or unmount)
120
+ // This is how we prevent debounced value from updating if value is changed ...
121
+ // .. within the delay period. Timeout gets cleared and restarted.
122
+ return () => {
123
+ clearTimeout(handler);
124
+ };
125
+ },
126
+ [value, delay] // Only re-call effect if value or delay changes
127
+ );
128
+
129
+ return debouncedValue;
130
+}
131
+// Above copied from https://usehooks.com/
src/backend/ReactDebugHooks.js
+6
-2
@@ -226,6 +226,7 @@ type ReactCurrentDispatcher = {
226
227
type HooksNode = {
228
index: number,
229
+ isEditable: boolean,
230
name: string,
231
value: mixed,
232
subHooks: Array<HooksNode>,
@@ -402,6 +403,7 @@ function buildTree(rootStack, readHookLog): HooksTree {
403
name: parseCustomHookName(stack[j - 1].functionName),
404
value: undefined,
405
index: -1,
406
+ isEditable: false,
407
subHooks: children,
408
});
409
stackOfChildren.push(levelChildren);
@@ -409,10 +411,12 @@ function buildTree(rootStack, readHookLog): HooksTree {
411
}
412
prevStack = stack;
413
}
414
+ const { primitive } = hook;
415
levelChildren.push({
413
- name: hook.primitive,
416
+ index: primitive === 'DebugValue' ? -1 : index++,
417
+ isEditable: primitive === 'Reducer' || primitive === 'State',
418
+ name: primitive,
419
value: hook.value,
415
- index: hook.primitive === 'DebugValue' ? -1 : index++,
420
subHooks: [],
421
});
422
}
src/backend/types.js
+1
@@ -100,6 +100,7 @@ export type DevToolsHook = {
100
101
export type HooksNode = {
102
index: number,
103
+ isEditable: boolean,
104
name: string,
105
value: mixed,
106
subHooks: Array<HooksNode>,
src/devtools/views/ButtonIcon.js
+9
@@ -10,6 +10,7 @@ type Props = {|
10
| 'down'
11
| 'more'
12
| 'search'
13
+ | 'undo'
14
| 'up'
15
| 'view-dom'
16
| 'view-source',
@@ -33,6 +34,9 @@ export default function ButtonIcon({ type }: Props) {
34
case 'search':
35
pathData = PATH_SEARCH;
36
break;
37
+ case 'undo':
38
+ pathData = PATH_UNDO;
39
+ break;
40
case 'up':
41
pathData = PATH_UP;
42
break;
@@ -85,6 +89,11 @@ const PATH_SEARCH = `
89
0-7-3.13-7-7s3.13-7 7-7 7 3.13 7 7-3.13 7-7 7z
90
`;
91
92
+const PATH_UNDO = `
93
+ M12.5 8c-2.65 0-5.05.99-6.9 2.6L2 7v9h9l-3.62-3.62c1.39-1.16 3.16-1.88 5.12-1.88
94
+ 3.54 0 6.55 2.31 7.6 5.5l2.37-.78C21.08 11.03 17.15 8 12.5 8z
95
+`;
96
+
97
const PATH_UP = 'M7.41 15.41L12 10.83l4.59 4.58L18 14l-6-6-6 6z';
98
99
const PATH_VIEW_DOM = `
src/devtools/views/HooksTree.js
+3
-2
@@ -56,7 +56,7 @@ type HookViewProps = {|
56
|};
57
58
function HookView({ canEditHooks, hook, id, path = [] }: HookViewProps) {
59
- const { name, index, subHooks, value } = hook;
59
+ const { name, index, isEditable, subHooks, value } = hook;
60
61
const bridge = useContext(BridgeContext);
62
const store = useContext(StoreContext);
@@ -121,7 +121,8 @@ function HookView({ canEditHooks, hook, id, path = [] }: HookViewProps) {
121
}
122
} else {
123
let overrideValueFn = null;
124
- if (canEditHooks && name === 'State') {
124
+ // TODO Maybe read editable value from debug hook?
125
+ if (canEditHooks && isEditable) {
126
overrideValueFn = (path: Array<string | number>, value: any) => {
127
const rendererID = store.getRendererIDForElement(id);
128
bridge.send('overrideHook', {
src/devtools/views/InspectedElementTree.css
+6
-1
@@ -32,11 +32,11 @@
32
}
33
34
.ValueInput {
35
+ width: 100%;
36
background: none;
37
border: 1px solid transparent;
38
color: var(--color-attribute-editable-value);
39
border-radius: 0.125rem;
39
- width: 100%;
40
font-family: var(--font-family-monospace);
41
font-size: var(--font-size-monospace-normal);
42
}
@@ -55,3 +55,8 @@
55
font-style: italic;
56
padding-left: 0.75rem;
57
}
58
+
59
+.ResetButton {
60
+ flex: 0 0 auto;
61
+ padding: 0 0.5rem;
62
+}
src/devtools/views/InspectedElementTree.js
+45
-18
@@ -1,6 +1,8 @@
1
// @flow
2
3
-import React, { useCallback, useState } from 'react';
3
+import React, { Fragment, useCallback, useState } from 'react';
4
+import Button from './Button';
5
+import ButtonIcon from './ButtonIcon';
6
import { getMetaValueLabel } from './utils';
7
import { meta } from '../../hydration';
8
import styles from './InspectedElementTree.css';
@@ -169,8 +171,13 @@ export function EditableValue({
171
path,
172
value,
173
}: EditableValueProps) {
174
+ const [hasPendingChanges, setHasPendingChanges] = useState(false);
175
const [editableValue, setEditableValue] = useState(value);
176
177
+ if (hasPendingChanges && editableValue === value) {
178
+ setHasPendingChanges(false);
179
+ }
180
+
181
const handleChange = useCallback(
182
({ target }) => {
183
if (dataType === 'boolean') {
@@ -181,14 +188,24 @@ export function EditableValue({
188
} else {
189
setEditableValue(target.value);
190
}
191
+ setHasPendingChanges(true);
192
},
193
[dataType, overrideValueFn, path]
194
);
195
196
+ const handleReset = useCallback(() => {
197
+ setEditableValue(value);
198
+ setHasPendingChanges(false);
199
+ }, [value]);
200
+
201
const handleKeyPress = useCallback(
202
({ key }) => {
203
if (key === 'Enter') {
204
overrideValueFn(path, editableValue);
205
+
206
+ // Don't reset the pending change flag here.
207
+ // The inspected fiber won't be updated until after the next "inspectElement" message.
208
+ // We'll reset that flag during a subsequent render.
209
}
210
},
211
[path, editableValue, overrideValueFn]
@@ -204,23 +221,33 @@ export function EditableValue({
221
type = 'number';
222
}
223
224
+ let inputValue = value;
225
+ if (hasPendingChanges) {
226
+ inputValue = editableValue == null ? '' : editableValue;
227
+ }
228
+
229
return (
208
- <label className={styles.ValueInputLabel}>
209
- <input
210
- checked={dataType === 'boolean' ? editableValue : undefined}
211
- className={styles.ValueInput}
212
- onChange={handleChange}
213
- onKeyDown={handleKeyDown}
214
- onKeyPress={handleKeyPress}
215
- type={type}
216
- value={
217
- dataType === 'boolean'
218
- ? undefined
219
- : editableValue != null
220
- ? editableValue
221
- : ''
222
- }
223
- />
224
- </label>
230
+ <Fragment>
231
+ <label className={styles.ValueInputLabel}>
232
+ <input
233
+ checked={dataType === 'boolean' ? inputValue : undefined}
234
+ className={styles.ValueInput}
235
+ onChange={handleChange}
236
+ onKeyDown={handleKeyDown}
237
+ onKeyPress={handleKeyPress}
238
+ type={type}
239
+ value={dataType === 'boolean' ? undefined : inputValue}
240
+ />
241
+ </label>
242
+ {hasPendingChanges && (
243
+ <Button
244
+ className={styles.ResetButton}
245
+ onClick={handleReset}
246
+ title="Reset value"
247
+ >
248
+ <ButtonIcon type="undo" />
249
+ </Button>
250
+ )}
251
+ </Fragment>
252
);
253
}