├── .gitignore
├── .babelrc
├── .coveralls.yml
├── test
├── mocha.opts
├── createActions.specs.js
├── createReducer.specs.js
├── createAction.specs.js
└── createAsyncAction.specs.js
├── examples
└── todomvc
│ ├── src
│ ├── constants
│ │ ├── TodoFilters.js
│ │ └── ActionTypes.js
│ ├── reducers
│ │ ├── index.js
│ │ ├── todos.js
│ │ └── todos.spec.js
│ ├── components
│ │ ├── Loading.js
│ │ ├── Header.js
│ │ ├── TodoTextInput.js
│ │ ├── Header.spec.js
│ │ ├── TodoItem.js
│ │ ├── Footer.js
│ │ ├── MainSection.js
│ │ ├── TodoTextInput.spec.js
│ │ ├── Footer.spec.js
│ │ ├── TodoItem.spec.js
│ │ └── MainSection.spec.js
│ ├── index.js
│ ├── containers
│ │ └── App.js
│ ├── shared
│ │ └── loading.js
│ └── actions
│ │ ├── index.js
│ │ └── index.spec.js
│ ├── .gitignore
│ ├── README.md
│ ├── public
│ └── index.html
│ └── package.json
├── .travis.yml
├── webpack.config.js
├── package.json
├── README_zh_CN.MD
├── src
└── index.js
└── README.MD
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules/
2 | .idea/
3 | coverage/
4 | lib/
--------------------------------------------------------------------------------
/.babelrc:
--------------------------------------------------------------------------------
1 | {
2 | "presets": ["es2015", "stage-1"]
3 | }
4 |
--------------------------------------------------------------------------------
/.coveralls.yml:
--------------------------------------------------------------------------------
1 | repo_token: DhTQydq8d4ErpMN5Nt9JQUU1XggMx7V8t
--------------------------------------------------------------------------------
/test/mocha.opts:
--------------------------------------------------------------------------------
1 | --compilers js:babel-register,jsx:babel-register
2 | --recursive
3 | --ui bdd
--------------------------------------------------------------------------------
/examples/todomvc/src/constants/TodoFilters.js:
--------------------------------------------------------------------------------
1 | export const SHOW_ALL = 'show_all'
2 | export const SHOW_COMPLETED = 'show_completed'
3 | export const SHOW_ACTIVE = 'show_active'
4 |
--------------------------------------------------------------------------------
/examples/todomvc/.gitignore:
--------------------------------------------------------------------------------
1 | # See http://help.github.com/ignore-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | node_modules
5 |
6 | # production
7 | build
8 |
9 | # misc
10 | .DS_Store
11 | npm-debug.log
12 |
--------------------------------------------------------------------------------
/examples/todomvc/src/reducers/index.js:
--------------------------------------------------------------------------------
1 | import { combineReducers } from 'redux'
2 | import todos from './todos'
3 | import {loadingReducer} from '../shared/loading'
4 |
5 | const rootReducer = combineReducers({
6 | todos,
7 | loading: loadingReducer
8 | })
9 |
10 | export default rootReducer
11 |
--------------------------------------------------------------------------------
/examples/todomvc/src/components/Loading.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 |
3 | const Loading = ({show})=> {
4 | return show && (
5 |
6 |
This is global loading mask...
7 |
8 | )
9 | }
10 |
11 | export default Loading;
--------------------------------------------------------------------------------
/examples/todomvc/src/constants/ActionTypes.js:
--------------------------------------------------------------------------------
1 | export const ADD_TODO = 'ADD_TODO'
2 | export const DELETE_TODO = 'DELETE_TODO'
3 | export const EDIT_TODO = 'EDIT_TODO'
4 | export const COMPLETE_TODO = 'COMPLETE_TODO'
5 | export const COMPLETE_ALL = 'COMPLETE_ALL'
6 | export const CLEAR_COMPLETED = 'CLEAR_COMPLETED'
7 |
--------------------------------------------------------------------------------
/.travis.yml:
--------------------------------------------------------------------------------
1 | language: node_js
2 | node_js:
3 | - 6
4 | install:
5 | |
6 | npm install -g npm@latest
7 | npm --version
8 | npm prune
9 | npm install --registry http://registry.npmjs.org
10 | script:
11 | - npm run test:coverage
12 | after_script:
13 | - cat ./coverage/lcov.info | ./node_modules/.bin/coveralls
--------------------------------------------------------------------------------
/webpack.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | entry: [
3 | './src/index.js'
4 | ],
5 | output: {
6 | path: 'lib/',
7 | filename: 'index.js',
8 | library: 'redux-action-tools',
9 | libraryTarget: 'umd'
10 | },
11 | module: {
12 | loaders: [{
13 | test: /\.js$/,
14 | loaders: ['babel'],
15 | exclude: /node_modules/
16 | }]
17 | }
18 | };
19 |
--------------------------------------------------------------------------------
/examples/todomvc/README.md:
--------------------------------------------------------------------------------
1 | # TodoMVC example
2 |
3 | This example is mainly copied from [Redux TodoMVC example](https://github.com/reactjs/redux/tree/master/examples/todomvc)
4 |
5 | I've changed all actions to [FSA actions](https://github.com/acdlite/flux-standard-action);
6 |
7 | And the addTodo action has been changed to an async action.
8 |
9 | I will add some more usage of redux-action-tools in this example (such as usage of meta) in future commits.
--------------------------------------------------------------------------------
/examples/todomvc/src/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { render } from 'react-dom'
3 | import { createStore, applyMiddleware } from 'redux'
4 | import { Provider } from 'react-redux'
5 | import thunk from 'redux-thunk'
6 | import logger from 'redux-logger'
7 | import App from './containers/App'
8 | import reducer from './reducers'
9 | import loadingMiddleware from './shared/loading'
10 | import 'todomvc-app-css/index.css'
11 |
12 | const store = createStore(
13 | reducer,
14 | applyMiddleware(
15 | loadingMiddleware,
16 | thunk,
17 | logger,
18 | )
19 | )
20 |
21 | render(
22 |
23 |
24 | ,
25 | document.getElementById('root')
26 | )
27 |
--------------------------------------------------------------------------------
/examples/todomvc/src/components/Header.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react'
2 | import PropTypes from 'prop-types'
3 | import TodoTextInput from './TodoTextInput'
4 |
5 | export default class Header extends Component {
6 | static propTypes = {
7 | addTodo: PropTypes.func.isRequired
8 | }
9 |
10 | handleSave = text => {
11 | if (text.length !== 0) {
12 | this.props.addTodo(text)
13 | }
14 | }
15 |
16 | render() {
17 | return (
18 |
24 | )
25 | }
26 | }
27 |
--------------------------------------------------------------------------------
/examples/todomvc/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | Redux TodoMVC Example
7 |
8 |
9 |
10 |
20 |
21 |
22 |
--------------------------------------------------------------------------------
/examples/todomvc/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "todomvc",
3 | "version": "0.0.1",
4 | "private": true,
5 | "devDependencies": {
6 | "enzyme": "^2.8.0",
7 | "react-addons-test-utils": "^15.3.0",
8 | "react-scripts": "^0.9.5"
9 | },
10 | "dependencies": {
11 | "classnames": "^2.2.5",
12 | "lodash": "^4.17.4",
13 | "prop-types": "^15.5.8",
14 | "react": "^15.5.0",
15 | "react-dom": "^15.5.0",
16 | "react-redux": "^5.0.3",
17 | "react-test-renderer": "^15.5.4",
18 | "redux": "^3.5.2",
19 | "redux-action-tools": "^1.2.2",
20 | "redux-logger": "^3.0.6",
21 | "redux-thunk": "^2.2.0",
22 | "todomvc-app-css": "^2.1.0"
23 | },
24 | "scripts": {
25 | "start": "react-scripts start",
26 | "build": "react-scripts build",
27 | "eject": "react-scripts eject",
28 | "test": "react-scripts test"
29 | }
30 | }
31 |
--------------------------------------------------------------------------------
/examples/todomvc/src/containers/App.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import PropTypes from 'prop-types'
3 | import { bindActionCreators } from 'redux'
4 | import { connect } from 'react-redux'
5 | import Header from '../components/Header'
6 | import MainSection from '../components/MainSection'
7 | import Loading from '../components/Loading'
8 | import * as TodoActions from '../actions'
9 |
10 | const App = ({todos, loading, actions}) => (
11 |
12 |
13 |
14 |
15 |
16 | )
17 |
18 | App.propTypes = {
19 | todos: PropTypes.array.isRequired,
20 | actions: PropTypes.object.isRequired
21 | }
22 |
23 | const mapStateToProps = state => ({
24 | todos: state.todos,
25 | loading: state.loading,
26 | })
27 |
28 | const mapDispatchToProps = dispatch => ({
29 | actions: bindActionCreators(TodoActions, dispatch)
30 | })
31 |
32 | export default connect(
33 | mapStateToProps,
34 | mapDispatchToProps
35 | )(App)
36 |
--------------------------------------------------------------------------------
/examples/todomvc/src/shared/loading.js:
--------------------------------------------------------------------------------
1 | import _ from 'lodash'
2 | import { ASYNC_PHASES } from 'redux-action-tools'
3 |
4 | const actionTypes = {
5 | ASYNC_STARTED: 'ASYNC_STARTED',
6 | ASYNC_ENDED: 'ASYNC_ENDED',
7 | };
8 |
9 | export function loadingReducer(state = false, action) {
10 | switch (action.type) {
11 | case actionTypes.ASYNC_STARTED:
12 | return true;
13 | case actionTypes.ASYNC_ENDED:
14 | return false;
15 | default: return state;
16 | }
17 | }
18 |
19 | export default function loadingMiddleWare({ dispatch }) {
20 | return next => (action) => {
21 | const asyncPhase = _.get(action, 'meta.asyncPhase');
22 | const omitLoading = _.get(action, 'meta.omitLoading');
23 |
24 | if (asyncPhase && !omitLoading) {
25 | dispatch({
26 | type: asyncPhase === ASYNC_PHASES.START
27 | ? actionTypes.ASYNC_STARTED
28 | : actionTypes.ASYNC_ENDED,
29 | payload: {
30 | source: 'ACTION',
31 | action,
32 | },
33 | });
34 | }
35 |
36 | return next(action);
37 | };
38 | }
39 |
--------------------------------------------------------------------------------
/examples/todomvc/src/actions/index.js:
--------------------------------------------------------------------------------
1 | import * as types from '../constants/ActionTypes'
2 | import {createAsyncAction} from 'redux-action-tools'
3 |
4 | // return a Promise maybe (70%) fulfilled 1 second later
5 | const maybe = (data)=> (new Promise(function(resolve, reject) {
6 | setTimeout(()=> {
7 | Math.random() > 0.3 ? resolve(data) : reject(new Error('Ops, async operation failed'));
8 | }, 1000)
9 | }))
10 |
11 | export const addTodo = createAsyncAction(types.ADD_TODO, text=> maybe({text}));
12 |
13 | // The metaCreator can be either an object or a function which presents: (payload, defaultMeta) => finalMeta.
14 | // When you provide an object, it will be merged with defaultMeta, equalize to (payload, defaultMeta) => ({...defaultMeta, ...yourObj})
15 | export const deleteTodo = createAsyncAction(types.DELETE_TODO, id=> maybe({id}), { omitLoading: true })
16 |
17 | // export const addTodo = text => ({ type: types.ADD_TODO, text })
18 | // export const deleteTodo = id => ({ type: types.DELETE_TODO, payload: {id} })
19 | export const editTodo = (id, text) => ({ type: types.EDIT_TODO, payload: {id, text} })
20 | export const completeTodo = id => ({ type: types.COMPLETE_TODO, payload: {id} })
21 | export const completeAll = () => ({ type: types.COMPLETE_ALL })
22 | export const clearCompleted = () => ({ type: types.CLEAR_COMPLETED })
23 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "redux-action-tools",
3 | "version": "1.2.2",
4 | "description": "redux action tools with full async support inspired by redux-action",
5 | "main": "lib/index.js",
6 | "files": [
7 | "lib",
8 | "src"
9 | ],
10 | "scripts": {
11 | "build": "webpack",
12 | "test": "mocha",
13 | "test:coverage": "rimraf ./coverage && istanbul cover ./node_modules/.bin/_mocha",
14 | "prepublish": "rimraf ./lib && npm run test && npm run build"
15 | },
16 | "repository": {
17 | "type": "git",
18 | "url": "https://github.com/kpaxqin/redux-action-tools"
19 | },
20 | "keywords": [
21 | "redux",
22 | "action",
23 | "async",
24 | "promise",
25 | "flux",
26 | "fsa"
27 | ],
28 | "author": "kpaxqin",
29 | "license": "ISC",
30 | "devDependencies": {
31 | "babel-core": "^6.9.0",
32 | "babel-eslint": "^6.1.2",
33 | "babel-loader": "^6.2.4",
34 | "babel-preset-es2015": "^6.9.0",
35 | "babel-preset-stage-1": "^6.5.0",
36 | "chai": "^3.5.0",
37 | "coveralls": "^2.11.12",
38 | "flux-standard-action": "^0.6.1",
39 | "istanbul": "^1.1.0-alpha.1",
40 | "mocha": "^3.0.2",
41 | "rimraf": "^2.5.4",
42 | "sinon": "^1.17.5",
43 | "webpack": "^1.12.6"
44 | },
45 | "dependencies": {
46 | "camelcase": "^3.0.0"
47 | }
48 | }
49 |
--------------------------------------------------------------------------------
/examples/todomvc/src/actions/index.spec.js:
--------------------------------------------------------------------------------
1 | import * as types from '../constants/ActionTypes'
2 | import * as actions from './index'
3 |
4 | describe('todo actions', () => {
5 | it('addTodo should create ADD_TODO action', () => {
6 | expect(actions.addTodo('Use Redux')).toEqual({
7 | type: types.ADD_TODO,
8 | text: 'Use Redux'
9 | })
10 | })
11 |
12 | it('deleteTodo should create DELETE_TODO action', () => {
13 | expect(actions.deleteTodo(1)).toEqual({
14 | type: types.DELETE_TODO,
15 | id: 1
16 | })
17 | })
18 |
19 | it('editTodo should create EDIT_TODO action', () => {
20 | expect(actions.editTodo(1, 'Use Redux everywhere')).toEqual({
21 | type: types.EDIT_TODO,
22 | id: 1,
23 | text: 'Use Redux everywhere'
24 | })
25 | })
26 |
27 | it('completeTodo should create COMPLETE_TODO action', () => {
28 | expect(actions.completeTodo(1)).toEqual({
29 | type: types.COMPLETE_TODO,
30 | id: 1
31 | })
32 | })
33 |
34 | it('completeAll should create COMPLETE_ALL action', () => {
35 | expect(actions.completeAll()).toEqual({
36 | type: types.COMPLETE_ALL
37 | })
38 | })
39 |
40 | it('clearCompleted should create CLEAR_COMPLETED action', () => {
41 | expect(actions.clearCompleted()).toEqual({
42 | type: types.CLEAR_COMPLETED
43 | })
44 | })
45 | })
46 |
--------------------------------------------------------------------------------
/examples/todomvc/src/reducers/todos.js:
--------------------------------------------------------------------------------
1 | import { createReducer } from 'redux-action-tools'
2 | import { ADD_TODO, DELETE_TODO, EDIT_TODO, COMPLETE_TODO, COMPLETE_ALL, CLEAR_COMPLETED } from '../constants/ActionTypes'
3 |
4 | const initialState = [
5 | {
6 | text: 'Use Redux',
7 | completed: false,
8 | id: 0
9 | }
10 | ]
11 |
12 | const reducer = createReducer()
13 | .when(ADD_TODO)
14 | .done((state, {payload: {text}}) => [
15 | {
16 | id: state.reduce((maxId, todo) => Math.max(todo.id, maxId), -1) + 1,
17 | completed: false,
18 | text
19 | },
20 | ...state
21 | ])
22 | .when(DELETE_TODO)
23 | .done((state, {payload})=> state.filter(todo =>
24 | todo.id !== payload.id
25 | ))
26 | .when(EDIT_TODO, (state, {payload})=> state.map(todo =>
27 | todo.id === payload.id ?
28 | { ...todo, text: payload.text } :
29 | todo
30 | ))
31 | .when(COMPLETE_TODO, (state, {payload})=> state.map(todo =>
32 | todo.id === payload.id ?
33 | { ...todo, completed: !todo.completed } :
34 | todo
35 | ))
36 | .when(COMPLETE_ALL, (state)=> {
37 | const areAllMarked = state.every(todo => todo.completed)
38 | return state.map(todo => ({
39 | ...todo,
40 | completed: !areAllMarked
41 | }))
42 | })
43 | .when(CLEAR_COMPLETED, (state)=> state.filter(todo => todo.completed === false))
44 | .build(initialState);
45 |
46 | export default reducer;
47 |
--------------------------------------------------------------------------------
/examples/todomvc/src/components/TodoTextInput.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react'
2 | import PropTypes from 'prop-types'
3 | import classnames from 'classnames'
4 |
5 | export default class TodoTextInput extends Component {
6 | static propTypes = {
7 | onSave: PropTypes.func.isRequired,
8 | text: PropTypes.string,
9 | placeholder: PropTypes.string,
10 | editing: PropTypes.bool,
11 | newTodo: PropTypes.bool
12 | }
13 |
14 | state = {
15 | text: this.props.text || ''
16 | }
17 |
18 | handleSubmit = e => {
19 | const text = e.target.value.trim()
20 | if (e.which === 13) {
21 | this.props.onSave(text)
22 | if (this.props.newTodo) {
23 | this.setState({ text: '' })
24 | }
25 | }
26 | }
27 |
28 | handleChange = e => {
29 | this.setState({ text: e.target.value })
30 | }
31 |
32 | handleBlur = e => {
33 | if (!this.props.newTodo) {
34 | this.props.onSave(e.target.value)
35 | }
36 | }
37 |
38 | render() {
39 | return (
40 |
52 | )
53 | }
54 | }
55 |
--------------------------------------------------------------------------------
/examples/todomvc/src/components/Header.spec.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { createRenderer } from 'react-test-renderer/shallow';
3 |
4 | import Header from './Header'
5 | import TodoTextInput from './TodoTextInput'
6 |
7 | const setup = () => {
8 | const props = {
9 | addTodo: jest.fn()
10 | }
11 |
12 | const renderer = createRenderer();
13 | renderer.render()
14 | const output = renderer.getRenderOutput()
15 |
16 | return {
17 | props: props,
18 | output: output,
19 | renderer: renderer
20 | }
21 | }
22 |
23 | describe('components', () => {
24 | describe('Header', () => {
25 | it('should render correctly', () => {
26 | const { output } = setup()
27 |
28 | expect(output.type).toBe('header')
29 | expect(output.props.className).toBe('header')
30 |
31 | const [ h1, input ] = output.props.children
32 |
33 | expect(h1.type).toBe('h1')
34 | expect(h1.props.children).toBe('todos')
35 |
36 | expect(input.type).toBe(TodoTextInput)
37 | expect(input.props.newTodo).toBe(true)
38 | expect(input.props.placeholder).toBe('What needs to be done?')
39 | })
40 |
41 | it('should call addTodo if length of text is greater than 0', () => {
42 | const { output, props } = setup()
43 | const input = output.props.children[1]
44 | input.props.onSave('')
45 | expect(props.addTodo).not.toBeCalled()
46 | input.props.onSave('Use Redux')
47 | expect(props.addTodo).toBeCalled()
48 | })
49 | })
50 | })
51 |
--------------------------------------------------------------------------------
/test/createActions.specs.js:
--------------------------------------------------------------------------------
1 | import { expect } from 'chai';
2 | import { createActions } from '../src';
3 |
4 | describe('createActions', () => {
5 |
6 | it('should return a map of action creators', () => {
7 | const { actionOne, actionTwo } = createActions({
8 | ACTION_ONE: (foo, bar) => (foo + bar),
9 | ACTION_TWO: (foo, bar) => (foo * bar)
10 | });
11 |
12 | expect(actionOne(1, 2)).to.deep.equal({
13 | type: 'ACTION_ONE',
14 | payload: 3
15 | });
16 |
17 | expect(actionTwo(2, 3)).to.deep.equal({
18 | type: 'ACTION_TWO',
19 | payload: 6
20 | });
21 | });
22 |
23 | it('should use identity when config for action is undefined', () => {
24 | const { actionOne, actionTwo } = createActions({
25 | ACTION_ONE: undefined,
26 | ACTION_TWO: undefined
27 | });
28 |
29 | expect(actionOne(1)).to.deep.equal({
30 | type: 'ACTION_ONE',
31 | payload: 1
32 | });
33 |
34 | expect(actionTwo(2)).to.deep.equal({
35 | type: 'ACTION_TWO',
36 | payload: 2
37 | });
38 | });
39 |
40 | it('should use the payload and meta creator provided in config', () => {
41 | const { actionOne, actionTwo } = createActions({
42 | ACTION_ONE: {
43 | payload: (foo, bar) => (foo + bar),
44 | meta: (foo, bar) => foo
45 | },
46 | ACTION_TWO: {
47 | payload: (foo, bar) => (foo * bar),
48 | meta: (foo, bar) => bar
49 | }
50 | });
51 |
52 | expect(actionOne(1, 2)).to.deep.equal({
53 | type: 'ACTION_ONE',
54 | payload: 3,
55 | meta: 1
56 | });
57 |
58 | expect(actionTwo(2, 3)).to.deep.equal({
59 | type: 'ACTION_TWO',
60 | payload: 6,
61 | meta: 3
62 | });
63 | });
64 |
65 | });
--------------------------------------------------------------------------------
/examples/todomvc/src/components/TodoItem.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react'
2 | import PropTypes from 'prop-types'
3 | import classnames from 'classnames'
4 | import TodoTextInput from './TodoTextInput'
5 |
6 | export default class TodoItem extends Component {
7 | static propTypes = {
8 | todo: PropTypes.object.isRequired,
9 | editTodo: PropTypes.func.isRequired,
10 | deleteTodo: PropTypes.func.isRequired,
11 | completeTodo: PropTypes.func.isRequired
12 | }
13 |
14 | state = {
15 | editing: false
16 | }
17 |
18 | handleDoubleClick = () => {
19 | this.setState({ editing: true })
20 | }
21 |
22 | handleSave = (id, text) => {
23 | if (text.length === 0) {
24 | this.props.deleteTodo(id)
25 | } else {
26 | this.props.editTodo(id, text)
27 | }
28 | this.setState({ editing: false })
29 | }
30 |
31 | render() {
32 | const { todo, completeTodo, deleteTodo } = this.props
33 |
34 | let element
35 | if (this.state.editing) {
36 | element = (
37 | this.handleSave(todo.id, text)} />
40 | )
41 | } else {
42 | element = (
43 |
44 | completeTodo(todo.id)} />
48 |
51 |
54 | )
55 | }
56 |
57 | return (
58 |
62 | {element}
63 |
64 | )
65 | }
66 | }
67 |
--------------------------------------------------------------------------------
/examples/todomvc/src/components/Footer.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react'
2 | import PropTypes from 'prop-types'
3 | import classnames from 'classnames'
4 | import { SHOW_ALL, SHOW_COMPLETED, SHOW_ACTIVE } from '../constants/TodoFilters'
5 |
6 | const FILTER_TITLES = {
7 | [SHOW_ALL]: 'All',
8 | [SHOW_ACTIVE]: 'Active',
9 | [SHOW_COMPLETED]: 'Completed'
10 | }
11 |
12 | export default class Footer extends Component {
13 | static propTypes = {
14 | completedCount: PropTypes.number.isRequired,
15 | activeCount: PropTypes.number.isRequired,
16 | filter: PropTypes.string.isRequired,
17 | onClearCompleted: PropTypes.func.isRequired,
18 | onShow: PropTypes.func.isRequired
19 | }
20 |
21 | renderTodoCount() {
22 | const { activeCount } = this.props
23 | const itemWord = activeCount === 1 ? 'item' : 'items'
24 |
25 | return (
26 |
27 | {activeCount || 'No'} {itemWord} left
28 |
29 | )
30 | }
31 |
32 | renderFilterLink(filter) {
33 | const title = FILTER_TITLES[filter]
34 | const { filter: selectedFilter, onShow } = this.props
35 |
36 | return (
37 | onShow(filter)}>
40 | {title}
41 |
42 | )
43 | }
44 |
45 | renderClearButton() {
46 | const { completedCount, onClearCompleted } = this.props
47 | if (completedCount > 0) {
48 | return (
49 |
53 | )
54 | }
55 | }
56 |
57 | render() {
58 | return (
59 |
70 | )
71 | }
72 | }
73 |
--------------------------------------------------------------------------------
/examples/todomvc/src/components/MainSection.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react'
2 | import PropTypes from 'prop-types'
3 | import TodoItem from './TodoItem'
4 | import Footer from './Footer'
5 | import { SHOW_ALL, SHOW_COMPLETED, SHOW_ACTIVE } from '../constants/TodoFilters'
6 |
7 | const TODO_FILTERS = {
8 | [SHOW_ALL]: () => true,
9 | [SHOW_ACTIVE]: todo => !todo.completed,
10 | [SHOW_COMPLETED]: todo => todo.completed
11 | }
12 |
13 | export default class MainSection extends Component {
14 | static propTypes = {
15 | todos: PropTypes.array.isRequired,
16 | actions: PropTypes.object.isRequired
17 | }
18 |
19 | state = { filter: SHOW_ALL }
20 |
21 | handleClearCompleted = () => {
22 | this.props.actions.clearCompleted()
23 | }
24 |
25 | handleShow = filter => {
26 | this.setState({ filter })
27 | }
28 |
29 | renderToggleAll(completedCount) {
30 | const { todos, actions } = this.props
31 | if (todos.length > 0) {
32 | return (
33 |
37 | )
38 | }
39 | }
40 |
41 | renderFooter(completedCount) {
42 | const { todos } = this.props
43 | const { filter } = this.state
44 | const activeCount = todos.length - completedCount
45 |
46 | if (todos.length) {
47 | return (
48 |
53 | )
54 | }
55 | }
56 |
57 | render() {
58 | const { todos, actions } = this.props
59 | const { filter } = this.state
60 |
61 | const filteredTodos = todos.filter(TODO_FILTERS[filter])
62 | const completedCount = todos.reduce((count, todo) =>
63 | todo.completed ? count + 1 : count,
64 | 0
65 | )
66 |
67 | return (
68 |
69 | {this.renderToggleAll(completedCount)}
70 |
71 | {filteredTodos.map(todo =>
72 |
73 | )}
74 |
75 | {this.renderFooter(completedCount)}
76 |
77 | )
78 | }
79 | }
80 |
--------------------------------------------------------------------------------
/test/createReducer.specs.js:
--------------------------------------------------------------------------------
1 | import { expect } from 'chai';
2 | import { createReducer } from '../src';
3 |
4 | const FOO_ACTION= 'FOO',
5 | BAR_ACTION= 'BAR';
6 |
7 | function getHandler(suffix) {
8 | return (state, action) => `${state}|${suffix}-${action.payload}`
9 | }
10 |
11 | describe('createReducer', () => {
12 | it('should create reducer function to handle action', () => {
13 | const reducer = createReducer()
14 | .when(FOO_ACTION, getHandler('FOO'))
15 | .build('initValue');
16 |
17 | expect(typeof reducer).to.equal('function');
18 | expect(reducer()).to.equal('initValue');
19 | expect(reducer('init', {type: FOO_ACTION, payload: 'foo'}))
20 | .to.equal('init|FOO-foo');
21 | });
22 |
23 | it('should create same handler for multi actions', () => {
24 | const reducer = createReducer()
25 | .when([FOO_ACTION, BAR_ACTION], getHandler('MULTI'))
26 | .build();
27 |
28 | expect(reducer('init', {type: FOO_ACTION, payload: 'foo'}))
29 | .to.equal('init|MULTI-foo');
30 |
31 | expect(reducer('init', {type: BAR_ACTION, payload: 'bar'}))
32 | .to.equal('init|MULTI-bar');
33 | });
34 |
35 | it('should create reducer function which can handle async case', () => {
36 | const reducer = createReducer()
37 | .when(FOO_ACTION, getHandler('FOO'))
38 | .done(getHandler('FOO_COMPLETED'))
39 | .failed(getHandler('FOO_FAILED'))
40 | .build();
41 |
42 | expect(reducer('init', {type: `${FOO_ACTION}_COMPLETED`, payload: 'foo completed'}))
43 | .to.equal('init|FOO_COMPLETED-foo completed');
44 |
45 | expect(reducer('init', {type: `${FOO_ACTION}_FAILED`, payload: 'foo failed'}))
46 | .to.equal('init|FOO_FAILED-foo failed');
47 | });
48 |
49 | it('should throw Error when use done or failed without follow when(action, handler)', () => {
50 | const runner = () => {
51 | createReducer()
52 | .done(getHandler('FOO_COMPLETED'))
53 | .failed(getHandler('FOO_FAILED'))
54 | .build()
55 | };
56 | expect(
57 | runner
58 | ).to.throw(
59 | Error,
60 | 'Method "done" & "failed" must follow the "when(action, ?handler)", and "action" should not be an array'
61 | );
62 | });
63 |
64 | it('should throw Error when use done or failed after when([...actions], handler)', () => {
65 | const runner = () => {
66 | createReducer()
67 | .when([FOO_ACTION, BAR_ACTION])
68 | .done(getHandler('FOO_COMPLETED'))
69 | .failed(getHandler('FOO_FAILED'))
70 | .build()
71 | };
72 |
73 | expect(
74 | runner
75 | ).to.throw(
76 | Error,
77 | 'Method "done" & "failed" must follow the "when(action, ?handler)", and "action" should not be an array'
78 | );
79 | });
80 | });
81 |
--------------------------------------------------------------------------------
/examples/todomvc/src/components/TodoTextInput.spec.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { createRenderer } from 'react-test-renderer/shallow';
3 | import TodoTextInput from './TodoTextInput'
4 |
5 | const setup = propOverrides => {
6 | const props = Object.assign({
7 | onSave: jest.fn(),
8 | text: 'Use Redux',
9 | placeholder: 'What needs to be done?',
10 | editing: false,
11 | newTodo: false
12 | }, propOverrides)
13 |
14 | const renderer = createRenderer()
15 |
16 | renderer.render(
17 |
18 | )
19 |
20 | const output = renderer.getRenderOutput()
21 |
22 | return {
23 | props: props,
24 | output: output,
25 | renderer: renderer
26 | }
27 | }
28 |
29 | describe('components', () => {
30 | describe('TodoTextInput', () => {
31 | it('should render correctly', () => {
32 | const { output } = setup()
33 | expect(output.props.placeholder).toEqual('What needs to be done?')
34 | expect(output.props.value).toEqual('Use Redux')
35 | expect(output.props.className).toEqual('')
36 | })
37 |
38 | it('should render correctly when editing=true', () => {
39 | const { output } = setup({ editing: true })
40 | expect(output.props.className).toEqual('edit')
41 | })
42 |
43 | it('should render correctly when newTodo=true', () => {
44 | const { output } = setup({ newTodo: true })
45 | expect(output.props.className).toEqual('new-todo')
46 | })
47 |
48 | it('should update value on change', () => {
49 | const { output, renderer } = setup()
50 | output.props.onChange({ target: { value: 'Use Radox' } })
51 | const updated = renderer.getRenderOutput()
52 | expect(updated.props.value).toEqual('Use Radox')
53 | })
54 |
55 | it('should call onSave on return key press', () => {
56 | const { output, props } = setup()
57 | output.props.onKeyDown({ which: 13, target: { value: 'Use Redux' } })
58 | expect(props.onSave).toBeCalledWith('Use Redux')
59 | })
60 |
61 | it('should reset state on return key press if newTodo', () => {
62 | const { output, renderer } = setup({ newTodo: true })
63 | output.props.onKeyDown({ which: 13, target: { value: 'Use Redux' } })
64 | const updated = renderer.getRenderOutput()
65 | expect(updated.props.value).toEqual('')
66 | })
67 |
68 | it('should call onSave on blur', () => {
69 | const { output, props } = setup()
70 | output.props.onBlur({ target: { value: 'Use Redux' } })
71 | expect(props.onSave).toBeCalledWith('Use Redux')
72 | })
73 |
74 | it('shouldnt call onSave on blur if newTodo', () => {
75 | const { output, props } = setup({ newTodo: true })
76 | output.props.onBlur({ target: { value: 'Use Redux' } })
77 | expect(props.onSave).not.toBeCalled()
78 | })
79 | })
80 | })
81 |
--------------------------------------------------------------------------------
/examples/todomvc/src/components/Footer.spec.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { createRenderer } from 'react-test-renderer/shallow';
3 | import Footer from './Footer'
4 | import { SHOW_ALL, SHOW_ACTIVE } from '../constants/TodoFilters'
5 |
6 | const setup = propOverrides => {
7 | const props = Object.assign({
8 | completedCount: 0,
9 | activeCount: 0,
10 | filter: SHOW_ALL,
11 | onClearCompleted: jest.fn(),
12 | onShow: jest.fn()
13 | }, propOverrides)
14 |
15 | const renderer = createRenderer()
16 | renderer.render()
17 | const output = renderer.getRenderOutput()
18 |
19 | return {
20 | props: props,
21 | output: output
22 | }
23 | }
24 |
25 | const getTextContent = elem => {
26 | const children = Array.isArray(elem.props.children) ?
27 | elem.props.children : [ elem.props.children ]
28 |
29 | return children.reduce((out, child) =>
30 | // Concatenate the text
31 | // Children are either elements or text strings
32 | out + (child.props ? getTextContent(child) : child)
33 | , '')
34 | }
35 |
36 | describe('components', () => {
37 | describe('Footer', () => {
38 | it('should render container', () => {
39 | const { output } = setup()
40 | expect(output.type).toBe('footer')
41 | expect(output.props.className).toBe('footer')
42 | })
43 |
44 | it('should display active count when 0', () => {
45 | const { output } = setup({ activeCount: 0 })
46 | const [ count ] = output.props.children
47 | expect(getTextContent(count)).toBe('No items left')
48 | })
49 |
50 | it('should display active count when above 0', () => {
51 | const { output } = setup({ activeCount: 1 })
52 | const [ count ] = output.props.children
53 | expect(getTextContent(count)).toBe('1 item left')
54 | })
55 |
56 | it('should render filters', () => {
57 | const { output } = setup()
58 | const [ , filters ] = output.props.children
59 | expect(filters.type).toBe('ul')
60 | expect(filters.props.className).toBe('filters')
61 | expect(filters.props.children.length).toBe(3)
62 | filters.props.children.forEach(function checkFilter(filter, i) {
63 | expect(filter.type).toBe('li')
64 | const a = filter.props.children
65 | expect(a.props.className).toBe(i === 0 ? 'selected' : '')
66 | expect(a.props.children).toBe({
67 | 0: 'All',
68 | 1: 'Active',
69 | 2: 'Completed'
70 | }[i])
71 | })
72 | })
73 |
74 | it('should call onShow when a filter is clicked', () => {
75 | const { output, props } = setup()
76 | const [ , filters ] = output.props.children
77 | const filterLink = filters.props.children[1].props.children
78 | filterLink.props.onClick({})
79 | expect(props.onShow).toBeCalledWith(SHOW_ACTIVE)
80 | })
81 |
82 | it('shouldnt show clear button when no completed todos', () => {
83 | const { output } = setup({ completedCount: 0 })
84 | const [ , , clear ] = output.props.children
85 | expect(clear).toBe(undefined)
86 | })
87 |
88 | it('should render clear button when completed todos', () => {
89 | const { output } = setup({ completedCount: 1 })
90 | const [ , , clear ] = output.props.children
91 | expect(clear.type).toBe('button')
92 | expect(clear.props.children).toBe('Clear completed')
93 | })
94 |
95 | it('should call onClearCompleted on clear button click', () => {
96 | const { output, props } = setup({ completedCount: 1 })
97 | const [ , , clear ] = output.props.children
98 | clear.props.onClick({})
99 | expect(props.onClearCompleted).toBeCalled()
100 | })
101 | })
102 | })
103 |
--------------------------------------------------------------------------------
/examples/todomvc/src/components/TodoItem.spec.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { createRenderer } from 'react-test-renderer/shallow';
3 | import TodoItem from './TodoItem'
4 | import TodoTextInput from './TodoTextInput'
5 |
6 | const setup = ( editing = false ) => {
7 | const props = {
8 | todo: {
9 | id: 0,
10 | text: 'Use Redux',
11 | completed: false
12 | },
13 | editTodo: jest.fn(),
14 | deleteTodo: jest.fn(),
15 | completeTodo: jest.fn()
16 | }
17 |
18 | const renderer = createRenderer()
19 |
20 | renderer.render(
21 |
22 | )
23 |
24 | let output = renderer.getRenderOutput()
25 |
26 | if (editing) {
27 | const label = output.props.children.props.children[1]
28 | label.props.onDoubleClick({})
29 | output = renderer.getRenderOutput()
30 | }
31 |
32 | return {
33 | props: props,
34 | output: output,
35 | renderer: renderer
36 | }
37 | }
38 |
39 | describe('components', () => {
40 | describe('TodoItem', () => {
41 | it('initial render', () => {
42 | const { output } = setup()
43 |
44 | expect(output.type).toBe('li')
45 | expect(output.props.className).toBe('')
46 |
47 | const div = output.props.children
48 |
49 | expect(div.type).toBe('div')
50 | expect(div.props.className).toBe('view')
51 |
52 | const [ input, label, button ] = div.props.children
53 |
54 | expect(input.type).toBe('input')
55 | expect(input.props.checked).toBe(false)
56 |
57 | expect(label.type).toBe('label')
58 | expect(label.props.children).toBe('Use Redux')
59 |
60 | expect(button.type).toBe('button')
61 | expect(button.props.className).toBe('destroy')
62 | })
63 |
64 | it('input onChange should call completeTodo', () => {
65 | const { output, props } = setup()
66 | const input = output.props.children.props.children[0]
67 | input.props.onChange({})
68 | expect(props.completeTodo).toBeCalledWith(0)
69 | })
70 |
71 | it('button onClick should call deleteTodo', () => {
72 | const { output, props } = setup()
73 | const button = output.props.children.props.children[2]
74 | button.props.onClick({})
75 | expect(props.deleteTodo).toBeCalledWith(0)
76 | })
77 |
78 | it('label onDoubleClick should put component in edit state', () => {
79 | const { output, renderer } = setup()
80 | const label = output.props.children.props.children[1]
81 | label.props.onDoubleClick({})
82 | const updated = renderer.getRenderOutput()
83 | expect(updated.type).toBe('li')
84 | expect(updated.props.className).toBe('editing')
85 | })
86 |
87 | it('edit state render', () => {
88 | const { output } = setup(true)
89 |
90 | expect(output.type).toBe('li')
91 | expect(output.props.className).toBe('editing')
92 |
93 | const input = output.props.children
94 | expect(input.type).toBe(TodoTextInput)
95 | expect(input.props.text).toBe('Use Redux')
96 | expect(input.props.editing).toBe(true)
97 | })
98 |
99 | it('TodoTextInput onSave should call editTodo', () => {
100 | const { output, props } = setup(true)
101 | output.props.children.props.onSave('Use Redux')
102 | expect(props.editTodo).toBeCalledWith(0, 'Use Redux')
103 | })
104 |
105 | it('TodoTextInput onSave should call deleteTodo if text is empty', () => {
106 | const { output, props } = setup(true)
107 | output.props.children.props.onSave('')
108 | expect(props.deleteTodo).toBeCalledWith(0)
109 | })
110 |
111 | it('TodoTextInput onSave should exit component from edit state', () => {
112 | const { output, renderer } = setup(true)
113 | output.props.children.props.onSave('Use Redux')
114 | const updated = renderer.getRenderOutput()
115 | expect(updated.type).toBe('li')
116 | expect(updated.props.className).toBe('')
117 | })
118 | })
119 | })
120 |
--------------------------------------------------------------------------------
/README_zh_CN.MD:
--------------------------------------------------------------------------------
1 | # redux-action-tools
2 |
3 | 轻量级、全面兼容异步处理的action工具。
4 |
5 | 本项目受`redux-actions`与`redux-promise-thunk`的启发。
6 |
7 | ## 下载
8 | `npm i redux-action-tools`
9 |
10 | ## 使用
11 |
12 | ### createAction(actionName, payloadCreator [, metaCreator])
13 |
14 | 与`redux-actions`的[createAction](https://github.com/acdlite/redux-actions#createactiontype-payloadcreator--identity-metacreator)完全相同。
15 | 本项目单独实现只是为了减少不必要的依赖和体积。
16 |
17 | ### createAsyncAction(actionName, promiseCreator [, metaCreator])
18 |
19 | **此方法依赖[redux-thunk](https://github.com/gaearon/redux-thunk)中间件才能正常工作**.
20 |
21 | 第二个参数promiseCreator必须是一个返回promise对象的方法。使用promise表达异步逻辑是规范、易用的,并且很容易和async/await函数兼容。
22 |
23 | 看下面的例子:
24 |
25 | ```js
26 | //editTodo方法是一个返回thunk的action creator, 注意promiseCreator只能接受一个参数
27 | const editTodo = createAsyncAction('EDIT_TODO', function(todo, dispatch, getState) {
28 | return todoApi.edit(todo); //todoApi.edit() should return a Promise object;
29 | });
30 |
31 | //TodoItem.jsx
32 |
33 | class TodoItem extends Component {
34 | //...
35 | handleEdit(todo) {
36 | dispatch(editTodo(todo));//调用action creator的参数(只能是一个)会传给promiseCreator
37 | }
38 | //...
39 | }
40 | ```
41 |
42 | 第二、三个参数与thunk中的`dispatch`和`getState`相同,方便在异步过程中触发其它的action和获取应用状态。
43 |
44 | > 本质上,`createAsyncAction`返回的是一个创建**thunk**的action creator。借助`redux-thunk`对thunk的处理以实现异步。
45 |
46 | 在整个过程中,如下三个action可能会被触发:
47 |
48 | | type | When | payload | meta.asyncPhase |
49 | | -------- | ----- | :----: | :----: |
50 | | `${actionName}` | promiseCreator调用之前 | promiseCreator的第一个参数 | 'START' |
51 | | `${actionName}_COMPLETED` | promise resolved | value of promise | 'COMPLETED' |
52 | | `${actionName}_FAILED` | promise rejected | reason of promise | 'FAILED' |
53 |
54 | 上表中第一个action是同步的且一定会被触发。第二三个分别对应promise的成功与失败。
55 |
56 | >社区中有一些方案是完全使用meta代表异步action的不同阶段。然而个人认为不同的action应该由type区分,meta只应对action做元数据级的描述。
57 | 这种基于type的区分不仅更加清晰,也更加接近redux的启蒙——Elm中的做法。
58 |
59 | #### 乐观更新
60 | 由于第一个action是同步的,所以实现乐观更新非常容易,这是本项目相对于`redux-promise`的优点。
61 |
62 | #### meta.asyncPhase 与中间件拦截
63 | 本项目使用meta.asyncPhase来表示不同的异步阶段。
64 | 配合中间件针对meta.asyncPhase属性做拦截,可以轻松实现异步请求的全局Loading显示与通用异常处理:
65 |
66 | ```js
67 | import _ from 'lodash'
68 | import { ASYNC_PHASES } from 'redux-action-tools'
69 |
70 | export default function loadingMiddleWare({ dispatch }) {
71 | return next => (action) => {
72 | const asyncPhase = _.get(action, 'meta.asyncPhase');
73 | const omitLoading = _.get(action, 'meta.omitLoading');
74 |
75 | if (asyncPhase && !omitLoading) {
76 | dispatch({
77 | type: asyncPhase === ASYNC_PHASES.START
78 | ? 'ASYNC_STARTED'
79 | : 'ASYNC_ENDED',
80 | payload: {
81 | source: 'ACTION',
82 | action,
83 | },
84 | });
85 | }
86 |
87 | return next(action);
88 | };
89 | }
90 |
91 | ```
92 |
93 | 通过metaCreator你可以自定义meta对象,通知中间件绕过公共处理逻辑:
94 |
95 | ```js
96 | const requestWithoutLoadingSpinner = createAsyncAction(type, promiseCreator, (payload, defaultMeta) => {
97 | return { ...defaultMeta, omitLoading: true };
98 | })
99 | ```
100 |
101 |
102 | ### createReducer
103 |
104 | 使用 *XXX_COMPLETED*, *XXX_FAILED* 来区分action虽然更加清晰,写起来却非常麻烦:
105 | 每个异步action你都需要写三个action type以及对应的reducer,这无疑会带来大量重复、无意义的工作,而这也是`createReducer`可以发挥作用的地方:
106 |
107 |
108 | ```js
109 |
110 | const handler = (state, action) => newState
111 |
112 | const reducer = createReducer()
113 | .when([ACTION_FOO, ACTION_BAR], handlerForBothActions) // 多个action共用一个处理逻辑
114 | .when('BAZ', handler) // handler可用于实现乐观更新,你也可以不传这个handler
115 | .done(handler) // 处理 'BAZ_COMPLETED'
116 | .failed(errorHandler) // 处理 'BAZ_FAILED'
117 | .build(initValue); // 不要忘了调用build函数! 初始值是可选的,但强烈建议你提供初始值
118 | ```
119 |
120 | 上述写法既避免了`switch-case`语句,又对异步action的处理提供了一致的抽象:
121 | 上例中你只需要声明"BAZ"这一个action即可,**没有多余的字符串与action**
122 |
123 | 唯一需要注意的,是`.done`与`.failed`的使用是有限制的——他们必须知道自己属于哪个action。
124 |
125 | 下面两个例子都会抛出异常:
126 |
127 | ```js
128 |
129 | reducer = createReducer()
130 | .done(handler) // throw error here, cuz we don't know which action to handle
131 | .build()
132 |
133 | reducer = createReducer()
134 | .when([A, B])
135 | .done(handler) // throw error here, same reason since we don't know which one you mean
136 |
137 | ```
138 |
139 |
140 |
--------------------------------------------------------------------------------
/examples/todomvc/src/components/MainSection.spec.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { createRenderer } from 'react-test-renderer/shallow';
3 | import MainSection from './MainSection'
4 | import TodoItem from './TodoItem'
5 | import Footer from './Footer'
6 | import { SHOW_ALL, SHOW_COMPLETED } from '../constants/TodoFilters'
7 |
8 | const setup = propOverrides => {
9 | const props = Object.assign({
10 | todos: [
11 | {
12 | text: 'Use Redux',
13 | completed: false,
14 | id: 0
15 | }, {
16 | text: 'Run the tests',
17 | completed: true,
18 | id: 1
19 | }
20 | ],
21 | actions: {
22 | editTodo: jest.fn(),
23 | deleteTodo: jest.fn(),
24 | completeTodo: jest.fn(),
25 | completeAll: jest.fn(),
26 | clearCompleted: jest.fn()
27 | }
28 | }, propOverrides)
29 |
30 | const renderer = createRenderer()
31 | renderer.render()
32 | const output = renderer.getRenderOutput()
33 |
34 | return {
35 | props: props,
36 | output: output,
37 | renderer: renderer
38 | }
39 | }
40 |
41 | describe('components', () => {
42 | describe('MainSection', () => {
43 | it('should render container', () => {
44 | const { output } = setup()
45 | expect(output.type).toBe('section')
46 | expect(output.props.className).toBe('main')
47 | })
48 |
49 | describe('toggle all input', () => {
50 | it('should render', () => {
51 | const { output } = setup()
52 | const [ toggle ] = output.props.children
53 | expect(toggle.type).toBe('input')
54 | expect(toggle.props.type).toBe('checkbox')
55 | expect(toggle.props.checked).toBe(false)
56 | })
57 |
58 | it('should be checked if all todos completed', () => {
59 | const { output } = setup({ todos: [
60 | {
61 | text: 'Use Redux',
62 | completed: true,
63 | id: 0
64 | }
65 | ]
66 | })
67 | const [ toggle ] = output.props.children
68 | expect(toggle.props.checked).toBe(true)
69 | })
70 |
71 | it('should call completeAll on change', () => {
72 | const { output, props } = setup()
73 | const [ toggle ] = output.props.children
74 | toggle.props.onChange({})
75 | expect(props.actions.completeAll).toBeCalled()
76 | })
77 | })
78 |
79 | describe('footer', () => {
80 | it('should render', () => {
81 | const { output } = setup()
82 | const [ , , footer ] = output.props.children
83 | expect(footer.type).toBe(Footer)
84 | expect(footer.props.completedCount).toBe(1)
85 | expect(footer.props.activeCount).toBe(1)
86 | expect(footer.props.filter).toBe(SHOW_ALL)
87 | })
88 |
89 | it('onShow should set the filter', () => {
90 | const { output, renderer } = setup()
91 | const [ , , footer ] = output.props.children
92 | footer.props.onShow(SHOW_COMPLETED)
93 | const updated = renderer.getRenderOutput()
94 | const [ , , updatedFooter ] = updated.props.children
95 | expect(updatedFooter.props.filter).toBe(SHOW_COMPLETED)
96 | })
97 |
98 | it('onClearCompleted should call clearCompleted', () => {
99 | const { output, props } = setup()
100 | const [ , , footer ] = output.props.children
101 | footer.props.onClearCompleted()
102 | expect(props.actions.clearCompleted).toBeCalled()
103 | })
104 | })
105 |
106 | describe('todo list', () => {
107 | it('should render', () => {
108 | const { output, props } = setup()
109 | const [ , list ] = output.props.children
110 | expect(list.type).toBe('ul')
111 | expect(list.props.children.length).toBe(2)
112 | list.props.children.forEach((item, i) => {
113 | expect(item.type).toBe(TodoItem)
114 | expect(item.props.todo).toBe(props.todos[i])
115 | })
116 | })
117 |
118 | it('should filter items', () => {
119 | const { output, renderer, props } = setup()
120 | const [ , , footer ] = output.props.children
121 | footer.props.onShow(SHOW_COMPLETED)
122 | const updated = renderer.getRenderOutput()
123 | const [ , updatedList ] = updated.props.children
124 | expect(updatedList.props.children.length).toBe(1)
125 | expect(updatedList.props.children[0].props.todo).toBe(props.todos[1])
126 | })
127 | })
128 | })
129 | })
130 |
--------------------------------------------------------------------------------
/src/index.js:
--------------------------------------------------------------------------------
1 | import camelCase from 'camelcase';
2 |
3 | const ASYNC_PHASES = {
4 | START: 'START',
5 | COMPLETED: 'COMPLETED',
6 | FAILED: 'FAILED'
7 | };
8 |
9 | const identity = id => id;
10 |
11 | function createAction (type, payloadCreator, metaCreator) {
12 | const finalActionCreator = typeof payloadCreator === 'function'
13 | ? payloadCreator
14 | : identity;
15 | return (...args) => {
16 | const action = {
17 | type
18 | };
19 |
20 | if (args[0] !== undefined && args[0] !== null) {
21 | action.payload = args[0] instanceof Error
22 | ? args[0]
23 | : finalActionCreator(...args);
24 | }
25 |
26 | if (action.payload instanceof Error) {
27 | action.error = true;
28 | }
29 |
30 | if (typeof metaCreator === 'function') {
31 | action.meta = metaCreator(...args);
32 | }
33 |
34 | return action;
35 | };
36 | }
37 |
38 | function createActions(actionConfigs) {
39 | const actions = {};
40 | for (let type in actionConfigs) { //use for-in instead of reduce
41 | if (Object.prototype.hasOwnProperty.call(actionConfigs, type)) {
42 | const config = actionConfigs[type];
43 | const actionName = camelCase(type);
44 |
45 | if (typeof config === 'function') {
46 | actions[actionName] = createAction(type, config);
47 | } else {
48 | const { payload, meta } = config || {};
49 | actions[actionName] = createAction(type, payload, meta)
50 | }
51 | }
52 | }
53 | return actions;
54 | }
55 |
56 | function getAsyncMeta(metaCreator, payload, asyncPhase) {
57 | const asyncMetaCreator = typeof metaCreator === 'function'
58 | ? metaCreator
59 | : (payload, defaultMeta) => ({...defaultMeta, ...metaCreator});
60 |
61 | return asyncMetaCreator(payload, {asyncPhase});
62 | }
63 |
64 | function createAsyncAction(type, payloadCreator, metaCreator) {
65 | const startAction = createAction(type, identity, (_, meta) => meta);
66 | const completeAction = createAction(`${type}_${ASYNC_PHASES.COMPLETED}`, identity, (_, meta) => meta);
67 | const failedAction = createAction(`${type}_${ASYNC_PHASES.FAILED}`, identity, (_, meta) => meta);
68 |
69 | return syncPayload => {
70 | return (dispatch, getState) => {
71 | dispatch(
72 | startAction(syncPayload, getAsyncMeta(metaCreator, syncPayload, ASYNC_PHASES.START))
73 | );
74 |
75 | const promise = payloadCreator(syncPayload, dispatch, getState);
76 |
77 | invariant(
78 | isPromise(promise),
79 | 'payloadCreator should return a promise'
80 | );
81 |
82 | return promise.then(value => {
83 | dispatch(
84 | completeAction(value, getAsyncMeta(metaCreator, value, ASYNC_PHASES.COMPLETED))
85 | );
86 | return value;
87 | }, e => {
88 | dispatch(
89 | failedAction(e, getAsyncMeta(metaCreator, e, ASYNC_PHASES.FAILED))
90 | );
91 | return Promise.reject(e);
92 | });
93 | }
94 | };
95 | }
96 |
97 | function isPromise(object) {
98 | return object && typeof object.then === 'function';
99 | }
100 |
101 | function invariant(condition, message) {
102 | if (!condition) {
103 | throw new Error(message);
104 | }
105 | }
106 |
107 | function ActionHandler() {
108 | this.currentAction = undefined;
109 | this.handlers = {};
110 | }
111 |
112 | ActionHandler.prototype = {
113 | when(actionType, handler) {
114 | if (Array.isArray(actionType)) {
115 | this.currentAction = undefined;
116 | actionType.forEach((type) => {
117 | this.handlers[type] = handler;
118 | })
119 | } else {
120 | this.currentAction = actionType;
121 | this.handlers[actionType] = handler;
122 | }
123 | return this;
124 | },
125 | done(handler) {
126 | this._guardDoneAndFailed();
127 | this.handlers[`${this.currentAction}_${ASYNC_PHASES.COMPLETED}`] = handler;
128 | return this;
129 | },
130 | failed(handler) {
131 | this._guardDoneAndFailed();
132 | this.handlers[`${this.currentAction}_${ASYNC_PHASES.FAILED}`] = handler;
133 | return this;
134 | },
135 | build(initValue = null) {
136 | return (state = initValue, action) => {
137 | const handler = action ? this.handlers[action.type] : undefined;
138 |
139 | if (typeof handler === 'function') {
140 | return handler(state, action);
141 | }
142 |
143 | return state;
144 | };
145 | },
146 | _guardDoneAndFailed() {
147 | if (!this.currentAction) {
148 | throw new Error(
149 | 'Method "done" & "failed" must follow the "when(action, ?handler)", and "action" should not be an array'
150 | );
151 | }
152 | }
153 | };
154 |
155 | function createReducer() {
156 | return new ActionHandler();
157 | }
158 |
159 | export {
160 | createAction,
161 | createActions,
162 | createAsyncAction,
163 | createReducer,
164 | ASYNC_PHASES
165 | }
--------------------------------------------------------------------------------
/test/createAction.specs.js:
--------------------------------------------------------------------------------
1 | import { expect } from 'chai';
2 | import { createAction } from '../src/index';
3 | import { isFSA } from 'flux-standard-action';
4 |
5 |
6 | describe('createAction()', () => {
7 | describe('resulting action creator', () => {
8 | const type = 'TYPE';
9 |
10 | /*
11 | * The first 8 tests below comes from the redux-actions: https://github.com/acdlite/redux-actions/blob/master/src/createAction.js
12 | * */
13 |
14 | it('returns a valid FSA', () => {
15 | const actionCreator = createAction(type, b => b);
16 | const foobar = { foo: 'bar' };
17 | const action = actionCreator(foobar);
18 | expect(isFSA(action)).to.be.true;
19 | });
20 |
21 | it('uses return value as payload', () => {
22 | const actionCreator = createAction(type, b => b);
23 | const foobar = { foo: 'bar' };
24 | const action = actionCreator(foobar);
25 | expect(action).to.deep.equal({
26 | type,
27 | payload: foobar
28 | });
29 | });
30 |
31 | it('uses identity function if payloadCreator is not a function', () => {
32 | const actionCreator = createAction(type);
33 | const foobar = { foo: 'bar' };
34 | const action = actionCreator(foobar);
35 | expect(action).to.deep.equal({
36 | type,
37 | payload: foobar
38 | });
39 | expect(isFSA(action)).to.be.true;
40 | });
41 |
42 | it('accepts a second parameter for adding meta to object', () => {
43 | const actionCreator = createAction(type, null, ({ cid }) => ({ cid }));
44 | const foobar = { foo: 'bar', cid: 5 };
45 | const action = actionCreator(foobar);
46 | expect(action).to.deep.equal({
47 | type,
48 | payload: foobar,
49 | meta: {
50 | cid: 5
51 | }
52 | });
53 | expect(isFSA(action)).to.be.true;
54 | });
55 |
56 | it('sets error to true if payload is an Error object', () => {
57 | const actionCreator = createAction(type);
58 | const errObj = new TypeError('this is an error');
59 |
60 | const errAction = actionCreator(errObj);
61 | expect(errAction).to.deep.equal({
62 | type,
63 | payload: errObj,
64 | error: true
65 | });
66 | expect(isFSA(errAction)).to.be.true;
67 |
68 | const foobar = { foo: 'bar', cid: 5 };
69 | const noErrAction = actionCreator(foobar);
70 | expect(noErrAction).to.deep.equal({
71 | type,
72 | payload: foobar
73 | });
74 | expect(isFSA(noErrAction)).to.be.true;
75 | });
76 |
77 | it('sets error to true if payload is an Error object and meta is provided', () => {
78 | const actionCreator = createAction(type, null, (_, meta) => meta);
79 | const errObj = new TypeError('this is an error');
80 |
81 | const errAction = actionCreator(errObj, { foo: 'bar' });
82 | expect(errAction).to.deep.equal({
83 | type,
84 | payload: errObj,
85 | error: true,
86 | meta: { foo: 'bar' }
87 | });
88 | });
89 |
90 | it('sets payload only when defined', () => {
91 | const action = createAction(type)();
92 | expect(action).to.deep.equal({
93 | type
94 | });
95 |
96 | const explictUndefinedAction = createAction(type)(undefined);
97 | expect(explictUndefinedAction).to.deep.equal({
98 | type
99 | });
100 |
101 | const explictNullAction = createAction(type)(null);
102 | expect(explictNullAction).to.deep.equal({
103 | type
104 | });
105 |
106 | const baz = '1';
107 | const actionCreator = createAction(type, null, () => ({ bar: baz }));
108 | expect(actionCreator()).to.deep.equal({
109 | type,
110 | meta: {
111 | bar: '1'
112 | }
113 | });
114 |
115 | const validPayload = [false, 0, ''];
116 | for (let i = 0; i < validPayload.length; i++) {
117 | const validValue = validPayload[i];
118 | const expectPayload = createAction(type)(validValue);
119 | expect(expectPayload).to.deep.equal({
120 | type,
121 | payload: validValue
122 | });
123 | }
124 | });
125 |
126 | it('bypasses payloadCreator if payload is an Error object', () => {
127 | const actionCreator = createAction(type, () => 'not this', (_, meta) => meta);
128 | const errObj = new TypeError('this is an error');
129 |
130 | const errAction = actionCreator(errObj, { foo: 'bar' });
131 | expect(errAction).to.deep.equal({
132 | type,
133 | payload: errObj,
134 | error: true,
135 | meta: { foo: 'bar' }
136 | });
137 | });
138 |
139 | /*
140 | * Customized tests below
141 | * */
142 |
143 | it('should set error to true if payloadCreator returns an Error', () => {
144 | const errObj = new TypeError('this is an error');
145 | const actionCreator = createAction(type, () => errObj);
146 |
147 | const errAction = actionCreator({});
148 | expect(errAction).to.deep.equal({
149 | type,
150 | payload: errObj,
151 | error: true
152 | });
153 | });
154 |
155 | it('should accept multi params', () => {
156 | const actionCreator = createAction(type, (foo, bar) => (foo + bar));
157 | expect(actionCreator(1, 2)).to.deep.equal({
158 | type,
159 | payload: 3
160 | });
161 | });
162 |
163 | });
164 | });
165 |
166 |
--------------------------------------------------------------------------------
/README.MD:
--------------------------------------------------------------------------------
1 | # redux-action-tools
2 |
3 | [](https://travis-ci.org/kpaxqin/redux-action-tools)
4 | [](https://coveralls.io/github/kpaxqin/redux-action-tools?branch=master)
5 |
6 | Light-weight action tools with async and optimistic update support.
7 |
8 | > This project is inspired by `redux-actions` and `redux-promise-thunk`
9 |
10 | [中文文档](https://github.com/kpaxqin/redux-action-tools/blob/master/README_zh_CN.MD)
11 |
12 | ## Install
13 | `npm i redux-action-tools`
14 |
15 | ## Usage and APIs
16 |
17 | ### createAction(actionName, payloadCreator [, metaCreator])
18 |
19 | Same as [createAction](https://github.com/acdlite/redux-actions#createactiontype-payloadcreator--identity-metacreator) in `redux-actions`, we write our own for less dependency and fix some defects.
20 |
21 | ### createAsyncAction(actionName, promiseCreator [, metaCreator])
22 |
23 | **This function is relly on `redux-thunk`**.
24 |
25 | The `createAction` returns an action creator for pain action object, While `createAsyncAction` will return an action creator for `thunk`.
26 |
27 | #### promiseCreator(syncPayload, dispatch, getState)
28 |
29 | *This function should return a promise object.*
30 |
31 | The action creator returned by `createAsyncAction` receives one parameter -- the sync payload, we will dispatch a sync action same as `createAction`. And then, call the promiseCreator for the async behaviour, dispatch action for the result of it.
32 |
33 | The `dispatch` and `getState` is the same as a normal thunk action, enables you to customize your async behaviour, even dispatch other actions.
34 |
35 | Simple example below:
36 |
37 | ```js
38 | const asyncAction = createAsyncAction('ASYNC', function (syncPayload, dispatch, getState) {
39 | const user = getState().user;
40 |
41 | return asyncApi(syncPayload, user)
42 | .then((result) => {
43 | dispatch(otherAction(result));
44 |
45 | return result; // don't forget to return a result.
46 | })
47 | });
48 |
49 | //In your component
50 |
51 | class Foo extends Component {
52 | //...
53 | doAsync() {
54 | // You don't need dispatch here if you're using bindActionCreators
55 | dispatch(asyncAction(syncPayload));
56 | }
57 | }
58 | ```
59 |
60 | The action creator returned by `createAsyncAction` receives one parameter -- the sync payload, we will pass it to the `promiseCreator` as first parameter.
61 |
62 | The `dispatch` and `getState` is the same as a normal [thunk](https://github.com/gaearon/redux-thunk) action, enables you to customize your async behaviour, even dispatch other actions.
63 |
64 | After you dispatch the async action, following flux standard action will been triggered:
65 |
66 | | type | When | payload | meta.asyncPhase |
67 | | -------- | ----- | :----: | :----: |
68 | | `${actionName}` | before promiseCreator been called | sync payload | 'START' |
69 | | `${actionName}_COMPLETED` | promise resolved | value of promise | 'COMPLETED' |
70 | | `${actionName}_FAILED` | promise rejected | reason of promise | 'FAILED' |
71 |
72 |
73 | > Idea here is that we should use different type, rather than just meta, to identity different actions during an async process. This will be more clear and closer to what we do in`Elm`
74 |
75 | #### Optimistic update
76 |
77 | Since the first action will be triggered before async behaviour, its easy to support optimistic update.
78 |
79 | #### meta.asyncPhase and middleware
80 |
81 | We use `meta.asyncPhase` to identity different phases.
82 | You can use it with middleware to handle features like global loading spinner or common error handler:
83 |
84 | ```js
85 | import _ from 'lodash'
86 | import { ASYNC_PHASES } from 'redux-action-tools'
87 |
88 | export default function loadingMiddleWare({ dispatch }) {
89 | return next => (action) => {
90 | const asyncPhase = _.get(action, 'meta.asyncPhase');
91 | const omitLoading = _.get(action, 'meta.omitLoading');
92 |
93 | if (asyncPhase && !omitLoading) {
94 | dispatch({
95 | type: asyncPhase === ASYNC_PHASES.START
96 | ? 'ASYNC_STARTED'
97 | : 'ASYNC_ENDED',
98 | payload: {
99 | source: 'ACTION',
100 | action,
101 | },
102 | });
103 | }
104 |
105 | return next(action);
106 | };
107 | }
108 |
109 | ```
110 |
111 | And with metaCreator, you can change the meta object and skip the common process:
112 |
113 | ```js
114 | const requestWithoutLoadingSpinner = createAsyncAction(type, promiseCreator, (payload, defaultMeta) => {
115 | return { ...defaultMeta, omitLoading: true };
116 | })
117 | ```
118 |
119 |
120 | ### createReducer
121 |
122 | But, writing things like *XXX_COMPLETED*, *XXX_FAILED* is awful !!
123 |
124 | And this is why we build the `createReducer`!
125 |
126 | ```js
127 |
128 | const handler = (state, action) => newState
129 |
130 | const reducer = createReducer()
131 | .when([ACTION_FOO, ACTION_BAR], handlerForBothActions) // share handler for multi actions
132 | .when('BAZ', handler) // optimistic update here if you need
133 | .done(handler) // handle 'BAZ_COMPLETED'
134 | .failed(errorHandler) // handle 'BAZ_FAILED'
135 | .build(initValue); // Don't forget 'build()' !
136 |
137 |
138 | const reducer = createReducer()
139 | .when(FOO) // no optimistic update here, just declare the parent action for .done & .failed
140 | .done(handler) //
141 | .build()
142 | ```
143 |
144 | With `createReducer`, we can skip the switch-case statement which lots of people don't like it.
145 | And more important, we provide a common and semantic way to handle the async behaviour.
146 |
147 | However, there are some limitations you should know when you use `.done` and `.failed`:
148 |
149 | ```js
150 |
151 | reducer = createReducer()
152 | .done(handler) // throw error here, cuz we don't know which action to handle
153 | .build()
154 |
155 | reducer = createReducer()
156 | .when([A, B])
157 | .done(handler) // throw error here, same reason since we don't know which one you mean
158 |
159 | ```
160 |
161 |
162 |
--------------------------------------------------------------------------------
/examples/todomvc/src/reducers/todos.spec.js:
--------------------------------------------------------------------------------
1 | import todos from './todos'
2 | import * as types from '../constants/ActionTypes'
3 |
4 | describe('todos reducer', () => {
5 | it('should handle initial state', () => {
6 | expect(
7 | todos(undefined, {})
8 | ).toEqual([
9 | {
10 | text: 'Use Redux',
11 | completed: false,
12 | id: 0
13 | }
14 | ])
15 | })
16 |
17 | it('should handle ADD_TODO', () => {
18 | expect(
19 | todos([], {
20 | type: types.ADD_TODO,
21 | text: 'Run the tests'
22 | })
23 | ).toEqual([
24 | {
25 | text: 'Run the tests',
26 | completed: false,
27 | id: 0
28 | }
29 | ])
30 |
31 | expect(
32 | todos([
33 | {
34 | text: 'Use Redux',
35 | completed: false,
36 | id: 0
37 | }
38 | ], {
39 | type: types.ADD_TODO,
40 | text: 'Run the tests'
41 | })
42 | ).toEqual([
43 | {
44 | text: 'Run the tests',
45 | completed: false,
46 | id: 1
47 | }, {
48 | text: 'Use Redux',
49 | completed: false,
50 | id: 0
51 | }
52 | ])
53 |
54 | expect(
55 | todos([
56 | {
57 | text: 'Run the tests',
58 | completed: false,
59 | id: 1
60 | }, {
61 | text: 'Use Redux',
62 | completed: false,
63 | id: 0
64 | }
65 | ], {
66 | type: types.ADD_TODO,
67 | text: 'Fix the tests'
68 | })
69 | ).toEqual([
70 | {
71 | text: 'Fix the tests',
72 | completed: false,
73 | id: 2
74 | }, {
75 | text: 'Run the tests',
76 | completed: false,
77 | id: 1
78 | }, {
79 | text: 'Use Redux',
80 | completed: false,
81 | id: 0
82 | }
83 | ])
84 | })
85 |
86 | it('should handle DELETE_TODO', () => {
87 | expect(
88 | todos([
89 | {
90 | text: 'Run the tests',
91 | completed: false,
92 | id: 1
93 | }, {
94 | text: 'Use Redux',
95 | completed: false,
96 | id: 0
97 | }
98 | ], {
99 | type: types.DELETE_TODO,
100 | id: 1
101 | })
102 | ).toEqual([
103 | {
104 | text: 'Use Redux',
105 | completed: false,
106 | id: 0
107 | }
108 | ])
109 | })
110 |
111 | it('should handle EDIT_TODO', () => {
112 | expect(
113 | todos([
114 | {
115 | text: 'Run the tests',
116 | completed: false,
117 | id: 1
118 | }, {
119 | text: 'Use Redux',
120 | completed: false,
121 | id: 0
122 | }
123 | ], {
124 | type: types.EDIT_TODO,
125 | text: 'Fix the tests',
126 | id: 1
127 | })
128 | ).toEqual([
129 | {
130 | text: 'Fix the tests',
131 | completed: false,
132 | id: 1
133 | }, {
134 | text: 'Use Redux',
135 | completed: false,
136 | id: 0
137 | }
138 | ])
139 | })
140 |
141 | it('should handle COMPLETE_TODO', () => {
142 | expect(
143 | todos([
144 | {
145 | text: 'Run the tests',
146 | completed: false,
147 | id: 1
148 | }, {
149 | text: 'Use Redux',
150 | completed: false,
151 | id: 0
152 | }
153 | ], {
154 | type: types.COMPLETE_TODO,
155 | id: 1
156 | })
157 | ).toEqual([
158 | {
159 | text: 'Run the tests',
160 | completed: true,
161 | id: 1
162 | }, {
163 | text: 'Use Redux',
164 | completed: false,
165 | id: 0
166 | }
167 | ])
168 | })
169 |
170 | it('should handle COMPLETE_ALL', () => {
171 | expect(
172 | todos([
173 | {
174 | text: 'Run the tests',
175 | completed: true,
176 | id: 1
177 | }, {
178 | text: 'Use Redux',
179 | completed: false,
180 | id: 0
181 | }
182 | ], {
183 | type: types.COMPLETE_ALL
184 | })
185 | ).toEqual([
186 | {
187 | text: 'Run the tests',
188 | completed: true,
189 | id: 1
190 | }, {
191 | text: 'Use Redux',
192 | completed: true,
193 | id: 0
194 | }
195 | ])
196 |
197 | // Unmark if all todos are currently completed
198 | expect(
199 | todos([
200 | {
201 | text: 'Run the tests',
202 | completed: true,
203 | id: 1
204 | }, {
205 | text: 'Use Redux',
206 | completed: true,
207 | id: 0
208 | }
209 | ], {
210 | type: types.COMPLETE_ALL
211 | })
212 | ).toEqual([
213 | {
214 | text: 'Run the tests',
215 | completed: false,
216 | id: 1
217 | }, {
218 | text: 'Use Redux',
219 | completed: false,
220 | id: 0
221 | }
222 | ])
223 | })
224 |
225 | it('should handle CLEAR_COMPLETED', () => {
226 | expect(
227 | todos([
228 | {
229 | text: 'Run the tests',
230 | completed: true,
231 | id: 1
232 | }, {
233 | text: 'Use Redux',
234 | completed: false,
235 | id: 0
236 | }
237 | ], {
238 | type: types.CLEAR_COMPLETED
239 | })
240 | ).toEqual([
241 | {
242 | text: 'Use Redux',
243 | completed: false,
244 | id: 0
245 | }
246 | ])
247 | })
248 |
249 | it('should not generate duplicate ids after CLEAR_COMPLETED', () => {
250 | expect(
251 | [
252 | {
253 | type: types.COMPLETE_TODO,
254 | id: 0
255 | }, {
256 | type: types.CLEAR_COMPLETED
257 | }, {
258 | type: types.ADD_TODO,
259 | text: 'Write more tests'
260 | }
261 | ].reduce(todos, [
262 | {
263 | id: 0,
264 | completed: false,
265 | text: 'Use Redux'
266 | }, {
267 | id: 1,
268 | completed: false,
269 | text: 'Write tests'
270 | }
271 | ])
272 | ).toEqual([
273 | {
274 | text: 'Write more tests',
275 | completed: false,
276 | id: 2
277 | }, {
278 | text: 'Write tests',
279 | completed: false,
280 | id: 1
281 | }
282 | ])
283 | })
284 | })
285 |
--------------------------------------------------------------------------------
/test/createAsyncAction.specs.js:
--------------------------------------------------------------------------------
1 | import { expect } from 'chai';
2 | import sinon from 'sinon';
3 | import { isFSA } from 'flux-standard-action';
4 | import { createAsyncAction, ASYNC_PHASES } from '../src/index';
5 |
6 | describe('createAsyncAction', () => {
7 | let dispatch, getState;
8 | const type = "ACTION";
9 |
10 | function invokeThunk (thunk) {
11 | return thunk(dispatch, getState);
12 | }
13 |
14 | function setDispatchForAsyncCase(assertion, done) {
15 | dispatch = sinon.spy(function(action) {
16 | try {
17 | if (dispatch.callCount === 2) { //skip the first call of dispatch
18 | assertion(action);
19 |
20 | done();
21 | }
22 | } catch(e) {
23 | done(e)
24 | }
25 | });
26 | }
27 |
28 | context('when action triggered', () => {
29 | beforeEach(() => {
30 | dispatch = sinon.spy();
31 | getState = sinon.spy();
32 | });
33 |
34 | it('dispatch a FSA action immediately when action triggered', () => {
35 | const actionCreator = createAsyncAction(type, createPromisePayload, {foo: 1});
36 | const syncPayload = {};
37 | const thunk = actionCreator(syncPayload);
38 | invokeThunk(thunk);
39 |
40 | const action = dispatch.args[0][0];
41 |
42 | expect(isFSA(action)).to.be.true;
43 | expect(action.type).to.equal(type);
44 | expect(action.payload).to.equal(syncPayload);
45 | expect(action.meta).to.deep.equal({
46 | asyncPhase: ASYNC_PHASES.START,
47 | foo: 1
48 | })
49 | });
50 |
51 | it('dispatch a FSA action immediately when action triggered and metaCreator is provided', () => {
52 | const actionCreator = createAsyncAction(type, createPromisePayload, (payload, meta) => ({...payload, ...meta}));
53 | const syncPayload = {foo: 2};
54 | const thunk = actionCreator(syncPayload);
55 | invokeThunk(thunk);
56 |
57 | const action = dispatch.args[0][0];
58 |
59 | expect(isFSA(action)).to.be.true;
60 | expect(action.type).to.equal(type);
61 | expect(action.payload).to.equal(syncPayload);
62 | expect(action.meta).to.deep.equal({
63 | asyncPhase: ASYNC_PHASES.START,
64 | foo: 2
65 | })
66 | });
67 |
68 | it('should call promiseCreator with dispatch and getState as second & third arguments', () => {
69 | const actionCreator = createAsyncAction(type, (payload, second, third) => {
70 | expect(second).to.equal(dispatch);
71 | expect(third).to.equal(getState);
72 | return Promise.resolve(payload);
73 | });
74 |
75 | const syncPayload = {foo: 2};
76 | const thunk = actionCreator(syncPayload);
77 | invokeThunk(thunk);
78 | });
79 |
80 | it('should throw error if payloadCreator haven\'t return a promise', () => {
81 | expect(() => {
82 | const actionCreator = createAsyncAction(type, () => {
83 | return 'not a promise'
84 | });
85 |
86 | invokeThunk(actionCreator());
87 | }).to.throw(Error, 'payloadCreator should return a promise')
88 | });
89 |
90 | it('should return a promise object for invoke', () => {
91 | const actionCreator = createAsyncAction(type, (payload) => {
92 | return Promise.resolve(payload)
93 | });
94 |
95 | const result = invokeThunk(actionCreator());
96 |
97 | expect(typeof result.then).to.be.equal('function');
98 | });
99 |
100 | });
101 |
102 | context('when promise resolved', () => {
103 | it('dispatch a FSA action with resolved value', (done) => {
104 | const actionCreator = createAsyncAction(type, createPromisePayload);
105 | const syncPayload = {foo: 2};
106 | const thunk = actionCreator(syncPayload);
107 |
108 | setDispatchForAsyncCase((action) => {
109 | expect(isFSA(action)).to.be.true;
110 | expect(action.payload).to.deep.equal({
111 | bar: 1,
112 | foo: 2
113 | });
114 | expect(action.meta).to.deep.equal({
115 | asyncPhase: ASYNC_PHASES.COMPLETED
116 | })
117 | }, done);
118 |
119 | invokeThunk(thunk);
120 | });
121 |
122 | it('dispatch a FSA action with resolved value when metaCreator is provided', (done) => {
123 | const actionCreator = createAsyncAction(type, createPromisePayload, (payload, meta) => ({...payload, ...meta}));
124 | const syncPayload = {foo: 2};
125 | const thunk = actionCreator(syncPayload);
126 |
127 | setDispatchForAsyncCase((action) => {
128 | expect(isFSA(action)).to.be.true;
129 | expect(action.payload).to.deep.equal({
130 | bar: 1,
131 | foo: 2
132 | });
133 | expect(action.meta).to.deep.equal({
134 | asyncPhase: ASYNC_PHASES.COMPLETED,
135 | bar: 1,
136 | foo: 2
137 | })
138 | }, done);
139 |
140 | invokeThunk(thunk);
141 | });
142 |
143 | it('resolve the promise returned by invoke', done => {
144 | dispatch = sinon.spy();
145 |
146 | const actionCreator = createAsyncAction(type, (payload) => {
147 | return Promise.resolve(payload)
148 | });
149 |
150 | const payload = {};
151 |
152 | const result = invokeThunk(actionCreator(payload));
153 |
154 | result.then((result) => {
155 | expect(result).to.equal(payload);
156 | done();
157 | }).catch(e => done(e));
158 | })
159 | });
160 |
161 | context('when promise rejected', () => {
162 | it('dispatch a FSA action with rejected error', (done) => {
163 | const actionCreator = createAsyncAction(type, createPromisePayload);
164 | const syncPayload = {error: true, msg: 'oops'};
165 | const thunk = actionCreator(syncPayload);
166 |
167 | setDispatchForAsyncCase((action) => {
168 | expect(isFSA(action)).to.be.true;
169 | expect(action.error).to.be.true;
170 | expect(action.payload).to.deep.equal(new Error(syncPayload.msg));
171 | expect(action.meta).to.deep.equal({
172 | asyncPhase: ASYNC_PHASES.FAILED
173 | })
174 | }, done);
175 |
176 | invokeThunk(thunk);
177 | });
178 |
179 | it('dispatch a FSA action with rejected error when metaCreator is provided', (done) => {
180 | const actionCreator = createAsyncAction(type, createPromisePayload, (payload, meta) => ({errorMsg: payload.message, ...meta}));
181 | const syncPayload = {error: true, msg: 'oops'};
182 | const thunk = actionCreator(syncPayload);
183 |
184 | setDispatchForAsyncCase((action) => {
185 | expect(isFSA(action)).to.be.true;
186 | expect(action.error).to.be.true;
187 | expect(action.payload).to.deep.equal(new Error(syncPayload.msg));
188 | expect(action.meta).to.deep.equal({
189 | asyncPhase: ASYNC_PHASES.FAILED,
190 | errorMsg: 'oops'
191 | });
192 | }, done);
193 |
194 | invokeThunk(thunk);
195 | });
196 |
197 | it('reject the promise returned by invoke', done => {
198 | dispatch = sinon.spy();
199 |
200 | const actionCreator = createAsyncAction(type, (payload) => {
201 | return Promise.reject(new Error('rejected'))
202 | });
203 |
204 | const payload = {};
205 |
206 | const result = invokeThunk(actionCreator(payload));
207 |
208 | result.then(() => {
209 | done(new Error('promise should not resolved'));
210 | }, e => {
211 | expect(e).to.be.instanceOf(Error);
212 | expect(e.message).to.equal('rejected');
213 | done();
214 | }).catch(e => done(e));
215 | })
216 | });
217 |
218 | context('when error occurs while dispatching the action for promise resolved', () => {
219 | it('should not call dispatch for the error', (done) => {
220 | const actionCreator = createAsyncAction(type, createPromisePayload);
221 | const syncPayload = {foo: 1};
222 | const thunk = actionCreator(syncPayload);
223 |
224 | dispatch = sinon.spy(function() {
225 | if (dispatch.callCount === 2) { // throw error for the promise resolve action
226 | process.nextTick(() => {
227 | try{
228 | expect(dispatch.callCount).to.equal(2);
229 | done();
230 | } catch (e) {
231 | done(e);
232 | }
233 | });
234 |
235 | throw new Error('dispatch error')
236 | }
237 | });
238 |
239 | invokeThunk(thunk);
240 | });
241 | })
242 | });
243 |
244 | function createPromisePayload(result) {
245 | return new Promise(function(resolve, reject) {
246 | setTimeout(function() {
247 | if (result.error) {
248 | reject(new Error(result.msg));
249 | } else {
250 | resolve({
251 | ...result,
252 | bar: 1
253 | })
254 | }
255 | });
256 | });
257 | }
--------------------------------------------------------------------------------