Repository: vigetlabs/microcosm
Pull request #485 (2018-03-19 11:48:07)

Add Observable::map
This commit adds the `map` method to Observables. This is useful for transforming a stream of values over time, necessary to fully replace the old style of building models with Presenters:

```javascript
class Namer extends Presenter {
  getModel(repo, { prefix }) {
    return {
      name: repo.domains.name.map(name => prefix + ' ' + name)
    }
  }
  render() {
    return <p>{this.model.name}</p>
  }
}

ReactDOM.render(<Namer prefix="Mr." />) // "Mr. Smith"
```

Long term, I kind of wonder if we should just pass `domains` as the first argument of `Presenter::getModel`, then this would just be:

```javascript
class Namer extends Presenter {
  getModel({ name },  { prefix }) {
    return {
      name: name.map(name => prefix + ' ' + name)
    }
  }
  render() {
    return <p>{this.model.name}</p>
  }
}
```

You could also call domain methods directly, maybe something like:

```javascript
// in Name domain
class Name extends Domain {
  withPrefix(prefix) {
    return this.map(name => `${prefix} ${name}`)
  }
}

class Namer extends Presenter {
  getModel({ name }, { prefix }) {
    return {
      name: name.withPrefix(prefix)
    }
  }
  // ...
}
```

codecov-io: # [Codecov](https://codecov.io/gh/vigetlabs/microcosm/pull/485?src=pr&el=h1) Report
> Merging [#485](https://codecov.io/gh/vigetlabs/microcosm/pull/485?src=pr&el=desc) into [serialize-domains](https://codecov.io/gh/vigetlabs/microcosm/commit/20d17a89d3f536904d665ca387a49978e5eeb97e?src=pr&el=desc) will **decrease** coverage by `0.35%`.
> The diff coverage is `86.66%`.

[![Impacted file tree graph](https://codecov.io/gh/vigetlabs/microcosm/pull/485/graphs/tree.svg?height=150&width=650&token=AaE0FLz9sU&src=pr)](https://codecov.io/gh/vigetlabs/microcosm/pull/485?src=pr&el=tree)

```diff
@@                  Coverage Diff                  @@
##           serialize-domains     #485      +/-   ##
=====================================================
- Coverage               98.2%   97.85%   -0.36%     
=====================================================
  Files                     28       29       +1     
  Lines                    669      698      +29     
  Branches                 128      135       +7     
=====================================================
+ Hits                     657      683      +26     
- Misses                    10       13       +3     
  Partials                   2        2
```


| [Impacted Files](https://codecov.io/gh/vigetlabs/microcosm/pull/485?src=pr&el=tree) | Coverage Δ | |
|---|---|---|
| [packages/microcosm/src/subject.js](https://codecov.io/gh/vigetlabs/microcosm/pull/485/diff?src=pr&el=tree#diff-cGFja2FnZXMvbWljcm9jb3NtL3NyYy9zdWJqZWN0Lmpz) | `100% <100%> (ø)` | :arrow_up: |
| [packages/microcosm/src/observable.js](https://codecov.io/gh/vigetlabs/microcosm/pull/485/diff?src=pr&el=tree#diff-cGFja2FnZXMvbWljcm9jb3NtL3NyYy9vYnNlcnZhYmxlLmpz) | `97.72% <75%> (-2.28%)` | :arrow_down: |
| [packages/microcosm-dom/src/utilities.js](https://codecov.io/gh/vigetlabs/microcosm/pull/485/diff?src=pr&el=tree#diff-cGFja2FnZXMvbWljcm9jb3NtLWRvbS9zcmMvdXRpbGl0aWVzLmpz) | `92.85% <0%> (ø)` | |

------

[Continue to review full report at Codecov](https://codecov.io/gh/vigetlabs/microcosm/pull/485?src=pr&el=continue).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/vigetlabs/microcosm/pull/485?src=pr&el=footer). Last update [20d17a8...71bb26a](https://codecov.io/gh/vigetlabs/microcosm/pull/485?src=pr&el=lastupdated). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments).


Patch:
diff --git a/packages/microcosm/src/observable.js b/packages/microcosm/src/observable.js
index 6e996a89..85ab2e55 100644
--- a/packages/microcosm/src/observable.js
+++ b/packages/microcosm/src/observable.js
@@ -149,6 +149,27 @@ export class Observable {
     }
   }
 
+  map(fn: (*) => *, scope: any): Observable {
+    if (typeof fn !== 'function') {
+      throw new TypeError(fn + ' is not a function')
+    }
+
+    return new Observable(observer => {
+      return this.subscribe({
+        next(value) {
+          try {
+            value = fn.call(scope, value)
+          } catch (e) {
+            return observer.error(e)
+          }
+          observer.next(value)
+        },
+        error: observer.error,
+        complete: observer.complete
+      })
+    })
+  }
+
   // $FlowFixMe - Flow does not support computed keys
   [observable]() {
     return this
diff --git a/packages/microcosm/src/subject.js b/packages/microcosm/src/subject.js
index 1deec979..9ed260a0 100644
--- a/packages/microcosm/src/subject.js
+++ b/packages/microcosm/src/subject.js
@@ -25,6 +25,10 @@ export class Subject {
     })
   }
 
+  get started(): boolean {
+    return this.status !== 'start'
+  }
+
   get status(): string {
     return this.meta.status
   }
@@ -141,6 +145,20 @@ export class Subject {
     }
   }
 
+  map(fn: (*) => *, scope: any): Observable {
+    return new Observable(observer => {
+      if (this.started) {
+        observer.next(fn.call(scope, this.payload))
+      }
+
+      if (this.closed) {
+        observer.complete()
+      } else {
+        return this._observable.map(fn, scope).subscribe(observer)
+      }
+    })
+  }
+
   static hash(obj: *): Subject {
     if (getObservable(obj)) {
       return obj


Test patch:
diff --git a/packages/microcosm-dom/test/presenter.test.js b/packages/microcosm-dom/test/presenter.test.js
index e0cd36fe..63e7f9c9 100644
--- a/packages/microcosm-dom/test/presenter.test.js
+++ b/packages/microcosm-dom/test/presenter.test.js
@@ -1,5 +1,5 @@
 import React from 'react'
-import { Microcosm, Observable, patch } from 'microcosm'
+import { Microcosm, patch } from 'microcosm'
 import { Presenter, ActionButton, withSend } from 'microcosm-dom/react'
 import { delay, mount, unmount, PropsTransition } from './helpers'
 
@@ -111,12 +111,7 @@ describe('::getModel', function() {
       class Namer extends Presenter {
         getModel(repo, { prefix }) {
           return {
-            // TODO: We should add Observable.map
-            name: new Observable(observer => {
-              return repo.domains.name.subscribe(name => {
-                observer.next(prefix + ' ' + name)
-              })
-            })
+            name: repo.domains.name.map(name => prefix + ' ' + name)
           }
         }
         render() {
diff --git a/packages/microcosm/test/unit/observable.test.js b/packages/microcosm/test/unit/observable.test.js
index 9a9f5ee8..b3b22c16 100644
--- a/packages/microcosm/test/unit/observable.test.js
+++ b/packages/microcosm/test/unit/observable.test.js
@@ -277,4 +277,45 @@ describe('Observable', function() {
       await wrapped
     })
   })
+
+  describe('.map', () => {
+    it('operates like array.map over a stream of values', () => {
+      let observable = new Observable(observer => {
+        for (var i = 0; i < 5; i++) {
+          observer.next(i)
+        }
+        observer.complete()
+      })
+
+      let double = observable.map(value => value * 2)
+      let answers = []
+
+      double.subscribe(value => answers.push(value))
+
+      expect(answers).toEqual([0, 2, 4, 6, 8])
+    })
+
+    it('can configure scope', () => {
+      let scope = {
+        degree: 2,
+        multiply(value) {
+          return value * this.degree
+        }
+      }
+
+      let observable = new Observable(observer => {
+        for (var i = 0; i < 5; i++) {
+          observer.next(i)
+        }
+        observer.complete()
+      })
+
+      let double = observable.map(scope.multiply, scope)
+      let answers = []
+
+      double.subscribe(value => answers.push(value))
+
+      expect(answers).toEqual([0, 2, 4, 6, 8])
+    })
+  })
 })
diff --git a/packages/microcosm/test/unit/subject.test.js b/packages/microcosm/test/unit/subject.test.js
index 4efbe991..e638fb82 100644
--- a/packages/microcosm/test/unit/subject.test.js
+++ b/packages/microcosm/test/unit/subject.test.js
@@ -310,4 +310,65 @@ describe('Subject', function() {
       expect(complete).toHaveBeenCalled()
     })
   })
+
+  describe('.map', () => {
+    it('works like observable.map', () => {
+      let subject = new Subject()
+
+      let double = subject.map(value => value * 2)
+      let answers = []
+
+      double.subscribe(value => answers.push(value))
+
+      for (var i = 0; i < 5; i++) {
+        subject.next(i)
+      }
+
+      expect(answers).toEqual([0, 2, 4, 6, 8])
+    })
+
+    it('starts with the current value', () => {
+      let subject = new Subject()
+
+      let double = subject.map(value => value * 2)
+      let answers = []
+
+      subject.next(5)
+
+      double.subscribe(value => answers.push(value))
+
+      expect(answers).toEqual([10])
+    })
+
+    it('only maps over the current and new iterations', () => {
+      let subject = new Subject()
+
+      let double = subject.map(value => value * 2)
+      let answers = []
+
+      subject.next(0)
+      subject.next(1)
+      double.subscribe(value => answers.push(value))
+      subject.next(2)
+      subject.next(3)
+
+      expect(answers).toEqual([2, 4, 6])
+    })
+
+    it('only emits the final value when it completes', () => {
+      let subject = new Subject()
+
+      let double = subject.map(value => value * 2)
+      let answers = []
+
+      subject.next(0)
+      subject.next(1)
+      subject.complete()
+      double.subscribe(value => answers.push(value))
+      subject.next(2)
+      subject.next(3)
+
+      expect(answers).toEqual([2])
+    })
+  })
 })