├── .babelrc ├── .eslintrc ├── .gitignore ├── .travis.yml ├── CHANGELOG.md ├── CREDITS.md ├── LICENSE ├── README.md ├── examples ├── demo-app.js ├── demo.html ├── extension.png ├── graph.png ├── simple-graph.js ├── your-app.html └── your-app.js ├── extension ├── .babelrc ├── .eslintrc ├── .gitignore ├── .travis.yml ├── LICENSE ├── README.md ├── Screen Shot 2017-11-15 at 3.34.11 AM.png ├── TODO.md ├── app │ ├── actions │ │ └── graph.js │ ├── components │ │ ├── Dock.js │ │ ├── Footer.css │ │ ├── Footer.js │ │ ├── Header.js │ │ ├── MainSection.css │ │ ├── MainSection.js │ │ ├── SelectorGraph.js │ │ ├── SelectorInspector.js │ │ ├── SelectorSearch.css │ │ ├── SelectorSearch.js │ │ ├── SelectorState.css │ │ ├── SelectorState.js │ │ ├── StateTree.js │ │ ├── TodoItem.css │ │ ├── TodoItem.js │ │ ├── TodoTextInput.css │ │ └── TodoTextInput.js │ ├── constants │ │ ├── ActionTypes.js │ │ └── TodoFilters.js │ ├── containers │ │ ├── App.js │ │ └── Root.js │ ├── reducers │ │ ├── graph.js │ │ └── index.js │ ├── store │ │ ├── configureStore.dev.js │ │ ├── configureStore.js │ │ └── configureStore.prod.js │ └── utils │ │ ├── apiMiddleware.js │ │ ├── rpc.js │ │ └── storage.js ├── appveyor.yml ├── chrome │ ├── assets │ │ └── img │ │ │ ├── icon-128-disabled.png │ │ │ ├── icon-128.png │ │ │ ├── icon-16-disabled.png │ │ │ ├── icon-16.png │ │ │ ├── icon-32.png │ │ │ ├── icon-48-disabled.png │ │ │ └── icon-48.png │ ├── extension │ │ ├── background.js │ │ ├── devtools.js │ │ ├── page-api.js │ │ ├── panel.js │ │ ├── reselect-tools-app.css │ │ └── reselect-tools-app.js │ ├── manifest.dev.json │ ├── manifest.prod.json │ └── views │ │ ├── background.pug │ │ ├── devtools.pug │ │ └── panel.pug ├── index.html ├── package.json ├── scripts │ ├── .eslintrc │ ├── build.js │ ├── compress.js │ ├── dev.js │ └── tasks.js ├── test │ ├── .eslintrc │ ├── app │ │ ├── actions │ │ │ └── todos.spec.js │ │ ├── components │ │ │ ├── Footer.spec.js │ │ │ ├── Header.spec.js │ │ │ ├── MainSection.spec.js │ │ │ ├── TodoItem.spec.js │ │ │ └── TodoTextInput.spec.js │ │ └── reducers │ │ │ └── todos.spec.js │ ├── e2e │ │ ├── injectpage.js │ │ └── todoapp.js │ ├── func.js │ ├── mocha.opts │ └── setup-app.js ├── webpack │ ├── .eslintrc │ ├── customPublicPath.js │ ├── dev.config.js │ ├── postcss.config.js │ ├── prod.config.js │ ├── replace │ │ ├── JsonpMainTemplate.runtime.js │ │ └── process-update.js │ └── test.config.js └── yarn.lock ├── lib └── index.js ├── package.json ├── src └── index.js └── test └── test.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["env"], 3 | "plugins": [ 4 | ], 5 | "env": { 6 | "commonjs": { 7 | "plugins": [ 8 | ["transform-es2015-modules-commonjs", { "loose": true }] 9 | ] 10 | }, 11 | "umd": { 12 | "plugins": [ 13 | ["transform-es2015-modules-umd", 14 | { 15 | "loose": true, 16 | "globals": { 17 | 'reselect': 'Reselect' 18 | } 19 | }] 20 | ], 21 | "moduleId": "ReselectTools" 22 | }, 23 | "test": { 24 | "plugins": [ 25 | ["transform-es2015-modules-commonjs", { "loose": true }], 26 | ] 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "node": true, 5 | "mocha": true, 6 | "es6": true, 7 | }, 8 | "extends": "eslint:recommended", 9 | "parserOptions": { 10 | "sourceType": "module" 11 | }, 12 | "rules": { 13 | "array-bracket-spacing": [2, "always"], 14 | "eol-last": 2, 15 | "indent": [2, 2, { 16 | "SwitchCase": 1 17 | }], 18 | "no-multiple-empty-lines": 2, 19 | "object-curly-spacing": [2, "always"], 20 | "quotes": [2, "single", {"avoidEscape": true, "allowTemplateLiterals": true}], 21 | "semi": [2, "never"], 22 | "strict": 0, 23 | "space-before-blocks": [2, "always"], 24 | "space-before-function-paren": [2, {"anonymous":"always","named":"never"}] 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .nyc_output 3 | coverage 4 | dist 5 | es 6 | .vscode 7 | .idea 8 | typescript_test/should_compile/index.js 9 | typescript_test/should_not_compile/index.js 10 | typescript_test/common.js 11 | flow_test/should_fail/flow-typed/index.js.flow 12 | flow_test/should_pass/flow-typed/index.js.flow 13 | .DS_STORE 14 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "4" 4 | - "5" 5 | - "6" 6 | - "7" 7 | script: 8 | - npm run lint 9 | - npm test 10 | - npm run test:cov 11 | - npm run compile 12 | 13 | after_success: 14 | - cat ./coverage/lcov.info | ./node_modules/codecov.io/bin/codecov.io.js 15 | - cat ./coverage/lcov.info | ./node_modules/coveralls/bin/coveralls.js 16 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change log 2 | 3 | All notable changes to this project will be documented in this file. 4 | This project adheres to [Semantic Versioning](http://semver.org/). 5 | 6 | ## [v0.0.7] - 2018/11/04 7 | 8 | ### New Features 9 | 10 | Removed need for createSelectorWithDependencies! (#6) 11 | Exception handling in the selectorGraph() (#10) 12 | -------------------------------------------------------------------------------- /CREDITS.md: -------------------------------------------------------------------------------- 1 | # CREDITS 2 | 3 | * Thanks to all commenters on the proposal [here](https://github.com/reactjs/reselect/issues/279) 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015-2016 Reselect Contributors 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 | # Reselect Tools 2 | [![Travis][build-badge]][build] 3 | [![npm package][npm-badge]][npm] 4 | [![Coveralls][coveralls-badge]][coveralls] 5 | 6 | Tools for working with the [reselect](https://github.com/reactjs/reselect) library: 7 | *Check selector dependencies, inputs, outputs, and recomputations at any time without refreshing!* 8 | 9 | ![Graph](examples/extension.png) 10 | 11 | 12 | 13 | ```js 14 | export const data$ = (state) => state.data; 15 | export const ui$ = (state) => state.ui; 16 | export const users$ = createSelector(data$, (data) => data.users); 17 | export const currentUser$ = createSelector(ui$, users$, (ui, users) => users[ui.currentUser]); 18 | ... 19 | 20 | // in configureStore.js 21 | import * as selectors from './selectors.js' 22 | import * as ReselectTools from 'reselect-tools' 23 | 24 | ReselectTools.getStateWith(() => store.getState()) // allows you to get selector inputs and outputs 25 | ReselectTools.registerSelectors(selectors) // register string names for selectors 26 | ... 27 | ReselectTools.checkSelector('currentUser$') 28 | => { 29 | inputs: [{currentUser: 1}, users: {1: {name: 'sam'}}] 30 | outputs: {name: 'sam'}, 31 | dependencies: [ui$, users$], 32 | recomputations: 1, 33 | isNamed: false, 34 | selectorName: 'currentUser$' 35 | } 36 | selectorGraph() 37 | => { 38 | nodes: { 39 | "data$": { 40 | name: "data$", 41 | recomputations: "N/A" 42 | }, 43 | "ui$": { 44 | name: "ui$", 45 | recomputations: "N/A" 46 | }, 47 | "users$": { 48 | name: "user$", 49 | recomputations: 1 50 | }, 51 | "currentUser$": { 52 | name: "currentUser$", 53 | recomputations: 1 54 | }, 55 | }, 56 | edges: [ 57 | { from: users$, to: data$ }, 58 | { from: users$, to: data$ }, 59 | { from: currentUser$, to: users$ }, 60 | { from: currentUser$, to: ui$ }, 61 | ] 62 | } 63 | ``` 64 | 65 | ## Table of Contents 66 | 67 | - [Motivation](#motivation) 68 | - [Getting Started](#getting-started) 69 | - [Example](#example) 70 | - [API](#api) 71 | - [`getStateWith`](#getstatewithfunc) 72 | - [`checkSelector`](#checkselectorselector) 73 | - [`selectorGraph`](#selectorgraphselectorkey--defaultselectorkey) 74 | - [`registerSelectors`](#registerselectorskeyselectorobj) 75 | - [License](#license) 76 | 77 | ## Motivation 78 | 79 | It's handy to visualize the application state tree with the [Redux Devtools](https://github.com/zalmoxisus/redux-devtools-extension). But I was using selectors a lot, and there was no easy way to visualize the *computed state tree*. So, I created this library to output graphs like this one: 80 | 81 | ![Graph](examples/graph.png) 82 | 83 | This library was intended to be used with the [chrome extension](./extension). However, it can be still be [useful without the chrome extension installed](#without-the-extension). The chrome extension will be useless without this library. 84 | 85 | See the original reselect issue [here](https://github.com/reactjs/reselect/issues/279). 86 | 87 | ## Getting Started 88 | 89 | Firstly, I apologize in advance that this section is required. It would be great to match the experience of installing redux devtools or react's. Hopefully the tools will be more tightly integrated with reselect at some point and these steps won't be necessary. 90 | 91 | 1. Install the Package 92 | 93 | npm install -s reselect-tools 94 | 95 | 2. Grab the [Chrome Extension](https://chrome.google.com/webstore/detail/reselect-devtools/cjmaipngmabglflfeepmdiffcijhjlbb) 96 | 97 | 3. Building the Graph: 98 | 99 | In order to start building out the selector graph, we need to tell the devtools about the selectors. 100 | ``` 101 | import { registerSelectors } from 'reselect-tools' 102 | registerSelectors({ mySelector$ }) 103 | ``` 104 | If you're keeping all your selectors in the same place, this is dead simple: 105 | ``` 106 | import * as selectors from './selectors.js' 107 | registerSelectors(selectors) 108 | ``` 109 | 110 | That's it! At this point you should be able to open the devtools and view the selector graph. 111 | 112 | The tools will automatically discover and name dependencies of the selectors. If you want to override the name of a selector, you can do so: 113 | ``` 114 | const foo$ = createSelector(bar$, (foo) => foo + 1); 115 | foo$.selectorName = 'bar$' // selector will show up as 'bar' 116 | ``` 117 | 118 | 4. Checking Selector Inputs and Outputs 119 | 120 | Imagine that your tests are passing, but you think some selector in your app might be receiving bad input from a depended-upon selector. Where in the chain is the problem? In order to allow ```checkSelector``` and by extension, the extension, to get this information, we need to give Reselect Tools some way of feeding state to a selector. 121 | ``` 122 | import store from './configureStore' 123 | ReselectTools.getStateWith(() => store.getState()) 124 | ``` 125 | 126 | ## Example 127 | 128 | The example is running [here](http://skortchmark.com/reselect-tools/examples/demo.html). Grab the extension and take a look! 129 | 130 | npm run example 131 | 132 | ## API 133 | 134 | ### getStateWith(func) 135 | 136 | `getStateWith` accepts a function which returns the current state. This state is then passed into ```checkSelector```. In most cases, this will be ```store.getState()``` 137 | 138 | ### checkSelector(selector) 139 | 140 | Outputs information about the selector at the given time. 141 | 142 | By default, outputs only the recomputations of the selector. 143 | If you use ```getStateWith```, it will output the selector's input and output values. 144 | If you use ```registerSelectors```, you can pass it the string name of a selector. 145 | 146 | 147 | ```js 148 | const two$ = () => 2; 149 | const four$ = () => 4 150 | const mySelector$ = createSelector(two$, four$, (two, four) => two + four) 151 | registerSelectors({ mySelector$ }) 152 | getStateWith(() => null) 153 | 154 | checkSelector('mySelector$') // { 155 | inputs: [2, 4], 156 | output: 6, 157 | dependencies: [two$, four$], 158 | recomputations: 1, 159 | } 160 | ``` 161 | 162 | 163 | ### selectorGraph(selectorKey = defaultSelectorKey) 164 | 165 | ```selectorGraph``` outputs a POJO with nodes and edges. A node is a selector in the tree, and an edge goes from a selector to the selectors it depends on. 166 | 167 | ```js 168 | selectorGraph() 169 | // { 170 | // nodes: { 171 | // "data$": { 172 | // name: "data$", 173 | // recomputations: "N/A" 174 | // }, 175 | // "ui$": { 176 | // name: "ui$", 177 | // recomputations: "N/A" 178 | // }, 179 | // "users$": { 180 | // name: "user$", 181 | // recomputations: 1 182 | // }, 183 | // "currentUser$": { 184 | // name: "currentUser$", 185 | // recomputations: 1 186 | // }, 187 | // }, 188 | // edges: [ 189 | // { from: users$, to: data$ }, 190 | // { from: users$, to: data$ }, 191 | // { from: currentUser$, to: users$ }, 192 | // { from: currentUser$, to: ui$ }, 193 | // ] 194 | // } 195 | ``` 196 | 197 | #### Using custom selectorKeys 198 | 199 | Nodes in the graph are keyed by string names. The name is determined by the ```selectorKey``` function. This function takes a selector and outputs a string which must be unique and consistent for a given selector. The ```defaultSelectorKey``` looks for a function name, then a match in the registry, and finally resorts to calling `toString` on the selector's ```resultFunc```. 200 | 201 | See the [tests](test/test.js#L246) for an alternate selectorKey. 202 | 203 | 204 | ### registerSelectors(keySelectorObj) 205 | 206 | Add a named selector to the graph. Set selector names as keys and selectors as values. 207 | 208 | 209 | ### Without The Extension 210 | 211 | If you're using an unsupported browser, or aren't happy with the extension, you can still get at the data. 212 | 213 | The dev tools bind to your app via this global: 214 | ``` 215 | window.__RESELECT_TOOLS__ = { 216 | selectorGraph, 217 | checkSelector 218 | } 219 | ``` 220 | Even without the devtools, you can call ```__RESELECT_TOOLS__.checkSelector('mySelector$')``` from the developer console or ```__RESLECT_TOOLS__.selectorGraph()``` to see what's going on. If the JSON output of the graph is hard to parse, there's an example of how to create a visual selector graph [here](tests/your-app.js). 221 | 222 | 223 | ## License 224 | 225 | MIT 226 | 227 | [build-badge]: https://api.travis-ci.org/skortchmark9/reselect-tools.svg?branch=master 228 | [build]: https://travis-ci.org/skortchmark9/reselect-tools 229 | 230 | [npm-badge]: https://img.shields.io/npm/v/reselect-tools.svg?style=flat-square 231 | [npm]: https://www.npmjs.org/package/reselect-tools 232 | 233 | [coveralls-badge]: https://coveralls.io/repos/github/skortchmark9/reselect-tools/badge.svg?branch=master 234 | [coveralls]: https://coveralls.io/github/skortchmark9/reselect-tools?branch=master 235 | -------------------------------------------------------------------------------- /examples/demo-app.js: -------------------------------------------------------------------------------- 1 | var { selectorGraph } = ReselectTools; 2 | var { createSelector } = Reselect; 3 | ReselectTools.getStateWith(() => STORE); 4 | 5 | var STORE = { 6 | data: { 7 | users: { 8 | '1': { 9 | id: '1', 10 | name: 'bob', 11 | pets: ['a', 'b'], 12 | }, 13 | '2': { 14 | id: '2', 15 | name: 'alice', 16 | pets: ['a'], 17 | } 18 | }, 19 | pets: { 20 | 'a': { 21 | name: 'fluffy', 22 | }, 23 | 'b': { 24 | name: 'paws', 25 | } 26 | } 27 | }, 28 | ui: { 29 | currentUser: '1', 30 | } 31 | }; 32 | 33 | const data$ = (state) => state.data; 34 | const ui$ = (state) => state.ui; 35 | var users$ = createSelector(data$, (data) => data.users); 36 | var pets$ = createSelector(data$, ({ pets }) => pets); 37 | var currentUser$ = createSelector(ui$, users$, (ui, users) => users[ui.currentUser]); 38 | 39 | var currentUserPets$ = createSelector(currentUser$, pets$, (currentUser, pets) => currentUser.pets.map((petId) => pets[petId])); 40 | 41 | const random$ = (state) => 1; 42 | const thingy$ = createSelector(random$, (number) => number + 1); 43 | 44 | const selectors = { 45 | data$, 46 | ui$, 47 | users$, 48 | pets$, 49 | currentUser$, 50 | currentUserPets$, 51 | random$, 52 | thingy$, 53 | }; 54 | 55 | ReselectTools.registerSelectors(selectors); 56 | 57 | 58 | drawCytoscapeGraph(selectorGraph()); -------------------------------------------------------------------------------- /examples/demo.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | A Demo App Example 4 | 10 | 11 | 12 |
13 |
14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /examples/extension.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skortchmark9/reselect-tools/1175df8465356b94f39af2197019c44355c957f4/examples/extension.png -------------------------------------------------------------------------------- /examples/graph.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skortchmark9/reselect-tools/1175df8465356b94f39af2197019c44355c957f4/examples/graph.png -------------------------------------------------------------------------------- /examples/simple-graph.js: -------------------------------------------------------------------------------- 1 | var { checkSelector } = ReselectTools; 2 | 3 | 4 | const cytoDefaults = { 5 | style: [ // the stylesheet for the graph 6 | { 7 | selector: 'node', 8 | style: { 9 | 'background-color': '#666', 10 | 'label': 'data(id)' 11 | } 12 | }, 13 | 14 | { 15 | selector: 'edge', 16 | style: { 17 | 'width': 3, 18 | 'line-color': '#ccc', 19 | 'target-arrow-color': '#ccc', 20 | 'target-arrow-shape': 'triangle' 21 | } 22 | } 23 | ], 24 | 25 | layout: { 26 | name: 'dagre', 27 | rankDir: 'BT', 28 | ranker: 'longest-path', 29 | } 30 | }; 31 | 32 | 33 | function drawCytoscapeGraph(graph) { 34 | const { nodes, edges } = graph; 35 | 36 | const cytoNodes = Object.keys(nodes).map((name) => ({ 37 | data: Object.assign({}, nodes[name], { 38 | id: name 39 | }) 40 | })); 41 | 42 | const findSelectorId = (selector) => { 43 | const node = cytoNodes.find(({ data }) => data.name === selector); 44 | return node.data.id; 45 | }; 46 | 47 | const cytoEdges = edges.map((edge, i) => ({data: { 48 | source: findSelectorId(edge.from), 49 | target: findSelectorId(edge.to), 50 | id: i, 51 | }})); 52 | 53 | const elements = cytoNodes.concat(cytoEdges); 54 | 55 | const cy = cytoscape(Object.assign({}, cytoDefaults, { 56 | container: document.getElementById('root'), // container to render in 57 | elements, 58 | })); 59 | 60 | cy.nodes().on("click", function(x, ...args) { 61 | const data = this.data(); 62 | console.log(data.name, checkSelector(data.name)); 63 | }); 64 | 65 | } 66 | -------------------------------------------------------------------------------- /examples/your-app.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | Example Graph: (just add json) 4 | 10 | 11 | 12 |
13 |
14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /examples/your-app.js: -------------------------------------------------------------------------------- 1 | // Comes from JSON.stringify(selectorGraph()); 2 | const graph = { 3 | "edges": [ 4 | { 5 | "from": "users$", 6 | "to": "data$" 7 | }, 8 | { 9 | "from": "pets$", 10 | "to": "data$" 11 | }, 12 | { 13 | "from": "currentUser$", 14 | "to": "ui$" 15 | }, 16 | { 17 | "from": "currentUser$", 18 | "to": "users$" 19 | }, 20 | { 21 | "from": "currentUserPets$", 22 | "to": "currentUser$" 23 | }, 24 | { 25 | "from": "currentUserPets$", 26 | "to": "pets$" 27 | }, 28 | { 29 | "from": "thingy$", 30 | "to": "random$" 31 | } 32 | ], 33 | "nodes": { 34 | "currentUser$": { 35 | "name": "currentUser$", 36 | "recomputations": 0 37 | }, 38 | "currentUserPets$": { 39 | "name": "currentUserPets$", 40 | "recomputations": 0 41 | }, 42 | "data$": { 43 | "name": "data$", 44 | "recomputations": null, 45 | }, 46 | "pets$": { 47 | "name": "pets$", 48 | "recomputations": 0 49 | }, 50 | "random$": { 51 | "name": "random$", 52 | "recomputations": null, 53 | }, 54 | "thingy$": { 55 | "name": "thingy$", 56 | "recomputations": 0 57 | }, 58 | "ui$": { 59 | "name": "ui$", 60 | "recomputations": null, 61 | }, 62 | "users$": { 63 | "name": "users$", 64 | "recomputations": 0 65 | } 66 | } 67 | } 68 | 69 | // This overrides the graph the devtools get - don't try this at home! 70 | window.__RESELECT_TOOLS__.selectorGraph = () => graph; 71 | 72 | 73 | drawCytoscapeGraph(graph); -------------------------------------------------------------------------------- /extension/.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["es2015", "stage-0", "react"], 3 | "plugins": ["add-module-exports", "transform-decorators-legacy", "transform-runtime"] 4 | } 5 | -------------------------------------------------------------------------------- /extension/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "babel-eslint", 3 | "extends": "airbnb", 4 | "globals": { 5 | "chrome": true 6 | }, 7 | "env": { 8 | "browser": true, 9 | "node": true 10 | }, 11 | "rules": { 12 | "react/forbid-prop-types": 0, 13 | "react/prefer-stateless-function": 0, 14 | "react/jsx-filename-extension": [1, { "extensions": [".js", ".jsx"] }], 15 | "jsx-a11y/no-static-element-interactions": 0, 16 | "jsx-a11y/label-has-for": 0, 17 | "consistent-return": 0, 18 | "comma-dangle": 0, 19 | "spaced-comment": 0, 20 | "global-require": 0 21 | }, 22 | "plugins": [ 23 | "react" 24 | ] 25 | } 26 | -------------------------------------------------------------------------------- /extension/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | npm-debug.log 3 | .DS_Store 4 | 5 | build/ 6 | dev/ 7 | 8 | *.zip 9 | *.crx 10 | *.pem 11 | update.xml 12 | -------------------------------------------------------------------------------- /extension/.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: required 2 | dist: trusty 3 | language: node_js 4 | node_js: 5 | - "6" 6 | - "7" 7 | cache: 8 | directories: 9 | - $HOME/.yarn-cache 10 | - node_modules 11 | env: 12 | - CXX=g++-4.8 13 | addons: 14 | apt: 15 | sources: 16 | - google-chrome 17 | - ubuntu-toolchain-r-test 18 | packages: 19 | - google-chrome-stable 20 | - g++-4.8 21 | 22 | install: 23 | - "/sbin/start-stop-daemon --start --quiet --pidfile /tmp/custom_xvfb_99.pid --make-pidfile --background --exec /usr/bin/Xvfb -- :99 -ac -screen 0 1280x1024x16" 24 | - npm install -g yarn 25 | - yarn install 26 | 27 | before_script: 28 | - export DISPLAY=:99.0 29 | - sh -e /etc/init.d/xvfb start & 30 | - sleep 3 31 | 32 | script: 33 | - yarn run lint 34 | - yarn test 35 | - yarn run build 36 | - yarn run test-e2e 37 | -------------------------------------------------------------------------------- /extension/LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 Jhen-Jie Hong 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 | -------------------------------------------------------------------------------- /extension/README.md: -------------------------------------------------------------------------------- 1 | # Reselect Devtools Extension 2 | 3 | > Chrome Extension for debugging Reselect. Used with [reselect-tools](https://github.com/skortchmark9/reselect-tools) 4 | 5 | ![Screenshot](Screen%20Shot%202017-11-15%20at%203.34.11%20AM.png) 6 | 7 | 8 | ## Installation 9 | 10 | Your app must be using [reselect-tools](https://github.com/skortchmark9/reselect-tools) for this to work. 11 | 12 | - from [Chrome Web Store](https://chrome.google.com/webstore/detail/reselect-devtools/cjmaipngmabglflfeepmdiffcijhjlbb); 13 | - or build it with `npm i && npm run build` and [load the extension's folder](https://developer.chrome.com/extensions/getstarted#unpacked) `./build/extension`; 14 | - or run it in dev mode with `npm i && npm run dev` and [load the extension's folder](https://developer.chrome.com/extensions/getstarted#unpacked) `./dev`. 15 | 16 | ## Development 17 | 18 | * Run script 19 | ```bash 20 | # build files to './dev' 21 | # start webpack development server 22 | $ npm run dev 23 | ``` 24 | * If you're developing Inject page, please allow `https://localhost:3000` connections. (Because `injectpage` injected GitHub (https) pages, so webpack server procotol must be https.) 25 | * [Load unpacked extensions](https://developer.chrome.com/extensions/getstarted#unpacked) with `./dev` folder. 26 | 27 | ## Caveat 28 | 29 | This is really just an MVP - I still need to write tests and remove cruft from the original [boilerplate](https://github.com/jhen0409/react-chrome-extension-boilerplate). However, its companion library is ready, so I want to get that off the ground and then I'll publish updates to this and productionize it better. 30 | 31 | ## Boilerplate Cruft 32 | 33 | #### React/Redux hot reload 34 | 35 | This boilerplate uses `Webpack` and `react-transform`, and use `Redux`. You can hot reload by editing related files of Popup & Window & Inject page. 36 | 37 | ### Build 38 | 39 | ```bash 40 | # build files to './build' 41 | $ npm run build 42 | ``` 43 | 44 | ### Compress 45 | 46 | ```bash 47 | # compress build folder to {manifest.name}.zip and crx 48 | $ npm run build 49 | $ npm run compress -- [options] 50 | ``` 51 | 52 | #### Options 53 | 54 | If you want to build `crx` file (auto update), please provide options, and add `update.xml` file url in [manifest.json](https://developer.chrome.com/extensions/autoupdate#update_url manifest.json). 55 | 56 | * --app-id: your extension id (can be get it when you first release extension) 57 | * --key: your private key path (default: './key.pem') 58 | you can use `npm run compress-keygen` to generate private key `./key.pem` 59 | * --codebase: your `crx` file url 60 | 61 | See [autoupdate guide](https://developer.chrome.com/extensions/autoupdate) for more information. 62 | 63 | ### Test 64 | 65 | * `test/app`: React components, Redux actions & reducers tests 66 | * `test/e2e`: E2E tests (use [chromedriver](https://www.npmjs.com/package/chromedriver), [selenium-webdriver](https://www.npmjs.com/package/selenium-webdriver)) 67 | 68 | ```bash 69 | # lint 70 | $ npm run lint 71 | # test/app 72 | $ npm test 73 | $ npm test -- --watch # watch files 74 | # test/e2e 75 | $ npm run build 76 | $ npm run test-e2e 77 | ``` 78 | 79 | ## LICENSE 80 | 81 | [MIT](LICENSE) 82 | -------------------------------------------------------------------------------- /extension/Screen Shot 2017-11-15 at 3.34.11 AM.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skortchmark9/reselect-tools/1175df8465356b94f39af2197019c44355c957f4/extension/Screen Shot 2017-11-15 at 3.34.11 AM.png -------------------------------------------------------------------------------- /extension/TODO.md: -------------------------------------------------------------------------------- 1 | Features: 2 | - [X] refresh button? 3 | - [X] make the graph pretty 4 | - [X] highlight dependencies through graph 5 | - [X] display number of recomputations (might need a refresh button) 6 | - [X] deal with long labels 7 | - [X] deal w/weird flexbox layout issues 8 | - [X] change N/A/ everywhere 9 | - [X] should we be able to get the value of selectors in the extension that we haven't added. - no 10 | - [X] pageApi can run into problems because we attempt to pass in an unregistered func with quotes in it. 11 | - [X] inject id field into graph nodes 12 | - [X] search selectors 13 | - [X] lock drawer open or closed 14 | - [X] find nodes depend on a given node 15 | - [X] highlight most recomputed nodes 16 | - [X] improve checkSelector rendering - zip inputs and dependencies 17 | - [ ] highlight unregistered nodes 18 | - [ ] show page action only when we are on a page w/devtools on it 19 | - [ ] Enable / disable depending on whether or not reselect tools have been installed 20 | 21 | 22 | Platforms: 23 | - [ ] Allow remote debugging with an RPC interface a la redux devtools 24 | 25 | Productionize 26 | - [X] Create icon 27 | - [X] Remove todoapp references 28 | - [X] Remove console logs 29 | - [X] Remove unnecessary pug templates 30 | - [X] Handle bad loads better 31 | - [X] Decide if we need redux 32 | - [ ] Remove all references to boilerplate 33 | - [ ] Set up linting, at least 34 | - [ ] At least look at the tests 35 | -------------------------------------------------------------------------------- /extension/app/actions/graph.js: -------------------------------------------------------------------------------- 1 | import * as types from '../constants/ActionTypes'; 2 | 3 | export function uncheckSelector() { 4 | return { type: types.UNCHECK_SELECTOR }; 5 | } 6 | 7 | export function checkSelectorFailed(selector) { 8 | return { type: types.CHECK_SELECTOR_FAILED, payload: { selector } }; 9 | } 10 | 11 | export function checkSelectorSuccess(selector) { 12 | return { type: types.CHECK_SELECTOR_SUCCESS, payload: { selector } }; 13 | } 14 | 15 | export function checkSelector(selector) { 16 | return { type: types.CHECK_SELECTOR, payload: { selector } }; 17 | } 18 | 19 | 20 | export function getSelectorGraphFailed() { 21 | return { type: types.GET_SELECTOR_GRAPH_FAILED }; 22 | } 23 | 24 | export function getSelectorGraphSuccess(graph) { 25 | return { type: types.GET_SELECTOR_GRAPH_SUCCESS, payload: { graph } }; 26 | } 27 | 28 | export function getSelectorGraph() { 29 | return { type: types.GET_SELECTOR_GRAPH }; 30 | } 31 | -------------------------------------------------------------------------------- /extension/app/components/Dock.js: -------------------------------------------------------------------------------- 1 | import React, { PropTypes } from 'react'; 2 | import MdKeyboardArrowLeft from 'react-icons/lib/md/keyboard-arrow-left'; 3 | import MdKeyboardArrowRight from 'react-icons/lib/md/keyboard-arrow-right'; 4 | import Button from 'remotedev-app/lib/components/Button'; 5 | 6 | const Subheader = ({ style, children, ...props }) => ( 7 |
{children}
8 | ); 9 | 10 | const messageContainerStyle = { 11 | display: 'flex', 12 | justifyContent: 'space-between', 13 | alignItems: 'center', 14 | flexShrink: 0, 15 | }; 16 | 17 | const Dock = ({ isOpen, toggleDock, message, children }) => { 18 | const dockStyle = { 19 | position: 'absolute', 20 | background: 'rgb(0, 43, 54)', 21 | top: 0, 22 | borderRight: '1px solid rgb(79, 90, 101)', 23 | borderTop: '1px solid rgb(79, 90, 101)', 24 | transform: `translateX(${isOpen ? 0 : '-100%'})`, 25 | left: 0, 26 | height: '100%', 27 | padding: '10px', 28 | minWidth: '220px', 29 | zIndex: 1, 30 | transition: 'transform 200ms ease-out', 31 | boxSizing: 'border-box', 32 | display: 'flex', 33 | flexDirection: 'column', 34 | }; 35 | const showButtonStyle = { 36 | position: 'relative', 37 | right: 0, 38 | top: 0, 39 | transition: 'transform 200ms ease-out', 40 | transform: `translateX(${isOpen ? 0 : '100%'})`, 41 | }; 42 | return ( 43 |
44 |
45 | {message} 46 | 47 | 51 | 52 |
53 | {children} 54 |
55 | ); 56 | }; 57 | 58 | Dock.propTypes = { 59 | isOpen: PropTypes.bool.isRequired, 60 | toggleDock: PropTypes.func, 61 | children: PropTypes.object, 62 | message: PropTypes.string 63 | }; 64 | 65 | export default Dock; 66 | -------------------------------------------------------------------------------- /extension/app/components/Footer.css: -------------------------------------------------------------------------------- 1 | .footer { 2 | color: #777; 3 | padding: 10px 15px; 4 | height: 20px; 5 | text-align: center; 6 | border-top: 1px solid #e6e6e6; 7 | } 8 | 9 | .footer:before { 10 | content: ''; 11 | position: absolute; 12 | right: 0; 13 | bottom: 0; 14 | left: 0; 15 | height: 50px; 16 | overflow: hidden; 17 | box-shadow: 0 1px 1px rgba(0, 0, 0, 0.2), 18 | 0 8px 0 -3px #f6f6f6, 19 | 0 9px 1px -3px rgba(0, 0, 0, 0.2), 20 | 0 16px 0 -6px #f6f6f6, 21 | 0 17px 2px -6px rgba(0, 0, 0, 0.2); 22 | } 23 | 24 | .filters { 25 | margin: 0; 26 | padding: 0; 27 | list-style: none; 28 | position: absolute; 29 | right: 0; 30 | left: 0; 31 | } 32 | 33 | .filters li { 34 | display: inline; 35 | } 36 | 37 | .filters li a { 38 | color: inherit; 39 | margin: 3px; 40 | padding: 3px 7px; 41 | text-decoration: none; 42 | border: 1px solid transparent; 43 | border-radius: 3px; 44 | } 45 | 46 | .filters li a.selected, 47 | .filters li a:hover { 48 | border-color: rgba(175, 47, 47, 0.1); 49 | } 50 | 51 | .filters li a.selected { 52 | border-color: rgba(175, 47, 47, 0.2); 53 | } 54 | 55 | .todoCount { 56 | float: left; 57 | text-align: left; 58 | } 59 | 60 | .todoCount strong { 61 | font-weight: 300; 62 | } 63 | 64 | .clearCompleted, 65 | html .clearCompleted:active { 66 | float: right; 67 | position: relative; 68 | line-height: 20px; 69 | text-decoration: none; 70 | cursor: pointer; 71 | } 72 | 73 | .clearCompleted:hover { 74 | text-decoration: underline; 75 | } 76 | 77 | @media (max-width: 430px) { 78 | .footer { 79 | height: 50px; 80 | } 81 | 82 | .filters { 83 | bottom: 10px; 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /extension/app/components/Footer.js: -------------------------------------------------------------------------------- 1 | import React, { PropTypes, Component } from 'react'; 2 | import classnames from 'classnames'; 3 | import { SHOW_ALL, SHOW_COMPLETED, SHOW_ACTIVE } from '../constants/TodoFilters'; 4 | import style from './Footer.css'; 5 | 6 | const FILTERS = [SHOW_ALL, SHOW_ACTIVE, SHOW_COMPLETED]; 7 | const FILTER_TITLES = { 8 | [SHOW_ALL]: 'All', 9 | [SHOW_ACTIVE]: 'Active', 10 | [SHOW_COMPLETED]: 'Completed' 11 | }; 12 | 13 | export default class Footer extends Component { 14 | 15 | static propTypes = { 16 | completedCount: PropTypes.number.isRequired, 17 | activeCount: PropTypes.number.isRequired, 18 | filter: PropTypes.string.isRequired, 19 | onClearCompleted: PropTypes.func.isRequired, 20 | onShow: PropTypes.func.isRequired 21 | }; 22 | 23 | constructor(props, context) { 24 | super(props, context); 25 | if (props.onShow) { 26 | this.filterHandlers = FILTERS.map(filter => () => props.onShow(filter)); 27 | } 28 | } 29 | 30 | componentWillReceiveProps(nextProps) { 31 | if (nextProps.onShow) { 32 | this.filterHandlers = FILTERS.map(filter => () => nextProps.onShow(filter)); 33 | } 34 | } 35 | 36 | renderTodoCount() { 37 | const { activeCount } = this.props; 38 | const itemWord = activeCount === 1 ? 'item' : 'items'; 39 | 40 | return ( 41 | 42 | {activeCount || 'No'} {itemWord} left 43 | 44 | ); 45 | } 46 | 47 | renderFilterLink(filter, handler) { 48 | const title = FILTER_TITLES[filter]; 49 | const { filter: selectedFilter } = this.props; 50 | 51 | return ( 52 | 57 | {title} 58 | 59 | ); 60 | } 61 | 62 | renderClearButton() { 63 | const { completedCount, onClearCompleted } = this.props; 64 | if (completedCount > 0) { 65 | return ( 66 | 72 | ); 73 | } 74 | } 75 | 76 | render() { 77 | return ( 78 | 89 | ); 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /extension/app/components/Header.js: -------------------------------------------------------------------------------- 1 | import React, { Component, PropTypes } from 'react'; 2 | import Button from 'remotedev-app/lib/components/Button'; 3 | import MdHelp from 'react-icons/lib/md/help'; 4 | import FindReplace from 'react-icons/lib/md/find-replace'; 5 | import RefreshIcon from 'react-icons/lib/md/refresh'; 6 | import styles from 'remotedev-app/lib/styles'; 7 | 8 | 9 | const headerStyles = { 10 | refreshButton: { 11 | position: 'absolute', 12 | top: 0, 13 | left: 0, 14 | }, 15 | select: { 16 | background: 'none', 17 | border: 'none', 18 | color: 'white', 19 | outline: 'none', 20 | }, 21 | }; 22 | 23 | class NumberButton extends Component { 24 | static propTypes = { 25 | defaultValue: PropTypes.number, 26 | onClick: PropTypes.func, 27 | numbers: PropTypes.array.isRequired, 28 | } 29 | constructor(props) { 30 | super(props); 31 | const value = props.defaultValue === undefined ? 1 : props.defaultValue; 32 | this.state = { value: value.toString() }; 33 | this.onNumberChange = this.onNumberChange.bind(this); 34 | this.onClickWithNumber = this.onClickWithNumber.bind(this); 35 | } 36 | onNumberChange(e) { 37 | this.setState({ value: e.target.value.toString() }); 38 | e.stopPropagation(); 39 | } 40 | onClickWithNumber(e) { 41 | this.props.onClick(parseInt(this.state.value, 10)); 42 | } 43 | stopPropagation(e) { 44 | e.stopPropagation(); 45 | } 46 | render() { 47 | const { numbers, children, ...other } = this.props; 48 | const { value } = this.state; 49 | const options = numbers.map(n => ); 50 | 51 | return ( 52 | 66 | ); 67 | } 68 | } 69 | 70 | 71 | export default function Header({ onRefresh, onHelp, onPaintWorst }) { 72 | return ( 73 |
74 | 79 | 83 | 88 | Select 89 | Most Recomputed 90 | 91 | 92 |
93 | ); 94 | } 95 | 96 | Header.propTypes = { 97 | onRefresh: PropTypes.func, 98 | onHelp: PropTypes.func, 99 | onPaintWorst: PropTypes.func, 100 | }; 101 | -------------------------------------------------------------------------------- /extension/app/components/MainSection.css: -------------------------------------------------------------------------------- 1 | .main { 2 | position: relative; 3 | z-index: 2; 4 | border-top: 1px solid #e6e6e6; 5 | } 6 | 7 | .todoList { 8 | margin: 0; 9 | padding: 0; 10 | list-style: none; 11 | } 12 | 13 | .todoList li { 14 | position: relative; 15 | font-size: 24px; 16 | border-bottom: 1px solid #ededed; 17 | } 18 | 19 | .todoList li:last-child { 20 | border-bottom: none; 21 | } 22 | 23 | .todoList li.editing { 24 | border-bottom: none; 25 | padding: 0; 26 | } 27 | 28 | .todoList li.editing .edit { 29 | display: block; 30 | width: 506px; 31 | padding: 13px 17px 12px 17px; 32 | margin: 0 0 0 43px; 33 | } 34 | 35 | .todoList li.editing .view { 36 | display: none; 37 | } 38 | 39 | .todoList li .toggle { 40 | text-align: center; 41 | width: 40px; 42 | /* auto, since non-WebKit browsers doesn't support input styling */ 43 | height: auto; 44 | position: absolute; 45 | top: 0; 46 | bottom: 0; 47 | margin: auto 0; 48 | border: none; /* Mobile Safari */ 49 | -webkit-appearance: none; 50 | -moz-appearance: none; 51 | } 52 | 53 | .todoList li .toggle:after { 54 | content: url('data:image/svg+xml;utf8,'); 55 | } 56 | 57 | .todoList li .toggle:checked:after { 58 | content: url('data:image/svg+xml;utf8,'); 59 | } 60 | 61 | .todoList li label { 62 | white-space: pre-line; 63 | word-break: break-all; 64 | padding: 15px 60px 15px 15px; 65 | margin-left: 45px; 66 | display: block; 67 | line-height: 1.2; 68 | transition: color 0.4s; 69 | } 70 | 71 | .todoList li.completed label { 72 | color: #d9d9d9; 73 | text-decoration: line-through; 74 | } 75 | 76 | .todoList li .destroy { 77 | display: none; 78 | position: absolute; 79 | top: 0; 80 | right: 10px; 81 | bottom: 0; 82 | width: 40px; 83 | height: 40px; 84 | margin: auto 0; 85 | font-size: 30px; 86 | color: #cc9a9a; 87 | margin-bottom: 11px; 88 | transition: color 0.2s ease-out; 89 | } 90 | 91 | .todoList li .destroy:hover { 92 | color: #af5b5e; 93 | } 94 | 95 | .todoList li .destroy:after { 96 | content: '×'; 97 | } 98 | 99 | .todoList li:hover .destroy { 100 | display: block; 101 | } 102 | 103 | .todoList li .edit { 104 | display: none; 105 | } 106 | 107 | .todoList li.editing:last-child { 108 | margin-bottom: -1px; 109 | } 110 | 111 | .toggleAll { 112 | position: absolute; 113 | top: -55px; 114 | left: -12px; 115 | width: 60px; 116 | height: 34px; 117 | text-align: center; 118 | border: none; /* Mobile Safari */ 119 | } 120 | 121 | .toggleAll:before { 122 | content: '❯'; 123 | font-size: 22px; 124 | color: #e6e6e6; 125 | padding: 10px 27px 10px 27px; 126 | } 127 | 128 | .toggleAll:checked:before { 129 | color: #737373; 130 | } 131 | 132 | /* 133 | Hack to remove background from Mobile Safari. 134 | Can't use it globally since it destroys checkboxes in Firefox 135 | */ 136 | @media screen and (-webkit-min-device-pixel-ratio:0) { 137 | .toggleAll, 138 | .todoList li .toggle { 139 | background: none; 140 | } 141 | 142 | .todoList li .toggle { 143 | height: 40px; 144 | } 145 | 146 | .toggleAll { 147 | transform: rotate(90deg); 148 | -webkit-appearance: none; 149 | -moz-appearance: none; 150 | } 151 | } 152 | -------------------------------------------------------------------------------- /extension/app/components/MainSection.js: -------------------------------------------------------------------------------- 1 | import React, { Component, PropTypes } from 'react'; 2 | import TodoItem from './TodoItem'; 3 | import Footer from './Footer'; 4 | import { SHOW_ALL, SHOW_COMPLETED, SHOW_ACTIVE } from '../constants/TodoFilters'; 5 | import style from './MainSection.css'; 6 | 7 | const TODO_FILTERS = { 8 | [SHOW_ALL]: () => true, 9 | [SHOW_ACTIVE]: todo => !todo.completed, 10 | [SHOW_COMPLETED]: todo => todo.completed 11 | }; 12 | 13 | export default class MainSection extends Component { 14 | 15 | static propTypes = { 16 | todos: PropTypes.array.isRequired, 17 | actions: PropTypes.object.isRequired 18 | }; 19 | 20 | constructor(props, context) { 21 | super(props, context); 22 | this.state = { filter: SHOW_ALL }; 23 | } 24 | 25 | handleClearCompleted = () => { 26 | const atLeastOneCompleted = this.props.todos.some(todo => todo.completed); 27 | if (atLeastOneCompleted) { 28 | this.props.actions.clearCompleted(); 29 | } 30 | }; 31 | 32 | handleShow = (filter) => { 33 | this.setState({ filter }); 34 | }; 35 | 36 | renderToggleAll(completedCount) { 37 | const { todos, actions } = this.props; 38 | if (todos.length > 0) { 39 | return ( 40 | 46 | ); 47 | } 48 | } 49 | 50 | renderFooter(completedCount) { 51 | const { todos } = this.props; 52 | const { filter } = this.state; 53 | const activeCount = todos.length - completedCount; 54 | 55 | if (todos.length) { 56 | return ( 57 |