├── .babelrc ├── .circleci └── config.yml ├── .gitignore ├── .prettierignore ├── .prettierrc ├── .releaserc ├── LICENSE ├── README.md ├── package.json ├── renovate.json ├── rollup.config.js ├── src ├── commandHandler.test.ts ├── commandHandler.ts ├── customDispatch.test.ts ├── customDispatch.ts ├── enhancer.test.ts ├── enhancer.ts ├── helpers │ ├── pathObject.test.ts │ ├── pathObject.ts │ ├── stateCleaner.test.ts │ └── stateCleaner.ts ├── index.ts ├── pluginConfig.ts ├── reducer.test.ts ├── reducer.ts ├── sendAction.test.ts ├── sendAction.ts ├── subscriptionsHandler.test.ts ├── subscriptionsHandler.ts └── testHelpers.ts ├── tsconfig.json ├── wallaby.js └── yarn.lock /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | "@babel/preset-env", 4 | "@babel/preset-typescript" 5 | ] 6 | } 7 | -------------------------------------------------------------------------------- /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | # Javascript Node CircleCI 2.0 configuration file 2 | # 3 | # Check https://circleci.com/docs/2.0/language-javascript/ for more details 4 | # 5 | 6 | defaults: &defaults 7 | docker: 8 | - image: circleci/node:10.19 9 | working_directory: ~/repo 10 | 11 | version: 2 12 | jobs: 13 | setup: 14 | <<: *defaults 15 | steps: 16 | - checkout 17 | - restore_cache: 18 | name: Restore node modules 19 | keys: 20 | - v1-dependencies-{{ checksum "package.json" }} 21 | # fallback to using the latest cache if no exact match is found 22 | - v1-dependencies- 23 | - run: 24 | name: Install dependencies 25 | command: yarn install 26 | - save_cache: 27 | name: Save node modules 28 | paths: 29 | - node_modules 30 | key: v1-dependencies-{{ checksum "package.json" }} 31 | 32 | tests: 33 | <<: *defaults 34 | steps: 35 | - checkout 36 | - restore_cache: 37 | name: Restore node modules 38 | keys: 39 | - v1-dependencies-{{ checksum "package.json" }} 40 | # fallback to using the latest cache if no exact match is found 41 | - v1-dependencies- 42 | - run: 43 | name: Run tests 44 | command: yarn ci:test 45 | 46 | build: 47 | <<: *defaults 48 | steps: 49 | - checkout 50 | - restore_cache: 51 | name: Restore node modules 52 | keys: 53 | - v1-dependencies-{{ checksum "package.json" }} 54 | # fallback to using the latest cache if no exact match is found 55 | - v1-dependencies- 56 | - run: 57 | name: Run Build 58 | command: yarn build 59 | - save_cache: 60 | name: Save node modules 61 | paths: 62 | - dist 63 | key: v1-build-{{ .Branch }}-{{ .Revision }} 64 | 65 | publish: 66 | <<: *defaults 67 | steps: 68 | - checkout 69 | - run: echo "//registry.npmjs.org/:_authToken=$NPM_TOKEN" >> ~/.npmrc 70 | - restore_cache: 71 | name: Restore node modules 72 | keys: 73 | - v1-dependencies-{{ checksum "package.json" }} 74 | # fallback to using the latest cache if no exact match is found 75 | - v1-dependencies- 76 | - restore_cache: 77 | name: Restore build 78 | keys: 79 | - v1-build-{{ .Branch }}-{{ .Revision }} 80 | - run: 81 | name: Publish to NPM 82 | command: yarn ci:publish 83 | 84 | workflows: 85 | version: 2 86 | test_and_release: 87 | jobs: 88 | - setup 89 | - tests: 90 | requires: 91 | - setup 92 | - build: 93 | requires: 94 | - tests 95 | - publish: 96 | requires: 97 | - build 98 | filters: 99 | branches: 100 | only: 101 | - master 102 | - next 103 | - beta 104 | - alpha 105 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | npm-debug.log 3 | coverage 4 | .nyc_output 5 | dist 6 | yarn-error.log 7 | .idea 8 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | **/.vscode 2 | **/android 3 | **/build 4 | **/compiled 5 | **/dist 6 | **/ios 7 | **/package.json 8 | **/release 9 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 100, 3 | "semi": false, 4 | "trailingComma": "es5" 5 | } -------------------------------------------------------------------------------- /.releaserc: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": [ 3 | "@semantic-release/commit-analyzer", 4 | "@semantic-release/release-notes-generator", 5 | "@semantic-release/npm", 6 | "@semantic-release/github", 7 | [ 8 | "@semantic-release/git", 9 | { 10 | "assets": "package.json", 11 | "message": "chore(release): ${nextRelease.version} [skip ci]\n\n${nextRelease.notes}" 12 | } 13 | ] 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 - 3016 Infinite Red LLC. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | > [!WARNING] 2 | > Development has moved been out of this repository. Look for current development at https://github.com/infinitered/reactotron. 3 | 4 | # reactotron-redux 5 | 6 | Find the docs located [here](https://github.com/infinitered/reactotron/blob/master/docs/plugin-redux.md) 7 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "reactotron-redux", 3 | "version": "3.1.3", 4 | "description": "A Reactotron plugin for Redux.", 5 | "author": "Infinite Red", 6 | "license": "MIT", 7 | "bugs": { 8 | "url": "https://github.com/infinitered/reactotron-redux/issues" 9 | }, 10 | "homepage": "https://github.com/infinitered/reactotron-redux", 11 | "repository": "https://github.com/infinitered/reactotron-redux", 12 | "files": [ 13 | "dist", 14 | "LICENSE", 15 | "README.md", 16 | "reactotron-redux.d.ts" 17 | ], 18 | "main": "dist/index.js", 19 | "types": "./dist/types/index.d.ts", 20 | "scripts": { 21 | "test": "jest", 22 | "test:watch": "jest --watch --notify", 23 | "format": "prettier --write {**,.}/*.ts", 24 | "build": "npm-run-all clean tsc compile", 25 | "build:dev": "npm-run-all clean tsc compile:dev", 26 | "clean": "trash dist", 27 | "lint": "eslint src --ext .ts,.tsx", 28 | "compile": "NODE_ENV=production rollup -c", 29 | "compile:dev": "NODE_ENV=development rollup -c", 30 | "tsc": "tsc", 31 | "ci:test": "yarn test", 32 | "ci:publish": "yarn semantic-release", 33 | "semantic-release": "semantic-release" 34 | }, 35 | "peerDependencies": { 36 | "reactotron-core-client": "^2.5.0", 37 | "redux": "^4.0.1" 38 | }, 39 | "devDependencies": { 40 | "@babel/core": "7.9.0", 41 | "@babel/preset-env": "7.9.0", 42 | "@babel/preset-typescript": "7.9.0", 43 | "@semantic-release/git": "7.1.0-beta.3", 44 | "@types/jest": "24.9.1", 45 | "@typescript-eslint/eslint-plugin": "1.3.0", 46 | "@typescript-eslint/parser": "1.3.0", 47 | "babel-eslint": "10.1.0", 48 | "babel-jest": "24.1.0", 49 | "eslint": "5.13.0", 50 | "eslint-config-prettier": "4.0.0", 51 | "eslint-config-standard": "12.0.0", 52 | "eslint-plugin-import": "2.16.0", 53 | "eslint-plugin-node": "8.0.1", 54 | "eslint-plugin-promise": "4.0.1", 55 | "eslint-plugin-standard": "4.0.0", 56 | "jest": "24.1.0", 57 | "npm-run-all": "4.1.5", 58 | "prettier": "1.16.4", 59 | "reactotron-core-client": "2.5.0", 60 | "redux": "4.0.1", 61 | "rollup": "1.1.2", 62 | "rollup-plugin-babel": "4.3.2", 63 | "rollup-plugin-babel-minify": "7.0.0", 64 | "rollup-plugin-filesize": "6.0.1", 65 | "rollup-plugin-node-resolve": "4.0.0", 66 | "rollup-plugin-resolve": "0.0.1-predev.1", 67 | "semantic-release": "16.0.0-beta.36", 68 | "trash-cli": "1.4.0", 69 | "ts-jest": "23.10.5", 70 | "typescript": "3.3.3" 71 | }, 72 | "eslintConfig": { 73 | "parser": "@typescript-eslint/parser", 74 | "extends": [ 75 | "plugin:@typescript-eslint/recommended", 76 | "standard", 77 | "prettier" 78 | ], 79 | "parserOptions": { 80 | "ecmaFeatures": { 81 | "jsx": true 82 | }, 83 | "project": "./tsconfig.json" 84 | }, 85 | "plugins": [ 86 | "@typescript-eslint" 87 | ], 88 | "globals": { 89 | "__DEV__": false, 90 | "jasmine": false, 91 | "beforeAll": false, 92 | "afterAll": false, 93 | "beforeEach": false, 94 | "afterEach": false, 95 | "test": false, 96 | "expect": false, 97 | "describe": false, 98 | "jest": false, 99 | "it": false 100 | }, 101 | "rules": { 102 | "no-unused-vars": 0, 103 | "no-undef": 0, 104 | "space-before-function-paren": 0, 105 | "@typescript-eslint/indent": 0, 106 | "@typescript-eslint/explicit-member-accessibility": 0, 107 | "@typescript-eslint/explicit-function-return-type": 0, 108 | "@typescript-eslint/no-explicit-any": 0, 109 | "@typescript-eslint/no-object-literal-type-assertion": 0, 110 | "@typescript-eslint/no-empty-interface": 0, 111 | "@typescript-eslint/no-var-requires": 0, 112 | "@typescript-eslint/member-delimiter-style": 0 113 | } 114 | }, 115 | "jest": { 116 | "preset": "ts-jest", 117 | "testEnvironment": "node", 118 | "testMatch": [ 119 | "**/*.test.[tj]s" 120 | ] 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "config:base" 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import resolve from "rollup-plugin-node-resolve" 2 | import babel from "rollup-plugin-babel" 3 | import filesize from "rollup-plugin-filesize" 4 | import minify from "rollup-plugin-babel-minify" 5 | 6 | export default { 7 | input: "src/index.ts", 8 | output: { 9 | file: "dist/index.js", 10 | format: "cjs", 11 | }, 12 | plugins: [ 13 | resolve({ extensions: [".ts"] }), 14 | babel({ extensions: [".ts"], runtimeHelpers: true }), 15 | process.env.NODE_ENV === "production" 16 | ? minify({ 17 | comments: false, 18 | }) 19 | : null, 20 | filesize(), 21 | ], 22 | external: ["redux", "reactotron-core-client"], 23 | } 24 | -------------------------------------------------------------------------------- /src/commandHandler.test.ts: -------------------------------------------------------------------------------- 1 | import createCommandHandler from "./commandHandler" 2 | import { DEFAULT_REPLACER_TYPE } from "./reducer" 3 | import { PluginConfig } from "./pluginConfig" 4 | import { defaultReactotronMock } from './testHelpers' 5 | 6 | // TODO: Write more tests around onBackup and onRestore. 7 | 8 | const defaultPluginConfig: PluginConfig = { 9 | restoreActionType: DEFAULT_REPLACER_TYPE, 10 | onBackup: (state: any) => state, 11 | onRestore: (restoringState: any) => restoringState, 12 | } 13 | 14 | describe("commandHandler", () => { 15 | it("should create a function when called", () => { 16 | const reactotronMock = { 17 | ...defaultReactotronMock, 18 | reduxStore: { 19 | getState: jest.fn().mockReturnValue({ topLevel: { here: true } }), 20 | subscribe: jest.fn() 21 | }, 22 | stateKeysResponse: jest.fn(), 23 | } 24 | 25 | const commandHandler = createCommandHandler(reactotronMock, defaultPluginConfig, () => {}) 26 | 27 | expect(typeof commandHandler).toEqual("function") 28 | }) 29 | 30 | it("should handle a 'state.keys.request' command type for no path", () => { 31 | const reactotronMock = { 32 | ...defaultReactotronMock, 33 | reduxStore: { 34 | getState: jest.fn().mockReturnValue({ topLevel: { here: true } }), 35 | subscribe: jest.fn() 36 | }, 37 | stateKeysResponse: jest.fn(), 38 | } 39 | 40 | const commandHandler = createCommandHandler(reactotronMock, defaultPluginConfig, () => {}) 41 | 42 | commandHandler({ type: "state.keys.request", payload: { path: "" } }) 43 | 44 | expect(reactotronMock.stateKeysResponse).toHaveBeenCalledWith(null, ["topLevel"]) 45 | }) 46 | 47 | it("should handle a 'state.keys.request' command type for a single level", () => { 48 | const reactotronMock = { 49 | ...defaultReactotronMock, 50 | reduxStore: { 51 | getState: jest.fn().mockReturnValue({ topLevel: { here: true } }), 52 | subscribe: jest.fn() 53 | }, 54 | stateKeysResponse: jest.fn(), 55 | } 56 | 57 | const commandHandler = createCommandHandler(reactotronMock, defaultPluginConfig, () => {}) 58 | 59 | commandHandler({ type: "state.keys.request", payload: { path: "topLevel" } }) 60 | 61 | expect(reactotronMock.stateKeysResponse).toHaveBeenCalledWith("topLevel", ["here"]) 62 | }) 63 | 64 | it("should handle a 'state.keys.request' command type for two levels", () => { 65 | const reactotronMock = { 66 | ...defaultReactotronMock, 67 | reduxStore: { 68 | getState: jest.fn().mockReturnValue({ topLevel: { here: { nested: true } } }), 69 | subscribe: jest.fn() 70 | }, 71 | stateKeysResponse: jest.fn(), 72 | } 73 | 74 | const commandHandler = createCommandHandler(reactotronMock, defaultPluginConfig, () => {}) 75 | 76 | commandHandler({ type: "state.keys.request", payload: { path: "topLevel.here" } }) 77 | 78 | expect(reactotronMock.stateKeysResponse).toHaveBeenCalledWith("topLevel.here", ["nested"]) 79 | }) 80 | 81 | it("should handle a 'state.keys.request' command type for a path that isn't an object", () => { 82 | const reactotronMock = { 83 | ...defaultReactotronMock, 84 | reduxStore: { 85 | getState: jest.fn().mockReturnValue({ topLevel: { here: { nested: true } } }), 86 | subscribe: jest.fn() 87 | }, 88 | stateKeysResponse: jest.fn(), 89 | } 90 | 91 | const commandHandler = createCommandHandler(reactotronMock, defaultPluginConfig, () => {}) 92 | 93 | commandHandler({ type: "state.keys.request", payload: { path: "topLevel.here.nested" } }) 94 | 95 | expect(reactotronMock.stateKeysResponse).toHaveBeenCalledWith("topLevel.here.nested", undefined) 96 | }) 97 | 98 | it("should handle a 'state.keys.request' command type for a path that is invalid", () => { 99 | const reactotronMock = { 100 | ...defaultReactotronMock, 101 | reduxStore: { 102 | getState: jest.fn().mockReturnValue({ topLevel: { here: { nested: true } } }), 103 | subscribe: jest.fn() 104 | }, 105 | stateKeysResponse: jest.fn(), 106 | } 107 | 108 | const commandHandler = createCommandHandler(reactotronMock, defaultPluginConfig, () => {}) 109 | 110 | commandHandler({ type: "state.keys.request", payload: { path: "topLevel2.here.nested" } }) 111 | 112 | expect(reactotronMock.stateKeysResponse).toHaveBeenCalledWith("topLevel2.here.nested", undefined) 113 | }) 114 | 115 | it("should handle a 'state.values.request' command type for a single level", () => { 116 | const reactotronMock = { 117 | ...defaultReactotronMock, 118 | reduxStore: { 119 | getState: jest.fn().mockReturnValue({ topLevel: { here: true } }), 120 | subscribe: jest.fn() 121 | }, 122 | stateValuesResponse: jest.fn(), 123 | } 124 | 125 | const commandHandler = createCommandHandler(reactotronMock, defaultPluginConfig, () => {}) 126 | 127 | commandHandler({ type: "state.values.request", payload: { path: "topLevel" } }) 128 | 129 | expect(reactotronMock.stateValuesResponse).toHaveBeenCalledWith("topLevel", { here: true }) 130 | }) 131 | 132 | it("should handle a 'state.values.request' command type for two levels", () => { 133 | const reactotronMock = { 134 | ...defaultReactotronMock, 135 | reduxStore: { 136 | getState: jest.fn().mockReturnValue({ topLevel: { here: { nested: true } } }), 137 | subscribe: jest.fn() 138 | }, 139 | stateValuesResponse: jest.fn(), 140 | } 141 | 142 | const commandHandler = createCommandHandler(reactotronMock, defaultPluginConfig, () => {}) 143 | 144 | commandHandler({ type: "state.values.request", payload: { path: "topLevel.here" } }) 145 | 146 | expect(reactotronMock.stateValuesResponse).toHaveBeenCalledWith("topLevel.here", { nested: true }) 147 | }) 148 | 149 | it("should handle a 'state.values.request' command type for a path that isn't an object", () => { 150 | const reactotronMock = { 151 | ...defaultReactotronMock, 152 | reduxStore: { 153 | getState: jest.fn().mockReturnValue({ topLevel: { here: { nested: true } } }), 154 | subscribe: jest.fn() 155 | }, 156 | stateValuesResponse: jest.fn(), 157 | } 158 | 159 | const commandHandler = createCommandHandler(reactotronMock, defaultPluginConfig, () => {}) 160 | 161 | commandHandler({ type: "state.values.request", payload: { path: "topLevel.here.nested" } }) 162 | 163 | expect(reactotronMock.stateValuesResponse).toHaveBeenCalledWith("topLevel.here.nested", true) 164 | }) 165 | 166 | it("should handle a 'state.values.request' command type for a path that is invalid", () => { 167 | const reactotronMock = { 168 | ...defaultReactotronMock, 169 | reduxStore: { 170 | getState: jest.fn().mockReturnValue({ topLevel: { here: { nested: true } } }), 171 | subscribe: jest.fn() 172 | }, 173 | stateValuesResponse: jest.fn(), 174 | } 175 | 176 | const commandHandler = createCommandHandler(reactotronMock, defaultPluginConfig, () => {}) 177 | 178 | commandHandler({ type: "state.values.request", payload: { path: "topLevel2.here.nested" } }) 179 | 180 | expect(reactotronMock.stateValuesResponse).toHaveBeenCalledWith("topLevel2.here.nested", undefined) 181 | }) 182 | 183 | it.todo("should handle a 'state.values.subscribe' command type") 184 | 185 | it("should handle a 'state.action.dispatch' command type", () => { 186 | const reactotronMock = { 187 | ...defaultReactotronMock, 188 | reduxStore: { 189 | dispatch: jest.fn(), 190 | subscribe: jest.fn() 191 | }, 192 | } 193 | const commandHandler = createCommandHandler(reactotronMock, defaultPluginConfig, () => {}) 194 | 195 | commandHandler({ type: "state.action.dispatch", payload: { action: { type: "Do a thing." } } }) 196 | 197 | expect(reactotronMock.reduxStore.dispatch).toHaveBeenCalledWith({ type: "Do a thing." }) 198 | }) 199 | 200 | it("should handle a 'state.backup.request' command type", () => { 201 | const reactotronMock = { 202 | ...defaultReactotronMock, 203 | reduxStore: { 204 | getState: jest.fn().mockReturnValue({ myState: true }), 205 | subscribe: jest.fn() 206 | }, 207 | send: jest.fn(), 208 | } 209 | 210 | const commandHandler = createCommandHandler(reactotronMock, defaultPluginConfig, () => {}) 211 | 212 | commandHandler({ type: "state.backup.request" }) 213 | 214 | expect(reactotronMock.reduxStore.getState).toHaveBeenCalledTimes(1) 215 | expect(reactotronMock.send).toHaveBeenCalledWith("state.backup.response", { 216 | state: { myState: true }, 217 | }) 218 | }) 219 | 220 | it("should handle a 'state.restore.request' command type", () => { 221 | const reactotronMock = { 222 | ...defaultReactotronMock, 223 | reduxStore: { 224 | getState: jest.fn(), 225 | dispatch: jest.fn(), 226 | subscribe: jest.fn() 227 | }, 228 | } 229 | 230 | const commandHandler = createCommandHandler(reactotronMock, defaultPluginConfig, () => {}) 231 | 232 | commandHandler({ type: "state.restore.request", payload: { state: { myReplacedState: true } } }) 233 | 234 | expect(reactotronMock.reduxStore.dispatch).toHaveBeenCalledWith({ 235 | type: DEFAULT_REPLACER_TYPE, 236 | state: { myReplacedState: true }, 237 | }) 238 | }) 239 | }) 240 | -------------------------------------------------------------------------------- /src/commandHandler.ts: -------------------------------------------------------------------------------- 1 | import { Reactotron } from "reactotron-core-client"; 2 | 3 | import stateCleaner from "./helpers/stateCleaner" 4 | import pathObject from "./helpers/pathObject" 5 | import createSubscriptionsHandler from "./subscriptionsHandler" 6 | import { PluginConfig } from "./pluginConfig" 7 | 8 | export default function createCommandHandler(reactotron: Reactotron, pluginConfig: PluginConfig, onReduxStoreCreation: (func: () => void) => void) { 9 | const subscriptionsHandler = createSubscriptionsHandler(reactotron, onReduxStoreCreation) 10 | 11 | return ({ type, payload }: { type: string; payload?: any }) => { 12 | const reduxStore = reactotron.reduxStore 13 | 14 | switch (type) { 15 | // client is asking for keys 16 | case "state.keys.request": 17 | case "state.values.request": 18 | const cleanedState = stateCleaner(reduxStore.getState()) 19 | 20 | if (!payload.path) { 21 | reactotron.stateKeysResponse( 22 | null, 23 | type === "state.keys.request" ? Object.keys(cleanedState) : cleanedState 24 | ) 25 | } else { 26 | const filteredObj = pathObject(payload.path, cleanedState) 27 | 28 | const responseMethod = 29 | type === "state.keys.request" 30 | ? reactotron.stateKeysResponse 31 | : reactotron.stateValuesResponse 32 | 33 | responseMethod( 34 | payload.path, 35 | type === "state.keys.request" 36 | ? typeof filteredObj === "object" 37 | ? Object.keys(filteredObj) 38 | : undefined 39 | : filteredObj 40 | ) 41 | } 42 | 43 | break 44 | 45 | // client is asking to subscribe to some paths 46 | case "state.values.subscribe": 47 | subscriptionsHandler.setSubscriptions(payload.paths) 48 | subscriptionsHandler.sendSubscriptions() 49 | break 50 | 51 | // server is asking to dispatch this action 52 | case "state.action.dispatch": 53 | reduxStore.dispatch(payload.action) 54 | break 55 | 56 | // server is asking to backup state 57 | case "state.backup.request": 58 | // run our state through our onBackup 59 | let backedUpState = reduxStore.getState() 60 | 61 | if (pluginConfig.onBackup) { 62 | backedUpState = pluginConfig.onBackup(backedUpState) 63 | } 64 | 65 | reactotron.send("state.backup.response", { state: backedUpState }) 66 | break 67 | 68 | // server is asking to clobber state with this 69 | case "state.restore.request": 70 | // run our state through our onRestore 71 | let restoredState = payload.state 72 | 73 | if (pluginConfig.onRestore) { 74 | restoredState = pluginConfig.onRestore(payload.state, reduxStore.getState()) 75 | } 76 | 77 | reactotron.reduxStore.dispatch({ 78 | type: pluginConfig.restoreActionType, 79 | state: restoredState, 80 | }) 81 | 82 | break 83 | } 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /src/customDispatch.test.ts: -------------------------------------------------------------------------------- 1 | import createCustomDispatch from "./customDispatch" 2 | import { DEFAULT_REPLACER_TYPE } from "./reducer" 3 | 4 | describe("customDispatch", () => { 5 | it("should send an action to reactotron", () => { 6 | const mockReactotron = { 7 | startTimer: () => jest.fn().mockReturnValue(1000), 8 | reportReduxAction: jest.fn(), 9 | } 10 | const mockStore = { 11 | dispatch: jest.fn(), 12 | } 13 | const action = { 14 | type: "Any Type", 15 | payload: { allTheSecrets: true }, 16 | } 17 | 18 | const dispatch = createCustomDispatch(mockReactotron, mockStore, {}) 19 | 20 | dispatch(action) 21 | 22 | expect(mockStore.dispatch).toHaveBeenCalledWith(action) 23 | expect(mockReactotron.reportReduxAction).toHaveBeenCalledWith(action, 1000, false) 24 | }) 25 | 26 | it.todo("should handle 'PERFORM_ACTION' actions correctly") 27 | 28 | it("should respect the exclude list and not send an item off of it if it is a string", () => { 29 | const mockReactotron = { 30 | startTimer: () => jest.fn().mockReturnValue(1000), 31 | reportReduxAction: jest.fn(), 32 | } 33 | const mockStore = { 34 | dispatch: jest.fn(), 35 | } 36 | const action = { 37 | type: "IGNORE_ME", 38 | payload: { allTheSecrets: true }, 39 | } 40 | 41 | const dispatch = createCustomDispatch(mockReactotron, mockStore, { 42 | restoreActionType: DEFAULT_REPLACER_TYPE, 43 | except: ["IGNORE_ME"], 44 | }) 45 | 46 | dispatch(action) 47 | 48 | expect(mockStore.dispatch).toHaveBeenCalledWith(action) 49 | expect(mockReactotron.reportReduxAction).not.toHaveBeenCalled() 50 | }) 51 | 52 | it("should respect the exclude list and not send an item off of it if it is a function", () => { 53 | const mockReactotron = { 54 | startTimer: () => jest.fn().mockReturnValue(1000), 55 | reportReduxAction: jest.fn(), 56 | } 57 | const mockStore = { 58 | dispatch: jest.fn(), 59 | } 60 | const action = { 61 | type: "IGNORE_ME", 62 | payload: { allTheSecrets: true }, 63 | } 64 | 65 | const dispatch = createCustomDispatch(mockReactotron, mockStore, { 66 | restoreActionType: DEFAULT_REPLACER_TYPE, 67 | except: [(actionType: string) => actionType.startsWith("IGNORE")], 68 | }) 69 | 70 | dispatch(action) 71 | 72 | expect(mockStore.dispatch).toHaveBeenCalledWith(action) 73 | expect(mockReactotron.reportReduxAction).not.toHaveBeenCalled() 74 | }) 75 | 76 | it("should respect the exclude list and send an item off of it if it is a function", () => { 77 | const mockReactotron = { 78 | startTimer: () => jest.fn().mockReturnValue(1000), 79 | reportReduxAction: jest.fn(), 80 | } 81 | const mockStore = { 82 | dispatch: jest.fn(), 83 | } 84 | const action = { 85 | type: "DONT_IGNORE_ME", 86 | payload: { allTheSecrets: true }, 87 | } 88 | 89 | const dispatch = createCustomDispatch(mockReactotron, mockStore, { 90 | restoreActionType: DEFAULT_REPLACER_TYPE, 91 | except: [(actionType: string) => actionType.startsWith("IGNORE")], 92 | }) 93 | 94 | dispatch(action) 95 | 96 | expect(mockStore.dispatch).toHaveBeenCalledWith(action) 97 | expect(mockReactotron.reportReduxAction).toHaveBeenCalledWith(action, 1000, false) 98 | }) 99 | 100 | it("should respect the exclude list and not send an item off of it if it is a regex", () => { 101 | const mockReactotron = { 102 | startTimer: () => jest.fn().mockReturnValue(1000), 103 | reportReduxAction: jest.fn(), 104 | } 105 | const mockStore = { 106 | dispatch: jest.fn(), 107 | } 108 | const action = { 109 | type: "IGNORE_ME", 110 | payload: { allTheSecrets: true }, 111 | } 112 | 113 | const dispatch = createCustomDispatch(mockReactotron, mockStore, { 114 | restoreActionType: DEFAULT_REPLACER_TYPE, 115 | except: [/[A-Z]/], 116 | }) 117 | 118 | dispatch(action) 119 | 120 | expect(mockStore.dispatch).toHaveBeenCalledWith(action) 121 | expect(mockReactotron.reportReduxAction).not.toHaveBeenCalled() 122 | }) 123 | 124 | it("should respect the exclude list and send an item off of it if it is a regex", () => { 125 | const mockReactotron = { 126 | startTimer: () => jest.fn().mockReturnValue(1000), 127 | reportReduxAction: jest.fn(), 128 | } 129 | const mockStore = { 130 | dispatch: jest.fn(), 131 | } 132 | const action = { 133 | type: "1234", 134 | payload: { allTheSecrets: true }, 135 | } 136 | 137 | const dispatch = createCustomDispatch(mockReactotron, mockStore, { 138 | restoreActionType: DEFAULT_REPLACER_TYPE, 139 | except: [/[A-Z]/], 140 | }) 141 | 142 | dispatch(action) 143 | 144 | expect(mockStore.dispatch).toHaveBeenCalledWith(action) 145 | expect(mockReactotron.reportReduxAction).toHaveBeenCalledWith(action, 1000, false) 146 | }) 147 | 148 | it("should respect the exclude list and should still send items not on the list", () => { 149 | const mockReactotron = { 150 | startTimer: () => jest.fn().mockReturnValue(1000), 151 | reportReduxAction: jest.fn(), 152 | } 153 | const mockStore = { 154 | dispatch: jest.fn(), 155 | } 156 | const action = { 157 | type: "DONT_IGNORE_ME", 158 | payload: { allTheSecrets: true }, 159 | } 160 | 161 | const dispatch = createCustomDispatch(mockReactotron, mockStore, { 162 | restoreActionType: DEFAULT_REPLACER_TYPE, 163 | except: ["IGNORE_ME"], 164 | }) 165 | 166 | dispatch(action) 167 | 168 | expect(mockStore.dispatch).toHaveBeenCalledWith(action) 169 | expect(mockReactotron.reportReduxAction).toHaveBeenCalledWith(action, 1000, false) 170 | }) 171 | 172 | it("should exclude the restoreActionType by default", () => { 173 | const mockReactotron = { 174 | startTimer: () => jest.fn().mockReturnValue(1000), 175 | reportReduxAction: jest.fn(), 176 | } 177 | const mockStore = { 178 | dispatch: jest.fn(), 179 | } 180 | const action = { 181 | type: DEFAULT_REPLACER_TYPE, 182 | payload: { allTheSecrets: true }, 183 | } 184 | 185 | const dispatch = createCustomDispatch(mockReactotron, mockStore, { 186 | restoreActionType: DEFAULT_REPLACER_TYPE, 187 | }) 188 | 189 | dispatch(action) 190 | 191 | expect(mockStore.dispatch).toHaveBeenCalledWith(action) 192 | expect(mockReactotron.reportReduxAction).not.toHaveBeenCalled() 193 | }) 194 | 195 | it("should call isActionImportant and mark the action as important if it returns true", () => { 196 | const mockReactotron = { 197 | startTimer: () => jest.fn().mockReturnValue(1000), 198 | reportReduxAction: jest.fn(), 199 | } 200 | const mockStore = { 201 | dispatch: jest.fn(), 202 | } 203 | const action = { 204 | type: "MAKE_ME_IMPORTANT", 205 | payload: { allTheSecrets: true }, 206 | } 207 | 208 | const dispatch = createCustomDispatch(mockReactotron, mockStore, { 209 | restoreActionType: DEFAULT_REPLACER_TYPE, 210 | isActionImportant: action => action.type === "MAKE_ME_IMPORTANT", 211 | }) 212 | 213 | dispatch(action) 214 | 215 | expect(mockStore.dispatch).toHaveBeenCalledWith(action) 216 | expect(mockReactotron.reportReduxAction).toHaveBeenCalledWith(action, 1000, true) 217 | }) 218 | 219 | it("should call isActionImportant and mark the action as important if it returns false", () => { 220 | const mockReactotron = { 221 | startTimer: () => jest.fn().mockReturnValue(1000), 222 | reportReduxAction: jest.fn(), 223 | } 224 | const mockStore = { 225 | dispatch: jest.fn(), 226 | } 227 | const action = { 228 | type: "MAKE_ME_NOT_MPORTANT", 229 | payload: { allTheSecrets: true }, 230 | } 231 | 232 | const dispatch = createCustomDispatch(mockReactotron, mockStore, { 233 | restoreActionType: DEFAULT_REPLACER_TYPE, 234 | isActionImportant: action => action.type === "MAKE_ME_IMPORTANT", 235 | }) 236 | 237 | dispatch(action) 238 | 239 | expect(mockStore.dispatch).toHaveBeenCalledWith(action) 240 | expect(mockReactotron.reportReduxAction).toHaveBeenCalledWith(action, 1000, false) 241 | }) 242 | }) 243 | -------------------------------------------------------------------------------- /src/customDispatch.ts: -------------------------------------------------------------------------------- 1 | import { PluginConfig } from "./pluginConfig" 2 | 3 | export default function createCustomDispatch( 4 | reactotron: any, 5 | store: { dispatch: Function }, 6 | pluginConfig: PluginConfig 7 | ) { 8 | const exceptions = [pluginConfig.restoreActionType, ...(pluginConfig.except || [])] 9 | 10 | return (action: any) => { 11 | // start a timer 12 | const elapsed = reactotron.startTimer() 13 | 14 | // call the original dispatch that actually does the real work 15 | const result = store.dispatch(action) 16 | 17 | // stop the timer 18 | const ms = elapsed() 19 | 20 | var unwrappedAction = action.type === "PERFORM_ACTION" && action.action ? action.action : action 21 | 22 | const isException = exceptions.some(exception => { 23 | if (typeof exception === "string") { 24 | return unwrappedAction.type === exception 25 | } else if (typeof exception === "function") { 26 | return exception(unwrappedAction.type) 27 | } else if (exception instanceof RegExp) { 28 | return exception.test(unwrappedAction.type) 29 | } else { 30 | return false 31 | } 32 | }) 33 | 34 | // action not blacklisted? 35 | // if matchException is true, action.type is matched with exception 36 | if (!isException) { 37 | // check if the app considers this important 38 | let important = false 39 | if (pluginConfig && typeof pluginConfig.isActionImportant === "function") { 40 | important = !!pluginConfig.isActionImportant(unwrappedAction) 41 | } 42 | 43 | reactotron.reportReduxAction(unwrappedAction, ms, important) 44 | } 45 | 46 | return result 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/enhancer.test.ts: -------------------------------------------------------------------------------- 1 | import createEnhancer from "./enhancer" 2 | 3 | // TODO: More testing 4 | 5 | describe("enhancer", () => { 6 | it("should return a function", () => { 7 | const enhancer = createEnhancer(null, {}, () => {}) 8 | 9 | expect(typeof enhancer).toEqual("function") 10 | }) 11 | }) 12 | -------------------------------------------------------------------------------- /src/enhancer.ts: -------------------------------------------------------------------------------- 1 | import { Reactotron } from "reactotron-core-client"; 2 | 3 | import reactotronReducer from "./reducer" 4 | import createCustomDispatch from "./customDispatch" 5 | import { PluginConfig } from "./pluginConfig" 6 | 7 | export default function createEnhancer( 8 | reactotron: Reactotron, 9 | pluginConfig: PluginConfig, 10 | handleStoreCreation: () => void 11 | ) { 12 | return (skipSettingStore = false) => createStore => (reducer, ...args) => { 13 | const originalStore = createStore( 14 | reactotronReducer(reducer, pluginConfig.restoreActionType), 15 | ...args 16 | ) 17 | const store = { 18 | ...originalStore, 19 | dispatch: createCustomDispatch(reactotron, originalStore, pluginConfig), 20 | } 21 | 22 | if (!skipSettingStore) { 23 | reactotron.reduxStore = store 24 | handleStoreCreation() 25 | } 26 | 27 | return store 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/helpers/pathObject.test.ts: -------------------------------------------------------------------------------- 1 | import pathObject from "./pathObject" 2 | 3 | describe("pathObject", () => { 4 | it("should return the entire object if a null is passed", () => { 5 | const obj = { isThis: { here: true } } 6 | const path = null 7 | 8 | const pathedObj = pathObject(path, obj) 9 | 10 | expect(pathedObj).toEqual(obj) 11 | }) 12 | 13 | it("should return the entire object if a empty string is passed", () => { 14 | const obj = { isThis: { here: true } } 15 | const path = "" 16 | 17 | const pathedObj = pathObject(path, obj) 18 | 19 | expect(pathedObj).toEqual(obj) 20 | }) 21 | 22 | it("should return the section of object if a single level path is passed", () => { 23 | const obj = { isThis: { here: true } } 24 | const path = "isThis" 25 | 26 | const pathedObj = pathObject(path, obj) 27 | 28 | expect(pathedObj).toEqual(obj.isThis) 29 | }) 30 | 31 | it("should return the section of object if a two level path is passed", () => { 32 | const obj = { isThis: { here: true } } 33 | const path = "isThis.here" 34 | 35 | const pathedObj = pathObject(path, obj) 36 | 37 | expect(pathedObj).toEqual(true) 38 | }) 39 | 40 | it("should return the section of object if a three level path is passed", () => { 41 | const obj = { isThis: { here: { again: true } } } 42 | const path = "isThis.here.again" 43 | 44 | const pathedObj = pathObject(path, obj) 45 | 46 | expect(pathedObj).toEqual(true) 47 | }) 48 | 49 | it("should return undefined of object if an invalid path is passed on level one", () => { 50 | const obj = { isThis: { here: { again: true } } } 51 | const path = "isThis2.here.again" 52 | 53 | const pathedObj = pathObject(path, obj) 54 | 55 | expect(pathedObj).toEqual(undefined) 56 | }) 57 | 58 | it("should return undefined of object if an invalid path is passed on level two", () => { 59 | const obj = { isThis: { here: { again: true } } } 60 | const path = "isThis.here2.again" 61 | 62 | const pathedObj = pathObject(path, obj) 63 | 64 | expect(pathedObj).toEqual(undefined) 65 | }) 66 | 67 | it("should return undefined of object if an invalid path is passed on level three", () => { 68 | const obj = { isThis: { here: { again: true } } } 69 | const path = "isThis.here.again2" 70 | 71 | const pathedObj = pathObject(path, obj) 72 | 73 | expect(pathedObj).toEqual(undefined) 74 | }) 75 | }) 76 | -------------------------------------------------------------------------------- /src/helpers/pathObject.ts: -------------------------------------------------------------------------------- 1 | export default function pathObject(path: string, obj: any) { 2 | if (!path) return obj 3 | 4 | const splitPaths = path.split(".") 5 | 6 | let pathedObj = obj 7 | 8 | for (let i = 0; i < splitPaths.length; i++) { 9 | const curPath = splitPaths[i] 10 | pathedObj = pathedObj[curPath] 11 | 12 | if (i < splitPaths.length - 1 && typeof pathedObj !== "object") { 13 | pathedObj = undefined 14 | break 15 | } 16 | } 17 | 18 | return pathedObj 19 | } 20 | -------------------------------------------------------------------------------- /src/helpers/stateCleaner.test.ts: -------------------------------------------------------------------------------- 1 | import stateCleaner from "./stateCleaner" 2 | 3 | describe("stateCleaner", () => { 4 | it("should pass the state back if there is nothing special on it", () => { 5 | const state = { thisIsHere: true } 6 | const cleanedState = stateCleaner(state) 7 | 8 | expect(cleanedState).toEqual(cleanedState) 9 | }) 10 | 11 | it("should call 'toJS' if it exists on the object to handle immutable", () => { 12 | const actualState = { thisIsHere: true } 13 | const state = { toJS: () => actualState } 14 | const cleanedState = stateCleaner(state) 15 | 16 | expect(cleanedState).toEqual(actualState) 17 | }) 18 | }) 19 | -------------------------------------------------------------------------------- /src/helpers/stateCleaner.ts: -------------------------------------------------------------------------------- 1 | export default (state: any) => { 2 | // If we have a toJS, lets assume we need to call it to get a plan 'ol JS object 3 | // NOTE: This handles ImmutableJS 4 | if (state.toJS) { 5 | return state.toJS() 6 | } 7 | 8 | return state 9 | } 10 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { StoreEnhancer } from "redux" 2 | import { Reactotron } from "reactotron-core-client" 3 | 4 | import createCommandHander from "./commandHandler" 5 | import createSendAction from "./sendAction" 6 | import createEnhancer from "./enhancer" 7 | import { DEFAULT_REPLACER_TYPE } from "./reducer" 8 | import { PluginConfig } from "./pluginConfig" 9 | 10 | function reactotronRedux(pluginConfig: PluginConfig = {}) { 11 | const mergedPluginConfig: PluginConfig = { 12 | ...pluginConfig, 13 | restoreActionType: pluginConfig.restoreActionType || DEFAULT_REPLACER_TYPE, 14 | } 15 | 16 | const storeCreationHandlers = [] 17 | const onReduxStoreCreation = (func: () => void) => { 18 | storeCreationHandlers.push(func) 19 | } 20 | const handleStoreCreation = () => { 21 | storeCreationHandlers.forEach(func => { 22 | func() 23 | }) 24 | } 25 | 26 | return (reactotron: Reactotron) => { 27 | return { 28 | onCommand: createCommandHander(reactotron, mergedPluginConfig, onReduxStoreCreation), 29 | features: { 30 | createEnhancer: createEnhancer(reactotron, mergedPluginConfig, handleStoreCreation), 31 | setReduxStore: store => { 32 | reactotron.reduxStore = store 33 | handleStoreCreation() 34 | }, 35 | reportReduxAction: createSendAction(reactotron), 36 | }, 37 | } 38 | } 39 | } 40 | 41 | export { reactotronRedux } 42 | 43 | declare module "reactotron-core-client" { 44 | // eslint-disable-next-line import/export 45 | export interface Reactotron { 46 | reduxStore?: any 47 | 48 | /** 49 | * Enhancer creator 50 | */ 51 | createEnhancer?: (skipSettingStore?: boolean) => StoreEnhancer 52 | 53 | /** 54 | * Store setter 55 | */ 56 | setReduxStore?: (store: any) => void 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/pluginConfig.ts: -------------------------------------------------------------------------------- 1 | export interface PluginConfig { 2 | restoreActionType?: string 3 | onBackup?: (state: any) => any 4 | onRestore?: (restoringState: any, reduxState: any) => any 5 | except?: (string | Function | RegExp)[] 6 | isActionImportant?: (action: any) => boolean 7 | } 8 | -------------------------------------------------------------------------------- /src/reducer.test.ts: -------------------------------------------------------------------------------- 1 | import reducer, { DEFAULT_REPLACER_TYPE } from "./reducer" 2 | 3 | describe("reducer", () => { 4 | it("should do nothing if it isn't the special type", () => { 5 | const rootReducer = jest.fn() 6 | 7 | const reactotronReducer = reducer(rootReducer) 8 | 9 | reactotronReducer({ myState: true }, { type: "Not The Special Type" }) 10 | 11 | expect(rootReducer).toHaveBeenCalledWith({ myState: true }, { type: "Not The Special Type" }) 12 | }) 13 | 14 | it("should return the root reducers result if the special action isn't passed", () => { 15 | function rootReducer(state: any, action: any) { 16 | return { 17 | ...state, 18 | lastAction: action, 19 | } 20 | } 21 | 22 | const reactotronReducer = reducer(rootReducer) 23 | 24 | const result = reactotronReducer({ myState: true }, { type: "Not The Special Type" }) 25 | 26 | expect(result).toEqual({ 27 | myState: true, 28 | lastAction: { 29 | type: "Not The Special Type", 30 | }, 31 | }) 32 | }) 33 | 34 | it("should still call the root reducer if the special action is called", () => { 35 | const rootReducer = jest.fn() 36 | 37 | const reactotronReducer = reducer(rootReducer) 38 | 39 | reactotronReducer( 40 | { myState: true }, 41 | { type: DEFAULT_REPLACER_TYPE, state: { myState: true } } 42 | ) 43 | 44 | expect(rootReducer).toHaveBeenCalledWith( 45 | { myState: true }, 46 | { type: DEFAULT_REPLACER_TYPE, state: { myState: true } } 47 | ) 48 | }) 49 | 50 | it("should replace the state if the special action is called", () => { 51 | const rootReducer = jest.fn() 52 | 53 | const reactotronReducer = reducer(rootReducer) 54 | 55 | reactotronReducer( 56 | { myState: true }, 57 | { type: DEFAULT_REPLACER_TYPE, state: { myNewState: true } } 58 | ) 59 | 60 | expect(rootReducer).toHaveBeenCalledWith( 61 | { myNewState: true }, 62 | { type: DEFAULT_REPLACER_TYPE, state: { myNewState: true } } 63 | ) 64 | }) 65 | }) 66 | -------------------------------------------------------------------------------- /src/reducer.ts: -------------------------------------------------------------------------------- 1 | export const DEFAULT_REPLACER_TYPE = "REACTOTRON_RESTORE_STATE" 2 | 3 | export default function reactotronReducer( 4 | rootReducer: Function, 5 | actionName = DEFAULT_REPLACER_TYPE 6 | ) { 7 | // return this reducer 8 | return (state: any, action: { type: string; state?: any }) => { 9 | // is this action the one we're waiting for? if so, use the state it passed 10 | const whichState = action.type === actionName ? action.state : state 11 | return rootReducer(whichState, action) 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/sendAction.test.ts: -------------------------------------------------------------------------------- 1 | import createSendAction from "./sendAction" 2 | import { defaultReactotronMock } from './testHelpers' 3 | 4 | describe("sendAction", () => { 5 | it("should send a basic action to reactotron", () => { 6 | const mockReactotron = { 7 | ...defaultReactotronMock, 8 | send: jest.fn(), 9 | } 10 | const sendAction = createSendAction(mockReactotron) 11 | 12 | sendAction({ type: "My Type" }, 10, false) 13 | 14 | expect(mockReactotron.send).toHaveBeenCalledWith( 15 | "state.action.complete", 16 | { name: "My Type", action: { type: "My Type" }, ms: 10 }, 17 | false 18 | ) 19 | }) 20 | 21 | it("should send a important action to reactotron", () => { 22 | const mockReactotron = { 23 | ...defaultReactotronMock, 24 | send: jest.fn(), 25 | } 26 | const sendAction = createSendAction(mockReactotron) 27 | 28 | sendAction({ type: "My Type" }, 10, true) 29 | 30 | expect(mockReactotron.send).toHaveBeenCalledWith( 31 | "state.action.complete", 32 | { name: "My Type", action: { type: "My Type" }, ms: 10 }, 33 | true 34 | ) 35 | }) 36 | 37 | it.todo("should handle the type of an action being a symbol") 38 | }) 39 | -------------------------------------------------------------------------------- /src/sendAction.ts: -------------------------------------------------------------------------------- 1 | import { Reactotron } from "reactotron-core-client"; 2 | 3 | export default function createSendAction(reactotron: Reactotron) { 4 | return (action: { type: any }, ms: number, important = false) => { 5 | // let's call the type, name because that's "generic" name in Reactotron 6 | let { type: name } = action 7 | 8 | // convert from symbol to type if necessary 9 | if (typeof name === "symbol") { 10 | name = name 11 | .toString() 12 | .replace(/^Symbol\(/, "") 13 | .replace(/\)$/, "") 14 | } 15 | 16 | // off ya go! 17 | reactotron.send("state.action.complete", { name, action, ms }, important) 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/subscriptionsHandler.test.ts: -------------------------------------------------------------------------------- 1 | import createSubscriptionHandler from "./subscriptionsHandler" 2 | 3 | describe("createSubscriptionHandler", () => { 4 | describe("sendSubscriptions", () => { 5 | it("should return an empty array if there are no subscriptions", () => { 6 | const mockReactotron = { 7 | reduxStore: { 8 | getState: jest.fn(), 9 | subscribe: jest.fn(), 10 | }, 11 | stateValuesChange: jest.fn(), 12 | } 13 | 14 | const handler = createSubscriptionHandler(mockReactotron, () => {}) 15 | 16 | handler.setSubscriptions([]) 17 | handler.sendSubscriptions() 18 | 19 | expect(mockReactotron.stateValuesChange).toHaveBeenCalledWith([]) 20 | }) 21 | 22 | it("should return an array with a single item with all the store if an empty subscription is passed", () => { 23 | const mockState = { red1: { test: true }, red2: { test: false } } 24 | const mockReactotron = { 25 | reduxStore: { 26 | getState: jest.fn().mockReturnValue(mockState), 27 | subscribe: jest.fn(), 28 | }, 29 | stateValuesChange: jest.fn(), 30 | } 31 | 32 | const handler = createSubscriptionHandler(mockReactotron, () => {}) 33 | 34 | handler.setSubscriptions([""]) 35 | handler.sendSubscriptions() 36 | 37 | expect(mockReactotron.stateValuesChange).toHaveBeenCalledWith([ 38 | { path: "", value: mockState }, 39 | ]) 40 | }) 41 | 42 | it("should return an array with a single item with all the store if an null subscription is passed", () => { 43 | const mockState = { red1: { test: true }, red2: { test: false } } 44 | const mockReactotron = { 45 | reduxStore: { 46 | getState: jest.fn().mockReturnValue(mockState), 47 | subscribe: jest.fn(), 48 | }, 49 | stateValuesChange: jest.fn(), 50 | } 51 | 52 | const handler = createSubscriptionHandler(mockReactotron, () => {}) 53 | 54 | handler.setSubscriptions([null]) 55 | handler.sendSubscriptions() 56 | 57 | expect(mockReactotron.stateValuesChange).toHaveBeenCalledWith([ 58 | { path: null, value: mockState }, 59 | ]) 60 | }) 61 | 62 | it("should return an array with a single item with all the store if an * subscription is passed", () => { 63 | const mockState = { red1: { test: true }, red2: { test: false } } 64 | const mockReactotron = { 65 | reduxStore: { 66 | getState: jest.fn().mockReturnValue(mockState), 67 | subscribe: jest.fn(), 68 | }, 69 | stateValuesChange: jest.fn(), 70 | } 71 | 72 | const handler = createSubscriptionHandler(mockReactotron, () => {}) 73 | 74 | handler.setSubscriptions(["*"]) 75 | handler.sendSubscriptions() 76 | 77 | expect(mockReactotron.stateValuesChange).toHaveBeenCalledWith([ 78 | { path: "", value: mockState }, 79 | ]) 80 | }) 81 | 82 | it("should return an array with a all items in sub items of a * path at the first level", () => { 83 | const mockState = { red1: { test: true, obj: { nested: true } }, red2: { test: false } } 84 | const mockReactotron = { 85 | reduxStore: { 86 | getState: jest.fn().mockReturnValue(mockState), 87 | subscribe: jest.fn(), 88 | }, 89 | stateValuesChange: jest.fn(), 90 | } 91 | 92 | const handler = createSubscriptionHandler(mockReactotron, () => {}) 93 | 94 | handler.setSubscriptions(["red1.*"]) 95 | handler.sendSubscriptions() 96 | 97 | expect(mockReactotron.stateValuesChange).toHaveBeenCalledWith([ 98 | { path: "red1.test", value: true }, 99 | { path: "red1.obj", value: { nested: true } }, 100 | ]) 101 | }) 102 | 103 | it("should return an array with a all items in sub items of a * path at the second level", () => { 104 | const mockState = { red1: { test: true, obj: { nested: true, anotherItem: 10 } }, red2: { test: false } } 105 | const mockReactotron = { 106 | reduxStore: { 107 | getState: jest.fn().mockReturnValue(mockState), 108 | subscribe: jest.fn(), 109 | }, 110 | stateValuesChange: jest.fn(), 111 | } 112 | 113 | const handler = createSubscriptionHandler(mockReactotron, () => {}) 114 | 115 | handler.setSubscriptions(["red1.obj.*"]) 116 | handler.sendSubscriptions() 117 | 118 | expect(mockReactotron.stateValuesChange).toHaveBeenCalledWith([ 119 | { path: "red1.obj.nested", value: true }, 120 | { path: "red1.obj.anotherItem", value: 10 }, 121 | ]) 122 | }) 123 | 124 | it("should handle multipple subscriptions", () => { 125 | const mockState = { red1: { test: true, obj: { nested: true, anotherItem: 10 } }, red2: { test: false } } 126 | const mockReactotron = { 127 | reduxStore: { 128 | getState: jest.fn().mockReturnValue(mockState), 129 | subscribe: jest.fn(), 130 | }, 131 | stateValuesChange: jest.fn(), 132 | } 133 | 134 | const handler = createSubscriptionHandler(mockReactotron, () => {}) 135 | 136 | handler.setSubscriptions(["red1.obj.nested", "red1.test"]) 137 | handler.sendSubscriptions() 138 | 139 | expect(mockReactotron.stateValuesChange).toHaveBeenCalledWith([ 140 | { path: "red1.obj.nested", value: true }, 141 | { path: "red1.test", value: true }, 142 | ]) 143 | }) 144 | 145 | it("should handle subscription changes", () => { 146 | const mockState = { red1: { test: true, obj: { nested: true, anotherItem: 10 } }, red2: { test: false } } 147 | const mockReactotron = { 148 | reduxStore: { 149 | getState: jest.fn().mockReturnValue(mockState), 150 | subscribe: jest.fn(), 151 | }, 152 | stateValuesChange: jest.fn(), 153 | } 154 | 155 | const handler = createSubscriptionHandler(mockReactotron, () => {}) 156 | 157 | handler.setSubscriptions(["red1.obj.nested"]) 158 | handler.sendSubscriptions() 159 | 160 | expect(mockReactotron.stateValuesChange).toHaveBeenCalledWith([ 161 | { path: "red1.obj.nested", value: true }, 162 | ]) 163 | 164 | handler.setSubscriptions(["red1.test"]) 165 | handler.sendSubscriptions() 166 | 167 | expect(mockReactotron.stateValuesChange).toHaveBeenCalledWith([ 168 | { path: "red1.test", value: true }, 169 | ]) 170 | }) 171 | }) 172 | 173 | describe("sendSubscriptionsIfNeeded", () => { 174 | it('should not send subscriptions when there are none', () => { 175 | const mockState = { red1: { test: true, obj: { nested: true, anotherItem: 10 } }, red2: { test: false } } 176 | const mockReactotron = { 177 | reduxStore: { 178 | getState: jest.fn().mockReturnValue(mockState), 179 | subscribe: jest.fn(), 180 | }, 181 | stateValuesChange: jest.fn(), 182 | } 183 | 184 | const handler = createSubscriptionHandler(mockReactotron, () => {}) 185 | 186 | handler.setSubscriptions([]) 187 | handler.sendSubscriptionsIfNeeded() 188 | 189 | expect(mockReactotron.stateValuesChange).not.toHaveBeenCalled() 190 | }) 191 | 192 | it('should send subscriptions when there are at least one', () => { 193 | const mockState = { red1: { test: true, obj: { nested: true, anotherItem: 10 } }, red2: { test: false } } 194 | const mockReactotron = { 195 | reduxStore: { 196 | getState: jest.fn().mockReturnValue(mockState), 197 | subscribe: jest.fn(), 198 | }, 199 | stateValuesChange: jest.fn(), 200 | } 201 | 202 | const handler = createSubscriptionHandler(mockReactotron, () => {}) 203 | 204 | handler.setSubscriptions([""]) 205 | handler.sendSubscriptionsIfNeeded() 206 | 207 | expect(mockReactotron.stateValuesChange).toHaveBeenCalled() 208 | }) 209 | }) 210 | }) 211 | -------------------------------------------------------------------------------- /src/subscriptionsHandler.ts: -------------------------------------------------------------------------------- 1 | import pathObject from "./helpers/pathObject" 2 | 3 | export default function createSubscriptionHandler( 4 | reactotron: any, 5 | onReduxStoreCreation: (func: () => void) => void 6 | ) { 7 | let subscriptions: string[] = [] 8 | 9 | function setSubscriptions(subs: string[]) { 10 | subscriptions = subs 11 | } 12 | 13 | function getChanges() { 14 | // If we don't have reactotron, dont have a store or getState isn't a function then get out. Now. 15 | if ( 16 | !reactotron || 17 | !reactotron.reduxStore || 18 | typeof reactotron.reduxStore.getState !== "function" 19 | ) { 20 | return [] 21 | } 22 | 23 | const state = reactotron.reduxStore.getState() 24 | 25 | const changes = [] 26 | 27 | subscriptions.forEach(path => { 28 | let cleanedPath = path 29 | let starredPath = false 30 | 31 | if (path && path.endsWith("*")) { 32 | // Handle the star! 33 | starredPath = true 34 | cleanedPath = path.substr(0, path.length - 2) 35 | } 36 | 37 | const values = pathObject(cleanedPath, state) 38 | 39 | if (starredPath && cleanedPath && values) { 40 | changes.push( 41 | ...Object.entries(values).map(val => ({ 42 | path: `${cleanedPath}.${val[0]}`, 43 | value: val[1], 44 | })) 45 | ) 46 | } else { 47 | changes.push({ path: cleanedPath, value: values }) 48 | } 49 | }) 50 | 51 | return changes 52 | } 53 | 54 | function sendSubscriptions() { 55 | const changes = getChanges() 56 | reactotron.stateValuesChange(changes) 57 | } 58 | 59 | function sendSubscriptionsIfNeeded() { 60 | const changes = getChanges() 61 | 62 | if (changes.length > 0) { 63 | reactotron.stateValuesChange(changes) 64 | } 65 | } 66 | 67 | onReduxStoreCreation(() => { 68 | reactotron.reduxStore.subscribe(sendSubscriptionsIfNeeded) 69 | }) 70 | 71 | return { 72 | sendSubscriptions, 73 | sendSubscriptionsIfNeeded, 74 | setSubscriptions, 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /src/testHelpers.ts: -------------------------------------------------------------------------------- 1 | export const defaultReactotronMock = { 2 | startTimer: jest.fn(), 3 | configure: jest.fn(), 4 | close: jest.fn(), 5 | connect: jest.fn(), 6 | send: jest.fn(), 7 | display: jest.fn(), 8 | reportError: jest.fn(), 9 | use: jest.fn(), 10 | onCustomCommand: jest.fn(), 11 | } 12 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowJs": false, 4 | "declaration": true, 5 | "declarationDir": "dist/types", 6 | "emitDeclarationOnly": true, 7 | "emitDecoratorMetadata": true, 8 | "allowSyntheticDefaultImports": true, 9 | "experimentalDecorators": true, 10 | "module": "es2015", 11 | "moduleResolution": "node", 12 | "noImplicitAny": false, 13 | "noImplicitReturns": true, 14 | "noImplicitThis": true, 15 | "noUnusedLocals": true, 16 | "sourceMap": true, 17 | "target": "es2015" 18 | }, 19 | "exclude": ["node_modules"], 20 | "include": ["src"] 21 | } 22 | -------------------------------------------------------------------------------- /wallaby.js: -------------------------------------------------------------------------------- 1 | module.exports = function(wallaby) { 2 | return { 3 | files: ["src/**/*.ts", "!src/**/*.test.ts"], 4 | 5 | tests: ["src/**/*.test.ts"], 6 | 7 | compilers: { 8 | "**/*.ts": wallaby.compilers.babel(), 9 | }, 10 | 11 | env: { 12 | type: "node", 13 | runner: "node", 14 | }, 15 | 16 | testFramework: "jest", 17 | } 18 | } 19 | --------------------------------------------------------------------------------