├── .babelrc ├── .gitignore ├── .travis.yml ├── .vscode ├── extensions.json └── settings.json ├── LICENSE ├── README.md ├── jest.json ├── package.json ├── postcss.config.js ├── script └── deploy_gh_page.sh ├── src ├── Interfaces │ ├── state.d.ts │ └── store.d.ts ├── components │ ├── LoadingIndicator │ │ ├── index.tsx │ │ └── test │ │ │ └── index.test.tsx │ └── helloworld │ │ ├── index.tsx │ │ └── test │ │ └── index.test.tsx ├── containers │ ├── App │ │ ├── Header.tsx │ │ ├── constants.ts │ │ ├── index.tsx │ │ └── test │ │ │ └── index.test.tsx │ └── HomePage │ │ ├── Loadable.ts │ │ ├── actions.ts │ │ ├── constants.ts │ │ ├── index.tsx │ │ ├── reducer.ts │ │ ├── saga.ts │ │ ├── selectors.ts │ │ └── test │ │ ├── __snapshots__ │ │ └── saga.test.ts.snap │ │ ├── index.test.tsx │ │ ├── reducer.test.ts │ │ ├── saga.test.ts │ │ └── selectors.test.ts ├── index.d.ts ├── index.html ├── main.tsx ├── reducers.ts ├── store.ts ├── test │ ├── enzymeConfigure.ts │ ├── fetchPolyfill.ts │ ├── raf.ts │ └── store.test.ts └── utils │ ├── checkStore.ts │ ├── constants.ts │ ├── injectReducer.tsx │ ├── injectSaga.tsx │ ├── reducerInjectors.ts │ ├── sagaInjectors.ts │ └── test │ ├── checkStore.test.ts │ ├── injectReducer.test.tsx │ ├── injectSaga.test.tsx │ ├── reducerInjectors.test.ts │ └── sagaInjectors.test.ts ├── tsconfig.json ├── tslint.json ├── webpack.config.js ├── webpack.config.prod.js └── yarn.lock /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | "@babel/preset-env", 4 | "@babel/react" 5 | ], 6 | "plugins": [ 7 | [ 8 | "@babel/plugin-transform-runtime", 9 | { 10 | "regenerator": true 11 | } 12 | ], 13 | "@babel/plugin-syntax-dynamic-import" 14 | ] 15 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | 6 | # Runtime data 7 | pids 8 | *.pid 9 | *.seed 10 | 11 | # Directory for instrumented libs generated by jscoverage/JSCover 12 | lib-cov 13 | 14 | # Coverage directory used by tools like istanbul 15 | coverage 16 | 17 | # nyc test coverage 18 | .nyc_output 19 | 20 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 21 | .grunt 22 | 23 | # node-waf configuration 24 | .lock-wscript 25 | 26 | # Compiled binary addons (http://nodejs.org/api/addons.html) 27 | build/Release 28 | 29 | # Dependency directories 30 | node_modules 31 | jspm_packages 32 | 33 | # Optional npm cache directory 34 | .npm 35 | 36 | # Optional REPL history 37 | .node_repl_history 38 | 39 | dist 40 | 41 | .awcache 42 | 43 | .DS_Store -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - '8' 4 | cache: 5 | directories: 6 | - node_modules 7 | before_install: 8 | - npm install -g yarn 9 | install: 10 | - yarn 11 | - yarn add coveralls 12 | script: 13 | - npm run build # build 14 | - npm run test # run mocha unit tests with coverage 15 | after_script: 16 | - 'cat coverage/lcov.info | ./node_modules/.bin/coveralls' # sends the coverage report to coveralls -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "eg2.tslint" 4 | ] 5 | } -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "typescript.tsdk": "node_modules/typescript/lib" 3 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | GNU GENERAL PUBLIC LICENSE 2 | Version 2, June 1991 3 | 4 | Copyright (C) 1989, 1991 Free Software Foundation, Inc., 5 | 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA 6 | Everyone is permitted to copy and distribute verbatim copies 7 | of this license document, but changing it is not allowed. 8 | 9 | Preamble 10 | 11 | The licenses for most software are designed to take away your 12 | freedom to share and change it. By contrast, the GNU General Public 13 | License is intended to guarantee your freedom to share and change free 14 | software--to make sure the software is free for all its users. This 15 | General Public License applies to most of the Free Software 16 | Foundation's software and to any other program whose authors commit to 17 | using it. (Some other Free Software Foundation software is covered by 18 | the GNU Lesser General Public License instead.) You can apply it to 19 | your programs, too. 20 | 21 | When we speak of free software, we are referring to freedom, not 22 | price. Our General Public Licenses are designed to make sure that you 23 | have the freedom to distribute copies of free software (and charge for 24 | this service if you wish), that you receive source code or can get it 25 | if you want it, that you can change the software or use pieces of it 26 | in new free programs; and that you know you can do these things. 27 | 28 | To protect your rights, we need to make restrictions that forbid 29 | anyone to deny you these rights or to ask you to surrender the rights. 30 | These restrictions translate to certain responsibilities for you if you 31 | distribute copies of the software, or if you modify it. 32 | 33 | For example, if you distribute copies of such a program, whether 34 | gratis or for a fee, you must give the recipients all the rights that 35 | you have. You must make sure that they, too, receive or can get the 36 | source code. And you must show them these terms so they know their 37 | rights. 38 | 39 | We protect your rights with two steps: (1) copyright the software, and 40 | (2) offer you this license which gives you legal permission to copy, 41 | distribute and/or modify the software. 42 | 43 | Also, for each author's protection and ours, we want to make certain 44 | that everyone understands that there is no warranty for this free 45 | software. If the software is modified by someone else and passed on, we 46 | want its recipients to know that what they have is not the original, so 47 | that any problems introduced by others will not reflect on the original 48 | authors' reputations. 49 | 50 | Finally, any free program is threatened constantly by software 51 | patents. We wish to avoid the danger that redistributors of a free 52 | program will individually obtain patent licenses, in effect making the 53 | program proprietary. To prevent this, we have made it clear that any 54 | patent must be licensed for everyone's free use or not licensed at all. 55 | 56 | The precise terms and conditions for copying, distribution and 57 | modification follow. 58 | 59 | GNU GENERAL PUBLIC LICENSE 60 | TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION 61 | 62 | 0. This License applies to any program or other work which contains 63 | a notice placed by the copyright holder saying it may be distributed 64 | under the terms of this General Public License. The "Program", below, 65 | refers to any such program or work, and a "work based on the Program" 66 | means either the Program or any derivative work under copyright law: 67 | that is to say, a work containing the Program or a portion of it, 68 | either verbatim or with modifications and/or translated into another 69 | language. (Hereinafter, translation is included without limitation in 70 | the term "modification".) Each licensee is addressed as "you". 71 | 72 | Activities other than copying, distribution and modification are not 73 | covered by this License; they are outside its scope. The act of 74 | running the Program is not restricted, and the output from the Program 75 | is covered only if its contents constitute a work based on the 76 | Program (independent of having been made by running the Program). 77 | Whether that is true depends on what the Program does. 78 | 79 | 1. You may copy and distribute verbatim copies of the Program's 80 | source code as you receive it, in any medium, provided that you 81 | conspicuously and appropriately publish on each copy an appropriate 82 | copyright notice and disclaimer of warranty; keep intact all the 83 | notices that refer to this License and to the absence of any warranty; 84 | and give any other recipients of the Program a copy of this License 85 | along with the Program. 86 | 87 | You may charge a fee for the physical act of transferring a copy, and 88 | you may at your option offer warranty protection in exchange for a fee. 89 | 90 | 2. You may modify your copy or copies of the Program or any portion 91 | of it, thus forming a work based on the Program, and copy and 92 | distribute such modifications or work under the terms of Section 1 93 | above, provided that you also meet all of these conditions: 94 | 95 | a) You must cause the modified files to carry prominent notices 96 | stating that you changed the files and the date of any change. 97 | 98 | b) You must cause any work that you distribute or publish, that in 99 | whole or in part contains or is derived from the Program or any 100 | part thereof, to be licensed as a whole at no charge to all third 101 | parties under the terms of this License. 102 | 103 | c) If the modified program normally reads commands interactively 104 | when run, you must cause it, when started running for such 105 | interactive use in the most ordinary way, to print or display an 106 | announcement including an appropriate copyright notice and a 107 | notice that there is no warranty (or else, saying that you provide 108 | a warranty) and that users may redistribute the program under 109 | these conditions, and telling the user how to view a copy of this 110 | License. (Exception: if the Program itself is interactive but 111 | does not normally print such an announcement, your work based on 112 | the Program is not required to print an announcement.) 113 | 114 | These requirements apply to the modified work as a whole. If 115 | identifiable sections of that work are not derived from the Program, 116 | and can be reasonably considered independent and separate works in 117 | themselves, then this License, and its terms, do not apply to those 118 | sections when you distribute them as separate works. But when you 119 | distribute the same sections as part of a whole which is a work based 120 | on the Program, the distribution of the whole must be on the terms of 121 | this License, whose permissions for other licensees extend to the 122 | entire whole, and thus to each and every part regardless of who wrote it. 123 | 124 | Thus, it is not the intent of this section to claim rights or contest 125 | your rights to work written entirely by you; rather, the intent is to 126 | exercise the right to control the distribution of derivative or 127 | collective works based on the Program. 128 | 129 | In addition, mere aggregation of another work not based on the Program 130 | with the Program (or with a work based on the Program) on a volume of 131 | a storage or distribution medium does not bring the other work under 132 | the scope of this License. 133 | 134 | 3. You may copy and distribute the Program (or a work based on it, 135 | under Section 2) in object code or executable form under the terms of 136 | Sections 1 and 2 above provided that you also do one of the following: 137 | 138 | a) Accompany it with the complete corresponding machine-readable 139 | source code, which must be distributed under the terms of Sections 140 | 1 and 2 above on a medium customarily used for software interchange; or, 141 | 142 | b) Accompany it with a written offer, valid for at least three 143 | years, to give any third party, for a charge no more than your 144 | cost of physically performing source distribution, a complete 145 | machine-readable copy of the corresponding source code, to be 146 | distributed under the terms of Sections 1 and 2 above on a medium 147 | customarily used for software interchange; or, 148 | 149 | c) Accompany it with the information you received as to the offer 150 | to distribute corresponding source code. (This alternative is 151 | allowed only for noncommercial distribution and only if you 152 | received the program in object code or executable form with such 153 | an offer, in accord with Subsection b above.) 154 | 155 | The source code for a work means the preferred form of the work for 156 | making modifications to it. For an executable work, complete source 157 | code means all the source code for all modules it contains, plus any 158 | associated interface definition files, plus the scripts used to 159 | control compilation and installation of the executable. However, as a 160 | special exception, the source code distributed need not include 161 | anything that is normally distributed (in either source or binary 162 | form) with the major components (compiler, kernel, and so on) of the 163 | operating system on which the executable runs, unless that component 164 | itself accompanies the executable. 165 | 166 | If distribution of executable or object code is made by offering 167 | access to copy from a designated place, then offering equivalent 168 | access to copy the source code from the same place counts as 169 | distribution of the source code, even though third parties are not 170 | compelled to copy the source along with the object code. 171 | 172 | 4. You may not copy, modify, sublicense, or distribute the Program 173 | except as expressly provided under this License. Any attempt 174 | otherwise to copy, modify, sublicense or distribute the Program is 175 | void, and will automatically terminate your rights under this License. 176 | However, parties who have received copies, or rights, from you under 177 | this License will not have their licenses terminated so long as such 178 | parties remain in full compliance. 179 | 180 | 5. You are not required to accept this License, since you have not 181 | signed it. However, nothing else grants you permission to modify or 182 | distribute the Program or its derivative works. These actions are 183 | prohibited by law if you do not accept this License. Therefore, by 184 | modifying or distributing the Program (or any work based on the 185 | Program), you indicate your acceptance of this License to do so, and 186 | all its terms and conditions for copying, distributing or modifying 187 | the Program or works based on it. 188 | 189 | 6. Each time you redistribute the Program (or any work based on the 190 | Program), the recipient automatically receives a license from the 191 | original licensor to copy, distribute or modify the Program subject to 192 | these terms and conditions. You may not impose any further 193 | restrictions on the recipients' exercise of the rights granted herein. 194 | You are not responsible for enforcing compliance by third parties to 195 | this License. 196 | 197 | 7. If, as a consequence of a court judgment or allegation of patent 198 | infringement or for any other reason (not limited to patent issues), 199 | conditions are imposed on you (whether by court order, agreement or 200 | otherwise) that contradict the conditions of this License, they do not 201 | excuse you from the conditions of this License. If you cannot 202 | distribute so as to satisfy simultaneously your obligations under this 203 | License and any other pertinent obligations, then as a consequence you 204 | may not distribute the Program at all. For example, if a patent 205 | license would not permit royalty-free redistribution of the Program by 206 | all those who receive copies directly or indirectly through you, then 207 | the only way you could satisfy both it and this License would be to 208 | refrain entirely from distribution of the Program. 209 | 210 | If any portion of this section is held invalid or unenforceable under 211 | any particular circumstance, the balance of the section is intended to 212 | apply and the section as a whole is intended to apply in other 213 | circumstances. 214 | 215 | It is not the purpose of this section to induce you to infringe any 216 | patents or other property right claims or to contest validity of any 217 | such claims; this section has the sole purpose of protecting the 218 | integrity of the free software distribution system, which is 219 | implemented by public license practices. Many people have made 220 | generous contributions to the wide range of software distributed 221 | through that system in reliance on consistent application of that 222 | system; it is up to the author/donor to decide if he or she is willing 223 | to distribute software through any other system and a licensee cannot 224 | impose that choice. 225 | 226 | This section is intended to make thoroughly clear what is believed to 227 | be a consequence of the rest of this License. 228 | 229 | 8. If the distribution and/or use of the Program is restricted in 230 | certain countries either by patents or by copyrighted interfaces, the 231 | original copyright holder who places the Program under this License 232 | may add an explicit geographical distribution limitation excluding 233 | those countries, so that distribution is permitted only in or among 234 | countries not thus excluded. In such case, this License incorporates 235 | the limitation as if written in the body of this License. 236 | 237 | 9. The Free Software Foundation may publish revised and/or new versions 238 | of the General Public License from time to time. Such new versions will 239 | be similar in spirit to the present version, but may differ in detail to 240 | address new problems or concerns. 241 | 242 | Each version is given a distinguishing version number. If the Program 243 | specifies a version number of this License which applies to it and "any 244 | later version", you have the option of following the terms and conditions 245 | either of that version or of any later version published by the Free 246 | Software Foundation. If the Program does not specify a version number of 247 | this License, you may choose any version ever published by the Free Software 248 | Foundation. 249 | 250 | 10. If you wish to incorporate parts of the Program into other free 251 | programs whose distribution conditions are different, write to the author 252 | to ask for permission. For software which is copyrighted by the Free 253 | Software Foundation, write to the Free Software Foundation; we sometimes 254 | make exceptions for this. Our decision will be guided by the two goals 255 | of preserving the free status of all derivatives of our free software and 256 | of promoting the sharing and reuse of software generally. 257 | 258 | NO WARRANTY 259 | 260 | 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY 261 | FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN 262 | OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES 263 | PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED 264 | OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF 265 | MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS 266 | TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE 267 | PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, 268 | REPAIR OR CORRECTION. 269 | 270 | 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING 271 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR 272 | REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, 273 | INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING 274 | OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED 275 | TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY 276 | YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER 277 | PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE 278 | POSSIBILITY OF SUCH DAMAGES. 279 | 280 | END OF TERMS AND CONDITIONS 281 | 282 | How to Apply These Terms to Your New Programs 283 | 284 | If you develop a new program, and you want it to be of the greatest 285 | possible use to the public, the best way to achieve this is to make it 286 | free software which everyone can redistribute and change under these terms. 287 | 288 | To do so, attach the following notices to the program. It is safest 289 | to attach them to the start of each source file to most effectively 290 | convey the exclusion of warranty; and each file should have at least 291 | the "copyright" line and a pointer to where the full notice is found. 292 | 293 | {description} 294 | Copyright (C) {year} {fullname} 295 | 296 | This program is free software; you can redistribute it and/or modify 297 | it under the terms of the GNU General Public License as published by 298 | the Free Software Foundation; either version 2 of the License, or 299 | (at your option) any later version. 300 | 301 | This program is distributed in the hope that it will be useful, 302 | but WITHOUT ANY WARRANTY; without even the implied warranty of 303 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 304 | GNU General Public License for more details. 305 | 306 | You should have received a copy of the GNU General Public License along 307 | with this program; if not, write to the Free Software Foundation, Inc., 308 | 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. 309 | 310 | Also add information on how to contact you by electronic and paper mail. 311 | 312 | If the program is interactive, make it output a short notice like this 313 | when it starts in an interactive mode: 314 | 315 | Gnomovision version 69, Copyright (C) year name of author 316 | Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'. 317 | This is free software, and you are welcome to redistribute it 318 | under certain conditions; type `show c' for details. 319 | 320 | The hypothetical commands `show w' and `show c' should show the appropriate 321 | parts of the General Public License. Of course, the commands you use may 322 | be called something other than `show w' and `show c'; they could even be 323 | mouse-clicks or menu items--whatever suits your program. 324 | 325 | You should also get your employer (if you work as a programmer) or your 326 | school, if any, to sign a "copyright disclaimer" for the program, if 327 | necessary. Here is a sample; alter the names: 328 | 329 | Yoyodyne, Inc., hereby disclaims all copyright interest in the program 330 | `Gnomovision' (which makes passes at compilers) written by James Hacker. 331 | 332 | {signature of Ty Coon}, 1 April 1989 333 | Ty Coon, President of Vice 334 | 335 | This General Public License does not permit incorporating your program into 336 | proprietary programs. If your program is a subroutine library, you may 337 | consider it more useful to permit linking proprietary applications with the 338 | library. If this is what you want to do, use the GNU Lesser General 339 | Public License instead of this License. 340 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | # react-typescript-kits 3 | 4 | [![Build Status](https://travis-ci.org/EYHN/react-typescript-kits.svg?branch=master)](https://travis-ci.org/EYHN/react-typescript-kits)[![Coverage Status](https://coveralls.io/repos/github/EYHN/react-typescript-kits/badge.svg?branch=master)](https://coveralls.io/github/EYHN/react-typescript-kits?branch=master) 5 | 6 | 这可能是最厉害的 react + typescript 手脚架。 7 | 8 | 灵感来自于 [react-boilerplate](https://github.com/react-boilerplate/react-boilerplate)。 9 | 10 | ## 特性 | feature 11 | 12 | * 翻译人员友好的国际化文件。 13 | 14 | * 根据路由的代码分割。 15 | 16 | * Typescript 优秀的开发体验。 17 | 18 | * 彻底的组件化方案。 19 | 20 | ## 技术栈 | stack 21 | 22 | webpack 23 | 24 | react 25 | 26 | typescript 27 | 28 | redux 29 | 30 | tslint 31 | 32 | immutable 33 | 34 | redux-immutable 35 | 36 | redux-saga 37 | 38 | reselect 39 | -------------------------------------------------------------------------------- /jest.json: -------------------------------------------------------------------------------- 1 | { 2 | "transform": { 3 | "^.+\\.tsx?$": "/node_modules/ts-jest/preprocessor.js" 4 | }, 5 | "testRegex": "(/__tests__/.*|\\.(test|spec))\\.(tsx?|jsx?)$", 6 | "moduleFileExtensions": [ 7 | "ts", 8 | "tsx", 9 | "js", 10 | "jsx" 11 | ], 12 | "browser": true, 13 | "collectCoverage": true, 14 | "collectCoverageFrom" : ["src/**/*.{js,jsx,ts,tsx}", "!**/node_modules/**", "!src/**/*.{d.ts}"], 15 | "coverageDirectory": "coverage", 16 | "setupFiles": ["./src/test/raf.ts", "./src/test/enzymeConfigure.ts", "./src/test/fetchPolyfill.ts"], 17 | "moduleDirectories": ["node_modules", "./src"], 18 | "globals": { 19 | "ts-jest": { 20 | "tsConfigFile": "tsconfig.json", 21 | "useBabelrc": true 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-typescript-kits", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "clean": "rimraf dist", 8 | "start": "http-server dist -a 127.0.0.1 -p 8888 -o", 9 | "dev": "webpack-dev-server --inline --progress --hot", 10 | "build": "npm run clean && npm run lint && webpack --config webpack.config.prod.js", 11 | "lint": "tslint --force --format verbose \"src/**/*.{ts,tsx}\"", 12 | "test": "jest --config jest.json" 13 | }, 14 | "author": "EYHN", 15 | "license": "GPL-3.0", 16 | "devDependencies": { 17 | "@babel/cli": "7.0.0", 18 | "@babel/core": "7.0.0", 19 | "@babel/plugin-syntax-dynamic-import": "7.0.0", 20 | "@babel/plugin-transform-runtime": "7.0.0", 21 | "@babel/preset-env": "7.0.0", 22 | "@babel/preset-react": "7.0.0", 23 | "@types/enzyme": "3.1.13", 24 | "@types/fontfaceobserver": "0.0.6", 25 | "@types/history": "4.7.0", 26 | "@types/invariant": "2.2.29", 27 | "@types/jest": "23.3.1", 28 | "@types/node": "10.9.3", 29 | "@types/prop-types": "15.5.5", 30 | "@types/react": "16.4.12", 31 | "@types/react-dom": "16.0.7", 32 | "@types/react-helmet": "5.0.7", 33 | "@types/react-redux": "6.0.6", 34 | "@types/react-router": "4.0.30", 35 | "@types/react-router-dom": "4.3.0", 36 | "@types/react-router-redux": "5.0.15", 37 | "@types/redux-immutable": "3.0.38", 38 | "@types/webpack": "4.4.11", 39 | "autoprefixer": "9.1.3", 40 | "awesome-typescript-loader": "5.2.0", 41 | "babel-core": "^7.0.0-0", 42 | "babel-loader": "8.0.0", 43 | "bundle-loader": "0.5.6", 44 | "css-loader": "1.0.0", 45 | "enzyme": "3.5.0", 46 | "enzyme-adapter-react-16": "1.3.0", 47 | "file-loader": "2.0.0", 48 | "html-webpack-plugin": "3.2.0", 49 | "isomorphic-fetch": "2.2.1", 50 | "jest": "23.5.0", 51 | "path": "0.12.7", 52 | "postcss": "7.0.2", 53 | "postcss-loader": "3.0.0", 54 | "raf": "3.4.0", 55 | "raw-loader": "0.5.1", 56 | "rimraf": "2.6.2", 57 | "source-map-loader": "0.2.4", 58 | "style-loader": "0.23.0", 59 | "ts-jest": "23.1.4", 60 | "tslint": "5.11.0", 61 | "tslint-react": "3.6.0", 62 | "typescript": "3.0.1", 63 | "url-loader": "1.1.1", 64 | "webpack": "4.17.1", 65 | "webpack-bundle-analyzer": "2.13.1", 66 | "webpack-cli": "3.1.0", 67 | "webpack-dev-server": "3.1.6" 68 | }, 69 | "dependencies": { 70 | "@babel/runtime": "7.0.0", 71 | "fontfaceobserver": "2.0.13", 72 | "history": "4.7.2", 73 | "hoist-non-react-statics": "3.0.1", 74 | "http-server": "0.11.1", 75 | "immutable": "3.8.2", 76 | "invariant": "2.2.4", 77 | "offline-plugin": "5.0.5", 78 | "prop-types": "15.6.2", 79 | "react": "16.4.2", 80 | "react-async-component": "2.0.0", 81 | "react-dom": "16.4.2", 82 | "react-helmet": "5.2.0", 83 | "react-redux": "5.0.7", 84 | "react-router-dom": "4.3.1", 85 | "react-router-redux": "5.0.0-alpha.6", 86 | "redux": "4.0.0", 87 | "redux-immutable": "4.0.0", 88 | "redux-saga": "0.16.0", 89 | "reselect": "3.0.1", 90 | "sanitize.css": "7.0.1", 91 | "typesafe-actions": "2.0.4", 92 | "utility-types": "2.0.0", 93 | "warning": "4.0.2" 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: [ 3 | require('autoprefixer') 4 | ] 5 | } -------------------------------------------------------------------------------- /script/deploy_gh_page.sh: -------------------------------------------------------------------------------- 1 | mkdir ./deploy-gh-page 2 | cp -rf "${DeployPath}" ./deploy-gh-page 3 | cd ./deploy-gh-page 4 | 5 | git init 6 | git config --global push.default matching 7 | git config --global user.email "${GitHubEmail}" 8 | git config --global user.name "${GitHubUser}" 9 | 10 | git add --all . 11 | 12 | git commit -m "Auto deploy github page - `date`" 13 | git push --quiet --force https://${GitHubKEY}@github.com/${GitHubRepo}.git master:${DeployBranch} 14 | -------------------------------------------------------------------------------- /src/Interfaces/state.d.ts: -------------------------------------------------------------------------------- 1 | export type IState = any; 2 | -------------------------------------------------------------------------------- /src/Interfaces/store.d.ts: -------------------------------------------------------------------------------- 1 | import { Store } from 'redux'; 2 | import { SagaMiddleware } from 'redux-saga'; 3 | 4 | export interface IStore extends Store { 5 | injectedReducers: any; 6 | runSaga: SagaMiddleware['run']; 7 | injectedSagas: any; 8 | } 9 | -------------------------------------------------------------------------------- /src/components/LoadingIndicator/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | const LoadingIndicator = () => ( 4 |
5 | 加载中 6 |
7 | ); 8 | 9 | export default LoadingIndicator; 10 | -------------------------------------------------------------------------------- /src/components/LoadingIndicator/test/index.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { shallow } from 'enzyme'; 3 | 4 | import LoadingIndicator from '../index'; 5 | 6 | describe('', () => { 7 | it('should render the `messages.loading`', () => { 8 | const renderedComponent = shallow( 9 | 10 | ); 11 | expect(renderedComponent.contains('加载中')); 12 | }); 13 | }); 14 | -------------------------------------------------------------------------------- /src/components/helloworld/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export default class Helloworld extends React.PureComponent { 4 | public render() { 5 | return ( 6 |
7 |

Hello World

8 |
9 | ); 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/components/helloworld/test/index.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { shallow } from 'enzyme'; 3 | 4 | import Helloworld from '../index'; 5 | 6 | describe('', () => { 7 | it('should render the `messages.startProjectHeader`', () => { 8 | const renderedComponent = shallow( 9 | 10 | ); 11 | expect(renderedComponent.contains('Hello World')); 12 | }); 13 | }); 14 | -------------------------------------------------------------------------------- /src/containers/App/Header.tsx: -------------------------------------------------------------------------------- 1 | import { Helmet } from 'react-helmet'; 2 | import React from 'react'; 3 | 4 | const Header: React.SFC = () => ( 5 | 6 | 7 | 8 | ); 9 | 10 | export default Header; 11 | -------------------------------------------------------------------------------- /src/containers/App/constants.ts: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EYHN/react-typescript-kits/2a596a884a84388ee93a458c40ff9bc838237528/src/containers/App/constants.ts -------------------------------------------------------------------------------- /src/containers/App/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Switch, Route } from 'react-router'; 3 | import HomePage from 'containers/HomePage/Loadable'; 4 | 5 | import Header from './Header'; 6 | 7 | const App: React.SFC<{}> = () => ( 8 |
9 |
10 | 11 | 12 | 13 |
14 | ); 15 | 16 | export default App; 17 | -------------------------------------------------------------------------------- /src/containers/App/test/index.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { shallow } from 'enzyme'; 3 | 4 | import App from '../index'; 5 | 6 | import Header from '../Header'; 7 | import { Route } from 'react-router'; 8 | 9 | describe('', () => { 10 | it('should render the header', () => { 11 | const renderedComponent = shallow( 12 | 13 | ); 14 | expect(renderedComponent.find(Header).length).toBe(1); 15 | }); 16 | 17 | it('should render some routes', () => { 18 | const renderedComponent = shallow( 19 | 20 | ); 21 | expect(renderedComponent.find(Route).length).not.toBe(0); 22 | }); 23 | }); 24 | -------------------------------------------------------------------------------- /src/containers/HomePage/Loadable.ts: -------------------------------------------------------------------------------- 1 | import LoadingIndicator from '../../components/LoadingIndicator'; 2 | import { asyncComponent } from 'react-async-component'; 3 | 4 | export default asyncComponent({ 5 | resolve: () => import(/* webpackChunkName: "home" */ './index'), 6 | LoadingComponent: LoadingIndicator 7 | }); 8 | -------------------------------------------------------------------------------- /src/containers/HomePage/actions.ts: -------------------------------------------------------------------------------- 1 | import { LOAD_HITOKOTO, LOAD_HITOKOTO_SUCCESS, LOAD_HITOKOTO_ERROR } from 'containers/HomePage/constants'; 2 | import { createAction, createStandardAction } from 'typesafe-actions'; 3 | 4 | export const loadHitokoto = createAction(LOAD_HITOKOTO); 5 | 6 | export const hitokotoLoaded = createStandardAction(LOAD_HITOKOTO_SUCCESS)(); 7 | 8 | export const hitokotoLoadingError = createStandardAction(LOAD_HITOKOTO_ERROR)(); 9 | -------------------------------------------------------------------------------- /src/containers/HomePage/constants.ts: -------------------------------------------------------------------------------- 1 | export const LOAD_HITOKOTO = 'app/Home/CHANGE_USERNAME/LOAD_HITOKOTO'; 2 | export const LOAD_HITOKOTO_SUCCESS = 'app/Home/CHANGE_USERNAME/LOAD_HITOKOTO_SUCCESS'; 3 | export const LOAD_HITOKOTO_ERROR = 'app/Home/CHANGE_USERNAME/LOAD_HITOKOTO_ERROR'; 4 | -------------------------------------------------------------------------------- /src/containers/HomePage/index.tsx: -------------------------------------------------------------------------------- 1 | 2 | import { createSelector } from 'reselect'; 3 | import React from 'react'; 4 | import { Dispatch, compose } from 'redux'; 5 | import { connect } from 'react-redux'; 6 | import Helloworld from 'components/helloworld/index'; 7 | import { loadHitokoto } from 'containers/HomePage/actions'; 8 | import reducer from './reducer'; 9 | import saga from './saga'; 10 | 11 | import injectReducer from 'utils/injectReducer'; 12 | import injectSaga from 'utils/injectSaga'; 13 | import { makeSelectHitokoto } from 'containers/HomePage/selectors'; 14 | import { $Call } from 'utility-types'; 15 | import { ONCE_TILL_UNMOUNT } from 'utils/constants'; 16 | 17 | interface IHomePageProps { 18 | } 19 | 20 | const mapStateToProps = createSelector( 21 | makeSelectHitokoto(), 22 | (hitokoto) => ({ hitokoto }) 23 | ); 24 | 25 | export const mapDispatchToProps = (dispatch: Dispatch) => ({ 26 | onGetHitokoto: () => (dispatch(loadHitokoto())) 27 | }); 28 | 29 | type stateProps = $Call; 30 | type dispatchProps = $Call; 31 | 32 | type Props = stateProps & IHomePageProps & dispatchProps; 33 | 34 | export class HomePage extends React.PureComponent { 35 | 36 | public render() { 37 | return ( 38 |
39 | 40 |

{this.props.hitokoto}

41 |
42 | ); 43 | } 44 | } 45 | 46 | // tslint:disable-next-line:max-line-length 47 | const withConnect = connect(mapStateToProps, mapDispatchToProps); 48 | 49 | const withReducer = injectReducer({ key: 'home', reducer }); 50 | const withSaga = injectSaga({ key: 'home', saga, mode: ONCE_TILL_UNMOUNT }); 51 | 52 | export default compose( 53 | withReducer, 54 | withSaga, 55 | withConnect 56 | )(HomePage); 57 | -------------------------------------------------------------------------------- /src/containers/HomePage/reducer.ts: -------------------------------------------------------------------------------- 1 | import { fromJS } from 'immutable'; 2 | import { $Call, $Values } from 'utility-types'; 3 | import * as actions from './actions'; 4 | import { getType } from 'typesafe-actions'; 5 | export type HomeAction = $Call<$Values>; 6 | 7 | const initialState = fromJS({ 8 | loading: false, 9 | error: false, 10 | hitokoto: null 11 | }); 12 | 13 | export default function homeReducer(state = initialState, action: HomeAction) { 14 | switch (action.type) { 15 | case getType(actions.loadHitokoto): 16 | return state 17 | .set('loading', true) 18 | .set('error', false) 19 | .set('hitokoto', null); 20 | case getType(actions.hitokotoLoaded): 21 | return state 22 | .set('loading', false) 23 | .set('hitokoto', action.payload); 24 | case getType(actions.hitokotoLoadingError): 25 | return state 26 | .set('error', true) 27 | .set('loading', false); 28 | default: 29 | return state; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/containers/HomePage/saga.ts: -------------------------------------------------------------------------------- 1 | import { hitokotoLoaded, hitokotoLoadingError, loadHitokoto } from 'containers/HomePage/actions'; 2 | import { put, takeLatest, call } from 'redux-saga/effects'; 3 | import { LOAD_HITOKOTO } from 'containers/HomePage/constants'; 4 | 5 | export async function fetchHitokoto() { 6 | return fetch('https://sslapi.hitokoto.cn/').then(res => res.json()); 7 | } 8 | 9 | export function* getHitokoto() { 10 | try { 11 | const data = yield call(fetchHitokoto); 12 | const hitokoto = data.hitokoto; 13 | yield put(hitokotoLoaded(hitokoto)); 14 | } catch (err) { 15 | yield put(hitokotoLoadingError(err)); 16 | } 17 | } 18 | 19 | export default function* hitokotoData() { 20 | yield takeLatest(LOAD_HITOKOTO, getHitokoto); 21 | yield put(loadHitokoto()); 22 | } 23 | -------------------------------------------------------------------------------- /src/containers/HomePage/selectors.ts: -------------------------------------------------------------------------------- 1 | import { IState } from 'Interfaces/state'; 2 | import { createSelector } from 'reselect'; 3 | 4 | export const selectHome = (state: IState) => state.get('home'); 5 | 6 | export const makeSelectHitokoto = () => createSelector( 7 | selectHome, 8 | (homeState) => homeState.get('hitokoto') 9 | ); 10 | -------------------------------------------------------------------------------- /src/containers/HomePage/test/__snapshots__/saga.test.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`getHitokoto Saga after requests should call the hitokotoLoadingError action if the response errors 1`] = ` 4 | Object { 5 | "@@redux-saga/IO": true, 6 | "CALL": Object { 7 | "args": Array [], 8 | "context": null, 9 | "fn": [Function], 10 | }, 11 | } 12 | `; 13 | 14 | exports[`getHitokoto Saga after requests should dispatch the hitokotoLoaded action if it requests the data successfully 1`] = ` 15 | Object { 16 | "@@redux-saga/IO": true, 17 | "CALL": Object { 18 | "args": Array [], 19 | "context": null, 20 | "fn": [Function], 21 | }, 22 | } 23 | `; 24 | -------------------------------------------------------------------------------- /src/containers/HomePage/test/index.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { shallow } from 'enzyme'; 3 | 4 | import { HomePage, mapDispatchToProps } from '../'; 5 | import Helloworld from 'components/helloworld/index'; 6 | import { loadHitokoto } from 'containers/HomePage/actions'; 7 | 8 | describe('', () => { 9 | 10 | it('should render the Helloworld', () => { 11 | const renderedComponent = shallow( 12 | 17 | ); 18 | expect(renderedComponent.contains()).toEqual(true); 19 | }); 20 | 21 | describe('mapDispatchToProps', () => { 22 | describe('changeTheme', () => { 23 | it('should be injected', () => { 24 | const dispatch = jest.fn(); 25 | const result = mapDispatchToProps(dispatch); 26 | expect(result.onGetHitokoto).toBeDefined(); 27 | }); 28 | 29 | it('should dispatch changeTheme when called', () => { 30 | const dispatch = jest.fn(); 31 | const result = mapDispatchToProps(dispatch); 32 | result.onGetHitokoto(); 33 | expect(dispatch).toHaveBeenCalledWith(loadHitokoto()); 34 | }); 35 | }); 36 | }); 37 | }); 38 | -------------------------------------------------------------------------------- /src/containers/HomePage/test/reducer.test.ts: -------------------------------------------------------------------------------- 1 | import {fromJS} from 'immutable'; 2 | 3 | import homeReducer from '../reducer'; 4 | import {loadHitokoto, hitokotoLoaded, hitokotoLoadingError} from 'containers/HomePage/actions'; 5 | 6 | describe('homeReducer', () => { 7 | let state: any; 8 | beforeEach(() => { 9 | state = fromJS({ 10 | loading: false, 11 | error: false, 12 | hitokoto: null 13 | }); 14 | }); 15 | 16 | it('should return the initial state', () => { 17 | const expectedResult = state; 18 | expect(homeReducer(undefined, {} as any)).toEqual(expectedResult); 19 | }); 20 | 21 | it('should handle the loadHitokoto action correctly', () => { 22 | const expectedResult = state 23 | .set('loading', true) 24 | .set('error', false) 25 | .set('hitokoto', null); 26 | 27 | expect(homeReducer(state, loadHitokoto())).toEqual(expectedResult); 28 | }); 29 | 30 | it('should handle the hitokotoLoaded action correctly', () => { 31 | const expectedResult = state 32 | .set('loading', false) 33 | .set('error', false) 34 | .set('hitokoto', '123'); 35 | 36 | expect(homeReducer(state, hitokotoLoaded('123'))).toEqual(expectedResult); 37 | }); 38 | 39 | it('should handle the hitokotoLoadingError action correctly', () => { 40 | const fixture = { 41 | msg: 'Not found' 42 | }; 43 | const expectedResult = state 44 | .set('error', true) 45 | .set('loading', false) 46 | .set('hitokoto', null); 47 | 48 | expect(homeReducer(state, hitokotoLoadingError(fixture))).toEqual(expectedResult); 49 | }); 50 | }); 51 | -------------------------------------------------------------------------------- /src/containers/HomePage/test/saga.test.ts: -------------------------------------------------------------------------------- 1 | import hitokotoData, {getHitokoto, fetchHitokoto} from 'containers/HomePage/saga'; 2 | import {put, takeLatest, call} from 'redux-saga/effects'; 3 | import {hitokotoLoaded, hitokotoLoadingError, loadHitokoto} from 'containers/HomePage/actions'; 4 | import {LOAD_HITOKOTO} from 'containers/HomePage/constants'; 5 | 6 | describe('getHitokoto Saga', () => { 7 | let getHitokotoGenerator: IterableIterator; 8 | 9 | it('should call the fetchHitokoto function', () => { 10 | getHitokotoGenerator = getHitokoto(); 11 | 12 | const callfetchDescriptor = getHitokotoGenerator.next().value; 13 | expect(callfetchDescriptor).toEqual(call(fetchHitokoto)); 14 | }); 15 | 16 | describe('after requests', () => { 17 | beforeEach(() => { 18 | getHitokotoGenerator = getHitokoto(); 19 | 20 | const callfetchDescriptor = getHitokotoGenerator.next().value; 21 | expect(callfetchDescriptor).toMatchSnapshot(); 22 | }); 23 | 24 | it('should dispatch the hitokotoLoaded action if it requests the data successfully', () => { 25 | const hitokoto = '123'; 26 | const putDescriptor = getHitokotoGenerator.next({hitokoto}).value; 27 | expect(putDescriptor).toEqual(put(hitokotoLoaded(hitokoto))); 28 | }); 29 | 30 | it('should call the hitokotoLoadingError action if the response errors', () => { 31 | const response = new Error('Some error'); 32 | const putDescriptor = getHitokotoGenerator.throw(response).value; 33 | expect(putDescriptor).toEqual(put(hitokotoLoadingError(response))); 34 | }); 35 | }); 36 | }); 37 | 38 | describe('hitokotoData Saga', () => { 39 | let hitokotoDataSaga: IterableIterator; 40 | 41 | beforeEach(() => { 42 | hitokotoDataSaga = hitokotoData(); 43 | }); 44 | 45 | it('should start task to watch for LOAD_HITOKOTO action', () => { 46 | const takeLatestDescriptor = hitokotoDataSaga.next().value; 47 | expect(takeLatestDescriptor).toEqual(takeLatest(LOAD_HITOKOTO, getHitokoto)); 48 | }); 49 | 50 | it('should put LOAD_HITOKOTO action', () => { 51 | hitokotoDataSaga.next(); 52 | const putDescriptor = hitokotoDataSaga.next().value; 53 | expect(putDescriptor).toEqual(put(loadHitokoto())); 54 | }); 55 | }); 56 | -------------------------------------------------------------------------------- /src/containers/HomePage/test/selectors.test.ts: -------------------------------------------------------------------------------- 1 | import { fromJS } from 'immutable'; 2 | 3 | import { 4 | selectHome, 5 | makeSelectHitokoto, 6 | } from '../selectors'; 7 | 8 | describe('selectHome', () => { 9 | it('should select the home state', () => { 10 | const homeState = fromJS({ 11 | hitokoto: '123' 12 | }); 13 | const mockedState = fromJS({ 14 | home: homeState 15 | }); 16 | expect(selectHome(mockedState)).toEqual(homeState); 17 | }); 18 | }); 19 | 20 | describe('makeSelectHitokoto', () => { 21 | const hitokotoSelector = makeSelectHitokoto(); 22 | it('should select the hitokoto', () => { 23 | const hitokoto = '123'; 24 | const mockedState = fromJS({ 25 | home: { 26 | hitokoto 27 | } 28 | }); 29 | expect(hitokotoSelector(mockedState)).toEqual(hitokoto); 30 | }); 31 | }); 32 | -------------------------------------------------------------------------------- /src/index.d.ts: -------------------------------------------------------------------------------- 1 | 2 | declare interface Window { 3 | __REDUX_DEVTOOLS_EXTENSION_COMPOSE__: any; 4 | } 5 | 6 | declare interface System { 7 | import(request: string): Promise; 8 | } 9 | 10 | declare var System: System; 11 | -------------------------------------------------------------------------------- /src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | EYHN 6 | 7 | 8 | 9 | 10 | 11 |
12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /src/main.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import { Provider } from 'react-redux'; 4 | import { ConnectedRouter } from 'react-router-redux'; 5 | import configureStore from './store'; 6 | // tslint:disable-next-line:no-import-side-effect 7 | import 'sanitize.css/sanitize.css'; 8 | import FontFaceObserver from 'fontfaceobserver'; 9 | import createHistory from 'history/createBrowserHistory'; 10 | import App from './containers/App'; 11 | 12 | const openSansObserver = new FontFaceObserver('Noto Sans', {}); 13 | 14 | openSansObserver.load().then(() => { 15 | document.body.classList.add('fontLoaded'); 16 | }, () => { 17 | document.body.classList.remove('fontLoaded'); 18 | }); 19 | 20 | const initialState = {}; 21 | const history = createHistory(); 22 | const store = configureStore(initialState, history); 23 | 24 | const MOUNT_NODE = document.getElementById('app'); 25 | 26 | const render = (Content: typeof App) => { 27 | ReactDOM.render( 28 | 29 | 30 | 31 | 32 | 33 | , MOUNT_NODE 34 | ); 35 | }; 36 | 37 | render(App); 38 | 39 | // 注入 sw 40 | if (process.env.NODE_ENV === 'production') { 41 | require('offline-plugin/runtime').install(); 42 | } 43 | 44 | if (module.hot) { 45 | module.hot.accept(['./containers/App'], () => { 46 | ReactDOM.unmountComponentAtNode(MOUNT_NODE); 47 | render(require('./containers/App').default); 48 | }); 49 | } 50 | -------------------------------------------------------------------------------- /src/reducers.ts: -------------------------------------------------------------------------------- 1 | import { fromJS } from 'immutable'; 2 | import { combineReducers } from 'redux-immutable'; 3 | import { LOCATION_CHANGE } from 'react-router-redux'; 4 | import { Action, Reducer } from 'redux'; 5 | 6 | const routeInitialState = fromJS({ 7 | location: null 8 | }); 9 | 10 | function routeReducer(state = routeInitialState, action: Action & {payload: any}) { 11 | switch (action.type) { 12 | case LOCATION_CHANGE: 13 | return state.merge({ 14 | location: action.payload 15 | }); 16 | default: 17 | return state; 18 | } 19 | } 20 | 21 | export default function createReducer(asyncReducers?: {[key: string]: Reducer}) { 22 | return combineReducers({ 23 | route: routeReducer, 24 | ...asyncReducers 25 | }); 26 | } 27 | -------------------------------------------------------------------------------- /src/store.ts: -------------------------------------------------------------------------------- 1 | import { createStore, applyMiddleware, compose } from 'redux'; 2 | import { fromJS } from 'immutable'; 3 | import { routerMiddleware } from 'react-router-redux'; 4 | import createSagaMiddleware from 'redux-saga'; 5 | import { History } from 'history'; 6 | import { IStore } from './Interfaces/store'; 7 | import createReducer from './reducers'; 8 | 9 | const sagaMiddleware = createSagaMiddleware(); 10 | 11 | export default function configureStore(initialState: any = {}, history: History) { 12 | const middlewares = [ 13 | sagaMiddleware, 14 | routerMiddleware(history) 15 | ]; 16 | 17 | const enhancers = [ 18 | applyMiddleware(...middlewares) 19 | ]; 20 | 21 | const composeEnhancers = 22 | process.env.NODE_ENV !== 'production' && 23 | typeof window === 'object' && 24 | window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ ? 25 | window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ : compose; 26 | 27 | const store = createStore( 28 | createReducer(), 29 | fromJS(initialState), 30 | composeEnhancers(...enhancers) 31 | ) as IStore; 32 | 33 | store.runSaga = sagaMiddleware.run; 34 | store.injectedReducers = {}; 35 | store.injectedSagas = {}; 36 | 37 | if (module.hot) { 38 | module.hot.accept('./reducers', () => { 39 | store.replaceReducer(createReducer(store.injectedReducers)); 40 | }); 41 | } 42 | return store; 43 | } 44 | -------------------------------------------------------------------------------- /src/test/enzymeConfigure.ts: -------------------------------------------------------------------------------- 1 | 2 | import { configure as enzymeConfigure } from 'enzyme'; 3 | import Adapter from 'enzyme-adapter-react-16'; 4 | 5 | enzymeConfigure({ adapter: new Adapter() }); 6 | -------------------------------------------------------------------------------- /src/test/fetchPolyfill.ts: -------------------------------------------------------------------------------- 1 | (global as any).fetch = require('isomorphic-fetch'); 2 | -------------------------------------------------------------------------------- /src/test/raf.ts: -------------------------------------------------------------------------------- 1 | require('raf').polyfill(); 2 | -------------------------------------------------------------------------------- /src/test/store.test.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Test store addons 3 | */ 4 | 5 | import createHistory from 'history/createBrowserHistory'; 6 | import configureStore from '../store'; 7 | import { IStore } from 'Interfaces/store'; 8 | 9 | describe('configureStore', () => { 10 | let store: IStore; 11 | 12 | beforeAll(() => { 13 | store = configureStore({}, createHistory()); 14 | }); 15 | 16 | describe('injectedReducers', () => { 17 | it('should contain an object for reducers', () => { 18 | expect(typeof store.injectedReducers).toBe('object'); 19 | }); 20 | }); 21 | 22 | describe('injectedSagas', () => { 23 | it('should contain an object for sagas', () => { 24 | expect(typeof store.injectedSagas).toBe('object'); 25 | }); 26 | }); 27 | 28 | describe('runSaga', () => { 29 | it('should contain a hook for `sagaMiddleware.run`', () => { 30 | expect(typeof store.runSaga).toBe('function'); 31 | }); 32 | }); 33 | }); 34 | 35 | describe('configureStore params', () => { 36 | it('should call window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__', () => { 37 | const compose = jest.fn(); 38 | compose.mockReturnValueOnce({}); 39 | window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ = () => () => compose; 40 | configureStore(undefined, createHistory()); 41 | expect(compose).toHaveBeenCalled(); 42 | }); 43 | }); 44 | -------------------------------------------------------------------------------- /src/utils/checkStore.ts: -------------------------------------------------------------------------------- 1 | import invariant from 'invariant'; 2 | import { IStore } from 'Interfaces/store'; 3 | 4 | /** 5 | * Validate the shape of redux store 6 | */ 7 | export default function checkStore(store: IStore) { 8 | invariant( 9 | typeof store.dispatch === 'function' && 10 | typeof store.subscribe === 'function' && 11 | typeof store.getState === 'function' && 12 | typeof store.replaceReducer === 'function' && 13 | typeof store.runSaga === 'function' && 14 | typeof store.injectedReducers === 'object' && store.injectedReducers != null && 15 | typeof store.injectedSagas === 'object' && store.injectedSagas != null, 16 | '(app/utils...) injectors: Expected a valid redux store' 17 | ); 18 | } 19 | -------------------------------------------------------------------------------- /src/utils/constants.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Restart the saga every time remount. 3 | */ 4 | export const RESTART_ON_REMOUNT = '@@saga-injector/restart-on-remount'; 5 | 6 | /** 7 | * Never stop it even if the component is removed. 8 | */ 9 | export const DAEMON = '@@saga-injector/daemon'; 10 | 11 | /** 12 | * Call once until the component is unloaded. 13 | */ 14 | export const ONCE_TILL_UNMOUNT = '@@saga-injector/once-till-unmount'; 15 | -------------------------------------------------------------------------------- /src/utils/injectReducer.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | const hoistNonReactStatics = require('hoist-non-react-statics'); 4 | 5 | import getInjectors from './reducerInjectors'; 6 | import { Reducer } from 'redux'; 7 | 8 | export default ({ key , reducer }: {key: string; reducer: Reducer}) => 9 | (WrappedComponent: React.ComponentClass | React.StatelessComponent) => { 10 | class ReducerInjector extends React.Component { 11 | static WrappedComponent = WrappedComponent; 12 | static contextTypes = { 13 | store: PropTypes.object.isRequired 14 | }; 15 | static displayName = `withReducer(${(WrappedComponent.displayName || WrappedComponent.name || 'Component')})`; 16 | injectors = getInjectors(this.context.store); 17 | 18 | componentWillMount() { 19 | const { injectReducer } = this.injectors; 20 | 21 | injectReducer(key, reducer); 22 | } 23 | 24 | render() { 25 | return ; 26 | } 27 | } 28 | 29 | return hoistNonReactStatics(ReducerInjector, WrappedComponent) as typeof WrappedComponent; 30 | }; 31 | -------------------------------------------------------------------------------- /src/utils/injectSaga.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | const hoistNonReactStatics = require('hoist-non-react-statics'); 4 | 5 | import getInjectors from './sagaInjectors'; 6 | 7 | export default ({ key, saga, mode }: {key: string; saga: () => IterableIterator; mode?: any}) => 8 | (WrappedComponent: React.ComponentClass | React.StatelessComponent) => { 9 | class InjectSaga extends React.Component { 10 | static WrappedComponent = WrappedComponent; 11 | static contextTypes = { 12 | store: PropTypes.object.isRequired 13 | }; 14 | static displayName = `withSaga(${(WrappedComponent.displayName || WrappedComponent.name || 'Component')})`; 15 | injectors = getInjectors(this.context.store); 16 | 17 | componentWillMount() { 18 | const { injectSaga } = this.injectors; 19 | 20 | injectSaga(key, { saga, mode }, this.props); 21 | } 22 | 23 | componentWillUnmount() { 24 | const { ejectSaga } = this.injectors; 25 | 26 | ejectSaga(key); 27 | } 28 | 29 | render() { 30 | return ; 31 | } 32 | } 33 | 34 | return hoistNonReactStatics(InjectSaga, WrappedComponent); 35 | }; 36 | -------------------------------------------------------------------------------- /src/utils/reducerInjectors.ts: -------------------------------------------------------------------------------- 1 | import invariant from 'invariant'; 2 | 3 | import checkStore from './checkStore'; 4 | import createReducer from '../reducers'; 5 | import { Reducer } from 'redux'; 6 | import { IStore } from 'Interfaces/store'; 7 | 8 | export function injectReducerFactory(store: IStore, isValid: boolean = false) { 9 | return (key: string, reducer: Reducer) => { 10 | if (!isValid) { checkStore(store); } 11 | 12 | invariant( 13 | typeof key === 'string' && !!key && typeof reducer === 'function', 14 | '(app/utils...) injectReducer: Expected `reducer` to be a reducer function' 15 | ); 16 | 17 | if (Reflect.has(store.injectedReducers, key) && store.injectedReducers[key] === reducer) { return; } 18 | 19 | store.injectedReducers[key] = reducer; // eslint-disable-line no-param-reassign 20 | store.replaceReducer(createReducer(store.injectedReducers)); 21 | }; 22 | } 23 | 24 | export default function getInjectors(store: IStore) { 25 | checkStore(store); 26 | 27 | return { 28 | injectReducer: injectReducerFactory(store, true) 29 | }; 30 | } 31 | -------------------------------------------------------------------------------- /src/utils/sagaInjectors.ts: -------------------------------------------------------------------------------- 1 | import invariant from 'invariant'; 2 | 3 | import checkStore from './checkStore'; 4 | import { 5 | DAEMON, 6 | ONCE_TILL_UNMOUNT, 7 | RESTART_ON_REMOUNT, 8 | } from './constants'; 9 | import { IStore } from '../Interfaces/store'; 10 | import { SagaIterator } from 'redux-saga'; 11 | 12 | const allowedModes = [RESTART_ON_REMOUNT, DAEMON, ONCE_TILL_UNMOUNT]; 13 | 14 | const checkKey = (key: string) => invariant( 15 | typeof key === 'string' && !!key, 16 | '(app/utils...) injectSaga: Expected `key` to be a non empty string' 17 | ); 18 | 19 | interface Descriptor { 20 | saga?: SagaIterator; 21 | mode?: string; 22 | } 23 | 24 | const checkDescriptor = (descriptor: Descriptor) => { 25 | invariant( 26 | typeof descriptor.saga === 'function' && allowedModes.includes(descriptor.mode) , 27 | '(app/utils...) injectSaga: Expected a valid saga descriptor' 28 | ); 29 | }; 30 | 31 | /** 32 | * Return a function to run saga. 33 | * @param store redux store 34 | * @param isValid Has the store been checked? 35 | */ 36 | export function injectSagaFactory(store: IStore, isValid: boolean = false) { 37 | return (key: string, descriptor: any = {}, args?: any) => { 38 | if (!isValid) { checkStore(store); } 39 | 40 | const newDescriptor = { ...descriptor, mode: descriptor.mode || RESTART_ON_REMOUNT }; 41 | const { saga, mode } = newDescriptor; 42 | 43 | checkKey(key); 44 | checkDescriptor(newDescriptor); 45 | 46 | let hasSaga = Reflect.has(store.injectedSagas, key); 47 | 48 | if (process.env.NODE_ENV !== 'production') { 49 | const oldDescriptor = store.injectedSagas[key]; 50 | if (hasSaga && oldDescriptor.saga !== saga) { 51 | oldDescriptor.task.cancel(); 52 | hasSaga = false; 53 | } 54 | } 55 | 56 | if (!hasSaga || (hasSaga && mode !== DAEMON && mode !== ONCE_TILL_UNMOUNT)) { 57 | store.injectedSagas[key] = { ...newDescriptor, task: store.runSaga(saga, args) }; 58 | } 59 | }; 60 | } 61 | 62 | /** 63 | * Return a function to remove saga. 64 | * @param store redux store 65 | * @param isValid Has the store been checked? 66 | */ 67 | export function ejectSagaFactory(store: IStore, isValid: boolean = false) { 68 | return (key: string) => { 69 | if (!isValid) { checkStore(store); } 70 | 71 | checkKey(key); 72 | 73 | if (Reflect.has(store.injectedSagas, key)) { 74 | const descriptor = store.injectedSagas[key]; 75 | if (descriptor.mode !== DAEMON) { 76 | descriptor.task.cancel(); 77 | if (process.env.NODE_ENV === 'production') { 78 | store.injectedSagas[key] = 'done'; 79 | } 80 | } 81 | } 82 | }; 83 | } 84 | 85 | export default function getInjectors(store: IStore) { 86 | checkStore(store); 87 | 88 | return { 89 | injectSaga: injectSagaFactory(store, true), 90 | ejectSaga: ejectSagaFactory(store, true) 91 | }; 92 | } 93 | -------------------------------------------------------------------------------- /src/utils/test/checkStore.test.ts: -------------------------------------------------------------------------------- 1 | import checkStore from '../checkStore'; 2 | 3 | describe('checkStore', () => { 4 | let store: any; 5 | 6 | const Void = {}; 7 | 8 | beforeEach(() => { 9 | store = { 10 | dispatch: () => Void, 11 | subscribe: () => Void, 12 | getState: () => Void, 13 | replaceReducer: () => Void, 14 | runSaga: () => Void, 15 | injectedReducers: {}, 16 | injectedSagas: {} 17 | }; 18 | }); 19 | 20 | it('should not throw if passed valid store shape', () => { 21 | expect(() => checkStore(store)).not.toThrow(); 22 | }); 23 | 24 | it('should throw if passed invalid store shape', () => { 25 | expect(() => checkStore(Void as any)).toThrow(); 26 | expect(() => checkStore({ ...store, injectedSagas: null })).toThrow(); 27 | expect(() => checkStore({ ...store, injectedReducers: null })).toThrow(); 28 | expect(() => checkStore({ ...store, runSaga: null })).toThrow(); 29 | expect(() => checkStore({ ...store, replaceReducer: null })).toThrow(); 30 | }); 31 | }); 32 | -------------------------------------------------------------------------------- /src/utils/test/injectReducer.test.tsx: -------------------------------------------------------------------------------- 1 | import { shallow } from 'enzyme'; 2 | import React from 'react'; 3 | import { createMemoryHistory } from 'history'; 4 | 5 | import configureStore from 'store'; 6 | import injectReducer from '../injectReducer'; 7 | import * as reducerInjectors from '../reducerInjectors'; 8 | import { IStore } from 'Interfaces/store'; 9 | import { Reducer } from 'redux'; 10 | 11 | const Component: React.SFC = () => null; 12 | 13 | const reducer = (a: any) => (a); 14 | 15 | describe('injectReducer decorator', () => { 16 | let store: IStore; 17 | let injectors: { injectReducer: Reducer }; 18 | let ComponentWithReducer: React.ComponentClass; 19 | 20 | beforeAll(() => { 21 | (reducerInjectors as any).default = jest.fn().mockImplementation(() => injectors); 22 | }); 23 | 24 | beforeEach(() => { 25 | store = configureStore({}, createMemoryHistory()); 26 | injectors = { 27 | injectReducer: jest.fn() 28 | }; 29 | ComponentWithReducer = injectReducer({ key: 'test', reducer })(Component) as React.ComponentClass; 30 | (reducerInjectors.default as jest.Mock).mockClear(); 31 | }); 32 | 33 | it('should inject a given reducer', () => { 34 | shallow(, { context: { store } }); 35 | 36 | expect(injectors.injectReducer).toHaveBeenCalledTimes(1); 37 | expect(injectors.injectReducer).toHaveBeenCalledWith('test', reducer); 38 | }); 39 | 40 | it('should set a correct display name', () => { 41 | expect(ComponentWithReducer.displayName).toBe('withReducer(Component)'); 42 | expect(injectReducer({ key: 'test', reducer })(() => null).displayName).toBe('withReducer(Component)'); 43 | }); 44 | 45 | it('should propagate props', () => { 46 | const props = { testProp: 'test' }; 47 | const renderedComponent = shallow(, { context: { store } }); 48 | 49 | expect(renderedComponent.prop('testProp')).toBe('test'); 50 | }); 51 | }); 52 | -------------------------------------------------------------------------------- /src/utils/test/injectSaga.test.tsx: -------------------------------------------------------------------------------- 1 | import { createMemoryHistory } from 'history'; 2 | import { put } from 'redux-saga/effects'; 3 | import { shallow } from 'enzyme'; 4 | import React from 'react'; 5 | 6 | import configureStore from 'store'; 7 | import injectSaga from '../injectSaga'; 8 | import * as sagaInjectors from '../sagaInjectors'; 9 | import { IStore } from 'Interfaces/store'; 10 | 11 | const Component: React.SFC = () => null; 12 | 13 | function* testSaga() { 14 | yield put({ type: 'TEST', payload: 'yup' }); 15 | } 16 | 17 | describe('injectSaga decorator', () => { 18 | let store: IStore; 19 | let injectors: { injectSaga: () => any; ejectSaga: () => any }; 20 | let ComponentWithSaga: React.ComponentClass<{test: string}>; 21 | 22 | beforeAll(() => { 23 | (sagaInjectors as any).default = jest.fn().mockImplementation(() => injectors); 24 | }); 25 | 26 | beforeEach(() => { 27 | store = configureStore({}, createMemoryHistory()); 28 | injectors = { 29 | injectSaga: jest.fn(), 30 | ejectSaga: jest.fn() 31 | }; 32 | ComponentWithSaga = 33 | injectSaga({ key: 'test', saga: testSaga, mode: 'testMode' })(Component); 34 | (sagaInjectors.default as jest.Mock).mockClear(); 35 | }); 36 | 37 | it('should inject given saga, mode, and props', () => { 38 | const props = { test: 'test' }; 39 | shallow(, { context: { store } }); 40 | 41 | expect(injectors.injectSaga).toHaveBeenCalledTimes(1); 42 | expect(injectors.injectSaga).toHaveBeenCalledWith('test', { saga: testSaga, mode: 'testMode' }, props); 43 | }); 44 | 45 | it('should eject on unmount with a correct saga key', () => { 46 | const props = { test: 'test' }; 47 | const renderedComponent = shallow(, { context: { store } }); 48 | renderedComponent.unmount(); 49 | 50 | expect(injectors.ejectSaga).toHaveBeenCalledTimes(1); 51 | expect(injectors.ejectSaga).toHaveBeenCalledWith('test'); 52 | }); 53 | 54 | it('should set a correct display name', () => { 55 | expect(ComponentWithSaga.displayName).toBe('withSaga(Component)'); 56 | expect(injectSaga({ key: 'test', saga: testSaga })(() => null).displayName).toBe('withSaga(Component)'); 57 | }); 58 | 59 | it('should propagate props', () => { 60 | const props = { test: 'test' }; 61 | const renderedComponent = shallow(, { context: { store } }); 62 | 63 | expect(renderedComponent.prop('test')).toBe('test'); 64 | }); 65 | }); 66 | -------------------------------------------------------------------------------- /src/utils/test/reducerInjectors.test.ts: -------------------------------------------------------------------------------- 1 | import { createMemoryHistory } from 'history'; 2 | import { fromJS } from 'immutable'; 3 | import configureStore from '../../store'; 4 | 5 | import getInjectors, { 6 | injectReducerFactory, 7 | } from '../reducerInjectors'; 8 | import { Action } from 'redux'; 9 | import { IStore } from '../../Interfaces/store'; 10 | import { $Call } from 'utility-types'; 11 | 12 | const initialState = fromJS({ reduced: 'soon' }); 13 | 14 | const reducer = (state = initialState, action: Action & { payload: any }) => { 15 | switch (action.type) { 16 | case 'TEST': 17 | return state.set('reduced', action.payload); 18 | default: 19 | return state; 20 | } 21 | }; 22 | 23 | describe('reducer injectors', () => { 24 | let store: IStore; 25 | let injectReducer: $Call; 26 | 27 | describe('getInjectors', () => { 28 | beforeEach(() => { 29 | store = configureStore({}, createMemoryHistory()); 30 | }); 31 | 32 | it('should return injectors', () => { 33 | expect(getInjectors(store)).toEqual(expect.objectContaining({ 34 | injectReducer: expect.any(Function) 35 | })); 36 | }); 37 | 38 | it('should throw if passed invalid store shape', () => { 39 | Reflect.deleteProperty(store, 'dispatch'); 40 | 41 | expect(() => getInjectors(store)).toThrow(); 42 | }); 43 | }); 44 | 45 | describe('injectReducer helper', () => { 46 | beforeEach(() => { 47 | store = configureStore({}, createMemoryHistory()); 48 | injectReducer = injectReducerFactory(store, true); 49 | }); 50 | 51 | it('should check a store if the second argument is falsy', () => { 52 | const inject = injectReducerFactory({} as any); 53 | 54 | expect(() => inject('test', reducer)).toThrow(); 55 | }); 56 | 57 | it('it should not check a store if the second argument is true', () => { 58 | Reflect.deleteProperty(store, 'dispatch'); 59 | 60 | expect(() => injectReducer('test', reducer)).not.toThrow(); 61 | }); 62 | 63 | it('should validate a reducer and reducer\'s key', () => { 64 | expect(() => injectReducer('', reducer)).toThrow(); 65 | expect(() => injectReducer(1 as any, reducer)).toThrow(); 66 | expect(() => injectReducer(1 as any, 1 as any)).toThrow(); 67 | }); 68 | 69 | it('given a store, it should provide a function to inject a reducer', () => { 70 | injectReducer('test', reducer); 71 | 72 | const actual = store.getState().get('test'); 73 | const expected = initialState; 74 | 75 | expect(actual.toJS()).toEqual(expected.toJS()); 76 | }); 77 | 78 | it('should not assign reducer if already existing', () => { 79 | store.replaceReducer = jest.fn(); 80 | injectReducer('test', reducer); 81 | injectReducer('test', reducer); 82 | 83 | expect(store.replaceReducer).toHaveBeenCalledTimes(1); 84 | }); 85 | 86 | it('should assign reducer if different implementation for hot reloading', () => { 87 | store.replaceReducer = jest.fn(); 88 | injectReducer('test', reducer); 89 | injectReducer('test', (a) => a); 90 | 91 | expect(store.replaceReducer).toHaveBeenCalledTimes(2); 92 | }); 93 | }); 94 | }); 95 | -------------------------------------------------------------------------------- /src/utils/test/sagaInjectors.test.ts: -------------------------------------------------------------------------------- 1 | import { createMemoryHistory } from 'history'; 2 | import { put } from 'redux-saga/effects'; 3 | 4 | import configureStore from '../../store'; 5 | import getInjectors, { 6 | injectSagaFactory, 7 | ejectSagaFactory, 8 | } from '../sagaInjectors'; 9 | import { 10 | DAEMON, 11 | ONCE_TILL_UNMOUNT, 12 | RESTART_ON_REMOUNT, 13 | } from '../constants'; 14 | import { IStore } from '../../Interfaces/store'; 15 | import { $Call } from 'utility-types'; 16 | 17 | function* testSaga() { 18 | yield put({ type: 'TEST', payload: 'yup' }); 19 | } 20 | 21 | describe('injectors', () => { 22 | const originalNodeEnv = process.env.NODE_ENV; 23 | let store: IStore; 24 | let injectSaga: $Call; 25 | let ejectSaga: $Call; 26 | 27 | describe('getInjectors', () => { 28 | beforeEach(() => { 29 | store = configureStore({}, createMemoryHistory()); 30 | }); 31 | 32 | it('should return injectors', () => { 33 | expect(getInjectors(store)).toEqual(expect.objectContaining({ 34 | injectSaga: expect.any(Function), 35 | ejectSaga: expect.any(Function) 36 | })); 37 | }); 38 | 39 | it('should throw if passed invalid store shape', () => { 40 | Reflect.deleteProperty(store, 'dispatch'); 41 | 42 | expect(() => getInjectors(store)).toThrow(); 43 | }); 44 | }); 45 | 46 | describe('ejectSaga helper', () => { 47 | beforeEach(() => { 48 | store = configureStore({}, createMemoryHistory()); 49 | injectSaga = injectSagaFactory(store, true); 50 | ejectSaga = ejectSagaFactory(store, true); 51 | }); 52 | 53 | it('should check a store if the second argument is falsy', () => { 54 | const eject = ejectSagaFactory({} as any); 55 | 56 | expect(() => eject('test')).toThrow(); 57 | }); 58 | 59 | it('should not check a store if the second argument is true', () => { 60 | Reflect.deleteProperty(store, 'dispatch'); 61 | injectSaga('test', { saga: testSaga }); 62 | 63 | expect(() => ejectSaga('test')).not.toThrow(); 64 | }); 65 | 66 | it('should validate saga\'s key', () => { 67 | expect(() => ejectSaga('')).toThrow(); 68 | expect(() => ejectSaga(1 as any)).toThrow(); 69 | }); 70 | 71 | it('should cancel a saga in a default mode', () => { 72 | const cancel = jest.fn(); 73 | store.injectedSagas.test = { task: { cancel } }; 74 | ejectSaga('test'); 75 | 76 | expect(cancel).toHaveBeenCalled(); 77 | }); 78 | 79 | it('should not cancel a daemon saga', () => { 80 | const cancel = jest.fn(); 81 | store.injectedSagas.test = { task: { cancel }, mode: DAEMON }; 82 | ejectSaga('test'); 83 | 84 | expect(cancel).not.toHaveBeenCalled(); 85 | }); 86 | 87 | it('should ignore saga that was not previously injected', () => { 88 | expect(() => ejectSaga('test')).not.toThrow(); 89 | }); 90 | 91 | it('should remove non daemon saga\'s descriptor in production', () => { 92 | process.env.NODE_ENV = 'production'; 93 | injectSaga('test', { saga: testSaga, mode: RESTART_ON_REMOUNT }); 94 | injectSaga('test1', { saga: testSaga, mode: ONCE_TILL_UNMOUNT }); 95 | 96 | ejectSaga('test'); 97 | ejectSaga('test1'); 98 | 99 | expect(store.injectedSagas.test).toBe('done'); 100 | expect(store.injectedSagas.test1).toBe('done'); 101 | process.env.NODE_ENV = originalNodeEnv; 102 | }); 103 | 104 | it('should not remove daemon saga\'s descriptor in production', () => { 105 | process.env.NODE_ENV = 'production'; 106 | injectSaga('test', { saga: testSaga, mode: DAEMON }); 107 | ejectSaga('test'); 108 | 109 | expect(store.injectedSagas.test.saga).toBe(testSaga); 110 | process.env.NODE_ENV = originalNodeEnv; 111 | }); 112 | 113 | it('should not remove daemon saga\'s descriptor in development', () => { 114 | injectSaga('test', { saga: testSaga, mode: DAEMON }); 115 | ejectSaga('test'); 116 | 117 | expect(store.injectedSagas.test.saga).toBe(testSaga); 118 | }); 119 | }); 120 | 121 | describe('injectSaga helper', () => { 122 | beforeEach(() => { 123 | store = configureStore({}, createMemoryHistory()); 124 | injectSaga = injectSagaFactory(store, true); 125 | ejectSaga = ejectSagaFactory(store, true); 126 | }); 127 | 128 | it('should check a store if the second argument is falsy', () => { 129 | const inject = injectSagaFactory({} as any); 130 | 131 | expect(() => inject('test', testSaga)).toThrow(); 132 | }); 133 | 134 | it('it should not check a store if the second argument is true', () => { 135 | Reflect.deleteProperty(store, 'dispatch'); 136 | 137 | expect(() => injectSaga('test', { saga: testSaga })).not.toThrow(); 138 | }); 139 | 140 | it('should validate saga\'s key', () => { 141 | expect(() => injectSaga('', { saga: testSaga })).toThrow(); 142 | expect(() => injectSaga(1 as any, { saga: testSaga })).toThrow(); 143 | }); 144 | 145 | it('should validate saga\'s descriptor', () => { 146 | expect(() => injectSaga('test')).toThrow(); 147 | expect(() => injectSaga('test', { saga: 1 })).toThrow(); 148 | expect(() => injectSaga('test', { saga: testSaga, mode: 'testMode' })).toThrow(); 149 | expect(() => injectSaga('test', { saga: testSaga, mode: 1 })).toThrow(); 150 | expect(() => injectSaga('test', { saga: testSaga, mode: RESTART_ON_REMOUNT })).not.toThrow(); 151 | expect(() => injectSaga('test', { saga: testSaga, mode: DAEMON })).not.toThrow(); 152 | expect(() => injectSaga('test', { saga: testSaga, mode: ONCE_TILL_UNMOUNT })).not.toThrow(); 153 | }); 154 | 155 | it('should pass args to saga.run', () => { 156 | const args = {}; 157 | store.runSaga = jest.fn(); 158 | injectSaga('test', { saga: testSaga }, args); 159 | 160 | expect(store.runSaga).toHaveBeenCalledWith(testSaga, args); 161 | }); 162 | 163 | it('should not start daemon and once-till-unmount sagas if were started before', () => { 164 | store.runSaga = jest.fn(); 165 | 166 | injectSaga('test1', { saga: testSaga, mode: DAEMON }); 167 | injectSaga('test1', { saga: testSaga, mode: DAEMON }); 168 | injectSaga('test2', { saga: testSaga, mode: ONCE_TILL_UNMOUNT }); 169 | injectSaga('test2', { saga: testSaga, mode: ONCE_TILL_UNMOUNT }); 170 | 171 | expect(store.runSaga).toHaveBeenCalledTimes(2); 172 | }); 173 | 174 | it('should start any saga that was not started before', () => { 175 | store.runSaga = jest.fn(); 176 | 177 | injectSaga('test1', { saga: testSaga }); 178 | injectSaga('test2', { saga: testSaga, mode: DAEMON }); 179 | injectSaga('test3', { saga: testSaga, mode: ONCE_TILL_UNMOUNT }); 180 | 181 | expect(store.runSaga).toHaveBeenCalledTimes(3); 182 | }); 183 | 184 | it('should restart a saga if different implementation for hot reloading', () => { 185 | const cancel = jest.fn(); 186 | store.injectedSagas.test = { saga: testSaga, task: { cancel } }; 187 | store.runSaga = jest.fn(); 188 | 189 | function* testSaga1() { 190 | yield put({ type: 'TEST', payload: 'yup' }); 191 | } 192 | 193 | injectSaga('test', { saga: testSaga1 }); 194 | 195 | expect(cancel).toHaveBeenCalledTimes(1); 196 | expect(store.runSaga).toHaveBeenCalledWith(testSaga1, undefined); 197 | }); 198 | 199 | it('should not cancel saga if different implementation in production', () => { 200 | process.env.NODE_ENV = 'production'; 201 | const cancel = jest.fn(); 202 | store.injectedSagas.test = { saga: testSaga, task: { cancel }, mode: RESTART_ON_REMOUNT }; 203 | 204 | function* testSaga1() { 205 | yield put({ type: 'TEST', payload: 'yup' }); 206 | } 207 | 208 | injectSaga('test', { saga: testSaga1, mode: DAEMON }); 209 | 210 | expect(cancel).toHaveBeenCalledTimes(0); 211 | process.env.NODE_ENV = originalNodeEnv; 212 | }); 213 | 214 | it('should save an entire descriptor in the saga registry', () => { 215 | injectSaga('test', { saga: testSaga, foo: 'bar' }); 216 | expect(store.injectedSagas.test.foo).toBe('bar'); 217 | }); 218 | }); 219 | }); 220 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "outDir": "./dist/", 4 | "baseUrl": "./src", 5 | "sourceMap": true, 6 | "noImplicitAny": true, 7 | "module": "esnext", 8 | "target": "esnext", 9 | "jsx": "preserve", 10 | "experimentalDecorators": true, 11 | "allowSyntheticDefaultImports": true, 12 | "noUnusedLocals": true, 13 | "noUnusedParameters": true, 14 | "moduleResolution": "node", 15 | "typeRoots": [ 16 | "./node_modules/@types" 17 | ] 18 | }, 19 | "include": [ 20 | "src/**/*" 21 | ] 22 | } -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["tslint:recommended", "tslint-react"], 3 | "rules": { 4 | "arrow-parens": false, 5 | "arrow-return-shorthand": [false], 6 | "comment-format": [true, "check-space"], 7 | "import-blacklist": [true, "rxjs"], 8 | "interface-over-type-literal": false, 9 | "interface-name": false, 10 | "max-line-length": [true, 120], 11 | "member-access": false, 12 | "member-ordering": [true, { "order": "fields-first" }], 13 | "newline-before-return": false, 14 | "no-any": false, 15 | "no-empty-interface": false, 16 | "no-import-side-effect": [true], 17 | "no-inferrable-types": [true, "ignore-params", "ignore-properties"], 18 | "no-invalid-this": [true, "check-function-in-method"], 19 | "no-null-keyword": false, 20 | "no-require-imports": false, 21 | "no-submodule-imports": false, 22 | "no-this-assignment": [true, { "allow-destructuring": true }], 23 | "no-trailing-whitespace": true, 24 | "no-var-requires": false, 25 | "object-literal-sort-keys": false, 26 | "object-literal-shorthand": false, 27 | "one-variable-per-declaration": [false], 28 | "only-arrow-functions": [true, "allow-declarations"], 29 | "ordered-imports": [false], 30 | "prefer-method-signature": false, 31 | "prefer-template": [true, "allow-single-concat"], 32 | "quotemark": [true, "single"], 33 | "semicolon": [true, "always"], 34 | "trailing-comma": [true, { 35 | "singleline": "never", 36 | "multiline": { 37 | "objects": "never", 38 | "arrays": "never", 39 | "functions": "never", 40 | "typeLiterals": "ignore" 41 | }, 42 | "esSpecCompliant": true 43 | }], 44 | "triple-equals": [true, "allow-null-check"], 45 | "type-literal-delimiter": true, 46 | "typedef": [true, "property-declaration"], 47 | "variable-name": [true, "ban-keywords", "check-format", "allow-pascal-case", "allow-leading-underscore"], 48 | "jsx-no-lambda": false, 49 | "jsx-boolean-value" : ["never"] 50 | } 51 | } -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | var webpack = require('webpack'); 2 | var path = require('path'); 3 | 4 | var HtmlWebpackPlugin = require('html-webpack-plugin'); 5 | 6 | var HtmlWebpackConfig = { 7 | title: 'react', 8 | filename: 'index.html', 9 | template: "./src/index.html", 10 | hash: true, 11 | showErrors: true 12 | }; 13 | 14 | 15 | module.exports = { 16 | mode: "development", 17 | entry: [ 18 | './src/main.tsx' 19 | ], 20 | output: { 21 | filename: "bundle.js", 22 | chunkFilename: "[name].js", 23 | path: __dirname + "/dist" 24 | }, 25 | 26 | devtool: "source-map", 27 | 28 | plugins: [ 29 | new HtmlWebpackPlugin(HtmlWebpackConfig) 30 | ], 31 | 32 | resolve: { 33 | extensions: [".ts", ".tsx", ".js", ".jsx"], 34 | modules: [path.resolve(__dirname, "src"), "node_modules"] 35 | }, 36 | 37 | module: { 38 | rules: [ 39 | { 40 | test: /\.(ts|tsx)?$/, 41 | use: [ 42 | { 43 | loader: "awesome-typescript-loader", 44 | options: { 45 | useBabel: true, 46 | reportFiles: [ 47 | "src/**/!(test)/*" 48 | ] 49 | } 50 | } 51 | ] 52 | }, 53 | { 54 | test: /\.(css)$/, 55 | use: [ 56 | { 57 | loader: 'style-loader' 58 | }, 59 | { 60 | loader: "css-loader" 61 | }, 62 | { 63 | loader: "postcss-loader", 64 | options: { 65 | sourceMap: true 66 | } 67 | } 68 | ] 69 | }, 70 | { 71 | test: /\.(png|jpg)$/, 72 | use: [{ 73 | loader: 'url-loader', 74 | options: { 75 | limit: 8192 76 | } 77 | }] 78 | }, 79 | { 80 | test: /\.(ttf|otf|woff|woff2|eot)$/, 81 | use: [{ 82 | loader: 'url-loader', 83 | options: { 84 | limit: 8192 85 | } 86 | }] 87 | }, 88 | { 89 | test: /\.(js|jsx)$/, 90 | exclude: path.resolve(__dirname, "node_modules"), 91 | use: [ 92 | { 93 | loader: 'babel-loader' 94 | } 95 | ], 96 | } 97 | ] 98 | }, 99 | devServer: { 100 | port: process.env.PORT || 8888, 101 | host: 'localhost', 102 | publicPath: '/', 103 | contentBase: path.resolve(__dirname, "src"), 104 | historyApiFallback: true, 105 | open: true, 106 | headers: { 107 | "access-control-allow-origin":"*" 108 | } 109 | } 110 | } -------------------------------------------------------------------------------- /webpack.config.prod.js: -------------------------------------------------------------------------------- 1 | var webpack = require('webpack'); 2 | var path = require('path'); 3 | var HtmlWebpackPlugin = require('html-webpack-plugin'); 4 | var OfflinePlugin = require('offline-plugin'); 5 | var BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin; 6 | 7 | var HtmlWebpackConfig = { 8 | title: 'hexo', 9 | filename: 'index.html', 10 | template: "./src/index.html", 11 | hash: true, 12 | showErrors: true, 13 | minify: { 14 | removeComments: true, 15 | collapseWhitespace: true, 16 | removeRedundantAttributes: true, 17 | useShortDoctype: true, 18 | removeEmptyAttributes: true, 19 | removeStyleLinkTypeAttributes: true, 20 | keepClosingSlash: true, 21 | minifyJS: true, 22 | minifyCSS: true, 23 | minifyURLs: true 24 | }, 25 | }; 26 | 27 | const plugins = [ 28 | new OfflinePlugin({}), 29 | new HtmlWebpackPlugin(HtmlWebpackConfig) 30 | ]; 31 | 32 | if (process.env.BUILD_ANALYZER) { 33 | plugins.push(new BundleAnalyzerPlugin()) 34 | } 35 | 36 | module.exports = { 37 | mode: "production", 38 | entry: [ 39 | './src/main.tsx' 40 | ], 41 | output: { 42 | filename: "bundle.js", 43 | chunkFilename: "[name].js", 44 | path: __dirname + "/dist" 45 | }, 46 | 47 | devtool: "source-map", 48 | 49 | plugins: plugins, 50 | 51 | resolve: { 52 | extensions: [".ts", ".tsx", ".js", ".jsx"], 53 | modules: [path.resolve(__dirname, "src"), "node_modules"] 54 | }, 55 | 56 | module: { 57 | rules: [ 58 | { 59 | test: /\.(ts|tsx)?$/, 60 | use: [ 61 | { 62 | loader: "awesome-typescript-loader", 63 | options: { 64 | useBabel: true, 65 | reportFiles: [ 66 | "src/**/!(test)/*" 67 | ] 68 | } 69 | } 70 | ] 71 | }, 72 | { 73 | test: /\.(css)$/, 74 | use: [ 75 | { 76 | loader: 'style-loader' 77 | }, 78 | { 79 | loader: "css-loader" 80 | }, 81 | { 82 | loader: "postcss-loader", 83 | options: { 84 | sourceMap: true 85 | } 86 | } 87 | ] 88 | }, 89 | { 90 | test: /\.(png|jpg)$/, 91 | use: [{ 92 | loader: 'url-loader', 93 | options: { 94 | limit: 8192 95 | } 96 | }] 97 | }, 98 | { 99 | test: /\.(ttf|otf|woff|woff2|eot)$/, 100 | use: [{ 101 | loader: 'url-loader', 102 | options: { 103 | limit: 8192 104 | } 105 | }] 106 | }, 107 | { 108 | test: /\.(js|jsx)$/, 109 | exclude: path.resolve(__dirname, "node_modules"), 110 | use: [ 111 | { 112 | loader: 'babel-loader' 113 | } 114 | ], 115 | } 116 | ] 117 | } 118 | } --------------------------------------------------------------------------------