├── .babelrc
├── .eslintrc
├── .gitignore
├── .travis.yml
├── CNAME
├── README.md
├── book.json
├── docs
├── API.md
├── README.md
├── introduction
│ ├── Concepts.md
│ ├── README.md
│ └── Tutorial.md
└── recipes
│ ├── README.md
│ └── ReusableReducer.md
├── examples
├── buildAll.js
├── testAll.js
└── todo
│ ├── .babelrc
│ ├── .eslintrc
│ ├── .gitignore
│ ├── index.html
│ ├── package.json
│ ├── src
│ ├── components
│ │ ├── TodoForm.js
│ │ └── TodoList.js
│ ├── containers
│ │ ├── ActiveTodo.js
│ │ ├── App.js
│ │ └── CompletedTodo.js
│ ├── helpers
│ │ └── callApi.js
│ ├── index.js
│ ├── models
│ │ ├── entity.js
│ │ ├── form.js
│ │ └── todo
│ │ │ ├── active.js
│ │ │ └── completed.js
│ ├── routes.js
│ └── schemas.js
│ ├── test
│ └── models
│ │ └── todo
│ │ └── active.spec.js
│ ├── webpack.config.js
│ └── yarn.lock
├── index.js
├── package.json
├── src
├── composeReducers.js
├── connect.js
├── constants.js
├── createReducer.js
├── createStore.js
├── feeble.js
├── middlewares
│ ├── api.js
│ └── epic.js
├── model.js
├── typeSet.js
└── utils
│ ├── isActionCreator.js
│ └── isNamespace.js
└── test
├── createReducer.spec.js
├── createStore.spec.js
├── feeble.spec.js
├── middlewares
└── api.spec.js
├── model.spec.js
└── utils
├── isActionCreator.spec.js
└── isNamespace.spec.js
/.babelrc:
--------------------------------------------------------------------------------
1 | {
2 | "presets": ["es2015", "stage-0", "react"]
3 | }
4 |
--------------------------------------------------------------------------------
/.eslintrc:
--------------------------------------------------------------------------------
1 | {
2 | "parser": "babel-eslint",
3 | "extends": "airbnb",
4 | "env": {
5 | "jest": true
6 | },
7 | "rules": {
8 | "semi": [2, "never"],
9 | "import/no-unresolved": 0,
10 | "no-underscore-dangle": 0,
11 | "no-param-reassign": 0,
12 | "no-shadow": 0,
13 | "no-unused-vars": [2, { "argsIgnorePattern": "^_" }]
14 | },
15 | "plugins": [
16 | "react"
17 | ]
18 | }
19 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | /node_modules
2 | /lib
3 | npm-debug.log
4 | /coverage
5 | /.nyc_output
6 | /_book
7 | /yarn.lock
8 | yarn-error.log
9 | .yarnclean
10 |
--------------------------------------------------------------------------------
/.travis.yml:
--------------------------------------------------------------------------------
1 | language: node_js
2 |
3 | node_js:
4 | - "6"
5 | - "7"
6 |
7 | branches:
8 | only:
9 | - master
10 |
11 | script:
12 | - npm run lint
13 | - npm run test -- --coverage
14 | - npm run build
15 | - npm run check:examples
16 |
17 | after_success:
18 | - bash <(curl -s https://codecov.io/bash)
19 |
--------------------------------------------------------------------------------
/CNAME:
--------------------------------------------------------------------------------
1 | feeble.js.org
2 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
Feeble
2 |
3 |
4 | React + Redux Architecture
5 |
6 |
7 |
25 |
26 |
27 | ## Introduction
28 |
29 | Feeble is a framework built on top of React/Redux/redux-observable which aims to make building React/Redux applications easier and better.
30 |
31 | If you are familiar with React/Redux/redux-observable, you'll love Feeble :see_no_evil:.
32 |
33 | ## Installation
34 |
35 | ```bash
36 | npm install feeble --save
37 | ```
38 |
39 | ## Example
40 |
41 | ```javascript
42 | import React from 'react'
43 | import ReactDOM from 'react-dom'
44 | import feeble, { connect } from 'feeble'
45 |
46 | // 1. Create a app
47 | const app = feeble()
48 |
49 | // 2.1 Create model
50 | const counter = feeble.model({
51 | namespace: 'count',
52 | state: 0,
53 | })
54 |
55 | // 2.2 Create action creators
56 | counter.action('increment')
57 | counter.action('decrement')
58 |
59 | // 2.3 Create reducer
60 | counter.reducer(on => {
61 | on(counter.increment, state => state + 1)
62 | on(counter.decrement, state => state - 1)
63 | })
64 |
65 | // 2.4 Attach model to the app
66 | app.model(counter)
67 |
68 | // 3. Create view
69 | const App = connect(({ count }) => ({
70 | count
71 | }))(function({ dispatch, count }) {
72 | return (
73 |
74 |
{ count }
75 |
76 |
77 |
78 | )
79 | })
80 |
81 | // 4. Mount the view
82 | const tree = app.mount()
83 |
84 | // 5. Render to DOM
85 | ReactDOM.render(tree, document.getElementById('root'))
86 | ```
87 |
88 | For more complex examples, please see [/examples](/examples).
89 |
90 | ## Documentation
91 |
92 | https://feeblejs.github.io/feeble
93 |
94 | ## License
95 |
96 | [MIT](https://tldrlegal.com/license/mit-license)
97 |
--------------------------------------------------------------------------------
/book.json:
--------------------------------------------------------------------------------
1 | {
2 | "gitbook": "2.5.2",
3 | "structure": {
4 | "summary": "docs/README.md"
5 | }
6 | }
7 |
--------------------------------------------------------------------------------
/docs/API.md:
--------------------------------------------------------------------------------
1 | # API Reference
2 |
3 | * [`feeble API`](#feeble-api)
4 | * [feeble(options)](#feebleoptions)
5 | * [feeble.model(options)](#feeblemodeloptions)
6 | * [connect(mapStateToProps, mapDispatchToProps, mergeProps, options)](#connectmapstatetoprops-mapdispatchtoprops-mergeprops-options)
7 | * [`app API`](#app-api)
8 | * [app.model(...args)](#appmodelargs)
9 | * [app.middleware(..args)](#appmiddlewareargs)
10 | * [app.mount(component)](#appmountcomponent)
11 | * [app.tree()](#apptree)
12 | * [app.store](#appstore)
13 | * [`model API`](#model-api)
14 | * [model.action(name, fn, fn)](#modelactionname-fn-fn)
15 | * [model.apiAction(name, fn, fn)](#modelapiactionname-fn-fn)
16 | * [model.reducer(fn)](#modelreducerfn)
17 | * [model.selector(name, ...fns, fn, options)](#modelselectorname-fns-fn-options)
18 | * [model.select(name, ...args)](#modelselectname-args)
19 | * [model.epic(fn)](#modelepicfn)
20 | * [model.addReducer(fn)](#modeladdreducerfn)
21 | * [model.getState()](#modelgetstate)
22 |
23 | ## feeble API
24 |
25 | ### `feeble(options)`
26 |
27 | Create a feeble app.
28 |
29 | * `options: Object` - A list of options to pass to the app, currently supported options are:
30 | * `callApi: Function` - A function interact wiht server, if this option is presented, Feeble will add a api middleware to Redux store, see more details for [api middleware](#todo).
31 |
32 | ### `feeble.model(options)`
33 |
34 | Create a model.
35 |
36 | * `options: Object` - A list of options to pass to the model, currently supported options are:
37 | * `namespace: String` - Namespace of the model, this is required. Namespace can be nested by a double colon `::`.
38 | * `state: any` - Initial state of the model.
39 |
40 | ### `connect(mapStateToProps, mapDispatchToProps, mergeProps, options)`
41 |
42 | Same as `react-redux`'s [connect](https://github.com/reactjs/react-redux/blob/master/docs/api.md#connectmapstatetoprops-mapdispatchtoprops-mergeprops-options).
43 |
44 | ## app API
45 |
46 | ### `app.model(...args)`
47 |
48 | * `args: Array` - An array of models.
49 |
50 | Attach one or multiple models to the app. The app's initial state tree will generate from attached model's namespace and initial state. And model's reducer will mount to the specify node by model's namespace.
51 |
52 | #### Example
53 |
54 | ```javascript
55 | const app = feeble()
56 |
57 | const count = feeble.model({
58 | namespace: 'count',
59 | state: 0,
60 | })
61 |
62 | const todoA = feeble.model({
63 | namespace: 'todo::a',
64 | state: [ 'bar' ],
65 | })
66 |
67 | const todoB = feeble.model({
68 | namespace: 'todo::b',
69 | state: [ 'foo' ],
70 | })
71 |
72 | app.model(count, todoA, todoB)
73 | ```
74 |
75 | Above example will generate following state tree:
76 |
77 | ```javascript
78 | {
79 | count: 0,
80 | todo: {
81 | a: [ 'bar' ],
82 | b: [ 'foo' ],
83 | },
84 | }
85 | ```
86 |
87 | And `count`'s reducer will mount to `count`, `todoA`'s reducer will mount to `todo.a`, `todoB`'s reducer will mount to `todo.b`. Namespace is a good way to split you large application to small modules.
88 |
89 | ### `app.middleware(...args)`
90 |
91 | * `args: Array` - An array of middlewares
92 |
93 | Apply Redux middlewares to your app.
94 |
95 |
96 | ### `app.mount(component)`
97 |
98 | * `component: Component` - A React component instance.
99 |
100 | Mount a component the you app, it's useful when you want testing your [container components](https://github.com/reactjs/redux/blob/master/docs/basics/UsageWithReact.md#presentational-and-container-components).
101 |
102 | #### Example
103 |
104 | ```javascript
105 | import test from 'ava'
106 | import app from './app' // <== yor app
107 | import { mount } from 'enzyme'
108 | import React from 'react'
109 | import Counter from 'containers/Counter' // <== Container component you want test
110 |
111 | test('todo', t => {
112 | const wrapper = mount(app.mount())
113 |
114 | wrapper.find('#increment').simulate('click')
115 |
116 | t.is(app.store.getState().count, 1)
117 | })
118 | ```
119 |
120 | ### `app.tree()`
121 |
122 | Access your app's root React instance.
123 |
124 | ### `app.store`
125 |
126 | Access Redux store.
127 |
128 | ## model API
129 |
130 | ### `model.action(name, [fn], [fn])`
131 |
132 | Create a action creator.
133 |
134 | * `name: String`: Name of the action creator, the name should be validate JavaScript function name, because action creator will be a method of model.
135 | * `fn: Function`: Transform multiple arguments as the payload. If you omit this param, the first argument pass to action creator will be the payload.
136 | * `fn: Function`: Transform multiple arguments as the meta.
137 |
138 | #### Example
139 |
140 | ```javascript
141 | const foo = feeble.model({
142 | namespace: 'foo'
143 | })
144 |
145 | // create a "simple" method on "foo"
146 | foo.action('simple')
147 | // produce "{ type: 'todo::simple', payload: 'blah' }"
148 | foo.simple('blah')
149 |
150 | // create a "better" method on "foo"
151 | foo.action('better', str => str + str)
152 | // produce "{ type: 'todo::better', payload: 'blah blah' }"
153 | foo.better('blah')
154 |
155 | // create a "best" method on "foo"
156 | foo.action('best', str => str + str, str => str.toUpperCase())
157 | // produce "{ type: 'todo::best', payload: 'blah blah', meta: 'BLAH' }"
158 | foo.best('blah')
159 | ```
160 |
161 | ### `model.apiAction(name, fn, [fn])`
162 |
163 | Create a API call action creator.
164 |
165 | If set `callApi` option to `app`, model will expose `apiAction` to allow you create API call action creator.
166 |
167 | * `name: String` - Name of the action creator.
168 | * `fn: Function`: Transform multiple arguments as the api request.
169 | * `fn: Function`: Transform multiple arguments as the meta.
170 |
171 | #### Example
172 |
173 | ```javascript
174 | const todo = feeble.model({
175 | namespace: 'todo',
176 | state: [],
177 | })
178 |
179 | todo.apiAction('create', name => ({
180 | method: 'post',
181 | endpoint: '/todos',
182 | body: { name },
183 | }))
184 | ```
185 |
186 | When you call `todo.create('Workout')` will produce following action:
187 |
188 | ```javascript
189 | {
190 | types: ['todo::create_request', 'todo::create_success', 'todo::create_error'],
191 | [CALL_API]: {
192 | method: 'post',
193 | endpoint: '/todos',
194 | body: { name },
195 | },
196 | }
197 | ```
198 |
199 | When api middleware find a [CALL_API] property in the action, it will dispatch a request action and pass the "CALL_API" object to your "callApi" function, here is the request action:
200 |
201 | ```javascript
202 | {
203 | type: 'todo::create_request',
204 | payload: {
205 | method: 'post',
206 | endpoint: '/todos',
207 | body: { name },
208 | }
209 | }
210 | ```
211 |
212 | Then, api api middleware calls your "callApi" function, after "callApi" returns promise resolved, following action will be dispatched:
213 |
214 | ```javascript
215 | {
216 | type: 'todo::create_success',
217 | payload: {
218 | name: 'Workout'
219 | }
220 | }
221 | ```
222 |
223 | If "callApi" rejects, a error action will be dispatched:
224 |
225 | ```javascript
226 | {
227 | type: 'todo::create_error',
228 | payload: "errors from server",
229 | error: true,
230 | }
231 | ```
232 |
233 | ### `model.reducer(fn)`
234 |
235 | Create reducer.
236 |
237 | * `fn: Function` - A function takes a `on` param which register action to the reducer.
238 |
239 | #### Example
240 |
241 | ```javascript
242 | count.reducer(on => {
243 | on(todo.increment, (state, payload) => state + payload)
244 | on(todo.decrement, (state, payload) => state + payload)
245 | })
246 | ```
247 |
248 | ### `model.selector(name, ...fns, fn, options)`
249 |
250 | * `name: Function` - Name of the selector, access the selector by calling `model.select(name)` later.
251 | * `fns: Array` - Input selectors.
252 | * `fn: Function` - Result function.
253 | * `options: Object` - A list of options, currently supported options are:
254 | * `structured: Boolean` - Create a structured selector if true.
255 |
256 | ### `model.select(name, ...args)`
257 |
258 | Access selectors.
259 |
260 | * `name: Function` - Name of the selector.
261 | * `args: Array` - Arguments pass to the selector.
262 |
263 | ### `model.epic(fn)`
264 |
265 | Create epic.
266 |
267 | * `fn: Function` - A epic function.
268 |
269 | ### Example
270 |
271 | ```javascript
272 | model.epic(action$ =>
273 | action$.ofAction(model.ping)
274 | .mapTo(model.pong())
275 | )
276 | ```
277 |
278 | ### `model.addReducer(fn)`
279 |
280 | Add a exists reducer to model. This is useful when you work with third party libraries or you legacy codes.
281 |
282 | * `fn: Function` - A normal Redux reducer.
283 |
284 | ### `model.getState()`
285 |
286 | Get current model state.
287 |
--------------------------------------------------------------------------------
/docs/README.md:
--------------------------------------------------------------------------------
1 | # Table of content
2 |
3 | * [Read Me](/README.md)
4 | * [Introduction](/docs/introduction/README.md)
5 | * [Tutorial](/docs/introduction/Tutorial.md)
6 | * [Concepts](/docs/introduction/Concepts.md)
7 | * [Recipes](/docs/recipes/README.md)
8 | * [Reusable Reducer](/docs/recipes/ReusableReducer.md)
9 | * [API Reference](/docs/API.md)
10 |
11 |
--------------------------------------------------------------------------------
/docs/introduction/Concepts.md:
--------------------------------------------------------------------------------
1 | # Concepts
2 |
3 | Feeble structures all your logic to a `app` and the only concept Feeble introduced is `model`, `model` let you model your domain's actions, reducer, epics, and selectors in one place.
4 |
5 | ## Model
6 |
7 | A `model` is a object contains `state`, `actions`, `reducer`, `epics`, `selectors`.
8 |
9 | Here's a typical model example:
10 |
11 | ```javascript
12 | const count = feeble.model({
13 | namespace: 'count',
14 | state: 0,
15 | })
16 |
17 | count.action('increment')
18 | count.action('double')
19 |
20 | count.reducer(on => {
21 | on(count.increment, state => state + 1)
22 | on(count.double, state => state * 2)
23 | })
24 | ```
25 |
26 | Let's walk through above example line by line to see what dose it do.
27 |
28 | First, we create a `model` using `feeble.model`, and giving it a namespace which is required for a model, and a initial state.
29 |
30 | Then, we define a `increment` action creator by calling `count.action`, and we can use `count.increment` to reference this action creator later.
31 |
32 | Last, we create `reducer` by calling `count.reducer`, `counter.reducer` accept a function which takes a `on` param, you can use `on` to register actions to reducer.
33 |
34 | `action creator` and `reducer` are all Redux's concepts, so what is `epics`?
35 |
36 | Feeble using `redux-observable` to handle side effects, an Epic is the core primitive of redux-observable. Let's define a `epic` for above `count` model.
37 |
38 | ```javascript
39 | model.epic(action$ =>
40 | action$.ofType(count.increment)
41 | .mapTo(count.double())
42 | })
43 | ```
44 |
45 | This.epic doubles count after you increase it.
46 |
47 | When you attach model to the `app`, Feeble will run your saga automaticly.
48 |
--------------------------------------------------------------------------------
/docs/introduction/README.md:
--------------------------------------------------------------------------------
1 | # Introduction
2 |
3 | * [Tutorial](Tutorial.md)
4 | * [Concepts](Concepts.md)
5 |
--------------------------------------------------------------------------------
/docs/introduction/Tutorial.md:
--------------------------------------------------------------------------------
1 | # Tutorial
2 |
3 | Let's build a small todo application using feeble. This tutorial assumes you're familiar with a few things:
4 |
5 | * [React](https://facebook.github.io/react)
6 | * [Redux](http://redux.js.org)
7 | * [redux-saga](http://yelouafi.github.io/redux-saga)
8 | * Some ES6 syntax
9 |
10 | ## Boilerplate
11 |
12 | We will use [create-react-app](https://github.com/facebookincubator/create-react-app) to setup our app in this tutorial.
13 |
14 | ```bash
15 | npm i -g create-react-app
16 |
17 | create-react-app --scripts-version feeble-scripts todo
18 | cd todo/
19 | npm start
20 | ```
21 |
22 | ## Creating todo model
23 |
24 | We'll start building our application by creating a todo `model`. Create `src/models/todo.js` and add following snippets:
25 |
26 | ```javascript
27 | import feeble from 'feeble';
28 |
29 | const todo = feeble.model({
30 | namespace: 'todo',
31 | state: ['Workout'],
32 | });
33 |
34 | export default todo
35 | ```
36 |
37 | Then, export todo `model` in `src/models/index.js`:
38 |
39 | ```javascript
40 | import Todo from './todo'
41 |
42 | export default [
43 | Todo,
44 | ]
45 | ```
46 |
47 | ## Redndering our data
48 |
49 | Ok, It's times render our todo list. Edit `src/containers/App/index.js`:
50 |
51 | ```javascript
52 | import React, { Component } from 'react';
53 | import { connect } from 'feeble';
54 | import Todo from '../../models/todo';
55 |
56 | class App extends Component {
57 | render() {
58 | const { todos } = this.props;
59 |
60 | return (
61 |
62 |
63 | {todos.map((todo, index) =>
64 | - {todo}
65 | )}
66 |
67 |
68 | );
69 | }
70 | }
71 |
72 | export default connect(() => ({
73 | todos: Todo.getState() // <== Get data directly from Todo model
74 | }))(App);
75 | ```
76 |
77 | Look at your browser, the page should shows a `Workout` todo now.
78 |
79 | ## Adding items
80 |
81 | Let's go back our todo model and remove the sample items. Then add a `add` action and the `reducer`:
82 |
83 | ```javascript
84 | const todo = feeble.model({
85 | namespace: 'todo',
86 | state: [],
87 | })
88 |
89 | todo.action('add')
90 |
91 | todo.reducer(on => {
92 | on(todo.add, (state, payload) => [...state, payload])
93 | })
94 | ```
95 |
96 | Then add a input to our view:
97 |
98 | ```javascript
99 | import React, { Component } from 'react';
100 | import { connect } from 'feeble';
101 | import Todo from './todo';
102 |
103 | class App extends Component {
104 | handleSubmit = event => {
105 | event.preventDefault()
106 | this.props.dispatch(Todo.add(this.input.value))
107 | this.input.value = ''
108 | }
109 |
110 | render() {
111 | const { todos } = this.props;
112 |
113 | return (
114 |
115 |
121 |
122 | {todos.map((todo, index) =>
123 | - {todo}
124 | )}
125 |
126 |
127 | );
128 | }
129 | }
130 |
131 | export default connect(() => ({
132 | todos: Todo.getState()
133 | }))(App);
134 | ```
135 |
136 | Look at your application again, and type something on the input then press `enter`, you will see item is added to the list.
137 |
--------------------------------------------------------------------------------
/docs/recipes/README.md:
--------------------------------------------------------------------------------
1 | # Recipes
2 |
3 | These are some use cases and code snippets to get you started with Feeble in a real app.
4 |
5 | * [Reusable Reducer](ReusableReducer.md)
6 |
--------------------------------------------------------------------------------
/docs/recipes/ReusableReducer.md:
--------------------------------------------------------------------------------
1 | # Reusable Reducer
2 |
3 | As feeble's model allow you create multiple reducers, you can use this feature to write reusable reducers.
4 |
5 | Let's say we have a post list and user list on the UI, a post model and a user model keep these lists's state.
6 |
7 | ```javascript
8 | // models/post.js
9 | const post = feeble.model({
10 | namespace: 'post',
11 | state: {
12 | loading: false,
13 | data: [],
14 | }
15 | })
16 |
17 | post.action('fetch', () => {
18 | method: 'get',
19 | endpoint: '/posts',
20 | })
21 |
22 | post.reducer(on => {
23 | on(post.fetch.request, state => ({
24 | ...state,
25 | loading: true,
26 | }))
27 |
28 | on(post.fetch.success, (state, payload) => ({
29 | ...state,
30 | loading: true,
31 | data: payload,
32 | }))
33 | })
34 | ```
35 |
36 | ```javascript
37 | // models/user.js
38 | const user = feeble.model({
39 | namespace: 'user',
40 | state: {
41 | loading: false,
42 | data: [],
43 | }
44 | })
45 |
46 | user.action('fetch', () => {
47 | method: 'get',
48 | endpoint: '/users',
49 | })
50 |
51 | user.reducer(on => {
52 | on(user.fetch.request, state => ({
53 | ...state,
54 | loading: true,
55 | }))
56 |
57 | on(user.fetch.success, (state, payload) => ({
58 | ...state,
59 | loading: true,
60 | data: payload,
61 | }))
62 | })
63 | ```
64 |
65 | These two models are 90% similar, especially the reducer. Let's extract the reducer to `models/conerns/list.js`:
66 |
67 | ```javascript
68 | export default function list(fetch) {
69 | return on => {
70 | on(fetch.request, state => ({
71 | ...state,
72 | loading: true,
73 | }))
74 |
75 | on(fetch.success, (state, payload) => ({
76 | ...state,
77 | loading: true,
78 | data: payload,
79 | }))
80 | }
81 | }
82 | ```
83 |
84 | Applying the `list` to these models:
85 |
86 | ```javascript
87 | // models/post.js
88 | import list from './concerns/list'
89 |
90 | const post = feeble.model({
91 | namespace: 'post',
92 | state: {
93 | loading: false,
94 | data: [],
95 | }
96 | })
97 |
98 | post.action('fetch', () => {
99 | method: 'get',
100 | endpoint: '/posts',
101 | })
102 |
103 | post.reducer(list(fetch))
104 | ```
105 |
106 | ```javascript
107 | // models/user.js
108 | import list from './concerns/list'
109 |
110 | const user = feeble.model({
111 | namespace: 'user',
112 | state: {
113 | loading: false,
114 | data: [],
115 | }
116 | })
117 |
118 | user.action('fetch', () => {
119 | method: 'get',
120 | endpoint: '/users',
121 | })
122 |
123 | user.reducer(list(fetch))
124 |
125 | // You can create more reducers for other actions
126 | // user.reducer(...)
127 | ```
128 |
--------------------------------------------------------------------------------
/examples/buildAll.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Runs an ordered set of commands within each of the build directories.
3 | */
4 |
5 | import fs from 'fs'
6 | import path from 'path'
7 | import { spawnSync } from 'child_process'
8 |
9 | var exampleDirs = fs.readdirSync(__dirname).filter((file) => {
10 | return fs.statSync(path.join(__dirname, file)).isDirectory()
11 | })
12 |
13 | // Ordering is important here. `npm install` must come first.
14 | var cmdArgs = [
15 | { cmd: 'npm', args: [ 'install' ] },
16 | { cmd: './node_modules/.bin/webpack', args: [ 'src/index.js' ] }
17 | ]
18 |
19 | for (const dir of exampleDirs) {
20 | for (const cmdArg of cmdArgs) {
21 | // declare opts in this scope to avoid https://github.com/joyent/node/issues/9158
22 | const opts = {
23 | cwd: path.join(__dirname, dir),
24 | stdio: 'inherit'
25 | }
26 | let result = {}
27 | if (process.platform === 'win32') {
28 | result = spawnSync(cmdArg.cmd + '.cmd', cmdArg.args, opts)
29 | } else {
30 | result = spawnSync(cmdArg.cmd, cmdArg.args, opts)
31 | }
32 | if (result.status !== 0) {
33 | throw new Error('Building examples exited with non-zero')
34 | }
35 | }
36 | }
37 |
--------------------------------------------------------------------------------
/examples/testAll.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Runs an ordered set of commands within each of the build directories.
3 | */
4 |
5 | import fs from 'fs'
6 | import path from 'path'
7 | import { spawnSync } from 'child_process'
8 |
9 | var exampleDirs = fs.readdirSync(__dirname).filter((file) => {
10 | return fs.statSync(path.join(__dirname, file)).isDirectory()
11 | })
12 |
13 | // Ordering is important here. `npm install` must come first.
14 | var cmdArgs = [
15 | { cmd: 'npm', args: [ 'install' ] },
16 | { cmd: 'npm', args: [ 'test' ] }
17 | ]
18 |
19 | for (const dir of exampleDirs) {
20 | for (const cmdArg of cmdArgs) {
21 | // declare opts in this scope to avoid https://github.com/joyent/node/issues/9158
22 | const opts = {
23 | cwd: path.join(__dirname, dir),
24 | stdio: 'inherit'
25 | }
26 |
27 | let result = {}
28 | if (process.platform === 'win32') {
29 | result = spawnSync(cmdArg.cmd + '.cmd', cmdArg.args, opts)
30 | } else {
31 | result = spawnSync(cmdArg.cmd, cmdArg.args, opts)
32 | }
33 | if (result.status !== 0) {
34 | throw new Error('Building examples exited with non-zero')
35 | }
36 | }
37 | }
38 |
--------------------------------------------------------------------------------
/examples/todo/.babelrc:
--------------------------------------------------------------------------------
1 | {
2 | "presets": ["es2015", "stage-0", "react"]
3 | }
4 |
--------------------------------------------------------------------------------
/examples/todo/.eslintrc:
--------------------------------------------------------------------------------
1 | {
2 | "parser": "babel-eslint",
3 | "extends": "airbnb",
4 | "rules": {
5 | "semi": [2, "never"],
6 | "import/no-unresolved": 0
7 | },
8 | "plugins": [
9 | "react"
10 | ]
11 | }
12 |
--------------------------------------------------------------------------------
/examples/todo/.gitignore:
--------------------------------------------------------------------------------
1 | /node_modules
2 | /dist
3 |
--------------------------------------------------------------------------------
/examples/todo/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | Feeble Todo Example
6 |
7 |
8 |
9 |
10 |
11 |
12 |
--------------------------------------------------------------------------------
/examples/todo/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "feeble-todo-example",
3 | "description": "Feeble todo example",
4 | "version": "1.0.0",
5 | "main": "index.js",
6 | "scripts": {
7 | "start": "webpack-dev-server --colors --content-base . --hot --inline --history-api-fallback",
8 | "test": "jest",
9 | "lint": "NODE_PATH=src eslint src test"
10 | },
11 | "jest": {
12 | "modulePaths": [
13 | "/src"
14 | ]
15 | },
16 | "keywords": [],
17 | "author": "Wei Zhu ",
18 | "license": "MIT",
19 | "dependencies": {
20 | "aphrodite": "^0.5.0",
21 | "feeble": "1.0.0-alpha4",
22 | "feeble-router": "^0.1.0",
23 | "jest": "^18.1.0",
24 | "lodash": "^4.13.1",
25 | "material-ui": "^0.15.2",
26 | "normalizr": "^2.2.1",
27 | "react": "^15.2.1",
28 | "react-dom": "^15.2.1",
29 | "react-tap-event-plugin": "^2.0.1",
30 | "redux-form": "^5.3.1",
31 | "superagent": "^2.1.0"
32 | },
33 | "devDependencies": {
34 | "babel-core": "^6.11.4",
35 | "babel-eslint": "^6.1.2",
36 | "babel-loader": "^6.2.4",
37 | "babel-preset-es2015": "^6.9.0",
38 | "babel-preset-react": "^6.11.1",
39 | "babel-preset-stage-0": "^6.5.0",
40 | "case-sensitive-paths-webpack-plugin": "^1.1.4",
41 | "enzyme": "^2.4.1",
42 | "eslint": "^2.13.1",
43 | "eslint-config-airbnb": "^9.0.1",
44 | "eslint-plugin-import": "^1.11.1",
45 | "eslint-plugin-jsx-a11y": "^1.5.5",
46 | "eslint-plugin-react": "^5.2.2",
47 | "redux-logger": "^2.6.1",
48 | "webpack": "^1.13.1",
49 | "webpack-dev-server": "^1.14.1"
50 | }
51 | }
52 |
--------------------------------------------------------------------------------
/examples/todo/src/components/TodoForm.js:
--------------------------------------------------------------------------------
1 | import React, { PropTypes } from 'react'
2 | import { TextField } from 'material-ui'
3 | import { reduxForm } from 'redux-form'
4 |
5 | function TodoForm({ fields, handleSubmit }) {
6 | return (
7 |
14 | )
15 | }
16 |
17 | TodoForm.propTypes = {
18 | fields: PropTypes.object.isRequired,
19 | handleSubmit: PropTypes.func.isRequired,
20 | }
21 |
22 | export default reduxForm({
23 | form: 'todo',
24 | fields: ['name'],
25 | })(TodoForm)
26 |
--------------------------------------------------------------------------------
/examples/todo/src/components/TodoList.js:
--------------------------------------------------------------------------------
1 | import React, { PropTypes } from 'react'
2 | import { StyleSheet, css } from 'aphrodite'
3 | import { List, ListItem, Checkbox } from 'material-ui'
4 |
5 | const styles = StyleSheet.create({
6 | completed: {
7 | textDecoration: 'line-through',
8 | },
9 | })
10 |
11 | function TodoItem({ todo, handleCheck }) {
12 | const checkbox =
13 |
14 | return (
15 |
16 |
17 | {todo.name}
18 |
19 |
20 | )
21 | }
22 |
23 | TodoItem.propTypes = {
24 | todo: PropTypes.object.isRequired,
25 | handleCheck: PropTypes.func.isRequired,
26 | }
27 |
28 | export default function TodoList({ todos, handleCheck }) {
29 | return (
30 |
31 | {todos.map(todo =>
32 |
33 | )}
34 |
35 | )
36 | }
37 |
38 | TodoList.propTypes = {
39 | todos: PropTypes.array.isRequired,
40 | handleCheck: PropTypes.func.isRequired,
41 | }
42 |
--------------------------------------------------------------------------------
/examples/todo/src/containers/ActiveTodo.js:
--------------------------------------------------------------------------------
1 | import React, { Component, PropTypes } from 'react'
2 | import { connect } from 'feeble'
3 | import { push } from 'feeble-router'
4 | import { Tabs, Tab } from 'material-ui/Tabs'
5 | import { reset } from 'redux-form'
6 | import Todo from '../models/todo/active'
7 | import TodoForm from '../components/TodoForm'
8 | import TodoList from '../components/TodoList'
9 |
10 | class ActiveTodo extends Component {
11 | static propTypes = {
12 | dispatch: PropTypes.func.isRequired,
13 | todos: PropTypes.array.isRequired,
14 | }
15 |
16 | componentWillMount() {
17 | const { dispatch } = this.props
18 | dispatch(Todo.fetch())
19 | }
20 |
21 | handleSubmit = todo => {
22 | const { dispatch } = this.props
23 | dispatch(Todo.create(todo))
24 | dispatch(reset('todo'))
25 | }
26 |
27 | handleCheck = todo => event => {
28 | const { dispatch } = this.props
29 | dispatch(Todo.complete(todo))
30 | }
31 |
32 | render() {
33 | const { todos, dispatch } = this.props
34 |
35 | return (
36 |
37 | dispatch(push('/'))}>
38 |
39 |
40 |
41 | dispatch(push('/completed'))} />
42 |
43 | )
44 | }
45 | }
46 |
47 | export default connect(
48 | () => ({
49 | todos: Todo.select('list'),
50 | })
51 | )(ActiveTodo)
52 |
--------------------------------------------------------------------------------
/examples/todo/src/containers/App.js:
--------------------------------------------------------------------------------
1 | import React, { PropTypes } from 'react'
2 | import { StyleSheet, css } from 'aphrodite'
3 | import MuiThemeProvider from 'material-ui/styles/MuiThemeProvider'
4 |
5 | const styles = StyleSheet.create({
6 | root: {
7 | width: '380px',
8 | margin: '100px auto 0 auto',
9 | },
10 | })
11 |
12 | export default function App(props) {
13 | return (
14 |
15 |
16 | {props.children}
17 |
18 |
19 | )
20 | }
21 |
22 | App.propTypes = {
23 | children: PropTypes.any.isRequired,
24 | }
25 |
--------------------------------------------------------------------------------
/examples/todo/src/containers/CompletedTodo.js:
--------------------------------------------------------------------------------
1 | import React, { Component, PropTypes } from 'react'
2 | import { connect } from 'feeble'
3 | import { browserHistory } from 'feeble-router'
4 | import { Tabs, Tab } from 'material-ui/Tabs'
5 | import entityModel from '../models/entity'
6 | import Todo from '../models/todo/completed'
7 | import TodoList from '../components/TodoList'
8 |
9 | class CompletedTodo extends Component {
10 | componentWillMount() {
11 | const { dispatch } = this.props
12 | dispatch(Todo.fetch())
13 | }
14 |
15 | handleCheck = todo => event => {
16 | const { dispatch } = this.props
17 | dispatch(Todo.uncomplete(todo))
18 | }
19 |
20 | render() {
21 | const { todos } = this.props
22 |
23 | return (
24 |
25 | browserHistory.push('/')} />
26 | browserHistory.push('/completed')}>
27 |
28 |
29 |
30 |
31 |
32 |
33 | )
34 | }
35 | }
36 |
37 | CompletedTodo.propTypes = {
38 | dispatch: PropTypes.func.isRequired,
39 | todos: PropTypes.array.isRequired,
40 | }
41 |
42 | export default connect(
43 | () => ({
44 | todos: Todo.select('list'),
45 | })
46 | )(CompletedTodo)
47 |
--------------------------------------------------------------------------------
/examples/todo/src/helpers/callApi.js:
--------------------------------------------------------------------------------
1 | import superagent from 'superagent'
2 | import { normalize } from 'normalizr'
3 |
4 | function formatUrl(endpoint) {
5 | const api = 'https://peaceful-shore-58208.herokuapp.com'
6 | // const api = 'http://0.0.0.0:9292'
7 | return [api, endpoint].join('/')
8 | }
9 |
10 | export default function callApi({ method, endpoint, query, body, schema }) {
11 | return new Promise((resolve, reject) => {
12 | const request = superagent[method](formatUrl(endpoint))
13 |
14 | if (query) { request.query(query) }
15 |
16 | if (body) { request.send(body) }
17 |
18 | request.end((error, res) => {
19 | if (error) {
20 | return reject({
21 | payload: res.body || error,
22 | error: true,
23 | meta: { status: res.status, headers: res.headers },
24 | })
25 | }
26 | return resolve({ payload: normalize(res.body, schema) })
27 | })
28 | })
29 | }
30 |
--------------------------------------------------------------------------------
/examples/todo/src/index.js:
--------------------------------------------------------------------------------
1 | import feeble from 'feeble'
2 | import router from 'feeble-router'
3 | import ReactDOM from 'react-dom'
4 | import createLogger from 'redux-logger'
5 | import routes from './routes'
6 | import TodoActive from './models/todo/active'
7 | import TodoCompleted from './models/todo/completed'
8 | import Form from './models/form'
9 | import Entity from './models/entity'
10 | import injectTapEventPlugin from 'react-tap-event-plugin'
11 | import callApi from './helpers/callApi'
12 |
13 | injectTapEventPlugin()
14 |
15 | const app = feeble({
16 | callApi,
17 | })
18 |
19 | app.model(
20 | TodoActive,
21 | TodoCompleted,
22 | Form,
23 | Entity
24 | )
25 |
26 | app.middleware(
27 | createLogger()
28 | )
29 |
30 | app.use(router)
31 |
32 | const tree = app.router(routes)
33 |
34 | ReactDOM.render(tree, document.getElementById('app'))
35 |
--------------------------------------------------------------------------------
/examples/todo/src/models/entity.js:
--------------------------------------------------------------------------------
1 | import feeble from 'feeble'
2 | import merge from 'lodash/fp/merge'
3 |
4 | const model = feeble.model({
5 | namespace: 'entity',
6 | state: {},
7 | })
8 |
9 | model.action('create', (name, data) => ({ name, data }))
10 | model.action('update', (name, id, data) => ({ name, id, data }))
11 |
12 | model.reducer(on => {
13 | const pattern = action => action.payload && action.payload.entities
14 | on(pattern, (state, payload) => merge(state, payload.entities))
15 |
16 | on(model.create, (state, payload) => ({
17 | ...state,
18 | [payload.name]: {
19 | ...state[payload.name],
20 | [payload.data.id]: payload.data
21 | }
22 | }))
23 |
24 | on(model.update, (state, payload) =>
25 | merge(state, {
26 | [payload.name]: {
27 | [payload.id]: payload.data,
28 | },
29 | })
30 | )
31 | })
32 |
33 | export default model
34 |
--------------------------------------------------------------------------------
/examples/todo/src/models/form.js:
--------------------------------------------------------------------------------
1 | import feeble from 'feeble'
2 | import { reducer } from 'redux-form'
3 |
4 | const model = feeble.model({
5 | namespace: 'form',
6 | })
7 |
8 | model.addReducer(reducer)
9 |
10 | export default model
11 |
--------------------------------------------------------------------------------
/examples/todo/src/models/todo/active.js:
--------------------------------------------------------------------------------
1 | import feeble from 'feeble'
2 | import { Observable } from 'rxjs/Rx'
3 | import Entity from '../entity'
4 | import schemas from '../../schemas'
5 | import without from 'lodash/without'
6 |
7 | const model = feeble.model({
8 | namespace: 'todo::active',
9 | state: {
10 | ids: [],
11 | },
12 | })
13 |
14 | model.apiAction('fetch', () => ({
15 | method: 'get',
16 | endpoint: '/todos',
17 | schema: schemas.TODO_ARRAY,
18 | }))
19 |
20 | model.apiAction('create', data => {
21 | const todo = {
22 | id: +new Date,
23 | completed: false,
24 | ...data,
25 | }
26 |
27 | return {
28 | method: 'post',
29 | endpoint: '/todos',
30 | body: todo,
31 | schema: schemas.TODO,
32 | }
33 | })
34 |
35 | model.apiAction('complete', todo => ({
36 | method: 'put',
37 | endpoint: `/todos/${todo.id}`,
38 | body: { ...todo, completed: true },
39 | schema: schemas.TODO,
40 | }))
41 |
42 | model.action('add')
43 | model.action('remove')
44 |
45 | model.reducer(on => {
46 | on(model.fetch.success, (state, payload) => ({
47 | ids: payload.result,
48 | }))
49 |
50 | on(model.add, (state, payload) => ({
51 | ids: [...state.ids, payload],
52 | }))
53 |
54 | on(model.remove, (state, payload) => ({
55 | ids: without(state.ids, payload),
56 | }))
57 | })
58 |
59 | model.selector('list',
60 | () => Entity.getState().todo,
61 | () => model.getState().ids,
62 | (entities, ids) => ids.map(id => entities[id])
63 | )
64 |
65 | // create
66 | model.epic(action$ =>
67 | action$.ofAction(model.create.request)
68 | .mergeMap(({ payload }) =>
69 | Observable.concat(
70 | Observable.of(Entity.create('todo', payload.body)),
71 | Observable.of(model.add(payload.body.id))
72 | )
73 | )
74 | )
75 |
76 | // complete
77 | model.epic(action$ =>
78 | action$.ofAction(model.complete.request)
79 | .mergeMap(({ payload }) =>
80 | Observable.concat(
81 | Observable.of(Entity.update('todo', payload.body.id, payload.body)),
82 | Observable.of(model.remove(payload.body.id))
83 | )
84 | )
85 | )
86 |
87 | export default model
88 |
--------------------------------------------------------------------------------
/examples/todo/src/models/todo/completed.js:
--------------------------------------------------------------------------------
1 | import feeble from 'feeble'
2 | import { Observable } from 'rxjs/Rx'
3 | import Entity from '../entity'
4 | import schemas from '../../schemas'
5 | import without from 'lodash/without'
6 |
7 | const model = feeble.model({
8 | namespace: 'todo::completed',
9 | state: {
10 | ids: [],
11 | },
12 | })
13 |
14 | model.apiAction('fetch', () => ({
15 | method: 'get',
16 | endpoint: '/todos/completed',
17 | schema: schemas.TODO_ARRAY,
18 | }))
19 |
20 | model.apiAction('uncomplete', todo => ({
21 | method: 'put',
22 | endpoint: `/todos/${todo.id}`,
23 | body: { ...todo, completed: false },
24 | schema: schemas.TODO,
25 | }))
26 |
27 | model.action('remove')
28 |
29 | model.reducer(on => {
30 | on(model.fetch.success, (state, payload) => ({
31 | ids: payload.result,
32 | }))
33 |
34 | on(model.remove, (state, payload) => ({
35 | ids: without(state.ids, payload),
36 | }))
37 | })
38 |
39 | model.selector('list',
40 | () => Entity.getState().todo,
41 | () => model.getState().ids,
42 | (entities, ids) => ids.map(id => entities[id])
43 | )
44 |
45 | // uncomplete
46 | model.epic(action$ =>
47 | action$.ofAction(model.uncomplete.request)
48 | .mergeMap(({ payload }) =>
49 | Observable.concat(
50 | Observable.of(Entity.update('todo', payload.body.id, payload.body)),
51 | Observable.of(model.remove(payload.body.id))
52 | )
53 | )
54 | )
55 |
56 | export default model
57 |
--------------------------------------------------------------------------------
/examples/todo/src/routes.js:
--------------------------------------------------------------------------------
1 | import React, { PropTypes } from 'react'
2 | import { Router, IndexRoute, Route } from 'feeble-router'
3 | import App from './containers/App'
4 | import ActiveTodo from './containers/ActiveTodo'
5 | import CompletedTodo from './containers/CompletedTodo'
6 |
7 | export default function routes({ history }) {
8 | return (
9 |
10 |
11 |
12 | >
13 |
14 |
15 | )
16 | }
17 |
18 | routes.propTypes = {
19 | history: PropTypes.object.isRequired,
20 | }
21 |
--------------------------------------------------------------------------------
/examples/todo/src/schemas.js:
--------------------------------------------------------------------------------
1 | import { Schema, arrayOf } from 'normalizr'
2 |
3 | const todo = new Schema('todo')
4 |
5 | export default {
6 | TODO: todo,
7 | TODO_ARRAY: arrayOf(todo),
8 | }
9 |
--------------------------------------------------------------------------------
/examples/todo/test/models/todo/active.spec.js:
--------------------------------------------------------------------------------
1 | import model from 'models/todo/active'
2 | import schemas from 'schemas'
3 |
4 | const reducer = model.getReducer()
5 |
6 | test('basic', () => {
7 | expect(model.getNamespace()).toBe('todo::active')
8 | expect(model.getState()).toEqual({ ids: [] })
9 | })
10 |
11 | test('action fetch', () => {
12 | expect(model.fetch().getRequest()).toEqual({
13 | method: 'get',
14 | endpoint: '/todos',
15 | schema: schemas.TODO_ARRAY,
16 | })
17 | })
18 |
19 | test('action create', () => {
20 | const data = {
21 | id: 1,
22 | name: 'foo',
23 | }
24 | expect(model.create(data).getRequest()).toEqual({
25 | method: 'post',
26 | endpoint: '/todos',
27 | body: {
28 | id: 1,
29 | name: 'foo',
30 | completed: false,
31 | },
32 | schema: schemas.TODO,
33 | })
34 | })
35 |
36 | test('reduce fetch success', () => {
37 | expect(reducer(undefined, model.fetch.success({
38 | result: [1, 2],
39 | }))).toEqual({
40 | ids: [1, 2],
41 | })
42 | })
43 |
44 | test('reduce create success', () => {
45 | expect(reducer(undefined, model.fetch.success({
46 | result: [1],
47 | }))).toEqual({
48 | ids: [1],
49 | })
50 | })
51 |
--------------------------------------------------------------------------------
/examples/todo/webpack.config.js:
--------------------------------------------------------------------------------
1 | var path = require('path')
2 | var webpack = require('webpack')
3 | var CaseSensitivePathsPlugin = require('case-sensitive-paths-webpack-plugin')
4 |
5 | module.exports = {
6 | devtool: 'source-map',
7 | entry: './src/index.js',
8 | output: {
9 | path: path.join(__dirname, 'dist'),
10 | filename: 'bundle.js'
11 | },
12 | module: {
13 | loaders: [
14 | {
15 | test: /\.js$/,
16 | loader: 'babel',
17 | exclude: /node_modules/,
18 | include: [
19 | path.resolve(__dirname, './src')
20 | ]
21 | },
22 | ]
23 | },
24 | progress: true,
25 | resolve: {
26 | extensions: ['', '.json', '.js'],
27 | },
28 | plugins: [
29 | new CaseSensitivePathsPlugin()
30 | ]
31 | };
32 |
--------------------------------------------------------------------------------
/index.js:
--------------------------------------------------------------------------------
1 | module.exports = require('./lib/feeble')
2 | module.exports.connect = require('./lib/connect').default
3 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "feeble",
3 | "version": "1.0.0-alpha4",
4 | "description": "React + Redux Architecture",
5 | "main": "index.js",
6 | "scripts": {
7 | "clean": "rimraf lib",
8 | "lint": "eslint src test",
9 | "test": "jest",
10 | "test:examples": "babel-node examples/testAll.js",
11 | "check:src": "npm run lint && npm run test",
12 | "check:examples": "npm run build:examples && npm run test:examples",
13 | "prebuild": "npm run clean",
14 | "build": "babel src --out-dir lib",
15 | "build:examples": "babel-node examples/buildAll.js",
16 | "prepublish": "npm run check:src && npm run check:examples && npm run build",
17 | "docs:prepare": "gitbook install",
18 | "docs:watch": "npm run docs:prepare && gitbook serve",
19 | "docs:build": "npm run docs:prepare && rimraf _book && gitbook build",
20 | "docs:publish": "npm run docs:build && cd _book && git init && git commit --allow-empty -m 'Update docs' && git checkout -b gh-pages && git add . && git commit -am 'Update docs' && git push git@github.com:tianche/feeble gh-pages --force"
21 | },
22 | "jest": {
23 | "modulePaths": [
24 | "/src"
25 | ],
26 | "testPathDirs": [
27 | "/test"
28 | ]
29 | },
30 | "pre-commit": [
31 | "lint"
32 | ],
33 | "files": [
34 | "lib",
35 | "src"
36 | ],
37 | "keywords": [
38 | "react",
39 | "redux"
40 | ],
41 | "author": "Wei Zhu ",
42 | "license": "MIT",
43 | "dependencies": {
44 | "invariant": "^2.2.1",
45 | "lodash": "^4.13.1",
46 | "react-redux": "^4.4.5",
47 | "react-router": "^2.6.0",
48 | "react-router-redux": "^4.0.5",
49 | "redux": "^3.5.2",
50 | "redux-observable": "^0.12.2",
51 | "reselect": "^2.5.3",
52 | "rxjs": "^5.0.3"
53 | },
54 | "devDependencies": {
55 | "babel-cli": "^6.11.4",
56 | "babel-core": "^6.11.4",
57 | "babel-eslint": "^6.1.2",
58 | "babel-polyfill": "^6.9.1",
59 | "babel-preset-es2015": "^6.5.0",
60 | "babel-preset-react": "^6.11.1",
61 | "babel-preset-stage-0": "^6.5.0",
62 | "babel-register": "^6.9.0",
63 | "enzyme": "^2.4.1",
64 | "eslint": "^2.13.1",
65 | "eslint-config-airbnb": "^9.0.1",
66 | "eslint-plugin-import": "^1.11.1",
67 | "eslint-plugin-jsx-a11y": "^1.5.5",
68 | "eslint-plugin-react": "^5.2.2",
69 | "gitbook-cli": "^2.3.0",
70 | "jest": "^18.1.0",
71 | "pre-commit": "^1.2.2",
72 | "react": "^15.2.1",
73 | "react-addons-test-utils": "^15.2.1",
74 | "react-dom": "^15.2.1",
75 | "react-router": "^2.6.0",
76 | "rimraf": "^2.5.4"
77 | },
78 | "peerDependencirs": {
79 | "react": "^0.14.0 || ^15.0.0-0"
80 | }
81 | }
82 |
--------------------------------------------------------------------------------
/src/composeReducers.js:
--------------------------------------------------------------------------------
1 | export default function composeReducers(reducers) {
2 | return (state, action) => {
3 | if (reducers.length === 0) {
4 | return state
5 | }
6 |
7 | const last = reducers[reducers.length - 1]
8 | const rest = reducers.slice(0, -1)
9 |
10 | return rest.reduceRight((enhanced, reducer) => reducer(enhanced, action), last(state, action))
11 | }
12 | }
13 |
--------------------------------------------------------------------------------
/src/connect.js:
--------------------------------------------------------------------------------
1 | import { connect as _connect } from 'react-redux'
2 |
3 | export default function connect(mapStateToProps, mapDispatchToProps, mergeProps, options) {
4 | let wrappedMapStateToProps = mapStateToProps
5 | if (mapStateToProps.length === 0) {
6 | wrappedMapStateToProps = _state => mapStateToProps()
7 | }
8 | return _connect(wrappedMapStateToProps, mapDispatchToProps, mergeProps, options)
9 | }
10 |
--------------------------------------------------------------------------------
/src/constants.js:
--------------------------------------------------------------------------------
1 | export const CALL_API = '__feeble_call_api'
2 |
3 | export const NAMESPACE_PATTERN = '^[a-zA-Z]+(::[a-zA-Z]+)*$'
4 |
--------------------------------------------------------------------------------
/src/createReducer.js:
--------------------------------------------------------------------------------
1 | import { combineReducers } from 'redux'
2 | import set from 'lodash/set'
3 |
4 | const combine = (reducers) => {
5 | Object.keys(reducers).forEach(key => {
6 | if (typeof reducers[key] === 'object') {
7 | reducers[key] = combineReducers(reducers[key])
8 | }
9 | })
10 | return combineReducers(reducers)
11 | }
12 |
13 | export default function createReducer(models) {
14 | const reducers = models.reduce((acc, model) => {
15 | const nsPath = model.getNamespace().replace('::', '.')
16 | set(acc, nsPath, model.getReducer())
17 | return acc
18 | }, {})
19 |
20 | return combine(reducers)
21 | }
22 |
--------------------------------------------------------------------------------
/src/createStore.js:
--------------------------------------------------------------------------------
1 | import { createStore as _createStore, applyMiddleware } from 'redux'
2 | import createReducer from './createReducer'
3 |
4 | function createStore(models, middlewares, initialState) {
5 | const reducer = createReducer(models)
6 |
7 | const enhancer = middlewares && middlewares.length > 0 ?
8 | applyMiddleware(...middlewares) : undefined
9 |
10 | const store = _createStore(
11 | reducer,
12 | initialState,
13 | enhancer
14 | )
15 |
16 | return store
17 | }
18 |
19 | export default createStore
20 |
--------------------------------------------------------------------------------
/src/feeble.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { Provider } from 'react-redux'
3 | import model from './model'
4 | import createApiMiddleware from './middlewares/api'
5 | import createEpicMiddleware from './middlewares/epic'
6 | import createStore from './createStore'
7 |
8 | function feeble(options = {}) {
9 | let _started = false
10 | const _app = {}
11 | const _models = []
12 | const _store = {}
13 | const _middlewares = []
14 | let _tree = null
15 |
16 | function middleware(...middlewares) {
17 | _middlewares.push(...middlewares)
18 | }
19 |
20 | function model(...models) {
21 | _models.push(...models)
22 | }
23 |
24 | function addDefaultMiddlewares() {
25 | const epicMiddleware = createEpicMiddleware(_models)
26 | _middlewares.unshift(epicMiddleware)
27 | if (options.callApi) {
28 | _middlewares.unshift(createApiMiddleware(options.callApi))
29 | }
30 | }
31 |
32 | function addDefaultModels() {
33 | // not default model currently
34 | }
35 |
36 | function start() {
37 | addDefaultMiddlewares()
38 | addDefaultModels()
39 | Object.assign(_store, createStore(_models, _middlewares))
40 | _started = true
41 | }
42 |
43 | function mount(component) {
44 | if (!_started) {
45 | start()
46 | }
47 | _tree = (
48 |
49 | {component}
50 |
51 | )
52 |
53 | return _tree
54 | }
55 |
56 | function tree() {
57 | return _tree
58 | }
59 |
60 | function use(ext) {
61 | return ext(_app)
62 | }
63 |
64 | Object.assign(_app, {
65 | middleware,
66 | model,
67 | mount,
68 | store: _store,
69 | tree,
70 | use,
71 | start,
72 | })
73 |
74 | return _app
75 | }
76 |
77 | feeble.model = model
78 |
79 | export default feeble
80 |
--------------------------------------------------------------------------------
/src/middlewares/api.js:
--------------------------------------------------------------------------------
1 | import { CALL_API } from '../constants'
2 | import omit from 'lodash/omit'
3 |
4 | export default function createApiMiddleware(callAPI) {
5 | return store => next => action => {
6 | const request = action[CALL_API]
7 | if (typeof request === 'undefined') {
8 | return next(action)
9 | }
10 |
11 | const { types, endpoint } = request // eslint-disable-line
12 |
13 | if (typeof endpoint === 'function') {
14 | request.endpoint = endpoint(store.getState())
15 | }
16 |
17 | function actionWith(data) {
18 | const finalAction = {
19 | ...data,
20 | meta: {
21 | ...data.meta,
22 | ...action.meta,
23 | },
24 | }
25 | return finalAction
26 | }
27 |
28 | const [requestType, successType, errorType] = types
29 |
30 | next(actionWith({
31 | type: requestType,
32 | payload: omit(request, 'types'),
33 | meta: action.meta,
34 | }))
35 |
36 | return callAPI(request, action)
37 | .then(
38 | action => {
39 | next(actionWith({
40 | ...action,
41 | type: successType,
42 | }))
43 | return action.payload
44 | },
45 | action => {
46 | next(actionWith({
47 | ...action,
48 | type: errorType,
49 | }))
50 | return action.payload
51 | }
52 | )
53 | }
54 | }
55 |
--------------------------------------------------------------------------------
/src/middlewares/epic.js:
--------------------------------------------------------------------------------
1 | import {
2 | ActionsObservable,
3 | createEpicMiddleware as createMiddleware,
4 | combineEpics,
5 | } from 'redux-observable'
6 |
7 | ActionsObservable.prototype.ofAction = function (...actions) {
8 | return this.ofType(...actions.map(action => action.getType()))
9 | }
10 |
11 | export default function createEpicMiddleware(models) {
12 | const epics = models.map(model => model.getEpic())
13 | const rootEpic = combineEpics(...epics)
14 | return createMiddleware(rootEpic)
15 | }
16 |
--------------------------------------------------------------------------------
/src/model.js:
--------------------------------------------------------------------------------
1 | import typeSet from './typeSet'
2 | import { CALL_API, NAMESPACE_PATTERN } from './constants'
3 | import { createSelector, createStructuredSelector } from 'reselect'
4 | import { combineEpics } from 'redux-observable'
5 | import invariant from 'invariant'
6 | import composeReducers from './composeReducers'
7 | import isNamespace from './utils/isNamespace'
8 | import isActionCreator from './utils/isActionCreator'
9 | import isUndefined from 'lodash/isUndefined'
10 | import isFunction from 'lodash/isFunction'
11 |
12 | const identity = (arg) => arg
13 |
14 | const invariantReducer = (value, name) => {
15 | invariant(
16 | isUndefined(value) || isFunction(value),
17 | '%s should be a function',
18 | name
19 | )
20 | }
21 |
22 | function model(options) {
23 | invariant(
24 | isNamespace(options.namespace),
25 | '%s is not a valid namespace, namespace should be a string ' +
26 | 'and match the pattern %s',
27 | options.namespace,
28 | NAMESPACE_PATTERN
29 | )
30 |
31 | const _initialState = options.state
32 | let _state = _initialState
33 | let _model = {}
34 | const _epics = []
35 | const _namespace = options.namespace
36 | const _selectors = {}
37 | const _reducers = [
38 | (state = _state) => state,
39 | ]
40 |
41 | function action(type, payloadReducer, metaReducer) {
42 | invariantReducer(payloadReducer, 'payload reducer')
43 | invariantReducer(metaReducer, 'meta reducer')
44 |
45 | if (typeof payloadReducer === 'undefined') {
46 | payloadReducer = identity
47 | }
48 |
49 | const fullType = [_namespace, type].join('::')
50 |
51 | invariant(
52 | !typeSet.has(fullType),
53 | '%s has already token by another action',
54 | fullType
55 | )
56 |
57 | typeSet.add(fullType)
58 |
59 | function actionCreator(...args) {
60 | const action = { type: fullType }
61 |
62 | action.payload = payloadReducer(...args)
63 |
64 | if (metaReducer) {
65 | action.meta = metaReducer(...args)
66 | }
67 |
68 | return action
69 | }
70 |
71 | actionCreator.getType = () => fullType
72 | actionCreator.toString = () => fullType
73 |
74 | _model[type] = actionCreator
75 |
76 | return actionCreator
77 | }
78 |
79 | function apiAction(type, requestReducer, metaReducer) {
80 | invariantReducer(requestReducer, 'request reducer')
81 | invariantReducer(metaReducer, 'meta reducer')
82 |
83 | const suffixes = ['request', 'success', 'error']
84 |
85 | const types = suffixes.map(suffix => [_namespace, `${type}_${suffix}`].join('::'))
86 |
87 | function apiActionCreator(...args) {
88 | const request = requestReducer(...args)
89 | const action = {
90 | [CALL_API]: {
91 | types,
92 | ...request,
93 | },
94 | }
95 | if (metaReducer) {
96 | action.meta = metaReducer(...args)
97 | }
98 | Object.defineProperty(action, 'getRequest', { value: () => request })
99 | return action
100 | }
101 |
102 | suffixes.forEach((suffix, index) => {
103 | const type = types[index]
104 |
105 | invariant(
106 | !typeSet.has(type),
107 | '%s has already token by another action',
108 | type
109 | )
110 |
111 | typeSet.add(type)
112 |
113 | apiActionCreator[suffix] = (payload, meta) => ({ type, payload, meta })
114 | apiActionCreator[suffix].toString = () => type
115 | apiActionCreator[suffix].getType = () => type
116 | })
117 |
118 | _model[type] = apiActionCreator
119 |
120 | return apiActionCreator
121 | }
122 |
123 | function reducer(handlers = {}, enhancer = identity) {
124 | const patternHandlers = []
125 |
126 | function on(pattern, handler) {
127 | if (typeof pattern === 'string') {
128 | handlers[pattern] = handler
129 | } else if (isActionCreator(pattern)) {
130 | handlers[pattern.getType()] = handler
131 | } else if (Array.isArray(pattern)) {
132 | pattern.forEach(p => on(p, handler))
133 | } else if (typeof pattern === 'function') {
134 | patternHandlers.push({
135 | pattern,
136 | handler,
137 | })
138 | }
139 | }
140 |
141 | if (typeof handlers === 'function') {
142 | const factory = handlers
143 | handlers = {}
144 | factory(on)
145 | }
146 |
147 | let reduce = (state = _initialState, action) => {
148 | if (action && handlers[action.type]) {
149 | return handlers[action.type](state, action.payload, action.meta)
150 | }
151 | for (const { pattern, handler } of patternHandlers) {
152 | if (pattern(action)) {
153 | return handler(state, action.payload, action.meta)
154 | }
155 | }
156 | return state
157 | }
158 |
159 | reduce = enhancer(reduce)
160 |
161 | _reducers.push(reduce)
162 |
163 | return reduce
164 | }
165 |
166 | function selector(name, ...args) {
167 | const isOptions = v => !isUndefined(v.structured)
168 | const last = args.pop()
169 | if (isOptions(last) && last.structured) {
170 | _selectors[name] = createStructuredSelector(...args)
171 | } else {
172 | _selectors[name] = createSelector(...args, last)
173 | }
174 | }
175 |
176 | function select(name, ...args) {
177 | return _selectors[name](...args)
178 | }
179 |
180 | function epic(_epic) {
181 | return _epics.push(_epic)
182 | }
183 |
184 | function getNamespace() {
185 | return _namespace
186 | }
187 |
188 | function addReducer(reducer) {
189 | _reducers.push(reducer)
190 | return reducer
191 | }
192 |
193 | function getReducer() {
194 | const reducer = composeReducers(_reducers)
195 | return (state, action) => {
196 | const nextState = reducer(state, action)
197 | _state = nextState
198 | return nextState
199 | }
200 | }
201 |
202 | function getEpic() {
203 | return combineEpics(..._epics)
204 | }
205 |
206 | function getState() {
207 | return _state
208 | }
209 |
210 | _model = {
211 | action,
212 | apiAction,
213 | reducer,
214 | selector,
215 | select,
216 | epic,
217 | getNamespace,
218 | addReducer,
219 | getReducer,
220 | getEpic,
221 | getState,
222 | }
223 |
224 | return _model
225 | }
226 |
227 | export default model
228 |
--------------------------------------------------------------------------------
/src/typeSet.js:
--------------------------------------------------------------------------------
1 | const types = {}
2 |
3 | function add(name) {
4 | types[name] = true
5 | }
6 |
7 | function remove(name) {
8 | delete types[name]
9 | }
10 |
11 | function has(name) {
12 | return !!types[name]
13 | }
14 |
15 | function values() {
16 | return Object.keys(types)
17 | }
18 |
19 | function clear() {
20 | values().forEach(remove)
21 | }
22 |
23 | export default {
24 | add,
25 | remove,
26 | has,
27 | values,
28 | clear,
29 | }
30 |
--------------------------------------------------------------------------------
/src/utils/isActionCreator.js:
--------------------------------------------------------------------------------
1 | import isFunction from 'lodash/isFunction'
2 |
3 | export default function isActionCreator(v) {
4 | return isFunction(v.toString) && isFunction(v.getType) && v.toString() === v.getType()
5 | }
6 |
--------------------------------------------------------------------------------
/src/utils/isNamespace.js:
--------------------------------------------------------------------------------
1 | import { NAMESPACE_PATTERN } from '../constants'
2 |
3 | export default function isNamspace(v) {
4 | return typeof v === 'string' && new RegExp(NAMESPACE_PATTERN).test(v)
5 | }
6 |
--------------------------------------------------------------------------------
/test/createReducer.spec.js:
--------------------------------------------------------------------------------
1 | import feeble from 'feeble'
2 | import createReducer from 'createReducer'
3 |
4 | test('default', () => {
5 | const foo = feeble.model({
6 | namespace: 'foo',
7 | state: 1,
8 | })
9 |
10 | const bar1 = feeble.model({
11 | namespace: 'bar::one',
12 | state: 2,
13 | })
14 |
15 | const bar2 = feeble.model({
16 | namespace: 'bar::two',
17 | state: 3,
18 | })
19 |
20 | const reducer = createReducer([foo, bar1, bar2])
21 |
22 | expect(reducer(undefined, { type: 'init' })).toEqual({
23 | foo: 1,
24 | bar: {
25 | one: 2,
26 | two: 3,
27 | },
28 | })
29 | })
30 |
--------------------------------------------------------------------------------
/test/createStore.spec.js:
--------------------------------------------------------------------------------
1 | import model from 'model'
2 | import typeSet from 'typeSet'
3 | import createStore from 'createStore'
4 |
5 | afterEach(() => {
6 | typeSet.clear()
7 | })
8 |
9 | test('create store', () => {
10 | const counter = model({
11 | namespace: 'counter',
12 | state: 0,
13 | })
14 |
15 | const increment = counter.action('increment')
16 |
17 | counter.reducer(on => {
18 | on(increment, state => state + 1)
19 | })
20 |
21 | const store = createStore([counter])
22 |
23 | store.dispatch(increment())
24 |
25 | expect(store.getState().counter).toBe(1)
26 | })
27 |
28 | test('nested namespace', () => {
29 | const counterFactory = ({ namespace, state }) => {
30 | const counter = model({
31 | namespace,
32 | state,
33 | })
34 |
35 | counter.action('increment')
36 |
37 | counter.reducer(on => {
38 | on(counter.increment, state => state + 1) // eslint-disable-line
39 | })
40 |
41 | return counter
42 | }
43 |
44 | const counterOne = counterFactory({
45 | namespace: 'counter::one',
46 | state: 1,
47 | })
48 |
49 | const counterTwo = counterFactory({
50 | namespace: 'counter::two',
51 | state: 2,
52 | })
53 |
54 | const store = createStore([counterOne, counterTwo])
55 |
56 | expect(store.getState().counter).toEqual({
57 | one: 1,
58 | two: 2,
59 | })
60 |
61 | store.dispatch(counterOne.increment())
62 |
63 | expect(store.getState().counter).toEqual({
64 | one: 2,
65 | two: 2,
66 | })
67 |
68 | store.dispatch(counterTwo.increment())
69 |
70 | expect(store.getState().counter).toEqual({
71 | one: 2,
72 | two: 3,
73 | })
74 | })
75 |
--------------------------------------------------------------------------------
/test/feeble.spec.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { mount } from 'enzyme'
3 | import feeble from 'feeble'
4 | import model from 'model'
5 | import typeSet from 'typeSet'
6 | import 'rxjs'
7 |
8 | afterEach(() => {
9 | typeSet.clear()
10 | })
11 |
12 | test('create a new app', () => {
13 | const app = feeble()
14 |
15 | expect(typeof app.middleware).toBe('function')
16 | expect(typeof app.model).toBe('function')
17 | expect(typeof app.use).toBe('function')
18 | expect(typeof app.start).toBe('function')
19 | expect(typeof app.store).toBe('object')
20 | })
21 |
22 | test('epic', () => {
23 | const app = feeble()
24 |
25 | const counter = model({
26 | namespace: 'counter',
27 | state: 0,
28 | })
29 |
30 | const increment = counter.action('increment')
31 | const double = counter.action('double')
32 |
33 | counter.reducer(on => {
34 | on(increment, state => state + 1)
35 | on(double, state => state * 2)
36 | })
37 |
38 | counter.epic(action$ =>
39 | action$.ofAction(increment)
40 | .map(() => double())
41 | )
42 |
43 | app.model(counter)
44 | app.start()
45 | app.store.dispatch(increment())
46 |
47 | expect(app.store.getState().counter).toBe(2)
48 | })
49 |
50 | test('mount', () => {
51 | const app = feeble()
52 |
53 | app.start()
54 |
55 | function Hello() {
56 | return Hello
57 | }
58 |
59 | const wrapper = mount(app.mount())
60 |
61 | expect(wrapper.find(Hello).length).toBe(1)
62 | })
63 |
--------------------------------------------------------------------------------
/test/middlewares/api.spec.js:
--------------------------------------------------------------------------------
1 | import { CALL_API } from '../../src/constants'
2 | import createApi from 'middlewares/api'
3 |
4 | function callAPI(callAPI, action) {
5 | return new Promise((resolve, reject) => {
6 | setTimeout(() => {
7 | if (action.meta.success) {
8 | resolve({ payload: { name: 'ava' } })
9 | } else {
10 | reject({ payload: 'i am a error', error: true })
11 | }
12 | })
13 | })
14 | }
15 |
16 | const store = {
17 | getState() {
18 | return {}
19 | },
20 | }
21 |
22 | let api = createApi(callAPI)
23 |
24 | test('CALL_API not given', () => {
25 | const next = jest.fn()
26 | const action = { type: 'INCREMENT' }
27 | api(store)(next)(action)
28 |
29 | expect(next).toBeCalledWith(action)
30 | })
31 |
32 | test('call api success', () => {
33 | const next = jest.fn()
34 |
35 | const action = {
36 | [CALL_API]: {
37 | types: ['FETCH_REQUEST', 'FETCH_SUCCESS', 'FETCH_ERROR'],
38 | method: 'get',
39 | endpoint: '/users',
40 | query: { active: true },
41 | },
42 | meta: {
43 | age: 18,
44 | success: true,
45 | },
46 | }
47 |
48 | return api(store)(next)(action).then(() => {
49 | expect(next.mock.calls[0][0]).toEqual({
50 | type: 'FETCH_REQUEST',
51 | payload: {
52 | method: 'get',
53 | endpoint: '/users',
54 | query: { active: true },
55 | },
56 | meta: {
57 | age: 18,
58 | success: true,
59 | },
60 | })
61 |
62 | expect(next.mock.calls[1][0]).toEqual({
63 | type: 'FETCH_SUCCESS',
64 | payload: {
65 | name: 'ava',
66 | },
67 | meta: {
68 | age: 18,
69 | success: true,
70 | },
71 | })
72 | })
73 | })
74 |
75 | test('call api error', () => {
76 | const next = jest.fn()
77 |
78 | const action = {
79 | [CALL_API]: {
80 | types: ['FETCH_REQUEST', 'FETCH_SUCCESS', 'FETCH_ERROR'],
81 | method: 'get',
82 | endpoint: '/users',
83 | query: { active: true },
84 | },
85 | meta: {
86 | age: 18,
87 | success: false,
88 | },
89 | }
90 |
91 | return api(store)(next)(action).then(() => {
92 | expect(next.mock.calls[0][0]).toEqual({
93 | type: 'FETCH_REQUEST',
94 | payload: {
95 | method: 'get',
96 | endpoint: '/users',
97 | query: { active: true },
98 | },
99 | meta: {
100 | age: 18,
101 | success: false,
102 | },
103 | })
104 |
105 | expect(next.mock.calls[1][0]).toEqual({
106 | type: 'FETCH_ERROR',
107 | payload: 'i am a error',
108 | error: true,
109 | meta: {
110 | age: 18,
111 | success: false,
112 | },
113 | })
114 | })
115 | })
116 |
117 | test('endpoint can be a function', () => {
118 | const next = () => {}
119 | const fetch = jest.fn().mockReturnValue(new Promise(resolve => setTimeout(() => {
120 | resolve({ payload: 'foo' })
121 | })))
122 | const endpoint = jest.fn().mockReturnValue('/users')
123 | api = createApi(fetch)
124 |
125 | const action = {
126 | [CALL_API]: {
127 | types: ['FETCH_REQUEST', 'FETCH_SUCCESS', 'FETCH_ERROR'],
128 | method: 'get',
129 | endpoint,
130 | },
131 | }
132 |
133 | return api(store)(next)(action).then(() => {
134 | expect(fetch.mock.calls[0][0]).toEqual({
135 | types: ['FETCH_REQUEST', 'FETCH_SUCCESS', 'FETCH_ERROR'],
136 | method: 'get',
137 | endpoint: '/users',
138 | })
139 | })
140 | })
141 |
--------------------------------------------------------------------------------
/test/model.spec.js:
--------------------------------------------------------------------------------
1 | import model from 'model'
2 | import typeSet from 'typeSet'
3 | import isActionCreator from 'utils/isActionCreator'
4 | import { CALL_API } from '../src/constants'
5 |
6 | afterEach(() => {
7 | typeSet.clear()
8 | })
9 |
10 | test('create model', () => {
11 | const counter = model({
12 | namespace: 'counter',
13 | state: 0,
14 | })
15 |
16 | expect(counter.getNamespace()).toBe('counter')
17 | })
18 |
19 | test('throw error for invalid namespace', () => {
20 | expect(() => {
21 | model({ namespace: 'foo1', state: 1 })
22 | }).toThrowError(
23 | 'foo1 is not a valid namespace, namespace should be a string ' +
24 | 'and match the pattern ^[a-zA-Z]+(::[a-zA-Z]+)*$'
25 | )
26 | })
27 |
28 | test('create action creator', () => {
29 | const counter = model({
30 | namespace: 'counter',
31 | state: 0,
32 | })
33 |
34 | counter.action('increment', () => 'hello', () => 'feeble')
35 |
36 | expect(counter.increment()).toEqual({
37 | type: 'counter::increment',
38 | payload: 'hello',
39 | meta: 'feeble',
40 | })
41 | })
42 |
43 | test('create api action creator', () => {
44 | const counter = model({
45 | namespace: 'counter',
46 | state: 0,
47 | })
48 |
49 | counter.apiAction('save', () => ({ method: 'post', endpoint: 'save' }))
50 |
51 | expect(counter.save()).toEqual({
52 | [CALL_API]: {
53 | types: [
54 | 'counter::save_request',
55 | 'counter::save_success',
56 | 'counter::save_error',
57 | ],
58 | method: 'post',
59 | endpoint: 'save',
60 | },
61 | })
62 |
63 | expect(counter.save().getRequest()).toEqual({
64 | method: 'post',
65 | endpoint: 'save',
66 | })
67 |
68 | expect(isActionCreator(counter.save.request)).toBe(true)
69 | expect(isActionCreator(counter.save.success)).toBe(true)
70 | expect(isActionCreator(counter.save.error)).toBe(true)
71 | expect(counter.save.request.getType()).toBe('counter::save_request')
72 | expect(counter.save.success.getType()).toBe('counter::save_success')
73 | expect(counter.save.error.getType()).toBe('counter::save_error')
74 | })
75 |
76 | test('has default reducer', () => {
77 | const foo = model({
78 | namespace: 'foo',
79 | state: 1,
80 | })
81 |
82 | expect(foo.getReducer()()).toBe(1)
83 | })
84 |
85 | test('create reducer', () => {
86 | const counter = model({
87 | namespace: 'counter',
88 | state: 0,
89 | })
90 |
91 | const reducer = counter.reducer(on => {
92 | on('counter::increment', state => state + 1)
93 | })
94 |
95 | expect(reducer(undefined, { type: 'counter::increment' })).toBe(1)
96 | })
97 |
98 | test('reducer enhancer', () => {
99 | const counter = model({
100 | namespace: 'counter',
101 | state: 0,
102 | })
103 |
104 | const double = reducer => (state, action) => reducer(state, action) * 2
105 |
106 | counter.reducer(on => {
107 | on('counter::increment', state => state + 1)
108 | }, double)
109 |
110 | const reducer = counter.getReducer()
111 |
112 | expect(reducer(undefined, { type: 'counter::increment' })).toBe(2)
113 | expect(counter.getState()).toBe(2)
114 | })
115 |
116 | test('define multiple reducers', () => {
117 | const counter = model({
118 | namespace: 'counter',
119 | state: 0,
120 | })
121 |
122 | counter.action('add1')
123 | counter.action('add2')
124 | counter.action('add3')
125 | counter.action('add4')
126 |
127 | counter.reducer(on => {
128 | on(counter.add1, state => state + 1)
129 | })
130 |
131 | counter.reducer(on => {
132 | on(counter.add2, state => state + 2)
133 | })
134 |
135 | counter.addReducer((state, { type }) => {
136 | switch (type) {
137 | case counter.add3.getType():
138 | return state + 3
139 | default:
140 | return state
141 | }
142 | })
143 |
144 | counter.addReducer((state, { type }) => {
145 | switch (type) {
146 | case counter.add4.getType():
147 | return state + 4
148 | default:
149 | return state
150 | }
151 | })
152 |
153 | const reducer = counter.getReducer()
154 | const state1 = reducer(undefined, counter.add1())
155 | const state2 = reducer(state1, counter.add2())
156 | const state3 = reducer(state2, counter.add3())
157 | const state4 = reducer(state3, counter.add4())
158 |
159 | expect(state1).toBe(1)
160 | expect(state2).toBe(3)
161 | expect(state3).toBe(6)
162 | expect(state4).toBe(10)
163 | })
164 |
165 | test('get state', () => {
166 | const foo = model({
167 | namespace: 'foo',
168 | state: 1,
169 | })
170 |
171 | foo.action('double')
172 |
173 | foo.reducer(on => {
174 | on(foo.double, state => state * 2)
175 | })
176 |
177 | expect(foo.getState()).toBe(1)
178 |
179 | foo.getReducer()(undefined, foo.double())
180 |
181 | expect(foo.getState()).toBe(2)
182 | })
183 |
184 | test('use initial state', () => {
185 | const counter = model({
186 | namespace: 'counter',
187 | state: 0,
188 | })
189 |
190 | counter.action('increment')
191 |
192 | counter.reducer(on => {
193 | on(counter.increment, state => state + 1)
194 | })
195 |
196 | const reducer = counter.getReducer()
197 |
198 | expect(reducer(undefined, counter.increment())).toBe(1)
199 | expect(reducer(undefined, counter.increment())).toBe(1)
200 | })
201 |
202 | test('create selector', () => {
203 | const rectangle = model({
204 | namespace: 'rectangle',
205 | })
206 |
207 | const resultFunc = jest.fn((width, height) => width * height)
208 |
209 | rectangle.selector('area',
210 | rect => rect.width,
211 | rect => rect.height,
212 | resultFunc
213 | )
214 |
215 | expect(rectangle.select('area', { width: 10, height: 5 })).toBe(50)
216 | expect(resultFunc.mock.calls.length).toBe(1)
217 | expect(rectangle.select('area', { width: 10, height: 5 })).toBe(50)
218 | expect(resultFunc.mock.calls.length).toBe(1)
219 | expect(rectangle.select('area', { width: 10, height: 5 })).toBe(50)
220 | expect(rectangle.select('area', { width: 10, height: 6 })).toBe(60)
221 | expect(resultFunc.mock.calls.length).toBe(2)
222 | })
223 |
224 | test('structured selctor', () => {
225 | const rectangle = model({
226 | namespace: 'rectangle',
227 | })
228 |
229 | rectangle.selector('geometry', {
230 | area: rect => rect.width * rect.height,
231 | perimeter: rect => (rect.width + rect.height) * 2,
232 | }, { structured: true })
233 |
234 | const rect = { width: 10, height: 5 }
235 |
236 | expect(rectangle.select('geometry', rect)).toEqual({ area: 50, perimeter: 30 })
237 | expect(rectangle.select('geometry', rect)).toBe(rectangle.select('geometry', rect))
238 | })
239 |
--------------------------------------------------------------------------------
/test/utils/isActionCreator.spec.js:
--------------------------------------------------------------------------------
1 | import isActionCreator from 'utils/isActionCreator'
2 |
3 | test('isActionCreator', () => {
4 | const pattern1 = {
5 | toString: () => 'foo',
6 | getType: () => 'foo',
7 | }
8 |
9 | const pattern2 = {
10 | toString: () => 'foo',
11 | getType: () => 'bar',
12 | }
13 |
14 | const pattern3 = {
15 | toString: 'foo',
16 | getType: 'bar',
17 | }
18 |
19 | expect(isActionCreator(pattern1)).toBe(true)
20 | expect(isActionCreator(pattern2)).toBe(false)
21 | expect(isActionCreator(pattern3)).toBe(false)
22 | })
23 |
--------------------------------------------------------------------------------
/test/utils/isNamespace.spec.js:
--------------------------------------------------------------------------------
1 | import isNamespace from 'utils/isNamespace'
2 |
3 | test('isNamspace', () => {
4 | expect(isNamespace('foo')).toBe(true)
5 | expect(isNamespace('foo::bar')).toBe(true)
6 | expect(isNamespace(':foo')).toBe(false)
7 | expect(isNamespace('FOO')).toBe(true)
8 | expect(isNamespace('fOO')).toBe(true)
9 | })
10 |
--------------------------------------------------------------------------------