├── .babelrc ├── .editorconfig ├── .eslintrc.yml ├── .gitignore ├── .npmignore ├── .travis.yml ├── LICENSE ├── README.md ├── docs ├── en-US │ ├── api.md │ └── recipes.md └── zh-CN │ ├── README.md │ ├── api.md │ └── recipes.md ├── examples ├── counter-immer │ ├── .babelrc │ ├── .eslintrc.yml │ ├── README.md │ ├── package.json │ ├── public │ │ └── index.html │ ├── src │ │ ├── App.css │ │ ├── App.jsx │ │ ├── index.jsx │ │ └── models │ │ │ └── counter.js │ └── webpack.config.js ├── counter-immutable │ ├── .babelrc │ ├── .eslintrc.yml │ ├── README.md │ ├── package.json │ ├── public │ │ └── index.html │ ├── src │ │ ├── App.css │ │ ├── App.jsx │ │ ├── index.jsx │ │ └── models │ │ │ └── counter.js │ └── webpack.config.js ├── counter-persist(v5)-immutable │ ├── README.md │ ├── package.json │ ├── public │ │ └── index.html │ ├── src │ │ ├── App.css │ │ ├── App.jsx │ │ ├── autoMergeImmutable.js │ │ ├── index.jsx │ │ └── models │ │ │ └── counter.js │ └── webpack.config.js ├── counter-persist(v5) │ ├── README.md │ ├── package.json │ ├── public │ │ └── index.html │ ├── src │ │ ├── App.css │ │ ├── App.jsx │ │ ├── index.jsx │ │ └── models │ │ │ └── counter.js │ └── webpack.config.js ├── counter-persist-immutable │ ├── .babelrc │ ├── .eslintrc.yml │ ├── README.md │ ├── package.json │ ├── public │ │ └── index.html │ ├── src │ │ ├── App.css │ │ ├── App.jsx │ │ ├── index.jsx │ │ └── models │ │ │ └── counter.js │ └── webpack.config.js ├── counter-persist │ ├── .babelrc │ ├── .eslintrc.yml │ ├── README.md │ ├── package.json │ ├── public │ │ └── index.html │ ├── src │ │ ├── App.css │ │ ├── App.jsx │ │ ├── index.jsx │ │ └── models │ │ │ └── counter.js │ └── webpack.config.js ├── counter-undo │ ├── .babelrc │ ├── .eslintrc.yml │ ├── README.md │ ├── package.json │ ├── public │ │ └── index.html │ ├── src │ │ ├── App.css │ │ ├── App.jsx │ │ ├── index.jsx │ │ └── models │ │ │ └── counter.js │ └── webpack.config.js ├── counter │ ├── .babelrc │ ├── .eslintrc.yml │ ├── README.md │ ├── package.json │ ├── public │ │ └── index.html │ ├── src │ │ ├── App.css │ │ ├── App.jsx │ │ ├── index.jsx │ │ └── models │ │ │ └── counter.js │ └── webpack.config.js └── simple-router │ ├── .babelrc │ ├── .eslintrc.yml │ ├── README.md │ ├── package.json │ ├── public │ └── index.html │ ├── src │ ├── Router.jsx │ ├── components │ │ ├── About.jsx │ │ ├── AddTopic.jsx │ │ ├── Header.jsx │ │ ├── Home.jsx │ │ ├── Topic.jsx │ │ └── Topics.jsx │ └── index.jsx │ └── webpack.config.js ├── mickey.svg ├── package.json ├── src ├── ActionsProvider.js ├── Plugin.js ├── Provider.js ├── actions.js ├── baseModel.js ├── checkModel.js ├── constants.js ├── createApp.js ├── createErrorHandler.js ├── createHistory.js ├── createModel.js ├── createPromiseMiddleware.js ├── createReducer.js ├── createRender.js ├── createStore.js ├── getReducer.js ├── getSaga.js ├── index.js ├── injectActions.js ├── internalModel.js ├── prefixDispatch.js ├── registerModel.js ├── utils.js └── watcher.js └── test ├── index.js ├── setup.js └── spec ├── ActionsProvider.spec.js ├── Plugin.spec.js ├── checkModel.spec.js ├── createApp.spec.js ├── createErrorHandler.spec.js ├── createHistory.spec.js ├── createModel.spec.js ├── getSaga.spec.js ├── injectActions.spec.js ├── utils.spec.js └── watcher.spec.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | [ 4 | "env", 5 | { 6 | "modules": false, 7 | "loose": true, 8 | "useBuiltIns": true 9 | } 10 | ], 11 | "react", 12 | "stage-0" 13 | ], 14 | "plugins": [ 15 | "transform-runtime" 16 | ], 17 | "env": { 18 | "test": { 19 | "presets": [ 20 | [ 21 | "env" 22 | ], 23 | "react", 24 | "stage-0" 25 | ], 26 | "plugins": [ 27 | "istanbul", 28 | "transform-decorators-legacy" 29 | ] 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig is awesome: http://EditorConfig.org 2 | 3 | # top-most EditorConfig file 4 | root = true 5 | 6 | [*] 7 | charset = utf-8 8 | end_of_line = lf 9 | trim_trailing_whitespace = true 10 | insert_final_newline = true 11 | indent_style = space 12 | indent_size = 2 13 | 14 | [*.md] 15 | trim_trailing_whitespace = false 16 | 17 | [Makefile] 18 | indent_style = tab 19 | -------------------------------------------------------------------------------- /.eslintrc.yml: -------------------------------------------------------------------------------- 1 | extends: eslint-config-airbnb 2 | parser: babel-eslint 3 | 4 | env: 5 | es6: true 6 | node: true 7 | mocha: true 8 | browser: true 9 | 10 | rules: 11 | semi: 12 | - warn 13 | - never 14 | arrow-body-style: 15 | - error 16 | - as-needed 17 | no-param-reassign: 18 | - error 19 | - props: false 20 | 21 | parserOptions: 22 | ecmaFeatures: 23 | experimentalObjectRestSpread: true 24 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .idea 3 | .vscode 4 | .nyc_output 5 | npm-debug.log 6 | node_modules 7 | dist 8 | /lib 9 | /test/coverage 10 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .idea 3 | .vscode 4 | .eslintrc.yml 5 | .babelrc 6 | .npmignore 7 | .gitignore 8 | .editorconfig 9 | .nyc_output 10 | npm-debug.log 11 | node_modules 12 | /test/coverage 13 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: false 2 | language: node_js 3 | 4 | node_js: 5 | - "6" 6 | - "7" 7 | 8 | install: npm install 9 | 10 | script: 11 | - npm run lint 12 | - npm test 13 | 14 | after_success: 15 | - npm run coveralls 16 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 W.Y. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Mickey 2 | 3 | mickey.svg 4 | 5 | > Lightweight front-end framework for creating React and Redux based app painlessly. 6 | 7 | > Totally base on [redux](https://github.com/reactjs/redux), [redux-saga](https://github.com/yelouafi/redux-saga) and [react-router](https://github.com/ReactTraining/react-router), very friendly to redux users. 8 | 9 | > (Inspired by [dva](https://github.com/dvajs/dva)) 10 | 11 | [![MIT License](https://img.shields.io/badge/license-MIT_License-green.svg?style=flat-square)](https://github.com/mickeyjsx/mickey/blob/master/LICENSE) 12 | 13 | [![NPM Version](https://img.shields.io/npm/v/mickey.svg?style=flat-square)](https://www.npmjs.com/package/mickey) 14 | [![Build Status](https://img.shields.io/travis/mickeyjsx/mickey.svg?style=flat)](https://travis-ci.org/mickeyjsx/mickey) 15 | [![Coverage Status](https://img.shields.io/coveralls/mickeyjsx/mickey.svg?style=flat)](https://coveralls.io/r/mickeyjsx/mickey) 16 | [![NPM downloads](http://img.shields.io/npm/dm/mickey.svg?style=flat)](https://npmjs.org/package/mickey) 17 | [![Dependencies](https://david-dm.org/mickeyjsx/mickey/status.svg)](https://david-dm.org/mickeyjsx/mickey) 18 | [![Package Quality](http://npm.packagequality.com/shield/mickey.svg)](http://packagequality.com/#?package=mickey) 19 | 20 | [查看中文](./docs/zh-CN/README.md) 21 | 22 | ## Features 23 | 24 | - **Minimal API** (Only 6 newly introduced). Easy to learn, easy to start 25 | - **No [`diapatch`](https://github.com/reactjs/react-redux/blob/master/docs/api.md#inject-just-dispatch-and-dont-listen-to-store), no [`put`](https://redux-saga.js.org/docs/api/#putaction)**, just forget about [action types](http://redux.js.org/docs/basics/Actions.html) 26 | - **Support loading models dynamically** with [code-splitting](https://webpack.js.org/guides/code-splitting/) to improve performance 27 | - **Support HMR** for components and models with [babel-plugin-mickey-model-loader](https://github.com/mickeyjsx/babel-plugin-mickey-model-loader) 28 | - **Full-featured hook mechanism** 29 | 30 | ## Quick Start 31 | 32 | Use [create-react-app](https://github.com/facebookincubator/create-react-app) to create an app: 33 | 34 | ```shell 35 | $ npm i -g create-react-app 36 | $ create-react-app my-app 37 | ``` 38 | 39 | Then install mickey from npm: 40 | 41 | ```shell 42 | $ cd my-app 43 | $ npm install mickey --save 44 | $ npm start 45 | ``` 46 | 47 | Update `index.js` as follow: 48 | 49 | ```jsx 50 | import React from 'react' 51 | import createApp, {connect, injectActions} from 'mickey' 52 | 53 | // 1. Initialize 54 | const app = createApp() 55 | 56 | // 2. Model 57 | app.model({ 58 | namespace: 'counter', 59 | state: { 60 | count: 0, 61 | loading: false, 62 | }, 63 | increment: state => ({ ...state, count: state.count + 1 }), 64 | decrement: state => ({ ...state, count: state.count - 1 }), 65 | incrementAsync: { 66 | * effect(payload, { call }, { succeed }) { 67 | const delay = timeout => new Promise((resolve) => { 68 | setTimeout(resolve, timeout) 69 | }) 70 | yield call(delay, 2000) 71 | yield succeed() 72 | }, 73 | prepare: state => ({ ...state, loading: true }), 74 | succeed: state => ({ ...state, count: state.count + 1, loading: false }), 75 | }, 76 | }) 77 | 78 | // 3. Component 79 | const Comp = (props) => ( 80 |
81 |

{props.counter.count}

82 | 83 | 84 | 85 |
86 | ) 87 | 88 | // 4. Connect state with component and inject `actions` 89 | const App = injectActions( 90 | connect(state => ({ counter: state.counter })(Comp)) 91 | ) 92 | 93 | // 5. View 94 | app.render(, document.getElementById('root')) 95 | ``` 96 | 97 | ## Examples 98 | 99 | - [Counter](./examples/counter): Basic usage of mickey 100 | - [Counter-Persist](./examples/counter-persist): Work with [redux-persist](https://github.com/rt2zz/redux-persist) 101 | - [Counter-Persist(v5)](./examples/counter-persist(v5)): Work with [redux-persist v5](https://github.com/rt2zz/redux-persist) 102 | - [Counter-Immutable](./examples/counter-immutable): Work with [ImmutableJS](https://github.com/facebook/immutable-js) 103 | - [Counter-Immer](./examples/counter-immer): Work with [Immer](https://github.com/mweststrate/immer) 104 | - [Counter-Persist-Immutable](./examples/counter-persist-immutable): Work with [redux-persist](https://github.com/rt2zz/redux-persist) and [ImmutableJS](https://github.com/facebook/immutable-js) 105 | - [Counter-Persist(v5)-Immutable](./examples/counter-persist(v5)-immutable): Work with [redux-persist v5](https://github.com/rt2zz/redux-persist) and [ImmutableJS](https://github.com/facebook/immutable-js) 106 | - [Counter-Undo](./examples/counter-undo): Work with [redux-undo](https://github.com/omnidan/redux-undo) 107 | - [Simple-Router](./examples/simple-router): Base on [react-router@4.x](https://reacttraining.com/react-router/) 108 | - [mickey-todo](https://github.com/mickeyjsx/mickey-todo) ([demo](https://mickeyjsx.github.io/todo)): Simple Todo application build with mickey 109 | - [mickey-vstar](https://github.com/mickeyjsx/mickey-vstar) ([demo](http://mickeyjsx.github.io/vstar)): A web app to show your or others GitHub repos stars 110 | - [HackerNews](https://github.com/mickeyjsx/mickey-hackernews) ([demo](http://mickeyjsx.github.io/hackernews)): [HackerNews](https://github.com/vuejs/vue-hackernews-2.0) clone built with mickey 111 | 112 | ## More 113 | 114 | - [API Reference](./docs/en-US/api.md) 115 | - [API 文档](./docs/zh-CN/api.md) 116 | - [mickey.svg](./mickey.svg) badaged in this document is download from [Free Vectors](http://all-free-download.com/free-vector/download/disney-disney-vector_288586.html) 117 | 118 | 119 | ## Related 120 | 121 | - [mickey-model-extend](https://github.com/mickeyjsx/mickey-model-extend) Utility method to extend mickey model 122 | - [babel-plugin-mickey-model-loader](https://github.com/mickeyjsx/babel-plugin-mickey-model-loader) Inject a model loader function to mickey instance with hmr support 123 | - [babel-plugin-mickey-model-validator](https://github.com/mickeyjsx/babel-plugin-mickey-model-validator) Validate models shipped by mickey to avoid certain syntax pitfalls 124 | 125 | 126 | ## Contributing 127 | 128 | Pull requests and stars are highly welcome. 129 | 130 | For bugs and feature requests, please [create an issue](https://github.com/mickeyjsx/mickey/issues/new). 131 | -------------------------------------------------------------------------------- /docs/en-US/api.md: -------------------------------------------------------------------------------- 1 | # API 2 | 3 | [查看中文](../zh-CN/api.md) 4 | 5 | ## Module exports 6 | 7 | 1. Default export a initialize method: `import createApp from 'mickey'` 8 | 2. Component and method 9 | - [<ActionsProvider actions>](#actionsprovider-actions) 10 | - [injectActions({propName = 'actions', withRef = false})](#injectactionscomponent-propname--actions-withref--false) 11 | 3. Directly export The following components and methods from [dependencies](https://github.com/mickeyjsx/mickey/blob/master/package.json#L31). 12 | 13 | - [redux](https://github.com/reactjs/redux) 14 | - [compose](http://redux.js.org/docs/api/compose.html) 15 | - [applyMiddleware](http://redux.js.org/docs/api/applyMiddleware.html) 16 | - [react-redux](https://github.com/reactjs/react-redux) 17 | - [connect](https://github.com/reactjs/react-redux/blob/master/docs/api.md#connectmapstatetoprops-mapdispatchtoprops-mergeprops-options) 18 | - [react-router](https://reacttraining.com/react-router/) 19 | - [StaticRouter](https://reacttraining.com/react-router/core/api/StaticRouter) 20 | - [MemoryRouter](https://reacttraining.com/react-router/web/api/MemoryRouter) 21 | - [Redirect](https://reacttraining.com/react-router/web/api/Redirect) 22 | - [Prompt](https://reacttraining.com/react-router/core/api/Prompt) 23 | - [Switch](https://reacttraining.com/react-router/core/api/Switch) 24 | - [Route](https://reacttraining.com/react-router/core/api/Route) 25 | - [withRouter](https://reacttraining.com/react-router/core/api/withRouter) 26 | - [react-router-dom](https://github.com/ReactTraining/react-router/tree/master/packages/react-router-dom) 27 | - [HashRouter](https://reacttraining.com/react-router/web/api/HashRouter) 28 | - [BrowserRouter](https://reacttraining.com/react-router/web/api/BrowserRouter) 29 | - [Link](https://reacttraining.com/react-router/web/api/Link) 30 | - [NavLink](https://reacttraining.com/react-router/web/api/NavLink) 31 | 32 | ## API 33 | 34 | **Initialization** 35 | 36 | - [createApp(options)](#createappoptions) 37 | - [options.initialState](#optionsinitialstate) 38 | - [options.initialReducer](#optionsinitialreducer) 39 | - [options.historyMode](#optionshistorymode) 40 | - [options.hooks](#optionshooks) 41 | - [options.hooks.onError](#optionshooksonerror) 42 | - [options.hooks.onAction](#optionshooksonaction) 43 | - [options.hooks.onEffect](#optionshooksoneffect) 44 | - [options.hooks.onReducer](#optionshooksonreducer) 45 | - [options.hooks.onStateChange](#optionshooksonstatechange) 46 | - [options.hooks.extraReducers](#optionshooksextrareducers) 47 | - [options.hooks.extraEnhancers](#optionshooksextraenhancers) 48 | - [options.extensions](#optionsextensions) 49 | - [options.extensions.createReducer](#createreducer) 50 | - [options.extensions.combineReducers](#combinereducers) 51 | 52 | **Methods** 53 | 54 | - [app.model(model)](#appmodelmodel) 55 | - [model.namespace](#modelnamespace) 56 | - [model.state](#modelstate) 57 | - [model.watcher](#modelwatcher) 58 | - [model.enhancers](#modelenhancers) 59 | - [model.createReducer](#modelcreatereducer) 60 | - [model[...actionsAndEffects]](#modelactionsandeffects) 61 | - [app.eject(namespace)](#appejectnamespace) 62 | - [app.has(namespace)](#apphasnamespace) 63 | - [app.load(pattern)](#apploadpattern) 64 | - [app.render(component, container, callback)](#apprendercomponent-container-callback) 65 | 66 | **Properties** 67 | 68 | - app.store 69 | - app.history 70 | - app.actions 71 | - app.plugin 72 | 73 | ### createApp(options) 74 | 75 | Create an instance of Mickey and return it: 76 | 77 | ```es6 78 | import createApp from 'mickey'; 79 | const app = createApp(options); 80 | ``` 81 | 82 | 83 | ## <ActionsProvider actions> 84 | 85 | Makes the `actions` available to the `injectActions()` calls in the component hierarchy below. We should never use this component, and it was used in the `render` method, like this: 86 | 87 | ``` 88 | 89 | 90 | 91 | ``` 92 | 93 | ## injectActions(Component, {propName = 'actions', withRef = false}) 94 | 95 | Inject `actions` to a React component. By default the propName would be `actions`. If `withRef` is true, stores a ref to the wrapped component instance and makes it available via getWrappedInstance() method. 96 | 97 | For example, [Counter](https://github.com/mickeyjsx/mickey/blob/master/examples/counter): 98 | 99 | ```jsx 100 | import React from 'react' 101 | import { connect, injectActions } from 'mickey' 102 | import './App.css' 103 | 104 | const App = props => ( 105 |
106 |

{props.count}

107 |
108 | 109 | 110 | 122 |
123 |
124 | ) 125 | 126 | export default injectActions(connect(store => ({ ...store.counter }))(App)) 127 | ``` 128 | -------------------------------------------------------------------------------- /docs/en-US/recipes.md: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mickeyjsx/mickey/af21557b89620f9425b2a3f191c94e0186189897/docs/en-US/recipes.md -------------------------------------------------------------------------------- /docs/zh-CN/README.md: -------------------------------------------------------------------------------- 1 | # Mickey 2 | 3 | mickey.svg 4 | 5 | > 一款轻量、高效、易上手的前端框架,无痛开发基于 React 和 Redux 的应用 6 | 7 | > 完全基于 [redux](https://github.com/reactjs/redux),[redux-saga](https://github.com/yelouafi/redux-saga) 和 [react-router](https://github.com/ReactTraining/react-router) 实现,对 Redux 用户极其友好 8 | 9 | > (Inspired by [dva](https://github.com/dvajs/dva)) 10 | 11 | 12 | [![MIT License](https://img.shields.io/badge/license-MIT_License-green.svg?style=flat-square)](https://github.com/mickeyjsx/mickey/blob/master/LICENSE) 13 | 14 | [![NPM Version](https://img.shields.io/npm/v/mickey.svg?style=flat-square)](https://www.npmjs.com/package/mickey) 15 | [![Build Status](https://img.shields.io/travis/mickeyjsx/mickey.svg?style=flat)](https://travis-ci.org/mickeyjsx/mickey) 16 | [![Coverage Status](https://img.shields.io/coveralls/mickeyjsx/mickey.svg?style=flat)](https://coveralls.io/r/mickeyjsx/mickey) 17 | [![NPM downloads](http://img.shields.io/npm/dm/mickey.svg?style=flat)](https://npmjs.org/package/mickey) 18 | [![Dependencies](https://david-dm.org/mickeyjsx/mickey/status.svg)](https://david-dm.org/mickeyjsx/mickey) 19 | [![Package Quality](http://npm.packagequality.com/shield/mickey.svg)](http://packagequality.com/#?package=mickey) 20 | 21 | [View README in English](../../README.md#features) 22 | 23 | ## 特性 24 | 25 | - **轻量的API**:只有 6 个新方法,易学易用 26 | - **不再需要使用 [`diapatch`](https://github.com/reactjs/react-redux/blob/master/docs/api.md#inject-just-dispatch-and-dont-listen-to-store) 和 [`put`](https://redux-saga.js.org/docs/api/#putaction)** 方法,也不需要维护 [action](http://redux.js.org/docs/basics/Actions.html) 字符串 27 | - **支持动态加载**:结合 [code-splitting](https://webpack.js.org/guides/code-splitting/) 可以实现路由和模型动态加载 28 | - **支持 HMR**:结合 [babel-plugin-mickey-model-loader](https://github.com/mickeyjsx/babel-plugin-mickey-model-loader) 实现组件和模型热替换 29 | - 功能完备的**插件机制** 30 | 31 | ## 快速开始 32 | 33 | 使用 [create-react-app](https://github.com/facebookincubator/create-react-app) 创建一个新的 app: 34 | 35 | ```shell 36 | $ npm i -g create-react-app 37 | $ create-react-app my-app 38 | ``` 39 | 40 | 然后安装 mickey: 41 | 42 | ```shell 43 | $ cd my-app 44 | $ npm install mickey --save 45 | $ npm start 46 | ``` 47 | 48 | 修改项目中的 `index.js` 如下: 49 | 50 | ```jsx 51 | import React from 'react' 52 | import createApp, {connect, injectActions} from 'mickey' 53 | 54 | // 1. Initialize 55 | const app = createApp() 56 | 57 | // 2. Model 58 | app.model({ 59 | namespace: 'counter', 60 | state: { 61 | count: 0, 62 | loading: false, 63 | }, 64 | increment: state => ({ ...state, count: state.count + 1 }), 65 | decrement: state => ({ ...state, count: state.count - 1 }), 66 | incrementAsync: { 67 | * effect(payload, { call }, { succeed }) { 68 | const delay = timeout => new Promise((resolve) => { 69 | setTimeout(resolve, timeout) 70 | }) 71 | yield call(delay, 2000) 72 | yield succeed() 73 | }, 74 | prepare: state => ({ ...state, loading: true }), 75 | succeed: state => ({ ...state, count: state.count + 1, loading: false }), 76 | }, 77 | }) 78 | 79 | // 3. Component 80 | const Comp = (props) => ( 81 |
82 |

{props.counter.count}

83 | 84 | 85 | 86 |
87 | ) 88 | 89 | // 4. Connect state with component and inject `actions` 90 | const App = injectActions( 91 | connect(state => ({ counter: state.counter })(Comp) 92 | ) 93 | 94 | // 5. View 95 | app.render(, document.getElementById('root')) 96 | ``` 97 | 98 | ## 示例项目 99 | 100 | - [Counter](../../examples/counter):简单的计数器 101 | - [Counter-Persist](../../examples/counter-persist):搭配 [redux-persist](https://github.com/rt2zz/redux-persist)使用 102 | - [Counter-Immutable](../../examples/counter-immutable):搭配 [ImmutableJS](https://github.com/facebook/immutable-js/) 使用 103 | - [Counter-Persist-Immutable](../../examples/counter-persist-immutable):搭配 [redux-persist](https://github.com/rt2zz/redux-persist) 和 [ImmutableJS](https://github.com/facebook/immutable-js/) 使用 104 | - [Counter-Undo](../../examples/counter-undo):搭配 [redux-undo](https://github.com/omnidan/redux-undo) 使用 105 | - [Simple-Router](./examples/simple-router):基于 [react-router@4.x](https://reacttraining.com/react-router/) 106 | - [mickey-todo](https://github.com/mickeyjsx/mickey-todo) ([demo](https://mickeyjsx.github.io/todo)): 简单的 TODO 应用 107 | - [mickey-vstar](https://github.com/mickeyjsx/mickey-vstar) ([demo](http://mickeyjsx.github.io/vstar)):查询指定 Github 账号中被加星项目并按加星数排序 108 | - [HackerNews](https://github.com/mickeyjsx/mickey-hackernews) ([demo](http://mickeyjsx.github.io/hackernews)):基于 mickey 实现的 [HackerNews](https://github.com/vuejs/vue-hackernews-2.0) 109 | 110 | ## 了解更多 111 | 112 | - [API Reference](../en-US/api.md) 113 | - [API 文档](./api.md) 114 | - [mickey.svg](../../mickey.svg) 下载自 [Free Vectors](http://all-free-download.com/free-vector/download/disney-disney-vector_288586.html) 115 | 116 | ## 相关项目 117 | 118 | - [mickey-model-extend](https://github.com/mickeyjsx/mickey-model-extend) 扩展 mickey model 的工具函数 119 | - [babel-plugin-mickey-model-loader](https://github.com/mickeyjsx/babel-plugin-mickey-model-loader) 向 mickey 实例中注入 `load` 方法并提供 HMR 支持 120 | - [babel-plugin-mickey-model-validator](https://github.com/mickeyjsx/babel-plugin-mickey-model-validator) 验证 mickey 模型中潜在的语法错误,如在异步 action 处理方法中调用 `call` 时忘记使用 `yield` 关键字 121 | 122 | ## 贡献代码 123 | 124 | 非常欢迎给我们提 MR,如果喜欢我们的代码请在右上角加星。 125 | 126 | 发现任何 BUG 和使用问题请给我们[提 ISSUE](https://github.com/mickeyjsx/mickey/issues/new)。 127 | -------------------------------------------------------------------------------- /docs/zh-CN/recipes.md: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mickeyjsx/mickey/af21557b89620f9425b2a3f191c94e0186189897/docs/zh-CN/recipes.md -------------------------------------------------------------------------------- /examples/counter-immer/.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | "es2015", 4 | "react", 5 | "stage-0" 6 | ], 7 | "plugins": [ 8 | "add-module-exports", 9 | "transform-runtime" 10 | ], 11 | "env": { 12 | "test": { 13 | "plugins": [ 14 | "istanbul" 15 | ] 16 | } 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /examples/counter-immer/.eslintrc.yml: -------------------------------------------------------------------------------- 1 | extends: eslint-config-airbnb 2 | parser: babel-eslint 3 | 4 | env: 5 | es6: true 6 | node: true 7 | mocha: true 8 | browser: true 9 | 10 | rules: 11 | semi: 12 | - warn 13 | - never 14 | arrow-body-style: 15 | - error 16 | - as-needed 17 | no-param-reassign: 18 | - error 19 | - props: false 20 | 21 | parserOptions: 22 | ecmaFeatures: 23 | experimentalObjectRestSpread: true 24 | -------------------------------------------------------------------------------- /examples/counter-immer/README.md: -------------------------------------------------------------------------------- 1 | # Counter-Immutable 2 | 3 | > Counter example work with [ImmutableJS](https://github.com/facebook/immutable-js/) for mickey 4 | 5 | ## Usage 6 | 7 | ```bash 8 | $ npm install 9 | $ npm start 10 | ``` 11 | -------------------------------------------------------------------------------- /examples/counter-immer/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "counter-immuatable", 3 | "description": "Counter example work with ImmutableJS for mickey", 4 | "dependencies": { 5 | "immer": "^1.0.1", 6 | "immutable": "^3.8.1", 7 | "mickey": "^1.2.0", 8 | "react": "^15.6.1", 9 | "redux-immutablejs": "^0.0.8" 10 | }, 11 | "devDependencies": { 12 | "babel-cli": "^6.26.0", 13 | "babel-core": "^6.26.0", 14 | "babel-eslint": "^7.2.3", 15 | "babel-loader": "^7.1.2", 16 | "babel-plugin-add-module-exports": "^0.2.1", 17 | "babel-plugin-transform-runtime": "^6.23.0", 18 | "babel-preset-es2015": "^6.24.1", 19 | "babel-preset-react": "^6.24.1", 20 | "babel-preset-stage-0": "^6.24.1", 21 | "cross-env": "^5.0.5", 22 | "css-loader": "^0.28.7", 23 | "eslint": "^4.5.0", 24 | "eslint-config-airbnb": "^15.1.0", 25 | "eslint-plugin-import": "^2.7.0", 26 | "eslint-plugin-jsx-a11y": "^5.1.1", 27 | "eslint-plugin-react": "^7.3.0", 28 | "husky": "^0.14.3", 29 | "rimraf": "^2.6.1", 30 | "style-loader": "^0.18.2", 31 | "webpack": "^3.5.5", 32 | "webpack-dev-server": "^2.7.1" 33 | }, 34 | "scripts": { 35 | "lint": "eslint --ext .js,.jsx src", 36 | "start": "webpack-dev-server --open", 37 | "build": "cross-env NODE_ENV=production webpack -p" 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /examples/counter-immer/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Counter Example - Mickey 7 | 8 | 9 | 10 |
11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /examples/counter-immer/src/App.css: -------------------------------------------------------------------------------- 1 | body { 2 | height: 100vh; 3 | padding: 0; 4 | margin: 0; 5 | } 6 | 7 | #counter-app { 8 | margin: 0 auto; 9 | width: 300px; 10 | } 11 | 12 | h1 { 13 | font-size: 180px; 14 | margin: 0; 15 | text-align: center; 16 | } 17 | 18 | .btn-wrap { 19 | text-align: center; 20 | } 21 | 22 | button { 23 | background-color: #fff; 24 | border: 1px solid #a0a0a0; 25 | border-radius: 4px; 26 | font-size: 120%; 27 | outline: none; 28 | width: 60px; 29 | height: 32px; 30 | margin: 0 10px; 31 | cursor: pointer; 32 | } 33 | -------------------------------------------------------------------------------- /examples/counter-immer/src/App.jsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable react/prop-types */ 2 | 3 | import React from 'react' 4 | import { connect, injectActions } from 'mickey' 5 | import './App.css' 6 | 7 | const App = props => ( 8 |
9 |

{props.count}

10 |
11 | 12 | 13 | 25 |
26 |
27 | ) 28 | 29 | export default injectActions( 30 | connect((store) => { 31 | console.log('data:', store.counter) // eslint-disable-line 32 | return ({ ...store.counter }) 33 | })(App), 34 | ) 35 | -------------------------------------------------------------------------------- /examples/counter-immer/src/index.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import createApp from 'mickey' 3 | import App from './App' 4 | 5 | // 1. Initialize 6 | const app = createApp({ 7 | initialState: {}, 8 | }) 9 | 10 | 11 | // 2. Model 12 | app.model(require('./models/counter.js')) 13 | 14 | // 3. View 15 | app.render(, document.getElementById('root')) 16 | -------------------------------------------------------------------------------- /examples/counter-immer/src/models/counter.js: -------------------------------------------------------------------------------- 1 | import produce from 'immer' 2 | 3 | const delay = timeout => new Promise((resolve) => { 4 | setTimeout(resolve, timeout) 5 | }) 6 | 7 | export default { 8 | namespace: 'counter', 9 | state: { 10 | count: 0, 11 | loading: false, 12 | }, 13 | increment: state => produce(state, (draft) => { draft.count += 1 }), 14 | decrement: state => produce(state, (draft) => { draft.count -= 1 }), 15 | incrementAsync: { 16 | * effect(payload, { call }, { succeed }) { 17 | yield call(delay, 2000) 18 | yield succeed() 19 | }, 20 | prepare: state => produce(state, (draft) => { draft.loading = true }), 21 | succeed: state => produce(state, (draft) => { 22 | draft.count += 1 23 | draft.loading = false 24 | }), 25 | }, 26 | } 27 | -------------------------------------------------------------------------------- /examples/counter-immer/webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | const webpack = require('webpack') 3 | 4 | const config = { 5 | // in development, 6 | // 'webpack-dev-server/client' and 'webpac/hot/dev-server' will be automatically added 7 | entry: [ 8 | './src/index.jsx', 9 | ], 10 | output: { 11 | path: path.resolve(__dirname, 'dist'), 12 | filename: 'app.js', 13 | publicPath: '/', 14 | }, 15 | resolve: { 16 | extensions: ['*', '.js', '.jsx'], 17 | }, 18 | module: { 19 | rules: [ 20 | { 21 | test: /\.jsx?$/, 22 | include: [ 23 | path.resolve(__dirname, 'src'), 24 | ], 25 | loader: 'babel-loader', 26 | }, 27 | { 28 | test: /\.css$/, 29 | use: [ 30 | { 31 | loader: 'style-loader', 32 | }, 33 | { 34 | loader: 'css-loader', 35 | }, 36 | ], 37 | }, 38 | ], 39 | }, 40 | } 41 | 42 | if (process.env.NODE_ENV === 'production') { 43 | config.devtool = 'source-map' 44 | // Exclude react and react-dom in the production bundle 45 | config.externals = { 46 | react: 'React', 47 | 'react-dom': 'ReactDOM', 48 | } 49 | } else { 50 | config.devtool = 'cheap-module-source-map' 51 | config.devServer = { 52 | contentBase: path.resolve(__dirname, 'public'), 53 | clientLogLevel: 'none', 54 | quiet: true, 55 | port: 8000, 56 | compress: true, 57 | hot: true, 58 | historyApiFallback: { 59 | disableDotRule: true, 60 | }, 61 | } 62 | 63 | // HMR support 64 | config.plugins = [ 65 | new webpack.HotModuleReplacementPlugin(), 66 | new webpack.NamedModulesPlugin(), 67 | ] 68 | } 69 | 70 | module.exports = config 71 | -------------------------------------------------------------------------------- /examples/counter-immutable/.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | "es2015", 4 | "react", 5 | "stage-0" 6 | ], 7 | "plugins": [ 8 | "add-module-exports", 9 | "transform-runtime" 10 | ], 11 | "env": { 12 | "test": { 13 | "plugins": [ 14 | "istanbul" 15 | ] 16 | } 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /examples/counter-immutable/.eslintrc.yml: -------------------------------------------------------------------------------- 1 | extends: eslint-config-airbnb 2 | parser: babel-eslint 3 | 4 | env: 5 | es6: true 6 | node: true 7 | mocha: true 8 | browser: true 9 | 10 | rules: 11 | semi: 12 | - warn 13 | - never 14 | arrow-body-style: 15 | - error 16 | - as-needed 17 | no-param-reassign: 18 | - error 19 | - props: false 20 | 21 | parserOptions: 22 | ecmaFeatures: 23 | experimentalObjectRestSpread: true 24 | -------------------------------------------------------------------------------- /examples/counter-immutable/README.md: -------------------------------------------------------------------------------- 1 | # Counter-Immutable 2 | 3 | > Counter example work with [ImmutableJS](https://github.com/facebook/immutable-js/) for mickey 4 | 5 | ## Usage 6 | 7 | ```bash 8 | $ npm install 9 | $ npm start 10 | ``` 11 | -------------------------------------------------------------------------------- /examples/counter-immutable/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "counter-immuatable", 3 | "description": "Counter example work with ImmutableJS for mickey", 4 | "dependencies": { 5 | "immutable": "^3.8.1", 6 | "mickey": "^0.2.4", 7 | "react": "^15.6.1", 8 | "redux-immutablejs": "^0.0.8" 9 | }, 10 | "devDependencies": { 11 | "babel-cli": "^6.26.0", 12 | "babel-core": "^6.26.0", 13 | "babel-eslint": "^7.2.3", 14 | "babel-loader": "^7.1.2", 15 | "babel-plugin-add-module-exports": "^0.2.1", 16 | "babel-plugin-transform-runtime": "^6.23.0", 17 | "babel-preset-es2015": "^6.24.1", 18 | "babel-preset-react": "^6.24.1", 19 | "babel-preset-stage-0": "^6.24.1", 20 | "cross-env": "^5.0.5", 21 | "css-loader": "^0.28.7", 22 | "eslint": "^4.5.0", 23 | "eslint-config-airbnb": "^15.1.0", 24 | "eslint-plugin-import": "^2.7.0", 25 | "eslint-plugin-jsx-a11y": "^5.1.1", 26 | "eslint-plugin-react": "^7.3.0", 27 | "husky": "^0.14.3", 28 | "rimraf": "^2.6.1", 29 | "style-loader": "^0.18.2", 30 | "webpack": "^3.5.5", 31 | "webpack-dev-server": "^2.7.1" 32 | }, 33 | "scripts": { 34 | "lint": "eslint --ext .js,.jsx src", 35 | "start": "webpack-dev-server --open", 36 | "build": "cross-env NODE_ENV=production webpack -p" 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /examples/counter-immutable/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Counter Example - Mickey 7 | 8 | 9 | 10 |
11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /examples/counter-immutable/src/App.css: -------------------------------------------------------------------------------- 1 | body { 2 | height: 100vh; 3 | padding: 0; 4 | margin: 0; 5 | } 6 | 7 | #counter-app { 8 | margin: 0 auto; 9 | width: 300px; 10 | } 11 | 12 | h1 { 13 | font-size: 180px; 14 | margin: 0; 15 | text-align: center; 16 | } 17 | 18 | .btn-wrap { 19 | text-align: center; 20 | } 21 | 22 | button { 23 | background-color: #fff; 24 | border: 1px solid #a0a0a0; 25 | border-radius: 4px; 26 | font-size: 120%; 27 | outline: none; 28 | width: 60px; 29 | height: 32px; 30 | margin: 0 10px; 31 | cursor: pointer; 32 | } 33 | -------------------------------------------------------------------------------- /examples/counter-immutable/src/App.jsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable react/prop-types */ 2 | 3 | import React from 'react' 4 | import { connect, injectActions } from 'mickey' 5 | import './App.css' 6 | 7 | const App = props => ( 8 |
9 |

{props.count}

10 |
11 | 12 | 13 | 25 |
26 |
27 | ) 28 | 29 | export default injectActions( 30 | connect((store) => { 31 | console.log('data:', store.get('counter').toJS()) // eslint-disable-line 32 | return ({ ...store.get('counter').toJS() }) 33 | })(App), 34 | ) 35 | -------------------------------------------------------------------------------- /examples/counter-immutable/src/index.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import createApp from 'mickey' 3 | import Immutable from 'immutable' 4 | import { createReducer, combineReducers } from 'redux-immutablejs' 5 | import App from './App' 6 | 7 | // 1. Initialize 8 | const app = createApp({ 9 | initialState: Immutable.fromJS({}), 10 | extensions: { 11 | createReducer, 12 | combineReducers, 13 | }, 14 | }) 15 | 16 | 17 | // 2. Model 18 | app.model(require('./models/counter.js')) 19 | 20 | // 3. View 21 | app.render(, document.getElementById('root')) 22 | -------------------------------------------------------------------------------- /examples/counter-immutable/src/models/counter.js: -------------------------------------------------------------------------------- 1 | import Immutable from 'immutable' 2 | 3 | const delay = timeout => new Promise((resolve) => { 4 | setTimeout(resolve, timeout) 5 | }) 6 | 7 | export default { 8 | namespace: 'counter', 9 | state: Immutable.fromJS({ 10 | count: 0, 11 | loading: false, 12 | }), 13 | increment: state => state.merge({ count: state.get('count') + 1 }), 14 | decrement: state => state.merge({ count: state.get('count') - 1 }), 15 | incrementAsync: { 16 | * effect(payload, { call }, { succeed }) { 17 | yield call(delay, 2000) 18 | yield succeed() 19 | }, 20 | prepare: state => state.merge({ loading: true }), 21 | succeed: state => state.merge({ count: state.get('count') + 1, loading: false }), 22 | }, 23 | } 24 | -------------------------------------------------------------------------------- /examples/counter-immutable/webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | const webpack = require('webpack') 3 | 4 | const config = { 5 | // in development, 6 | // 'webpack-dev-server/client' and 'webpac/hot/dev-server' will be automatically added 7 | entry: [ 8 | './src/index.jsx', 9 | ], 10 | output: { 11 | path: path.resolve(__dirname, 'dist'), 12 | filename: 'app.js', 13 | publicPath: '/', 14 | }, 15 | resolve: { 16 | extensions: ['*', '.js', '.jsx'], 17 | }, 18 | module: { 19 | rules: [ 20 | { 21 | test: /\.jsx?$/, 22 | include: [ 23 | path.resolve(__dirname, 'src'), 24 | ], 25 | loader: 'babel-loader', 26 | }, 27 | { 28 | test: /\.css$/, 29 | use: [ 30 | { 31 | loader: 'style-loader', 32 | }, 33 | { 34 | loader: 'css-loader', 35 | }, 36 | ], 37 | }, 38 | ], 39 | }, 40 | } 41 | 42 | if (process.env.NODE_ENV === 'production') { 43 | config.devtool = 'source-map' 44 | // Exclude react and react-dom in the production bundle 45 | config.externals = { 46 | react: 'React', 47 | 'react-dom': 'ReactDOM', 48 | } 49 | } else { 50 | config.devtool = 'cheap-module-source-map' 51 | config.devServer = { 52 | contentBase: path.resolve(__dirname, 'public'), 53 | clientLogLevel: 'none', 54 | quiet: true, 55 | port: 8000, 56 | compress: true, 57 | hot: true, 58 | historyApiFallback: { 59 | disableDotRule: true, 60 | }, 61 | } 62 | 63 | // HMR support 64 | config.plugins = [ 65 | new webpack.HotModuleReplacementPlugin(), 66 | new webpack.NamedModulesPlugin(), 67 | ] 68 | } 69 | 70 | module.exports = config 71 | -------------------------------------------------------------------------------- /examples/counter-persist(v5)-immutable/README.md: -------------------------------------------------------------------------------- 1 | # Counter-Persist-Immutable(v5) 2 | 3 | > Counter example work with [redux-persist(v5)](https://github.com/rt2zz/redux-persist) and [ImmutableJS](https://github.com/facebook/immutable-js/) for mickey 4 | 5 | ## Features 6 | 7 | - Integrate [Redux](https://github.com/rackt/redux) & [ImmutableJs](https://facebook.github.io/immutable-js/) 8 | - Delay render until rehydration complete 9 | 10 | ## Usage 11 | 12 | ```bash 13 | $ npm install 14 | $ npm start 15 | ``` 16 | 17 | ## Resources 18 | 19 | - [Mickey Persist](https://github.com/mickeyjsx/mickey-persist) -------------------------------------------------------------------------------- /examples/counter-persist(v5)-immutable/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "counter-persist-immuatable(v5)", 3 | "description": "Counter example work with persist-v5 and immutable for mickey", 4 | "dependencies": { 5 | "@actra-development-oss/redux-persist-transform-filter-immutable": "^0.1.7", 6 | "immutable": "^3.8.1", 7 | "mickey": "^1.1.0", 8 | "react": "^16.2.0", 9 | "redux-action-buffer": "^1.1.0", 10 | "redux-immutablejs": "^0.0.8", 11 | "redux-persist": "^5.4.0", 12 | "redux-persist-immutable": "^4.3.1", 13 | "redux-persist-transform-filter-immutable": "^0.3.0", 14 | "redux-persist-transform-immutable": "^4.3.0", 15 | "mickey-persist": "^1.0.1" 16 | }, 17 | "devDependencies": { 18 | "babel-cli": "^6.26.0", 19 | "babel-core": "^6.26.0", 20 | "babel-eslint": "^7.2.3", 21 | "babel-loader": "^7.1.2", 22 | "babel-plugin-add-module-exports": "^0.2.1", 23 | "babel-plugin-transform-runtime": "^6.23.0", 24 | "babel-preset-es2015": "^6.24.1", 25 | "babel-preset-react": "^6.24.1", 26 | "babel-preset-stage-0": "^6.24.1", 27 | "cross-env": "^5.0.5", 28 | "css-loader": "^0.28.7", 29 | "eslint": "^4.5.0", 30 | "eslint-config-airbnb": "^15.1.0", 31 | "eslint-plugin-import": "^2.7.0", 32 | "eslint-plugin-jsx-a11y": "^5.1.1", 33 | "eslint-plugin-react": "^7.3.0", 34 | "husky": "^0.14.3", 35 | "rimraf": "^2.6.1", 36 | "style-loader": "^0.18.2", 37 | "webpack": "^3.5.5", 38 | "webpack-dev-server": "^2.7.1" 39 | }, 40 | "scripts": { 41 | "lint": "eslint --ext .js,.jsx src", 42 | "start": "webpack-dev-server --open", 43 | "build": "cross-env NODE_ENV=production webpack -p" 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /examples/counter-persist(v5)-immutable/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Counter Example - Mickey 7 | 8 | 9 | 10 |
11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /examples/counter-persist(v5)-immutable/src/App.css: -------------------------------------------------------------------------------- 1 | body { 2 | height: 100vh; 3 | padding: 0; 4 | margin: 0; 5 | } 6 | 7 | #counter-app { 8 | margin: 0 auto; 9 | width: 300px; 10 | } 11 | 12 | h1 { 13 | font-size: 180px; 14 | margin: 0; 15 | text-align: center; 16 | } 17 | 18 | .btn-wrap { 19 | text-align: center; 20 | } 21 | 22 | button { 23 | background-color: #fff; 24 | border: 1px solid #a0a0a0; 25 | border-radius: 4px; 26 | font-size: 120%; 27 | outline: none; 28 | width: 100px; 29 | height: 32px; 30 | margin: 10px; 31 | cursor: pointer; 32 | } 33 | -------------------------------------------------------------------------------- /examples/counter-persist(v5)-immutable/src/App.jsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable react/prop-types */ 2 | 3 | import React from 'react' 4 | import { injectActions, connect } from 'mickey' 5 | import './App.css' 6 | 7 | const App = props => ( 8 |
9 |

{props.count}

10 |
11 | 12 | 13 | 25 | 26 |
27 |
28 | ) 29 | 30 | export default injectActions( 31 | connect((store) => { 32 | console.log('data:', ({ ...store['counter'].toJS() })) // eslint-disable-line 33 | return ({ ...store['counter'].toJS() }) 34 | })(App), 35 | ) 36 | -------------------------------------------------------------------------------- /examples/counter-persist(v5)-immutable/src/autoMergeImmutable.js: -------------------------------------------------------------------------------- 1 | 2 | var _typeof = typeof Symbol === "function" && typeof Symbol.iterator === "symbol" ? function (obj) { return typeof obj; } : function (obj) { return obj && typeof Symbol === "function" && obj.constructor === Symbol && obj !== Symbol.prototype ? "symbol" : typeof obj; }; 3 | 4 | var _extends = Object.assign || function (target) { for (var i = 1; i < arguments.length; i++) { var source = arguments[i]; for (var key in source) { if (Object.prototype.hasOwnProperty.call(source, key)) { target[key] = source[key]; } } } return target; }; 5 | 6 | export function autoMergeImmutable(inboundState, originalState, reducedState, _ref) { 7 | var debug = _ref.debug; 8 | var newState = _extends({}, reducedState); 9 | // only rehydrate if inboundState exists and is an object 10 | if (inboundState && (typeof inboundState === 'undefined' ? 'undefined' : _typeof(inboundState)) === 'object') { 11 | Object.keys(inboundState).forEach(function (key) { 12 | // ignore _persist data 13 | if (key === '_persist') return; 14 | // if reducer modifies substate, skip auto rehydration 15 | if (originalState[key] !== reducedState[key]) { 16 | if (process.env.NODE_ENV !== 'production' && debug) console.log('redux-persist/stateReconciler: sub state for key `%s` modified, skipping.', key); 17 | return; 18 | } 19 | if (isPlainEnoughObject(reducedState[key])) { 20 | // if object is plain enough shallow merge the new values (hence "Level2") 21 | newState[key] = newState[key].merge(inboundState[key]); 22 | return; 23 | } 24 | // otherwise hard set 25 | newState[key] = inboundState[key]; 26 | }); 27 | } 28 | 29 | if (process.env.NODE_ENV !== 'production' && debug && inboundState && (typeof inboundState === 'undefined' ? 'undefined' : _typeof(inboundState)) === 'object') console.log('redux-persist/stateReconciler: rehydrated keys \'' + Object.keys(inboundState).join(', ') + '\''); 30 | 31 | return newState; 32 | } 33 | 34 | function isPlainEnoughObject(o) { 35 | return o !== null && !Array.isArray(o) && (typeof o === 'undefined' ? 'undefined' : _typeof(o)) === 'object'; 36 | } -------------------------------------------------------------------------------- /examples/counter-persist(v5)-immutable/src/index.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import createApp, { applyMiddleware } from 'mickey' 3 | import Immutable from 'immutable' 4 | import storage from 'redux-persist/lib/storage' 5 | import { persistStore, persistCombineReducers } from 'redux-persist' 6 | import immutableTransform from 'redux-persist-transform-immutable' 7 | import { PersistGate } from 'redux-persist/lib/integration/react' 8 | import createFilter from 'redux-persist-transform-filter-immutable'; 9 | import MickeyPersist from 'mickey-persist' 10 | import App from './App' 11 | import { autoMergeImmutable } from './autoMergeImmutable' 12 | import { combineReducers } from 'redux-immutablejs'; 13 | 14 | //specific key to persist 15 | const saveSubsetFilter = createFilter( 16 | 'counter', 17 | ['count'] 18 | ); 19 | 20 | const persistConfig = { 21 | transforms: [saveSubsetFilter, immutableTransform()], 22 | keyPrefix: 'mickey-immutable:', 23 | storage, 24 | whitelist: ['counter'], 25 | key: 'primary', 26 | stateReconciler: autoMergeImmutable // rehydrate immutable state 27 | } 28 | // 1. Initialize 29 | const app = createApp({ 30 | initialState: {}, //must be a no-immutable object 31 | extensions: { 32 | combineReducers: MickeyPersist(persistCombineReducers.bind(this, persistConfig), combineReducers), 33 | }, 34 | }) 35 | 36 | // 2. Model 37 | app.model(require('./models/counter.js')) 38 | // 3. View 39 | app.render(null, document.getElementById('root'), { 40 | beforeRender: ({ store }) => new Promise(((resolve) => { 41 | resolve( 42 | 43 | ); 44 | })), 45 | }) 46 | 47 | -------------------------------------------------------------------------------- /examples/counter-persist(v5)-immutable/src/models/counter.js: -------------------------------------------------------------------------------- 1 | import Immutable from 'immutable' 2 | 3 | const delay = timeout => new Promise((resolve) => { 4 | setTimeout(resolve, timeout) 5 | }) 6 | 7 | export default { 8 | namespace: 'counter', 9 | state: Immutable.fromJS({ 10 | count: 0, 11 | loading: false, 12 | }), 13 | increment: (state) => { 14 | return state.merge({ count: state.get('count') + 1 }) 15 | }, 16 | decrement: state => state.merge({ count: state.get('count') - 1 }), 17 | incrementAsync: { 18 | * effect(payload, { call }, { succeed }) { 19 | yield call(delay, 2000) 20 | yield succeed() 21 | }, 22 | prepare: state => state.merge({ loading: true }), 23 | succeed: state => state.merge({ count: state.get('count') + 1, loading: false }), 24 | }, 25 | } 26 | -------------------------------------------------------------------------------- /examples/counter-persist(v5)-immutable/webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | const webpack = require('webpack') 3 | 4 | const config = { 5 | // in development, 6 | // 'webpack-dev-server/client' and 'webpac/hot/dev-server' will be automatically added 7 | entry: [ 8 | './src/index.jsx', 9 | ], 10 | output: { 11 | path: path.resolve(__dirname, 'dist'), 12 | filename: 'app.js', 13 | publicPath: '/', 14 | }, 15 | resolve: { 16 | extensions: ['*', '.js', '.jsx'], 17 | }, 18 | module: { 19 | rules: [ 20 | { 21 | test: /\.jsx?$/, 22 | include: [ 23 | path.resolve(__dirname, 'src'), 24 | ], 25 | loader: 'babel-loader', 26 | }, 27 | { 28 | test: /\.css$/, 29 | use: [ 30 | { 31 | loader: 'style-loader', 32 | }, 33 | { 34 | loader: 'css-loader', 35 | }, 36 | ], 37 | }, 38 | ], 39 | }, 40 | } 41 | 42 | if (process.env.NODE_ENV === 'production') { 43 | config.devtool = 'source-map' 44 | // Exclude react and react-dom in the production bundle 45 | config.externals = { 46 | react: 'React', 47 | 'react-dom': 'ReactDOM', 48 | } 49 | } else { 50 | config.devtool = 'cheap-module-source-map' 51 | config.devServer = { 52 | contentBase: path.resolve(__dirname, 'public'), 53 | clientLogLevel: 'none', 54 | quiet: true, 55 | port: 8000, 56 | compress: true, 57 | hot: true, 58 | historyApiFallback: { 59 | disableDotRule: true, 60 | }, 61 | } 62 | 63 | // HMR support 64 | config.plugins = [ 65 | new webpack.HotModuleReplacementPlugin(), 66 | new webpack.NamedModulesPlugin(), 67 | ] 68 | } 69 | 70 | module.exports = config 71 | -------------------------------------------------------------------------------- /examples/counter-persist(v5)/README.md: -------------------------------------------------------------------------------- 1 | # Counter-Persist(v5) 2 | 3 | > Counter example work with [redux-persist](https://github.com/rt2zz/redux-persist) for mickey 4 | 5 | ## Features 6 | 7 | - Delay render until rehydration complete 8 | 9 | ## Usage 10 | 11 | ```bash 12 | $ npm install 13 | $ npm start 14 | ``` 15 | 16 | ## Resources 17 | 18 | - [Redux Action Buffer](https://github.com/rt2zz/redux-action-buffer#redux-persist-example) 19 | - [Mickey Persist](https://github.com/mickeyjsx/mickey-persist) 20 | -------------------------------------------------------------------------------- /examples/counter-persist(v5)/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "counter-persist(v5)", 3 | "description": "Counter example work with redux-persist for mickey", 4 | "dependencies": { 5 | "mickey": "^1.1.0", 6 | "react": "^16.2.0", 7 | "react-dom": "^16.2.0", 8 | "redux-action-buffer": "^1.1.0", 9 | "redux-persist": "^5.4.0", 10 | "redux-persist-transform-filter": "0.0.16", 11 | "mickey-persist": "^1.0.1" 12 | }, 13 | "devDependencies": { 14 | "babel-cli": "^6.26.0", 15 | "babel-core": "^6.26.0", 16 | "babel-eslint": "^7.2.3", 17 | "babel-loader": "^7.1.2", 18 | "babel-plugin-add-module-exports": "^0.2.1", 19 | "babel-plugin-transform-runtime": "^6.23.0", 20 | "babel-preset-es2015": "^6.24.1", 21 | "babel-preset-react": "^6.24.1", 22 | "babel-preset-stage-0": "^6.24.1", 23 | "cross-env": "^5.0.5", 24 | "css-loader": "^0.28.7", 25 | "eslint": "^4.5.0", 26 | "eslint-config-airbnb": "^15.1.0", 27 | "eslint-plugin-import": "^2.7.0", 28 | "eslint-plugin-jsx-a11y": "^5.1.1", 29 | "eslint-plugin-react": "^7.3.0", 30 | "husky": "^0.14.3", 31 | "rimraf": "^2.6.1", 32 | "style-loader": "^0.18.2", 33 | "webpack": "^3.5.5", 34 | "webpack-dev-server": "^2.7.1" 35 | }, 36 | "scripts": { 37 | "lint": "eslint --ext .js,.jsx src", 38 | "start": "webpack-dev-server --open", 39 | "build": "cross-env NODE_ENV=production webpack -p" 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /examples/counter-persist(v5)/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Counter Example - Mickey 7 | 8 | 9 | 10 |
11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /examples/counter-persist(v5)/src/App.css: -------------------------------------------------------------------------------- 1 | body { 2 | height: 100vh; 3 | padding: 0; 4 | margin: 0; 5 | } 6 | 7 | #counter-app { 8 | margin: 0 auto; 9 | width: 300px; 10 | } 11 | 12 | h1 { 13 | font-size: 180px; 14 | margin: 0; 15 | text-align: center; 16 | } 17 | 18 | .btn-wrap { 19 | text-align: center; 20 | } 21 | 22 | button { 23 | background-color: #fff; 24 | border: 1px solid #a0a0a0; 25 | border-radius: 4px; 26 | font-size: 120%; 27 | outline: none; 28 | width: 100px; 29 | height: 32px; 30 | margin: 10px; 31 | cursor: pointer; 32 | } 33 | -------------------------------------------------------------------------------- /examples/counter-persist(v5)/src/App.jsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable react/prop-types */ 2 | 3 | import React from 'react' 4 | import { connect, injectActions } from 'mickey' 5 | import './App.css' 6 | 7 | const App = props => ( 8 |
9 |

{props.count}

10 |
11 | 12 | 13 | 25 | 26 |
27 |
28 | ) 29 | 30 | export default injectActions( 31 | connect(store => ({ ...store.counter }))(App), 32 | ) 33 | -------------------------------------------------------------------------------- /examples/counter-persist(v5)/src/index.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import createApp, { applyMiddleware } from 'mickey' 3 | import { persistStore, persistCombineReducers, REHYDRATE, PERSIST } from 'redux-persist' 4 | import { PersistGate } from 'redux-persist/lib/integration/react' 5 | import createActionBuffer from 'redux-action-buffer' 6 | import App from './App' 7 | import storage from 'redux-persist/lib/storage' 8 | import createFilter from 'redux-persist-transform-filter'; 9 | import MickeyPersist from 'mickey-persist'; 10 | 11 | //specific key to persist 12 | const saveSubsetFilter = createFilter( 13 | 'counter', 14 | ['count'] 15 | ); 16 | 17 | const persistConfig = { 18 | transforms: [saveSubsetFilter], 19 | keyPrefix: 'mickey:', 20 | storage, 21 | whitelist: ['counter'], 22 | key: 'primary' 23 | } 24 | // 1. Initialize 25 | const app = createApp({ 26 | extensions: { 27 | combineReducers: MickeyPersist( persistCombineReducers.bind(this, persistConfig) ), 28 | } 29 | }) 30 | 31 | 32 | // 2. Model 33 | app.model(require('./models/counter.js')) 34 | // 3. View 35 | app.render(null, document.getElementById('root'), { 36 | beforeRender: ({ store }) => new Promise(((resolve) => { 37 | resolve( 38 | 39 | ); 40 | })), 41 | }) -------------------------------------------------------------------------------- /examples/counter-persist(v5)/src/models/counter.js: -------------------------------------------------------------------------------- 1 | import { REHYDRATE } from 'redux-persist' 2 | 3 | const delay = timeout => new Promise((resolve) => { 4 | setTimeout(resolve, timeout) 5 | }) 6 | 7 | export default { 8 | namespace: 'counter', 9 | state: { 10 | count: 0, 11 | loading: false, 12 | }, 13 | increment: state => ({ ...state, count: state.count + 1 }), 14 | decrement: state => ({ ...state, count: state.count - 1 }), 15 | incrementAsync: { 16 | * effect(payload, { call }, { succeed }) { 17 | yield call(delay, 2000) 18 | yield succeed() 19 | }, 20 | prepare: state => ({ ...state, loading: true }), 21 | succeed: state => ({ ...state, count: state.count + 1, loading: false }), 22 | }, 23 | } 24 | -------------------------------------------------------------------------------- /examples/counter-persist(v5)/webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | const webpack = require('webpack') 3 | 4 | const config = { 5 | // in development, 6 | // 'webpack-dev-server/client' and 'webpac/hot/dev-server' will be automatically added 7 | entry: [ 8 | './src/index.jsx', 9 | ], 10 | output: { 11 | path: path.resolve(__dirname, 'dist'), 12 | filename: 'app.js', 13 | publicPath: '/', 14 | }, 15 | resolve: { 16 | extensions: ['*', '.js', '.jsx'], 17 | }, 18 | module: { 19 | rules: [ 20 | { 21 | test: /\.jsx?$/, 22 | include: [ 23 | path.resolve(__dirname, 'src'), 24 | ], 25 | loader: 'babel-loader', 26 | }, 27 | { 28 | test: /\.css$/, 29 | use: [ 30 | { 31 | loader: 'style-loader', 32 | }, 33 | { 34 | loader: 'css-loader', 35 | }, 36 | ], 37 | }, 38 | ], 39 | }, 40 | } 41 | 42 | if (process.env.NODE_ENV === 'production') { 43 | config.devtool = 'source-map' 44 | // Exclude react and react-dom in the production bundle 45 | config.externals = { 46 | react: 'React', 47 | 'react-dom': 'ReactDOM', 48 | } 49 | } else { 50 | config.devtool = 'cheap-module-source-map' 51 | config.devServer = { 52 | contentBase: path.resolve(__dirname, 'public'), 53 | clientLogLevel: 'none', 54 | quiet: true, 55 | port: 8000, 56 | compress: true, 57 | hot: true, 58 | historyApiFallback: { 59 | disableDotRule: true, 60 | }, 61 | } 62 | 63 | // HMR support 64 | config.plugins = [ 65 | new webpack.HotModuleReplacementPlugin(), 66 | new webpack.NamedModulesPlugin(), 67 | ] 68 | } 69 | 70 | module.exports = config 71 | -------------------------------------------------------------------------------- /examples/counter-persist-immutable/.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | "es2015", 4 | "react", 5 | "stage-0" 6 | ], 7 | "plugins": [ 8 | "add-module-exports", 9 | "transform-runtime" 10 | ], 11 | "env": { 12 | "test": { 13 | "plugins": [ 14 | "istanbul" 15 | ] 16 | } 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /examples/counter-persist-immutable/.eslintrc.yml: -------------------------------------------------------------------------------- 1 | extends: eslint-config-airbnb 2 | parser: babel-eslint 3 | 4 | env: 5 | es6: true 6 | node: true 7 | mocha: true 8 | browser: true 9 | 10 | rules: 11 | semi: 12 | - warn 13 | - never 14 | arrow-body-style: 15 | - error 16 | - as-needed 17 | no-param-reassign: 18 | - error 19 | - props: false 20 | 21 | parserOptions: 22 | ecmaFeatures: 23 | experimentalObjectRestSpread: true 24 | -------------------------------------------------------------------------------- /examples/counter-persist-immutable/README.md: -------------------------------------------------------------------------------- 1 | # Counter-Persist-Immutable 2 | 3 | > Counter example work with [redux-persist](https://github.com/rt2zz/redux-persist) and [ImmutableJS](https://github.com/facebook/immutable-js/) for mickey 4 | 5 | ## Features 6 | 7 | - Integrate [Redux](https://github.com/rackt/redux) & [ImmutableJs](https://facebook.github.io/immutable-js/) 8 | - Delay render until rehydration complete 9 | - Buffer all actions into a queue until rehydration complete 10 | 11 | 12 | ## Usage 13 | 14 | ```bash 15 | $ npm install 16 | $ npm start 17 | ``` 18 | 19 | ## Resources 20 | 21 | - [Redux Action Buffer](https://github.com/rt2zz/redux-action-buffer#redux-persist-example) 22 | - [Delay Render Until Rehydration Complete](https://github.com/rt2zz/redux-persist/blob/master/docs/recipes.md#delay-render-until-rehydration-complete) 23 | -------------------------------------------------------------------------------- /examples/counter-persist-immutable/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "counter-persist-immuatable", 3 | "description": "Counter example work with persist and immutable for mickey", 4 | "dependencies": { 5 | "immutable": "^3.8.1", 6 | "mickey": "^0.2.4", 7 | "react": "^15.6.1", 8 | "redux-action-buffer": "^1.1.0", 9 | "redux-immutablejs": "^0.0.8", 10 | "redux-persist": "^4.9.1", 11 | "redux-persist-immutable": "^4.3.1", 12 | "redux-persist-transform-immutable": "^4.3.0" 13 | }, 14 | "devDependencies": { 15 | "babel-cli": "^6.26.0", 16 | "babel-core": "^6.26.0", 17 | "babel-eslint": "^7.2.3", 18 | "babel-loader": "^7.1.2", 19 | "babel-plugin-add-module-exports": "^0.2.1", 20 | "babel-plugin-transform-runtime": "^6.23.0", 21 | "babel-preset-es2015": "^6.24.1", 22 | "babel-preset-react": "^6.24.1", 23 | "babel-preset-stage-0": "^6.24.1", 24 | "cross-env": "^5.0.5", 25 | "css-loader": "^0.28.7", 26 | "eslint": "^4.5.0", 27 | "eslint-config-airbnb": "^15.1.0", 28 | "eslint-plugin-import": "^2.7.0", 29 | "eslint-plugin-jsx-a11y": "^5.1.1", 30 | "eslint-plugin-react": "^7.3.0", 31 | "husky": "^0.14.3", 32 | "rimraf": "^2.6.1", 33 | "style-loader": "^0.18.2", 34 | "webpack": "^3.5.5", 35 | "webpack-dev-server": "^2.7.1" 36 | }, 37 | "scripts": { 38 | "lint": "eslint --ext .js,.jsx src", 39 | "start": "webpack-dev-server --open", 40 | "build": "cross-env NODE_ENV=production webpack -p" 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /examples/counter-persist-immutable/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Counter Example - Mickey 7 | 8 | 9 | 10 |
11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /examples/counter-persist-immutable/src/App.css: -------------------------------------------------------------------------------- 1 | body { 2 | height: 100vh; 3 | padding: 0; 4 | margin: 0; 5 | } 6 | 7 | #counter-app { 8 | margin: 0 auto; 9 | width: 300px; 10 | } 11 | 12 | h1 { 13 | font-size: 180px; 14 | margin: 0; 15 | text-align: center; 16 | } 17 | 18 | .btn-wrap { 19 | text-align: center; 20 | } 21 | 22 | button { 23 | background-color: #fff; 24 | border: 1px solid #a0a0a0; 25 | border-radius: 4px; 26 | font-size: 120%; 27 | outline: none; 28 | width: 100px; 29 | height: 32px; 30 | margin: 10px; 31 | cursor: pointer; 32 | } 33 | -------------------------------------------------------------------------------- /examples/counter-persist-immutable/src/App.jsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable react/prop-types */ 2 | 3 | import React from 'react' 4 | import { injectActions, connect } from 'mickey' 5 | import './App.css' 6 | 7 | const App = props => ( 8 |
9 |

{props.count}

10 |
11 | 12 | 13 | 25 | 26 |
27 |
28 | ) 29 | 30 | export default injectActions( 31 | connect((store) => { 32 | console.log('data:', store.get('counter').toJS()) // eslint-disable-line 33 | return ({ ...store.get('counter').toJS() }) 34 | })(App), 35 | ) 36 | -------------------------------------------------------------------------------- /examples/counter-persist-immutable/src/index.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import createApp, { applyMiddleware } from 'mickey' 3 | import Immutable from 'immutable' 4 | import { createReducer, combineReducers } from 'redux-immutablejs' 5 | import { persistStore, autoRehydrate } from 'redux-persist-immutable' 6 | import immutableTransform from 'redux-persist-transform-immutable' 7 | import { REHYDRATE } from 'redux-persist/constants' 8 | import createActionBuffer from 'redux-action-buffer' 9 | import App from './App' 10 | 11 | // 1. Initialize 12 | const app = createApp({ 13 | initialState: Immutable.fromJS({}), 14 | extensions: { 15 | createReducer, 16 | combineReducers, 17 | }, 18 | hooks: { 19 | extraEnhancers: [ 20 | // add `autoRehydrate` as an enhancer to your store 21 | autoRehydrate(), 22 | // make sure to apply this after autoRehydrate 23 | applyMiddleware( 24 | // buffer other reducers before rehydrated 25 | createActionBuffer(REHYDRATE), 26 | ), 27 | ], 28 | }, 29 | }) 30 | 31 | 32 | // 2. Model 33 | app.model(require('./models/counter.js')) 34 | 35 | // 3. View 36 | app.render(, document.getElementById('root'), { 37 | beforeRender: ({ store }) => new Promise(((resolve) => { 38 | // begin periodically persisting the store 39 | persistStore(store, { 40 | debounce: 500, 41 | whitelist: ['counter'], 42 | keyPrefix: 'mickey-immutable:', 43 | transforms: [immutableTransform()], 44 | }, () => { 45 | resolve() // delay render after rehydrated 46 | }) 47 | })), 48 | }) 49 | -------------------------------------------------------------------------------- /examples/counter-persist-immutable/src/models/counter.js: -------------------------------------------------------------------------------- 1 | import Immutable from 'immutable' 2 | import { REHYDRATE } from 'redux-persist/constants' 3 | 4 | const delay = timeout => new Promise((resolve) => { 5 | setTimeout(resolve, timeout) 6 | }) 7 | 8 | export default { 9 | namespace: 'counter', 10 | state: Immutable.fromJS({ 11 | count: 0, 12 | loading: false, 13 | }), 14 | enhancers: [ 15 | reducer => (state, action) => { 16 | const { type, payload } = action 17 | if (type === REHYDRATE) { 18 | return payload.counter 19 | } 20 | return reducer(state, action) 21 | }, 22 | ], 23 | increment: state => state.merge({ count: state.get('count') + 1 }), 24 | decrement: state => state.merge({ count: state.get('count') - 1 }), 25 | incrementAsync: { 26 | * effect(payload, { call }, { succeed }) { 27 | yield call(delay, 2000) 28 | yield succeed() 29 | }, 30 | prepare: state => state.merge({ loading: true }), 31 | succeed: state => state.merge({ count: state.get('count') + 1, loading: false }), 32 | }, 33 | } 34 | -------------------------------------------------------------------------------- /examples/counter-persist-immutable/webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | const webpack = require('webpack') 3 | 4 | const config = { 5 | // in development, 6 | // 'webpack-dev-server/client' and 'webpac/hot/dev-server' will be automatically added 7 | entry: [ 8 | './src/index.jsx', 9 | ], 10 | output: { 11 | path: path.resolve(__dirname, 'dist'), 12 | filename: 'app.js', 13 | publicPath: '/', 14 | }, 15 | resolve: { 16 | extensions: ['*', '.js', '.jsx'], 17 | }, 18 | module: { 19 | rules: [ 20 | { 21 | test: /\.jsx?$/, 22 | include: [ 23 | path.resolve(__dirname, 'src'), 24 | ], 25 | loader: 'babel-loader', 26 | }, 27 | { 28 | test: /\.css$/, 29 | use: [ 30 | { 31 | loader: 'style-loader', 32 | }, 33 | { 34 | loader: 'css-loader', 35 | }, 36 | ], 37 | }, 38 | ], 39 | }, 40 | } 41 | 42 | if (process.env.NODE_ENV === 'production') { 43 | config.devtool = 'source-map' 44 | // Exclude react and react-dom in the production bundle 45 | config.externals = { 46 | react: 'React', 47 | 'react-dom': 'ReactDOM', 48 | } 49 | } else { 50 | config.devtool = 'cheap-module-source-map' 51 | config.devServer = { 52 | contentBase: path.resolve(__dirname, 'public'), 53 | clientLogLevel: 'none', 54 | quiet: true, 55 | port: 8000, 56 | compress: true, 57 | hot: true, 58 | historyApiFallback: { 59 | disableDotRule: true, 60 | }, 61 | } 62 | 63 | // HMR support 64 | config.plugins = [ 65 | new webpack.HotModuleReplacementPlugin(), 66 | new webpack.NamedModulesPlugin(), 67 | ] 68 | } 69 | 70 | module.exports = config 71 | -------------------------------------------------------------------------------- /examples/counter-persist/.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | "es2015", 4 | "react", 5 | "stage-0" 6 | ], 7 | "plugins": [ 8 | "add-module-exports", 9 | "transform-runtime" 10 | ], 11 | "env": { 12 | "test": { 13 | "plugins": [ 14 | "istanbul" 15 | ] 16 | } 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /examples/counter-persist/.eslintrc.yml: -------------------------------------------------------------------------------- 1 | extends: eslint-config-airbnb 2 | parser: babel-eslint 3 | 4 | env: 5 | es6: true 6 | node: true 7 | mocha: true 8 | browser: true 9 | 10 | rules: 11 | semi: 12 | - warn 13 | - never 14 | arrow-body-style: 15 | - error 16 | - as-needed 17 | no-param-reassign: 18 | - error 19 | - props: false 20 | 21 | parserOptions: 22 | ecmaFeatures: 23 | experimentalObjectRestSpread: true 24 | -------------------------------------------------------------------------------- /examples/counter-persist/README.md: -------------------------------------------------------------------------------- 1 | # Counter-Persist 2 | 3 | > Counter example work with [redux-persist](https://github.com/rt2zz/redux-persist) for mickey 4 | 5 | ## Features 6 | 7 | - Delay render until rehydration complete 8 | - Buffer all actions into a queue until rehydration complete 9 | 10 | 11 | ## Usage 12 | 13 | ```bash 14 | $ npm install 15 | $ npm start 16 | ``` 17 | 18 | ## Resources 19 | 20 | - [Redux Action Buffer](https://github.com/rt2zz/redux-action-buffer#redux-persist-example) 21 | - [Delay Render Until Rehydration Complete](https://github.com/rt2zz/redux-persist/blob/master/docs/recipes.md#delay-render-until-rehydration-complete) 22 | -------------------------------------------------------------------------------- /examples/counter-persist/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "counter-persist", 3 | "description": "Counter example work with redux-persist for mickey", 4 | "dependencies": { 5 | "mickey": "^0.2.4", 6 | "react": "^15.6.1", 7 | "redux-action-buffer": "^1.1.0", 8 | "redux-persist": "^4.9.1" 9 | }, 10 | "devDependencies": { 11 | "babel-cli": "^6.26.0", 12 | "babel-core": "^6.26.0", 13 | "babel-eslint": "^7.2.3", 14 | "babel-loader": "^7.1.2", 15 | "babel-plugin-add-module-exports": "^0.2.1", 16 | "babel-plugin-transform-runtime": "^6.23.0", 17 | "babel-preset-es2015": "^6.24.1", 18 | "babel-preset-react": "^6.24.1", 19 | "babel-preset-stage-0": "^6.24.1", 20 | "cross-env": "^5.0.5", 21 | "css-loader": "^0.28.7", 22 | "eslint": "^4.5.0", 23 | "eslint-config-airbnb": "^15.1.0", 24 | "eslint-plugin-import": "^2.7.0", 25 | "eslint-plugin-jsx-a11y": "^5.1.1", 26 | "eslint-plugin-react": "^7.3.0", 27 | "husky": "^0.14.3", 28 | "rimraf": "^2.6.1", 29 | "style-loader": "^0.18.2", 30 | "webpack": "^3.5.5", 31 | "webpack-dev-server": "^2.7.1" 32 | }, 33 | "scripts": { 34 | "lint": "eslint --ext .js,.jsx src", 35 | "start": "webpack-dev-server --open", 36 | "build": "cross-env NODE_ENV=production webpack -p" 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /examples/counter-persist/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Counter Example - Mickey 7 | 8 | 9 | 10 |
11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /examples/counter-persist/src/App.css: -------------------------------------------------------------------------------- 1 | body { 2 | height: 100vh; 3 | padding: 0; 4 | margin: 0; 5 | } 6 | 7 | #counter-app { 8 | margin: 0 auto; 9 | width: 300px; 10 | } 11 | 12 | h1 { 13 | font-size: 180px; 14 | margin: 0; 15 | text-align: center; 16 | } 17 | 18 | .btn-wrap { 19 | text-align: center; 20 | } 21 | 22 | button { 23 | background-color: #fff; 24 | border: 1px solid #a0a0a0; 25 | border-radius: 4px; 26 | font-size: 120%; 27 | outline: none; 28 | width: 100px; 29 | height: 32px; 30 | margin: 10px; 31 | cursor: pointer; 32 | } 33 | -------------------------------------------------------------------------------- /examples/counter-persist/src/App.jsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable react/prop-types */ 2 | 3 | import React from 'react' 4 | import { connect, injectActions } from 'mickey' 5 | import './App.css' 6 | 7 | const App = props => ( 8 |
9 |

{props.count}

10 |
11 | 12 | 13 | 25 | 26 |
27 |
28 | ) 29 | 30 | export default injectActions( 31 | connect(store => ({ ...store.counter }))(App), 32 | ) 33 | -------------------------------------------------------------------------------- /examples/counter-persist/src/index.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import createApp, { applyMiddleware } from 'mickey' 3 | import { persistStore, autoRehydrate } from 'redux-persist' 4 | import { REHYDRATE } from 'redux-persist/constants' 5 | import createActionBuffer from 'redux-action-buffer' 6 | import App from './App' 7 | 8 | // 1. Initialize 9 | const app = createApp({ 10 | hooks: { 11 | extraEnhancers: [ 12 | // add `autoRehydrate` as an enhancer to your store 13 | autoRehydrate(), 14 | // make sure to apply this after autoRehydrate 15 | applyMiddleware( 16 | // buffer other reducers before rehydrated 17 | createActionBuffer(REHYDRATE), 18 | ), 19 | ], 20 | }, 21 | }) 22 | 23 | 24 | // 2. Model 25 | app.model(require('./models/counter.js')) 26 | 27 | // 3. View 28 | app.render(, document.getElementById('root'), { 29 | beforeRender: ({ store }) => new Promise(((resolve) => { 30 | // begin periodically persisting the store 31 | persistStore(store, { 32 | debounce: 500, 33 | whitelist: ['counter'], 34 | keyPrefix: 'mickey:', 35 | }, () => { 36 | resolve() // delay render after rehydrated 37 | }) 38 | })), 39 | }) 40 | -------------------------------------------------------------------------------- /examples/counter-persist/src/models/counter.js: -------------------------------------------------------------------------------- 1 | import { REHYDRATE } from 'redux-persist/constants' 2 | 3 | const delay = timeout => new Promise((resolve) => { 4 | setTimeout(resolve, timeout) 5 | }) 6 | 7 | export default { 8 | namespace: 'counter', 9 | state: { 10 | count: 0, 11 | loading: false, 12 | }, 13 | enhancers: [ 14 | reducer => (state, action) => { 15 | const { type, payload } = action 16 | if (type === REHYDRATE) { 17 | return { 18 | ...state, 19 | ...payload.counter, 20 | } 21 | } 22 | return reducer(state, action) 23 | }, 24 | ], 25 | increment: state => ({ ...state, count: state.count + 1 }), 26 | decrement: state => ({ ...state, count: state.count - 1 }), 27 | incrementAsync: { 28 | * effect(payload, { call }, { succeed }) { 29 | yield call(delay, 2000) 30 | yield succeed() 31 | }, 32 | prepare: state => ({ ...state, loading: true }), 33 | succeed: state => ({ ...state, count: state.count + 1, loading: false }), 34 | }, 35 | } 36 | -------------------------------------------------------------------------------- /examples/counter-persist/webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | const webpack = require('webpack') 3 | 4 | const config = { 5 | // in development, 6 | // 'webpack-dev-server/client' and 'webpac/hot/dev-server' will be automatically added 7 | entry: [ 8 | './src/index.jsx', 9 | ], 10 | output: { 11 | path: path.resolve(__dirname, 'dist'), 12 | filename: 'app.js', 13 | publicPath: '/', 14 | }, 15 | resolve: { 16 | extensions: ['*', '.js', '.jsx'], 17 | }, 18 | module: { 19 | rules: [ 20 | { 21 | test: /\.jsx?$/, 22 | include: [ 23 | path.resolve(__dirname, 'src'), 24 | ], 25 | loader: 'babel-loader', 26 | }, 27 | { 28 | test: /\.css$/, 29 | use: [ 30 | { 31 | loader: 'style-loader', 32 | }, 33 | { 34 | loader: 'css-loader', 35 | }, 36 | ], 37 | }, 38 | ], 39 | }, 40 | } 41 | 42 | if (process.env.NODE_ENV === 'production') { 43 | config.devtool = 'source-map' 44 | // Exclude react and react-dom in the production bundle 45 | config.externals = { 46 | react: 'React', 47 | 'react-dom': 'ReactDOM', 48 | } 49 | } else { 50 | config.devtool = 'cheap-module-source-map' 51 | config.devServer = { 52 | contentBase: path.resolve(__dirname, 'public'), 53 | clientLogLevel: 'none', 54 | quiet: true, 55 | port: 8000, 56 | compress: true, 57 | hot: true, 58 | historyApiFallback: { 59 | disableDotRule: true, 60 | }, 61 | } 62 | 63 | // HMR support 64 | config.plugins = [ 65 | new webpack.HotModuleReplacementPlugin(), 66 | new webpack.NamedModulesPlugin(), 67 | ] 68 | } 69 | 70 | module.exports = config 71 | -------------------------------------------------------------------------------- /examples/counter-undo/.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | "es2015", 4 | "react", 5 | "stage-0" 6 | ], 7 | "plugins": [ 8 | "add-module-exports", 9 | "transform-runtime" 10 | ], 11 | "env": { 12 | "test": { 13 | "plugins": [ 14 | "istanbul" 15 | ] 16 | } 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /examples/counter-undo/.eslintrc.yml: -------------------------------------------------------------------------------- 1 | extends: eslint-config-airbnb 2 | parser: babel-eslint 3 | 4 | env: 5 | es6: true 6 | node: true 7 | mocha: true 8 | browser: true 9 | 10 | rules: 11 | semi: 12 | - warn 13 | - never 14 | arrow-body-style: 15 | - error 16 | - as-needed 17 | no-param-reassign: 18 | - error 19 | - props: false 20 | 21 | parserOptions: 22 | ecmaFeatures: 23 | experimentalObjectRestSpread: true 24 | -------------------------------------------------------------------------------- /examples/counter-undo/README.md: -------------------------------------------------------------------------------- 1 | # Counter-Undo 2 | 3 | > Counter example work with [redux-undo](https://github.com/omnidan/redux-undo) for mickey 4 | 5 | ## Usage 6 | 7 | ```bash 8 | $ npm install 9 | $ npm start 10 | ``` 11 | -------------------------------------------------------------------------------- /examples/counter-undo/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "counter-undo", 3 | "description": "Counter example work with redux-undo for mickey", 4 | "dependencies": { 5 | "mickey": "^0.2.4", 6 | "react": "^15.6.1", 7 | "redux-undo": "^1.0.0-beta9-9-6" 8 | }, 9 | "devDependencies": { 10 | "babel-cli": "^6.26.0", 11 | "babel-core": "^6.26.0", 12 | "babel-eslint": "^7.2.3", 13 | "babel-loader": "^7.1.2", 14 | "babel-plugin-add-module-exports": "^0.2.1", 15 | "babel-plugin-transform-runtime": "^6.23.0", 16 | "babel-preset-es2015": "^6.24.1", 17 | "babel-preset-react": "^6.24.1", 18 | "babel-preset-stage-0": "^6.24.1", 19 | "cross-env": "^5.0.5", 20 | "css-loader": "^0.28.7", 21 | "eslint": "^4.5.0", 22 | "eslint-config-airbnb": "^15.1.0", 23 | "eslint-plugin-import": "^2.7.0", 24 | "eslint-plugin-jsx-a11y": "^5.1.1", 25 | "eslint-plugin-react": "^7.3.0", 26 | "husky": "^0.14.3", 27 | "rimraf": "^2.6.1", 28 | "style-loader": "^0.18.2", 29 | "webpack": "^3.5.5", 30 | "webpack-dev-server": "^2.7.1" 31 | }, 32 | "scripts": { 33 | "lint": "eslint --ext .js,.jsx src", 34 | "start": "webpack-dev-server --open", 35 | "build": "cross-env NODE_ENV=production webpack -p" 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /examples/counter-undo/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Counter Example - Mickey 7 | 8 | 9 | 10 |
11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /examples/counter-undo/src/App.css: -------------------------------------------------------------------------------- 1 | body { 2 | height: 100vh; 3 | padding: 0; 4 | margin: 0; 5 | } 6 | 7 | #counter-app { 8 | margin: 0 auto; 9 | width: 300px; 10 | } 11 | 12 | h1 { 13 | font-size: 180px; 14 | margin: 0; 15 | text-align: center; 16 | } 17 | 18 | .btn-wrap { 19 | text-align: center; 20 | } 21 | 22 | button { 23 | background-color: #fff; 24 | border: 1px solid #a0a0a0; 25 | border-radius: 4px; 26 | font-size: 120%; 27 | outline: none; 28 | width: 60px; 29 | height: 32px; 30 | margin: 10px; 31 | cursor: pointer; 32 | } 33 | -------------------------------------------------------------------------------- /examples/counter-undo/src/App.jsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable react/prop-types */ 2 | 3 | import React from 'react' 4 | import { injectActions, connect } from 'mickey' 5 | import { ActionCreators } from 'redux-undo' 6 | import './App.css' 7 | 8 | const App = props => ( 9 |
10 |

{props.count}

11 |
12 | 13 | 14 | 26 | 27 | 28 |
29 |
30 | ) 31 | 32 | export default injectActions( 33 | connect((store) => { 34 | console.log('data:', store.counter) 35 | return { ...store.counter.present } 36 | })(App), 37 | ) 38 | -------------------------------------------------------------------------------- /examples/counter-undo/src/index.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import createApp from 'mickey' 3 | 4 | import App from './App' 5 | 6 | // 1. Initialize 7 | const app = createApp() 8 | 9 | // 2. Model 10 | app.model(require('./models/counter.js')) 11 | 12 | // 3. View 13 | app.render(, document.getElementById('root')) 14 | -------------------------------------------------------------------------------- /examples/counter-undo/src/models/counter.js: -------------------------------------------------------------------------------- 1 | import undoable from 'redux-undo' 2 | 3 | const delay = timeout => new Promise((resolve) => { 4 | setTimeout(resolve, timeout) 5 | }) 6 | 7 | export default { 8 | namespace: 'counter', 9 | state: { 10 | count: 0, 11 | loading: false, 12 | }, 13 | enhancers: [ 14 | reducer => (state, action) => { 15 | const undoOpts = {} 16 | const newState = undoable(reducer, undoOpts)(state, action) 17 | return { ...newState } 18 | }, 19 | ], 20 | increment: state => ({ ...state, count: state.count + 1 }), 21 | decrement: state => ({ ...state, count: state.count - 1 }), 22 | incrementAsync: { 23 | * effect(payload, { call }, { succeed }) { 24 | yield call(delay, 2000) 25 | yield succeed() 26 | }, 27 | prepare: state => ({ ...state, loading: true }), 28 | succeed: state => ({ ...state, count: state.count + 1, loading: false }), 29 | }, 30 | } 31 | -------------------------------------------------------------------------------- /examples/counter-undo/webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | const webpack = require('webpack') 3 | 4 | const config = { 5 | // in development, 6 | // 'webpack-dev-server/client' and 'webpac/hot/dev-server' will be automatically added 7 | entry: [ 8 | './src/index.jsx', 9 | ], 10 | output: { 11 | path: path.resolve(__dirname, 'dist'), 12 | filename: 'app.js', 13 | publicPath: '/', 14 | }, 15 | resolve: { 16 | extensions: ['*', '.js', '.jsx'], 17 | }, 18 | module: { 19 | rules: [ 20 | { 21 | test: /\.jsx?$/, 22 | include: [ 23 | path.resolve(__dirname, 'src'), 24 | ], 25 | loader: 'babel-loader', 26 | }, 27 | { 28 | test: /\.css$/, 29 | use: [ 30 | { 31 | loader: 'style-loader', 32 | }, 33 | { 34 | loader: 'css-loader', 35 | }, 36 | ], 37 | }, 38 | ], 39 | }, 40 | } 41 | 42 | if (process.env.NODE_ENV === 'production') { 43 | config.devtool = 'source-map' 44 | // Exclude react and react-dom in the production bundle 45 | config.externals = { 46 | react: 'React', 47 | 'react-dom': 'ReactDOM', 48 | } 49 | } else { 50 | config.devtool = 'cheap-module-source-map' 51 | config.devServer = { 52 | contentBase: path.resolve(__dirname, 'public'), 53 | clientLogLevel: 'none', 54 | quiet: true, 55 | port: 8000, 56 | compress: true, 57 | hot: true, 58 | historyApiFallback: { 59 | disableDotRule: true, 60 | }, 61 | } 62 | 63 | // HMR support 64 | config.plugins = [ 65 | new webpack.HotModuleReplacementPlugin(), 66 | new webpack.NamedModulesPlugin(), 67 | ] 68 | } 69 | 70 | module.exports = config 71 | -------------------------------------------------------------------------------- /examples/counter/.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | "es2015", 4 | "react", 5 | "stage-0" 6 | ], 7 | "plugins": [ 8 | "add-module-exports", 9 | "transform-runtime" 10 | ], 11 | "env": { 12 | "test": { 13 | "plugins": [ 14 | "istanbul" 15 | ] 16 | } 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /examples/counter/.eslintrc.yml: -------------------------------------------------------------------------------- 1 | extends: eslint-config-airbnb 2 | parser: babel-eslint 3 | 4 | env: 5 | es6: true 6 | node: true 7 | mocha: true 8 | browser: true 9 | 10 | rules: 11 | semi: 12 | - warn 13 | - never 14 | arrow-body-style: 15 | - error 16 | - as-needed 17 | no-param-reassign: 18 | - error 19 | - props: false 20 | 21 | parserOptions: 22 | ecmaFeatures: 23 | experimentalObjectRestSpread: true 24 | -------------------------------------------------------------------------------- /examples/counter/README.md: -------------------------------------------------------------------------------- 1 | # Counter 2 | 3 | > Counter example for mickey 4 | 5 | ## Usage 6 | 7 | ```bash 8 | $ npm install 9 | $ npm start 10 | ``` 11 | -------------------------------------------------------------------------------- /examples/counter/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "counter", 3 | "description": "Counter example for mickey", 4 | "dependencies": { 5 | "mickey": "^1.3.2", 6 | "react": "^15.6.1" 7 | }, 8 | "devDependencies": { 9 | "babel-cli": "^6.26.0", 10 | "babel-core": "^6.26.0", 11 | "babel-eslint": "^7.2.3", 12 | "babel-loader": "^7.1.2", 13 | "babel-plugin-add-module-exports": "^0.2.1", 14 | "babel-plugin-transform-runtime": "^6.23.0", 15 | "babel-preset-es2015": "^6.24.1", 16 | "babel-preset-react": "^6.24.1", 17 | "babel-preset-stage-0": "^6.24.1", 18 | "cross-env": "^5.0.5", 19 | "css-loader": "^0.28.7", 20 | "eslint": "^4.5.0", 21 | "eslint-config-airbnb": "^15.1.0", 22 | "eslint-plugin-import": "^2.7.0", 23 | "eslint-plugin-jsx-a11y": "^5.1.1", 24 | "eslint-plugin-react": "^7.3.0", 25 | "husky": "^0.14.3", 26 | "rimraf": "^2.6.1", 27 | "style-loader": "^0.18.2", 28 | "webpack": "^3.5.5", 29 | "webpack-dev-server": "^2.7.1" 30 | }, 31 | "scripts": { 32 | "lint": "eslint --ext .js,.jsx src", 33 | "start": "webpack-dev-server --open", 34 | "build": "cross-env NODE_ENV=production webpack -p" 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /examples/counter/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Counter Example - Mickey 7 | 8 | 9 | 10 |
11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /examples/counter/src/App.css: -------------------------------------------------------------------------------- 1 | body { 2 | height: 100vh; 3 | padding: 0; 4 | margin: 0; 5 | } 6 | 7 | #counter-app { 8 | margin: 0 auto; 9 | width: 300px; 10 | } 11 | 12 | h1 { 13 | font-size: 180px; 14 | margin: 0; 15 | text-align: center; 16 | } 17 | 18 | .btn-wrap { 19 | text-align: center; 20 | } 21 | 22 | button { 23 | background-color: #fff; 24 | border: 1px solid #a0a0a0; 25 | border-radius: 4px; 26 | font-size: 120%; 27 | outline: none; 28 | width: 60px; 29 | height: 32px; 30 | margin: 0 10px; 31 | cursor: pointer; 32 | } 33 | -------------------------------------------------------------------------------- /examples/counter/src/App.jsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable react/prop-types */ 2 | 3 | import React from 'react' 4 | import { connect, injectActions } from 'mickey' 5 | import './App.css' 6 | 7 | const App = props => ( 8 |
9 |

{props.count}

10 |
11 | 12 | 13 | 25 |
26 |
27 | ) 28 | 29 | export default injectActions(connect(store => ({ ...store.counter }))(App)) 30 | -------------------------------------------------------------------------------- /examples/counter/src/index.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import createApp from 'mickey' 3 | import App from './App' 4 | 5 | // 1. Initialize 6 | const app = createApp() 7 | 8 | // 2. Model 9 | app.model(require('./models/counter.js')) 10 | 11 | // 3. View 12 | app.render(, document.getElementById('root')) 13 | -------------------------------------------------------------------------------- /examples/counter/src/models/counter.js: -------------------------------------------------------------------------------- 1 | const delay = timeout => new Promise((resolve) => { 2 | setTimeout(resolve, timeout) 3 | }) 4 | 5 | export default { 6 | namespace: 'counter', 7 | state: { 8 | count: 0, 9 | loading: false, 10 | }, 11 | increment: state => ({ ...state, count: state.count + 1 }), 12 | decrement: state => ({ ...state, count: state.count - 1 }), 13 | incrementAsync: { 14 | * effect(payload, { call }, { succeed }) { 15 | yield call(delay, 2000) 16 | yield succeed() 17 | }, 18 | prepare: state => ({ ...state, loading: true }), 19 | succeed: state => ({ ...state, count: state.count + 1, loading: false }), 20 | }, 21 | } 22 | -------------------------------------------------------------------------------- /examples/counter/webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | const webpack = require('webpack') 3 | 4 | const config = { 5 | // in development, 6 | // 'webpack-dev-server/client' and 'webpac/hot/dev-server' will be automatically added 7 | entry: [ 8 | './src/index.jsx', 9 | ], 10 | output: { 11 | path: path.resolve(__dirname, 'dist'), 12 | filename: 'app.js', 13 | publicPath: '/', 14 | }, 15 | resolve: { 16 | extensions: ['*', '.js', '.jsx'], 17 | }, 18 | module: { 19 | rules: [ 20 | { 21 | test: /\.jsx?$/, 22 | include: [ 23 | path.resolve(__dirname, 'src'), 24 | ], 25 | loader: 'babel-loader', 26 | }, 27 | { 28 | test: /\.css$/, 29 | use: [ 30 | { 31 | loader: 'style-loader', 32 | }, 33 | { 34 | loader: 'css-loader', 35 | }, 36 | ], 37 | }, 38 | ], 39 | }, 40 | } 41 | 42 | if (process.env.NODE_ENV === 'production') { 43 | config.devtool = 'source-map' 44 | // Exclude react and react-dom in the production bundle 45 | config.externals = { 46 | react: 'React', 47 | 'react-dom': 'ReactDOM', 48 | } 49 | } else { 50 | config.devtool = 'cheap-module-source-map' 51 | config.devServer = { 52 | contentBase: path.resolve(__dirname, 'public'), 53 | clientLogLevel: 'none', 54 | quiet: true, 55 | port: 8000, 56 | compress: true, 57 | hot: true, 58 | historyApiFallback: { 59 | disableDotRule: true, 60 | }, 61 | } 62 | 63 | // HMR support 64 | config.plugins = [ 65 | new webpack.HotModuleReplacementPlugin(), 66 | new webpack.NamedModulesPlugin(), 67 | ] 68 | } 69 | 70 | module.exports = config 71 | -------------------------------------------------------------------------------- /examples/simple-router/.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | "es2015", 4 | "react", 5 | "stage-0" 6 | ], 7 | "plugins": [ 8 | "add-module-exports", 9 | "transform-runtime" 10 | ], 11 | "env": { 12 | "test": { 13 | "plugins": [ 14 | "istanbul" 15 | ] 16 | } 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /examples/simple-router/.eslintrc.yml: -------------------------------------------------------------------------------- 1 | extends: eslint-config-airbnb 2 | parser: babel-eslint 3 | 4 | env: 5 | es6: true 6 | node: true 7 | mocha: true 8 | browser: true 9 | 10 | rules: 11 | semi: 12 | - warn 13 | - never 14 | arrow-body-style: 15 | - error 16 | - as-needed 17 | no-param-reassign: 18 | - error 19 | - props: false 20 | 21 | parserOptions: 22 | ecmaFeatures: 23 | experimentalObjectRestSpread: true 24 | -------------------------------------------------------------------------------- /examples/simple-router/README.md: -------------------------------------------------------------------------------- 1 | # Counter 2 | 3 | > Counter example for mickey 4 | 5 | ## Usage 6 | 7 | ```bash 8 | $ npm install 9 | $ npm start 10 | ``` 11 | -------------------------------------------------------------------------------- /examples/simple-router/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "counter", 3 | "description": "Counter example for mickey", 4 | "dependencies": { 5 | "react": "^15.6.1" 6 | }, 7 | "devDependencies": { 8 | "babel-cli": "^6.26.0", 9 | "babel-core": "^6.26.0", 10 | "babel-eslint": "^7.2.3", 11 | "babel-loader": "^7.1.2", 12 | "babel-plugin-add-module-exports": "^0.2.1", 13 | "babel-plugin-transform-runtime": "^6.23.0", 14 | "babel-preset-es2015": "^6.24.1", 15 | "babel-preset-react": "^6.24.1", 16 | "babel-preset-stage-0": "^6.24.1", 17 | "cross-env": "^5.0.5", 18 | "css-loader": "^0.28.7", 19 | "eslint": "^4.5.0", 20 | "eslint-config-airbnb": "^15.1.0", 21 | "eslint-plugin-import": "^2.7.0", 22 | "eslint-plugin-jsx-a11y": "^5.1.1", 23 | "eslint-plugin-react": "^7.3.0", 24 | "husky": "^0.14.3", 25 | "rimraf": "^2.6.1", 26 | "style-loader": "^0.18.2", 27 | "webpack": "^3.5.5", 28 | "webpack-dev-server": "^2.7.1" 29 | }, 30 | "scripts": { 31 | "lint": "eslint --ext .js,.jsx src", 32 | "start": "webpack-dev-server --open", 33 | "build": "cross-env NODE_ENV=production webpack -p" 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /examples/simple-router/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Counter Example - Mickey 7 | 8 | 9 | 10 |
11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /examples/simple-router/src/Router.jsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable react/prop-types */ 2 | 3 | import React from 'react' 4 | import { HashRouter as Router, Route, Switch } from '../../../lib' 5 | import Header from './components/Header' 6 | import Home from './components/Home' 7 | import About from './components/About' 8 | import Topics from './components/Topics' 9 | 10 | const Routers = () => ( 11 |
12 |
13 |
14 |
15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 |
23 |
24 | ) 25 | 26 | export default Routers 27 | -------------------------------------------------------------------------------- /examples/simple-router/src/components/About.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | const About = () => ( 4 |
5 |

About

6 |
7 | ) 8 | 9 | export default About 10 | -------------------------------------------------------------------------------- /examples/simple-router/src/components/AddTopic.jsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable react/prop-types */ 2 | 3 | import React from 'react' 4 | import { injectActions } from '../../../../lib' 5 | 6 | const AddTopic = ({ actions }) => { 7 | let input 8 | 9 | const submit = () => { 10 | if (input.value.trim()) { 11 | actions.topics.add(input.value) 12 | input.value = '' 13 | } 14 | } 15 | 16 | return ( 17 |
18 | { input = elem }} /> 19 | 20 |
21 | ) 22 | } 23 | 24 | export default injectActions(AddTopic) 25 | -------------------------------------------------------------------------------- /examples/simple-router/src/components/Header.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { Link } from '../../../../lib' 3 | 4 | const Header = () => ( 5 |
6 | 13 |
14 | ) 15 | 16 | export default Header 17 | -------------------------------------------------------------------------------- /examples/simple-router/src/components/Home.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | const Home = () => ( 4 |
5 |

Home

6 |
7 | ) 8 | 9 | export default Home 10 | -------------------------------------------------------------------------------- /examples/simple-router/src/components/Topic.jsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable react/prop-types */ 2 | 3 | import React from 'react' 4 | import { injectActions } from '../../../../lib' 5 | 6 | const Topic = ({ topic, actions }) => ( 7 |
8 |

{topic || 'Topic not found'}

9 | 10 |
11 | ) 12 | 13 | export default injectActions(Topic) 14 | -------------------------------------------------------------------------------- /examples/simple-router/src/components/Topics.jsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable react/prop-types */ 2 | 3 | import React from 'react' 4 | import { Route, Link, connect } from '../../../../lib' 5 | import Topic from './Topic' 6 | import AddTopic from './AddTopic' 7 | 8 | const Topics = ({ topics }) => ( 9 |
10 |

Topics

11 |
    12 | { 13 | topics.map((topic, idx) => ( 14 |
  • 15 | {topic} 16 |
  • 17 | )) 18 | } 19 |
20 | ( 23 | idx === match.params.topicId)} /> 24 | )} 25 | /> 26 | 27 |
28 | ) 29 | 30 | export default connect(({ topics }) => ({ topics }))(Topics) 31 | -------------------------------------------------------------------------------- /examples/simple-router/src/index.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import createApp from '../../../lib' 3 | import Router from './Router' 4 | 5 | // 1. Initialize 6 | const app = createApp({ historyMode: 'hash' }) 7 | 8 | // 2. Model 9 | app.model({ 10 | namespace: 'topics', 11 | state: [ 12 | 'foo', 13 | ], 14 | add: (state, topic) => [...state, topic], 15 | }) 16 | 17 | // 3. View 18 | app.render(, document.getElementById('root')) 19 | 20 | console.log(app) // eslint-disable-line 21 | -------------------------------------------------------------------------------- /examples/simple-router/webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | const webpack = require('webpack') 3 | 4 | const config = { 5 | // in development, 6 | // 'webpack-dev-server/client' and 'webpac/hot/dev-server' will be automatically added 7 | entry: [ 8 | './src/index.jsx', 9 | ], 10 | output: { 11 | path: path.resolve(__dirname, 'dist'), 12 | filename: 'app.js', 13 | publicPath: '/', 14 | }, 15 | resolve: { 16 | extensions: ['*', '.js', '.jsx'], 17 | }, 18 | module: { 19 | rules: [ 20 | { 21 | test: /\.jsx?$/, 22 | include: [ 23 | path.resolve(__dirname, 'src'), 24 | ], 25 | loader: 'babel-loader', 26 | }, 27 | { 28 | test: /\.css$/, 29 | use: [ 30 | { 31 | loader: 'style-loader', 32 | }, 33 | { 34 | loader: 'css-loader', 35 | }, 36 | ], 37 | }, 38 | ], 39 | }, 40 | } 41 | 42 | if (process.env.NODE_ENV === 'production') { 43 | config.devtool = 'source-map' 44 | // Exclude react and react-dom in the production bundle 45 | config.externals = { 46 | react: 'React', 47 | 'react-dom': 'ReactDOM', 48 | } 49 | } else { 50 | config.devtool = 'cheap-module-source-map' 51 | config.devServer = { 52 | contentBase: path.resolve(__dirname, 'public'), 53 | clientLogLevel: 'none', 54 | quiet: true, 55 | port: 8000, 56 | compress: true, 57 | hot: true, 58 | historyApiFallback: { 59 | disableDotRule: true, 60 | }, 61 | } 62 | 63 | // HMR support 64 | config.plugins = [ 65 | new webpack.HotModuleReplacementPlugin(), 66 | new webpack.NamedModulesPlugin(), 67 | ] 68 | } 69 | 70 | module.exports = config 71 | -------------------------------------------------------------------------------- /mickey.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "mickey", 3 | "version": "2.0.0-alpha.1", 4 | "description": "Lightweight front-end framework for creating React and Redux based app painlessly.", 5 | "main": "./lib/index.js", 6 | "keywords": [ 7 | "mickey", 8 | "react", 9 | "redux", 10 | "redux-saga", 11 | "elm", 12 | "framework", 13 | "frontend" 14 | ], 15 | "author": { 16 | "name": "bubkoo", 17 | "email": "bubkoo.wy@gmail.com" 18 | }, 19 | "license": "MIT", 20 | "repository": { 21 | "type": "git", 22 | "url": "git+https://github.com/bubkoo/mickey.git" 23 | }, 24 | "bugs": { 25 | "url": "https://github.com/bubkoo/mickey/issues" 26 | }, 27 | "homepage": "https://github.com/bubkoo/mickey#readme", 28 | "peerDependencies": { 29 | "react": "*", 30 | "prop-types": "*", 31 | "react-dom": "*" 32 | }, 33 | "dependencies": { 34 | "connected-react-router": "^4.4.1", 35 | "global": "^4.3.2", 36 | "history": "^4.7.2", 37 | "invariant": "^2.2.4", 38 | "is-plain-object": "^2.0.4", 39 | "lodash.flatten": "^4.4.0", 40 | "lodash.get": "^4.4.2", 41 | "lodash.isfunction": "^3.0.9", 42 | "minimatch": "^3.0.4", 43 | "object-assign": "^4.1.1", 44 | "react-redux": "^5.0.7", 45 | "react-router": "^4.3.1", 46 | "react-router-dom": "^4.3.1", 47 | "redux": "^4.0.0", 48 | "redux-actions": "^2.6.1", 49 | "redux-devtools-extension": "^2.13.7", 50 | "redux-saga": "^0.16.0", 51 | "warning": "^4.0.2" 52 | }, 53 | "devDependencies": { 54 | "babel-cli": "^6.26.0", 55 | "babel-eslint": "^7.2.3", 56 | "babel-plugin-add-module-exports": "^0.2.1", 57 | "babel-plugin-istanbul": "^4.1.4", 58 | "babel-plugin-transform-decorators-legacy": "^1.3.4", 59 | "babel-plugin-transform-runtime": "^6.23.0", 60 | "babel-preset-env": "^1.6.1", 61 | "babel-preset-react": "^6.24.1", 62 | "babel-preset-stage-0": "^6.24.1", 63 | "babel-register": "^6.26.0", 64 | "chai": "^4.1.2", 65 | "coveralls": "^2.13.1", 66 | "cross-env": "^5.0.5", 67 | "eslint": "^4.11.0", 68 | "eslint-config-airbnb": "^15.1.0", 69 | "eslint-plugin-import": "^2.7.0", 70 | "eslint-plugin-jsx-a11y": "^5.1.1", 71 | "eslint-plugin-react": "^7.3.0", 72 | "husky": "^0.14.3", 73 | "jsdom": "^11.2.0", 74 | "mocha": "^3.5.0", 75 | "nyc": "^11.1.0", 76 | "react": "^16.3.2", 77 | "react-dom": "^16.3.2", 78 | "rimraf": "^2.6.1", 79 | "sinon": "^4.1.2" 80 | }, 81 | "files": [ 82 | "lib", 83 | "src", 84 | "*.d.ts" 85 | ], 86 | "nyc": { 87 | "include": [ 88 | "src/**/*.js" 89 | ], 90 | "reporter": [ 91 | "html", 92 | "text" 93 | ], 94 | "require": [ 95 | "babel-register", 96 | "./test/setup.js" 97 | ], 98 | "report-dir": "./test/coverage", 99 | "sourceMap": false, 100 | "instrument": false 101 | }, 102 | "scripts": { 103 | "test": "cross-env NODE_ENV=test nyc mocha ./test/index.js", 104 | "coveralls": "nyc report --reporter=text-lcov | coveralls", 105 | "lint": "eslint --ext .js,.jsx src", 106 | "build": "rimraf lib && babel src --out-dir lib", 107 | "prebuild": "npm run lint", 108 | "prepare": "npm run lint && npm run build && npm run test", 109 | "precommit": "npm run lint" 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /src/ActionsProvider.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | 4 | export function createProvider(actionsKey = 'actions') { 5 | class Provider extends React.Component { 6 | constructor(props, context) { 7 | super(props, context) 8 | this[actionsKey] = props.actions 9 | } 10 | 11 | getChildContext() { 12 | return { [actionsKey]: this[actionsKey] } 13 | } 14 | 15 | render() { 16 | return React.Children.only(this.props.children) 17 | } 18 | } 19 | 20 | Provider.propTypes = { 21 | actions: PropTypes.object.isRequired, // eslint-disable-line 22 | children: PropTypes.element.isRequired, 23 | } 24 | 25 | Provider.childContextTypes = { 26 | [actionsKey]: PropTypes.object.isRequired, 27 | } 28 | 29 | return Provider 30 | } 31 | 32 | export default createProvider() 33 | -------------------------------------------------------------------------------- /src/Plugin.js: -------------------------------------------------------------------------------- 1 | import invariant from 'invariant' 2 | import { isPlainObject, getEnhancer } from './utils' 3 | 4 | 5 | export default class Plugin { 6 | static hooks = [ 7 | 'onError', 8 | 'onEffect', 9 | 'onStateChange', 10 | 11 | 'onAction', 12 | 'onReducer', 13 | 'extraReducers', 14 | 'extraEnhancers', 15 | ] 16 | 17 | constructor() { 18 | this.hooks = Plugin.hooks.reduce((memo, name) => { 19 | memo[name] = [] 20 | return memo 21 | }, {}) 22 | } 23 | 24 | use(plugins) { 25 | invariant(isPlainObject(plugins), 'hook should be plain object') 26 | 27 | Object.keys(plugins).forEach((key) => { 28 | invariant(this.hooks[key], `unknown hook property: "${key}"`) 29 | const plugin = plugins[key] 30 | if (plugin) { 31 | if (key === 'extraEnhancers') { 32 | this.hooks[key] = plugin 33 | } else if (Array.isArray(plugin)) { 34 | this.hooks[key].push(...plugin) 35 | } else { 36 | this.hooks[key].push(plugin) 37 | } 38 | } 39 | }) 40 | 41 | return this 42 | } 43 | 44 | apply(name, defaultHandler) { 45 | const handlers = this.hooks[name] 46 | const validHooks = ['onError'] 47 | 48 | invariant(validHooks.includes(name), `plugin.apply: hook "${name}" cannot be applied`) 49 | 50 | return (...args) => { 51 | if (handlers.length) { 52 | handlers.forEach(fn => fn(...args)) 53 | } else if (defaultHandler) { 54 | defaultHandler(...args) 55 | } 56 | } 57 | } 58 | 59 | get(name) { 60 | const handlers = this.hooks[name] 61 | invariant(handlers, `hook "${name}" cannot be got`) 62 | 63 | if (name === 'extraReducers') { 64 | return handlers.reduce((memo, reducer) => ({ ...memo, ...reducer }), {}) 65 | } else if (name === 'onReducer') { 66 | return getEnhancer(handlers) 67 | } 68 | 69 | return handlers 70 | } 71 | } 72 | 73 | -------------------------------------------------------------------------------- /src/Provider.js: -------------------------------------------------------------------------------- 1 | import warning from 'warning' 2 | import React, { createElement } from 'react' 3 | import PropTypes from 'prop-types' 4 | import { Provider as StoreProvider } from 'react-redux' 5 | import { ConnectedRouter, routerActions } from 'connected-react-router' 6 | import ActionsProvider from './ActionsProvider' 7 | 8 | 9 | export default class Provider extends React.Component { 10 | static propTypes = { 11 | children: PropTypes.element.isRequired, 12 | app: PropTypes.shape({ 13 | store: PropTypes.object.isRequired, 14 | actions: PropTypes.object.isRequired, 15 | history: PropTypes.object, 16 | }).isRequired, 17 | } 18 | 19 | addRouterActions() { 20 | const { app } = this.props 21 | if (app.history) { 22 | // Add `push`, `replace`, `go`, `goForward` and `goBack` methods to actions.router, 23 | // when called, will dispatch the crresponding action provided by connected-react-router. 24 | app.actions.router = Object.keys(routerActions).reduce((memo, action) => ({ 25 | ...memo, 26 | [action]: (...args) => { 27 | app.store.dispatch(routerActions[action](...args)) 28 | }, 29 | }), {}) 30 | 31 | app.actions.routing = Object.keys(routerActions).reduce((memo, action) => ({ 32 | ...memo, 33 | [action]: (...args) => { 34 | if (process.env.NODE_ENV !== 'production') { 35 | warning( 36 | false, 37 | '\'app.actions.routing\' is deprecated, use \'app.actions.router\' instead.', 38 | ) 39 | } 40 | 41 | app.store.dispatch(routerActions[action](...args)) 42 | }, 43 | }), {}) 44 | } 45 | } 46 | 47 | renderProvider(Component) { 48 | const { app } = this.props 49 | 50 | return createElement(ActionsProvider, { actions: app.actions }, 51 | createElement(StoreProvider, { store: app.store }, 52 | Component, 53 | ), 54 | ) 55 | } 56 | 57 | render() { 58 | const { children, app } = this.props 59 | const child = React.Children.only(children) 60 | this.addRouterActions() 61 | return this.renderProvider( 62 | app.history 63 | ? createElement(ConnectedRouter, { history: app.history }, child) 64 | : child, 65 | ) 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/actions.js: -------------------------------------------------------------------------------- 1 | import { NAMESPACE_SEP } from './constants' 2 | import { prefixType } from './utils' 3 | 4 | export function getModelActions(model, dispatch) { 5 | const { actions, namespace } = model 6 | return Object.keys(actions).reduce((memo, methodName) => ({ 7 | ...memo, 8 | [methodName]: payload => dispatch({ 9 | payload, 10 | type: prefixType(namespace, actions[methodName]), 11 | }), 12 | }), {}) 13 | } 14 | 15 | function createActions(app, model) { 16 | const { actions, namespace } = model 17 | return Object.keys(actions).reduce((memo, methodName) => ({ 18 | ...memo, 19 | // dispatch should be ready after the app started 20 | [methodName]: payload => app.store.dispatch({ 21 | payload, 22 | type: prefixType(namespace, actions[methodName]), 23 | }), 24 | }), {}) 25 | } 26 | 27 | export function addActions(app, model) { 28 | const { actions } = app 29 | const { namespace } = model 30 | const nss = namespace.split(NAMESPACE_SEP) 31 | let temp = actions 32 | nss.forEach((ns) => { 33 | if (!temp[ns]) { 34 | temp[ns] = {} 35 | } 36 | temp = temp[ns] 37 | }) 38 | 39 | Object.assign(temp, createActions(app, model)) 40 | 41 | return actions 42 | } 43 | 44 | export function removeActions(app, namespace) { 45 | const { actions } = app 46 | const nss = namespace.split(NAMESPACE_SEP) 47 | const lastIndex = nss.length - 1 48 | 49 | let temp = actions 50 | let removed = null 51 | 52 | nss.forEach((ns, index) => { 53 | if (index === lastIndex) { 54 | removed = temp[ns] 55 | delete temp[ns] 56 | } else if (temp) { 57 | temp = temp[ns] 58 | } 59 | }) 60 | 61 | // clean sub tree 62 | for (let i = 0; i < lastIndex; i += 1) { 63 | temp = actions 64 | nss.some((ns) => { // eslint-disable-line 65 | if (temp && temp[ns]) { 66 | if (Object.keys(temp[ns]).length === 0) { 67 | delete temp[ns] 68 | return true 69 | } 70 | temp = temp[ns] 71 | return false 72 | } 73 | 74 | return true 75 | }) 76 | } 77 | 78 | return removed 79 | } 80 | -------------------------------------------------------------------------------- /src/baseModel.js: -------------------------------------------------------------------------------- 1 | import { MUTATE } from './constants' 2 | 3 | export default { 4 | [MUTATE]: (state, payload) => ({ 5 | ...state, 6 | ...payload, 7 | }), 8 | } 9 | -------------------------------------------------------------------------------- /src/checkModel.js: -------------------------------------------------------------------------------- 1 | import invariant from 'invariant' 2 | 3 | export default function checkModel(model, models) { 4 | const { namespace } = model 5 | 6 | invariant( 7 | namespace, 8 | 'app.model: namespace should be specified', 9 | ) 10 | 11 | invariant( 12 | typeof namespace === 'string', 13 | `app.model: namespace should be string, but got ${typeof namespace}`, 14 | ) 15 | 16 | invariant( 17 | !models.some(m => m.namespace === namespace), 18 | 'app.model: namespace should be unique', 19 | ) 20 | } 21 | 22 | -------------------------------------------------------------------------------- /src/constants.js: -------------------------------------------------------------------------------- 1 | export const MUTATE = 'mutate' 2 | export const NAMESPACE_SEP = '/' 3 | export const CANCEL_EFFECTS = '@@CANCEL_EFFECTS' 4 | export const MICKEY_UPDATE = '@@MICKEY/UPDATE' 5 | -------------------------------------------------------------------------------- /src/createApp.js: -------------------------------------------------------------------------------- 1 | import Plugin from './Plugin' 2 | import getSaga from './getSaga' 3 | import getReducer from './getReducer' 4 | import createStore from './createStore' 5 | import createRender from './createRender' 6 | import createModel from './createModel' 7 | import registerModel from './registerModel' 8 | import internalModel from './internalModel' 9 | import createReducer from './createReducer' 10 | import createHistory from './createHistory' 11 | import { removeActions } from './actions' 12 | import createErrorHandler from './createErrorHandler' 13 | import createPromiseMiddleware from './createPromiseMiddleware' 14 | import { startWatchers, stopWatchers } from './watcher' 15 | import { CANCEL_EFFECTS, MICKEY_UPDATE } from './constants' 16 | import { prefixType, fixNamespace } from './utils' 17 | 18 | 19 | export default function createApp(options = {}) { 20 | const { 21 | hooks = {}, 22 | historyMode, 23 | initialState = {}, 24 | initialReducer = {}, 25 | extensions = {}, 26 | } = options 27 | 28 | // supportted extensions 29 | const { createReducer: reducerCreator, combineReducers } = extensions 30 | // use `options.history` or create one with `historyMode` 31 | const history = options.history || createHistory(historyMode) 32 | 33 | const app = {} 34 | const plugin = (new Plugin()).use(hooks) 35 | const regModel = m => (registerModel(app, m)) 36 | 37 | return Object.assign(app, { 38 | plugin, 39 | history, 40 | actions: {}, 41 | models: [createModel({ ...internalModel })], 42 | 43 | // check the namespace available or not 44 | has(namespace) { 45 | namespace = fixNamespace(namespace) // eslint-disable-line 46 | return app.models.some(m => m.namespace === namespace) 47 | }, 48 | 49 | // register model before app is started 50 | model(raw) { 51 | regModel(raw) 52 | return app 53 | }, 54 | 55 | eject(namespace) { 56 | namespace = fixNamespace(namespace) // eslint-disable-line 57 | app.models = app.models.filter(m => m.namespace !== namespace) 58 | removeActions(app, namespace) 59 | 60 | return app 61 | }, 62 | 63 | load() { 64 | throw new Error('The method `load(pattern)` is unavailable. This method depend on `babel-plugin-mickey-model-loader`. For more information, see: https://github.com/mickeyjsx/babel-plugin-mickey-model-loader') 65 | }, 66 | 67 | // create store, steup reducer, start the app 68 | render(component, container, callback) { 69 | const onError = createErrorHandler(app) 70 | const onEffect = plugin.get('onEffect') 71 | const extraReducers = plugin.get('extraReducers') 72 | const reducerEnhancer = plugin.get('onReducer') 73 | 74 | const sagas = [] 75 | const reducers = { ...initialReducer } 76 | 77 | const innerGetSaga = m => getSaga(onError, onEffect, app, m) 78 | const innerGetReducer = m => getReducer(reducerCreator, m) 79 | const innerCreateReducer = asyncReducers => createReducer({ 80 | reducers, 81 | asyncReducers, 82 | extraReducers, 83 | reducerEnhancer, 84 | combineReducers, 85 | history: app.history, 86 | }) 87 | 88 | // handle reducers and sagas in model 89 | app.models.forEach((model) => { 90 | reducers[model.namespace] = innerGetReducer(model) 91 | sagas.push(innerGetSaga(model)) 92 | }) 93 | 94 | // create store 95 | const store = app.store = createStore({ // eslint-disable-line 96 | plugin, 97 | options, 98 | initialState, 99 | history: app.history, 100 | reducers: innerCreateReducer(), 101 | promiseMiddleware: createPromiseMiddleware(app), 102 | extraMiddlewares: plugin.get('onAction'), 103 | onStateChange: plugin.get('onStateChange'), 104 | extraEnhancers: plugin.get('extraEnhancers'), 105 | }) 106 | 107 | // run sagas 108 | sagas.forEach(store.runSaga) 109 | 110 | const render = createRender(app, component, container, callback) 111 | render(component, container, callback) 112 | 113 | // run watcher after render 114 | const unlisteners = {} 115 | app.models.forEach((model) => { 116 | unlisteners[model.namespace] = startWatchers(model, app, onError) 117 | }) 118 | 119 | // replace and inject some methods after the app started 120 | return Object.assign(app, { 121 | // after the first call fo render, the render function 122 | // should only do pure-render with any other actions 123 | render, 124 | onError, 125 | 126 | // inject model after app is started 127 | model(raw) { 128 | const namespace = fixNamespace(raw.namespace) 129 | 130 | // clean the old one when exists 131 | if (app.has(namespace)) { 132 | stopWatchers(unlisteners, namespace) 133 | removeActions(app, namespace) 134 | app.models = app.models.filter(m => m.namespace !== namespace) 135 | store.dispatch({ type: prefixType(namespace, CANCEL_EFFECTS) }) 136 | store.dispatch({ type: MICKEY_UPDATE }) 137 | } 138 | 139 | const model = regModel(raw) 140 | 141 | store.asyncReducers[namespace] = innerGetReducer(model) 142 | store.replaceReducer(innerCreateReducer(store.asyncReducers)) 143 | store.runSaga(innerGetSaga(model)) 144 | 145 | unlisteners[namespace] = startWatchers(model, app, onError) 146 | 147 | return app 148 | }, 149 | 150 | // remove model 151 | eject(namespace) { 152 | namespace = fixNamespace(namespace) // eslint-disable-line 153 | delete store.asyncReducers[namespace] 154 | delete reducers[namespace] 155 | 156 | // The pattern we recommend is to keep the old reducers around, so there's a warning 157 | // ref: https://stackoverflow.com/questions/34095804/replacereducer-causing-unexpected-key-error 158 | store.replaceReducer(innerCreateReducer(store.asyncReducers)) 159 | store.dispatch({ type: MICKEY_UPDATE }) 160 | store.dispatch({ type: prefixType(namespace, CANCEL_EFFECTS) }) 161 | 162 | stopWatchers(unlisteners, namespace) 163 | 164 | app.models = app.models.filter(m => m.namespace !== namespace) 165 | removeActions(app, namespace) 166 | 167 | return app 168 | }, 169 | }) 170 | }, 171 | }) 172 | } 173 | -------------------------------------------------------------------------------- /src/createErrorHandler.js: -------------------------------------------------------------------------------- 1 | const normalize = (err) => { 2 | if (typeof err === 'string') { 3 | return new Error(err) 4 | } 5 | return err 6 | } 7 | 8 | const defaultErrorHandler = (err) => { throw err } 9 | 10 | export default function createErrorHandler(app) { 11 | return (err) => { 12 | if (err) { 13 | const handler = app.plugin.apply('onError', defaultErrorHandler) 14 | handler(normalize(err), app) 15 | } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/createHistory.js: -------------------------------------------------------------------------------- 1 | import invariant from 'invariant' 2 | import { 3 | createHashHistory, 4 | createMemoryHistory, 5 | createBrowserHistory, 6 | } from 'history' 7 | 8 | const historyModes = ['browser', 'hash', 'memory'] 9 | 10 | export default function createHistory(historyMode) { 11 | if (historyMode) { 12 | if (process.env.NODE_ENV !== 'production') { 13 | invariant( 14 | historyModes.includes(historyMode), 15 | `historyMode "${historyMode}" is invalid, must be one of ${historyModes.join(', ')}!`, 16 | ) 17 | } 18 | 19 | if (historyMode === 'hash') { 20 | return createHashHistory() 21 | } else if (historyMode === 'browser') { 22 | return createBrowserHistory() 23 | } else if (historyMode === 'memory') { 24 | return createMemoryHistory() 25 | } 26 | } 27 | 28 | return null 29 | } 30 | -------------------------------------------------------------------------------- /src/createModel.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-underscore-dangle */ 2 | import invariant from 'invariant' 3 | import baseModel from './baseModel' 4 | import { MUTATE } from './constants' 5 | import { 6 | ucfirst, 7 | prefixType, 8 | prefixObject, 9 | isArray, 10 | isFunction, 11 | isGeneratorFn, 12 | fixNamespace, 13 | } from './utils' 14 | 15 | 16 | function isEffect(method) { 17 | // if a method is generator function then it should be an effect. 18 | return isGeneratorFn(method) 19 | || (isArray(method) && isGeneratorFn(method[0])) // [ *effect(){}, type ] 20 | } 21 | 22 | function createEmptyGroup(type) { 23 | return { 24 | type, 25 | actions: {}, 26 | effects: {}, 27 | reducers: {}, 28 | callbacks: [], 29 | effectCount: 0, 30 | } 31 | } 32 | 33 | function fillGroup(group, type, method, callback) { 34 | const { actions, effects, reducers, callbacks } = group 35 | if (typeof method === 'object' && !isArray(method)) { 36 | return false 37 | } 38 | 39 | if (isEffect(method)) { 40 | actions[type] = type 41 | effects[type] = method 42 | group.effectCount += 1 43 | return true 44 | } else if (isFunction(method)) { 45 | if (callback && callback !== 'prepare') { 46 | const prefixed = prefixType(type, callback) // query/succeed 47 | actions[type + ucfirst(callback)] = prefixed // { querySucceed: 'query/scuueed' } 48 | reducers[prefixed] = method 49 | callbacks.push(callback) 50 | } else { 51 | actions[type] = type 52 | reducers[type] = method 53 | } 54 | return true 55 | } 56 | 57 | return false 58 | } 59 | 60 | function parseGroups(raw, namespace) { 61 | const keys = Object.keys(raw) 62 | const groups = keys.map((type) => { 63 | const section = raw[type] 64 | const group = createEmptyGroup(type) 65 | 66 | if (!fillGroup(group, type, section)) { 67 | Object.keys(section).forEach(name => fillGroup(group, type, section[name], name)) 68 | } 69 | 70 | if (process.env.NODE_ENV !== 'production') { 71 | invariant( 72 | group.effectCount <= 1, 73 | `Less than one effect function should be specified in model "${namespace}" with action name "${type}".`, 74 | ) 75 | } 76 | 77 | return group 78 | }) 79 | 80 | if (process.env.NODE_ENV !== 'production') { 81 | const exist = keys.some(key => (key === MUTATE)) 82 | invariant( 83 | !exist, 84 | `The \`mutate\` is a reserved action for mutate the state. You should change \`mutate\` to other action names in model "${namespace}".`, 85 | ) 86 | } 87 | 88 | // extend model with `mutate` reducer 89 | groups.push({ 90 | type: MUTATE, 91 | actions: { [MUTATE]: MUTATE }, 92 | effects: {}, 93 | reducers: { [MUTATE]: baseModel[MUTATE] }, 94 | callbacks: [], 95 | effectCount: 0, 96 | }) 97 | 98 | return groups 99 | } 100 | 101 | function parseWatcher(watcher) { 102 | if (watcher) { 103 | const watchers = isArray(watcher) // eslint-disable-line 104 | ? [...watcher] : isFunction(watcher) 105 | ? [watcher] 106 | : Object.keys(watcher).map(key => watcher[key]) 107 | 108 | return watchers.filter(fn => isFunction(fn)) 109 | } 110 | 111 | return null 112 | } 113 | 114 | function getWatchers(m) { 115 | const { watcher, subscriptions } = m 116 | return parseWatcher(watcher) || parseWatcher(subscriptions) || [] 117 | } 118 | 119 | export default function createModel(m) { 120 | const { 121 | namespace, 122 | state, 123 | effects, 124 | reducers, 125 | enhancers, 126 | createReducer, 127 | ...others 128 | } = m 129 | 130 | const actions = {} 131 | const _effects = {} 132 | const _reducers = {} 133 | const _callbacks = {} 134 | 135 | if (effects) { 136 | Object.keys(effects).forEach((type) => { 137 | const method = effects[type] 138 | if (isEffect(method)) { 139 | _effects[type] = method 140 | actions[type] = type 141 | } 142 | }) 143 | } 144 | 145 | if (reducers) { 146 | Object.keys(reducers).forEach((type) => { 147 | const method = reducers[type] 148 | if (isFunction(method)) { 149 | _reducers[type] = method 150 | actions[type] = type 151 | } 152 | }) 153 | } 154 | 155 | const groups = parseGroups(others, namespace) 156 | groups.forEach((group) => { 157 | Object.assign(actions, group.actions) 158 | Object.assign(_effects, group.effects) 159 | Object.assign(_reducers, group.reducers) 160 | if (group.callbacks.length) { 161 | _callbacks[group.type] = group.callbacks 162 | } 163 | }) 164 | 165 | const ns = fixNamespace(namespace) 166 | return { 167 | namespace: ns, 168 | state, 169 | enhancers, 170 | createReducer, 171 | actions, 172 | effects: prefixObject(ns, _effects), 173 | reducers: prefixObject(ns, _reducers), 174 | callbacks: prefixObject(ns, _callbacks), 175 | watchers: getWatchers(m), 176 | } 177 | } 178 | -------------------------------------------------------------------------------- /src/createPromiseMiddleware.js: -------------------------------------------------------------------------------- 1 | import { NAMESPACE_SEP } from './constants' 2 | 3 | export default function createPromiseMiddleware(app) { 4 | const caches = {} 5 | 6 | function isEffect(type) { 7 | const cache = caches[type] 8 | if (cache) { 9 | return cache === 1 10 | } 11 | 12 | const parts = type.split(NAMESPACE_SEP) 13 | const namespace = parts.slice(0, -1).join(NAMESPACE_SEP) 14 | 15 | const ret = app.models.some((m) => { 16 | if (m.namespace === namespace && m.effects[type]) { 17 | return true 18 | } 19 | return false 20 | }) 21 | 22 | caches[type] = ret ? 1 : 2 23 | 24 | return ret 25 | } 26 | 27 | const middleware = () => next => (action) => { 28 | const { type } = action 29 | if (isEffect(type)) { 30 | // action should be a plain-object 31 | action.resolver = {} 32 | const promise = new Promise((resolve, reject) => { 33 | action.resolver.resolve = resolve 34 | action.resolver.reject = reject 35 | }) 36 | 37 | action.then = promise.then.bind(promise) 38 | action.catch = promise.catch.bind(promise) 39 | 40 | return next(action) 41 | } 42 | 43 | return next(action) 44 | } 45 | 46 | return middleware 47 | } 48 | -------------------------------------------------------------------------------- /src/createReducer.js: -------------------------------------------------------------------------------- 1 | import invariant from 'invariant' 2 | import { combineReducers } from 'redux' 3 | import { connectRouter } from 'connected-react-router' 4 | import { NAMESPACE_SEP } from './constants' 5 | import { isFunction } from './utils' 6 | 7 | function parseNamespace(reducers) { 8 | return Object.keys(reducers).reduce((memo, key) => { 9 | const nss = key.split(NAMESPACE_SEP) 10 | let temp = memo 11 | 12 | nss.forEach((ns, index) => { 13 | if (!temp[ns]) { 14 | temp[ns] = index === nss.length - 1 ? reducers[key] : {} 15 | } 16 | temp = temp[ns] 17 | }) 18 | 19 | return memo 20 | }, {}) 21 | } 22 | 23 | export default function createReducer({ 24 | history, 25 | reducers, 26 | asyncReducers = {}, 27 | extraReducers, 28 | reducerEnhancer, 29 | combineReducers: globalCombineReducers, 30 | }) { 31 | const combineMethod = globalCombineReducers || combineReducers 32 | const merged = { ...reducers, ...asyncReducers } 33 | const combine = section => Object.keys(section).reduce((memo, key) => ({ 34 | ...memo, 35 | [key]: isFunction(section[key]) 36 | ? section[key] 37 | : combineMethod(combine(section[key])), 38 | }), {}) 39 | 40 | if (process.env.NODE_ENV !== 'production') { 41 | invariant( 42 | Object.keys(extraReducers).every(key => !(key in merged)), 43 | `createReducer: extraReducers is conflict with other reducers, reducers list: ${Object.keys(merged).join(', ')}`, 44 | ) 45 | } 46 | 47 | const parsed = parseNamespace(merged) 48 | const combined = combine(parsed) 49 | const rootReducer = combineMethod({ 50 | ...combined, 51 | ...extraReducers, 52 | }) 53 | 54 | return reducerEnhancer( 55 | history 56 | ? connectRouter(history)(rootReducer) 57 | : rootReducer, 58 | ) 59 | } 60 | -------------------------------------------------------------------------------- /src/createRender.js: -------------------------------------------------------------------------------- 1 | import invariant from 'invariant' 2 | import React from 'react' 3 | import ReactDOM from 'react-dom' 4 | import Provider from './Provider' 5 | import { isFunction, isString, isHTMLElement } from './utils' 6 | 7 | 8 | function getCallbacks(options) { 9 | if (isFunction(options)) { 10 | return { afterRender: options } 11 | } 12 | return options || {} 13 | } 14 | 15 | 16 | export default function createRender(app, component, container, callback) { 17 | // return a render function 18 | return (_component, _container, _callback) => { 19 | const { beforeRender, afterRender } = getCallbacks(_callback || callback) 20 | // real render function 21 | const innerRender = (componentFromPromise, containerFromPromise) => { 22 | const innerComponent = componentFromPromise || component 23 | let innerContainer = containerFromPromise || container 24 | 25 | if (innerContainer) { 26 | if (isString(innerContainer)) { 27 | innerContainer = document.querySelector(innerContainer) 28 | invariant(innerContainer, `container with selector "${container}" not exist`) 29 | } 30 | 31 | invariant(isHTMLElement(innerContainer), 'container should be HTMLElement') 32 | } 33 | 34 | const canRender = innerComponent && innerContainer 35 | if (canRender) { 36 | ReactDOM.render( 37 | React.createElement(Provider, { app }, innerComponent), 38 | innerContainer, 39 | ) 40 | 41 | if (afterRender) { 42 | afterRender(app) 43 | } 44 | } 45 | } 46 | 47 | let result = true 48 | if (beforeRender) { 49 | result = beforeRender(app) 50 | } 51 | 52 | if (result && result.then) { 53 | result.then((val) => { 54 | if (val) { 55 | if (Array.isArray(val)) { 56 | innerRender(...val) 57 | } else { 58 | innerRender(val, _container) 59 | } 60 | } else { 61 | innerRender(_component, _container) 62 | } 63 | }, () => { }) 64 | } else if (result !== false) { 65 | innerRender(_component, _container) 66 | } 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/createStore.js: -------------------------------------------------------------------------------- 1 | import invariant from 'invariant' 2 | import { createStore, applyMiddleware, compose } from 'redux' 3 | import { routerMiddleware } from 'connected-react-router' 4 | import { composeWithDevTools } from 'redux-devtools-extension' 5 | import createSagaMiddleware from 'redux-saga/lib/internal/middleware' 6 | import { flatten } from './utils' 7 | 8 | export default function ({ 9 | history, 10 | reducers, 11 | initialState, 12 | promiseMiddleware, 13 | extraMiddlewares, 14 | extraEnhancers, 15 | onStateChange, 16 | options: { 17 | setupMiddlewares = f => f, 18 | }, 19 | }) { 20 | invariant( 21 | Array.isArray(extraEnhancers), 22 | `createStore: extraEnhancers should be array, but got ${typeof extraEnhancers}`, 23 | ) 24 | 25 | const sagaMiddleware = createSagaMiddleware() 26 | const middlewares = setupMiddlewares([ 27 | sagaMiddleware, 28 | promiseMiddleware, 29 | ...flatten(extraMiddlewares, true), 30 | ]) 31 | 32 | if (history) { 33 | middlewares.push(routerMiddleware(history)) 34 | } 35 | 36 | const composeEnhancers = process.env.NODE_ENV !== 'production' 37 | ? composeWithDevTools() 38 | : compose 39 | 40 | const enhancers = [ 41 | applyMiddleware(...middlewares), 42 | ...extraEnhancers, 43 | ] 44 | 45 | const store = createStore(reducers, initialState, composeEnhancers(...enhancers)) 46 | 47 | // extend store 48 | store.runSaga = sagaMiddleware.run 49 | store.asyncReducers = {} 50 | 51 | // execute listeners when state is changed 52 | onStateChange.forEach((listener) => { 53 | store.subscribe(() => { 54 | listener(store.getState()) 55 | }) 56 | }) 57 | 58 | return store 59 | } 60 | -------------------------------------------------------------------------------- /src/getReducer.js: -------------------------------------------------------------------------------- 1 | import { handleActions } from 'redux-actions' 2 | import { getEnhancer } from './utils' 3 | 4 | 5 | const defaultReducerCreator = (state, handlers) => handleActions(handlers, state) 6 | 7 | function wrap(reducers) { 8 | return Object.keys(reducers).reduce((memo, type) => ({ 9 | ...memo, 10 | // call reducer with `state` and `payload` 11 | [type]: (state, action) => reducers[type](state, action.payload), 12 | }), {}) 13 | } 14 | 15 | export default function getReducer(globalReducerCreator, model) { 16 | const { state, reducers, enhancers, createReducer } = model 17 | const enhancer = getEnhancer(enhancers) 18 | const creator = createReducer || globalReducerCreator || defaultReducerCreator 19 | return enhancer(creator(state, wrap(reducers))) 20 | } 21 | -------------------------------------------------------------------------------- /src/getSaga.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable func-names */ 2 | import warning from 'warning' 3 | import invariant from 'invariant' 4 | import { 5 | takeEveryHelper as takeEvery, 6 | takeLatestHelper as takeLatest, 7 | throttleHelper as throttle, 8 | } from 'redux-saga/lib/internal/sagaHelpers' 9 | import { delay, CANCEL as CANCEL_DELAY } from 'redux-saga' 10 | import * as sagaEffects from 'redux-saga/effects' 11 | import { CANCEL_EFFECTS, MUTATE } from './constants' 12 | import { prefixType, unfixType, prefixAndValidate } from './utils' 13 | import { getModelActions } from './actions' 14 | 15 | 16 | function applyOnEffect(handlers, effect, actionType, metadata) { 17 | return handlers.reduce( 18 | (_effect, handler) => handler(_effect, sagaEffects, actionType, metadata), 19 | effect, 20 | ) 21 | } 22 | 23 | // signature: *effect(payload, {call, digest}, callbacks, innerActions, actions) { } 24 | // usage: const result = yield digest(generator, arg1, arg2, ...) 25 | function* callGenerator(fn, ...args) { 26 | const ret = yield fn(...args) 27 | if (ret.then) { 28 | return yield new Promise((resolve) => { 29 | ret.then(data => resolve(data)) 30 | }) 31 | } 32 | 33 | return ret 34 | } 35 | 36 | function getEffects(model) { 37 | const { put, take } = sagaEffects 38 | const { namespace } = model 39 | 40 | function assertAction(type, name) { 41 | invariant(type, `${name}: action should be a plain Object with type`) 42 | warning( 43 | type.indexOf(prefixType(namespace, '')) !== 0, 44 | `${name}: ${type} should not be prefixed with namespace "${namespace}"`, 45 | ) 46 | } 47 | 48 | function innerPut(action) { 49 | const { type } = action 50 | assertAction(type, 'innerPut') 51 | return put({ ...action, type: prefixAndValidate(type, model) }) 52 | } 53 | 54 | function innerTake(type) { 55 | if (typeof type === 'string') { 56 | assertAction(type, 'innerTake') 57 | return take(prefixAndValidate(type, model)) 58 | } 59 | return take(type) 60 | } 61 | 62 | function mutate(payload, debug) { 63 | const action = { 64 | payload, 65 | type: prefixType(namespace, model.actions[MUTATE]), 66 | } 67 | 68 | if (process.env.NODE_ENV !== 'production' && debug) { 69 | action.debug = debug 70 | } 71 | 72 | return put(action) 73 | } 74 | 75 | return { 76 | ...sagaEffects, 77 | delay, 78 | CANCEL_DELAY, 79 | innerPut, 80 | innerTake, 81 | [MUTATE]: mutate, 82 | digest: callGenerator, 83 | } 84 | } 85 | 86 | function getCallbacks(model, actionType) { 87 | const { put } = sagaEffects 88 | const { namespace, effects, callbacks } = model 89 | const callbackEffects = {} 90 | if (effects[actionType] && callbacks[actionType]) { 91 | callbacks[actionType].forEach((callback) => { 92 | callbackEffects[callback] = (payload) => { 93 | const actionName = unfixType(namespace, actionType) 94 | const prefixed = prefixType(actionName, callback) 95 | const fullType = prefixAndValidate(prefixed, model) 96 | return put({ payload, type: fullType }) 97 | } 98 | }) 99 | } 100 | 101 | return callbackEffects 102 | } 103 | 104 | function getWatcher({ onError, onEffect, app, model, type, effect }) { 105 | let effectFn = effect 106 | let effectType = 'takeEvery' 107 | let ms 108 | 109 | if (Array.isArray(effect)) { 110 | effectFn = effect[0] 111 | const options = effect[1] 112 | if (options && options.type) { 113 | effectType = options.type 114 | if (effectType === 'throttle') { 115 | invariant( 116 | options.ms, 117 | 'options.ms should be defined if type is throttle', 118 | ) 119 | ms = options.ms 120 | } 121 | } 122 | invariant( 123 | ['watcher', 'takeEvery', 'takeLatest', 'throttle'].includes(effectType), 124 | 'effect type should be takeEvery, takeLatest, throttle or watcher', 125 | ) 126 | } 127 | 128 | const actions = app.actions 129 | const effects = getEffects(model) 130 | const callbacks = getCallbacks(model, type) 131 | const innerActions = getModelActions(model, sagaEffects.put) 132 | 133 | function* sagaWithCatch(...args) { 134 | const { payload, resolver: { resolve, reject } } = args[0] 135 | try { 136 | const ret = yield effectFn( 137 | payload, 138 | { ...effects, resolve, reject }, // 支持在业务逻辑中调用 resolve 和 reject 139 | callbacks, 140 | innerActions, 141 | actions, 142 | ) 143 | // 默认调用 resolve,ret 是在 effect 函数的返回值(return ret) 144 | // 如果在业务逻辑中已经触发 resolve,那么此处的 resolve 将不会生效 145 | resolve(ret) 146 | } catch (err) { 147 | onError(err) 148 | reject(err) 149 | } 150 | } 151 | 152 | const sagaWithOnEffect = applyOnEffect(onEffect, sagaWithCatch, type, { 153 | app, 154 | model: model.namespace, 155 | actions, 156 | effects, 157 | }) 158 | 159 | switch (effectType) { 160 | case 'watcher': 161 | return sagaWithCatch 162 | case 'takeLatest': 163 | return function* () { 164 | yield takeLatest(type, sagaWithOnEffect) 165 | } 166 | case 'throttle': 167 | return function* () { 168 | yield throttle(ms, type, sagaWithOnEffect) 169 | } 170 | default: 171 | return function* () { 172 | yield takeEvery(type, sagaWithOnEffect) 173 | } 174 | } 175 | } 176 | 177 | function getTask(model, type, watcher) { 178 | const { namespace } = model 179 | return function* run() { 180 | const task = yield sagaEffects.fork(watcher) 181 | yield sagaEffects.fork(function* () { 182 | const { cancel } = yield sagaEffects.race({ 183 | cancel: sagaEffects.take(prefixType(type, CANCEL_EFFECTS)), 184 | eject: sagaEffects.take(prefixType(namespace, CANCEL_EFFECTS)), 185 | }) 186 | 187 | yield sagaEffects.cancel(task) 188 | 189 | if (cancel) { 190 | yield sagaEffects.fork(run) 191 | } 192 | }) 193 | } 194 | } 195 | 196 | export default function getSaga(onError, onEffect, app, model) { 197 | return function* () { 198 | const { effects } = model 199 | const keys = Object.keys(effects) 200 | for (let i = 0, l = keys.length; i < l; i += 1) { 201 | const type = keys[i] 202 | const watcher = getWatcher({ onError, onEffect, app, model, type, effect: effects[type] }) 203 | const task = getTask(model, type, watcher) 204 | yield sagaEffects.fork(task) 205 | } 206 | } 207 | } 208 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | // native exports 2 | export { connect } from 'react-redux' 3 | export { applyMiddleware, compose, combineReducers } from 'redux' 4 | export { 5 | Route, 6 | Router, 7 | MemoryRouter, 8 | StaticRouter, 9 | Switch, 10 | Prompt, 11 | Redirect, 12 | matchPath, 13 | withRouter, 14 | } from 'react-router' 15 | export { Link, NavLink, BrowserRouter, HashRouter } from 'react-router-dom' 16 | 17 | // mickey exports 18 | export * as utils from './utils' 19 | export injectActions from './injectActions' 20 | export ActionsProvider from './ActionsProvider' 21 | export default from './createApp' 22 | -------------------------------------------------------------------------------- /src/injectActions.js: -------------------------------------------------------------------------------- 1 | import invariant from 'invariant' 2 | import React from 'react' 3 | import PropTypes from 'prop-types' 4 | 5 | function getDisplayName(Component) { 6 | const name = (Component && (Component.displayName || Component.name)) || 'Component' 7 | return `injectActions(${name})` 8 | } 9 | 10 | export default function injectActions(WrappedComponent, options = {}) { 11 | const { 12 | propName = 'actions', 13 | withRef = false, 14 | } = options 15 | 16 | class InjectActions extends React.Component { 17 | constructor(props, context) { 18 | super(props, context) 19 | const { actions } = context 20 | if (process.env.NODE_ENV !== 'production') { 21 | invariant( 22 | actions, 23 | '[injectActions] Could not find required `actions` object. ' + 24 | ' needs to exist in the component ancestry.', 25 | ) 26 | } 27 | } 28 | 29 | getWrappedInstance() { 30 | if (process.env.NODE_ENV !== 'production') { 31 | invariant( 32 | withRef, 33 | 'To access the wrapped instance, you need to specify { withRef: true } in the options argument of the injectActions() call.', 34 | ) 35 | } 36 | return this.wrappedInstance 37 | } 38 | 39 | render() { 40 | const props = { 41 | ...this.props, 42 | [propName]: this.context.actions, 43 | } 44 | 45 | if (withRef) { 46 | props.ref = (wrappedInstance) => { this.wrappedInstance = wrappedInstance } 47 | } 48 | 49 | return React.createElement(WrappedComponent, props) 50 | } 51 | } 52 | 53 | InjectActions.WrappedComponent = WrappedComponent 54 | InjectActions.displayName = getDisplayName(WrappedComponent) 55 | InjectActions.contextTypes = { 56 | actions: PropTypes.object.isRequired, 57 | } 58 | 59 | return InjectActions 60 | } 61 | -------------------------------------------------------------------------------- /src/internalModel.js: -------------------------------------------------------------------------------- 1 | // internal model to update global state when do unmodel 2 | export default { 3 | namespace: '@@MICKEY', 4 | state: 0, 5 | reducers: { 6 | UPDATE(state) { return state + 1 }, 7 | }, 8 | } 9 | -------------------------------------------------------------------------------- /src/prefixDispatch.js: -------------------------------------------------------------------------------- 1 | import warning from 'warning' 2 | import invariant from 'invariant' 3 | import { prefixAndValidate, prefixType } from './utils' 4 | 5 | export default function prefixDispatch(dispatch, model) { 6 | return (action) => { 7 | const { type } = action 8 | 9 | if (process.env.NODE_ENV !== 'production') { 10 | const { namespace } = model 11 | invariant(type, 'innerDispatch: action should be a plain Object with type') 12 | warning( 13 | type.indexOf(prefixType(namespace, '')) !== 0, 14 | `innerDispatch: ${type} should not be prefixed with namespace ${namespace}`, 15 | ) 16 | } 17 | 18 | return dispatch({ ...action, type: prefixAndValidate(type, model) }) 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/registerModel.js: -------------------------------------------------------------------------------- 1 | import checkModel from './checkModel' 2 | import createModel from './createModel' 3 | import { addActions } from './actions' 4 | 5 | export default function registerModel(app, raw) { 6 | const model = createModel(raw) 7 | if (process.env.NODE_ENV !== 'production') { 8 | checkModel(model, app.models) 9 | } 10 | app.models.push(model) 11 | addActions(app, model) 12 | return model 13 | } 14 | -------------------------------------------------------------------------------- /src/utils.js: -------------------------------------------------------------------------------- 1 | import isFunction from 'lodash.isfunction' 2 | import { NAMESPACE_SEP } from './constants' 3 | 4 | export asign from 'object-assign' 5 | export getByPath from 'lodash.get' 6 | export flatten from 'lodash.flatten' 7 | export minimatch from 'minimatch' 8 | export isPlainObject from 'is-plain-object' 9 | export isFunction from 'lodash.isfunction' 10 | 11 | // Test generator function that compilied with babel(babel-profill) 12 | export const isGeneratorFn = fn => ( 13 | typeof fn === 'function' && 14 | fn.constructor && 15 | ( 16 | fn.constructor.name === 'GeneratorFunction' || 17 | fn.constructor.displayName === 'GeneratorFunction' 18 | ) 19 | ) 20 | 21 | export const isArray = Array.isArray.bind(Array) 22 | export const isString = str => typeof str === 'string' 23 | export const ucfirst = s => s.charAt(0).toUpperCase() + s.substr(1) 24 | export const filename = file => file.match(/([^/]+)\.(js|ts)$/)[1] 25 | export const isHTMLElement = node => !!(node && typeof node === 'object' && node.nodeType && node.nodeName) 26 | 27 | export const unfixType = (namespace, type) => (type.replace(`${namespace}${NAMESPACE_SEP}`, '')) 28 | export const prefixType = (namespace, type) => (`${namespace}${NAMESPACE_SEP}${type}`) 29 | 30 | export const prefixObject = (namespace, obj) => Object.keys(obj).reduce((memo, type) => { 31 | memo[prefixType(namespace, type)] = obj[type] 32 | return memo 33 | }, {}) 34 | 35 | export const prefixAndValidate = (type, model) => { 36 | const { effects, reducers, namespace } = model 37 | 38 | const prefixed = prefixType(namespace, type) 39 | const final = prefixed.replace(/\/@@[^/]+?$/, '') 40 | if (reducers[final] || effects[final]) { 41 | return final 42 | } 43 | return type 44 | } 45 | 46 | export const getEnhancer = (enhancers) => { 47 | if (enhancers) { 48 | if (isArray(enhancers)) { 49 | return reducer => enhancers.reduce((memo, reducerEnhancer) => reducerEnhancer(memo), reducer) 50 | } else if (isFunction(enhancers)) { 51 | return reducer => enhancers(reducer) 52 | } 53 | } 54 | 55 | return f => f 56 | } 57 | 58 | export const getNamespaceFromPath = (path) => { 59 | const parts = path.split('/') 60 | const file = parts.pop() 61 | 62 | if (path[0] === '.' || path[0] === '/') { 63 | parts.shift() 64 | } 65 | 66 | parts.push(filename(file)) 67 | 68 | return parts.join('.') 69 | } 70 | 71 | export const fixNamespace = namespace => namespace.replace(/\//g, '_').replace(/\./g, '/') 72 | -------------------------------------------------------------------------------- /src/watcher.js: -------------------------------------------------------------------------------- 1 | import warning from 'warning' 2 | import { isFunction, getByPath } from './utils' 3 | import { getModelActions } from './actions' 4 | import prefixDispatch from './prefixDispatch' 5 | 6 | function getState(app, path, defaultValue) { 7 | const state = app.store.getState() 8 | return path ? getByPath(state, path, defaultValue) : state 9 | } 10 | 11 | export function startWatchers(model, app, onError) { 12 | const history = app.history 13 | const dispatch = app.store.dispatch 14 | const innerActions = getModelActions(model, dispatch) 15 | 16 | const funcs = [] 17 | const nonFuncs = [] 18 | 19 | if (model.watchers) { 20 | model.watchers.forEach((watcher, index) => { 21 | const unlistener = watcher({ 22 | history, 23 | getState: (path, defaultValue) => getState(app, path, defaultValue), 24 | // never need to call the following methods, just export for debug 25 | dispatch, 26 | innerDispatch: prefixDispatch(dispatch, model), 27 | }, innerActions, app.actions, onError) 28 | 29 | if (isFunction(unlistener)) { 30 | funcs.push(unlistener) 31 | } else { 32 | nonFuncs.push([model.namespace, index]) 33 | } 34 | }) 35 | } 36 | 37 | return { funcs, nonFuncs } 38 | } 39 | 40 | export function stopWatchers(unlisteners, namespace) { 41 | if (!unlisteners[namespace]) return 42 | 43 | const { funcs, nonFuncs } = unlisteners[namespace] 44 | warning( 45 | nonFuncs.length === 0, 46 | `watcher should return unlistener function, check watchers in these models: ${nonFuncs.map(item => item[0]).join(', ')}`, 47 | ) 48 | 49 | funcs.forEach(unlistener => unlistener()) 50 | delete unlisteners[namespace] 51 | } 52 | -------------------------------------------------------------------------------- /test/index.js: -------------------------------------------------------------------------------- 1 | import './spec/utils.spec' 2 | import './spec/watcher.spec' 3 | import './spec/createHistory.spec' 4 | // import './spec/steupHistoryHooks.spec' 5 | import './spec/Plugin.spec' 6 | import './spec/ActionsProvider.spec' 7 | import './spec/createModel.spec' 8 | import './spec/checkModel.spec' 9 | import './spec/createErrorHandler.spec' 10 | import './spec/injectActions.spec' 11 | import './spec/getSaga.spec' 12 | import './spec/createApp.spec' 13 | -------------------------------------------------------------------------------- /test/setup.js: -------------------------------------------------------------------------------- 1 | import { JSDOM } from 'jsdom' 2 | 3 | const { window } = new JSDOM('
', { url: 'http://localhost' }) 4 | 5 | const copyProps = (source, target) => { 6 | Object.getOwnPropertyNames(source) 7 | .filter(prop => typeof target[prop] === 'undefined') 8 | .forEach((prop) => { target[prop] = source[prop] }) 9 | } 10 | 11 | global.window = window 12 | global.document = window.document 13 | global.navigator = { userAgent: 'node.js' } 14 | 15 | copyProps(window, global) 16 | 17 | -------------------------------------------------------------------------------- /test/spec/ActionsProvider.spec.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable react/no-multi-comp */ 2 | /* eslint-disable react/jsx-filename-extension */ 3 | /* eslint-disable react/prefer-stateless-function */ 4 | 5 | import { expect } from 'chai' 6 | import sinon from 'sinon' 7 | import React from 'react' 8 | import PropTypes from 'prop-types' 9 | import TestUtils from 'react-dom/test-utils' 10 | import ActionsProvider, { createProvider } from '../../src/ActionsProvider' 11 | 12 | describe('ActionsProvider', () => { 13 | const createChild = (actionsKey = 'actions') => { 14 | class Child extends React.Component { 15 | render() { 16 | return
17 | } 18 | } 19 | 20 | Child.contextTypes = { 21 | [actionsKey]: PropTypes.object.isRequired, 22 | } 23 | 24 | return Child 25 | } 26 | 27 | const Child = createChild() 28 | 29 | it('should enforce a single child', () => { 30 | const actions = {} 31 | 32 | // Ignore propTypes warnings 33 | const propTypes = ActionsProvider.propTypes 34 | ActionsProvider.propTypes = {} 35 | 36 | try { 37 | expect( 38 | () => TestUtils.renderIntoDocument( 39 | 40 |
41 | , 42 | ), 43 | ).to.not.throw() 44 | 45 | expect( 46 | () => TestUtils.renderIntoDocument( 47 | , 48 | ), 49 | ).to.throw(/a single React element child/) 50 | 51 | expect( 52 | () => TestUtils.renderIntoDocument( 53 | 54 |
55 |
56 | , 57 | ), 58 | ).to.throw(/a single React element child/) 59 | } finally { 60 | ActionsProvider.propTypes = propTypes 61 | } 62 | }) 63 | 64 | it('should add the actions to the child context', () => { 65 | const actions = { foo: 1 } 66 | const spy = sinon.spy(console, 'error') 67 | const tree = TestUtils.renderIntoDocument( 68 | 69 | 70 | , 71 | ) 72 | spy.restore() 73 | expect(spy.callCount).to.be.equal(0) 74 | 75 | const child = TestUtils.findRenderedComponentWithType(tree, Child) 76 | expect(child.context.actions).to.be.eql(actions) 77 | }) 78 | 79 | it('should add the actions to the child context using a custom actions key', () => { 80 | const actions = { foo: 1 } 81 | const CustomProvider = createProvider('customActionsKey') 82 | const CustomChild = createChild('customActionsKey') 83 | 84 | const spy = sinon.spy(console, 'error') 85 | const tree = TestUtils.renderIntoDocument( 86 | 87 | 88 | , 89 | ) 90 | spy.restore() 91 | expect(spy.callCount).to.be.equal(0) 92 | 93 | const child = TestUtils.findRenderedComponentWithType(tree, CustomChild) 94 | expect(child.context.customActionsKey).to.be.eql(actions) 95 | }) 96 | }) 97 | -------------------------------------------------------------------------------- /test/spec/Plugin.spec.js: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai' 2 | import Plugin from '../../src/Plugin' 3 | 4 | describe('Plugin', () => { 5 | it('should return a plugin instance', () => { 6 | const plugin = new Plugin() 7 | expect(plugin).to.be.an.instanceof(Plugin) 8 | }) 9 | 10 | it('should have empty plugins on the property `hooks` after initialize', () => { 11 | const plugin = new Plugin() 12 | Object.keys(plugin.hooks).forEach((key) => { 13 | const plugins = plugin.hooks[key] 14 | expect(plugins).to.be.eql([]) 15 | }) 16 | }) 17 | 18 | describe('plugin.use(hooks)', () => { 19 | it('should throw an error if `hooks` is not a plain-object', () => { 20 | const plugin = new Plugin() 21 | const badFn = () => { plugin.use(1) } 22 | expect(badFn).to.throw(/should be plain object/) 23 | }) 24 | 25 | it('should throw an error if given an unknown hook name', () => { 26 | const plugin = new Plugin() 27 | const badFn = () => { plugin.use({ foo: 1 }) } 28 | expect(badFn).to.throw(/unknown hook property/) 29 | }) 30 | 31 | it('should replace `extraEnhancers` when apply a new one', () => { 32 | const plugin = new Plugin() 33 | plugin.use({ extraEnhancers: 1 }) 34 | plugin.use({ extraEnhancers: 2 }) 35 | expect(plugin.hooks.extraEnhancers).to.be.eql(2) 36 | }) 37 | 38 | it('should append to plugins set except `extraEnhancers`', () => { 39 | const hooks = Plugin.hooks 40 | const plugin = new Plugin() 41 | hooks.forEach((name) => { 42 | plugin.use({ [name]: 1 }) 43 | }) 44 | hooks.forEach((name) => { 45 | plugin.use({ [name]: [2, 3] }) 46 | }) 47 | hooks.forEach((name) => { 48 | plugin.use({ [name]: 4 }) 49 | }) 50 | 51 | hooks.forEach((name) => { 52 | if (name === 'extraEnhancers') { 53 | expect(plugin.hooks[name]).to.be.eql(4) 54 | } else { 55 | expect(plugin.hooks[name]).to.be.eql([1, 2, 3, 4]) 56 | } 57 | }) 58 | }) 59 | }) 60 | 61 | describe('plugin.get(name)', () => { 62 | it('should throw error when trying to get hooks with illegal name', () => { 63 | const plugin = new Plugin() 64 | const badFn = () => { plugin.get('foo') } 65 | expect(badFn).to.be.throw(/cannot be got/) 66 | }) 67 | 68 | it('should return an reducer enhance function when get `onReducer`', () => { 69 | const plugin1 = new Plugin() 70 | const enhancer1 = plugin1.get('onReducer') 71 | 72 | expect(enhancer1(1)).to.be.equal(1) 73 | 74 | const plugin2 = new Plugin() 75 | plugin2.use({ 76 | onReducer: [ 77 | state => (state + 1), 78 | state => (state + 2), 79 | ], 80 | }) 81 | const enhancer2 = plugin2.get('onReducer') 82 | expect(enhancer2(1)).to.be.equal(4) 83 | }) 84 | 85 | it('should return a merge reducer when get `extraReducers`', () => { 86 | const plugin1 = new Plugin() 87 | expect(plugin1.get('extraReducers')).to.be.eql({}) 88 | 89 | const plugin2 = new Plugin() 90 | plugin2.use({ 91 | extraReducers: [ 92 | { foo: 'bar' }, 93 | { baz: 1 }, 94 | ], 95 | }) 96 | expect(plugin2.get('extraReducers')).to.be.eql({ foo: 'bar', baz: 1 }) 97 | }) 98 | 99 | it('should return an array called with name except `onReducer` and `extraReducers`', () => { 100 | const hooks = Plugin.hooks 101 | const plugin = new Plugin() 102 | hooks.forEach((name) => { 103 | plugin.use({ [name]: 1 }) 104 | }) 105 | hooks.forEach((name) => { 106 | plugin.use({ [name]: [2, 3] }) 107 | }) 108 | hooks.forEach((name) => { 109 | plugin.use({ [name]: 4 }) 110 | }) 111 | 112 | hooks.forEach((name) => { 113 | if (name !== 'extraEnhancers' && name !== 'onReducer') { 114 | expect(plugin.hooks[name]).to.be.eql([1, 2, 3, 4]) 115 | } 116 | }) 117 | }) 118 | }) 119 | 120 | describe('plugin.apply(name, defaultHandler)', () => { 121 | it('should throw error when trying to apply hooks except `onError`', () => { 122 | const plugin = new Plugin() 123 | const badFn1 = () => { plugin.apply('foo') } 124 | const badFn2 = () => { plugin.apply('onAction') } 125 | 126 | expect(badFn1).to.be.throw(/cannot be applied/) 127 | expect(badFn2).to.be.throw(/cannot be applied/) 128 | }) 129 | 130 | it('should return a handler function', () => { 131 | const plugin = new Plugin() 132 | const handler = plugin.apply('onError') 133 | expect(handler).to.be.an.instanceof(Function) 134 | }) 135 | 136 | it('should work with the specified handlers', () => { 137 | let errorMessage = '' 138 | 139 | function onError(err) { 140 | errorMessage = err.message 141 | } 142 | 143 | const plugin = new Plugin() 144 | plugin.use({ 145 | onError, 146 | }) 147 | plugin.apply('onError')({ message: 'Hello Mickey' }) 148 | expect(errorMessage).to.eql('Hello Mickey') 149 | }) 150 | 151 | it('should call default handler if the handlers is empty', () => { 152 | let errorMessage = '' 153 | 154 | function onError(err) { 155 | errorMessage = err.message 156 | } 157 | 158 | const plugin = new Plugin() 159 | plugin.apply('onError', onError)({ message: 'hello mickey' }) 160 | expect(errorMessage).to.eql('hello mickey') 161 | }) 162 | }) 163 | }) 164 | -------------------------------------------------------------------------------- /test/spec/checkModel.spec.js: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai' 2 | import checkModel from '../../src/checkModel' 3 | 4 | describe('checkModel', () => { 5 | it('should throw an error when `namespace` not be specified', () => { 6 | expect(() => { 7 | checkModel({}) 8 | }).to.throw(/namespace should be specified/) 9 | }) 10 | 11 | it('should throw an error when `namespace` not be a string', () => { 12 | expect(() => { 13 | checkModel({ namespace: 1 }) 14 | }).to.throw(/namespace should be string/) 15 | }) 16 | 17 | it('should throw an error when `namespace` not unique', () => { 18 | expect(() => { 19 | checkModel({ 20 | namespace: '-', 21 | }, [{ 22 | namespace: '-', 23 | 24 | }]) 25 | }).to.throw(/namespace should be unique/) 26 | }) 27 | }) 28 | -------------------------------------------------------------------------------- /test/spec/createErrorHandler.spec.js: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai' 2 | import createApp from '../../src/createApp' 3 | import createErrorHandler from '../../src/createErrorHandler' 4 | 5 | describe('createErrorHandler', () => { 6 | it('should return a function', () => { 7 | const app = createApp() 8 | expect(createErrorHandler(app)).to.be.an.instanceof(Function) 9 | }) 10 | 11 | it('should use the defaule error handler when no handler specified', () => { 12 | const app = createApp() 13 | const handler = createErrorHandler(app) 14 | expect(handler).to.not.throw() 15 | expect(() => { handler('foo') }).to.throw('foo') 16 | }) 17 | 18 | it('should call the specified handlers with `error` and `app`', () => { 19 | let instance 20 | let error 21 | const app = createApp({ 22 | hooks: { 23 | onError: [ 24 | (err, appInstance) => { instance = appInstance }, 25 | (err) => { error = err }, 26 | ], 27 | }, 28 | }) 29 | const handler = createErrorHandler(app) 30 | handler(new Error('foo')) 31 | 32 | expect(instance).to.be.eql(app) 33 | expect(error).to.be.an.instanceOf(Error) 34 | }) 35 | }) 36 | -------------------------------------------------------------------------------- /test/spec/createHistory.spec.js: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai' 2 | import createHistory from '../../src/createHistory' 3 | 4 | const routerActions = ['push', 'replace', 'go', 'goBack', 'goForward'] 5 | 6 | 7 | describe('createHistory', () => { 8 | it('should throw an error when specified an unknown `historyMode`', () => { 9 | expect(() => { createHistory('foo') }).to.throw(/historyMode "foo" is invalid/) 10 | 11 | const env = process.env.NODE_ENV 12 | process.env.NODE_ENV = 'production' 13 | expect(() => { createHistory('foo') }).to.not.throw() 14 | process.env.NODE_ENV = env 15 | }) 16 | 17 | it('should return null when no `historyMode` specified', () => { 18 | expect(createHistory()).to.be.null // eslint-disable-line 19 | expect(createHistory(0)).to.be.null // eslint-disable-line 20 | expect(createHistory(false)).to.be.null // eslint-disable-line 21 | }) 22 | 23 | it('should creat a hashHistory when call with \'hash\'', () => { 24 | const history = createHistory('hash') 25 | routerActions.forEach((key) => { 26 | expect(history).to.have.property(key) 27 | }) 28 | }) 29 | 30 | it('should creat a browserHistory when call with \'browser\'', () => { 31 | const history = createHistory('browser') 32 | routerActions.forEach((key) => { 33 | expect(history).to.have.property(key) 34 | }) 35 | }) 36 | 37 | it('should creat a memoryHistory when call with \'memory\'', () => { 38 | const history = createHistory('memory') 39 | routerActions.forEach((key) => { 40 | expect(history).to.have.property(key) 41 | }) 42 | }) 43 | }) 44 | -------------------------------------------------------------------------------- /test/spec/createModel.spec.js: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai' 2 | import createModel from '../../src/createModel' 3 | import baseModel from '../../src/baseModel' 4 | 5 | describe('createModel', () => { 6 | it('should fix namespace', () => { 7 | expect(createModel({ namespace: 'a/b.c' }).namespace).to.be.equal('a_b/c') 8 | }) 9 | 10 | it('should return the same `state`, `enhancers` and `createReducer`', () => { 11 | const model = { 12 | namespace: 'foo.bar', 13 | state: 1, 14 | enhancers: [2], 15 | createReducer: f => f, 16 | } 17 | const ret = createModel(model) 18 | 19 | expect(ret.state).to.be.eql(model.state) 20 | expect(ret.enhancers).to.be.eql(model.enhancers) 21 | expect(ret.createReducer).to.be.eql(model.createReducer) 22 | }) 23 | 24 | it('should parse group', () => { 25 | const model = { 26 | namespace: 'foo.bar', 27 | state: 1, 28 | query: { 29 | * effect(payload, effects, callbacks) { yield callbacks.succeed() }, 30 | prepare: state => state, 31 | succeed: state => state, 32 | failed: state => state, 33 | }, 34 | } 35 | const ret = createModel(model) 36 | 37 | expect(ret.actions).to.own.include({ 38 | query: 'query', 39 | querySucceed: 'query/succeed', 40 | queryFailed: 'query/failed', 41 | }) 42 | 43 | expect(ret.reducers).to.own.include({ 44 | 'foo/bar/query': model.query.prepare, 45 | 'foo/bar/query/succeed': model.query.succeed, 46 | 'foo/bar/query/failed': model.query.failed, 47 | }) 48 | 49 | expect(ret.effects).to.be.eql({ 50 | 'foo/bar/query': model.query.effect, 51 | }) 52 | 53 | expect(ret.callbacks).to.be.eql({ 54 | 'foo/bar/query': ['succeed', 'failed'], 55 | }) 56 | }) 57 | 58 | it('should merge groups', () => { 59 | const model = { 60 | namespace: 'foo.bar', 61 | state: 1, 62 | reducers: { baz1: state => state }, 63 | effects: { * baz2(payload, effects, callbacks) { yield callbacks.succeed() } }, 64 | query: { 65 | * effect(payload, effects, callbacks) { yield callbacks.succeed() }, 66 | prepare: state => state, 67 | succeed: state => state, 68 | failed: state => state, 69 | }, 70 | del: { 71 | * effect(payload, effects, callbacks) { yield callbacks.succeed() }, 72 | prepare: state => state, 73 | succeed: state => state, 74 | failed: state => state, 75 | }, 76 | } 77 | const ret = createModel(model) 78 | 79 | expect(ret.actions).to.own.include({ 80 | baz1: 'baz1', 81 | baz2: 'baz2', 82 | query: 'query', 83 | querySucceed: 'query/succeed', 84 | queryFailed: 'query/failed', 85 | del: 'del', 86 | delSucceed: 'del/succeed', 87 | delFailed: 'del/failed', 88 | }) 89 | 90 | expect(ret.reducers).to.own.include({ 91 | 'foo/bar/baz1': model.reducers.baz1, 92 | 'foo/bar/query': model.query.prepare, 93 | 'foo/bar/query/succeed': model.query.succeed, 94 | 'foo/bar/query/failed': model.query.failed, 95 | 'foo/bar/del': model.del.prepare, 96 | 'foo/bar/del/succeed': model.del.succeed, 97 | 'foo/bar/del/failed': model.del.failed, 98 | }) 99 | 100 | expect(ret.effects).to.be.eql({ 101 | 'foo/bar/baz2': model.effects.baz2, 102 | 'foo/bar/query': model.query.effect, 103 | 'foo/bar/del': model.del.effect, 104 | }) 105 | 106 | expect(ret.callbacks).to.be.eql({ 107 | 'foo/bar/query': ['succeed', 'failed'], 108 | 'foo/bar/del': ['succeed', 'failed'], 109 | }) 110 | }) 111 | 112 | it('should treat `[Generator, options]` as effect', () => { 113 | const model = { 114 | namespace: 'foo.bar', 115 | state: 1, 116 | query: { 117 | effect: [function* effect(payload, effects, callbacks) { yield callbacks.succeed() }, {}], 118 | prepare: state => state, 119 | succeed: state => state, 120 | failed: state => state, 121 | }, 122 | } 123 | const ret = createModel(model) 124 | expect(ret.actions).to.own.include({ 125 | query: 'query', 126 | querySucceed: 'query/succeed', 127 | queryFailed: 'query/failed', 128 | }) 129 | 130 | expect(ret.reducers).to.own.include({ 131 | 'foo/bar/query': model.query.prepare, 132 | 'foo/bar/query/succeed': model.query.succeed, 133 | 'foo/bar/query/failed': model.query.failed, 134 | }) 135 | 136 | expect(ret.effects).to.be.eql({ 137 | 'foo/bar/query': model.query.effect, 138 | }) 139 | 140 | expect(ret.callbacks).to.be.eql({ 141 | 'foo/bar/query': ['succeed', 'failed'], 142 | }) 143 | }) 144 | 145 | it('shoule parse unGrouped reducers and effects', () => { 146 | const model = { 147 | namespace: 'foo.bar', 148 | state: 1, 149 | reducer1: state => state, 150 | reducer2: 1, 151 | * effect1(payload, effects, callbacks) { yield callbacks.succeed() }, 152 | effect2: [function* effect(payload, effects, callbacks) { yield callbacks.succeed() }, {}], 153 | effect3: 1, 154 | } 155 | const ret = createModel(model) 156 | 157 | expect(ret.actions).to.own.include({ 158 | reducer1: 'reducer1', 159 | effect1: 'effect1', 160 | effect2: 'effect2', 161 | }) 162 | 163 | expect(ret.reducers).to.own.include({ 164 | 'foo/bar/reducer1': model.reducer1, 165 | }) 166 | 167 | expect(ret.effects).to.be.eql({ 168 | 'foo/bar/effect1': model.effect1, 169 | 'foo/bar/effect2': model.effect2, 170 | }) 171 | 172 | expect(ret.callbacks).to.be.eql({}) 173 | }) 174 | 175 | it('should ignore invalid actions', () => { 176 | const model = { 177 | namespace: 'foo.bar', 178 | state: 1, 179 | reducers: { 180 | reducer1: state => state, 181 | reducer2: 1, 182 | }, 183 | effects: { 184 | effect1: function* effect1(payload, effects) { yield effects.call() }, 185 | effect2: [function* effect2(payload, effects) { yield effects.call() }, {}], 186 | effect3: 1, 187 | }, 188 | query: { 189 | * effect(payload, effects, callbacks) { yield callbacks.succeed() }, 190 | prepare: state => state, 191 | succeed: state => state, 192 | failed: 1, 193 | }, 194 | } 195 | const ret = createModel(model) 196 | 197 | expect(ret.actions).to.own.include({ 198 | reducer1: 'reducer1', 199 | effect1: 'effect1', 200 | effect2: 'effect2', 201 | query: 'query', 202 | querySucceed: 'query/succeed', 203 | }) 204 | 205 | expect(ret.reducers).to.own.include({ 206 | 'foo/bar/reducer1': model.reducers.reducer1, 207 | 'foo/bar/query': model.query.prepare, 208 | 'foo/bar/query/succeed': model.query.succeed, 209 | }) 210 | 211 | expect(ret.effects).to.be.eql({ 212 | 'foo/bar/effect1': model.effects.effect1, 213 | 'foo/bar/effect2': model.effects.effect2, 214 | 'foo/bar/query': model.query.effect, 215 | }) 216 | 217 | expect(ret.callbacks).to.be.eql({ 218 | 'foo/bar/query': ['succeed'], 219 | }) 220 | }) 221 | 222 | it('should throw an error if there are more than one effects in group', () => { 223 | const badFn = () => { 224 | createModel({ 225 | namespace: 'foo.bar', 226 | state: 1, 227 | query: { 228 | * effect1(payload, effects, callbacks) { yield callbacks.succeed() }, 229 | * effect2(payload, effects, callbacks) { yield callbacks.succeed() }, 230 | prepare: state => state, 231 | succeed: state => state, 232 | failed: state => state, 233 | }, 234 | }) 235 | } 236 | 237 | expect(badFn).to.throw(/Less than one effect function should be specified in model/) 238 | 239 | const env = process.env.NODE_ENV 240 | process.env.NODE_ENV = 'production' 241 | expect(badFn).to.not.throw() 242 | process.env.NODE_ENV = env 243 | }) 244 | 245 | it('should extend model with base `mutate` reducer', () => { 246 | const model = { 247 | namespace: 'foo.bar', 248 | state: {}, 249 | } 250 | const ret = createModel(model) 251 | expect(ret.actions).to.be.eql({ 252 | mutate: 'mutate', 253 | }) 254 | 255 | expect(ret.reducers).to.be.eql({ 256 | 'foo/bar/mutate': baseModel.mutate, 257 | }) 258 | 259 | expect(ret.effects).to.be.eql({}) 260 | 261 | expect(ret.callbacks).to.be.eql({}) 262 | }) 263 | 264 | it('should throw an error if use the revesed action name `mutate`', () => { 265 | const badFn = () => { 266 | createModel({ 267 | namespace: 'foo.bar', 268 | state: 1, 269 | mutate: state => state, 270 | }) 271 | } 272 | 273 | expect(badFn).to.throw(/The `mutate` is a reserved action for mutate the state. You should change `mutate` to other action names/) 274 | 275 | const env = process.env.NODE_ENV 276 | process.env.NODE_ENV = 'production' 277 | expect(badFn).to.not.throw() 278 | process.env.NODE_ENV = env 279 | }) 280 | }) 281 | -------------------------------------------------------------------------------- /test/spec/getSaga.spec.js: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai' 2 | import sinon from 'sinon' 3 | import createApp from '../../src/createApp' 4 | 5 | describe('getSata', () => { 6 | it('should inject actions to async reducers', () => { 7 | const app = createApp() 8 | app.model({ 9 | namespace: 'foo', 10 | state: 0, 11 | add: state => state + 1, 12 | }) 13 | app.model({ 14 | namespace: 'counter', 15 | state: { 16 | count: 0, 17 | loading: false, 18 | }, 19 | increment: state => ({ ...state, count: state.count + 1 }), 20 | decrement: state => ({ ...state, count: state.count - 1 }), 21 | incrementAsync: { 22 | * effect(payload, effects, { succeed }, innerActions, actions) { 23 | yield succeed() 24 | yield innerActions.increment() 25 | yield actions.foo.add() 26 | }, 27 | prepare: state => ({ ...state, loading: true }), 28 | succeed: state => ({ ...state, loading: false }), 29 | }, 30 | }) 31 | app.render() 32 | 33 | app.actions.counter.increment() 34 | app.actions.counter.incrementAsync() 35 | 36 | const state = app.store.getState() 37 | expect(state.foo).to.be.equal(1) 38 | expect(state.counter.count).to.be.equal(2) 39 | expect(state.counter.loading).to.be.equal(false) 40 | }) 41 | 42 | it('should inject actions to watcher', () => { 43 | const app = createApp() 44 | app.model({ 45 | namespace: 'foo', 46 | state: 0, 47 | add: state => state + 1, 48 | }) 49 | app.model({ 50 | namespace: 'counter', 51 | state: { 52 | count: 0, 53 | loading: false, 54 | }, 55 | increment: state => ({ ...state, count: state.count + 1 }), 56 | decrement: state => ({ ...state, count: state.count - 1 }), 57 | incrementAsync: { 58 | * effect(payload, { innerPut }, { succeed }, innerActions, actions) { 59 | yield succeed() 60 | yield innerPut({ type: 'increment' }) 61 | yield innerActions.increment() 62 | yield actions.foo.add() 63 | }, 64 | prepare: state => ({ ...state, loading: true }), 65 | succeed: state => ({ ...state, loading: false }), 66 | }, 67 | watcher(helpers, innerActions, actions) { 68 | helpers.dispatch({ type: 'foo/add' }) 69 | helpers.innerDispatch({ type: 'increment' }) 70 | innerActions.incrementAsync() 71 | actions.foo.add() 72 | }, 73 | }) 74 | app.render() 75 | 76 | const state = app.store.getState() 77 | expect(state.foo).to.be.equal(3) 78 | expect(state.counter.count).to.be.equal(3) 79 | expect(state.counter.loading).to.be.equal(false) 80 | }) 81 | 82 | it('should throw an error if effect type is `throttle` but no throttle duration specified', () => { 83 | const app = createApp() 84 | app.model({ 85 | namespace: 'foo', 86 | state: 0, 87 | incrementAsync: { 88 | effect: [ 89 | function* effect(payload, { delay }) { 90 | yield delay(10) 91 | }, 92 | { type: 'throttle' }, 93 | ], 94 | }, 95 | }) 96 | 97 | const logStub = sinon.stub(console, 'error') 98 | app.render() 99 | expect(logStub.callCount).to.be.eql(1) 100 | expect(logStub.firstCall.args[1]).to.be.match(/options\.ms should be defined if type is throttle/) 101 | logStub.restore() 102 | }) 103 | 104 | it('should inject `innerTake` to async reducers', () => { 105 | const app = createApp() 106 | app.model({ 107 | namespace: 'counter', 108 | state: { 109 | count: 0, 110 | loading: false, 111 | }, 112 | increment: state => ({ ...state, count: state.count + 1 }), 113 | decrement: state => ({ ...state, count: state.count - 1 }), 114 | incrementAsync: { 115 | * effect(payload, { innerPut, innerTake }, { succeed }) { 116 | innerTake('increment') 117 | yield succeed() 118 | yield innerPut({ type: 'increment' }) 119 | }, 120 | prepare: state => ({ ...state, loading: true }), 121 | succeed: state => ({ ...state, loading: false }), 122 | }, 123 | }) 124 | app.render() 125 | 126 | app.actions.counter.incrementAsync() 127 | app.actions.counter.increment() 128 | 129 | const state = app.store.getState() 130 | expect(state.counter.count).to.be.equal(2) 131 | expect(state.counter.loading).to.be.equal(false) 132 | }) 133 | 134 | it('should extend model with base `mutate` reducer', () => { 135 | const app = createApp() 136 | app.model({ 137 | namespace: 'foo', 138 | state: { count: 0 }, 139 | * add(payload, { mutate }) { 140 | yield mutate({ count: 1 }); 141 | }, 142 | }) 143 | 144 | app.render() 145 | 146 | app.actions.foo.add() 147 | const state = app.store.getState() 148 | expect(state.foo).to.be.eql({ count: 1 }) 149 | }) 150 | }) 151 | -------------------------------------------------------------------------------- /test/spec/injectActions.spec.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable react/no-multi-comp */ 2 | 3 | import { expect } from 'chai' 4 | import React from 'react' 5 | import ReactDOM from 'react-dom' 6 | import PropTypes from 'prop-types' 7 | import TestUtils from 'react-dom/test-utils' 8 | import injectActions from '../../src/injectActions' 9 | 10 | describe('injectActions', () => { 11 | class Passthrough extends React.Component { // eslint-disable-line 12 | render() { 13 | return
// eslint-disable-line 14 | } 15 | } 16 | 17 | class ProviderMock extends React.Component { 18 | getChildContext() { 19 | return { actions: this.props.actions } 20 | } 21 | 22 | render() { 23 | return React.Children.only(this.props.children) 24 | } 25 | } 26 | 27 | ProviderMock.childContextTypes = { 28 | actions: PropTypes.object.isRequired, 29 | } 30 | 31 | ProviderMock.propTypes = { 32 | actions: PropTypes.object.isRequired, // eslint-disable-line 33 | children: PropTypes.element.isRequired, 34 | } 35 | 36 | it('should receive the store in the context', () => { 37 | const actions = { foo: 1 } 38 | 39 | @injectActions 40 | class Container extends React.Component { 41 | render() { 42 | return 43 | } 44 | } 45 | 46 | const tree = TestUtils.renderIntoDocument( 47 | 48 | 49 | , 50 | ) 51 | 52 | const container = TestUtils.findRenderedComponentWithType(tree, Container) 53 | expect(container.context.actions).to.be.eql(actions) 54 | }) 55 | 56 | it('should pass actions and props to the given component', () => { 57 | const actions = { 58 | foo: 'bar', 59 | baz: 42, 60 | hello: 'world', 61 | } 62 | 63 | @injectActions 64 | class Container extends React.Component { 65 | render() { 66 | return 67 | } 68 | } 69 | 70 | const container = TestUtils.renderIntoDocument( 71 | 72 | 73 | , 74 | ) 75 | const stub = TestUtils.findRenderedComponentWithType(container, Passthrough) 76 | expect(stub.props.actions).to.be.eql(actions) 77 | expect(stub.props.baz).to.equal(50) 78 | expect(stub.props.hello).to.be.undefined // eslint-disable-line 79 | 80 | expect(() => 81 | TestUtils.findRenderedComponentWithType(container, Container), 82 | ).to.not.throw() 83 | }) 84 | 85 | it('should throw an error if a component is not passed to injectActions', () => { 86 | const WrappedComponent = injectActions() 87 | const render = () => { ReactDOM.render(, React.createElement('div')) } 88 | expect(render).to.throw() 89 | }) 90 | 91 | it('should throw when trying to access the wrapped instance if withRef is not specified', () => { 92 | const actions = {} 93 | 94 | class Container extends React.Component { // eslint-disable-line 95 | render() { 96 | return 97 | } 98 | } 99 | 100 | const Decorated = injectActions(Container) 101 | const tree = TestUtils.renderIntoDocument( 102 | 103 | 104 | , 105 | ) 106 | 107 | const decorated = TestUtils.findRenderedComponentWithType(tree, Decorated) 108 | expect(() => decorated.getWrappedInstance()).to.throw( 109 | /To access the wrapped instance, you need to specify \{ withRef: true \} in the options argument of the injectActions\(\) call\./, 110 | ) 111 | }) 112 | 113 | it('should return the instance of the wrapped component for use in calling child methods', () => { 114 | const actions = {} 115 | 116 | const someData = { 117 | some: 'data', 118 | } 119 | 120 | class Container extends React.Component { 121 | someInstanceMethod() { // eslint-disable-line 122 | return someData 123 | } 124 | 125 | render() { 126 | return 127 | } 128 | } 129 | 130 | const Decorated = injectActions(Container, { withRef: true }) 131 | const tree = TestUtils.renderIntoDocument( 132 | 133 | 134 | , 135 | ) 136 | 137 | const decorated = TestUtils.findRenderedComponentWithType(tree, Decorated) 138 | expect(() => decorated.someInstanceMethod()).to.throw() 139 | expect(decorated.getWrappedInstance().someInstanceMethod()).to.be.eql(someData) 140 | expect(decorated.wrappedInstance.someInstanceMethod()).to.be.eql(someData) 141 | }) 142 | }) 143 | -------------------------------------------------------------------------------- /test/spec/utils.spec.js: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai' 2 | import { getNamespaceFromPath, getEnhancer } from '../../src/utils' 3 | 4 | describe('utils', () => { 5 | describe('getNamespaceFromPath', () => { 6 | it('should work with relative path', () => { 7 | expect(getNamespaceFromPath('./a/b/c.js')).to.be.equal('a.b.c') 8 | expect(getNamespaceFromPath('../a/b/c.js')).to.be.equal('a.b.c') 9 | }) 10 | 11 | it('should work with absolute path', () => { 12 | expect(getNamespaceFromPath('/a/b/c.js')).to.be.equal('a.b.c') 13 | expect(getNamespaceFromPath('a/b/c.js')).to.be.equal('a.b.c') 14 | }) 15 | }) 16 | 17 | describe('getEnhancer', () => { 18 | it('should return a function which just return the input if enhancers is null', () => { 19 | const enhancer = getEnhancer() 20 | expect(enhancer(1)).to.be.eql(1) 21 | }) 22 | 23 | it('should return a function which when enhancers is a function', () => { 24 | const enhancer = getEnhancer(reducer => reducer + 1) 25 | expect(enhancer(1)).to.be.eql(2) 26 | }) 27 | 28 | it('should return a function which when enhancers is an array', () => { 29 | const enhancer = getEnhancer([reducer => reducer + 1, reducer => reducer - 1]) 30 | expect(enhancer(1)).to.be.eql(1) 31 | }) 32 | }) 33 | }) 34 | -------------------------------------------------------------------------------- /test/spec/watcher.spec.js: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai' 2 | import sinon from 'sinon' 3 | import createApp from '../../src/createApp' 4 | 5 | describe('watcher', () => { 6 | it('should call handlers with correct arguments', () => { 7 | const args = [] 8 | const app = createApp() 9 | app.model({ 10 | namespace: 'count', 11 | state: 0, 12 | add: (state, payload) => state + payload, 13 | }) 14 | app.model({ 15 | namespace: 'foo.bar', 16 | state: 1, 17 | sub: (state, payload) => state - payload, 18 | watcher(helpers, innerActions, actions, onError) { 19 | args.push(helpers, innerActions, actions, onError) 20 | }, 21 | }) 22 | app.render() 23 | 24 | const [helpers, innerActions, actions, onError] = args 25 | const { history, getState } = helpers 26 | 27 | expect(history).to.be.null //eslint-disable-line 28 | expect(getState()).to.eql(app.store.getState()) 29 | expect(getState(['count'])).to.eql(0) 30 | expect(getState('count')).to.eql(0) 31 | expect(getState('foo.bar')).to.eql(1) 32 | expect(getState(['foo', 'bar'])).to.eql(1) 33 | 34 | expect(innerActions).to.have.own.property('sub') 35 | 36 | expect(actions).to.have.deep.nested.property('count.add') 37 | expect(actions).to.have.deep.nested.property('foo.bar.sub') 38 | 39 | const badFn = () => { onError('error') } 40 | expect(badFn).to.throw() 41 | }) 42 | 43 | it('should dispatch actions correctly', () => { 44 | const app = createApp() 45 | app.model({ 46 | namespace: 'count', 47 | state: 0, 48 | add: (state, payload) => state + payload, 49 | }) 50 | app.model({ 51 | namespace: 'foo.bar', 52 | state: 1, 53 | sub: (state, payload) => state - payload, 54 | watcher(helpers, innerActions, actions) { 55 | actions.count.add(2) 56 | innerActions.sub(1) 57 | }, 58 | }) 59 | 60 | app.render() 61 | 62 | expect(app.store.getState().count).to.eql(2) 63 | expect(app.store.getState().foo.bar).to.eql(0) 64 | }) 65 | 66 | it('should unlisten corresponding listeners when eject model', () => { 67 | const app = createApp() 68 | let flag1 = false 69 | let flag2 = false 70 | 71 | app.model({ 72 | namespace: 'count', 73 | state: 0, 74 | watcher() { 75 | return () => { flag1 = true } 76 | }, 77 | }) 78 | app.model({ 79 | namespace: 'foo.bar', 80 | state: 0, 81 | watcher() { 82 | return () => { flag2 = true } 83 | }, 84 | }) 85 | app.render() 86 | 87 | const spy = sinon.stub(console, 'error') 88 | 89 | app.eject('count') 90 | app.eject('foo.bar') 91 | spy.restore() 92 | 93 | expect(flag1).to.be.eql(true) 94 | expect(flag2).to.be.eql(true) 95 | }) 96 | 97 | it('should give a warning message if unlistener is not a function when unlisten', () => { 98 | const app = createApp() 99 | app.model({ 100 | namespace: 'count', 101 | state: 0, 102 | watcher: [() => null], 103 | }) 104 | app.model({ 105 | namespace: 'foo.bar', 106 | state: 0, 107 | }) 108 | app.render() 109 | 110 | const spy = sinon.stub(console, 'error') 111 | 112 | app.eject('count') 113 | app.eject('foo.bar') 114 | 115 | expect(spy.firstCall.args[0]).to.match(/watcher should return unlistener function/) 116 | 117 | spy.restore() 118 | }) 119 | }) 120 | --------------------------------------------------------------------------------