Support editable props, state, and context values

This commit is contained in:
Brian Vaughn
2019-02-17 13:07:39 -08:00
parent 6c226b0c80
commit a4212dcdce
15 changed files with 428 additions and 37 deletions
@@ -0,0 +1,13 @@
.App {
/* GitHub.com frontend fonts */
font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Helvetica, Arial,
sans-serif, Apple Color Emoji, Segoe UI Emoji, Segoe UI Symbol;
font-size: 14px;
line-height: 1.5;
}
.Header {
font-size: 1.5rem;
font-weight: bold;
margin-bottom: 0.5rem;
}
@@ -0,0 +1,57 @@
// @flow
import React, { createContext, Component, Fragment } from 'react';
import styles from './EditableProps.css';
type StatefulFunctionProps = {| count: number |};
function StatefulFunction({ count }: StatefulFunctionProps) {
return <li>Count: {count}</li>;
}
const BoolContext = createContext(true);
// $FlowFixMe Flow does not yet know about Context.displayName
BoolContext.displayName = 'BoolContext';
type Props = {| name: string, toggle: boolean |};
type State = {| cities: Array<string>, state: string |};
class StatefulClass extends Component<Props, State> {
static contextType = BoolContext;
state: State = {
cities: ['San Francisco', 'San Jose'],
state: 'California',
};
handleChange = ({ target }) =>
this.setState({
state: target.value,
});
render() {
return (
<Fragment>
<li>Name: {this.props.name}</li>
<li>Toggle: {this.props.toggle ? 'true' : 'false'}</li>
<li>
State: <input value={this.state.state} onChange={this.handleChange} />
</li>
<li>Cities: {this.state.cities.join(', ')}</li>
<li>Context: {this.context ? 'true' : 'false'}</li>
</Fragment>
);
}
}
export default function EditableProps() {
return (
<div className={styles.App}>
<div className={styles.Header}>Editable props</div>
<ul>
<StatefulClass name="Brian" toggle={true} />
<StatefulFunction count={1} />
</ul>
</div>
);
}
+5
View File
@@ -0,0 +1,5 @@
// @flow
import EditableProps from './EditableProps';
export default EditableProps;
+2
View File
@@ -4,6 +4,7 @@
import { createElement } from 'react';
import { render, unmountComponentAtNode } from 'react-dom';
import EditableProps from './EditableProps';
import ElementTypes from './ElementTypes';
import InspectableElements from './InspectableElements';
import ToDoList from './ToDoList';
@@ -24,6 +25,7 @@ function mountTestApp() {
mountHelper(ToDoList);
mountHelper(InspectableElements);
mountHelper(ElementTypes);
mountHelper(EditableProps);
}
function unmountTestApp() {
+44 -2
View File
@@ -18,6 +18,18 @@ const debug = (methodName, ...args) => {
}
};
type InspectSelectParams = {|
id: number,
rendererID: number,
|};
type SetInParams = {|
id: number,
path: Array<string | number>,
rendererID: number,
value: any,
|};
export default class Agent extends EventEmitter {
_bridge: Bridge = ((null: any): Bridge);
_rendererInterfaces: { [key: RendererID]: RendererInterface } = {};
@@ -27,6 +39,9 @@ export default class Agent extends EventEmitter {
bridge.addListener('highlightElementInDOM', this.highlightElementInDOM);
bridge.addListener('inspectElement', this.inspectElement);
bridge.addListener('overrideContext', this.overrideContext);
bridge.addListener('overrideProps', this.overrideProps);
bridge.addListener('overrideState', this.overrideState);
bridge.addListener('selectElement', this.selectElement);
bridge.addListener('startInspectingDOM', this.startInspectingDOM);
bridge.addListener('stopInspectingDOM', this.stopInspectingDOM);
@@ -80,7 +95,7 @@ export default class Agent extends EventEmitter {
}
};
inspectElement = ({ id, rendererID }: { id: number, rendererID: number }) => {
inspectElement = ({ id, rendererID }: InspectSelectParams) => {
const renderer = this._rendererInterfaces[rendererID];
if (renderer == null) {
console.warn(`Invalid renderer id "${rendererID}" for element "${id}"`);
@@ -89,7 +104,7 @@ export default class Agent extends EventEmitter {
}
};
selectElement = ({ id, rendererID }: { id: number, rendererID: number }) => {
selectElement = ({ id, rendererID }: InspectSelectParams) => {
const renderer = this._rendererInterfaces[rendererID];
if (renderer == null) {
console.warn(`Invalid renderer id "${rendererID}" for element "${id}"`);
@@ -98,6 +113,33 @@ export default class Agent extends EventEmitter {
}
};
overrideContext = ({ id, path, rendererID, value }: SetInParams) => {
const renderer = this._rendererInterfaces[rendererID];
if (renderer == null) {
console.warn(`Invalid renderer id "${rendererID}" for element "${id}"`);
} else {
renderer.setInContext(id, path, value);
}
};
overrideProps = ({ id, path, rendererID, value }: SetInParams) => {
const renderer = this._rendererInterfaces[rendererID];
if (renderer == null) {
console.warn(`Invalid renderer id "${rendererID}" for element "${id}"`);
} else {
renderer.setInProps(id, path, value);
}
};
overrideState = ({ id, path, rendererID, value }: SetInParams) => {
const renderer = this._rendererInterfaces[rendererID];
if (renderer == null) {
console.warn(`Invalid renderer id "${rendererID}" for element "${id}"`);
} else {
renderer.setInState(id, path, value);
}
};
setRendererInterface(
rendererID: RendererID,
rendererInterface: RendererInterface
+62 -7
View File
@@ -2,7 +2,8 @@
import { gte } from 'semver';
import {
ElementTypeClassOrFunction,
ElementTypeClass,
ElementTypeFunction,
ElementTypeContext,
ElementTypeForwardRef,
ElementTypeMemo,
@@ -12,7 +13,7 @@ import {
ElementTypeSuspense,
} from 'src/devtools/types';
import { getDisplayName, utfEncodeString } from '../utils';
import { cleanForBridge } from './utils';
import { cleanForBridge, copyWithSet, setInObject } from './utils';
import {
__DEBUG__,
TREE_OPERATION_ADD,
@@ -193,6 +194,8 @@ export function attach(
DEPRECATED_PLACEHOLDER_SYMBOL_STRING,
} = ReactSymbols;
const { overrideProps } = renderer;
const debug = (name: string, fiber: Fiber, parentFiber: ?Fiber): void => {
if (__DEBUG__) {
const fiberData = getDataForFiber(fiber);
@@ -295,13 +298,19 @@ export function attach(
switch (tag) {
case ClassComponent:
case FunctionComponent:
case IncompleteClassComponent:
fiberData = {
displayName: getDisplayName(resolvedType),
key,
type: ElementTypeClass,
};
break;
case FunctionComponent:
case IndeterminateComponent:
fiberData = {
displayName: getDisplayName(resolvedType),
key,
type: ElementTypeClassOrFunction,
type: ElementTypeFunction,
};
break;
case ForwardRef:
@@ -1072,7 +1081,7 @@ export function attach(
tag === IncompleteClassComponent ||
tag === IndeterminateComponent
) {
if (stateNode && Object.keys(stateNode.context).length > 0) {
if (stateNode && stateNode.context != null) {
context = stateNode.context;
}
} else if (
@@ -1135,7 +1144,7 @@ export function attach(
id,
// Does the current renderer support editable props/state/hooks?
canEditValues: false, // TODO
canEditFunctionProps: typeof overrideProps === 'function',
// Inspectable properties.
// TODO Review sanitization approach for the below inspectable values.
@@ -1156,6 +1165,49 @@ export function attach(
};
}
function setInProps(id: number, path: Array<string | number>, value: any) {
const fiber = findCurrentFiberUsingSlowPath(idToFiberMap.get(id));
if (fiber !== null) {
const instance = fiber.stateNode;
if (instance === null) {
if (typeof overrideProps === 'function') {
overrideProps(fiber, path, value);
}
} else {
fiber.pendingProps = copyWithSet(instance.props, path, value);
instance.forceUpdate();
}
}
}
function setInState(id: number, path: Array<string | number>, value: any) {
const fiber = findCurrentFiberUsingSlowPath(idToFiberMap.get(id));
if (fiber !== null) {
const instance = fiber.stateNode;
setInObject(instance.state, path, value);
instance.forceUpdate();
}
}
function setInContext(id: number, path: Array<string | number>, value: any) {
// To simplify hydration and display of primative context values (e.g. number, string)
// the inspectElement() method wraps context in a {value: ...} object.
// We need to remove the first part of the path (the "value") before continuing.
path = path.slice(1);
const fiber = findCurrentFiberUsingSlowPath(idToFiberMap.get(id));
if (fiber !== null) {
const instance = fiber.stateNode;
if (path.length === 0) {
// Simple context value
instance.context = value;
} else {
setInObject(instance.context, path, value);
}
instance.forceUpdate();
}
}
return {
getFiberIDFromNative,
getNativeFromReactElement,
@@ -1164,7 +1216,10 @@ export function attach(
inspectElement,
selectElement,
cleanup,
walkTree,
renderer,
setInContext,
setInProps,
setInState,
walkTree,
};
}
+3
View File
@@ -55,6 +55,9 @@ export type RendererInterface = {
inspectElement: (id: number) => InspectedElement | null,
renderer: ReactRenderer | null,
selectElement: (id: number) => void,
setInProps: (id: number, path: Array<string | number>, value: any) => void,
setInState: (id: number, path: Array<string | number>, value: any) => void,
setInContext: (id: number, path: Array<string | number>, value: any) => void,
walkTree: () => void,
};
+34
View File
@@ -16,3 +16,37 @@ export function cleanForBridge(data: Object | null): DehydratedData | null {
return null;
}
}
export function copyWithSet(
obj: Object | Array<any>,
path: Array<string | number>,
value: any,
index: number = 0
): Object | Array<any> {
if (index >= path.length) {
return value;
}
const key = path[index];
const updated = Array.isArray(obj) ? obj.slice() : { ...obj };
// $FlowFixMe number or string is fine here
updated[key] = copyWithSet(obj[key], path, value, index + 1);
return updated;
}
export function setInObject(
object: Object,
path: Array<string | number>,
value: any
) {
const last = path.pop();
if (object != null) {
const parent: Object = path.reduce(
// $FlowFixMe
(reduced, attribute) => reduced[attribute],
object
);
if (parent) {
parent[last] = value;
}
}
}
+12 -11
View File
@@ -1,18 +1,19 @@
// @flow
export const ElementTypeClassOrFunction = 1;
export const ElementTypeContext = 2;
export const ElementTypeForwardRef = 3;
export const ElementTypeMemo = 4;
export const ElementTypeOtherOrUnknown = 5;
export const ElementTypeProfiler = 6;
export const ElementTypeRoot = 7;
export const ElementTypeSuspense = 8;
export const ElementTypeClass = 1;
export const ElementTypeFunction = 2;
export const ElementTypeContext = 3;
export const ElementTypeForwardRef = 4;
export const ElementTypeMemo = 5;
export const ElementTypeOtherOrUnknown = 6;
export const ElementTypeProfiler = 7;
export const ElementTypeRoot = 8;
export const ElementTypeSuspense = 9;
// Different types of elements displayed in the Elements tree.
// These types may be used to visually distinguish types,
// or to enable/disable certain functionality.
export type ElementType = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8;
export type ElementType = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9;
// Each element on the frontend corresponds to a Fiber on the backend.
// Some of its information (e.g. id, type, displayName) come from the backend.
@@ -47,8 +48,8 @@ export type Owner = {|
export type InspectedElement = {|
id: number,
// Does the current renderer support editable props/state/hooks?
canEditValues: boolean,
// Does the current renderer support editable function props?
canEditFunctionProps: boolean,
// Inspectable properties.
context: Object | null,
+3 -2
View File
@@ -1,7 +1,7 @@
// @flow
import React, { Fragment, useCallback, useContext, useMemo } from 'react';
import { ElementTypeClassOrFunction } from 'src/devtools/types';
import { ElementTypeClass, ElementTypeFunction } from 'src/devtools/types';
import { createRegExp } from './utils';
import { TreeContext } from './TreeContext';
@@ -42,7 +42,8 @@ export default function ElementView({ index, style }: Props) {
);
const isSelected = selectedElementID === id;
const showDollarR = isSelected && type === ElementTypeClassOrFunction;
const showDollarR =
isSelected && (type === ElementTypeClass || type === ElementTypeFunction);
// TODO styles.SelectedElement is 100% width but it doesn't take horizontal overflow into account.
@@ -7,16 +7,44 @@
}
.Item {
display: flex;
}
.Name {
color: var(--color-attribute-name);
flex: 0 0 auto;
}
.Name:after {
content: ': ';
color: var(--color-text-color);
margin-right: 0.5rem;
}
.Value {
color: var(--color-attribute-value);
}
.ValueInputLabel {
flex: 1 1 100%;
}
.ValueInputLabel:focus-within {
background-color: var(--color-button-background-focus);
}
.ValueInput {
background: none;
border: 1px solid transparent;
color: var(--color-attribute-editable-value);
border-radius: 0.125rem;
width: 100%;
font-family: var(--font-family-monospace);
font-size: var(--font-size-monospace-normal);
}
.ValueInput:focus {
background-color: var(--color-button-background-focus);
outline: none;
}
.None {
color: var(--color-dimmer);
font-style: italic;
+106 -8
View File
@@ -1,19 +1,23 @@
// @flow
import React from 'react';
import React, { useCallback, useState } from 'react';
import { getMetaValueLabel } from './utils';
import { meta } from '../../hydration';
import styles from './InspectedElementTree.css';
type OverrideValueFn = (path: Array<string | number>, value: any) => void;
type Props = {|
data: Object | null,
label: string,
overrideValueFn?: ?OverrideValueFn,
showWhenEmpty?: boolean,
|};
export default function InspectedElementTree({
data,
label,
overrideValueFn,
showWhenEmpty = false,
}: Props) {
const isEmpty = data === null || Object.keys(data).length === 0;
@@ -33,6 +37,8 @@ export default function InspectedElementTree({
key={name}
depth={1}
name={name}
overrideValueFn={overrideValueFn}
path={[name]}
value={(data: any)[name]}
/>
))}
@@ -44,10 +50,18 @@ export default function InspectedElementTree({
type KeyValueProps = {|
depth: number,
name: string,
overrideValueFn?: ?OverrideValueFn,
path?: Array<any>,
value: any,
|};
export function KeyValue({ depth, name, value }: KeyValueProps) {
export function KeyValue({
depth,
name,
overrideValueFn,
path = [],
value,
}: KeyValueProps) {
const dataType = typeof value;
const isSimpleType =
dataType === 'number' ||
@@ -72,14 +86,24 @@ export function KeyValue({ depth, name, value }: KeyValueProps) {
children = (
<div key="root" className={styles.Item} style={{ paddingLeft }}>
<span className={styles.Name}>{name}</span>:{' '}
<span className={styles.Value}>{displayValue}</span>
<span className={styles.Name}>{name}</span>
{typeof overrideValueFn === 'function' ? (
<EditableValue
dataType={dataType}
overrideValueFn={overrideValueFn}
path={path}
value={value}
/>
) : (
<span className={styles.Value}>{displayValue}</span>
)}
</div>
);
} else if (value.hasOwnProperty(meta.type)) {
// TODO Is this type even necessary? Can we just drop it?
children = (
<div key="root" className={styles.Item} style={{ paddingLeft }}>
<span className={styles.Name}>{name}</span>:{' '}
<span className={styles.Name}>{name}</span>
<span className={styles.Value}>{getMetaValueLabel(value)}</span>
</div>
);
@@ -90,6 +114,8 @@ export function KeyValue({ depth, name, value }: KeyValueProps) {
key={index}
depth={depth + 1}
name={index}
overrideValueFn={overrideValueFn}
path={path.concat(index)}
value={value[index]}
/>
));
@@ -99,13 +125,21 @@ export function KeyValue({ depth, name, value }: KeyValueProps) {
className={styles.Item}
style={{ paddingLeft }}
>
<span className={styles.Name}>{name}</span>: <span>Array</span>
<span className={styles.Name}>{name}</span>
<span>Array</span>
</div>
);
} else {
// $FlowFixMe
children = Object.entries(value).map(([name, value]) => (
<KeyValue key={name} depth={depth + 1} name={name} value={value} />
<KeyValue
key={name}
depth={depth + 1}
name={name}
overrideValueFn={overrideValueFn}
path={path.concat(name)}
value={value}
/>
));
children.unshift(
<div
@@ -113,7 +147,8 @@ export function KeyValue({ depth, name, value }: KeyValueProps) {
className={styles.Item}
style={{ paddingLeft }}
>
<span className={styles.Name}>{name}</span>: <span>Object</span>
<span className={styles.Name}>{name}</span>
<span>Object</span>
</div>
);
}
@@ -121,3 +156,66 @@ export function KeyValue({ depth, name, value }: KeyValueProps) {
return children;
}
type EditableValueProps = {|
dataType: string,
overrideValueFn: OverrideValueFn,
path: Array<string | number>,
value: any,
|};
function EditableValue({
dataType,
overrideValueFn,
path,
value,
}: EditableValueProps) {
const [editableValue, setEditableValue] = useState(value);
const handleChange = useCallback(
({ target }) => {
if (dataType === 'boolean') {
setEditableValue(target.checked);
overrideValueFn(path, target.checked);
} else if (dataType === 'number') {
setEditableValue(parseFloat(target.value));
} else {
setEditableValue(target.value);
}
},
[dataType, setEditableValue]
);
const handleKeyPress = useCallback(
({ key }) => {
if (key === 'Enter') {
overrideValueFn(path, editableValue);
}
},
[path, editableValue, overrideValueFn]
);
const handleKeyDown = useCallback(event => event.stopPropagation(), []);
// Render different input types based on the dataType
let type = 'text';
if (dataType === 'boolean') {
type = 'checkbox';
} else if (dataType === 'number') {
type = 'number';
}
return (
<label className={styles.ValueInputLabel}>
<input
checked={dataType === 'boolean' ? editableValue : undefined}
className={styles.ValueInput}
onChange={handleChange}
onKeyDown={handleKeyDown}
onKeyPress={handleKeyPress}
type={type}
value={dataType === 'boolean' ? undefined : editableValue || ''}
/>
</label>
);
}
+56 -7
View File
@@ -15,9 +15,10 @@ import HooksTree from './HooksTree';
import InspectedElementTree from './InspectedElementTree';
import { hydrate } from 'src/hydration';
import styles from './SelectedElement.css';
import { ElementTypeClass, ElementTypeFunction } from '../types';
import type { InspectedElement } from '../types';
import type { DehydratedData } from 'src/devtools/types';
import type { DehydratedData, Element } from 'src/devtools/types';
export type Props = {||};
@@ -87,26 +88,74 @@ export default function SelectedElement(_: Props) {
)}
{inspectedElement !== null && (
<InspectedElementView inspectedElement={inspectedElement} />
<InspectedElementView
element={element}
inspectedElement={inspectedElement}
/>
)}
</div>
);
}
type InspectedElementViewProps = {|
element: Element,
inspectedElement: InspectedElement,
|};
function InspectedElementView({ inspectedElement }: InspectedElementViewProps) {
let { context, hooks, owners, props, state } = inspectedElement;
function InspectedElementView({
element,
inspectedElement,
}: InspectedElementViewProps) {
const { id, type } = element;
const { context, hooks, owners, props, state } = inspectedElement;
const { ownerStack } = useContext(TreeContext);
const bridge = useContext(BridgeContext);
const store = useContext(StoreContext);
let overrideContextFn = null;
let overridePropsFn = null;
let overrideStateFn = null;
if (type === ElementTypeClass) {
overrideContextFn = (path: Array<string | number>, value: any) => {
const rendererID = store.getRendererIDForElement(id);
bridge.send('overrideContext', { id, path, rendererID, value });
};
overridePropsFn = (path: Array<string | number>, value: any) => {
const rendererID = store.getRendererIDForElement(id);
bridge.send('overrideProps', { id, path, rendererID, value });
};
overrideStateFn = (path: Array<string | number>, value: any) => {
const rendererID = store.getRendererIDForElement(id);
bridge.send('overrideState', { id, path, rendererID, value });
};
} else if (type === ElementTypeFunction) {
// TODO Only enable this if renderer.canEditFunctionProps is true!
overridePropsFn = (path: Array<string | number>, value: any) => {
const rendererID = store.getRendererIDForElement(id);
bridge.send('overrideProps', { id, path, rendererID, value });
};
}
return (
<div className={styles.InspectedElement}>
<InspectedElementTree label="props" data={props} showWhenEmpty />
<InspectedElementTree label="state" data={state} />
<InspectedElementTree
label="props"
data={props}
overrideValueFn={overridePropsFn}
showWhenEmpty
/>
<InspectedElementTree
label="state"
data={state}
overrideValueFn={overrideStateFn}
/>
<HooksTree hooksTree={hooks} />
<InspectedElementTree label="context" data={context} />
<InspectedElementTree
label="context"
data={context}
overrideValueFn={overrideContextFn}
/>
{ownerStack.length === 0 && owners !== null && owners.length > 0 && (
<div className={styles.Owners}>
+1
View File
@@ -118,6 +118,7 @@ function updateDisplayDensity(displayDensity: DisplayDensity): void {
function updateThemeVariables(theme: Theme): void {
updateStyleHelper(theme, 'color-attribute-name');
updateStyleHelper(theme, 'color-attribute-value');
updateStyleHelper(theme, 'color-attribute-editable-value');
updateStyleHelper(theme, 'color-background');
updateStyleHelper(theme, 'color-border');
updateStyleHelper(theme, 'color-button-background');
+2
View File
@@ -6,6 +6,7 @@
/* Light theme */
--light-color-attribute-name: #ef6632;
--light-color-attribute-value: #1a1aa6;
--light-color-attribute-editable-value: #1a1aa6;
--light-color-background: #ffffff;
--light-color-button-background: #ffffff;
--light-color-button-background-focus: #ebf1fb;
@@ -31,6 +32,7 @@
/* Dark theme */
--dark-color-attribute-name: #9d87d2;
--dark-color-attribute-value: #cedae0;
--dark-color-attribute-editable-value: yellow;
--dark-color-background: #282c34;
--dark-color-button-background: #282c34;
--dark-color-button-background-focus: #3d424a;