diff --git a/packages/redux-devtools-inspector/.babelrc b/packages/redux-devtools-inspector/.babelrc new file mode 100644 index 00000000..162b8b90 --- /dev/null +++ b/packages/redux-devtools-inspector/.babelrc @@ -0,0 +1,15 @@ +{ + "presets": ["es2015", "stage-0", "react"], + "plugins": ["transform-runtime"], + "env": { + "development": { + "plugins": [["react-transform", { + "transforms": [{ + "transform": "react-transform-hmr", + "imports": ["react"], + "locals": ["module"] + }] + }]] + } + } +} diff --git a/packages/redux-devtools-inspector/.eslintrc b/packages/redux-devtools-inspector/.eslintrc new file mode 100644 index 00000000..59aac234 --- /dev/null +++ b/packages/redux-devtools-inspector/.eslintrc @@ -0,0 +1,57 @@ +{ + "parser": "babel-eslint", + "rules": { + "no-undef": ["error"], + "no-trailing-spaces": ["warn"], + "space-before-blocks": ["warn", "always"], + "no-unused-expressions": ["off"], + "no-underscore-dangle": ["off"], + "quote-props": ["warn", "as-needed"], + "no-multi-spaces": ["off"], + "no-unused-vars": ["warn"], + "no-loop-func": ["off"], + "key-spacing": ["off"], + "max-len": ["warn", 100], + "strict": ["off"], + "eol-last": ["warn"], + "no-console": ["warn"], + "indent": ["warn", 2], + "quotes": ["warn", "single", "avoid-escape"], + "curly": ["off"], + "jsx-quotes": ["warn", "prefer-single"], + + "react/jsx-boolean-value": "warn", + "react/jsx-no-undef": "error", + "react/jsx-uses-react": "warn", + "react/jsx-uses-vars": "warn", + "react/no-did-mount-set-state": "warn", + "react/no-did-update-set-state": "warn", + "react/no-multi-comp": "off", + "react/no-unknown-property": "error", + "react/react-in-jsx-scope": "error", + "react/self-closing-comp": "warn", + "react/jsx-wrap-multilines": "warn", + + "generator-star-spacing": "off", + "new-cap": "off", + "object-curly-spacing": "off", + "object-shorthand": "off", + + "babel/generator-star-spacing": "warn", + "babel/new-cap": "warn", + "babel/object-curly-spacing": ["warn", "always"], + "babel/object-shorthand": "warn" + }, + "plugins": [ + "react", + "babel" + ], + "settings": { + "ecmascript": 6, + "jsx": true + }, + "env": { + "browser": true, + "node": true + } +} diff --git a/packages/redux-devtools-inspector/.npmignore b/packages/redux-devtools-inspector/.npmignore new file mode 100644 index 00000000..7e7ce1e8 --- /dev/null +++ b/packages/redux-devtools-inspector/.npmignore @@ -0,0 +1,8 @@ +static +src +demo +.* +webpack.config.js +index.html +*.gif +*.png diff --git a/packages/redux-devtools-inspector/LICENSE b/packages/redux-devtools-inspector/LICENSE new file mode 100644 index 00000000..32950549 --- /dev/null +++ b/packages/redux-devtools-inspector/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2015 Alexander Kuznetsov + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/redux-devtools-inspector/README.md b/packages/redux-devtools-inspector/README.md new file mode 100644 index 00000000..12650821 --- /dev/null +++ b/packages/redux-devtools-inspector/README.md @@ -0,0 +1,68 @@ +# redux-devtools-inspector + +[![npm version](https://badge.fury.io/js/redux-devtools-inspector.svg)](https://badge.fury.io/js/redux-devtools-inspector) + +A state monitor for [Redux DevTools](https://github.com/gaearon/redux-devtools) that provides a convenient way to inspect "real world" app states that could be complicated and deeply nested. + +![](https://raw.githubusercontent.com/alexkuz/redux-devtools-inspector/master/demo.gif) + +### Installation + +``` +npm install --save-dev redux-devtools-inspector +``` + +### Usage + +You can use `Inspector` as the only monitor in your app: + +##### `containers/DevTools.js` + +```js +import React from 'react'; +import { createDevTools } from 'redux-devtools'; +import Inspector from 'redux-devtools-inspector'; + +export default createDevTools( + +); +``` + +Then you can render `` to any place inside app or even into a separate popup window. + +Alternative, you can use it together with [`DockMonitor`](https://github.com/gaearon/redux-devtools-dock-monitor) to make it dockable. +Consult the [`DockMonitor` README](https://github.com/gaearon/redux-devtools-dock-monitor) for details of this approach. + +[Read how to start using Redux DevTools.](https://github.com/gaearon/redux-devtools) + +### Features + +The inspector displays a list of actions and a preview panel which shows the state after the selected action and a diff with the previous state. If no actions are selected, the last state is shown. + +You may pin a certain part of the state to only track its changes. + +### Props + +Name | Type | Description +------------------ | ---------------- | ------------- +`theme` | Object or string | Contains either [base16](https://github.com/chriskempson/base16) theme name or object, that can be `base16` colors map or object containing classnames or styles. +`invertTheme` | Boolean | Inverts theme color luminance, making light theme out of dark theme and vice versa. +`supportImmutable` | Boolean | Better `Immutable` rendering in `Diff` (can affect performance if state has huge objects/arrays). `false` by default. +`tabs` | Array or function | Overrides list of tabs (see below) +`diffObjectHash` | Function | Optional callback for better array handling in diffs (see [jsondiffpatch docs](https://github.com/benjamine/jsondiffpatch/blob/master/docs/arrays.md)) +`diffPropertyFilter` | Function | Optional callback for ignoring particular props in diff (see [jsondiffpatch docs](https://github.com/benjamine/jsondiffpatch#options)) + + +If `tabs` is a function, it receives a list of default tabs and should return updated list, for example: +``` +defaultTabs => [...defaultTabs, { name: 'My Tab', component: MyTab }] +``` +If `tabs` is an array, only provided tabs are rendered. + +`component` is provided with `action` and other props, see [`ActionPreview.jsx`](src/ActionPreview.jsx#L42) for reference. + +Usage example: [`redux-devtools-test-generator`](https://github.com/zalmoxisus/redux-devtools-test-generator#containersdevtoolsjs). + +### License + +MIT diff --git a/packages/redux-devtools-inspector/demo.gif b/packages/redux-devtools-inspector/demo.gif new file mode 100644 index 00000000..382294f2 Binary files /dev/null and b/packages/redux-devtools-inspector/demo.gif differ diff --git a/packages/redux-devtools-inspector/demo/src/.noderequirer.json b/packages/redux-devtools-inspector/demo/src/.noderequirer.json new file mode 100644 index 00000000..20e76308 --- /dev/null +++ b/packages/redux-devtools-inspector/demo/src/.noderequirer.json @@ -0,0 +1,3 @@ +{ + "import": true +} diff --git a/packages/redux-devtools-inspector/demo/src/index.html b/packages/redux-devtools-inspector/demo/src/index.html new file mode 100644 index 00000000..f0a32249 --- /dev/null +++ b/packages/redux-devtools-inspector/demo/src/index.html @@ -0,0 +1,14 @@ + + + + + <%= htmlWebpackPlugin.options.package.name %> + + + + + + Fork me on GitHub +
+ + diff --git a/packages/redux-devtools-inspector/demo/src/js/DemoApp.jsx b/packages/redux-devtools-inspector/demo/src/js/DemoApp.jsx new file mode 100644 index 00000000..608b8748 --- /dev/null +++ b/packages/redux-devtools-inspector/demo/src/js/DemoApp.jsx @@ -0,0 +1,244 @@ +import React from 'react'; +import PageHeader from 'react-bootstrap/lib/PageHeader'; +import { connect } from 'react-redux'; +import pkg from '../../../package.json'; +import Button from 'react-bootstrap/lib/Button'; +import FormGroup from 'react-bootstrap/lib/FormGroup'; +import FormControl from 'react-bootstrap/lib/FormControl'; +import ControlLabel from 'react-bootstrap/lib/ControlLabel'; +import Form from 'react-bootstrap/lib/Form'; +import Col from 'react-bootstrap/lib/Col'; +import InputGroup from 'react-bootstrap/lib/InputGroup'; +import Combobox from 'react-input-enhancements/lib/Combobox'; +import * as base16 from 'base16'; +import * as inspectorThemes from '../../../src/themes'; +import getOptions from './getOptions'; +import { push as pushRoute } from 'react-router-redux'; + +const styles = { + wrapper: { + height: '100vh', + width: '80%', + margin: '0 auto', + paddingTop: '1px' + }, + header: { + }, + content: { + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + height: '50%' + }, + buttons: { + display: 'flex', + width: '40rem', + justifyContent: 'center', + flexWrap: 'wrap' + }, + muted: { + color: '#CCCCCC' + }, + button: { + margin: '0.5rem' + }, + links: { + textAlign: 'center' + }, + link: { + margin: '0 0.5rem', + cursor: 'pointer', + display: 'block' + }, + input: { + display: 'inline-block', + textAlign: 'left', + width: '30rem' + } +}; + +const themeOptions = [ + ...Object.keys(inspectorThemes) + .map(value => ({ value, label: inspectorThemes[value].scheme })), + null, + ...Object.keys(base16) + .map(value => ({ value, label: base16[value].scheme })) + .filter(opt => opt.label) +]; + +const ROOT = process.env.NODE_ENV === 'production' ? '/redux-devtools-inspector/' : '/'; + +function buildUrl(options) { + return `${ROOT}?` + [ + options.useExtension ? 'ext' : '', + options.supportImmutable ? 'immutable' : '', + options.theme ? 'theme=' + options.theme : '', + options.dark ? 'dark' : '' + ].filter(s => s).join('&'); +} + +class DemoApp extends React.Component { + render() { + const options = getOptions(); + + return ( +
+ + {pkg.name || Package Name} + +
{pkg.description || Package Description}
+
+
+
+ + + Theme: + + + + this.setTheme(options, value)} + optionFilters={[]}> + {props => } + + + + {options.dark ? 'Light theme' : 'Dark theme'} + + + + + +
+
+
+
+
+ + + + + + + + + + + + + + + + +
+
+
+ + {(options.useExtension ? 'Disable' : 'Enable') + ' Chrome Extension'} + + + {(options.supportImmutable ? 'Disable' : 'Enable') + ' Full Immutable Support'} + +
+
+ ); + } + + toggleExtension = () => { + const options = getOptions(); + + this.props.pushRoute(buildUrl({ ...options, useExtension: !options.useExtension })); + }; + + toggleImmutableSupport = () => { + const options = getOptions(); + + this.props.pushRoute(buildUrl({ ...options, supportImmutable: !options.supportImmutable })); + }; + + toggleTheme = () => { + const options = getOptions(); + + this.props.pushRoute(buildUrl({ ...options, dark: !options.dark })); + }; + + setTheme = (options, theme) => { + this.props.pushRoute(buildUrl({ ...options, theme })); + }; + + toggleTimeoutUpdate = () => { + const enabled = !this.props.timeoutUpdateEnabled; + this.props.toggleTimeoutUpdate(enabled); + + if (enabled) { + this.timeout = setInterval(this.props.timeoutUpdate, 1000); + } else { + clearTimeout(this.timeout); + } + } +} + +export default connect( + state => state, + { + toggleTimeoutUpdate: timeoutUpdateEnabled => ({ + type: 'TOGGLE_TIMEOUT_UPDATE', timeoutUpdateEnabled + }), + timeoutUpdate: () => ({ type: 'TIMEOUT_UPDATE' }), + increment: () => ({ type: 'INCREMENT' }), + push: () => ({ type: 'PUSH' }), + pop: () => ({ type: 'POP' }), + replace: () => ({ type: 'REPLACE' }), + changeNested: () => ({ type: 'CHANGE_NESTED' }), + pushHugeArray: () => ({ type: 'PUSH_HUGE_ARRAY' }), + addIterator: () => ({ type: 'ADD_ITERATOR' }), + addHugeObect: () => ({ type: 'ADD_HUGE_OBJECT' }), + addRecursive: () => ({ type: 'ADD_RECURSIVE' }), + addImmutableMap: () => ({ type: 'ADD_IMMUTABLE_MAP' }), + changeImmutableNested: () => ({ type: 'CHANGE_IMMUTABLE_NESTED' }), + hugePayload: () => ({ + type: 'HUGE_PAYLOAD', + payload: Array.from({ length: 10000 }).map((_, i) => i) + }), + addFunction: () => ({ type: 'ADD_FUNCTION' }), + addSymbol: () => ({ type: 'ADD_SYMBOL' }), + shuffleArray: () => ({ type: 'SHUFFLE_ARRAY' }), + pushRoute + } +)(DemoApp); diff --git a/packages/redux-devtools-inspector/demo/src/js/getOptions.js b/packages/redux-devtools-inspector/demo/src/js/getOptions.js new file mode 100644 index 00000000..52db6a7f --- /dev/null +++ b/packages/redux-devtools-inspector/demo/src/js/getOptions.js @@ -0,0 +1,11 @@ +export default function getOptions() { + return { + useExtension: window.location.search.indexOf('ext') !== -1, + supportImmutable: window.location.search.indexOf('immutable') !== -1, + theme: do { + const match = window.location.search.match(/theme=([^&]+)/); + match ? match[1] : 'inspector' + }, + dark: window.location.search.indexOf('dark') !== -1 + }; +} diff --git a/packages/redux-devtools-inspector/demo/src/js/index.js b/packages/redux-devtools-inspector/demo/src/js/index.js new file mode 100644 index 00000000..aa552d37 --- /dev/null +++ b/packages/redux-devtools-inspector/demo/src/js/index.js @@ -0,0 +1,100 @@ +import 'babel-polyfill'; +import React from 'react'; +import { render } from 'react-dom'; +import DemoApp from './DemoApp'; +import { Provider } from 'react-redux'; +import reducers from './reducers'; +import { createStore, applyMiddleware, compose, combineReducers } from 'redux'; +import createLogger from 'redux-logger'; +import { Router, Route, browserHistory } from 'react-router'; +import { syncHistoryWithStore, routerReducer, routerMiddleware } from 'react-router-redux'; +import { createDevTools, persistState } from 'redux-devtools'; +import DevtoolsInspector from '../../../src/DevtoolsInspector'; +import DockMonitor from 'redux-devtools-dock-monitor'; +import getOptions from './getOptions'; + +function getDebugSessionKey() { + const matches = window.location.href.match(/[?&]debug_session=([^&#]+)\b/); + return (matches && matches.length > 0)? matches[1] : null; +} + +const CustomComponent = () => +
+
Custom Tab Content
+
; + +const getDevTools = options => + createDevTools( + + [{ + name: 'Custom Tab', + component: CustomComponent + }, ...defaultTabs]} /> + + ); + +const ROOT = process.env.NODE_ENV === 'production' ? '/redux-devtools-inspector/' : '/'; + +let DevTools = getDevTools(getOptions()); + +const reduxRouterMiddleware = routerMiddleware(browserHistory); + +const enhancer = compose( + applyMiddleware(createLogger(), reduxRouterMiddleware), + (...args) => { + const useDevtoolsExtension = !!window.__REDUX_DEVTOOLS_EXTENSION__ && getOptions().useExtension; + const instrument = useDevtoolsExtension ? + window.__REDUX_DEVTOOLS_EXTENSION__() : DevTools.instrument(); + return instrument(...args); + }, + persistState(getDebugSessionKey()) +); + +const store = createStore(combineReducers({ + ...reducers, + routing: routerReducer +}), {}, enhancer); + +const history = syncHistoryWithStore(browserHistory, store); + +const handleRouterUpdate = () => { + renderApp(getOptions()); +}; + +const router = ( + + + +); + +const renderApp = options => { + DevTools = getDevTools(options); + const useDevtoolsExtension = !!window.__REDUX_DEVTOOLS_EXTENSION__ && options.useExtension; + + return render( + +
+ {router} + {!useDevtoolsExtension && } +
+
, + document.getElementById('root') + ); +} + +renderApp(getOptions()); diff --git a/packages/redux-devtools-inspector/demo/src/js/reducers.js b/packages/redux-devtools-inspector/demo/src/js/reducers.js new file mode 100644 index 00000000..588b9eee --- /dev/null +++ b/packages/redux-devtools-inspector/demo/src/js/reducers.js @@ -0,0 +1,103 @@ +import Immutable from 'immutable'; +import shuffle from 'lodash.shuffle'; + +const NESTED = { + long: { + nested: [{ + path: { + to: { + a: 'key' + } + } + }] + } +}; + +const IMMUTABLE_NESTED = Immutable.fromJS(NESTED); + +/* eslint-disable babel/new-cap */ + +const IMMUTABLE_MAP = Immutable.Map({ + map: Immutable.Map({ a:1, b: 2, c: 3 }), + list: Immutable.List(['a', 'b', 'c']), + set: Immutable.Set(['a', 'b', 'c']), + stack: Immutable.Stack(['a', 'b', 'c']), + seq: Immutable.Seq.of(1, 2, 3, 4, 5, 6, 7, 8) +}); + +/* eslint-enable babel/new-cap */ + +const HUGE_ARRAY = Array.from({ length: 5000 }) + .map((_, key) => ({ str: 'key ' + key })); + +const HUGE_OBJECT = Array.from({ length: 5000 }) + .reduce((o, _, key) => (o['key ' + key] = 'item ' + key, o), {}); + +const FUNC = function (a, b, c) { return a + b + c; }; + +const RECURSIVE = {}; +RECURSIVE.obj = RECURSIVE; + +function createIterator() { + const iterable = {}; + iterable[window.Symbol.iterator] = function *iterator() { + for (var i = 0; i < 333; i++) { + yield 'item ' + i; + } + } + + return iterable; +} + +const DEFAULT_SHUFFLE_ARRAY = [0, 1, null, { id: 1 }, { id: 2 }, 'string']; + +export default { + timeoutUpdateEnabled: (state=false, action) => action.type === 'TOGGLE_TIMEOUT_UPDATE' ? + action.timeoutUpdateEnabled : state, + store: (state=0, action) => action.type === 'INCREMENT' ? state + 1 : state, + undefined: (state={ val: undefined }) => state, + null: (state=null) => state, + func: (state=() => {}) => state, + array: (state=[], action) => action.type === 'PUSH' ? + [...state, Math.random()] : ( + action.type === 'POP' ? state.slice(0, state.length - 1) : ( + action.type === 'REPLACE' ? [Math.random(), ...state.slice(1)] : state + ) + ), + hugeArrays: (state=[], action) => action.type === 'PUSH_HUGE_ARRAY' ? + [ ...state, ...HUGE_ARRAY ] : state, + hugeObjects: (state=[], action) => action.type === 'ADD_HUGE_OBJECT' ? + [ ...state, HUGE_OBJECT ] : state, + iterators: (state=[], action) => action.type === 'ADD_ITERATOR' ? + [...state, createIterator()] : state, + nested: (state=NESTED, action) => + action.type === 'CHANGE_NESTED' ? + { + ...state, + long: { + nested: [{ + path: { + to: { + a: state.long.nested[0].path.to.a + '!' + } + } + }] + } + } : state, + recursive: (state=[], action) => action.type === 'ADD_RECURSIVE' ? + [...state, { ...RECURSIVE }] : state, + immutables: (state=[], action) => action.type === 'ADD_IMMUTABLE_MAP' ? + [...state, IMMUTABLE_MAP] : state, + immutableNested: (state=IMMUTABLE_NESTED, action) => action.type === 'CHANGE_IMMUTABLE_NESTED' ? + state.updateIn( + ['long', 'nested', 0, 'path', 'to', 'a'], + str => str + '!' + ) : state, + addFunction: (state=null, action) => action.type === 'ADD_FUNCTION' ? + { f: FUNC } : state, + addSymbol: (state=null, action) => action.type === 'ADD_SYMBOL' ? + { s: window.Symbol('symbol') } : state, + shuffleArray: (state=DEFAULT_SHUFFLE_ARRAY, action) => + action.type === 'SHUFFLE_ARRAY' ? + shuffle(state) : state +}; diff --git a/packages/redux-devtools-inspector/package.json b/packages/redux-devtools-inspector/package.json new file mode 100644 index 00000000..14a7ea64 --- /dev/null +++ b/packages/redux-devtools-inspector/package.json @@ -0,0 +1,91 @@ +{ + "name": "redux-devtools-inspector", + "version": "0.11.0", + "description": "Redux DevTools Diff Monitor", + "scripts": { + "build:lib": "NODE_ENV=production babel src --out-dir lib", + "build:demo": "NODE_ENV=production webpack -p", + "stats": "webpack --profile --json > stats.json", + "start": "webpack-dev-server", + "lint": "eslint --ext .jsx,.js --max-warnings 0 src", + "preversion": "npm run lint", + "version": "npm run build:demo && git add -A .", + "postversion": "git push", + "prepublish": "npm run build:lib", + "gh": "git subtree push --prefix demo/dist origin gh-pages" + }, + "main": "lib/index.js", + "repository": { + "url": "https://github.com/reduxjs/redux-devtools" + }, + "devDependencies": { + "babel": "^6.3.26", + "babel-cli": "^6.4.5", + "babel-core": "^6.4.5", + "babel-eslint": "^7.1.0", + "babel-loader": "^6.2.2", + "babel-plugin-react-transform": "^2.0.0", + "babel-plugin-transform-runtime": "^6.4.3", + "babel-preset-es2015": "^6.3.13", + "babel-preset-react": "^6.3.13", + "babel-preset-stage-0": "^6.3.13", + "base16": "^1.0.0", + "chokidar": "^1.6.1", + "clean-webpack-plugin": "^0.1.8", + "eslint": "^3.9.1", + "eslint-loader": "^1.2.1", + "eslint-plugin-babel": "^3.1.0", + "eslint-plugin-react": "^6.6.0", + "export-files-webpack-plugin": "0.0.1", + "html-webpack-plugin": "^2.8.1", + "imports-loader": "^0.6.5", + "json-loader": "^0.5.4", + "lodash.shuffle": "^4.2.0", + "nyan-progress-webpack-plugin": "^1.1.4", + "pre-commit": "^1.1.3", + "raw-loader": "^0.5.1", + "react": "^15.3.2", + "react-bootstrap": "^0.30.6", + "react-dom": "^15.3.2", + "react-input-enhancements": "^0.5.3", + "react-redux": "^4.4.0", + "react-router": "^3.0.0", + "react-router-redux": "^4.0.2", + "react-transform-hmr": "^1.0.2", + "redux": "^3.3.1", + "redux-devtools": "^3.1.0", + "redux-devtools-dock-monitor": "^1.0.1", + "redux-logger": "^2.5.2", + "webpack": "^1.12.13", + "webpack-dev-server": "^1.14.1" + }, + "peerDependencies": { + "react": ">=15.0.0", + "react-dom": ">=15.0.0" + }, + "author": "Alexander (http://kuzya.org/)", + "contributors": [ + "Mihail Diordiev (https://github.com/zalmoxisus)" + ], + "license": "MIT", + "dependencies": { + "babel-runtime": "^6.3.19", + "dateformat": "^1.0.12", + "hex-rgba": "^1.0.0", + "immutable": "^3.7.6", + "javascript-stringify": "^1.1.0", + "jsondiffpatch": "^0.2.4", + "jss": "^6.0.0", + "jss-nested": "^3.0.0", + "jss-vendor-prefixer": "^4.0.0", + "lodash.debounce": "^4.0.3", + "react-base16-styling": "^0.4.1", + "react-dragula": "^1.1.17", + "react-json-tree": "^0.10.7", + "react-pure-render": "^1.0.2", + "redux-devtools-themes": "^1.0.0" + }, + "pre-commit": [ + "lint" + ] +} diff --git a/packages/redux-devtools-inspector/src/.noderequirer.json b/packages/redux-devtools-inspector/src/.noderequirer.json new file mode 100644 index 00000000..20e76308 --- /dev/null +++ b/packages/redux-devtools-inspector/src/.noderequirer.json @@ -0,0 +1,3 @@ +{ + "import": true +} diff --git a/packages/redux-devtools-inspector/src/ActionList.jsx b/packages/redux-devtools-inspector/src/ActionList.jsx new file mode 100644 index 00000000..db5df2d4 --- /dev/null +++ b/packages/redux-devtools-inspector/src/ActionList.jsx @@ -0,0 +1,124 @@ +import React, { Component } from 'react'; +import ReactDOM from 'react-dom'; +import dragula from 'react-dragula'; +import ActionListRow from './ActionListRow'; +import ActionListHeader from './ActionListHeader'; +import shouldPureComponentUpdate from 'react-pure-render/function'; + +function getTimestamps(actions, actionIds, actionId) { + const idx = actionIds.indexOf(actionId); + const prevActionId = actionIds[idx - 1]; + + return { + current: actions[actionId].timestamp, + previous: idx ? actions[prevActionId].timestamp : 0 + }; +} + +export default class ActionList extends Component { + shouldComponentUpdate = shouldPureComponentUpdate; + + componentWillReceiveProps(nextProps) { + const node = this.node; + if (!node) { + this.scrollDown = true; + } else if (this.props.lastActionId !== nextProps.lastActionId) { + const { scrollTop, offsetHeight, scrollHeight } = node; + this.scrollDown = Math.abs(scrollHeight - (scrollTop + offsetHeight)) < 50; + } else { + this.scrollDown = false; + } + } + + componentDidMount() { + this.scrollDown = true; + this.scrollToBottom(); + + if (!this.props.draggableActions) return; + const container = ReactDOM.findDOMNode(this.refs.rows); + this.drake = dragula([container], { + copy: false, + copySortSource: false, + mirrorContainer: container, + accepts: (el, target, source, sibling) => ( + !sibling || parseInt(sibling.getAttribute('data-id')) + ), + moves: (el, source, handle) => ( + parseInt(el.getAttribute('data-id')) && + handle.className.indexOf('selectorButton') !== 0 + ), + }).on('drop', (el, target, source, sibling) => { + let beforeActionId = this.props.actionIds.length; + if (sibling && sibling.className.indexOf('gu-mirror') === -1) { + beforeActionId = parseInt(sibling.getAttribute('data-id')); + } + const actionId = parseInt(el.getAttribute('data-id')); + this.props.onReorderAction(actionId, beforeActionId) + }); + } + + componentWillUnmount() { + if (this.drake) this.drake.destroy(); + } + + componentDidUpdate() { + this.scrollToBottom(); + } + + scrollToBottom() { + if (this.scrollDown && this.node) { + this.node.scrollTop = this.node.scrollHeight; + } + } + + getRef = node => { + this.node = node; + } + + render() { + const { styling, actions, actionIds, isWideLayout, onToggleAction, skippedActionIds, + selectedActionId, startActionId, onSelect, onSearch, searchValue, currentActionId, + hideMainButtons, hideActionButtons, onCommit, onSweep, onJumpToState } = this.props; + const lowerSearchValue = searchValue && searchValue.toLowerCase(); + const filteredActionIds = searchValue ? actionIds.filter( + id => actions[id].action.type.toLowerCase().indexOf(lowerSearchValue) !== -1 + ) : actionIds; + + return ( +
+ 0} + hasStagedActions={actionIds.length > 1} /> +
+ {filteredActionIds.map(actionId => + = startActionId && actionId <= selectedActionId || + actionId === selectedActionId + } + isInFuture={ + actionIds.indexOf(actionId) > actionIds.indexOf(currentActionId) + } + onSelect={(e) => onSelect(e, actionId)} + timestamps={getTimestamps(actions, actionIds, actionId)} + action={actions[actionId].action} + onToggleClick={() => onToggleAction(actionId)} + onJumpClick={() => onJumpToState(actionId)} + onCommitClick={() => onCommit(actionId)} + hideActionButtons={hideActionButtons} + isSkipped={skippedActionIds.indexOf(actionId) !== -1} /> + )} +
+
+ ); + } +} diff --git a/packages/redux-devtools-inspector/src/ActionListHeader.jsx b/packages/redux-devtools-inspector/src/ActionListHeader.jsx new file mode 100644 index 00000000..097813e5 --- /dev/null +++ b/packages/redux-devtools-inspector/src/ActionListHeader.jsx @@ -0,0 +1,43 @@ +import React from 'react'; +import RightSlider from './RightSlider'; + +const getActiveButtons = (hasSkippedActions) => [ + hasSkippedActions && 'Sweep', + 'Commit' +].filter(a => a); + +const ActionListHeader = + ({ + styling, onSearch, hasSkippedActions, hasStagedActions, onCommit, onSweep, hideMainButtons + }) => +
+ onSearch(e.target.value)} + placeholder='filter...' + /> + {!hideMainButtons && +
+ +
+ {getActiveButtons(hasSkippedActions).map(btn => +
({ + Commit: onCommit, + Sweep: onSweep + })[btn]()} + {...styling([ + 'selectorButton', + 'selectorButtonSmall'], false, true)} + > + {btn} +
+ )} +
+
+
+ } +
; + +export default ActionListHeader; diff --git a/packages/redux-devtools-inspector/src/ActionListRow.jsx b/packages/redux-devtools-inspector/src/ActionListRow.jsx new file mode 100644 index 00000000..b7e30cde --- /dev/null +++ b/packages/redux-devtools-inspector/src/ActionListRow.jsx @@ -0,0 +1,128 @@ +import React, { Component, PropTypes } from 'react'; +import shouldPureComponentUpdate from 'react-pure-render/function'; +import dateformat from 'dateformat'; +import debounce from 'lodash.debounce'; +import RightSlider from './RightSlider'; + +const BUTTON_SKIP = 'Skip'; +const BUTTON_JUMP = 'Jump'; + +export default class ActionListRow extends Component { + state = { hover: false }; + + static propTypes = { + styling: PropTypes.func.isRequired, + isSelected: PropTypes.bool.isRequired, + action: PropTypes.object.isRequired, + isInFuture: PropTypes.bool.isRequired, + isInitAction: PropTypes.bool.isRequired, + onSelect: PropTypes.func.isRequired, + timestamps: PropTypes.shape({ + current: PropTypes.number.isRequired, + previous: PropTypes.number.isRequired + }).isRequired, + isSkipped: PropTypes.bool.isRequired + }; + + shouldComponentUpdate = shouldPureComponentUpdate + + render() { + const { styling, isSelected, action, actionId, isInitAction, onSelect, + timestamps, isSkipped, isInFuture, hideActionButtons } = this.props; + const { hover } = this.state; + const timeDelta = timestamps.current - timestamps.previous; + const showButtons = hover && !isInitAction || isSkipped; + + const isButtonSelected = btn => + btn === BUTTON_SKIP && isSkipped; + + let actionType = action.type; + if (typeof actionType === 'undefined') actionType = ''; + else if (actionType === null) actionType = ''; + else actionType = actionType.toString() || ''; + + return ( +
+
+ {actionType} +
+ {hideActionButtons ? + +
+ {timeDelta === 0 ? '+00:00:00' : + dateformat(timeDelta, timestamps.previous ? '+MM:ss.L' : 'h:MM:ss.L')} +
+
+ : +
+ +
+ {timeDelta === 0 ? '+00:00:00' : + dateformat(timeDelta, timestamps.previous ? '+MM:ss.L' : 'h:MM:ss.L')} +
+
+ +
+ {[BUTTON_JUMP, BUTTON_SKIP].map(btn => (!isInitAction || btn !== BUTTON_SKIP) && +
+ {btn} +
+ )} +
+
+
+ } +
+ ); + } + + handleButtonClick(btn, e) { + e.stopPropagation(); + + switch(btn) { + case BUTTON_SKIP: + this.props.onToggleClick(); + break; + case BUTTON_JUMP: + this.props.onJumpClick(); + break; + } + } + + handleMouseEnter = e => { + if (this.hover) return; + this.handleMouseLeave.cancel(); + this.handleMouseEnterDebounced(e.buttons); + } + + handleMouseEnterDebounced = debounce((buttons) => { + if (buttons) return; + this.setState({ hover: true }); + }, 150) + + handleMouseLeave = debounce(() => { + this.handleMouseEnterDebounced.cancel(); + if (this.state.hover) this.setState({ hover: false }); + }, 100) + + handleMouseDown = e => { + if (e.target.className.indexOf('selectorButton') === 0) return; + this.handleMouseLeave(); + } +} diff --git a/packages/redux-devtools-inspector/src/ActionPreview.jsx b/packages/redux-devtools-inspector/src/ActionPreview.jsx new file mode 100644 index 00000000..97730129 --- /dev/null +++ b/packages/redux-devtools-inspector/src/ActionPreview.jsx @@ -0,0 +1,97 @@ +import React, { Component } from 'react'; +import { DEFAULT_STATE } from './redux'; +import ActionPreviewHeader from './ActionPreviewHeader'; +import DiffTab from './tabs/DiffTab'; +import StateTab from './tabs/StateTab'; +import ActionTab from './tabs/ActionTab'; + +const DEFAULT_TABS = [{ + name: 'Action', + component: ActionTab +}, { + name: 'Diff', + component: DiffTab +}, { + name: 'State', + component: StateTab +}] + +class ActionPreview extends Component { + static defaultProps = { + tabName: DEFAULT_STATE.tabName + } + + render() { + const { + styling, delta, error, nextState, onInspectPath, inspectedPath, tabName, + isWideLayout, onSelectTab, action, actions, selectedActionId, startActionId, + computedStates, base16Theme, invertTheme, tabs, dataTypeKey, monitorState, updateMonitorState + } = this.props; + + const renderedTabs = (typeof tabs === 'function') ? + tabs(DEFAULT_TABS) : + (tabs ? tabs : DEFAULT_TABS); + + const { component: TabComponent } = ( + renderedTabs.find(tab => tab.name === tabName) + || renderedTabs.find(tab => tab.name === DEFAULT_STATE.tabName) + ); + + return ( +
+ + {!error && +
+ +
+ } + {error && +
{error}
+ } +
+ ); + } + + labelRenderer = ([key, ...rest], nodeType, expanded) => { + const { styling, onInspectPath, inspectedPath } = this.props; + + return ( + + + {key} + + onInspectPath([ + ...inspectedPath.slice(0, inspectedPath.length - 1), + ...[key, ...rest].reverse() + ])}> + {'(pin)'} + + {!expanded && ': '} + + ); + } +} + +export default ActionPreview; diff --git a/packages/redux-devtools-inspector/src/ActionPreviewHeader.jsx b/packages/redux-devtools-inspector/src/ActionPreviewHeader.jsx new file mode 100644 index 00000000..8623dfae --- /dev/null +++ b/packages/redux-devtools-inspector/src/ActionPreviewHeader.jsx @@ -0,0 +1,40 @@ +import React from 'react'; + +const ActionPreviewHeader = + ({ styling, inspectedPath, onInspectPath, tabName, onSelectTab, tabs }) => +
+
+ {tabs.map(tab => +
onSelectTab(tab.name)} + key={tab.name} + {...styling([ + 'selectorButton', + tab.name === tabName && 'selectorButtonSelected' + ], tab.name === tabName)}> + {tab.name} +
+ )} +
+
+ {inspectedPath.length ? + + onInspectPath([])} + {...styling('inspectedPathKeyLink')}> + {tabName} + + : tabName + } + {inspectedPath.map((key, idx) => + idx === inspectedPath.length - 1 ? {key} : + + onInspectPath(inspectedPath.slice(0, idx + 1))} + {...styling('inspectedPathKeyLink')}> + {key} + + + )} +
+
; + +export default ActionPreviewHeader; diff --git a/packages/redux-devtools-inspector/src/DevtoolsInspector.js b/packages/redux-devtools-inspector/src/DevtoolsInspector.js new file mode 100644 index 00000000..b8212820 --- /dev/null +++ b/packages/redux-devtools-inspector/src/DevtoolsInspector.js @@ -0,0 +1,269 @@ +import React, { Component, PropTypes } from 'react'; +import { createStylingFromTheme, base16Themes } from './utils/createStylingFromTheme'; +import shouldPureComponentUpdate from 'react-pure-render/function'; +import ActionList from './ActionList'; +import ActionPreview from './ActionPreview'; +import getInspectedState from './utils/getInspectedState'; +import createDiffPatcher from './createDiffPatcher'; +import { getBase16Theme } from 'react-base16-styling'; +import { reducer, updateMonitorState } from './redux'; +import { ActionCreators } from 'redux-devtools'; + +const { commit, sweep, toggleAction, jumpToAction, jumpToState, reorderAction } = ActionCreators; + +function getLastActionId(props) { + return props.stagedActionIds[props.stagedActionIds.length - 1]; +} + +function getCurrentActionId(props, monitorState) { + return monitorState.selectedActionId === null ? + props.stagedActionIds[props.currentStateIndex] : monitorState.selectedActionId; +} + +function getFromState(actionIndex, stagedActionIds, computedStates, monitorState) { + const { startActionId } = monitorState; + if (startActionId === null) { + return actionIndex > 0 ? computedStates[actionIndex - 1] : null; + } + let fromStateIdx = stagedActionIds.indexOf(startActionId - 1); + if (fromStateIdx === -1) fromStateIdx = 0; + return computedStates[fromStateIdx]; +} + +function createIntermediateState(props, monitorState) { + const { supportImmutable, computedStates, stagedActionIds, + actionsById: actions, diffObjectHash, diffPropertyFilter } = props; + const { inspectedStatePath, inspectedActionPath } = monitorState; + const currentActionId = getCurrentActionId(props, monitorState); + const currentAction = actions[currentActionId] && actions[currentActionId].action; + + const actionIndex = stagedActionIds.indexOf(currentActionId); + const fromState = getFromState(actionIndex, stagedActionIds, computedStates, monitorState); + const toState = computedStates[actionIndex]; + const error = toState && toState.error; + + const fromInspectedState = !error && fromState && + getInspectedState(fromState.state, inspectedStatePath, supportImmutable); + const toInspectedState = + !error && toState && getInspectedState(toState.state, inspectedStatePath, supportImmutable); + const delta = !error && fromState && toState && + createDiffPatcher(diffObjectHash, diffPropertyFilter).diff( + fromInspectedState, + toInspectedState + ); + + return { + delta, + nextState: toState && getInspectedState(toState.state, inspectedStatePath, false), + action: getInspectedState(currentAction, inspectedActionPath, false), + error + }; +} + +function createThemeState(props) { + const base16Theme = getBase16Theme(props.theme, base16Themes); + const styling = createStylingFromTheme(props.theme, props.invertTheme); + + return { base16Theme, styling }; +} + +export default class DevtoolsInspector extends Component { + constructor(props) { + super(props); + this.state = { + ...createIntermediateState(props, props.monitorState), + isWideLayout: false, + themeState: createThemeState(props) + }; + } + + static propTypes = { + dispatch: PropTypes.func, + computedStates: PropTypes.array, + stagedActionIds: PropTypes.array, + actionsById: PropTypes.object, + currentStateIndex: PropTypes.number, + monitorState: PropTypes.shape({ + initialScrollTop: PropTypes.number + }), + preserveScrollTop: PropTypes.bool, + draggableActions: PropTypes.bool, + stagedActions: PropTypes.array, + select: PropTypes.func.isRequired, + theme: PropTypes.oneOfType([ + PropTypes.object, + PropTypes.string + ]), + supportImmutable: PropTypes.bool, + diffObjectHash: PropTypes.func, + diffPropertyFilter: PropTypes.func, + hideMainButtons: PropTypes.bool, + hideActionButtons: PropTypes.bool + }; + + static update = reducer; + + static defaultProps = { + select: (state) => state, + supportImmutable: false, + draggableActions: true, + theme: 'inspector', + invertTheme: true + }; + + shouldComponentUpdate = shouldPureComponentUpdate; + + componentDidMount() { + this.updateSizeMode(); + this.updateSizeTimeout = window.setInterval(this.updateSizeMode.bind(this), 150); + } + + componentWillUnmount() { + window.clearTimeout(this.updateSizeTimeout); + } + + updateMonitorState = monitorState => { + this.props.dispatch(updateMonitorState(monitorState)); + }; + + updateSizeMode() { + const isWideLayout = this.refs.inspector.offsetWidth > 500; + + if (isWideLayout !== this.state.isWideLayout) { + this.setState({ isWideLayout }); + } + } + + componentWillReceiveProps(nextProps) { + let nextMonitorState = nextProps.monitorState; + const monitorState = this.props.monitorState; + + if ( + getCurrentActionId(this.props, monitorState) !== + getCurrentActionId(nextProps, nextMonitorState) || + monitorState.startActionId !== nextMonitorState.startActionId || + monitorState.inspectedStatePath !== nextMonitorState.inspectedStatePath || + monitorState.inspectedActionPath !== nextMonitorState.inspectedActionPath || + this.props.computedStates !== nextProps.computedStates || + this.props.stagedActionIds !== nextProps.stagedActionIds + ) { + this.setState(createIntermediateState(nextProps, nextMonitorState)); + } + + if (this.props.theme !== nextProps.theme || + this.props.invertTheme !== nextProps.invertTheme) { + this.setState({ themeState: createThemeState(nextProps) }); + } + } + + render() { + const { + stagedActionIds: actionIds, actionsById: actions, computedStates, draggableActions, + tabs, invertTheme, skippedActionIds, currentStateIndex, monitorState, dataTypeKey, + hideMainButtons, hideActionButtons + } = this.props; + const { selectedActionId, startActionId, searchValue, tabName } = monitorState; + const inspectedPathType = tabName === 'Action' ? 'inspectedActionPath' : 'inspectedStatePath'; + const { + themeState, isWideLayout, action, nextState, delta, error + } = this.state; + const { base16Theme, styling } = themeState; + + return ( +
+ + +
+ ); + } + + handleToggleAction = actionId => { + this.props.dispatch(toggleAction(actionId)); + }; + + handleJumpToState = actionId => { + if (jumpToAction) { + this.props.dispatch(jumpToAction(actionId)); + } else { // Fallback for redux-devtools-instrument < 1.5 + const index = this.props.stagedActionIds.indexOf(actionId); + if (index !== -1) this.props.dispatch(jumpToState(index)); + } + }; + + handleReorderAction = (actionId, beforeActionId) => { + if (reorderAction) this.props.dispatch(reorderAction(actionId, beforeActionId)); + }; + + handleCommit = () => { + this.props.dispatch(commit()); + }; + + handleSweep = () => { + this.props.dispatch(sweep()); + }; + + handleSearch = val => { + this.updateMonitorState({ searchValue: val }); + }; + + handleSelectAction = (e, actionId) => { + const { monitorState } = this.props; + let startActionId; + let selectedActionId; + + if (e.shiftKey && monitorState.selectedActionId !== null) { + if (monitorState.startActionId !== null) { + if (actionId >= monitorState.startActionId) { + startActionId = Math.min(monitorState.startActionId, monitorState.selectedActionId); + selectedActionId = actionId; + } else { + selectedActionId = Math.max(monitorState.startActionId, monitorState.selectedActionId); + startActionId = actionId; + } + } else { + startActionId = Math.min(actionId, monitorState.selectedActionId); + selectedActionId = Math.max(actionId, monitorState.selectedActionId); + } + } else { + startActionId = null; + if (actionId === monitorState.selectedActionId || monitorState.startActionId !== null) { + selectedActionId = null; + } else { + selectedActionId = actionId; + } + } + + this.updateMonitorState({ startActionId, selectedActionId }); + }; + + handleInspectPath = (pathType, path) => { + this.updateMonitorState({ [pathType]: path }); + }; + + handleSelectTab = tabName => { + this.updateMonitorState({ tabName }); + }; +} diff --git a/packages/redux-devtools-inspector/src/RightSlider.jsx b/packages/redux-devtools-inspector/src/RightSlider.jsx new file mode 100644 index 00000000..be0401c4 --- /dev/null +++ b/packages/redux-devtools-inspector/src/RightSlider.jsx @@ -0,0 +1,17 @@ +import React, { PropTypes } from 'react'; + +const RightSlider = ({ styling, shown, children, rotate }) => +
+ {children} +
; + +RightSlider.propTypes = { + shown: PropTypes.bool +}; + +export default RightSlider; diff --git a/packages/redux-devtools-inspector/src/createDiffPatcher.js b/packages/redux-devtools-inspector/src/createDiffPatcher.js new file mode 100644 index 00000000..6124cdac --- /dev/null +++ b/packages/redux-devtools-inspector/src/createDiffPatcher.js @@ -0,0 +1,29 @@ +import { DiffPatcher } from 'jsondiffpatch/src/diffpatcher'; + +const defaultObjectHash = (o, idx) => + o === null && '$$null' || + o && (o.id || o.id === 0) && `$$id:${JSON.stringify(o.id)}` || + o && (o._id ||o._id === 0) && `$$_id:${JSON.stringify(o._id)}` || + '$$index:' + idx; + +const defaultPropertyFilter = (name, context) => + typeof context.left[name] !== 'function' && + typeof context.right[name] !== 'function'; + +const defaultDiffPatcher = new DiffPatcher({ + arrays: { detectMove: false }, + objectHash: defaultObjectHash, + propertyFilter: defaultPropertyFilter +}); + +export default function createDiffPatcher(objectHash, propertyFilter) { + if (!objectHash && !propertyFilter) { + return defaultDiffPatcher; + } + + return new DiffPatcher({ + arrays: { detectMove: false }, + objectHash: objectHash || defaultObjectHash, + propertyFilter: propertyFilter || defaultPropertyFilter + }); +} diff --git a/packages/redux-devtools-inspector/src/index.js b/packages/redux-devtools-inspector/src/index.js new file mode 100644 index 00000000..2040c41e --- /dev/null +++ b/packages/redux-devtools-inspector/src/index.js @@ -0,0 +1 @@ +export default from './DevtoolsInspector'; diff --git a/packages/redux-devtools-inspector/src/redux.js b/packages/redux-devtools-inspector/src/redux.js new file mode 100644 index 00000000..8c36f436 --- /dev/null +++ b/packages/redux-devtools-inspector/src/redux.js @@ -0,0 +1,26 @@ +const UPDATE_MONITOR_STATE = '@@redux-devtools-inspector/UPDATE_MONITOR_STATE'; + +export const DEFAULT_STATE = { + selectedActionId: null, + startActionId: null, + inspectedActionPath: [], + inspectedStatePath: [], + tabName: 'Diff' +}; + +export function updateMonitorState(monitorState) { + return { type: UPDATE_MONITOR_STATE, monitorState }; +} + +function reduceUpdateState(state, action) { + return (action.type === UPDATE_MONITOR_STATE) ? { + ...state, + ...action.monitorState + } : state; +} + +export function reducer(props, state=DEFAULT_STATE, action) { + return { + ...reduceUpdateState(state, action) + }; +} diff --git a/packages/redux-devtools-inspector/src/tabs/ActionTab.jsx b/packages/redux-devtools-inspector/src/tabs/ActionTab.jsx new file mode 100644 index 00000000..f4b4d57b --- /dev/null +++ b/packages/redux-devtools-inspector/src/tabs/ActionTab.jsx @@ -0,0 +1,18 @@ +import React from 'react'; +import JSONTree from 'react-json-tree'; +import getItemString from './getItemString'; +import getJsonTreeTheme from './getJsonTreeTheme'; + +const ActionTab = ({ + action, styling, base16Theme, invertTheme, labelRenderer, dataTypeKey, isWideLayout +}) => + getItemString(styling, type, data, dataTypeKey, isWideLayout)} + invertTheme={invertTheme} + hideRoot + />; + +export default ActionTab; diff --git a/packages/redux-devtools-inspector/src/tabs/DiffTab.jsx b/packages/redux-devtools-inspector/src/tabs/DiffTab.jsx new file mode 100644 index 00000000..7974cb9b --- /dev/null +++ b/packages/redux-devtools-inspector/src/tabs/DiffTab.jsx @@ -0,0 +1,9 @@ +import React from 'react'; +import JSONDiff from './JSONDiff'; + +const DiffTab = ({ delta, styling, base16Theme, invertTheme, labelRenderer, isWideLayout }) => + ; + +export default DiffTab; diff --git a/packages/redux-devtools-inspector/src/tabs/JSONDiff.jsx b/packages/redux-devtools-inspector/src/tabs/JSONDiff.jsx new file mode 100644 index 00000000..48e070cd --- /dev/null +++ b/packages/redux-devtools-inspector/src/tabs/JSONDiff.jsx @@ -0,0 +1,126 @@ +import React, { Component } from 'react'; +import JSONTree from 'react-json-tree'; +import stringify from 'javascript-stringify'; +import getItemString from './getItemString'; +import getJsonTreeTheme from './getJsonTreeTheme'; + +function stringifyAndShrink(val, isWideLayout) { + if (val === null) { return 'null'; } + + const str = stringify(val); + if (typeof str === 'undefined') { return 'undefined'; } + + if (isWideLayout) return str.length > 42 ? str.substr(0, 30) + '…' + str.substr(-10) : str; + return str.length > 22 ? `${str.substr(0, 15)}…${str.substr(-5)}` : str; +} + +const expandFirstLevel = (keyName, data, level) => level <= 1; + +function prepareDelta(value) { + if (value && value._t === 'a') { + const res = {}; + for (let key in value) { + if (key !== '_t') { + if (key[0] === '_' && !value[key.substr(1)]) { + res[key.substr(1)] = value[key]; + } else if (value['_' + key]) { + res[key] = [value['_' + key][0], value[key][0]]; + } else if (!value['_' + key] && key[0] !== '_') { + res[key] = value[key]; + } + } + } + return res; + } + + return value; +} + +export default class JSONDiff extends Component { + state = { data: {} } + + componentDidMount() { + this.updateData(); + } + + componentDidUpdate(prevProps) { + if (prevProps.delta !== this.props.delta) { + this.updateData(); + } + } + + updateData() { + // this magically fixes weird React error, where it can't find a node in tree + // if we set `delta` as JSONTree data right away + // https://github.com/alexkuz/redux-devtools-inspector/issues/17 + + this.setState({ data: this.props.delta }); + } + + render() { + const { styling, base16Theme, ...props } = this.props; + + if (!this.state.data) { + return ( +
+ (states are equal) +
+ ); + } + + return ( + + ); + } + + getItemString = (type, data) => ( + getItemString( + this.props.styling, type, data, this.props.dataTypeKey, this.props.isWideLayout, true + ) + ) + + valueRenderer = (raw, value) => { + const { styling, isWideLayout } = this.props; + + function renderSpan(name, body) { + return ( + {body} + ); + } + + if (Array.isArray(value)) { + switch(value.length) { + case 1: + return ( + + {renderSpan('diffAdd', stringifyAndShrink(value[0], isWideLayout))} + + ); + case 2: + return ( + + {renderSpan('diffUpdateFrom', stringifyAndShrink(value[0], isWideLayout))} + {renderSpan('diffUpdateArrow', ' => ')} + {renderSpan('diffUpdateTo', stringifyAndShrink(value[1], isWideLayout))} + + ); + case 3: + return ( + + {renderSpan('diffRemove', stringifyAndShrink(value[0]))} + + ); + } + } + + return raw; + } +} diff --git a/packages/redux-devtools-inspector/src/tabs/StateTab.jsx b/packages/redux-devtools-inspector/src/tabs/StateTab.jsx new file mode 100644 index 00000000..5808ade4 --- /dev/null +++ b/packages/redux-devtools-inspector/src/tabs/StateTab.jsx @@ -0,0 +1,18 @@ +import React from 'react'; +import JSONTree from 'react-json-tree'; +import getItemString from './getItemString'; +import getJsonTreeTheme from './getJsonTreeTheme'; + +const StateTab = ({ + nextState, styling, base16Theme, invertTheme, labelRenderer, dataTypeKey, isWideLayout +}) => + getItemString(styling, type, data, dataTypeKey, isWideLayout)} + invertTheme={invertTheme} + hideRoot + />; + +export default StateTab; diff --git a/packages/redux-devtools-inspector/src/tabs/getItemString.js b/packages/redux-devtools-inspector/src/tabs/getItemString.js new file mode 100644 index 00000000..a69bc1ae --- /dev/null +++ b/packages/redux-devtools-inspector/src/tabs/getItemString.js @@ -0,0 +1,70 @@ +import React from 'react'; +import { Iterable } from 'immutable'; +import isIterable from '../utils/isIterable'; + +const IS_IMMUTABLE_KEY = '@@__IS_IMMUTABLE__@@'; + +function isImmutable(value) { + return Iterable.isKeyed(value) || Iterable.isIndexed(value) || Iterable.isIterable(value); +} + +function getShortTypeString(val, diff) { + if (diff && Array.isArray(val)) { + val = val[val.length === 2 ? 1 : 0]; + } + + if (isIterable(val) && !isImmutable(val)) { + return '(…)'; + } else if (Array.isArray(val)) { + return val.length > 0 ? '[…]' : '[]'; + } else if (val === null) { + return 'null'; + } else if (val === undefined) { + return 'undef'; + } else if (typeof val === 'object') { + return Object.keys(val).length > 0 ? '{…}' : '{}'; + } else if (typeof val === 'function') { + return 'fn'; + } else if (typeof val === 'string') { + return `"${val.substr(0, 10) + (val.length > 10 ? '…' : '')}"` + } else if (typeof val === 'symbol') { + return 'symbol' + } else { + return val; + } +} + +function getText(type, data, isWideLayout, isDiff) { + if (type === 'Object') { + const keys = Object.keys(data); + if (!isWideLayout) return keys.length ? '{…}' : '{}'; + + const str = keys + .slice(0, 3) + .map(key => `${key}: ${getShortTypeString(data[key], isDiff)}`) + .concat(keys.length > 3 ? ['…'] : []) + .join(', '); + + return `{ ${str} }`; + } else if (type === 'Array') { + if (!isWideLayout) return data.length ? '[…]' : '[]'; + + const str = data + .slice(0, 4) + .map(val => getShortTypeString(val, isDiff)) + .concat(data.length > 4 ? ['…'] : []).join(', '); + + return `[${str}]`; + } else { + return type; + } +} + +const getItemString = (styling, type, data, dataTypeKey, isWideLayout, isDiff) => + + {data[IS_IMMUTABLE_KEY] ? 'Immutable' : ''} + {dataTypeKey && data[dataTypeKey] ? data[dataTypeKey] + ' ' : ''} + {getText(type, data, isWideLayout, isDiff)} + ; + +export default getItemString; diff --git a/packages/redux-devtools-inspector/src/tabs/getJsonTreeTheme.js b/packages/redux-devtools-inspector/src/tabs/getJsonTreeTheme.js new file mode 100644 index 00000000..7bb2942a --- /dev/null +++ b/packages/redux-devtools-inspector/src/tabs/getJsonTreeTheme.js @@ -0,0 +1,17 @@ +export default function getJsonTreeTheme(base16Theme) { + return { + extend: base16Theme, + nestedNode: ({ style }, keyPath, nodeType, expanded) => ({ + style: { + ...style, + whiteSpace: expanded ? 'inherit' : 'nowrap' + } + }), + nestedNodeItemString: ({ style }, keyPath, nodeType, expanded) => ({ + style: { + ...style, + display: expanded ? 'none' : 'inline' + } + }) + }; +} diff --git a/packages/redux-devtools-inspector/src/themes/index.js b/packages/redux-devtools-inspector/src/themes/index.js new file mode 100644 index 00000000..6d8a8f69 --- /dev/null +++ b/packages/redux-devtools-inspector/src/themes/index.js @@ -0,0 +1 @@ +export { default as inspector } from './inspector'; diff --git a/packages/redux-devtools-inspector/src/themes/inspector.js b/packages/redux-devtools-inspector/src/themes/inspector.js new file mode 100644 index 00000000..f39a79e9 --- /dev/null +++ b/packages/redux-devtools-inspector/src/themes/inspector.js @@ -0,0 +1,20 @@ +export default { + scheme: 'inspector', + author: 'Alexander Kuznetsov (alexkuz@gmail.com)', + base00: '#181818', + base01: '#282828', + base02: '#383838', + base03: '#585858', + base04: '#b8b8b8', + base05: '#d8d8d8', + base06: '#e8e8e8', + base07: '#FFFFFF', + base08: '#E92F28', + base09: '#dc9656', + base0A: '#f7ca88', + base0B: '#65AD00', + base0C: '#86c1b9', + base0D: '#347BD9', + base0E: '#EC31C0', + base0F: '#a16946' +}; diff --git a/packages/redux-devtools-inspector/src/utils/createStylingFromTheme.js b/packages/redux-devtools-inspector/src/utils/createStylingFromTheme.js new file mode 100644 index 00000000..ae1096a9 --- /dev/null +++ b/packages/redux-devtools-inspector/src/utils/createStylingFromTheme.js @@ -0,0 +1,412 @@ +import jss from 'jss'; +import jssVendorPrefixer from 'jss-vendor-prefixer'; +import jssNested from 'jss-nested'; +import { createStyling } from 'react-base16-styling'; +import rgba from 'hex-rgba'; +import inspector from '../themes/inspector'; +import * as reduxThemes from 'redux-devtools-themes'; +import * as inspectorThemes from '../themes'; + +jss.use(jssVendorPrefixer()); +jss.use(jssNested()); + + +const colorMap = theme => ({ + TEXT_COLOR: theme.base06, + TEXT_PLACEHOLDER_COLOR: rgba(theme.base06, 60), + BACKGROUND_COLOR: theme.base00, + SELECTED_BACKGROUND_COLOR: rgba(theme.base03, 20), + SKIPPED_BACKGROUND_COLOR: rgba(theme.base03, 10), + HEADER_BACKGROUND_COLOR: rgba(theme.base03, 30), + HEADER_BORDER_COLOR: rgba(theme.base03, 20), + BORDER_COLOR: rgba(theme.base03, 50), + LIST_BORDER_COLOR: rgba(theme.base03, 50), + ACTION_TIME_BACK_COLOR: rgba(theme.base03, 20), + ACTION_TIME_COLOR: theme.base04, + PIN_COLOR: theme.base04, + ITEM_HINT_COLOR: rgba(theme.base0F, 90), + TAB_BACK_SELECTED_COLOR: rgba(theme.base03, 20), + TAB_BACK_COLOR: rgba(theme.base00, 70), + TAB_BACK_HOVER_COLOR: rgba(theme.base03, 40), + TAB_BORDER_COLOR: rgba(theme.base03, 50), + DIFF_ADD_COLOR: rgba(theme.base0B, 40), + DIFF_REMOVE_COLOR: rgba(theme.base08, 40), + DIFF_ARROW_COLOR: theme.base0E, + LINK_COLOR: rgba(theme.base0E, 90), + LINK_HOVER_COLOR: theme.base0E, + ERROR_COLOR: theme.base08, +}); + +const getSheetFromColorMap = map => ({ + inspector: { + display: 'flex', + 'flex-direction': 'column', + width: '100%', + height: '100%', + 'font-family': 'monaco, Consolas, "Lucida Console", monospace', + 'font-size': '12px', + 'font-smoothing': 'antialiased', + 'line-height': '1.5em', + + 'background-color': map.BACKGROUND_COLOR, + color: map.TEXT_COLOR + }, + + inspectorWide: { + 'flex-direction': 'row' + }, + + actionList: { + 'flex-basis': '40%', + 'flex-shrink': 0, + 'overflow-x': 'hidden', + 'overflow-y': 'auto', + 'border-bottom-width': '3px', + 'border-bottom-style': 'double', + display: 'flex', + 'flex-direction': 'column', + + 'background-color': map.BACKGROUND_COLOR, + 'border-color': map.LIST_BORDER_COLOR + }, + + actionListHeader: { + display: 'flex', + flex: '0 0 auto', + 'align-items': 'center', + 'border-bottom-width': '1px', + 'border-bottom-style': 'solid', + + 'border-color': map.LIST_BORDER_COLOR + }, + + actionListRows: { + overflow: 'auto', + + '& div.gu-transit': { + opacity: '0.3' + }, + + '& div.gu-mirror': { + position: 'fixed', + opacity: '0.8', + height: 'auto !important', + 'border-width': '1px', + 'border-style': 'solid', + 'border-color': map.LIST_BORDER_COLOR + }, + + '& div.gu-hide': { + display: 'none' + } + }, + + actionListHeaderSelector: { + display: 'inline-flex', + 'margin-right': '10px' + }, + + actionListWide: { + 'flex-basis': '40%', + 'border-bottom': 'none', + 'border-right-width': '3px', + 'border-right-style': 'double' + }, + + actionListItem: { + 'border-bottom-width': '1px', + 'border-bottom-style': 'solid', + display: 'flex', + 'justify-content': 'space-between', + padding: '5px 10px', + cursor: 'pointer', + 'user-select': 'none', + + '&:last-child': { + 'border-bottom-width': 0 + }, + + 'border-bottom-color': map.BORDER_COLOR + }, + + actionListItemSelected: { + 'background-color': map.SELECTED_BACKGROUND_COLOR + }, + + actionListItemSkipped: { + 'background-color': map.SKIPPED_BACKGROUND_COLOR + }, + + actionListFromFuture: { + opacity: '0.6' + }, + + actionListItemButtons: { + position: 'relative', + height: '20px', + display: 'flex' + }, + + actionListItemTime: { + display: 'inline', + padding: '4px 6px', + 'border-radius': '3px', + 'font-size': '0.8em', + 'line-height': '1em', + 'flex-shrink': 0, + + 'background-color': map.ACTION_TIME_BACK_COLOR, + color: map.ACTION_TIME_COLOR + }, + + actionListItemSelector: { + display: 'inline-flex' + }, + + actionListItemName: { + overflow: 'hidden', + 'text-overflow': 'ellipsis', + 'line-height': '20px' + }, + + actionListItemNameSkipped: { + 'text-decoration': 'line-through', + opacity: 0.3 + }, + + actionListHeaderSearch: { + outline: 'none', + border: 'none', + width: '100%', + padding: '5px 10px', + 'font-size': '1em', + 'font-family': 'monaco, Consolas, "Lucida Console", monospace', + + 'background-color': map.BACKGROUND_COLOR, + color: map.TEXT_COLOR, + + '&::-webkit-input-placeholder': { + color: map.TEXT_PLACEHOLDER_COLOR + }, + + '&::-moz-placeholder': { + color: map.TEXT_PLACEHOLDER_COLOR + } + }, + + actionListHeaderWrapper: { + position: 'relative', + height: '20px' + }, + + actionPreview: { + flex: 1, + display: 'flex', + 'flex-direction': 'column', + 'flex-grow': 1, + 'overflow-y': 'hidden', + + '& pre': { + border: 'inherit', + 'border-radius': '3px', + 'line-height': 'inherit', + color: 'inherit' + }, + + 'background-color': map.BACKGROUND_COLOR, + }, + + actionPreviewContent: { + flex: 1, + 'overflow-y': 'auto' + }, + + stateDiff: { + padding: '5px 0' + }, + + stateDiffEmpty: { + padding: '10px', + + color: map.TEXT_PLACEHOLDER_COLOR + }, + + stateError: { + padding: '10px', + 'margin-left': '14px', + 'font-weight': 'bold', + + color: map.ERROR_COLOR + }, + + inspectedPath: { + padding: '6px 0' + }, + + inspectedPathKey: { + '&:not(:last-child):after': { + content: '" > "' + } + }, + + inspectedPathKeyLink: { + cursor: 'pointer', + '&:hover': { + 'text-decoration': 'underline' + }, + + color: map.LINK_COLOR, + '&:hover': { + color: map.LINK_HOVER_COLOR + } + }, + + treeItemPin: { + 'font-size': '0.7em', + 'padding-left': '5px', + cursor: 'pointer', + '&:hover': { + 'text-decoration': 'underline' + }, + + color: map.PIN_COLOR + }, + + treeItemHint: { + color: map.ITEM_HINT_COLOR + }, + + previewHeader: { + flex: '0 0 30px', + padding: '5px 10px', + 'align-items': 'center', + 'border-bottom-width': '1px', + 'border-bottom-style': 'solid', + + 'background-color': map.HEADER_BACKGROUND_COLOR, + 'border-bottom-color': map.HEADER_BORDER_COLOR + }, + + tabSelector: { + position: 'relative', + 'z-index': 1, + display: 'inline-flex', + float: 'right' + }, + + selectorButton: { + cursor: 'pointer', + position: 'relative', + padding: '5px 10px', + 'border-style': 'solid', + 'border-width': '1px', + 'border-left-width': 0, + + '&:first-child': { + 'border-left-width': '1px', + 'border-top-left-radius': '3px', + 'border-bottom-left-radius': '3px' + }, + + '&:last-child': { + 'border-top-right-radius': '3px', + 'border-bottom-right-radius': '3px' + }, + + 'background-color': map.TAB_BACK_COLOR, + + '&:hover': { + 'background-color': map.TAB_BACK_HOVER_COLOR + }, + + 'border-color': map.TAB_BORDER_COLOR + }, + + selectorButtonSmall: { + padding: '0px 8px', + 'font-size': '0.8em' + }, + + selectorButtonSelected: { + 'background-color': map.TAB_BACK_SELECTED_COLOR + }, + + diff: { + padding: '2px 3px', + 'border-radius': '3px', + position: 'relative', + + color: map.TEXT_COLOR + }, + + diffWrap: { + position: 'relative', + 'z-index': 1 + }, + + diffAdd: { + 'background-color': map.DIFF_ADD_COLOR + }, + + diffRemove: { + 'text-decoration': 'line-through', + 'background-color': map.DIFF_REMOVE_COLOR + }, + + diffUpdateFrom: { + 'text-decoration': 'line-through', + 'background-color': map.DIFF_REMOVE_COLOR + }, + + diffUpdateTo: { + 'background-color': map.DIFF_ADD_COLOR + }, + + diffUpdateArrow: { + color: map.DIFF_ARROW_COLOR + }, + + rightSlider: { + 'font-smoothing': 'subpixel-antialiased', // http://stackoverflow.com/a/21136111/4218591 + position: 'absolute', + right: 0, + transform: 'translateX(150%)', + transition: 'transform 0.2s ease-in-out' + }, + + rightSliderRotate: { + transform: 'rotateX(90deg)', + transition: 'transform 0.2s ease-in-out 0.08s' + }, + + rightSliderShown: { + position: 'static', + transform: 'translateX(0)', + }, + + rightSliderRotateShown: { + transform: 'rotateX(0)', + transition: 'transform 0.2s ease-in-out 0.18s' + } +}); + +let themeSheet; + +const getDefaultThemeStyling = theme => { + if (themeSheet) { + themeSheet.detach(); + } + + themeSheet = jss.createStyleSheet( + getSheetFromColorMap(colorMap(theme)) + ).attach(); + + return themeSheet.classes; +}; + +export const base16Themes = { ...reduxThemes, ...inspectorThemes }; + +export const createStylingFromTheme = createStyling(getDefaultThemeStyling, { + defaultBase16: inspector, + base16Themes +}); diff --git a/packages/redux-devtools-inspector/src/utils/deepMap.js b/packages/redux-devtools-inspector/src/utils/deepMap.js new file mode 100644 index 00000000..c2e83b4a --- /dev/null +++ b/packages/redux-devtools-inspector/src/utils/deepMap.js @@ -0,0 +1,29 @@ +function deepMapCached(obj, f, ctx, cache) { + cache.push(obj); + if (Array.isArray(obj)) { + return obj.map(function(val, key) { + val = f.call(ctx, val, key); + return (typeof val === 'object' && cache.indexOf(val) === -1) ? + deepMapCached(val, f, ctx, cache) : val; + }); + } else if (typeof obj === 'object') { + const res = {}; + for (const key in obj) { + let val = obj[key]; + if (val && typeof val === 'object') { + val = f.call(ctx, val, key); + res[key] = cache.indexOf(val) === -1 ? + deepMapCached(val, f, ctx, cache) : val; + } else { + res[key] = f.call(ctx, val, key); + } + } + return res; + } else { + return obj; + } +} + +export default function deepMap(obj, f, ctx) { + return deepMapCached(obj, f, ctx, []); +} diff --git a/packages/redux-devtools-inspector/src/utils/getInspectedState.js b/packages/redux-devtools-inspector/src/utils/getInspectedState.js new file mode 100644 index 00000000..07f2f1d6 --- /dev/null +++ b/packages/redux-devtools-inspector/src/utils/getInspectedState.js @@ -0,0 +1,45 @@ +import { Iterable, fromJS } from 'immutable'; +import isIterable from './isIterable'; + +function iterateToKey(obj, key) { // maybe there's a better way, dunno + let idx = 0; + for (let entry of obj) { + if (Array.isArray(entry)) { + if (entry[0] === key) return entry[1]; + } else { + if (idx > key) return; + if (idx === key) return entry; + } + idx++; + } +} + +export default function getInspectedState(state, path, convertImmutable) { + state = path && path.length ? + { + [path[path.length - 1]]: path.reduce( + (s, key) => { + if (!s) { + return s; + } + + if (Iterable.isAssociative(s)) { + return s.get(key); + } else if (isIterable(s)) { + return iterateToKey(s, key); + } + + return s[key]; + }, + state + ) + } : state; + + if (convertImmutable) { + try { + state = fromJS(state).toJS(); + } catch(e) {} + } + + return state; +} diff --git a/packages/redux-devtools-inspector/src/utils/isIterable.js b/packages/redux-devtools-inspector/src/utils/isIterable.js new file mode 100644 index 00000000..dc5d038c --- /dev/null +++ b/packages/redux-devtools-inspector/src/utils/isIterable.js @@ -0,0 +1,4 @@ +export default function isIterable(obj) { + return obj !== null && typeof obj === 'object' && !Array.isArray(obj) && + typeof obj[window.Symbol.iterator] === 'function'; +} diff --git a/packages/redux-devtools-inspector/webpack.config.js b/packages/redux-devtools-inspector/webpack.config.js new file mode 100644 index 00000000..7e8ae0a3 --- /dev/null +++ b/packages/redux-devtools-inspector/webpack.config.js @@ -0,0 +1,76 @@ +var path = require('path'); +var webpack = require('webpack'); +var HtmlWebpackPlugin = require('html-webpack-plugin'); +var CleanWebpackPlugin = require('clean-webpack-plugin'); +var ExportFilesWebpackPlugin = require('export-files-webpack-plugin'); +var NyanProgressWebpackPlugin = require('nyan-progress-webpack-plugin'); + +var pkg = require('./package.json'); + +var isProduction = process.env.NODE_ENV === 'production'; + +module.exports = { + devtool: 'eval', + entry: isProduction ? + [ './demo/src/js/index' ] : + [ + 'webpack-dev-server/client?http://localhost:3000', + 'webpack/hot/only-dev-server', + './demo/src/js/index' + ], + output: { + path: path.join(__dirname, 'demo/dist'), + filename: 'js/bundle.js', + hash: true + }, + plugins: [ + new CleanWebpackPlugin(isProduction ? ['demo/dist'] : []), + new HtmlWebpackPlugin({ + inject: true, + template: 'demo/src/index.html', + filename: 'index.html', + package: pkg + }), + new webpack.DefinePlugin({ + 'process.env': { + NODE_ENV: JSON.stringify(process.env.NODE_ENV) + }, + }), + new webpack.NoErrorsPlugin(), + new NyanProgressWebpackPlugin() + ].concat(isProduction ? [ + new webpack.optimize.UglifyJsPlugin({ + compress: { warnings: false }, + output: { comments: false } + }) + ] : [ + new ExportFilesWebpackPlugin('demo/dist/index.html'), + new webpack.HotModuleReplacementPlugin() + ]), + resolve: { + extensions: ['', '.js', '.jsx'] + }, + module: { + loaders: [{ + test: /\.jsx?$/, + loaders: ['babel'], + include: [ + path.join(__dirname, 'src'), + path.join(__dirname, 'demo/src/js') + ] + }, { + test: /\.json$/, + loader: 'json' + }] + }, + devServer: isProduction ? null : { + quiet: false, + port: 3000, + hot: true, + stats: { + chunkModules: false, + colors: true + }, + historyApiFallback: true + } +};