Import the initial devTools implementation

This commit is contained in:
Dan Abramov 2015-07-15 00:09:54 +03:00
parent de43b2e110
commit 644cd6fab6
10 changed files with 620 additions and 1 deletions

View File

@ -9,9 +9,10 @@
"react/jsx-uses-react": 2,
"react/jsx-uses-vars": 2,
"react/react-in-jsx-scope": 2,
"no-console": 0,
// Temporarily disabled due to babel-eslint issues:
"block-scoped-var": 0,
"padded-blocks": 0
"padded-blocks": 0,
},
"plugins": [
"react"

55
src/createDevTools.js Normal file
View File

@ -0,0 +1,55 @@
import createAll from 'react-redux/lib/components/createAll';
import { bindActionCreators } from 'redux';
import { ActionCreators } from './devTools';
export default function createDevTools(React) {
const { PropTypes, Component } = React;
const { Provider, Connector } = createAll(React);
return class DevTools extends Component {
static propTypes = {
monitor: PropTypes.func.isRequired,
store: PropTypes.shape({
devToolsStore: PropTypes.shape({
dispatch: PropTypes.func.isRequired
}).isRequired
}).isRequired
};
constructor(props, context) {
if (props.store && !props.store.devToolsStore) {
console.error(
'Could not find the devTools store inside your store. ' +
'Have you applied devTools() higher-order store?'
);
}
super(props, context);
}
render() {
const { devToolsStore } = this.props.store;
return (
<Provider store={devToolsStore}>
{this.renderRoot}
</Provider>
);
}
renderRoot = () => {
return (
<Connector>
{this.renderMonitor}
</Connector>
);
};
renderMonitor = ({ dispatch, ...state }) => {
const { monitor: Monitor, ...rest } = this.props;
return (
<Monitor {...state}
{...bindActionCreators(ActionCreators, dispatch)}
{...rest} />
);
};
};
}

213
src/devTools.js Normal file
View File

@ -0,0 +1,213 @@
const ActionTypes = {
PERFORM_ACTION: 'PERFORM_ACTION',
RESET: 'RESET',
ROLLBACK: 'ROLLBACK',
COMMIT: 'COMMIT',
SWEEP: 'SWEEP',
TOGGLE_ACTION: 'TOGGLE_ACTION'
};
const INIT_ACTION = {
type: '@@INIT'
};
function last(arr) {
return arr[arr.length - 1];
}
function toggle(obj, key) {
const clone = { ...obj };
if (clone[key]) {
delete clone[key];
} else {
clone[key] = true;
}
return clone;
}
/**
* Computes the next entry in the log by applying an action.
*/
function computeNextEntry(reducer, action, state, error) {
if (error) {
return {
state,
error: 'Interrupted by an error up the chain'
};
}
let nextState = state;
let nextError;
try {
nextState = reducer(state, action);
} catch (err) {
nextError = err.toString();
}
return {
state: nextState,
error: nextError
};
}
/**
* Runs the reducer on all actions to get a fresh computation log.
* It's probably a good idea to do this only if the code has changed,
* but until we have some tests we'll just do it every time an action fires.
*/
function recomputeStates(reducer, committedState, stagedActions, skippedActions) {
const computedStates = [];
for (let i = 0; i < stagedActions.length; i++) {
const action = stagedActions[i];
const previousEntry = computedStates[i - 1];
const previousState = previousEntry ? previousEntry.state : committedState;
const previousError = previousEntry ? previousEntry.error : undefined;
const shouldSkip = Boolean(skippedActions[i]);
const entry = shouldSkip ?
previousEntry :
computeNextEntry(reducer, action, previousState, previousError);
computedStates.push(entry);
}
return computedStates;
}
/**
* Lifts the app state reducer into a DevTools state reducer.
*/
function liftReducer(reducer, initialState) {
const initialLiftedState = {
committedState: initialState,
stagedActions: [INIT_ACTION],
skippedActions: {}
};
/**
* Manages how the DevTools actions modify the DevTools state.
*/
return function liftedReducer(liftedState = initialLiftedState, liftedAction) {
let {
committedState,
stagedActions,
skippedActions,
computedStates
} = liftedState;
switch (liftedAction.type) {
case ActionTypes.RESET:
committedState = initialState;
stagedActions = [INIT_ACTION];
skippedActions = {};
break;
case ActionTypes.COMMIT:
committedState = last(computedStates).state;
stagedActions = [INIT_ACTION];
skippedActions = {};
break;
case ActionTypes.ROLLBACK:
stagedActions = [INIT_ACTION];
skippedActions = {};
break;
case ActionTypes.TOGGLE_ACTION:
const { index } = liftedAction;
skippedActions = toggle(skippedActions, index);
break;
case ActionTypes.SWEEP:
stagedActions = stagedActions.filter((_, i) => !skippedActions[i]);
skippedActions = {};
break;
case ActionTypes.PERFORM_ACTION:
const { action } = liftedAction;
stagedActions = [...stagedActions, action];
break;
default:
break;
}
computedStates = recomputeStates(
reducer,
committedState,
stagedActions,
skippedActions
);
return {
committedState,
stagedActions,
skippedActions,
computedStates
};
};
}
/**
* Lifts an app action to a DevTools action.
*/
function liftAction(action) {
const liftedAction = { type: ActionTypes.PERFORM_ACTION, action };
return liftedAction;
}
/**
* Unlifts the DevTools state to the app state.
*/
function unliftState(liftedState) {
const { computedStates } = liftedState;
const { state } = last(computedStates);
return state;
}
/**
* Unlifts the DevTools store to act like the app's store.
*/
function unliftStore(liftedStore) {
return {
...liftedStore,
devToolsStore: liftedStore,
dispatch(action) {
liftedStore.dispatch(liftAction(action));
return action;
},
getState() {
return unliftState(liftedStore.getState());
}
};
}
/**
* Action creators to change the DevTools state.
*/
export const ActionCreators = {
reset() {
return { type: ActionTypes.RESET };
},
rollback() {
return { type: ActionTypes.ROLLBACK };
},
commit() {
return { type: ActionTypes.COMMIT };
},
sweep() {
return { type: ActionTypes.SWEEP };
},
toggleAction(index) {
return { type: ActionTypes.TOGGLE_ACTION, index };
}
};
/**
* Redux DevTools middleware.
*/
export default function devTools() {
return next => (reducer, initialState) => {
const liftedReducer = liftReducer(reducer, initialState);
const liftedStore = next(liftedReducer);
const store = unliftStore(liftedStore);
return store;
};
}

View File

@ -0,0 +1,2 @@
export { default as devTools } from './devTools';
export { default as persistState } from './persistState';

39
src/persistState.js Normal file
View File

@ -0,0 +1,39 @@
export default function persistState(sessionId) {
if (!sessionId) {
return next => (...args) => next(...args);
}
return next => (reducer, initialState) => {
const key = `redux-dev-session-${sessionId}`;
let finalInitialState;
try {
finalInitialState = JSON.parse(localStorage.getItem(key)) || initialState;
next(reducer, initialState);
} catch (e) {
console.warn('Could not read debug session from localStorage:', e);
try {
localStorage.removeItem(key);
} finally {
finalInitialState = undefined;
}
}
const store = next(reducer, finalInitialState);
return {
...store,
dispatch(action) {
store.dispatch(action);
try {
localStorage.setItem(key, JSON.stringify(store.getState()));
} catch (e) {
console.warn('Could not write debug session from localStorage:', e);
}
return action;
}
};
};
}

51
src/react/DebugPanel.js Normal file
View File

@ -0,0 +1,51 @@
import React, { PropTypes } from 'react';
export function getDefaultStyle(props) {
let { left, right, bottom, top } = props;
if (typeof left === 'undefined' && typeof right === 'undefined') {
right = true;
}
if (typeof top === 'undefined' && typeof bottom === 'undefined') {
bottom = true;
}
return {
position: 'fixed',
zIndex: 999,
fontSize: 17,
overflow: 'scroll',
opacity: 0.92,
background: 'black',
color: 'white',
padding: '1em',
left: left ? 0 : undefined,
right: right ? 0 : undefined,
top: top ? 0 : undefined,
bottom: bottom ? 0 : undefined,
maxHeight: (bottom && top) ? '100%' : '20%',
maxWidth: (left && right) ? '100%' : '20%',
wordWrap: 'break-word'
};
}
export default class DebugPanel {
static propTypes = {
left: PropTypes.bool,
right: PropTypes.bool,
bottom: PropTypes.bool,
top: PropTypes.bool,
getStyle: PropTypes.func.isRequired
};
static defaultProps = {
getStyle: getDefaultStyle
};
render() {
return (
<div style={this.props.getStyle(this.props)}>
{this.props.children}
</div>
);
}
}

130
src/react/LogMonitor.js Normal file
View File

@ -0,0 +1,130 @@
import React, { PropTypes, findDOMNode } from 'react';
import LogMonitorEntry from './LogMonitorEntry';
export default class LogMonitor {
static propTypes = {
computedStates: PropTypes.array.isRequired,
stagedActions: PropTypes.array.isRequired,
skippedActions: PropTypes.object.isRequired,
reset: PropTypes.func.isRequired,
commit: PropTypes.func.isRequired,
rollback: PropTypes.func.isRequired,
sweep: PropTypes.func.isRequired,
toggleAction: PropTypes.func.isRequired,
select: PropTypes.func.isRequired
};
static defaultProps = {
select: (state) => state
};
componentWillReceiveProps(nextProps) {
if (this.props.stagedActions.length < nextProps.stagedActions.length) {
const scrollableNode = findDOMNode(this).parentElement;
const { scrollTop, offsetHeight, scrollHeight } = scrollableNode;
this.scrollDown = Math.abs(
scrollHeight - (scrollTop + offsetHeight)
) < 20;
} else {
this.scrollDown = false;
}
}
componentDidUpdate(prevProps) {
if (
prevProps.stagedActions.length < this.props.stagedActions.length &&
this.scrollDown
) {
const scrollableNode = findDOMNode(this).parentElement;
const { offsetHeight, scrollHeight } = scrollableNode;
scrollableNode.scrollTop = scrollHeight - offsetHeight;
this.scrollDown = false;
}
}
handleRollback() {
this.props.rollback();
}
handleSweep() {
this.props.sweep();
}
handleCommit() {
this.props.commit();
}
handleToggleAction(index) {
this.props.toggleAction(index);
}
handleReset() {
this.props.reset();
}
render() {
const elements = [];
const { skippedActions, stagedActions, computedStates, select } = this.props;
for (let i = 0; i < stagedActions.length; i++) {
const action = stagedActions[i];
const { state, error } = computedStates[i];
elements.push(
<LogMonitorEntry key={i}
index={i}
select={select}
action={action}
state={state}
collapsed={skippedActions[i]}
error={error}
onActionClick={::this.handleToggleAction} />
);
}
return (
<div style={{
fontFamily: 'monospace',
position: 'relative'
}}>
<div>
<a onClick={::this.handleReset}
style={{ textDecoration: 'underline', cursor: 'hand' }}>
Reset
</a>
</div>
{elements}
<div>
{computedStates.length > 1 &&
<a onClick={::this.handleRollback}
style={{ textDecoration: 'underline', cursor: 'hand' }}>
Rollback
</a>
}
{Object.keys(skippedActions).some(key => skippedActions[key]) &&
<span>
{' • '}
<a onClick={::this.handleSweep}
style={{ textDecoration: 'underline', cursor: 'hand' }}>
Sweep
</a>
</span>
}
{computedStates.length > 1 &&
<span>
<span>
{' • '}
</span>
<a onClick={::this.handleCommit}
style={{ textDecoration: 'underline', cursor: 'hand' }}>
Commit
</a>
</span>
}
</div>
</div>
);
}
}

View File

@ -0,0 +1,121 @@
import React, { PropTypes } from 'react';
function hsvToRgb(h, s, v) {
const i = Math.floor(h);
const f = h - i;
const p = v * (1 - s);
const q = v * (1 - f * s);
const t = v * (1 - (1 - f) * s);
const mod = i % 6;
const r = [v, q, p, p, t, v][mod];
const g = [t, v, v, q, p, p][mod];
const b = [p, p, t, v, v, q][mod];
return {
r: Math.round(r * 255),
g: Math.round(g * 255),
b: Math.round(b * 255)
};
}
function colorFromString(token) {
const splitToken = token.split('');
const finalToken = splitToken.concat(splitToken.reverse());
const number = finalToken.reduce(
(sum, char) => sum + char.charCodeAt(0),
0
) * Math.abs(Math.sin(token.length));
const h = Math.round((number * (180 / Math.PI) * token.length) % 360);
const s = number % 100 / 100;
const v = 1;
return hsvToRgb(h, s, v);
}
export default class LogMonitorEntry {
static propTypes = {
index: PropTypes.number.isRequired,
state: PropTypes.object.isRequired,
action: PropTypes.object.isRequired,
select: PropTypes.func.isRequired,
error: PropTypes.string,
onActionClick: PropTypes.func.isRequired,
collapsed: PropTypes.bool
};
printState(state, error) {
let errorText = error;
if (!errorText) {
try {
return JSON.stringify(this.props.select(state));
} catch (err) {
errorText = 'Error selecting state.';
}
}
return (
<span style={{
fontStyle: 'italic'
}}>
({errorText})
</span>
);
}
handleActionClick() {
const { index, onActionClick } = this.props;
if (index > 0) {
onActionClick(index);
}
}
render() {
const { index, error, action, state, collapsed } = this.props;
const { r, g, b } = colorFromString(action.type);
return (
<div style={{
textDecoration: collapsed ? 'line-through' : 'none'
}}>
<a onClick={::this.handleActionClick}
style={{
opacity: collapsed ? 0.5 : 1,
marginTop: '1em',
display: 'block',
paddingBottom: '1em',
paddingTop: '1em',
color: `rgb(${r}, ${g}, ${b})`,
cursor: (index > 0) ? 'hand' : 'default',
WebkitUserSelect: 'none'
}}>
{JSON.stringify(action)}
</a>
{!collapsed &&
<p style={{
textAlign: 'center',
transform: 'rotate(180deg)'
}}>
</p>
}
{!collapsed &&
<div style={{
paddingBottom: '1em',
paddingTop: '1em',
color: 'lightyellow'
}}>
{this.printState(state, error)}
</div>
}
<hr style={{
marginBottom: '2em'
}} />
</div>
);
}
}

6
src/react/index.js Normal file
View File

@ -0,0 +1,6 @@
import React from 'react';
import createDevTools from '../createDevTools';
export const DevTools = createDevTools(React);
export { default as LogMonitor } from './LogMonitor';
export { default as DebugPanel } from './DebugPanel';

View File

@ -0,0 +1 @@
// TODO