├── .babelrc ├── .circleci └── config.yml ├── .codeclimate.yml ├── .eslintrc.json ├── .gitignore ├── .npmignore ├── CHANGELOG.md ├── LICENSE ├── README.md ├── npm-shrinkwrap.json ├── package.json ├── src ├── RouteDispatcher.js ├── __tests__ │ ├── RouteDispatcher.test.js │ ├── dispatchRouteActions.test.js │ ├── enzyme.js │ └── withActions.test.js ├── createRouteDispatchers.js ├── defineRoutes.js ├── dispatchRouteActions.js ├── index.js └── withActions.js └── yarn.lock /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [["es2015", { "loose": true }], "stage-0", "react"], 3 | "env": { 4 | "production": { 5 | "presets": ["react-optimize", ["es2015", {"loose": true}], "react", "stage-0"], 6 | "plugins": ["transform-react-remove-prop-types", "transform-runtime"] 7 | } 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | # Javascript Node CircleCI 2.0 configuration file 2 | # 3 | # Check https://circleci.com/docs/2.0/language-javascript/ for more details 4 | # 5 | version: 2 6 | jobs: 7 | build: 8 | environment: 9 | CC_TEST_REPORTER_ID: 0b974da9715502db1fb0a919505f7ac77cdea995479b81fd25cbc38cc52fed2a 10 | docker: 11 | - image: node:8.4 12 | working_directory: ~/react-router-dispatcher 13 | steps: 14 | - checkout 15 | - run: 16 | name: "~/.npmrc" 17 | command: echo "//registry.npmjs.org/:_authToken=$NPM_TOKEN" >> ~/.npmrc 18 | - run: 19 | name: "Checking Versions" 20 | command: | 21 | node --version 22 | npm --version 23 | - restore_cache: 24 | key: dependency-cache-{{ checksum "package.json" }} 25 | - run: 26 | name: "npm install" 27 | command: npm install 28 | - save_cache: 29 | key: dependency-cache-{{ checksum "package.json" }} 30 | paths: 31 | - ./node_modules 32 | - run: 33 | command: | 34 | mkdir -p ./.build 35 | mkdir -p ./.build/lint 36 | mkdir -p ./.build/coverage 37 | mkdir -p ./.build/test 38 | mkdir -p ./coverage 39 | when: always 40 | - run: 41 | name: lint 42 | command: npm run ci-lint 43 | - run: 44 | name: "install code climate" 45 | command: | 46 | curl -L https://codeclimate.com/downloads/test-reporter/test-reporter-latest-linux-amd64 > ./cc-test-reporter 47 | chmod +x ./cc-test-reporter 48 | - run: 49 | name: test 50 | command: | 51 | # Prepare the test reporter 52 | ./cc-test-reporter before-build 53 | 54 | # Run the tests 55 | npm run ci-jest 56 | COVERAGE_EXIT_CODE=$? 57 | 58 | # Copy the coverage file for reporting purposes 59 | cp ./.build/coverage/lcov.info ./coverage 60 | 61 | # Prevent errors when re-building on the CI server (reports previously uploaded) 62 | set +e 63 | ./cc-test-reporter after-build -t lcov --exit-code $COVERAGE_EXIT_CODE 64 | REPORTER_EXIT_CODE=$? 65 | set -e 66 | if [ "$REPORTER_EXIT_CODE" != "0" ] && [ "$REPORTER_EXIT_CODE" != "255" ]; then 67 | exit $$REPORTER_EXIT_CODE 68 | fi 69 | - store_test_results: 70 | path: ./.build/test/test-report.xml 71 | - store_artifacts: 72 | path: ./.build/test 73 | prefix: "test" 74 | - store_artifacts: 75 | path: ./.build/lint 76 | prefix: "lint" 77 | - store_artifacts: 78 | path: ./.build/coverage 79 | prefix: "coverage" 80 | - deploy: 81 | name: Maybe Deploy 82 | command: | 83 | if [ "${CIRCLE_BRANCH}" == "master" ]; then 84 | git config -l 85 | git config user.email ci@circleci 86 | git config user.name CircleCI 87 | 88 | # Build for release 89 | npm run build 90 | 91 | # Tag the release, update package.json version and changelog, commit and push to github. 92 | ./node_modules/.bin/standard-version --no-verify -m "chore(release): %s. [skip ci]" 93 | git push --follow-tags origin master 94 | npm publish 95 | fi 96 | -------------------------------------------------------------------------------- /.codeclimate.yml: -------------------------------------------------------------------------------- 1 | 2 | engines: 3 | 4 | fixme: 5 | enabled: true 6 | 7 | nodesecurity: 8 | enabled: true 9 | 10 | eslint: 11 | enabled: true 12 | channel: "eslint-3" 13 | 14 | duplication: 15 | enabled: true 16 | config: 17 | languages: 18 | - javascript: 19 | 20 | ratings: 21 | paths: 22 | - "src/**/*" 23 | - "**.js" 24 | 25 | exclude_paths: 26 | - "lib/**/*" 27 | - "__tests__/**/*" 28 | - "src/__tests__/*" 29 | - "src/__tests__/**/*" 30 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "babel-eslint", 3 | "parserOptions": { 4 | "ecmaVersion": 6, 5 | "sourceType": "module", 6 | "ecmaFeatures": { 7 | "jsx": true, 8 | "experimentalObjectRestSpread": true 9 | } 10 | }, 11 | "rules": { 12 | "strict": 0 13 | }, 14 | "env": { 15 | "es6": true, 16 | "browser": true, 17 | "node": true, 18 | "jest": true 19 | }, 20 | "extends": [ 21 | "eslint:recommended", 22 | "plugin:react/recommended", 23 | "plugin:jest/recommended" 24 | ], 25 | "plugins": ["jest"] 26 | } 27 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | lib 3 | .idea 4 | npm-debug.log 5 | coverage/ 6 | .build/ 7 | test-report.xml -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | .babelrc 3 | .eslintrc 4 | scripts 5 | .idea 6 | .circleci 7 | docs/ 8 | src/ 9 | .build/ 10 | coverage/ 11 | jest_*/ 12 | .codeclimate.yml 13 | npm-shrinkwrap.json 14 | yarn.lock 15 | cc-test-reporter 16 | __tests__ -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | 3 | All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines. 4 | 5 | 6 | # [6.2.0](https://github.com/adam-26/react-router-dispatcher/compare/v6.1.1...v6.2.0) (2018-03-02) 7 | 8 | 9 | ### Features 10 | 11 | * **release:** Bump Minor ([#65](https://github.com/adam-26/react-router-dispatcher/issues/65)) ([32f8259](https://github.com/adam-26/react-router-dispatcher/commit/32f8259)) 12 | 13 | 14 | 15 | 16 | ## [6.1.1](https://github.com/adam-26/react-router-dispatcher/compare/v6.1.0...v6.1.1) (2018-03-01) 17 | 18 | 19 | 20 | 21 | # [6.1.0](https://github.com/adam-26/react-router-dispatcher/compare/v6.0.1...v6.1.0) (2018-03-01) 22 | 23 | 24 | ### Features 25 | 26 | * **api:** Add arg to action hoc ([#63](https://github.com/adam-26/react-router-dispatcher/issues/63)) ([0bb0ca0](https://github.com/adam-26/react-router-dispatcher/commit/0bb0ca0)) 27 | 28 | 29 | 30 | 31 | ## [6.0.1](https://github.com/adam-26/react-router-dispatcher/compare/v6.0.0...v6.0.1) (2018-03-01) 32 | 33 | 34 | ### Bug Fixes 35 | 36 | * **code:** Lifecycle dispatch ([#62](https://github.com/adam-26/react-router-dispatcher/issues/62)) ([f063f80](https://github.com/adam-26/react-router-dispatcher/commit/f063f80)) 37 | 38 | 39 | 40 | 41 | # [6.0.0](https://github.com/adam-26/react-router-dispatcher/compare/v2.7.1...v6.0.0) (2018-02-28) 42 | 43 | 44 | * fix-mappingApi (#59) ([99276e3](https://github.com/adam-26/react-router-dispatcher/commit/99276e3)) 45 | 46 | 47 | ### Bug Fixes 48 | 49 | * **build:** tag conflict ([#60](https://github.com/adam-26/react-router-dispatcher/issues/60)) ([74ad319](https://github.com/adam-26/react-router-dispatcher/commit/74ad319)) 50 | 51 | 52 | ### BREAKING CHANGES 53 | 54 | * Modify API to better support component specific mapping functions. Merge all props to single arg so the API is more similar react. 55 | 56 | 57 | 58 | 59 | ## [2.7.1](https://github.com/adam-26/react-router-dispatcher/compare/v2.7.0...v2.7.1) (2018-02-27) 60 | 61 | 62 | ### Bug Fixes 63 | 64 | * **docs:** readme ([#58](https://github.com/adam-26/react-router-dispatcher/issues/58)) ([d2abd63](https://github.com/adam-26/react-router-dispatcher/commit/d2abd63)) 65 | 66 | 67 | 68 | 69 | # [2.7.0](https://github.com/adam-26/react-router-dispatcher/compare/v2.6.0...v2.7.0) (2018-02-27) 70 | 71 | 72 | ### Features 73 | 74 | * **api:** Allow mapParamsToProps to be optional ([#57](https://github.com/adam-26/react-router-dispatcher/issues/57)) ([91695e5](https://github.com/adam-26/react-router-dispatcher/commit/91695e5)) 75 | 76 | 77 | 78 | 79 | # [2.6.0](https://github.com/adam-26/react-router-dispatcher/compare/v2.0.3...v2.6.0) (2018-02-26) 80 | 81 | 82 | ### Bug Fixes 83 | 84 | * **build:** bump version ([#56](https://github.com/adam-26/react-router-dispatcher/issues/56)) ([41f0357](https://github.com/adam-26/react-router-dispatcher/commit/41f0357)) 85 | 86 | 87 | ### Features 88 | 89 | * **api:** success/error handlers ([#55](https://github.com/adam-26/react-router-dispatcher/issues/55)) ([3e9b58b](https://github.com/adam-26/react-router-dispatcher/commit/3e9b58b)) 90 | 91 | 92 | 93 | 94 | ## [2.0.3](https://github.com/adam-26/react-router-dispatcher/compare/v2.0.2...v2.0.3) (2018-01-31) 95 | 96 | 97 | ### Bug Fixes 98 | 99 | * **code:** Prevent unnecessary renders ([#54](https://github.com/adam-26/react-router-dispatcher/issues/54)) ([e5f2bda](https://github.com/adam-26/react-router-dispatcher/commit/e5f2bda)) 100 | 101 | 102 | 103 | 104 | ## [2.0.2](https://github.com/adam-26/react-router-dispatcher/compare/v2.0.1...v2.0.2) (2017-12-21) 105 | 106 | 107 | ### Bug Fixes 108 | 109 | * **code:** lifecycle methods ([#53](https://github.com/adam-26/react-router-dispatcher/issues/53)) ([36f45e3](https://github.com/adam-26/react-router-dispatcher/commit/36f45e3)) 110 | 111 | 112 | 113 | 114 | ## [2.0.1](https://github.com/adam-26/react-router-dispatcher/compare/v2.0.0-beta.29...v2.0.1) (2017-12-19) 115 | 116 | 117 | ### Bug Fixes 118 | 119 | * **release:** Remove beta ([#52](https://github.com/adam-26/react-router-dispatcher/issues/52)) ([8cef37d](https://github.com/adam-26/react-router-dispatcher/commit/8cef37d)) 120 | 121 | 122 | 123 | 124 | # [2.0.0-beta.29](https://github.com/adam-26/react-router-dispatcher/compare/v2.0.0-beta.28...v2.0.0-beta.29) (2017-12-19) 125 | 126 | 127 | ### Bug Fixes 128 | 129 | * **docs:** Readme ([#51](https://github.com/adam-26/react-router-dispatcher/issues/51)) ([458fb0e](https://github.com/adam-26/react-router-dispatcher/commit/458fb0e)) 130 | 131 | 132 | 133 | 134 | # [2.0.0-beta.28](https://github.com/adam-26/react-router-dispatcher/compare/v2.0.0-beta.27...v2.0.0-beta.28) (2017-12-19) 135 | 136 | 137 | ### Bug Fixes 138 | 139 | * **docs:** Update links ([#50](https://github.com/adam-26/react-router-dispatcher/issues/50)) ([0d543bd](https://github.com/adam-26/react-router-dispatcher/commit/0d543bd)) 140 | 141 | 142 | 143 | 144 | # [2.0.0-beta.27](https://github.com/adam-26/react-router-dispatcher/compare/v2.0.0-beta.26...v2.0.0-beta.27) (2017-12-18) 145 | 146 | 147 | ### Bug Fixes 148 | 149 | * **code:** Support for null components ([#49](https://github.com/adam-26/react-router-dispatcher/issues/49)) ([daf2a26](https://github.com/adam-26/react-router-dispatcher/commit/daf2a26)) 150 | 151 | 152 | 153 | 154 | # [2.0.0-beta.26](https://github.com/adam-26/react-router-dispatcher/compare/v2.0.0-beta.25...v2.0.0-beta.26) (2017-12-18) 155 | 156 | 157 | ### Bug Fixes 158 | 159 | * **docs:** Update ([#48](https://github.com/adam-26/react-router-dispatcher/issues/48)) ([80b0cef](https://github.com/adam-26/react-router-dispatcher/commit/80b0cef)) 160 | 161 | 162 | 163 | 164 | # [2.0.0-beta.25](https://github.com/adam-26/react-router-dispatcher/compare/v2.0.0-beta.24...v2.0.0-beta.25) (2017-12-18) 165 | 166 | 167 | ### Features 168 | 169 | * **actions:** Enable stopping of actions ([#47](https://github.com/adam-26/react-router-dispatcher/issues/47)) ([b3ea62c](https://github.com/adam-26/react-router-dispatcher/commit/b3ea62c)) 170 | 171 | 172 | 173 | 174 | # [2.0.0-beta.24](https://github.com/adam-26/react-router-dispatcher/compare/v2.0.0-beta.23...v2.0.0-beta.24) (2017-12-18) 175 | 176 | 177 | ### Bug Fixes 178 | 179 | * **package:** update babel-jest to version 22.0.0 ([#44](https://github.com/adam-26/react-router-dispatcher/issues/44)) ([750cb38](https://github.com/adam-26/react-router-dispatcher/commit/750cb38)) 180 | 181 | 182 | 183 | 184 | # [2.0.0-beta.23](https://github.com/adam-26/react-router-dispatcher/compare/v2.0.0-beta.22...v2.0.0-beta.23) (2017-12-18) 185 | 186 | 187 | ### Bug Fixes 188 | 189 | * **bug:** Nested action components ([#45](https://github.com/adam-26/react-router-dispatcher/issues/45)) ([5c90b7d](https://github.com/adam-26/react-router-dispatcher/commit/5c90b7d)) 190 | 191 | 192 | 193 | 194 | # [2.0.0-beta.22](https://github.com/adam-26/react-router-dispatcher/compare/v2.0.0-beta.21...v2.0.0-beta.22) (2017-12-17) 195 | 196 | 197 | ### Bug Fixes 198 | 199 | * **build:** add Greenkeeper ([#43](https://github.com/adam-26/react-router-dispatcher/issues/43)) ([c66e397](https://github.com/adam-26/react-router-dispatcher/commit/c66e397)) 200 | 201 | 202 | 203 | 204 | # [2.0.0-beta.21](https://github.com/adam-26/react-router-dispatcher/compare/v2.0.0-beta.20...v2.0.0-beta.21) (2017-12-15) 205 | 206 | 207 | ### Features 208 | 209 | * **action:** endServerAction ([#42](https://github.com/adam-26/react-router-dispatcher/issues/42)) ([d9c26e9](https://github.com/adam-26/react-router-dispatcher/commit/d9c26e9)) 210 | 211 | 212 | 213 | 214 | # [2.0.0-beta.20](https://github.com/adam-26/react-router-dispatcher/compare/v2.0.0-beta.19...v2.0.0-beta.20) (2017-12-14) 215 | 216 | 217 | ### Bug Fixes 218 | 219 | * **docs:** clean up ([#41](https://github.com/adam-26/react-router-dispatcher/issues/41)) ([12bebdd](https://github.com/adam-26/react-router-dispatcher/commit/12bebdd)) 220 | 221 | 222 | 223 | 224 | # [2.0.0-beta.19](https://github.com/adam-26/react-router-dispatcher/compare/v2.0.0-beta.18...v2.0.0-beta.19) (2017-12-14) 225 | 226 | 227 | ### Bug Fixes 228 | 229 | * **bug:** resolving action names ([#40](https://github.com/adam-26/react-router-dispatcher/issues/40)) ([096c3f0](https://github.com/adam-26/react-router-dispatcher/commit/096c3f0)) 230 | 231 | 232 | 233 | 234 | # [2.0.0-beta.18](https://github.com/adam-26/react-router-dispatcher/compare/v2.0.0-beta.17...v2.0.0-beta.18) (2017-12-14) 235 | 236 | 237 | ### Bug Fixes 238 | 239 | * **bug:** component validation ([#39](https://github.com/adam-26/react-router-dispatcher/issues/39)) ([e83edff](https://github.com/adam-26/react-router-dispatcher/commit/e83edff)) 240 | 241 | 242 | 243 | 244 | # [2.0.0-beta.17](https://github.com/adam-26/react-router-dispatcher/compare/v2.0.0-beta.16...v2.0.0-beta.17) (2017-12-14) 245 | 246 | 247 | ### Bug Fixes 248 | 249 | * **build:** Version ([#38](https://github.com/adam-26/react-router-dispatcher/issues/38)) ([c8bb0cf](https://github.com/adam-26/react-router-dispatcher/commit/c8bb0cf)) 250 | 251 | 252 | ### Features 253 | 254 | * **actions:** Introduce Actions ([#36](https://github.com/adam-26/react-router-dispatcher/issues/36)) ([8b50aeb](https://github.com/adam-26/react-router-dispatcher/commit/8b50aeb)) 255 | 256 | 257 | 258 | 259 | # [2.0.0-beta.16](https://github.com/adam-26/react-router-dispatcher/compare/v2.0.0-beta.15...v2.0.0-beta.16) (2017-12-09) 260 | 261 | 262 | ### Bug Fixes 263 | 264 | * **docs:** Add warning ([#35](https://github.com/adam-26/react-router-dispatcher/issues/35)) ([42cc7f0](https://github.com/adam-26/react-router-dispatcher/commit/42cc7f0)) 265 | 266 | 267 | 268 | 269 | # [2.0.0-beta.15](https://github.com/adam-26/react-router-dispatcher/compare/v2.0.0-beta.14...v2.0.0-beta.15) (2017-12-08) 270 | 271 | 272 | ### Features 273 | 274 | * **api:** consolidate ([#34](https://github.com/adam-26/react-router-dispatcher/issues/34)) ([57fbfaa](https://github.com/adam-26/react-router-dispatcher/commit/57fbfaa)) 275 | 276 | 277 | 278 | 279 | # [2.0.0-beta.14](https://github.com/adam-26/react-router-dispatcher/compare/v2.0.0-beta.13...v2.0.0-beta.14) (2017-12-08) 280 | 281 | 282 | ### Features 283 | 284 | * **api:** matchRouteComponents ([#33](https://github.com/adam-26/react-router-dispatcher/issues/33)) ([fc9e596](https://github.com/adam-26/react-router-dispatcher/commit/fc9e596)) 285 | 286 | 287 | 288 | 289 | # [2.0.0-beta.13](https://github.com/adam-26/react-router-dispatcher/compare/v2.0.0-beta.12...v2.0.0-beta.13) (2017-12-08) 290 | 291 | 292 | ### Bug Fixes 293 | 294 | * **naming:** params ([#32](https://github.com/adam-26/react-router-dispatcher/issues/32)) ([c738390](https://github.com/adam-26/react-router-dispatcher/commit/c738390)) 295 | 296 | 297 | 298 | 299 | # [2.0.0-beta.12](https://github.com/adam-26/react-router-dispatcher/compare/v2.0.0-beta.11...v2.0.0-beta.12) (2017-12-08) 300 | 301 | 302 | ### Bug Fixes 303 | 304 | * **naming:** params ([#31](https://github.com/adam-26/react-router-dispatcher/issues/31)) ([2d1909a](https://github.com/adam-26/react-router-dispatcher/commit/2d1909a)) 305 | 306 | 307 | 308 | 309 | # [2.0.0-beta.11](https://github.com/adam-26/react-router-dispatcher/compare/v2.0.0-beta.10...v2.0.0-beta.11) (2017-12-08) 310 | 311 | 312 | ### Bug Fixes 313 | 314 | * **SSR:** dispatch ([#30](https://github.com/adam-26/react-router-dispatcher/issues/30)) ([dc3d804](https://github.com/adam-26/react-router-dispatcher/commit/dc3d804)) 315 | 316 | 317 | 318 | 319 | # [2.0.0-beta.10](https://github.com/adam-26/react-router-dispatcher/compare/v2.0.0-beta.9...v2.0.0-beta.10) (2017-12-08) 320 | 321 | 322 | ### Features 323 | 324 | * **loading:** Indicator ([#29](https://github.com/adam-26/react-router-dispatcher/issues/29)) ([7d09c69](https://github.com/adam-26/react-router-dispatcher/commit/7d09c69)) 325 | 326 | 327 | 328 | 329 | # [2.0.0-beta.9](https://github.com/adam-26/react-router-dispatcher/compare/v2.0.0-beta.8...v2.0.0-beta.9) (2017-12-07) 330 | 331 | 332 | ### Features 333 | 334 | * **propNames:** improve prop names ([#28](https://github.com/adam-26/react-router-dispatcher/issues/28)) ([568ae12](https://github.com/adam-26/react-router-dispatcher/commit/568ae12)) 335 | 336 | 337 | 338 | 339 | # [2.0.0-beta.8](https://github.com/adam-26/react-router-dispatcher/compare/v2.0.0-beta.7...v2.0.0-beta.8) (2017-12-07) 340 | 341 | 342 | ### Features 343 | 344 | * **universal:** simplify rendering ([#27](https://github.com/adam-26/react-router-dispatcher/issues/27)) ([dda483f](https://github.com/adam-26/react-router-dispatcher/commit/dda483f)) 345 | 346 | 347 | 348 | 349 | # [2.0.0-beta.7](https://github.com/adam-26/react-router-dispatcher/compare/v2.0.0-beta.6...v2.0.0-beta.7) (2017-12-07) 350 | 351 | 352 | ### Features 353 | 354 | * **factory:** pass action params ([#26](https://github.com/adam-26/react-router-dispatcher/issues/26)) ([144203d](https://github.com/adam-26/react-router-dispatcher/commit/144203d)) 355 | 356 | 357 | 358 | 359 | # [2.0.0-beta.6](https://github.com/adam-26/react-router-dispatcher/compare/v2.0.0-beta.5...v2.0.0-beta.6) (2017-12-07) 360 | 361 | 362 | ### Bug Fixes 363 | 364 | * **ssr:** assign defaults ([#25](https://github.com/adam-26/react-router-dispatcher/issues/25)) ([baaa447](https://github.com/adam-26/react-router-dispatcher/commit/baaa447)) 365 | 366 | 367 | 368 | 369 | # [2.0.0-beta.5](https://github.com/adam-26/react-router-dispatcher/compare/v2.0.0-beta.4...v2.0.0-beta.5) (2017-12-06) 370 | 371 | 372 | ### Bug Fixes 373 | 374 | * **bug:** factory method ([#24](https://github.com/adam-26/react-router-dispatcher/issues/24)) ([93ef5e0](https://github.com/adam-26/react-router-dispatcher/commit/93ef5e0)) 375 | 376 | 377 | 378 | 379 | # [2.0.0-beta.4](https://github.com/adam-26/react-router-dispatcher/compare/v2.0.0-beta.3...v2.0.0-beta.4) (2017-12-06) 380 | 381 | 382 | ### Bug Fixes 383 | 384 | * **bug:** render ([67372a7](https://github.com/adam-26/react-router-dispatcher/commit/67372a7)) 385 | 386 | 387 | 388 | 389 | # [2.0.0-beta.3](https://github.com/adam-26/react-router-dispatcher/compare/v2.0.0-beta.2...v2.0.0-beta.3) (2017-12-05) 390 | 391 | 392 | ### Bug Fixes 393 | 394 | * **v2:** add alpha tag ([#21](https://github.com/adam-26/react-router-dispatcher/issues/21)) ([484e708](https://github.com/adam-26/react-router-dispatcher/commit/484e708)) 395 | 396 | 397 | 398 | 399 | # [2.0.0-beta.2](https://github.com/adam-26/react-router-dispatcher/compare/v2.0.0-beta.1...v2.0.0-beta.2) (2017-12-04) 400 | 401 | 402 | ### Bug Fixes 403 | 404 | * **dispatchActions:** accept func ([#20](https://github.com/adam-26/react-router-dispatcher/issues/20)) ([9bda8a2](https://github.com/adam-26/react-router-dispatcher/commit/9bda8a2)) 405 | 406 | 407 | 408 | 409 | # [2.0.0-beta.1](https://github.com/adam-26/react-router-dispatcher/compare/v1.0.0-beta.18...v2.0.0-beta.1) (2017-12-04) 410 | 411 | 412 | ### Features 413 | 414 | * **v2:** Initial v2 release ([#19](https://github.com/adam-26/react-router-dispatcher/issues/19)) ([9938d6c](https://github.com/adam-26/react-router-dispatcher/commit/9938d6c)) 415 | 416 | 417 | 418 | 419 | # [1.0.0-beta.18](https://github.com/adam-26/react-router-dispatcher/compare/v1.0.0-beta.17...v1.0.0-beta.18) (2017-08-29) 420 | 421 | 422 | 423 | 424 | # [1.0.0-beta.17](https://github.com/adam-26/react-router-dispatcher/compare/v1.0.0-beta.16...v1.0.0-beta.17) (2017-08-29) 425 | 426 | 427 | 428 | 429 | # [1.0.0-beta.16](https://github.com/adam-26/react-router-dispatcher/compare/v1.0.0-beta.15...v1.0.0-beta.16) (2017-08-29) 430 | 431 | 432 | 433 | 434 | # [1.0.0-beta.15](https://github.com/adam-26/react-router-dispatcher/compare/v1.0.0-beta.14...v1.0.0-beta.15) (2017-08-28) 435 | 436 | 437 | 438 | 439 | # [1.0.0-beta.14](https://github.com/adam-26/react-router-dispatcher/compare/v1.0.0-beta.13...v1.0.0-beta.14) (2017-08-28) 440 | 441 | 442 | 443 | 444 | # [1.0.0-beta.13](https://github.com/adam-26/react-router-dispatcher/compare/v1.0.0-beta.12...v1.0.0-beta.13) (2017-08-27) 445 | 446 | 447 | 448 | 449 | # [1.0.0-beta.12](https://github.com/adam-26/react-router-dispatcher/compare/v1.0.0-beta.11...v1.0.0-beta.12) (2017-08-27) 450 | 451 | 452 | 453 | 454 | # [1.0.0-beta.11](https://github.com/adam-26/react-router-dispatcher/compare/v1.0.0-beta.10...v1.0.0-beta.11) (2017-08-27) 455 | 456 | 457 | 458 | 459 | # [1.0.0-beta.10](https://github.com/adam-26/react-router-dispatcher/compare/v1.0.0-beta.9...v1.0.0-beta.10) (2017-08-27) 460 | 461 | 462 | 463 | 464 | # [1.0.0-beta.9](https://github.com/adam-26/react-router-dispatcher/compare/v1.0.0-beta.8...v1.0.0-beta.9) (2017-08-27) 465 | 466 | 467 | 468 | 469 | # [1.0.0-beta.8](https://github.com/adam-26/react-router-dispatcher/compare/v1.0.0-beta.7...v1.0.0-beta.8) (2017-08-27) 470 | 471 | 472 | 473 | 474 | # [1.0.0-beta.7](https://github.com/adam-26/react-router-dispatcher/compare/v1.0.0-beta.6...v1.0.0-beta.7) (2017-08-27) 475 | 476 | 477 | 478 | 479 | # [1.0.0-beta.6](https://github.com/adam-26/react-router-dispatcher/compare/v1.0.0-beta.5...v1.0.0-beta.6) (2017-08-27) 480 | 481 | 482 | 483 | 484 | # [1.0.0-beta.5](https://github.com/adam-26/react-router-dispatcher/compare/v1.0.0-beta.4...v1.0.0-beta.5) (2017-08-27) 485 | 486 | 487 | 488 | 489 | # [1.0.0-beta.4](https://github.com/adam-26/react-router-dispatcher/compare/v1.0.0-beta.3...v1.0.0-beta.4) (2017-08-27) 490 | 491 | 492 | 493 | 494 | # [1.0.0-beta.3](https://github.com/adam-26/react-router-dispatcher/compare/v1.0.0-beta.1...v1.0.0-beta.3) (2017-08-27) 495 | 496 | 497 | ### Features 498 | 499 | * **publish:** ([#3](https://github.com/adam-26/react-router-dispatcher/issues/3)) ([68434cf](https://github.com/adam-26/react-router-dispatcher/commit/68434cf)) 500 | 501 | 502 | 503 | 504 | # [1.0.0-beta.1](https://github.com/adam-26/react-router-dispatcher/compare/v5.1.0...v1.0.0-beta.1) (2017-08-27) 505 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) adam-26 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. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # react-router-dispatcher 2 | 3 | [![Greenkeeper badge](https://badges.greenkeeper.io/adam-26/react-router-dispatcher.svg)](https://greenkeeper.io/) 4 | [![npm](https://img.shields.io/npm/v/react-router-dispatcher.svg)](https://www.npmjs.com/package/react-router-dispatcher) 5 | [![npm](https://img.shields.io/npm/dm/react-router-dispatcher.svg)](https://www.npmjs.com/package/react-router-dispatcher) 6 | [![CircleCI branch](https://img.shields.io/circleci/project/github/adam-26/react-router-dispatcher/master.svg)](https://circleci.com/gh/adam-26/react-router-dispatcher/tree/master) 7 | [![Maintainability](https://api.codeclimate.com/v1/badges/5ca5fb8baef7a77d54bf/maintainability)](https://codeclimate.com/github/adam-26/react-router-dispatcher/maintainability) 8 | [![Test Coverage](https://api.codeclimate.com/v1/badges/5ca5fb8baef7a77d54bf/test_coverage)](https://codeclimate.com/github/adam-26/react-router-dispatcher/test_coverage) 9 | [![Conventional Commits](https://img.shields.io/badge/Conventional%20Commits-1.0.0-yellow.svg)](https://conventionalcommits.org) 10 | 11 | react-router-dispatcher is designed to work with [react-router v4.x](https://github.com/ReactTraining/react-router), it: 12 | * uses _actions_ to encapsulate behaviors that can be invoked before rendering 13 | * supports server-side rendering, including resolving async promises before rendering 14 | * requires using [react-router-config v4.x](https://github.com/ReactTraining/react-router/tree/master/packages/react-router-config) route configuration 15 | 16 | #### Looking for **version 1.x**?? 17 | >[You can find it on the _V1_ branch](https://github.com/adam-26/react-router-dispatcher/tree/v1). 18 | Version 2+ has been simplified and **no longer requires [redux](redux.js.org)** 19 | 20 | ## Install 21 | ```sh 22 | // npm 23 | npm install --save react-router-dispatcher 24 | 25 | // yarn 26 | yarn add react-router-dispatcher 27 | ``` 28 | 29 | ## Available actions 30 | 31 | * [react-router-dispatcher-status-code](https://github.com/adam-26/react-router-dispatcher-status-code) set HTTP status code of streaming responses 32 | * [react-router-dispatcher-redirect](https://github.com/adam-26/react-router-dispatcher-redirect) redirect routes that support SSR streams by redirecting before render 33 | * [react-router-dispatcher-metadata](https://github.com/adam-26/react-router-dispatcher-metadata) SSR stream supported HTML metadata 34 | * [react-router-dispatcher-chunk](https://github.com/adam-26/react-router-dispatcher-chunk) react-chunk dynamic import support to support code-splitting 35 | 36 | ## Usage 37 | 38 | #### Universal rendering 39 | 40 | If your building a universal application, use the `createRouteDispatchers` factory method. 41 | 42 | ```js 43 | // dispatcher.js 44 | import { createRouteDispatchers } from 'react-router-dispatcher'; 45 | import { LOAD_METADATA } from 'react-router-metadata-action'; 46 | import { LOAD_DATA } from './loadDataAction'; 47 | 48 | // === route dispatcher configuration === 49 | // 1. define react-router-config route configuration 50 | const routes = [...]; 51 | 52 | // 2. define the ORDER that actions are invoked 53 | const orderedActionNames = [[LOAD_DATA], [LOAD_METADATA]]; 54 | 55 | // Use the createRouteDispatchers factory, 56 | // it returns everything required for rendering dispatcher actions 57 | const { 58 | UniversalRouteDispatcher, 59 | ClientRouteDispatcher, 60 | dispatchClientActions, 61 | dispatchServerActions 62 | } = createRouteDispatchers(routes, orderedActionNames /*, options */); 63 | ``` 64 | 65 | ##### server-side rendering 66 | ```js 67 | import Html from 'react-html-metadata'; 68 | import { dispatchServerActions, UniversalRouteDispatcher } from './dispatcher'; 69 | import apiClient from './someOtherPackage'; 70 | 71 | const location = request.url; // current request URL, from expressjs or similar 72 | const actionParams = { apiClient }; // passed to all dispatch action methods 73 | 74 | dispatchServerActions(location, actionParams /*, options */).then(({ metadata, store }) => { 75 | const staticRouterCtx = {}; 76 | 77 | // Render the response, supports rendering to stream and string 78 | const stream = renderToNodeStream( 79 | 80 | 81 | 82 | 83 | ); 84 | 85 | res.write(""); 86 | stream.pipe(res); 87 | }); 88 | ``` 89 | 90 | ##### client-side rendering 91 | ```js 92 | import { hydrate, render } from 'react-dom'; 93 | import Html from 'react-html-metadata'; 94 | import { 95 | dispatchClientActions, 96 | UniversalRouteDispatcher, 97 | ClientRouteDispatcher 98 | } from './dispatcher'; 99 | 100 | const location = window.location.pathname; // current url, from browser window 101 | const appData = window.__AppData; // data serialized from the server render 102 | 103 | // This is synchronous 104 | // It uses the appData to recreate the metadata on the client 105 | const { metadata } = dispatchClientActions(location, appData); 106 | 107 | // Use hydrate() with server-side rendering, 108 | // otherwise use render() with 109 | hydrate( 110 | 111 | 112 | 113 | 114 | 115 | ); 116 | ``` 117 | 118 | #### client-only rendering 119 | 120 | For the client app, use the exported `` component to render your application. 121 | 122 | ```js 123 | import { RouterDispatcher } from 'react-router-dispatcher'; 124 | 125 | const routeCfg = []; // same as server (react-router-config routes) 126 | 127 | // render your app 128 | 129 | 130 | 131 | 132 | ``` 133 | 134 | ### Actions 135 | 136 | >You **must assign actions to route components** (components that are assigned directly to react-router-config style routes) 137 | 138 | #### Define an _action_ 139 | 140 | Packages that support _react-router-dispatcher_ should export _actions_. 141 | 142 | ```js 143 | // loadDataAction.js - a simple action for loading async data 144 | import getDisplayName from 'react-display-name'; 145 | 146 | export const LOAD_DATA = 'LOAD_DATA_ACTION'; 147 | 148 | export default function loadDataAction() { 149 | return { 150 | name: LOAD_DATA, 151 | staticMethodName: 'loadData', 152 | initServerAction: (params) => ({ 153 | store: params.store || {} 154 | }), 155 | filterParamsToProps: (params) => { 156 | store: params.store 157 | } 158 | }; 159 | } 160 | ``` 161 | 162 | #### Applying actions to components 163 | 164 | ```js 165 | import React, { Component } from 'react'; 166 | import PropTypes from 'prop-types'; 167 | import { withActions } from 'react-router-dispatcher'; 168 | import loadDataAction from './loadDataAction'; 169 | 170 | class ExampleComponent extends Component { 171 | static propTypes = { 172 | store: PropTypes.object.isRequired, 173 | apiClient: PropTypes.object.isRequired 174 | }; 175 | 176 | // loadDataAction invokes this method to load data from an api 177 | static loadData(actionProps, routerCtx) { 178 | const { 179 | location, 180 | match: { 181 | params 182 | }, 183 | store, 184 | apiClient 185 | } = actionProps; 186 | 187 | // async functions must return a Promise 188 | return apiClient.loadById(params.id).then((data) => { 189 | store.exampleData = data; 190 | }); 191 | } 192 | 193 | render() { 194 | const {store: { exampleData }} = this.props; 195 | return
{exampleData}
196 | } 197 | } 198 | 199 | // the mapper must return the 'propTypes' expected by the component 200 | const mapParamsToProps = ({ apiClient }) => { apiClient }; 201 | export default withActions(mapParamsToProps, loadDataAction())(ExampleComponent); 202 | 203 | ``` 204 | 205 | ## API 206 | 207 | ### Actions 208 | 209 | It's _recommended_ that all actions are defined as factory _functions_ that return new action instances. 210 | It can be useful to allow actions to accept parameters to customize the actions behavior. 211 | 212 | #### Action Schema 213 | 214 | **name**: `string` 215 | * **required** 216 | * The action name should also be exported as a `string`, to be used for configuring action order 217 | 218 | **staticMethod**: `(props, routerCtx) => any` 219 | * One of `staticMethod` **or** `staticMethodName` is **required** 220 | * Action method implementation, can be defined here or using static methods on components actions are assigned to 221 | * return a `Promise` for **async** actions 222 | * for non-async actions, return data 223 | 224 | **staticMethodName**: `string` 225 | * One of `staticMethod` **or** `staticMethodName` is **required** 226 | * the name of the static method _required_ on any `Component` that the action is applied to 227 | 228 | **filterParamsToProps**: `(params) => Object` 229 | * **required** 230 | * filters all `actionParams` to include on params required by this action 231 | 232 | **hoc**: `(Component, ActionHOC) => node` 233 | * Optional 234 | * Defines a higher-order component that is applied to _all_ components that have the action assigned 235 | * Using higher-order components makes actions very versatile! 236 | 237 | **initServerAction**: `(actionParams) => Object` 238 | * Optional, but **required** if the action supports being invoked on the server **before rendering** 239 | * if your action supports server-side usage but does not need to perform any init, return an **empty** object 240 | * `initServerAction: (params) => {}` 241 | 242 | **initClientAction**: `(actionParams) => Object` 243 | * Optional, but **required** if the action supports being invoked on the client **before rendering** 244 | * if your action supports client-side usage but does not need to perform any init, return an **empty** object 245 | * `initClientAction: (params) => {}` 246 | 247 | **successHandler**: `(props, routerCtx) => void` 248 | * Optional, invoked after this action is successfully invoked on each matching route 249 | * Params will include any value(s) assigned from the static action methods 250 | * NOTE: `params` are the _raw_ dispatcher parameters 251 | 252 | **errorHandler**: `(err, props) => void` 253 | * Optional, invoked if any static action methods or success handler fails 254 | 255 | **stopServerActions**: `(props, routerCtx) => boolean` 256 | * Optional, allows an action to short-circuit/prevent invocation of following action sets with `dispatchOnServer()` 257 | * For example; An action may determine a redirect is required, therefore invoking following action sets is a waste of resources 258 | 259 | ### Methods 260 | 261 | #### `createRouteDispatchers(routes, orderedActionNames, options)` 262 | 263 | **routes**: `Array` 264 | * Routes defined using the [react-router-config](https://github.com/ReactTraining/react-router/tree/master/packages/react-router-config) format. 265 | 266 | **orderedActionNames**: `string | Array | Array> | (location, actionParams) => string|Array|Array>` 267 | * Configures the **order** that actions will be executed 268 | * A `string` can be used if only 1 action is used 269 | * An array of action names will execute all actions in **parallel** 270 | * A nested array enables actions to be executed **serially** 271 | * ie: `[['loadData'], ['parseData']]` first `loadData` is invoked on **each component**, then `parseData` is invoked on each component 272 | * A function, `dispatchActions(location, actionParams)`. Return one of the previously defined types (string, array, nested array). 273 | 274 | **options**: `Object` 275 | * routeComponentPropNames: `Array`, route prop name(s) that are known to be react components 276 | * loadingIndicator: `React Component`, a component to display for client-side renders when loading async data 277 | 278 | #### `withActions(mapParamsToProps, actions)` 279 | 280 | A higher-order component function for assigned actions to components 281 | 282 | **mapParamsToProps**: `(params, routerCtx) => Object` 283 | * A function that maps action parameters to prop values required by any actions applied to the component. 284 | * Pass `null` if no mapping function is required by the component. 285 | 286 | **actions**: 287 | * one or more actions to be applied to a react component 288 | * separate multiple actions using a comma: `withActions(null, loadData(), parseData())(Component)` 289 | 290 | ### Components 291 | 292 | #### `` component 293 | 294 | Props: 295 | 296 | **routes**: `Array` 297 | * Routes defined using the [react-router-config](https://github.com/ReactTraining/react-router/tree/master/packages/react-router-config) format. 298 | 299 | **actionNames**: `string | Array | Array> | (location, actionParams) => string|Array|Array>` 300 | * Configure the **action(s)** defined any any _route component_ to invoke before rendering. 301 | * See [createRouteDispatchers.orderedActionNames for more information](https://github.com/adam-26/react-router-dispatcher#API) 302 | 303 | **routeComponentPropNames**: `Array` 304 | * The **prop** names of _route components_ that are known to be react **components** 305 | * The default value is `component`. 306 | 307 | **actionParams**: `any` 308 | * Any value can be assigned to the action params, the value is passed to all **action methods**, common usages include passing api clients and application state (such as a redux store) 309 | 310 | **loadingIndicator**: `React Component` 311 | * A custom component to display on the client when async actions are pending completion 312 | * **note**: this is only rendered on the client 313 | 314 | **render**: `(routes, routeProps) => node` 315 | * A custom render method 316 | * you **must** invoke the react-router `renderRoutes` method within the render method 317 | 318 | ### Utilities 319 | 320 | #### defineRoutes 321 | 322 | The `defineRoutes` utility method automatically assigns `keys` to routes that don't have a key manually assigned. 323 | This key can be accessed from **actions** to determine the exact route that is responsible for invoking the action. 324 | 325 | ```js 326 | import { defineRoutes } from 'react-router-dispatcher'; 327 | 328 | const routes = defineRoutes([ 329 | // define react-router-config routes here 330 | ]); 331 | ``` 332 | 333 | #### matchRouteComponents 334 | 335 | Resolves all route components for a requested location and a given set of routes. 336 | 337 | ```js 338 | import { matchRouteComponents } from 'react-router-dispatcher'; 339 | 340 | const matchedRoutes = matchRouteComponents(location, routes, routeComponentPropNames); 341 | const [component, match, routerContext] = matchedRoutes[0]; 342 | const { route, routeComponentKey } = routerContext; 343 | ``` 344 | 345 | ### Contribute 346 | For questions or issues, please [open an issue](https://github.com/adam-26/react-router-dispatcher/issues), and you're welcome to submit a PR for bug fixes and feature requests. 347 | 348 | Before submitting a PR, ensure you run `npm test` to verify that your coe adheres to the configured lint rules and passes all tests. Be sure to include unit tests for any code changes or additions. 349 | 350 | ## License 351 | MIT 352 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-router-dispatcher", 3 | "version": "6.2.0", 4 | "description": "react-router v4 action dispatcher", 5 | "main": "lib/index.js", 6 | "module": "src/index.js", 7 | "engines": { 8 | "node": ">=4.8" 9 | }, 10 | "repository": "git+ssh://git@github.com/adam-26/react-router-dispatcher.git", 11 | "scripts": { 12 | "build": "rm -rf ./lib; NODE_ENV=production babel ./src -d lib --ignore '__tests__'", 13 | "lint": "eslint ./src", 14 | "ci-jest": "TEST_REPORT_PATH=./.build/test jest --ci --coverage --coverageDirectory ./.build/coverage --testResultsProcessor='./node_modules/jest-junit-reporter'", 15 | "ci-lint": "eslint ./src --format junit --output-file ./.build/lint/eslint.xml", 16 | "pretest": "npm run lint", 17 | "test": "jest" 18 | }, 19 | "keywords": [ 20 | "react", 21 | "react-router", 22 | "dispatcher" 23 | ], 24 | "author": "adam-26", 25 | "contributors": [], 26 | "license": "MIT", 27 | "bugs": { 28 | "url": "https://github.com/adam-26/react-router-dispatcher/issues" 29 | }, 30 | "homepage": "https://github.com/adam-26/react-router-dispatcher", 31 | "devDependencies": { 32 | "babel-cli": "^6.18.0", 33 | "babel-core": "^6.21.0", 34 | "babel-eslint": "^8.0.3", 35 | "babel-jest": "^22.0.0", 36 | "babel-plugin-transform-react-remove-prop-types": "^0.4.12", 37 | "babel-plugin-transform-runtime": "^6.15.0", 38 | "babel-preset-es2015": "^6.18.0", 39 | "babel-preset-react": "^6.16.0", 40 | "babel-preset-react-optimize": "^1.0.1", 41 | "babel-preset-stage-0": "^6.16.0", 42 | "babel-runtime": "^6.20.0", 43 | "enzyme": "^3.2.0", 44 | "enzyme-adapter-react-16": "^1.1.0", 45 | "eslint": "^4.13.1", 46 | "eslint-plugin-jest": "^21.5.0", 47 | "eslint-plugin-react": "^7.5.1", 48 | "jest": "^21.2.1", 49 | "jest-junit-reporter": "^1.1.0", 50 | "prop-types": "^15.6.0", 51 | "react": "^16.0.0", 52 | "react-dom": "^16.0.0", 53 | "react-router": "^4.0.0", 54 | "react-router-config": "^1.0.0-beta.4", 55 | "react-test-renderer": "^16.2.0", 56 | "standard-version": "^4.2.0" 57 | }, 58 | "peerDependencies": { 59 | "react": "^16.0.0", 60 | "react-router": "^4.0.0", 61 | "react-router-config": "^1.0.0-beta.4" 62 | }, 63 | "dependencies": { 64 | "history": "^4.7.2", 65 | "hoist-non-react-statics": "^2.3.1", 66 | "invariant": "^2.2.2", 67 | "react-display-name": "^0.2.3", 68 | "warning": "^3.0.0" 69 | }, 70 | "jest": { 71 | "mapCoverage": true, 72 | "testMatch": [ 73 | "**/__tests__/**/*.test.js?(x)" 74 | ], 75 | "testResultsProcessor": "./node_modules/jest-junit-reporter" 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /src/RouteDispatcher.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable react/prop-types */ 2 | import React, { Component } from 'react'; 3 | import PropTypes from 'prop-types'; 4 | import { withRouter, Route } from 'react-router'; 5 | import { renderRoutes } from 'react-router-config'; 6 | import { createPath } from 'history'; 7 | import { 8 | dispatchClientActions, 9 | dispatchServerActions, 10 | dispatchComponentActions, 11 | standardizeActionNames 12 | } from './dispatchRouteActions'; 13 | 14 | function isDispatchActionsEqual(arr1, arr2) { 15 | // Determine if a function was passed. 16 | const isFunc1 = typeof arr1 === 'function'; 17 | const isFunc2 = typeof arr2 === 'function'; 18 | if (isFunc1 || isFunc2) { 19 | if (isFunc1 !== isFunc2) { 20 | return false; 21 | } 22 | 23 | return arr1 === arr2; 24 | } 25 | 26 | // It should be an array 27 | if (arr1.length !== arr2.length) { 28 | return false; 29 | } 30 | 31 | for (let idx = 0, len = arr1.length; idx < len; idx++) { 32 | const item1 = arr1[idx]; 33 | const item2 = arr2[idx]; 34 | 35 | const isArray1 = Array.isArray(item1); 36 | if (isArray1 !== Array.isArray(item2)) { 37 | return false; 38 | } 39 | 40 | if (isArray1) { 41 | if (!isDispatchActionsEqual(item1, item2)) { 42 | return false; 43 | } 44 | } 45 | else { 46 | for (let j = 0, len = item1.length; j < len; j++) { 47 | if (item1[j] !== item2[j]) { 48 | return false; 49 | } 50 | } 51 | } 52 | } 53 | 54 | return true; 55 | } 56 | 57 | const DefaultLoadingIndicator = () => ( 58 |
Loading...
59 | ); 60 | 61 | const DEFAULT_COMPONENT_PROP_NAMES = ['component', 'components']; 62 | 63 | class RouteDispatcher extends Component { 64 | static propTypes = { 65 | /** 66 | * The function used to render routes. 67 | */ 68 | render: PropTypes.func, 69 | 70 | /** 71 | * The configured react-router routes (using react-router-config format). 72 | */ 73 | routes: PropTypes.array.isRequired, 74 | 75 | /** 76 | * The name of the action(s) to invoke on route components. 77 | * 78 | * This can be an array of strings, or an array of string arrays. When an array of arrays, 79 | * each array of actions is dispatched serially. 80 | */ 81 | actionNames: PropTypes.oneOfType([ 82 | PropTypes.arrayOf(PropTypes.arrayOf(PropTypes.string)), 83 | PropTypes.arrayOf(PropTypes.string), 84 | PropTypes.string, 85 | PropTypes.func 86 | ]).isRequired, 87 | 88 | /** 89 | * The name(s) of props on route components that can contain action dispatchers 90 | */ 91 | routeComponentPropNames: PropTypes.arrayOf(PropTypes.string), 92 | 93 | /** 94 | * The component to render when data is initially loading 95 | */ 96 | loadingIndicator: PropTypes.oneOfType([ 97 | PropTypes.node, 98 | PropTypes.element, 99 | PropTypes.func 100 | ]), 101 | 102 | /** 103 | * True to dispatch actions on the first render, otherwise false. 104 | * 105 | * If rendering on the server, this should be set to false. 106 | */ 107 | dispatchActionsOnFirstRender: PropTypes.bool, 108 | 109 | /** 110 | * Helpers are passed to all action dispatchers 111 | */ 112 | actionParams: PropTypes.any, 113 | 114 | /** 115 | * React router props 116 | */ 117 | match: PropTypes.object, 118 | location: PropTypes.object, 119 | history: PropTypes.object 120 | }; 121 | 122 | static defaultProps = { 123 | actionParams: {}, 124 | routeComponentPropNames: DEFAULT_COMPONENT_PROP_NAMES, 125 | dispatchActionsOnFirstRender: true, 126 | loadingIndicator: DefaultLoadingIndicator, 127 | render(routes, routeProps) { 128 | return renderRoutes(routes, routeProps); 129 | }, 130 | }; 131 | 132 | static dispatchServerActions(location, actionNames, routeCfg, props) { 133 | return dispatchServerActions(location, actionNames, routeCfg, props); 134 | } 135 | 136 | static dispatchClientActions(location, actionNames, routeCfg, props) { 137 | return dispatchClientActions(location, actionNames, routeCfg, props); 138 | } 139 | 140 | static componentDispatch(actionNames, props) { 141 | const { location, routes, routeComponentPropNames, actionParams } = props; 142 | return dispatchComponentActions( 143 | location, 144 | actionNames, 145 | { routes, routeComponentPropNames }, 146 | Object.assign({}, actionParams)); 147 | } 148 | 149 | constructor(props, context) { 150 | super(props, context); 151 | this.state = { 152 | previousLocation: null, 153 | hasDispatchedActions: !props.dispatchActionsOnFirstRender, 154 | dispatchActionNames: standardizeActionNames(props.actionNames) 155 | }; 156 | } 157 | 158 | componentWillMount() { 159 | const { hasDispatchedActions, dispatchActionNames } = this.state; 160 | if (hasDispatchedActions) { 161 | // data is already loaded 162 | return; 163 | } 164 | 165 | RouteDispatcher.componentDispatch(dispatchActionNames, this.props).then(() => { 166 | // re-render after data has loaded 167 | this.setState({ hasDispatchedActions: true }); 168 | }); 169 | } 170 | 171 | componentWillReceiveProps(nextProps) { 172 | const { location, actionNames } = this.props; 173 | const { location: nextLocation, actionNames: nextActionNames } = nextProps; 174 | 175 | let nextState; 176 | if (typeof nextLocation !== 'undefined' && createPath(nextLocation) !== createPath(location)) { 177 | nextState = { previousLocation: location }; 178 | } 179 | 180 | if (typeof nextActionNames !== 'undefined' && actionNames !== nextActionNames) { 181 | const nextDispatchActionNames = standardizeActionNames(nextActionNames); 182 | if (!isDispatchActionsEqual(this.state.dispatchActionNames, nextDispatchActionNames)) { 183 | nextState = { 184 | dispatchActionNames: nextDispatchActionNames, 185 | previousLocation: location 186 | }; 187 | } 188 | } 189 | 190 | if (typeof nextState !== 'undefined') { 191 | this.setState(nextState); 192 | 193 | // load data while the old screen remains 194 | RouteDispatcher.componentDispatch(nextActionNames, nextProps).then(() => { 195 | // clear previousLocation so the next screen renders 196 | this.setState({ previousLocation: null }); 197 | }); 198 | } 199 | } 200 | 201 | shouldComponentUpdate(nextProps, nextState) { 202 | const hasLocationChanged = nextState.previousLocation === null && this.state.previousLocation !== null; 203 | const haveActionParamsChanged = this.props.actionParams !== nextProps.actionParams; 204 | return hasLocationChanged || haveActionParamsChanged; 205 | } 206 | 207 | render() { 208 | const { 209 | location, 210 | routes, 211 | render, 212 | loadingIndicator, 213 | // DO NOT DELETE THESE PROPS - this is the easiest way to access route props 214 | /* eslint-disable no-unused-vars */ 215 | actionNames, 216 | routeComponentPropNames, 217 | dispatchActionsOnFirstRender, 218 | actionParams, 219 | match, 220 | history, 221 | /* eslint-enable no-unused-vars */ 222 | ...routeProps 223 | } = this.props; 224 | 225 | if (!this.state.hasDispatchedActions) { 226 | // Display a loading indicator until data is loaded 227 | return React.isValidElement(loadingIndicator) || typeof loadingIndicator === 'function' ? 228 | React.createElement(loadingIndicator) : 229 |
{loadingIndicator}
; 230 | } 231 | 232 | return ( 233 | render( 236 | Array.isArray(routes) ? routes : null, 237 | routeProps 238 | )} 239 | /> 240 | ); 241 | } 242 | } 243 | 244 | const RouterDispatcher = withRouter(RouteDispatcher); 245 | 246 | export { 247 | RouteDispatcher, 248 | DEFAULT_COMPONENT_PROP_NAMES 249 | }; 250 | 251 | export default RouterDispatcher; 252 | -------------------------------------------------------------------------------- /src/__tests__/RouteDispatcher.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { shallow, mount } from './enzyme'; 3 | import { RouteDispatcher } from '../RouteDispatcher'; 4 | import { MemoryRouter } from 'react-router' 5 | 6 | const LOAD_DATA = 'loadData'; 7 | const PARSE_DATA = 'parseData'; 8 | const DEFAULT_ACTION_NAMES = [[LOAD_DATA]]; 9 | 10 | describe('RouteDispatcher', () => { 11 | const location = { pathname: '/' }; 12 | const defaultRoutes = []; 13 | let componentDispatch, clientDispatch, serverDispatch; 14 | 15 | beforeEach(() => { 16 | componentDispatch = RouteDispatcher.componentDispatch; 17 | clientDispatch = RouteDispatcher.dispatchClientActions; 18 | serverDispatch = RouteDispatcher.dispatchServerActions; 19 | RouteDispatcher.componentDispatch = jest.fn(() => Promise.resolve()); 20 | RouteDispatcher.dispatchClientActions = jest.fn(() => Promise.resolve()); 21 | RouteDispatcher.dispatchServerActions = jest.fn(() => Promise.resolve()); 22 | }); 23 | 24 | afterEach(() => { 25 | RouteDispatcher.componentDispatch = componentDispatch; 26 | RouteDispatcher.dispatchClientActions = clientDispatch; 27 | RouteDispatcher.dispatchServerActions = serverDispatch; 28 | }); 29 | 30 | describe('constructor', () => { 31 | test('standardizes dispatchActions prop', () => { 32 | let dispatcher = new RouteDispatcher({ actionNames: LOAD_DATA }); 33 | expect(dispatcher.state.dispatchActionNames).toEqual([[LOAD_DATA]]); 34 | 35 | dispatcher = new RouteDispatcher({ actionNames: [LOAD_DATA] }); 36 | expect(dispatcher.state.dispatchActionNames).toEqual([[LOAD_DATA]]); 37 | 38 | dispatcher = new RouteDispatcher({ actionNames: [LOAD_DATA, PARSE_DATA] }); 39 | expect(dispatcher.state.dispatchActionNames).toEqual([[LOAD_DATA, PARSE_DATA]]); 40 | 41 | dispatcher = new RouteDispatcher({ actionNames: [[LOAD_DATA]] }); 42 | expect(dispatcher.state.dispatchActionNames).toEqual([[LOAD_DATA]]); 43 | 44 | dispatcher = new RouteDispatcher({ actionNames: [[LOAD_DATA], [PARSE_DATA]] }); 45 | expect(dispatcher.state.dispatchActionNames).toEqual([[LOAD_DATA], [PARSE_DATA]]); 46 | 47 | dispatcher = new RouteDispatcher({ actionNames: () => [[LOAD_DATA]] }); 48 | expect(typeof dispatcher.state.dispatchActionNames).toBe('function'); 49 | }); 50 | 51 | test('assigns state', () => { 52 | const dispatchActionsOnFirstRender = false; 53 | const dispatcher = new RouteDispatcher({ dispatchActionsOnFirstRender, actionNames: [[LOAD_DATA]] }); 54 | 55 | expect(dispatcher.state.previousLocation).toBe(null); 56 | expect(dispatcher.state.hasDispatchedActions).toBe(!dispatchActionsOnFirstRender); 57 | }); 58 | 59 | test('dispatches actions if not previously done', done => { 60 | const wrapper = shallow( [[LOAD_DATA]]} />); 65 | 66 | setImmediate(() => { 67 | expect(RouteDispatcher.componentDispatch.mock.calls).toHaveLength(1); 68 | expect(wrapper.state('hasDispatchedActions')).toBe(true); 69 | done(); 70 | }); 71 | }); 72 | }); 73 | 74 | describe('componentWillMount', () => { 75 | test('does not dispatch actions if previously dispatched', () => { 76 | shallow(); 77 | expect(RouteDispatcher.componentDispatch.mock.calls).toHaveLength(0); 78 | }); 79 | 80 | test('dispatches actions if not previously done', done => { 81 | const wrapper = shallow(); 82 | 83 | setImmediate(() => { 84 | expect(RouteDispatcher.componentDispatch.mock.calls).toHaveLength(1); 85 | expect(wrapper.state('hasDispatchedActions')).toBe(true); 86 | done(); 87 | }); 88 | }); 89 | }); 90 | 91 | describe('componentWillReceiveProps', () => { 92 | test('does not dispatch actions when location has not changed', () => { 93 | const currentLocation = {}; 94 | const wrapper = shallow(); 95 | wrapper.instance().componentWillReceiveProps({ location: currentLocation }); 96 | 97 | expect(RouteDispatcher.componentDispatch.mock.calls).toHaveLength(0); 98 | expect(wrapper.state('previousLocation')).toBe(null); 99 | }); 100 | 101 | test('does not dispatch actions when dispatchActions has not changed', () => { 102 | const wrapper = shallow(); 103 | wrapper.instance().componentWillReceiveProps({ actionNames: [[LOAD_DATA]] }); 104 | 105 | expect(RouteDispatcher.componentDispatch.mock.calls).toHaveLength(0); 106 | expect(wrapper.state('previousLocation')).toBe(null); 107 | }); 108 | 109 | test('does not dispatch actions when dispatchActions function has not changed', () => { 110 | const dispActionFunc = () => {}; 111 | const wrapper = shallow(); 112 | wrapper.instance().componentWillReceiveProps({ actionNames: dispActionFunc }); 113 | 114 | expect(RouteDispatcher.componentDispatch.mock.calls).toHaveLength(0); 115 | expect(wrapper.state('previousLocation')).toBe(null); 116 | }); 117 | 118 | test('dispatches actions when location has changed', done => { 119 | const newLocation = { pathname: '/root' }; 120 | const wrapper = shallow( 121 | ); 126 | 127 | wrapper.instance().componentWillReceiveProps({ location: newLocation }); 128 | 129 | expect(wrapper.state('previousLocation')).toEqual(location); 130 | setImmediate(() => { 131 | expect(RouteDispatcher.componentDispatch.mock.calls).toHaveLength(1); 132 | expect(wrapper.state('previousLocation')).toEqual(null); 133 | done(); 134 | }); 135 | }); 136 | 137 | test('dispatches actions when dispatchActions has changed', done => { 138 | const wrapper = shallow( 139 | ); 144 | 145 | wrapper.instance().componentWillReceiveProps({ actionNames: [[LOAD_DATA, PARSE_DATA]] }); 146 | 147 | expect(wrapper.state('previousLocation')).toEqual(location); 148 | setImmediate(() => { 149 | expect(RouteDispatcher.componentDispatch.mock.calls).toHaveLength(1); 150 | expect(wrapper.state('previousLocation')).toEqual(null); 151 | done(); 152 | }); 153 | }); 154 | 155 | test('dispatches actions when dispatchActions function has changed', done => { 156 | const dispActionFunc1 = () => {}; 157 | const dispActionFunc2 = () => {}; 158 | 159 | const wrapper = shallow( 160 | ); 165 | 166 | wrapper.instance().componentWillReceiveProps({ actionNames: dispActionFunc2 }); 167 | 168 | expect(wrapper.state('previousLocation')).toEqual(location); 169 | setImmediate(() => { 170 | expect(RouteDispatcher.componentDispatch.mock.calls).toHaveLength(1); 171 | expect(wrapper.state('previousLocation')).toEqual(null); 172 | done(); 173 | }); 174 | }); 175 | }); 176 | 177 | describe('render', () => { 178 | test('displays default loading component before actions have been dispatched', () => { 179 | const wrapper = shallow(); 180 | expect(wrapper.html()).toBe('
Loading...
'); 181 | }); 182 | 183 | test('displays loading component before actions have been dispatched', () => { 184 | class Indicator extends React.Component { 185 | render() { 186 | return
component
; 187 | } 188 | } 189 | 190 | const wrapper = shallow(); 191 | expect(wrapper.html()).toBe('
component
'); 192 | }); 193 | 194 | test('displays loading stateless component before actions have been dispatched', () => { 195 | const wrapper = shallow(
stateless
} location={location} routes={defaultRoutes} dispatchActionsOnFirstRender={true} actionNames={DEFAULT_ACTION_NAMES} />); 196 | expect(wrapper.html()).toBe('
stateless
'); 197 | }); 198 | 199 | test('displays loading markup before actions have been dispatched', () => { 200 | const wrapper = shallow(); 201 | expect(wrapper.html()).toBe('
markup
'); 202 | }); 203 | 204 | test('renders routes', () => { 205 | const mockRender = jest.fn(() => null); 206 | const routes = []; 207 | mount( 208 | 209 | 210 | 211 | ); 212 | 213 | expect(mockRender.mock.calls).toHaveLength(1); 214 | expect(mockRender.mock.calls[0][0]).toEqual(routes); 215 | expect(mockRender.mock.calls[0][1]).toEqual({ renderProp: '1' }); 216 | }); 217 | 218 | test('returns null if no routes exist', () => { 219 | const wrapper = mount( 220 | 221 | 222 | 223 | ); 224 | expect(wrapper.html()).toBe(null); 225 | }); 226 | }); 227 | }); 228 | -------------------------------------------------------------------------------- /src/__tests__/dispatchRouteActions.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { matchRoutes } from 'react-router-config'; 4 | import withActions from '../withActions'; 5 | import { 6 | dispatchRouteActions, 7 | dispatchServerActions, 8 | dispatchClientActions, 9 | resolveActionSets, 10 | resolveRouteComponents, 11 | reduceActionSets, 12 | dispatchComponentActions, 13 | parseDispatchActions 14 | } from '../dispatchRouteActions'; 15 | 16 | let order = []; 17 | let orderedParams = []; 18 | const appendOrder = (id) => order.push(id); 19 | const appendParams = (props, routeCtx) => orderedParams.push([props, routeCtx]); 20 | 21 | const LOAD_DATA = 'loadData'; 22 | const PARSE_DATA = 'parseData'; 23 | 24 | const defaultActionParams = { 25 | httpResponse: { 26 | statusCode: 200 27 | } 28 | }; 29 | 30 | function initRoutes(opts = {}) { 31 | const { 32 | mockInitServerAction, 33 | mockLoadDataMapToProps, 34 | mockInitClientAction, 35 | mockParseDataMapToProps, 36 | mockRootAction, 37 | mockHomeAction 38 | } = Object.assign({ 39 | mockInitServerAction: jest.fn(p => p), 40 | mockLoadDataMapToProps: jest.fn(p => p), 41 | mockInitClientAction: jest.fn(p => p), 42 | mockParseDataMapToProps: jest.fn(p => p), 43 | mockRootAction: jest.fn((actionProps, routerCtx) => { 44 | appendOrder(0); appendParams(actionProps, routerCtx); 45 | }), 46 | mockHomeAction: jest.fn((actionProps, routerCtx) => { 47 | appendOrder(1); appendParams(actionProps, routerCtx); 48 | }) 49 | }, opts); 50 | 51 | function loadDataAction() { 52 | return { 53 | name: LOAD_DATA, 54 | staticMethodName: 'primary', 55 | initServerAction: mockInitServerAction, 56 | filterParamsToProps: mockLoadDataMapToProps 57 | }; 58 | } 59 | 60 | function parseDataAction() { 61 | return { 62 | name: PARSE_DATA, 63 | staticMethodName: 'secondary', 64 | initClientAction: mockInitClientAction, 65 | filterParamsToProps: mockParseDataMapToProps 66 | }; 67 | } 68 | 69 | let Root = ({children}) =>
{children}
; 70 | Root.propTypes = {children: PropTypes.any}; 71 | Root.primary = mockRootAction; 72 | Root = withActions(null, loadDataAction())(Root); 73 | 74 | let Home = () =>

Hello World

; 75 | Home.secondary = mockHomeAction; 76 | Home = withActions(null, parseDataAction())(Home); 77 | 78 | const routes = [ 79 | { component: Root, 80 | routes: [ 81 | { path: '/', 82 | exact: true, 83 | component: Home 84 | } 85 | ] 86 | } 87 | ]; 88 | 89 | return { 90 | Home, 91 | Root, 92 | routes, 93 | mocks: { 94 | mockInitServerAction, 95 | mockLoadDataMapToProps, 96 | mockInitClientAction, 97 | mockParseDataMapToProps, 98 | mockRootAction, 99 | mockHomeAction 100 | } 101 | }; 102 | } 103 | 104 | describe('dispatchRouteActions', () => { 105 | 106 | const actions = [[LOAD_DATA, PARSE_DATA]]; 107 | const routeComponentPropNames = ['component']; 108 | const actionParams = {}; 109 | let location; 110 | let routes, Home, Root, mocks; 111 | 112 | beforeEach(() => { 113 | order = []; // reset 114 | orderedParams = []; 115 | 116 | const init = initRoutes(); 117 | routes = init.routes; 118 | mocks = init.mocks; 119 | Home = init.Home; 120 | Root = init.Root; 121 | location = '/'; 122 | }); 123 | 124 | describe('dispatchRouteActions', () => { 125 | test('resolveRouteComponents', () => { 126 | const branch = matchRoutes(routes, location); 127 | const resolved = resolveRouteComponents(branch, routeComponentPropNames); 128 | 129 | expect(resolved).toHaveLength(2); 130 | expect(resolved[0][0]).toEqual(Root); 131 | expect(resolved[1][0]).toEqual(Home); 132 | }); 133 | 134 | test('resolveActionSets - flat', () => { 135 | const match0 = {match: '0'}; 136 | const match1 = {match: '1'}; 137 | 138 | const routeComponents = [ 139 | [Root, match0], 140 | [Home, match1] 141 | ]; 142 | 143 | const actionSets = resolveActionSets(routeComponents, actions); 144 | 145 | expect(actionSets).toHaveLength(2); 146 | expect(actionSets[0].routeActions).toHaveLength(1); 147 | expect(actionSets[1].routeActions).toHaveLength(1); 148 | 149 | expect(actionSets[0].routeActions[0][0]).toEqual(Root.primary); 150 | expect(actionSets[0].routeActions[0][1]).toEqual(match0); 151 | 152 | expect(actionSets[1].routeActions[0][0]).toEqual(Home.secondary); 153 | expect(actionSets[1].routeActions[0][1]).toEqual(match1); 154 | }); 155 | 156 | test('resolveActionSets - serial', () => { 157 | const match0 = {match: '0'}; 158 | const match1 = {match: '1'}; 159 | 160 | const routeComponents = [ 161 | [Root, match0], 162 | [Home, match1] 163 | ]; 164 | 165 | const actionSets = resolveActionSets(routeComponents, [[LOAD_DATA], [PARSE_DATA]]); 166 | expect(actionSets).toHaveLength(2); 167 | 168 | expect(actionSets[0].routeActions).toHaveLength(1); 169 | expect(actionSets[0].routeActions[0][0]).toEqual(Root.primary); 170 | expect(actionSets[0].routeActions[0][1]).toEqual(match0); 171 | 172 | expect(actionSets[1].routeActions).toHaveLength(1); 173 | expect(actionSets[1].routeActions[0][0]).toEqual(Home.secondary); 174 | expect(actionSets[1].routeActions[0][1]).toEqual(match1); 175 | }); 176 | 177 | test('reduceActionSets - parallel', done => { 178 | 179 | jest.setTimeout(2500); 180 | 181 | const mocks = [ 182 | jest.fn(() => appendOrder(0)), 183 | jest.fn(() => appendOrder(1)), 184 | jest.fn(() => appendOrder(2)) 185 | ]; 186 | 187 | const mockFilterFn = jest.fn((params) => params); 188 | const mockInitFn = jest.fn((params) => params); 189 | 190 | const mockMapFns = [ 191 | jest.fn((params) => params), 192 | jest.fn((params) => params), 193 | jest.fn((params) => params) 194 | ]; 195 | 196 | let inputParams = { hello: 'world' }; 197 | const match = {match: '0'}; 198 | const location = { pathname: '/' }; 199 | const routerCtx = {}; 200 | const reduced = reduceActionSets([{ 201 | initParams: mockInitFn, 202 | filterParams: mockFilterFn, 203 | actionErrorHandler: e => { throw e; }, 204 | actionSuccessHandler: () => null, 205 | stopServerActions: () => false, 206 | routeActions: [ 207 | // TODO: Add MAP FNs 208 | [(m, h) => new Promise(resolve => { setTimeout(() => { mocks[0](m, h); resolve(); }, 300) }), match, routerCtx, mockMapFns[0]], 209 | [(m, h) => new Promise(resolve => { setTimeout(() => { mocks[1](m, h); resolve(); }, 200) }), match, routerCtx, mockMapFns[1]], 210 | [(m, h) => new Promise(resolve => { setTimeout(() => { mocks[2](m, h); resolve(); }, 100) }), match, routerCtx, mockMapFns[2]] 211 | ] 212 | }], location, inputParams); 213 | 214 | reduced.then((outputParams) => { 215 | // verify output 216 | expect(outputParams).toEqual(defaultActionParams); 217 | 218 | // verify mocks 219 | expect(mockFilterFn.mock.calls).toHaveLength(1); 220 | expect(mockFilterFn.mock.calls[0][0]).toEqual(defaultActionParams); 221 | 222 | expect(mockInitFn.mock.calls).toHaveLength(1); 223 | expect(mockInitFn.mock.calls[0][0]).toEqual(defaultActionParams); 224 | 225 | expect(mocks[0].mock.calls).toHaveLength(1); 226 | expect(mocks[0].mock.calls[0][0]).toEqual({ ...defaultActionParams, ...inputParams, location, match }); 227 | 228 | expect(mockMapFns[0].mock.calls).toHaveLength(1); 229 | expect(mockMapFns[0].mock.calls[0][0]).toEqual(inputParams); 230 | 231 | expect(mockMapFns[1].mock.calls).toHaveLength(1); 232 | expect(mockMapFns[1].mock.calls[0][0]).toEqual(inputParams); 233 | 234 | expect(mocks[1].mock.calls).toHaveLength(1); 235 | expect(mocks[1].mock.calls[0][0]).toEqual({ ...defaultActionParams, ...inputParams, location, match }); 236 | 237 | expect(mockMapFns[2].mock.calls).toHaveLength(1); 238 | expect(mockMapFns[2].mock.calls[0][0]).toEqual(inputParams); 239 | 240 | expect(mocks[2].mock.calls).toHaveLength(1); 241 | expect(mocks[2].mock.calls[0][0]).toEqual({ ...defaultActionParams, ...inputParams, location, match }); 242 | 243 | // verify order 244 | expect(order).toEqual([2,1,0]); 245 | done(); 246 | }); 247 | }); 248 | 249 | test('reduceActionSets - serial', done => { 250 | 251 | jest.setTimeout(2500); 252 | 253 | const mockActionFns = [ 254 | jest.fn(() => appendOrder(0)), 255 | jest.fn(() => appendOrder(1)), 256 | jest.fn(() => appendOrder(2)) 257 | ]; 258 | 259 | const mockFilterFns = [ 260 | jest.fn((params) => params), 261 | jest.fn((params) => params), 262 | jest.fn((params) => params) 263 | ]; 264 | 265 | const mockInitFns = [ 266 | jest.fn((params) => params), 267 | jest.fn((params) => params), 268 | jest.fn((params) => params) 269 | ]; 270 | 271 | const mockMapFns = [ 272 | jest.fn((params) => params), 273 | jest.fn((params) => params), 274 | jest.fn((params) => params) 275 | ]; 276 | 277 | let inputParams = { hello: 'world' }; 278 | const match = {match: '0'}; 279 | const location = { pathname: '/' }; 280 | const routerCtx = {}; 281 | const reduced = reduceActionSets([{ 282 | initParams: mockInitFns[0], 283 | filterParams: mockFilterFns[0], 284 | actionErrorHandler: e => { throw e; }, 285 | actionSuccessHandler: () => null, 286 | stopServerActions: () => false, 287 | routeActions: [ 288 | [(m, h) => new Promise(resolve => { setTimeout(() => { mockActionFns[0](m, h); resolve(); }, 300) }), match, routerCtx, mockMapFns[0]] 289 | ] 290 | }, { 291 | initParams: mockInitFns[1], 292 | filterParams: mockFilterFns[1], 293 | actionErrorHandler: e => { throw e; }, 294 | actionSuccessHandler: () => null, 295 | stopServerActions: () => false, 296 | routeActions: [ 297 | [(m, h) => new Promise(resolve => { setTimeout(() => { mockActionFns[1](m, h); resolve(); }, 200) }), match, routerCtx, mockMapFns[1]] 298 | ] 299 | }, { 300 | initParams: mockInitFns[2], 301 | filterParams: mockFilterFns[2], 302 | actionErrorHandler: e => { throw e; }, 303 | actionSuccessHandler: () => null, 304 | stopServerActions: () => false, 305 | routeActions: [ 306 | [(m, h) => new Promise(resolve => { setTimeout(() => { mockActionFns[2](m, h); resolve(); }, 100) }), match, routerCtx, mockMapFns[2]] 307 | ] 308 | }], location, inputParams); 309 | 310 | reduced.then((outputParams) => { 311 | // verify output 312 | expect(outputParams).toEqual(defaultActionParams); 313 | 314 | // verify mocks 315 | expect(mockActionFns[0].mock.calls).toHaveLength(1); 316 | expect(mockActionFns[0].mock.calls[0][0]).toEqual({ ...defaultActionParams, ...inputParams, location, match }); 317 | 318 | expect(mockFilterFns[0].mock.calls).toHaveLength(1); 319 | expect(mockFilterFns[0].mock.calls[0][0]).toEqual(defaultActionParams); 320 | 321 | expect(mockInitFns[0].mock.calls).toHaveLength(1); 322 | expect(mockInitFns[0].mock.calls[0][0]).toEqual(defaultActionParams); 323 | 324 | expect(mockMapFns[0].mock.calls).toHaveLength(1); 325 | expect(mockMapFns[0].mock.calls[0][0]).toEqual(inputParams); 326 | 327 | expect(mockActionFns[1].mock.calls).toHaveLength(1); 328 | expect(mockActionFns[1].mock.calls[0][0]).toEqual({ ...defaultActionParams, ...inputParams, location, match }); 329 | 330 | expect(mockFilterFns[1].mock.calls).toHaveLength(1); 331 | expect(mockFilterFns[1].mock.calls[0][0]).toEqual(defaultActionParams); 332 | 333 | expect(mockInitFns[1].mock.calls).toHaveLength(1); 334 | expect(mockInitFns[1].mock.calls[0][0]).toEqual(defaultActionParams); 335 | 336 | expect(mockMapFns[1].mock.calls).toHaveLength(1); 337 | expect(mockMapFns[1].mock.calls[0][0]).toEqual(inputParams); 338 | 339 | expect(mockActionFns[2].mock.calls).toHaveLength(1); 340 | expect(mockActionFns[2].mock.calls[0][0]).toEqual({ ...defaultActionParams, ...inputParams, location, match }); 341 | 342 | expect(mockFilterFns[2].mock.calls).toHaveLength(1); 343 | expect(mockFilterFns[2].mock.calls[0][0]).toEqual(defaultActionParams); 344 | 345 | expect(mockInitFns[2].mock.calls).toHaveLength(1); 346 | expect(mockInitFns[2].mock.calls[0][0]).toEqual(defaultActionParams); 347 | 348 | expect(mockMapFns[2].mock.calls).toHaveLength(1); 349 | expect(mockMapFns[2].mock.calls[0][0]).toEqual(inputParams); 350 | 351 | // verify order 352 | expect(order).toEqual([0,1,2]); 353 | done(); 354 | }); 355 | }); 356 | 357 | test('reduceActionSets - serial with stopServerAction should prevent action invocations', done => { 358 | 359 | jest.setTimeout(2500); 360 | 361 | const mockActionFns = [ 362 | jest.fn(() => appendOrder(0)), 363 | jest.fn(() => appendOrder(1)), 364 | jest.fn(() => appendOrder(2)) 365 | ]; 366 | 367 | const mockInitFns = [ 368 | jest.fn((params) => params), 369 | jest.fn((params) => params), 370 | jest.fn((params) => params) 371 | ]; 372 | 373 | const mockFilterFns = [ 374 | jest.fn((params) => params), 375 | jest.fn((params) => params), 376 | jest.fn((params) => params) 377 | ]; 378 | 379 | const mockMapFns = [ 380 | jest.fn((params) => params), 381 | jest.fn((params) => params), 382 | jest.fn((params) => params) 383 | ]; 384 | 385 | const mockStopServerActions = jest.fn(); 386 | let inputParams = { hello: 'world' }; 387 | const match = {match: '0'}; 388 | const location = { pathname: '/' }; 389 | const routerCtx = {}; 390 | const reduced = reduceActionSets([{ 391 | initParams: mockInitFns[0], 392 | filterParams: mockFilterFns[0], 393 | actionErrorHandler: e => { throw e; }, 394 | actionSuccessHandler: () => null, 395 | stopServerActions: () => false, 396 | routeActions: [ 397 | [(m, h) => new Promise(resolve => { setTimeout(() => { mockActionFns[0](m, h); resolve(); }, 300) }), match, routerCtx, mockMapFns[0]] 398 | ] 399 | }, { 400 | initParams: mockInitFns[1], 401 | filterParams: mockFilterFns[1], 402 | actionErrorHandler: e => { throw e; }, 403 | actionSuccessHandler: () => null, 404 | stopServerActions: () => true, 405 | routeActions: [ 406 | [(m, h) => new Promise(resolve => { setTimeout(() => { mockActionFns[1](m, h); resolve(); }, 200) }), match, routerCtx, mockMapFns[1]] 407 | ] 408 | }, { 409 | initParams: mockInitFns[2], 410 | filterParams: mockFilterFns[2], 411 | actionErrorHandler: e => { throw e; }, 412 | actionSuccessHandler: () => null, 413 | stopServerActions: mockStopServerActions, 414 | routeActions: [ 415 | [(m, h) => new Promise(resolve => { setTimeout(() => { mockActionFns[2](m, h); resolve(); }, 100) }), match, routerCtx, mockMapFns[2]] 416 | ] 417 | }], location, inputParams); 418 | 419 | reduced.then((outputParams) => { 420 | // verify output 421 | expect(outputParams).toEqual(defaultActionParams); 422 | 423 | // verify mocks 424 | expect(mockActionFns[0].mock.calls).toHaveLength(1); 425 | expect(mockActionFns[0].mock.calls[0][0]).toEqual({ ...defaultActionParams, ...inputParams, location, match }); 426 | 427 | expect(mockFilterFns[0].mock.calls).toHaveLength(1); 428 | expect(mockFilterFns[0].mock.calls[0][0]).toEqual(defaultActionParams); 429 | 430 | expect(mockInitFns[0].mock.calls).toHaveLength(1); 431 | expect(mockInitFns[0].mock.calls[0][0]).toEqual(defaultActionParams); 432 | 433 | expect(mockMapFns[0].mock.calls).toHaveLength(1); 434 | 435 | expect(mockActionFns[1].mock.calls).toHaveLength(1); 436 | expect(mockActionFns[1].mock.calls[0][0]).toEqual({ ...defaultActionParams, ...inputParams, location, match }); 437 | 438 | expect(mockFilterFns[1].mock.calls).toHaveLength(1); 439 | expect(mockFilterFns[1].mock.calls[0][0]).toEqual(defaultActionParams); 440 | 441 | expect(mockInitFns[1].mock.calls).toHaveLength(1); 442 | expect(mockInitFns[1].mock.calls[0][0]).toEqual(defaultActionParams); 443 | 444 | expect(mockMapFns[1].mock.calls).toHaveLength(1); 445 | 446 | // The last actionSet should NOT be invoked 447 | expect(mockActionFns[2].mock.calls).toHaveLength(0); 448 | expect(mockInitFns[2].mock.calls).toHaveLength(0); 449 | expect(mockFilterFns[2].mock.calls).toHaveLength(0); 450 | expect(mockMapFns[2].mock.calls).toHaveLength(0); 451 | 452 | expect(mockStopServerActions.mock.calls).toHaveLength(0); 453 | 454 | // verify order 455 | expect(order).toEqual([0,1]); 456 | done(); 457 | }); 458 | }); 459 | 460 | test('returns promise when no routes matched', done => { 461 | const { mockHomeAction, mockRootAction } = mocks; 462 | const p = dispatchRouteActions( 463 | { pathname: '/helloworld' }, 464 | actions, 465 | { routes: [], routeComponentPropNames }, 466 | actionParams); 467 | 468 | p.then(() => { 469 | expect(mockHomeAction.mock.calls).toHaveLength(0); 470 | expect(mockRootAction.mock.calls).toHaveLength(0); 471 | done(); 472 | }); 473 | }); 474 | 475 | test('returns promise when routes matched - dispatchAction function', done => { 476 | const { mockHomeAction, mockRootAction } = mocks; 477 | const p = dispatchRouteActions( 478 | { pathname: '/' }, 479 | () => [[LOAD_DATA, PARSE_DATA]], 480 | { routes, routeComponentPropNames }, 481 | actionParams); 482 | 483 | p.then(() => { 484 | expect(mockHomeAction.mock.calls).toHaveLength(1); 485 | expect(mockRootAction.mock.calls).toHaveLength(1); 486 | expect(order).toEqual([0, 1]); 487 | 488 | // verify props 489 | expect(orderedParams[0][0].location).toBeDefined(); 490 | expect(orderedParams[0][0].match).toBeDefined(); 491 | expect(orderedParams[0][0].httpResponse).toBeDefined(); 492 | 493 | // verify route params 494 | expect(orderedParams[0][1].route).toBeDefined(); 495 | expect(orderedParams[0][1].routeComponentKey).toBeDefined(); 496 | done(); 497 | }); 498 | }); 499 | 500 | test('returns promise when routes matched - flat', done => { 501 | const { mockHomeAction, mockRootAction } = mocks; 502 | const p = dispatchRouteActions( 503 | { pathname: '/' }, 504 | actions, 505 | { routes, routeComponentPropNames }, 506 | actionParams); 507 | 508 | p.then(() => { 509 | expect(mockHomeAction.mock.calls).toHaveLength(1); 510 | expect(mockRootAction.mock.calls).toHaveLength(1); 511 | expect(order).toEqual([0, 1]); 512 | done(); 513 | }); 514 | }); 515 | 516 | test('returns promise when routes matched - serial', done => { 517 | const { mockHomeAction, mockRootAction } = mocks; 518 | const p = dispatchRouteActions( 519 | { pathname: '/' }, 520 | [[LOAD_DATA], [PARSE_DATA]], 521 | { routes, routeComponentPropNames }, 522 | actionParams); 523 | 524 | p.then(() => { 525 | expect(mockHomeAction.mock.calls).toHaveLength(1); 526 | expect(mockRootAction.mock.calls).toHaveLength(1); 527 | expect(order).toEqual([0, 1]); 528 | done(); 529 | }); 530 | }); 531 | 532 | test('dispatchServerActions does not invoke client actions', done => { 533 | const { 534 | mockHomeAction, 535 | mockRootAction, 536 | mockInitServerAction, 537 | mockLoadDataMapToProps, 538 | mockInitClientAction, 539 | mockParseDataMapToProps 540 | } = mocks; 541 | 542 | const p = dispatchServerActions( 543 | { pathname: '/' }, 544 | [[LOAD_DATA], [PARSE_DATA]], 545 | { routes, routeComponentPropNames }, 546 | actionParams); 547 | 548 | p.then(() => { 549 | expect(mockRootAction.mock.calls).toHaveLength(1); 550 | expect(mockHomeAction.mock.calls).toHaveLength(0); 551 | expect(order).toEqual([0]); 552 | 553 | // Verify action mocks 554 | expect(mockInitServerAction.mock.calls).toHaveLength(1); 555 | expect(mockLoadDataMapToProps.mock.calls).toHaveLength(1); 556 | expect(mockInitClientAction.mock.calls).toHaveLength(0); 557 | expect(mockParseDataMapToProps.mock.calls).toHaveLength(0); 558 | 559 | done(); 560 | }); 561 | }); 562 | 563 | test('dispatchComponentActions does not invoke mapper or init functions', done => { 564 | const { 565 | mockHomeAction, 566 | mockRootAction, 567 | mockInitServerAction, 568 | mockLoadDataMapToProps, 569 | mockInitClientAction, 570 | mockParseDataMapToProps 571 | } = mocks; 572 | 573 | const p = dispatchComponentActions( 574 | { pathname: '/' }, 575 | [[LOAD_DATA], [PARSE_DATA]], 576 | { routes, routeComponentPropNames }, 577 | actionParams); 578 | 579 | p.then(() => { 580 | expect(mockRootAction.mock.calls).toHaveLength(1); 581 | expect(mockHomeAction.mock.calls).toHaveLength(1); 582 | expect(order).toEqual([0, 1]); 583 | 584 | // Verify action mocks 585 | expect(mockInitServerAction.mock.calls).toHaveLength(0); 586 | expect(mockLoadDataMapToProps.mock.calls).toHaveLength(0); 587 | expect(mockInitClientAction.mock.calls).toHaveLength(0); 588 | expect(mockParseDataMapToProps.mock.calls).toHaveLength(0); 589 | 590 | done(); 591 | }); 592 | }); 593 | 594 | test('dispatchClientActions does not invoke server actions', () => { 595 | 596 | // Custom init for client dispatcher tests 597 | const { routes, mocks } = initRoutes({ 598 | mockInitClientAction: jest.fn(p => ({ ...p, clientData: {} })), 599 | mockHomeAction: jest.fn((actionParams/*, routerCtx*/) => { 600 | actionParams.clientData.value = 1 601 | }) 602 | }); 603 | const { 604 | mockHomeAction, 605 | mockRootAction, 606 | mockInitServerAction, 607 | mockLoadDataMapToProps, 608 | mockInitClientAction, 609 | mockParseDataMapToProps 610 | } = mocks; 611 | 612 | const props = dispatchClientActions( 613 | { pathname: '/' }, 614 | [[LOAD_DATA], [PARSE_DATA]], 615 | { routes, routeComponentPropNames }, 616 | actionParams); 617 | 618 | expect(mockRootAction.mock.calls).toHaveLength(0); 619 | expect(mockHomeAction.mock.calls).toHaveLength(1); 620 | 621 | // Verify action mocks 622 | expect(mockInitClientAction.mock.calls).toHaveLength(1); 623 | expect(mockParseDataMapToProps.mock.calls).toHaveLength(1); 624 | expect(mockInitServerAction.mock.calls).toHaveLength(0); 625 | expect(mockLoadDataMapToProps.mock.calls).toHaveLength(0); 626 | 627 | expect(props).toEqual({ clientData: { value: 1 }, httpResponse: { statusCode: 200 } }); 628 | }); 629 | }); 630 | 631 | describe('parseDispatchActions', () => { 632 | test('convert single action to action set', () => { 633 | expect(parseDispatchActions('redir')).toEqual([['redir']]); 634 | }); 635 | 636 | test('convert single action array to action set', () => { 637 | expect(parseDispatchActions(['redir'])).toEqual([['redir']]); 638 | }); 639 | 640 | test('convert multiple action array to action set', () => { 641 | expect(parseDispatchActions(['redir', 'status'])).toEqual([['redir', 'status']]); 642 | }); 643 | 644 | test('returns single action set as action set', () => { 645 | expect(parseDispatchActions([['redir']])).toEqual([['redir']]); 646 | }); 647 | 648 | test('returns action set', () => { 649 | expect(parseDispatchActions([['redir', 'status']])).toEqual([['redir', 'status']]); 650 | }); 651 | 652 | test('converts combination of actions to action sets (1a)', () => { 653 | expect(parseDispatchActions([ 654 | 'first', 655 | ['second'], 656 | ['third', 'fourth'] 657 | ])).toEqual([ 658 | ['first'], 659 | ['second'], 660 | ['third', 'fourth'] 661 | ]); 662 | }); 663 | 664 | test('converts combination of actions to action sets (2a)', () => { 665 | expect(parseDispatchActions([ 666 | ['first', 'second'], 667 | 'third', 668 | ['fourth'] 669 | ])).toEqual([ 670 | ['first', 'second'], 671 | ['third'], 672 | ['fourth'] 673 | ]); 674 | }); 675 | 676 | test('converts combination of actions to action sets (3a)', () => { 677 | expect(parseDispatchActions([ 678 | ['first', 'second'], 679 | ['third'], 680 | 'fourth' 681 | ])).toEqual([ 682 | ['first', 'second'], 683 | ['third'], 684 | ['fourth'] 685 | ]); 686 | }); 687 | 688 | test('converts combination of actions to action sets (4a)', () => { 689 | expect(parseDispatchActions([ 690 | ['first'], 691 | ['second', 'third'], 692 | 'fourth' 693 | ])).toEqual([ 694 | ['first'], 695 | ['second', 'third'], 696 | ['fourth'] 697 | ]); 698 | }); 699 | }); 700 | }); 701 | -------------------------------------------------------------------------------- /src/__tests__/enzyme.js: -------------------------------------------------------------------------------- 1 | import { render, shallow, mount, ShallowWrapper, ReactWrapper, configure, EnzymeAdapter } from 'enzyme'; 2 | import Adapter from 'enzyme-adapter-react-16'; 3 | configure({ adapter: new Adapter() }); 4 | 5 | export { 6 | render, 7 | shallow, 8 | mount, 9 | ShallowWrapper, 10 | ReactWrapper, 11 | configure, 12 | EnzymeAdapter 13 | }; 14 | -------------------------------------------------------------------------------- /src/__tests__/withActions.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import withActions from '../withActions'; 3 | import { mount } from './enzyme'; 4 | 5 | class TestComponent extends React.Component { 6 | 7 | static redundantMethod() { 8 | return ''; 9 | } 10 | 11 | render() { 12 | return
test
; 13 | } 14 | } 15 | 16 | describe('withActions', () => { 17 | 18 | beforeEach(() => { 19 | }); 20 | 21 | afterEach(() => { 22 | }); 23 | 24 | test('validates action', () => { 25 | expect(() => withActions({})).toThrow(/mapParamsToProps/); 26 | expect(() => withActions(false)).toThrow(/mapParamsToProps/); 27 | 28 | expect(() => withActions(null, {})).toThrow(/name/); 29 | expect(() => withActions(null, { name: 1 })).toThrow(/name/); 30 | 31 | expect(() => withActions(null, { name: 'n' })).toThrow(/staticMethodName/); 32 | expect(() => withActions(null, { name: 'n', staticMethodName: 1 })).toThrow(/staticMethodName/); 33 | 34 | expect(() => withActions(null, { 35 | name: 'n', 36 | staticMethodName: 's', 37 | filterParamsToProps: false 38 | })).toThrow(/filterParamsToProps/); 39 | 40 | expect(() => withActions(null, { 41 | name: 'n', 42 | staticMethodName: 's', 43 | })).toThrow(/filterParamsToProps/); 44 | 45 | expect(() => withActions(null, { 46 | name: 'n', 47 | staticMethodName: 's', 48 | filterParamsToProps: () => {}, 49 | initServerAction: 1 50 | })).toThrow(/initServerAction/); 51 | 52 | expect(() => withActions(null, { 53 | name: 'n', 54 | staticMethodName: 's', 55 | filterParamsToProps: () => {}, 56 | initClientAction: 1 57 | })).toThrow(/initClientAction/); 58 | 59 | expect(() => withActions(null, { 60 | name: 'n', 61 | staticMethodName: 's', 62 | filterParamsToProps: () => {}, 63 | hoc: 1 64 | })).toThrow(/hoc/); 65 | }); 66 | 67 | test('throws when component has not defined the static method required by the action', () => { 68 | expect(() => withActions(null, { 69 | name: 'n', 70 | staticMethodName: 's', 71 | filterParamsToProps: () => {}, 72 | })(TestComponent)).toThrow(/missing the required static/); 73 | }); 74 | 75 | test('does not throw when action defines static method', () => { 76 | expect(() => withActions(null, { 77 | name: 'n', 78 | staticMethod: () => {}, 79 | staticMethodName: 'redundantMethod', 80 | filterParamsToProps: () => {}, 81 | })(TestComponent)).not.toThrow(); 82 | }); 83 | 84 | describe('getDispatcherActions', () => { 85 | let actionComponent; 86 | beforeAll(() => { 87 | actionComponent = withActions(null, { 88 | name: 'action1', 89 | staticMethod: () => {}, 90 | staticMethodName: 'method1', 91 | filterParamsToProps: () => {}, 92 | }, { 93 | name: 'action2', 94 | staticMethod: () => {}, 95 | initServerAction: p => p, 96 | staticMethodName: 'method1', 97 | filterParamsToProps: () => {}, 98 | }, { 99 | name: 'action3', 100 | initClientAction: p => p, 101 | staticMethod: () => {}, 102 | staticMethodName: 'method1', 103 | filterParamsToProps: () => {}, 104 | })(TestComponent); 105 | }); 106 | 107 | test('returns all assigned methods', () => { 108 | expect( 109 | actionComponent.getDispatcherActions().map(a => a.name)) 110 | .toEqual(['action1', 'action2', 'action3']); 111 | }); 112 | 113 | test('returns filtered action names', () => { 114 | expect( 115 | actionComponent.getDispatcherActions(['action1', 'action3']).map(a => a.name)) 116 | .toEqual(['action1', 'action3']); 117 | }); 118 | 119 | test('returns filtered actions', () => { 120 | expect( 121 | actionComponent 122 | .getDispatcherActions(null, a => typeof a.initServerAction === 'function') 123 | .map(a => a.name)) 124 | .toEqual(['action2']); 125 | }); 126 | 127 | test('applies multiple withActions() actions to component', () => { 128 | actionComponent = withActions(null, { 129 | name: 'action1', 130 | staticMethod: () => {}, 131 | staticMethodName: 'method1', 132 | filterParamsToProps: () => {}, 133 | })(TestComponent); 134 | actionComponent = withActions(null, { 135 | name: 'action2', 136 | staticMethod: () => {}, 137 | initServerAction: p => p, 138 | staticMethodName: 'method2', 139 | filterParamsToProps: () => {}, 140 | })(actionComponent); 141 | actionComponent = withActions(null, { 142 | name: 'action3', 143 | initClientAction: p => p, 144 | staticMethod: () => {}, 145 | staticMethodName: 'method3', 146 | filterParamsToProps: () => {}, 147 | })(actionComponent); 148 | 149 | expect( 150 | actionComponent.getDispatcherActions().map(a => a.name)) 151 | .toEqual(['action1', 'action2', 'action3']); 152 | }); 153 | 154 | test('applies withActions() to a null component', () => { 155 | actionComponent = withActions(null, { 156 | name: 'action1', 157 | staticMethod: () => {}, 158 | filterParamsToProps: () => {}, 159 | })(null); 160 | 161 | expect( 162 | actionComponent.getDispatcherActions().map(a => a.name)) 163 | .toEqual(['action1']); 164 | }); 165 | }); 166 | 167 | test('applies action HOC', () => { 168 | const HOC = (actionName) => (Component) => { 169 | const wrapped = (props) => ( 170 |
171 | {actionName} 172 | 173 |
174 | ); 175 | wrapped.displayName = 'wrapped'; 176 | return wrapped; 177 | }; 178 | 179 | const ActionComponent = withActions(null, { 180 | name: 'action1', 181 | staticMethod: () => {}, 182 | filterParamsToProps: () => {}, 183 | hoc: HOC('action1') 184 | }, { 185 | name: 'action2', 186 | staticMethod: () => {}, 187 | filterParamsToProps: () => {}, 188 | hoc: HOC('action2') 189 | }, { 190 | name: 'action3', 191 | staticMethod: () => {}, 192 | filterParamsToProps: () => {}, 193 | hoc: HOC('action3') 194 | })(TestComponent); 195 | 196 | const wrapper = mount(Hello World); 197 | expect( 198 | wrapper.html()) 199 | .toBe('
action1
action2
action3
test
'); 200 | }); 201 | }); 202 | -------------------------------------------------------------------------------- /src/createRouteDispatchers.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | import React from 'react'; 3 | import { parsePath } from 'history' 4 | import hoistNonReactStatic from 'hoist-non-react-statics'; 5 | import reactDisplayName from 'react-display-name'; 6 | import invariant from 'invariant'; 7 | import warning from 'warning'; 8 | import RouteDispatcher, { DEFAULT_COMPONENT_PROP_NAMES } from './RouteDispatcher'; 9 | import { getRouteComponents } from './dispatchRouteActions'; 10 | 11 | const __DEV__ = process.env.NODE_ENV !== 'production'; 12 | 13 | function RouteDispatcherHoc(displayNamePrefix, routeConfig, options) { 14 | const routerDispatcher = ({ routes, ...props }) => { 15 | return ( 16 | ); 21 | }; 22 | 23 | routerDispatcher.displayName = `${displayNamePrefix}(${reactDisplayName(RouteDispatcher)})`; 24 | 25 | routerDispatcher.propTypes = RouteDispatcher.propTypes; 26 | 27 | return hoistNonReactStatic(routerDispatcher, RouteDispatcher); 28 | } 29 | 30 | function mergeActions(flatActions, actionNames) { 31 | return flatActions.reduce((actions, actionName) => { 32 | if (actions.indexOf(actionName) === -1) { 33 | actions.push(actionName); 34 | } 35 | 36 | return actions; 37 | }, actionNames); 38 | } 39 | 40 | function getActionNames(actions) { 41 | return actions.map(action => { 42 | if (Array.isArray(action)) { 43 | return getActionNames(action); 44 | } 45 | 46 | if (typeof action.name === 'string') { 47 | return action.name; 48 | } 49 | 50 | invariant(false, `invalid dispatcher action 'name', expected string but encountered ${action.name}`); 51 | }); 52 | } 53 | 54 | function findRouteActions(routes, routeComponentPropNames, actionNames = []) { 55 | routes.forEach(route => { 56 | getRouteComponents(route, routeComponentPropNames).forEach(({ routeComponent }) => { 57 | if (typeof routeComponent.getDispatcherActions === 'function') { 58 | mergeActions(getActionNames(routeComponent.getDispatcherActions()), actionNames); 59 | } 60 | }); 61 | 62 | if (Array.isArray(route.routes)) { 63 | findRouteActions(route.routes, routeComponentPropNames, actionNames); 64 | } 65 | }); 66 | 67 | return actionNames; 68 | } 69 | 70 | function flattenActions(actions) { 71 | if (!Array.isArray(actions)) { 72 | return [actions]; 73 | } 74 | 75 | return actions.reduce((flatActions, action) => { 76 | if (Array.isArray(action)) { 77 | Array.prototype.push.apply(flatActions, flattenActions(action)); 78 | } 79 | else { 80 | flatActions.push(action); 81 | } 82 | 83 | return flatActions; 84 | }, []); 85 | } 86 | 87 | function createDispatchAction(dispatchFuncName, pathAndQuery, params, options) { 88 | invariant(typeof pathAndQuery === 'string', 'pathAnyQuery expects a string'); 89 | 90 | const { actionNames, routes, routeComponentPropNames } = options; 91 | return RouteDispatcher[dispatchFuncName]( 92 | parsePath(pathAndQuery), 93 | actionNames, 94 | { routes, routeComponentPropNames }, 95 | Object.assign({}, params)); 96 | } 97 | 98 | // use a factory method to simplify server usage 99 | export default function createRouteDispatchers(routeConfig, actionNames, options = {}) { 100 | invariant(Array.isArray(routeConfig), 'routeConfig expects an array of routes'); 101 | 102 | const dispatchOpts = Object.assign( 103 | { routeComponentPropNames: DEFAULT_COMPONENT_PROP_NAMES }, 104 | options, 105 | { actionNames: actionNames, routes: routeConfig }); 106 | 107 | const { routes, ...componentOptions } = dispatchOpts; 108 | 109 | // If no actions are configured, determine actions from component configuration 110 | if (typeof dispatchOpts.actionNames === 'undefined' || dispatchOpts.actionNames === null) { 111 | dispatchOpts.actionNames = findRouteActions(routes, dispatchOpts.routeComponentPropNames); 112 | } 113 | else if (__DEV__) { 114 | const configuredActionNames = flattenActions(dispatchOpts.actionNames); 115 | const routeActionNames = findRouteActions(routes, dispatchOpts.routeComponentPropNames); 116 | 117 | const unconfiguredActions = routeActionNames.filter(actionName => configuredActionNames.indexOf(actionName) === -1); 118 | const unusedActions = configuredActionNames.filter(actionName => routeActionNames.indexOf(actionName) === -1); 119 | warning(unconfiguredActions.length === 0, `The actions '${unconfiguredActions.join(', ')}' are used by route components, but are not configured for use by the route dispatcher.`); 120 | warning(unusedActions.length === 0, `The actions '${unusedActions.join(', ')}' are configured for use with the route dispatcher, but no route components have the action(s) applied.`); 121 | } 122 | 123 | return { 124 | 125 | /** 126 | * The configured action name(s). Useful for debugging purposes. 127 | */ 128 | actionNames: dispatchOpts.actionNames.slice(), 129 | 130 | /** 131 | * dispatch route actions on the server. 132 | * 133 | * @param pathAndQuery string the requested url path and query 134 | * @param params Object params for actions 135 | * @param options [Object] options for server dispatching 136 | * @returns {*} Components for rendering routes 137 | */ 138 | dispatchServerActions: (pathAndQuery, params = {}, options = {}) => 139 | createDispatchAction('dispatchServerActions', pathAndQuery, params, { ...dispatchOpts, ...options }), 140 | 141 | /** 142 | * Synchronous client dispatcher 143 | * 144 | * @param pathAndQuery 145 | * @param params 146 | * @param options 147 | */ 148 | dispatchClientActions: (pathAndQuery, params = {}, options = {}) => 149 | createDispatchAction('dispatchClientActions', pathAndQuery, params, { ...dispatchOpts, ...options }), 150 | 151 | ClientRouteDispatcher: RouteDispatcherHoc( 152 | 'ClientRouteDispatcher', 153 | routes, 154 | componentOptions), 155 | 156 | UniversalRouteDispatcher: RouteDispatcherHoc( 157 | 'UniversalRouteDispatcher', 158 | routes, 159 | { ...componentOptions, dispatchActionsOnFirstRender: false }) 160 | }; 161 | } 162 | -------------------------------------------------------------------------------- /src/defineRoutes.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | /** 4 | * Define react-router-config routes, and assigns a key to each route. 5 | * 6 | * @param routes 7 | * @param idx 8 | * @returns {Array} Routes with a key value assigned to each route 9 | */ 10 | export default function defineRoutes(routes: Array, idx?: number = 0) { 11 | routes.forEach(route => { 12 | route.key = typeof route.key !== 'undefined' ? route.key : idx++; 13 | if (Array.isArray(route.routes)) { 14 | defineRoutes(route.routes, idx); 15 | } 16 | }); 17 | 18 | return routes; 19 | } 20 | -------------------------------------------------------------------------------- /src/dispatchRouteActions.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | import React from 'react'; 3 | import invariant from 'invariant'; 4 | import { matchRoutes } from 'react-router-config'; 5 | 6 | // TODO: Remove the need for any default parameters 7 | const defaultParams = { 8 | httpResponse: { 9 | statusCode: 200 10 | } 11 | }; 12 | 13 | function isRouteComponent(routeComponent) { 14 | return React.isValidElement(routeComponent) || typeof routeComponent === 'function'; 15 | } 16 | 17 | function addRouteComponent(component, match, route, routeComponentKey, target) { 18 | target.push([component, match, { route, routeComponentKey }]); 19 | } 20 | 21 | export function getRouteComponents(route, routeComponentPropNames) { 22 | const routeComponents = []; 23 | routeComponentPropNames.forEach((propName) => { 24 | const routeComponent = route[propName]; 25 | if (isRouteComponent(routeComponent)) { 26 | routeComponents.push({ 27 | routeComponentKey: propName, 28 | routeComponent: routeComponent 29 | }); 30 | } 31 | else if (routeComponent !== null && typeof routeComponent === 'object') { 32 | // support assigning component(s) using key/value pairs (object) 33 | Object.keys(routeComponent).forEach(componentName => { 34 | const component = routeComponent[componentName]; 35 | if (isRouteComponent(component)) { 36 | routeComponents.push({ 37 | routeComponentKey: `${propName}.${componentName}`, 38 | routeComponent: routeComponent 39 | }); 40 | } 41 | }); 42 | } 43 | }); 44 | 45 | return routeComponents; 46 | } 47 | 48 | export function resolveRouteComponents(branch, routeComponentPropNames) { 49 | const routeComponents = []; 50 | branch.forEach(({ route, match }) => { 51 | // get the route component(s) for each route 52 | getRouteComponents(route, routeComponentPropNames).forEach(({ routeComponent, routeComponentKey }) => { 53 | addRouteComponent(routeComponent, match, route, routeComponentKey, routeComponents); 54 | }); 55 | }); 56 | 57 | return routeComponents 58 | } 59 | 60 | export function resolveActionSets(routeComponents, dispatchActions, initParamFuncName, isLifecycleMethod, actionFilter) { 61 | const actionSets = parseDispatchActions(dispatchActions); 62 | const resolvedActionSets = []; 63 | 64 | actionSets.forEach(actionSet => { 65 | actionSet.forEach(actionName => { 66 | const promises = []; 67 | let action = null; 68 | 69 | routeComponents.forEach(([component, match, routerContext]) => { 70 | if (typeof component.getDispatcherActions !== 'function') { 71 | return; 72 | } 73 | 74 | const componentActions = component.getDispatcherActions([actionName], actionFilter); 75 | if (componentActions.length === 0) { 76 | return; 77 | } 78 | 79 | // The dispatcher should invoke each individual action 80 | invariant(componentActions.length === 1, '[react-router-dispatcher]: .getDispatcherActions() returned more than 1 component action.'); 81 | 82 | const componentAction = componentActions[0]; 83 | if (action === null) { 84 | action = componentAction; 85 | } 86 | 87 | // Determine the component mapper - lifecycle methods should NOT map prop values 88 | const componentParamsToProps = isLifecycleMethod ? (p => p) : component.getDispatchParamToProps(); 89 | 90 | const { staticMethod, staticMethodName } = componentAction; 91 | const actionMethod = staticMethod || component[staticMethodName]; 92 | promises.push([actionMethod, match, routerContext, componentParamsToProps]); 93 | }); 94 | 95 | if (action === null) { 96 | return; 97 | } 98 | 99 | const { name, successHandler, errorHandler, stopServerActions, filterParamsToProps } = action; 100 | resolvedActionSets.push({ 101 | name: name, 102 | routeActions: promises, 103 | actionSuccessHandler: typeof successHandler === 'function' ? successHandler : () => null, 104 | actionErrorHandler: typeof errorHandler === 'function' ? errorHandler : err => { throw err }, 105 | stopServerActions: typeof stopServerActions === 'function' ? stopServerActions : false, // here or bundled with route actions? 106 | initParams: 107 | (typeof initParamFuncName === 'string' && action[initParamFuncName]) || (params => params), 108 | filterParams: isLifecycleMethod ? 109 | props => props : 110 | filterParamsToProps 111 | }); 112 | 113 | }); 114 | }); 115 | 116 | return resolvedActionSets; 117 | } 118 | 119 | function createActionSetPromise(resolvedActionSet, location, actionParams, props) { 120 | const { 121 | routeActions, 122 | actionSuccessHandler, 123 | actionErrorHandler, 124 | stopServerActions, 125 | initParams, 126 | filterParams 127 | } = resolvedActionSet; 128 | 129 | // This is a 2-step process - first init & assign the action parameters, this data is returned to the caller 130 | const filteredParams = filterParams(Object.assign(actionParams, initParams(actionParams))); 131 | // Then, append the route 'location' to the props that are passed to all action methods 132 | // - this prevents the 'location' data from being returned to the caller 133 | const filteredProps = Object.assign({ location }, filteredParams); 134 | 135 | // Invoke each route action 136 | return Promise.all(routeActions.map(([componentAction, match, routerContext, componentParamsToProps]) => { 137 | return Promise.resolve(componentAction( 138 | { 139 | ...componentParamsToProps(props, routerContext), 140 | ...filteredProps, 141 | match 142 | }, 143 | routerContext)); 144 | }) 145 | ) 146 | 147 | // Invoke any configured post-action handlers 148 | .then(() => Promise.resolve(actionSuccessHandler(filteredProps))) 149 | 150 | // Handle any action-specific error(s) 151 | .catch(err => actionErrorHandler(err, filteredProps)) 152 | 153 | // determine if the next action set should be invoked 154 | .then(() => Promise.resolve( 155 | // eslint-disable-next-line no-unused-vars 156 | !routeActions.some(([ componentAction, match, routerContext ]) => 157 | stopServerActions === false ? 158 | false : 159 | stopServerActions({ ...filteredProps, match }, routerContext)) 160 | )); 161 | } 162 | 163 | export function reduceActionSets(resolvedActionSets, location, props) { 164 | const actionParams = Object.assign({}, defaultParams); 165 | let promiseActionSet = Promise.resolve(true); // always start w/true to invoke the first actionSet 166 | 167 | while (resolvedActionSets.length > 0) { 168 | const resolvedActionSet = resolvedActionSets.shift(); // IMPORTANT: don't refactor this inside the promise fn 169 | promiseActionSet = promiseActionSet 170 | .then((invokeActions) => 171 | invokeActions ? createActionSetPromise(resolvedActionSet, location, actionParams, props) : Promise.resolve(invokeActions)); 172 | } 173 | 174 | return promiseActionSet.then(() => Promise.resolve(actionParams)); 175 | } 176 | 177 | export function matchRouteComponents(location, routes, routeComponentPropNames) { 178 | const branch = matchRoutes(routes, location.pathname); 179 | if (!branch.length) { 180 | return []; 181 | } 182 | 183 | return resolveRouteComponents(branch, routeComponentPropNames); 184 | } 185 | 186 | export function dispatchRouteActions(location, actions, routeConfig, props, initParamFuncName, isLifecycleMethod, actionFilter) { 187 | const { routes, routeComponentPropNames } = routeConfig; 188 | 189 | // Determine all RouteComponent(s) matched for the current route 190 | const routeComponents = matchRouteComponents(location, routes, routeComponentPropNames); 191 | if (routeComponents.length === 0) { 192 | return Promise.resolve(); 193 | } 194 | 195 | const dispatchActions = typeof actions === 'function' ? 196 | parseDispatchActions(actions(location, props)) : 197 | actions; 198 | 199 | const actionSets = resolveActionSets( 200 | routeComponents, 201 | dispatchActions, 202 | initParamFuncName, 203 | isLifecycleMethod, 204 | actionFilter); 205 | 206 | return reduceActionSets(actionSets, location, props); 207 | } 208 | 209 | export function parseDispatchActions(dispatchActions) { 210 | if (typeof dispatchActions === 'string') { 211 | return [[dispatchActions]]; 212 | } 213 | 214 | if (Array.isArray(dispatchActions)) { 215 | 216 | // Is a single action set defined? 217 | if (dispatchActions.every(action => typeof action === 'string')) { 218 | return [dispatchActions]; 219 | } 220 | 221 | return dispatchActions.map(actionSet => { 222 | if (Array.isArray(actionSet)) { 223 | return actionSet; 224 | } 225 | 226 | if (typeof actionSet === 'string') { 227 | return [actionSet]; 228 | } 229 | 230 | invariant(false, `Invalid dispatch action, '${actionSet}', expected string or array.`); 231 | }); 232 | } 233 | 234 | invariant(false, 'Invalid dispatch actions, expected string or array.'); 235 | } 236 | 237 | export function standardizeActionNames(dispatchActions) { 238 | if (typeof dispatchActions === 'function') { 239 | return dispatchActions; 240 | } 241 | 242 | return parseDispatchActions(dispatchActions); 243 | } 244 | 245 | function isClientAction(action) { 246 | return typeof action.initClientAction === 'function'; 247 | } 248 | 249 | function isServerAction(action) { 250 | return typeof action.initServerAction === 'function'; 251 | } 252 | 253 | /** 254 | * Dispatches asynchronous actions during a react components lifecycle 255 | * 256 | * @param location 257 | * @param actionNames 258 | * @param routeConfig 259 | * @param props 260 | */ 261 | export function dispatchComponentActions(location, actionNames, routeConfig, props) { 262 | return dispatchRouteActions( 263 | location, 264 | actionNames, 265 | routeConfig, 266 | props, 267 | 'initComponentAction', 268 | true); 269 | } 270 | 271 | /** 272 | * Dispatches asynchronous actions on the server 273 | * 274 | * @param location 275 | * @param actionNames 276 | * @param routeConfig 277 | * @param props 278 | * @returns {*} 279 | */ 280 | export function dispatchServerActions(location, actionNames, routeConfig, props) { 281 | return dispatchRouteActions( 282 | location, 283 | standardizeActionNames(actionNames), 284 | routeConfig, 285 | props, 286 | 'initServerAction', 287 | false, 288 | isServerAction); 289 | } 290 | 291 | /** 292 | * Dispatches synchronous actions on the client. 293 | * 294 | * @param location 295 | * @param actionNames 296 | * @param routeConfig 297 | * @param props 298 | * @returns {*} 299 | */ 300 | export function dispatchClientActions(location, actionNames, routeConfig, props) { 301 | const { routes, routeComponentPropNames } = routeConfig; 302 | 303 | const actionParams = Object.assign({}, defaultParams); // used for internal action parameters 304 | const clientActionSets = standardizeActionNames(actionNames); 305 | const routeComponents = matchRouteComponents( 306 | location, 307 | routes, 308 | routeComponentPropNames); 309 | 310 | clientActionSets.forEach(actionSet => { 311 | 312 | routeComponents.forEach(([component, match, routerCtx]) => { 313 | if (typeof component.getDispatcherActions !== 'function') { 314 | return; 315 | } 316 | 317 | const componentActions = component.getDispatcherActions(actionSet, isClientAction); 318 | const componentParamsToProps = component.getDispatchParamToProps(); 319 | 320 | componentActions.forEach(({ staticMethod, staticMethodName, filterParamsToProps, initClientAction }) => { 321 | const componentActionMethod = staticMethod || component[staticMethodName]; 322 | 323 | componentActionMethod( 324 | { 325 | ...componentParamsToProps(props, routerCtx), 326 | ...filterParamsToProps(Object.assign(actionParams, initClientAction(actionParams))), 327 | location, 328 | match 329 | }, 330 | routerCtx); 331 | }); 332 | }); 333 | }); 334 | 335 | return actionParams; 336 | } 337 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import defineRoutes from './defineRoutes'; 2 | import { matchRouteComponents } from './dispatchRouteActions'; 3 | import RouteDispatcher from './RouteDispatcher'; 4 | import createRouteDispatchers from './createRouteDispatchers'; 5 | import withActions from './withActions'; 6 | 7 | export { 8 | defineRoutes, 9 | createRouteDispatchers, 10 | RouteDispatcher, 11 | matchRouteComponents, 12 | withActions 13 | }; 14 | -------------------------------------------------------------------------------- /src/withActions.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | import React from 'react'; 3 | import getDisplayName from 'react-display-name'; 4 | import hoistNonReactStatics from 'hoist-non-react-statics'; 5 | import invariant from 'invariant'; 6 | import warning from 'warning'; 7 | 8 | const __DEV__ = process.env.NODE_ENV !== 'production'; 9 | 10 | function hoistSafeStatics(targetComponent, sourceComponent, blacklist = {}) { 11 | return hoistNonReactStatics(targetComponent, sourceComponent, Object.assign({ 12 | // prevent hoisting the following static methods 13 | getDispatcherActions: true, 14 | getDispatchParamToProps: true, 15 | getDispatchParamsToProps: true, 16 | appendActionDispatcher: true, 17 | WrappedComponent: true 18 | }, blacklist)); 19 | } 20 | 21 | export default function withActions(mapParamsToProps, ...actions) { 22 | if (!(typeof mapParamsToProps === 'undefined' || mapParamsToProps === null)) { 23 | invariant(typeof mapParamsToProps === 'function', '"mapParamsToProps" must be either a function, or null.'); 24 | } 25 | 26 | if (typeof actions !== 'undefined' && actions !== null) { 27 | actions = Array.isArray(actions) ? actions : [actions]; 28 | } 29 | 30 | invariant(actions.length !== 0, '"withActions(null, [action])" requires a minimum of 1 action, pass null as the first parameter if no "mapParamsToProps" function is required.'); 31 | 32 | const paramsToProps = mapParamsToProps || (params => params); 33 | 34 | if (__DEV__) { 35 | actions.forEach( 36 | ({ 37 | name, 38 | staticMethod, 39 | staticMethodName, 40 | filterParamsToProps, 41 | initServerAction, 42 | initClientAction, 43 | hoc 44 | }) => { 45 | 46 | invariant(typeof name !== 'undefined', `Action requires a 'name' property.`); 47 | invariant(typeof name === 'string', `Action expects 'name' to be a string.`); 48 | 49 | if (typeof staticMethod !== 'undefined') { 50 | invariant( 51 | typeof staticMethod === 'function', 52 | `Action '${name}' expects 'staticMethod' to be a function.`); 53 | } 54 | else { 55 | invariant( 56 | typeof staticMethodName !== 'undefined', 57 | `Action '${name}' requires a 'staticMethodName' property.`); 58 | invariant( 59 | typeof staticMethodName === 'string', 60 | `Action '${name}' expects 'staticMethodName' to be a string.`); 61 | } 62 | 63 | invariant( 64 | typeof filterParamsToProps !== 'undefined', 65 | `Action '${name}' requires a 'filterParamsToProps' property.`); 66 | invariant( 67 | typeof filterParamsToProps === 'function', 68 | `Action '${name}' expects 'filterParamsToProps' to be a function.`); 69 | 70 | if (typeof initServerAction !== 'undefined') { 71 | invariant( 72 | typeof initServerAction === 'function', 73 | `Action '${name}' expects 'initServerAction' to be a function.`); 74 | } 75 | 76 | if (typeof initClientAction !== 'undefined') { 77 | invariant( 78 | typeof initClientAction === 'function', 79 | `Action '${name}' expects 'initClientAction' to be a function.`); 80 | } 81 | 82 | if (typeof hoc !== 'undefined') { 83 | invariant( 84 | typeof hoc === 'function', 85 | `Action '${name}' expects 'hoc' to be a function.`); 86 | } 87 | }); 88 | } 89 | 90 | return (Component) => { 91 | const hocDispatcherActions = actions.slice(); 92 | const isComponentNull = Component === null; 93 | let ComposedComponent = null; 94 | 95 | let actionHOC = (props) => isComponentNull ? null : React.createElement(ComposedComponent, props); 96 | 97 | actionHOC.appendActionDispatcher = function appendActionDispatcher(ActionDispatcherComponent, hocBlacklist = {}) { 98 | if (typeof ActionDispatcherComponent === 'undefined' || 99 | ActionDispatcherComponent === null || 100 | typeof ActionDispatcherComponent.getDispatcherActions !== 'function') { 101 | // Ignore non dispatcher components 102 | return; 103 | } 104 | 105 | Array.prototype.unshift.apply(hocDispatcherActions, ActionDispatcherComponent.getDispatcherActions()); 106 | hoistSafeStatics(actionHOC, ActionDispatcherComponent, hocBlacklist); 107 | }; 108 | 109 | // Compose the actions (components) 110 | ComposedComponent = actions.reduceRight((child, { hoc }) => { 111 | if (typeof hoc !== 'function') { 112 | return child; 113 | } 114 | 115 | const wrappedComponent = hoc(child, actionHOC); 116 | const composedHOC = (child === null || wrappedComponent === child) ? 117 | wrappedComponent : 118 | hoistSafeStatics(wrappedComponent, child); 119 | 120 | if (child !== null) { 121 | composedHOC.WrappedComponent = child; 122 | 123 | if (typeof child.getDispatcherActions === 'function') { 124 | Array.prototype.unshift.apply(hocDispatcherActions, child.getDispatcherActions()); 125 | } 126 | } 127 | 128 | return composedHOC; 129 | }, Component); 130 | 131 | if (actions.length === 1 && !isComponentNull && typeof Component.getDispatcherActions === 'function') { 132 | // reduceRight() is not invoked when there is only a single action 133 | Array.prototype.unshift.apply(hocDispatcherActions, Component.getDispatcherActions()); 134 | } 135 | 136 | if (__DEV__) { 137 | if (!isComponentNull) { 138 | actions.forEach(({name, staticMethod, staticMethodName}) => { 139 | if (typeof staticMethod !== 'function') { 140 | invariant( 141 | typeof ComposedComponent[staticMethodName] === 'function', 142 | `Component '${getDisplayName(Component)}' is using action '${name}' but missing the required static method '${staticMethodName}'.`); 143 | } 144 | else { 145 | warning( 146 | typeof ComposedComponent[staticMethodName] !== 'function', 147 | `Component '${getDisplayName(Component)}' defines the static method '${staticMethodName}' for action '${name}', but it will never be invoked as the action has a static method assigned.`); 148 | } 149 | }); 150 | } 151 | } 152 | 153 | actionHOC.displayName = `withActions(${isComponentNull ? 'null' : getDisplayName(ComposedComponent)})`; 154 | 155 | if (!isComponentNull) { 156 | actionHOC = hoistSafeStatics(actionHOC, ComposedComponent); 157 | actionHOC.WrappedComponent = ComposedComponent; 158 | } 159 | 160 | actionHOC.getDispatcherActions = function getDispatcherActions( 161 | permittedActionNames: Array = [], 162 | filter: (action: Object) => boolean = () => true 163 | ) { 164 | permittedActionNames = permittedActionNames || []; 165 | return hocDispatcherActions.filter(action => { 166 | return (permittedActionNames.length === 0 ? 167 | true : 168 | permittedActionNames.indexOf(action.name) !== -1) && filter(action); 169 | }) 170 | }; 171 | 172 | actionHOC.getDispatchParamToProps = function getDispatchParamToProps() { 173 | return paramsToProps; 174 | }; 175 | 176 | return actionHOC; 177 | }; 178 | } 179 | --------------------------------------------------------------------------------