diff --git a/.eslintrc b/.eslintrc
index 04a5a3af..47dc0576 100644
--- a/.eslintrc
+++ b/.eslintrc
@@ -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"
diff --git a/src/createDevTools.js b/src/createDevTools.js
new file mode 100644
index 00000000..127f235c
--- /dev/null
+++ b/src/createDevTools.js
@@ -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 (
+
+ {this.renderRoot}
+
+ );
+ }
+
+ renderRoot = () => {
+ return (
+
+ {this.renderMonitor}
+
+ );
+ };
+
+ renderMonitor = ({ dispatch, ...state }) => {
+ const { monitor: Monitor, ...rest } = this.props;
+ return (
+
+ );
+ };
+ };
+}
diff --git a/src/devTools.js b/src/devTools.js
new file mode 100644
index 00000000..10677a4d
--- /dev/null
+++ b/src/devTools.js
@@ -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;
+ };
+}
diff --git a/src/index.js b/src/index.js
index e69de29b..6e63a3a8 100644
--- a/src/index.js
+++ b/src/index.js
@@ -0,0 +1,2 @@
+export { default as devTools } from './devTools';
+export { default as persistState } from './persistState';
diff --git a/src/persistState.js b/src/persistState.js
new file mode 100644
index 00000000..09a6d598
--- /dev/null
+++ b/src/persistState.js
@@ -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;
+ }
+ };
+ };
+}
diff --git a/src/react/DebugPanel.js b/src/react/DebugPanel.js
new file mode 100644
index 00000000..6dac89f4
--- /dev/null
+++ b/src/react/DebugPanel.js
@@ -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 (
+
+ {this.props.children}
+
+ );
+ }
+}
diff --git a/src/react/LogMonitor.js b/src/react/LogMonitor.js
new file mode 100644
index 00000000..4771178d
--- /dev/null
+++ b/src/react/LogMonitor.js
@@ -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(
+
+ );
+ }
+
+ return (
+
+
+ {elements}
+
+ {computedStates.length > 1 &&
+
+ Rollback
+
+ }
+ {Object.keys(skippedActions).some(key => skippedActions[key]) &&
+
+ {' • '}
+
+ Sweep
+
+
+ }
+ {computedStates.length > 1 &&
+
+
+ {' • '}
+
+
+ Commit
+
+
+ }
+
+
+ );
+ }
+}
diff --git a/src/react/LogMonitorEntry.js b/src/react/LogMonitorEntry.js
new file mode 100644
index 00000000..b122a64a
--- /dev/null
+++ b/src/react/LogMonitorEntry.js
@@ -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 (
+
+ ({errorText})
+
+ );
+ }
+
+ 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 (
+
+ );
+ }
+}
diff --git a/src/react/index.js b/src/react/index.js
new file mode 100644
index 00000000..cce0e96d
--- /dev/null
+++ b/src/react/index.js
@@ -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';
diff --git a/src/react/native/index.js b/src/react/native/index.js
new file mode 100644
index 00000000..0ffdd02f
--- /dev/null
+++ b/src/react/native/index.js
@@ -0,0 +1 @@
+// TODO
\ No newline at end of file