mirror of
https://github.com/facebook/react.git
synced 2025-11-01 09:12:30 +00:00
[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:
committed by
Brian Vaughn
parent
4ef6387d6e
commit
709baf1fec
@@ -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;
|
||||
}
|
||||
|
||||
+45
-99
@@ -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;
|
||||
}
|
||||
|
||||
+39
-2
@@ -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}
|
||||
/>
|
||||
:
|
||||
<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>
|
||||
|
||||
Vendored
+1
-13
@@ -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;
|
||||
}
|
||||
|
||||
+1
@@ -358,6 +358,7 @@ function InspectedElementView({
|
||||
inspectPath={inspectPropsPath}
|
||||
overrideValueFn={overridePropsFn}
|
||||
showWhenEmpty={true}
|
||||
canAddEntries={true}
|
||||
/>
|
||||
{type === ElementTypeSuspense ? (
|
||||
<InspectedElementTree
|
||||
|
||||
+65
-1
@@ -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},
|
||||
|
||||
Reference in New Issue
Block a user