From 644cd6fab6634210282eb0f6903bca3d1562ab1c Mon Sep 17 00:00:00 2001 From: Dan Abramov Date: Wed, 15 Jul 2015 00:09:54 +0300 Subject: [PATCH] Import the initial devTools implementation --- .eslintrc | 3 +- src/createDevTools.js | 55 +++++++++ src/devTools.js | 213 +++++++++++++++++++++++++++++++++++ src/index.js | 2 + src/persistState.js | 39 +++++++ src/react/DebugPanel.js | 51 +++++++++ src/react/LogMonitor.js | 130 +++++++++++++++++++++ src/react/LogMonitorEntry.js | 121 ++++++++++++++++++++ src/react/index.js | 6 + src/react/native/index.js | 1 + 10 files changed, 620 insertions(+), 1 deletion(-) create mode 100644 src/createDevTools.js create mode 100644 src/devTools.js create mode 100644 src/persistState.js create mode 100644 src/react/DebugPanel.js create mode 100644 src/react/LogMonitor.js create mode 100644 src/react/LogMonitorEntry.js create mode 100644 src/react/index.js create mode 100644 src/react/native/index.js 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 ( +
+
+ + Reset + +
+ {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 ( +
+ 0) ? 'hand' : 'default', + WebkitUserSelect: 'none' + }}> + {JSON.stringify(action)} + + + {!collapsed && +

+ ⇧ +

+ } + + {!collapsed && +
+ {this.printState(state, error)} +
+ } + +
+
+ ); + } +} 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