Repository: vigetlabs/microcosm
Pull request #286 (2017-04-05 19:23:43)

Generators define sequences of actions
Often times we need to dispatch multiple actions in sequential order. For example, what if we want to ask the user to confirm their action before deleting a record?

This can be accomplished by using a generator:

```javascript
function ask (message) {
  return action => {
    if (confirm(message)) {
      action.resolve()
    } else {
      action.reject()
    }
  }
}

function deleteUser (id) {
  return fetch.delete('/users/${id}').then(response => response.json())
}

function confirmAndDelete (user) {
  return function * (send) {
    yield send(ask, `Are you sure you want to delete ${user.name}?`)
    yield send(deleteUser, user.id)
  }
}
```

Each `yield` in the generator processes sequentially. A parent action is returned from `repo.push()` to represent the entire sequence. If any action is cancelled or rejected along the way, the parent action is rejected or cancelled with the same payload.

When all steps of the generator complete, the payload of the parent action will be the resolved payload of the final action.


Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index c2ab7eb4..33024e4d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,10 @@
 # Changelog
 
+## 12.6.0-beta
+
+- Actions may now create workflows of sequential actions using
+  generators. See `./docs/api/actions` for more information
+
 ## 12.5.0
 
 - Added a `defaults` static to Microcosm that passes default options
diff --git a/docs/api/actions.md b/docs/api/actions.md
index 54350213..6303ee96 100644
--- a/docs/api/actions.md
+++ b/docs/api/actions.md
@@ -47,7 +47,7 @@ on how you author action creators.
 
 ## Writing Action Creators
 
-There are three ways to write action creators in Microcosm, all of
+There are four ways to write action creators in Microcosm, all of
 which relate to the value returned from functions passed into `repo.push()`.
 
 ### Return a primitive value
@@ -104,6 +104,45 @@ function readPlanets () {
 repo.push(readPlanets)
 ```
 
+### Return a generator
+
+Often times we need to dispatch multiple actions in sequential
+order. For example, what if we want to ask the user to confirm their
+action before deleting a record?
+
+This can be accomplished by using a generator:
+
+```javascript
+function ask (message) {
+  return action => {
+    if (confirm(message)) {
+      action.resolve()
+    } else {
+      action.reject()
+    }
+  }
+}
+
+function deleteUser (id) {
+  return fetch.delete('/users/${id}').then(response => response.json())
+}
+
+function confirmAndDelete (user) {
+  return function * (send) {
+    yield send(ask, `Are you sure you want to delete ${user.name}?`)
+    yield send(deleteUser, user.id)
+  }
+}
+```
+
+Each `yield` in the generator processes sequentially. A parent action
+is returned from `repo.push()` to represent the entire sequence. If
+any action is cancelled or rejected along the way, the parent action
+is rejected or cancelled with the same payload.
+
+When all steps of the generator complete, the payload of the parent
+action will be the resolved payload of the final action.
+
 ## Dispatching to Domains
 
 One of the differences between Microcosm and other Flux
diff --git a/src/addons/jest-matchers.js b/src/addons/jest-matchers.js
index 4e58164e..6306e0aa 100644
--- a/src/addons/jest-matchers.js
+++ b/src/addons/jest-matchers.js
@@ -51,7 +51,7 @@ expect.extend({
     let actual = get(repo.state, key)
 
     if (arguments.length > 2) {
-      pass = actual === value
+      pass = JSON.stringify(actual) === JSON.stringify(value)
     } else {
       pass = actual !== undefined
     }
@@ -62,8 +62,8 @@ expect.extend({
     return {
       pass: pass,
       message: () => {
-        return `Expected '${path}' in repo.state ${operator} be ${JSON.stringify(value)} ` +
-               `but it is ${actual}.`
+        return `Expected '${path}' in repo.state ${operator} be ${this.utils.printExpected(value)} ` +
+               `but it is ${this.utils.printReceived(actual)}.`
       }
     }
   }
diff --git a/src/coroutine.js b/src/coroutine.js
index c6992f30..2caec680 100644
--- a/src/coroutine.js
+++ b/src/coroutine.js
@@ -1,4 +1,5 @@
-import { isPromise } from './utils'
+import Action from './action'
+import { get, isPromise } from './utils'
 
 /**
  * Coroutine is used by an action to determine how it should resolve
@@ -25,6 +26,36 @@ export default function coroutine (action, body, repo) {
     return action
   }
 
+  if (body && get(body, 'constructor.name') === 'GeneratorFunction') {
+    action.open()
+
+    let iterator = body(repo.push.bind(repo), repo)
+
+    function step (payload) {
+      let next = iterator.next(payload)
+
+      if (next.done) {
+        action.resolve(payload)
+      } else {
+        progress(next.value)
+      }
+    }
+
+    function progress (value) {
+      if (value instanceof Action) {
+        value.onDone(step)
+        value.onCancel(action.cancel, action)
+        value.onError(action.reject, action)
+      } else {
+        step(value)
+      }
+    }
+
+    step()
+
+    return action
+  }
+
   /**
    * Check for thunks. An escape hatch to direction work with an
    * action. It is triggered by returning a function from a


Test patch:
diff --git a/test/unit/middleware/generator-middleware.test.js b/test/unit/middleware/generator-middleware.test.js
new file mode 100644
index 00000000..932981cb
--- /dev/null
+++ b/test/unit/middleware/generator-middleware.test.js
@@ -0,0 +1,94 @@
+import Microcosm from '../../../src/microcosm'
+
+describe('Generator Middleware', function () {
+
+  it('processes actions sequentially', function () {
+    expect.assertions(1)
+
+    let stepper = n => n + 1
+    let repo = new Microcosm()
+
+    function range (start, end) {
+      return function * (send) {
+        let value = start
+
+        while (value < end) {
+          value = yield send(stepper, value)
+        }
+      }
+    }
+
+    repo.push(range, 0, 3).onDone(result => {
+      expect(result).toEqual(3)
+    })
+  })
+
+  it('continues if yielding a non-action value', function () {
+    expect.assertions(1)
+
+    let repo = new Microcosm()
+    let step = n => n
+
+    function test () {
+      return function * (send) {
+        yield send(step, 1)
+        yield true
+      }
+    }
+
+    repo.push(test).onDone(result => {
+      expect(result).toEqual(true)
+    })
+  })
+
+  it('a rejected step halts the chain', function () {
+    expect.assertions(1)
+
+    let stepper = n => n + 1
+    let repo = new Microcosm()
+
+    let sequence = repo.push(function () {
+      return function * (send, repo) {
+        yield send(stepper, 0)
+        yield repo.append(stepper).reject('Failure')
+        yield send(stepper)
+      }
+    })
+
+    sequence.onDone(function () {
+      throw new Error('Sequence should not have completed')
+    })
+
+    sequence.onError(result => {
+      expect(result).toEqual('Failure')
+    })
+  })
+
+  it('a cancelled step halts the chain', function () {
+    expect.assertions(1)
+
+    let stepper = n => n + 1
+    let repo = new Microcosm()
+
+    let sequence = repo.push(function () {
+      return function * (send, repo) {
+        yield send(stepper, 0)
+        yield repo.append(stepper).cancel('Cancelled')
+        yield send(stepper)
+      }
+    })
+
+    sequence.onDone(function () {
+      throw new Error('Sequence should not have resolved')
+    })
+
+    sequence.onError(function () {
+      throw new Error('Sequence should not have rejected')
+    })
+
+    sequence.onCancel(result => {
+      expect(result).toEqual('Cancelled')
+    })
+  })
+
+})