[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
This commit is contained in:
Hristo Kanchev
2019-09-10 22:30:43 +02:00
committed by Brian Vaughn
parent 4ef6387d6e
commit 709baf1fec
11 changed files with 306 additions and 117 deletions
+43
View File
@@ -82,3 +82,46 @@ export function printStore(store: Store, includeWeight: boolean = false) {
return snapshotLines.join('\n');
}
// We use JSON.parse to parse string values
// e.g. 'foo' is not valid JSON but it is a valid string
// so this method replaces e.g. 'foo' with "foo"
export function sanitizeForParse(value: any) {
if (typeof value === 'string') {
if (
value.length >= 2 &&
value.charAt(0) === "'" &&
value.charAt(value.length - 1) === "'"
) {
return '"' + value.substr(1, value.length - 2) + '"';
}
}
return value;
}
export function smartParse(value: any) {
switch (value) {
case 'Infinity':
return Infinity;
case 'NaN':
return NaN;
case 'undefined':
return undefined;
default:
return JSON.parse(sanitizeForParse(value));
}
}
export function smartStringify(value: any) {
if (typeof value === 'number') {
if (Number.isNaN(value)) {
return 'NaN';
} else if (!Number.isFinite(value)) {
return 'Infinity';
}
} else if (value === undefined) {
return 'undefined';
}
return JSON.stringify(value);
}
@@ -0,0 +1,9 @@
.Input {
flex: 0 1 auto;
padding: 1px;
box-shadow: 0px 1px 3px transparent;
}
.Input:focus {
color: var(--color-text);
box-shadow: 0px 1px 3px var(--color-shadow);
}
@@ -0,0 +1,78 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @flow
*/
import React, {useCallback, useState} from 'react';
import AutoSizeInput from './NativeStyleEditor/AutoSizeInput';
import styles from './EditableName.css';
type OverrideNameFn = (path: Array<string | number>, value: any) => void;
type EditableNameProps = {|
autoFocus?: boolean,
initialValue?: string,
overrideNameFn: OverrideNameFn,
|};
export default function EditableName({
autoFocus = false,
initialValue = '',
overrideNameFn,
}: EditableNameProps) {
const [editableName, setEditableName] = useState(initialValue);
const [isValid, setIsValid] = useState(false);
const handleChange = useCallback(
({target}) => {
const value = target.value.trim();
if (value) {
setIsValid(true);
} else {
setIsValid(false);
}
setEditableName(value);
},
[overrideNameFn],
);
const handleKeyDown = useCallback(
event => {
// Prevent keydown events from e.g. change selected element in the tree
event.stopPropagation();
switch (event.key) {
case 'Enter':
case 'Tab':
if (isValid) {
overrideNameFn(editableName);
}
break;
case 'Escape':
setEditableName(initialValue);
break;
default:
break;
}
},
[editableName, setEditableName, isValid, initialValue, overrideNameFn],
);
return (
<AutoSizeInput
autoFocus={autoFocus}
className={styles.Input}
onChange={handleChange}
onKeyDown={handleKeyDown}
placeholder="new prop"
type="text"
value={editableName}
/>
);
}
@@ -19,7 +19,23 @@
font-family: var(--font-family-monospace);
font-size: var(--font-size-monospace-normal);
}
.Input:focus {
.Invalid {
flex: 1 1;
background: none;
border: 1px solid transparent;
color: var(--color-attribute-editable-value);
border-radius: 0.125rem;
font-family: var(--font-family-monospace);
font-size: var(--font-size-monospace-normal);
background-color: var(--color-background-invalid);
color: var(--color-text-invalid);
--color-border: var(--color-text-invalid);
}
.Input:focus,
.Invalid:focus {
background-color: var(--color-button-background-focus);
outline: none;
}
@@ -7,143 +7,89 @@
* @flow
*/
import React, {Fragment, useCallback, useRef, useState} from 'react';
import React, {Fragment, useCallback, useRef} from 'react';
import Button from '../Button';
import ButtonIcon from '../ButtonIcon';
import styles from './EditableValue.css';
import {useEditableValue} from '../hooks';
type OverrideValueFn = (path: Array<string | number>, value: any) => void;
type EditableValueProps = {|
dataType: string,
initialValue: any,
overrideValueFn: OverrideValueFn,
path: Array<string | number>,
value: any,
|};
export default function EditableValue({
dataType,
initialValue,
overrideValueFn,
path,
value,
}: EditableValueProps) {
const [hasPendingChanges, setHasPendingChanges] = useState(false);
const [editableValue, setEditableValue] = useState(value);
const inputRef = useRef<HTMLInputElement | null>(null);
const {
editableValue,
hasPendingChanges,
isValid,
parsedValue,
reset,
update,
} = useEditableValue(initialValue);
if (hasPendingChanges && editableValue === value) {
setHasPendingChanges(false);
}
const handleChange = useCallback(
({target}) => {
if (dataType === 'boolean') {
setEditableValue(target.checked);
overrideValueFn(path, target.checked);
} else {
setEditableValue(target.value);
}
setHasPendingChanges(true);
},
[dataType, overrideValueFn, path],
);
const handleReset = useCallback(
() => {
setEditableValue(value);
setHasPendingChanges(false);
if (inputRef.current !== null) {
inputRef.current.focus();
}
},
[value],
);
const handleChange = useCallback(({target}) => update(target.value), [
update,
]);
const handleKeyDown = useCallback(
event => {
// Prevent keydown events from e.g. change selected element in the tree
event.stopPropagation();
const {key} = event;
if (key === 'Enter') {
if (dataType === 'number') {
const parsedValue = parseFloat(editableValue);
if (!Number.isNaN(parsedValue)) {
switch (event.key) {
case 'Enter':
if (isValid && hasPendingChanges) {
overrideValueFn(path, parsedValue);
}
} else {
overrideValueFn(path, editableValue);
}
// Don't reset the pending change flag here.
// The inspected fiber won't be updated until after the next "inspectElement" message.
// We'll reset that flag during a subsequent render.
} else if (key === 'Escape') {
setEditableValue(value);
setHasPendingChanges(false);
break;
case 'Escape':
reset();
break;
default:
break;
}
},
[editableValue, dataType, overrideValueFn, path, value],
[hasPendingChanges, isValid, overrideValueFn, parsedValue, reset],
);
// Render different input types based on the dataType
let type = 'text';
if (dataType === 'boolean') {
type = 'checkbox';
} else if (dataType === 'number') {
type = 'number';
}
let inputValue = value == null ? '' : value;
if (hasPendingChanges) {
inputValue = editableValue == null ? '' : editableValue;
}
let placeholder = '';
if (value === null) {
placeholder = '(null)';
} else if (value === undefined) {
if (editableValue === undefined) {
placeholder = '(undefined)';
} else if (dataType === 'string') {
placeholder = '(string)';
} else {
placeholder = 'Enter valid JSON';
}
return (
<Fragment>
{dataType === 'boolean' && (
<label className={styles.CheckboxLabel}>
<input
checked={inputValue}
className={styles.Checkbox}
onChange={handleChange}
onKeyDown={handleKeyDown}
ref={inputRef}
type={type}
/>
</label>
<input
autoComplete="new-password"
className={isValid ? styles.Input : styles.Invalid}
onChange={handleChange}
onKeyDown={handleKeyDown}
placeholder={placeholder}
ref={inputRef}
type="text"
value={editableValue}
/>
{hasPendingChanges && (
<Button
className={styles.ResetButton}
onClick={reset}
title="Reset value">
<ButtonIcon type="undo" />
</Button>
)}
{dataType !== 'boolean' && (
<input
className={styles.Input}
onChange={handleChange}
onKeyDown={handleKeyDown}
placeholder={placeholder}
ref={inputRef}
type={type}
value={inputValue}
/>
)}
{hasPendingChanges &&
dataType !== 'boolean' && (
<Button
className={styles.ResetButton}
onClick={handleReset}
title="Reset value">
<ButtonIcon type="undo" />
</Button>
)}
</Fragment>
);
}
@@ -46,3 +46,10 @@
font-style: italic;
padding-left: 0.75rem;
}
.AddEntry {
padding-left: 1rem;
white-space: nowrap;
display: flex;
align-items: center;
}
@@ -8,10 +8,12 @@
*/
import {copy} from 'clipboard-js';
import React, {useCallback} from 'react';
import React, {useCallback, useState} from 'react';
import Button from '../Button';
import ButtonIcon from '../ButtonIcon';
import KeyValue from './KeyValue';
import EditableName from './EditableName';
import EditableValue from './EditableValue';
import {alphaSortEntries, serializeDataForCopy} from '../utils';
import styles from './InspectedElementTree.css';
@@ -25,6 +27,7 @@ type Props = {|
label: string,
overrideValueFn?: ?OverrideValueFn,
showWhenEmpty?: boolean,
canAddEntries?: boolean,
|};
export default function InspectedElementTree({
@@ -32,6 +35,7 @@ export default function InspectedElementTree({
inspectPath,
label,
overrideValueFn,
canAddEntries = false,
showWhenEmpty = false,
}: Props) {
const entries = data != null ? Object.entries(data) : null;
@@ -39,6 +43,9 @@ export default function InspectedElementTree({
entries.sort(alphaSortEntries);
}
const [newPropKey, setNewPropKey] = useState<number>(0);
const [newPropName, setNewPropName] = useState<string>('');
const isEmpty = entries === null || entries.length === 0;
const handleCopy = useCallback(
@@ -46,7 +53,23 @@ export default function InspectedElementTree({
[data],
);
if (isEmpty && !showWhenEmpty) {
const handleNewEntryValue = useCallback(
(name, value) => {
if (!newPropName) {
return;
}
setNewPropName('');
setNewPropKey(key => key + 1);
if (typeof overrideValueFn === 'function') {
overrideValueFn(name, value);
}
},
[newPropName, overrideValueFn],
);
if (isEmpty && !showWhenEmpty && !canAddEntries) {
return null;
} else {
return (
@@ -73,6 +96,20 @@ export default function InspectedElementTree({
value={value}
/>
))}
{canAddEntries && (
<div className={styles.AddEntry} key={newPropKey}>
<EditableName
autoFocus={newPropKey > 0}
overrideNameFn={setNewPropName}
/>
:&nbsp;
<EditableValue
initialValue={''}
overrideValueFn={handleNewEntryValue}
path={[newPropName]}
/>
</div>
)}
</div>
);
}
@@ -105,7 +105,7 @@ export default function KeyValue({
dataType={dataType}
overrideValueFn={((overrideValueFn: any): OverrideValueFn)}
path={path}
value={value}
initialValue={value}
/>
) : (
<span className={styles.Value}>{displayValue}</span>
@@ -19,6 +19,7 @@ import ButtonIcon from '../../ButtonIcon';
import {serializeDataForCopy} from '../../utils';
import AutoSizeInput from './AutoSizeInput';
import styles from './StyleEditor.css';
import {sanitizeForParse} from '../../../utils';
import type {Style} from './types';
@@ -290,16 +291,3 @@ function Field({
/>
);
}
// We use JSON.parse to parse string values
// e.g. 'foo' is not valid JSON but it is a valid string
// so this method replaces e.g. 'foo' with "foo"
function sanitizeForParse(value: any) {
if (typeof value === 'string') {
if (value.charAt(0) === "'" && value.charAt(value.length - 1) === "'") {
return '"' + value.substr(1, value.length - 2) + '"';
}
}
return value;
}
@@ -358,6 +358,7 @@ function InspectedElementView({
inspectPath={inspectPropsPath}
overrideValueFn={overridePropsFn}
showWhenEmpty={true}
canAddEntries={true}
/>
{type === ElementTypeSuspense ? (
<InspectedElementTree
+65 -1
View File
@@ -8,11 +8,75 @@
*/
import throttle from 'lodash.throttle';
import {useCallback, useEffect, useLayoutEffect, useState} from 'react';
import {
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useState,
} from 'react';
import {unstable_batchedUpdates as batchedUpdates} from 'react-dom';
import {
localStorageGetItem,
localStorageSetItem,
} from 'react-devtools-shared/src/storage';
import {sanitizeForParse, smartParse, smartStringify} from '../utils';
type EditableValue = {|
editableValue: any,
hasPendingChanges: boolean,
isValid: boolean,
parsedValue: any,
reset: () => void,
update: (newValue: any) => void,
|};
// Convenience hook for working with an editable value that is validated via JSON.parse.
export function useEditableValue(
initialValue: any,
initialIsValid?: boolean = true,
): EditableValue {
const [editableValue, setEditableValue] = useState(() =>
smartStringify(initialValue),
);
const [parsedValue, setParsedValue] = useState(initialValue);
const [isValid, setIsValid] = useState(initialIsValid);
const reset = useCallback(() => {
setEditableValue(smartStringify(initialValue));
setParsedValue(initialValue);
setIsValid(initialIsValid);
}, []);
const update = useCallback(newValue => {
let isNewValueValid = false;
let newParsedValue;
try {
newParsedValue = smartParse(newValue);
isNewValueValid = true;
} catch (error) {}
batchedUpdates(() => {
setEditableValue(sanitizeForParse(newValue));
if (isNewValueValid) {
setParsedValue(newParsedValue);
}
setIsValid(isNewValueValid);
});
}, []);
return useMemo(
() => ({
editableValue,
hasPendingChanges: smartStringify(initialValue) !== editableValue,
isValid,
parsedValue,
reset,
update,
}),
[editableValue, initialValue, isValid, parsedValue],
);
}
export function useIsOverflowing(
containerRef: {current: HTMLDivElement | null},