diff --git a/packages/react-devtools-shared/src/devtools/utils.js b/packages/react-devtools-shared/src/devtools/utils.js index aaffcad56e272..0eb75451f2b6c 100644 --- a/packages/react-devtools-shared/src/devtools/utils.js +++ b/packages/react-devtools-shared/src/devtools/utils.js @@ -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); +} diff --git a/packages/react-devtools-shared/src/devtools/views/Components/EditableName.css b/packages/react-devtools-shared/src/devtools/views/Components/EditableName.css new file mode 100644 index 0000000000000..38b1f1d92b4f8 --- /dev/null +++ b/packages/react-devtools-shared/src/devtools/views/Components/EditableName.css @@ -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); +} \ No newline at end of file diff --git a/packages/react-devtools-shared/src/devtools/views/Components/EditableName.js b/packages/react-devtools-shared/src/devtools/views/Components/EditableName.js new file mode 100644 index 0000000000000..73b85cddf8c42 --- /dev/null +++ b/packages/react-devtools-shared/src/devtools/views/Components/EditableName.js @@ -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, 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 ( + + ); +} diff --git a/packages/react-devtools-shared/src/devtools/views/Components/EditableValue.css b/packages/react-devtools-shared/src/devtools/views/Components/EditableValue.css index 3087b852617e0..30fa8a43ee657 100644 --- a/packages/react-devtools-shared/src/devtools/views/Components/EditableValue.css +++ b/packages/react-devtools-shared/src/devtools/views/Components/EditableValue.css @@ -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; } diff --git a/packages/react-devtools-shared/src/devtools/views/Components/EditableValue.js b/packages/react-devtools-shared/src/devtools/views/Components/EditableValue.js index 46bc177763fe0..96f505c20f188 100644 --- a/packages/react-devtools-shared/src/devtools/views/Components/EditableValue.js +++ b/packages/react-devtools-shared/src/devtools/views/Components/EditableValue.js @@ -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, value: any) => void; type EditableValueProps = {| dataType: string, + initialValue: any, overrideValueFn: OverrideValueFn, path: Array, - 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(null); - - 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 { + editableValue, + hasPendingChanges, + isValid, + parsedValue, + reset, + update, + } = useEditableValue(initialValue); + + 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 ( - {dataType === 'boolean' && ( - - )} - {dataType !== 'boolean' && ( - + + {hasPendingChanges && ( + )} - {hasPendingChanges && - dataType !== 'boolean' && ( - - )} ); } diff --git a/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementTree.css b/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementTree.css index 620fbf412dd04..76c1e40761019 100644 --- a/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementTree.css +++ b/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementTree.css @@ -46,3 +46,10 @@ font-style: italic; padding-left: 0.75rem; } + +.AddEntry { + padding-left: 1rem; + white-space: nowrap; + display: flex; + align-items: center; +} diff --git a/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementTree.js b/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementTree.js index 1acc27af5a82c..edfb5f2c5679e 100644 --- a/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementTree.js +++ b/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementTree.js @@ -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(0); + const [newPropName, setNewPropName] = useState(''); + 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 && ( +
+ 0} + overrideNameFn={setNewPropName} + /> + :  + +
+ )} ); } diff --git a/packages/react-devtools-shared/src/devtools/views/Components/KeyValue.js b/packages/react-devtools-shared/src/devtools/views/Components/KeyValue.js index 8844a13f6807d..e05a35fc57a7f 100644 --- a/packages/react-devtools-shared/src/devtools/views/Components/KeyValue.js +++ b/packages/react-devtools-shared/src/devtools/views/Components/KeyValue.js @@ -105,7 +105,7 @@ export default function KeyValue({ dataType={dataType} overrideValueFn={((overrideValueFn: any): OverrideValueFn)} path={path} - value={value} + initialValue={value} /> ) : ( {displayValue} diff --git a/packages/react-devtools-shared/src/devtools/views/Components/NativeStyleEditor/StyleEditor.js b/packages/react-devtools-shared/src/devtools/views/Components/NativeStyleEditor/StyleEditor.js index 2a08e99cf4e71..8e0210d44b49c 100644 --- a/packages/react-devtools-shared/src/devtools/views/Components/NativeStyleEditor/StyleEditor.js +++ b/packages/react-devtools-shared/src/devtools/views/Components/NativeStyleEditor/StyleEditor.js @@ -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; -} diff --git a/packages/react-devtools-shared/src/devtools/views/Components/SelectedElement.js b/packages/react-devtools-shared/src/devtools/views/Components/SelectedElement.js index 8ac93a1995c0f..46af47c1d7773 100644 --- a/packages/react-devtools-shared/src/devtools/views/Components/SelectedElement.js +++ b/packages/react-devtools-shared/src/devtools/views/Components/SelectedElement.js @@ -358,6 +358,7 @@ function InspectedElementView({ inspectPath={inspectPropsPath} overrideValueFn={overridePropsFn} showWhenEmpty={true} + canAddEntries={true} /> {type === ElementTypeSuspense ? ( 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},