diff --git a/.babelrc b/.babelrc index 15d27ad9b4..166ed4e2d6 100644 --- a/.babelrc +++ b/.babelrc @@ -1,4 +1,5 @@ { "stage": 0, - "loose": "all" + "loose": "all", + "plugins": ["object-assign"] } diff --git a/examples/counter/containers/App.js b/examples/counter/containers/App.js index edca2dd1f2..ae75692994 100644 --- a/examples/counter/containers/App.js +++ b/examples/counter/containers/App.js @@ -1,9 +1,14 @@ import React, { Component } from 'react'; import CounterApp from './CounterApp'; -import { createStore, applyMiddleware, combineReducers } from 'redux'; +import { createStore, applyMiddleware, compose, combineReducers } from 'redux'; import { Provider } from 'react-redux'; import * as reducers from '../reducers'; +import devTools from '../redux-devtools/index'; +import persistState from '../redux-devtools/persistState'; +import DebugPanel from '../redux-devtools/DebugPanel'; +import ReduxMonitor from '../redux-devtools/ReduxMonitor'; + // TODO: move into a separate project function thunk({ dispatch, getState }) { return next => action => @@ -12,16 +17,28 @@ function thunk({ dispatch, getState }) { next(action); } -const createStoreWithMiddleware = applyMiddleware(thunk)(createStore); +const finalCreateStore = compose( + applyMiddleware(), + devTools(), + persistState(window.location.href.match(/[?&]debug_session=([^&]+)\b/)), + createStore +); + const reducer = combineReducers(reducers); -const store = createStoreWithMiddleware(reducer); +const store = finalCreateStore(combineReducers(reducers)); export default class App extends Component { render() { return ( - - {() => } - +
+ + {() => } + + + + + +
); } } diff --git a/examples/counter/redux-devtools/DebugPanel.js b/examples/counter/redux-devtools/DebugPanel.js new file mode 100644 index 0000000000..8c0b289007 --- /dev/null +++ b/examples/counter/redux-devtools/DebugPanel.js @@ -0,0 +1,46 @@ +import React, { PropTypes } from 'react'; + +export default class DebugPanel { + static propTypes = { + left: PropTypes.bool, + right: PropTypes.bool, + bottom: PropTypes.bool, + top: PropTypes.bool + }; + + render() { + if (process.env.NODE_ENV === 'production') { + return null; + } + + let { left, right, bottom, top } = this.props; + if (typeof left === 'undefined' && typeof right === 'undefined') { + right = true; + } + if (typeof top === 'undefined' && typeof bottom === 'undefined') { + bottom = true; + } + + return ( +
+ {this.props.children} +
+ ); + } +} diff --git a/examples/counter/redux-devtools/Entry.js b/examples/counter/redux-devtools/Entry.js new file mode 100644 index 0000000000..6447b768f8 --- /dev/null +++ b/examples/counter/redux-devtools/Entry.js @@ -0,0 +1,119 @@ +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) { + token = token.split(''); + token = token.concat(token.reverse()); + const number = token + .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 Entry { + 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) { + if (!error) { + try { + return JSON.stringify(this.props.select(state)); + } catch (err) { + error = 'Error selecting state.'; + } + } + + return ( + + ({error}) + + ); + } + + handleActionClick(e) { + const { index, onActionClick } = this.props; + if (index > 0) { + onActionClick(index); + } + } + + render() { + const { index, error, action, state, collapsed, onActionClick } = this.props; + const { type = '' } = action; + const { r, g, b } = colorFromString(action.type); + + return ( +
+ 0) ? 'hand' : 'default', + WebkitUserSelect: 'none' + }}> + {JSON.stringify(action)} + + + {!collapsed && +

+ ⇧ +

+ } + + {!collapsed && +
+ {this.printState(state, error)} +
+ } + +
+
+ ); + } +} diff --git a/examples/counter/redux-devtools/Monitor.js b/examples/counter/redux-devtools/Monitor.js new file mode 100644 index 0000000000..baddec54ac --- /dev/null +++ b/examples/counter/redux-devtools/Monitor.js @@ -0,0 +1,130 @@ +import React, { PropTypes, findDOMNode } from 'react'; +import Entry from './Entry'; + +export default class Monitor { + 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 { scrollTop, 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 ( +
+
+ + Reset + +
+ {elements} +
+ {computedStates.length > 1 && + + Rollback + + } + {Object.keys(skippedActions).some(key => skippedActions[key]) && + + {' • '} + + Sweep + + + } + {computedStates.length > 1 && + + + {' • '} + + + Commit + + + } +
+
+ ); + } +} diff --git a/examples/counter/redux-devtools/ReduxMonitor.js b/examples/counter/redux-devtools/ReduxMonitor.js new file mode 100644 index 0000000000..46ede1e468 --- /dev/null +++ b/examples/counter/redux-devtools/ReduxMonitor.js @@ -0,0 +1,41 @@ +import React, { PropTypes } from 'react'; +import { bindActionCreators } from 'redux'; +import { Provider, Connector } from 'react-redux'; +import { ActionCreators } from './index'; +import Monitor from './Monitor'; + +export default class ReduxMonitor { + static propTypes = { + store: PropTypes.shape({ + devToolsStore: PropTypes.shape({ + dispatch: PropTypes.func.isRequired + }).isRequired + }).isRequired, + select: PropTypes.func + }; + + render() { + const { devToolsStore } = this.props.store; + return ( + + {this.renderRoot} + + ); + } + + renderRoot = () => { + return ( + + {this.renderMonitor} + + ); + }; + + renderMonitor = ({ dispatch, ...props }) => { + return ( + + ); + }; +} diff --git a/examples/counter/redux-devtools/index.js b/examples/counter/redux-devtools/index.js new file mode 100644 index 0000000000..62392ab5eb --- /dev/null +++ b/examples/counter/redux-devtools/index.js @@ -0,0 +1,206 @@ +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) { + obj = { ...obj }; + if (obj[key]) { + delete obj[key]; + } else { + obj[key] = true; + } + return obj; +} + +/** + * 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' + }; + } + + try { + state = reducer(state, action); + } catch (err) { + error = err.toString(); + } + + return { state, error }; +} + +/** + * 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; + } + + 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/examples/counter/redux-devtools/persistState.js b/examples/counter/redux-devtools/persistState.js new file mode 100644 index 0000000000..5a438b319f --- /dev/null +++ b/examples/counter/redux-devtools/persistState.js @@ -0,0 +1,30 @@ +export default function persistState(sessionId) { + if (!sessionId) { + return next => (...args) => next(...args); + } + + return next => (reducer, initialState) => { + const key = `redux-dev-session-${sessionId}`; + + try { + initialState = JSON.parse(localStorage.getItem(key)) || initialState; + next(reducer, initialState); + } catch (e) { + try { + localStorage.removeItem(key); + } finally { + initialState = undefined; + } + } + + const store = next(reducer, initialState); + + return Object.assign(Object.create(store), { + dispatch(action) { + store.dispatch(action); + localStorage.setItem(key, JSON.stringify(store.getState())); + return action; + } + }); + }; +} diff --git a/package.json b/package.json index 86f808b435..751d38edbd 100644 --- a/package.json +++ b/package.json @@ -39,6 +39,7 @@ "babel-core": "^5.6.18", "babel-eslint": "^3.1.15", "babel-loader": "^5.1.4", + "babel-plugin-object-assign": "^1.2.0", "eslint": "^0.23", "eslint-config-airbnb": "0.0.6", "eslint-plugin-react": "^2.3.0",