Repository: vigetlabs/microcosm (js)

Issue:
Default options for effects and domains
On Microcosm, we have the ability to set default options:

```
class Repo extends Microcosm {
  static defaults {
    debug: true,
    maxHistory: 20
  }
}
```

We should do this for domains and effects. Additionally -- what if we allowed the configuration to cascade?

Pull request description:
Allow a cascade of options. Add static defaults to domains and effects
Domains and effects can have a `defaults` class property to provide default setup options:

```javascript
class Counter {
  static defaults = {
    start: 0
  }

  setup (repo, { start }) {
    console.log(start) // 0
  }
}

let repo = new Microcosm()

repo.addDomain('counter', Counter) // default start is 0
```

---

Fixes #384.

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 54925f80..351d9569 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -3,6 +3,8 @@
 # Edge
 
 - Microcosm ships with ES6 and UMD bundles
+- Domains and Effects can implement a `defaults` static object to
+  provide default setup options.
 
 # 12.9.0
 
diff --git a/docs/api/domains.md b/docs/api/domains.md
index c7529693..0b218ec3 100644
--- a/docs/api/domains.md
+++ b/docs/api/domains.md
@@ -202,3 +202,33 @@ const Planets = {
 
 repo.push(Actions.add, { name: 'earth' }) // this will add Earth
 ```
+
+### `Domain.defaults`
+
+Specifies default options a Domain is instantiated with. This
+provides a concise way to configure sensible defaults for setup
+options:
+
+```javascript
+class Counter {
+  static defaults = {
+    start: 0
+  }
+
+  setup (repo, { start }) {
+    console.log(start) // 0
+  }
+}
+
+let repo = new Microcosm()
+
+repo.addDomain('counter', Counter) // default start is 0
+```
+
+When instantiated, default options are determined in the following
+order:
+
+1. Microcosm defaults
+2. Microcosm instantiation options
+3. Domain defaults
+4. Options passed to `repo.addDomain`.
diff --git a/docs/api/effects.md b/docs/api/effects.md
index 393585bb..5cd9eef6 100644
--- a/docs/api/effects.md
+++ b/docs/api/effects.md
@@ -206,3 +206,34 @@ class Planets {
 repo.addEffect(Planets)
 repo.push(addPlanet, { name: 'earth' }) // this will add Earth
 ```
+
+### `Effect.defaults`
+
+Specifies default options a Effect is instantiated with. This
+provides a concise way to configure sensible defaults for setup
+options:
+
+```javascript
+class AutoSave {
+  static defaults = {
+    saveInterval: 5000
+  }
+
+  setup (repo, { saveInterval }) {
+    console.log(saveInterval) // 5000
+  }
+}
+
+let repo = new Microcosm()
+
+repo.addEffect(AutoSave) // default saveInterval is 5000
+```
+
+When instantiated, default options are determined in the following
+order:
+
+1. Microcosm defaults
+2. Microcosm instantiation options
+3. Effect defaults
+4. Instantiation options
+4. Options passed to `repo.addEffect`.
diff --git a/flow/domain.js b/flow/domain.js
index 0ff1eb0b..ab590244 100644
--- a/flow/domain.js
+++ b/flow/domain.js
@@ -20,14 +20,14 @@ declare interface Domain {
    * before it runs getInitialState. This is useful for one-time setup
    * instructions.
    */
-  setup(repo: Microcosm, options: ?Object): void,
+  setup(repo?: Microcosm, options?: Object): void,
 
   /**
    * Runs whenever a Microcosm is torn down. This usually happens when
    * a Presenter component unmounts. Useful for cleaning up work done
    * in `setup()`.
    */
-  teardown(repo: Microcosm): void,
+  teardown(repo?: Microcosm): void,
 
   /**
    * Allows a domain to transform data before it leaves the system. It
diff --git a/src/domain-engine.js b/src/domain-engine.js
index 6ad0e6c8..8a800b5a 100644
--- a/src/domain-engine.js
+++ b/src/domain-engine.js
@@ -4,7 +4,7 @@
 
 import MetaDomain from './meta-domain'
 import getRegistration from './get-registration'
-import { get, set, createOrClone } from './utils'
+import { get, set, merge, createOrClone } from './utils'
 import { castPath, type KeyPath } from './key-path'
 
 import type Action from './action'
@@ -65,8 +65,9 @@ class DomainEngine {
     return this.registry[type]
   }
 
-  add(key: string | KeyPath, config: Object | Function, options?: Object) {
-    let domain: Domain = createOrClone(config, options, this.repo)
+  add(key: string | KeyPath, config: *, options?: Object) {
+    let deepOptions = merge(this.repo.options, config.defaults, options)
+    let domain: Domain = createOrClone(config, deepOptions, this.repo)
     let keyPath: KeyPath = castPath(key)
 
     this.domains.push([keyPath, domain])
@@ -75,7 +76,7 @@ class DomainEngine {
     this.registry = {}
 
     if (domain.setup) {
-      domain.setup(this.repo, options)
+      domain.setup(this.repo, deepOptions)
     }
 
     if (domain.teardown) {
diff --git a/src/effect-engine.js b/src/effect-engine.js
index 1b59cd2a..259eb909 100644
--- a/src/effect-engine.js
+++ b/src/effect-engine.js
@@ -3,7 +3,7 @@
  */
 
 import getRegistration from './get-registration'
-import { createOrClone } from './utils'
+import { merge, createOrClone } from './utils'
 
 import type Action from './action'
 import type Microcosm from './microcosm'
@@ -18,10 +18,11 @@ class EffectEngine {
   }
 
   add(config: Object | Function, options?: Object) {
-    let effect: Effect = createOrClone(config, options, this.repo)
+    let deepOptions = merge(this.repo.options, config.defaults, options)
+    let effect: Effect = createOrClone(config, deepOptions, this.repo)
 
     if (effect.setup) {
-      effect.setup(this.repo, options)
+      effect.setup(this.repo, deepOptions)
     }
 
     if (effect.teardown) {
diff --git a/src/microcosm.js b/src/microcosm.js
index b8515ad9..389e817c 100644
--- a/src/microcosm.js
+++ b/src/microcosm.js
@@ -99,18 +99,19 @@ class Microcosm extends Emitter implements Domain {
   domains: DomainEngine
   effects: EffectEngine
   changes: CompareTree
+  options: Object
 
   constructor(preOptions?: ?Object, state?: Object, deserialize?: boolean) {
     super()
 
-    let options = merge(DEFAULTS, this.constructor.defaults, preOptions || {})
+    this.options = merge(DEFAULTS, this.constructor.defaults, preOptions || {})
 
-    this.parent = options.parent
+    this.parent = this.options.parent
 
     this.initial = this.parent ? this.parent.initial : this.getInitialState()
     this.state = this.parent ? this.parent.state : this.initial
 
-    this.history = this.parent ? this.parent.history : new History(options)
+    this.history = this.parent ? this.parent.history : new History(this.options)
 
     this.snapshots = Object.create(this.parent ? this.parent.snapshots : null)
     this.domains = new DomainEngine(this)
@@ -136,14 +137,14 @@ class Microcosm extends Emitter implements Domain {
     this.history.on('release', this.release, this)
 
     // Microcosm is now ready. Call the setup lifecycle method
-    this.setup(options)
+    this.setup(this.options)
 
     // If given state, reset to that snapshot
     if (state) {
       this.reset(state, deserialize)
     }
 
-    if (options.debug) {
+    if (this.options.debug) {
       installDevtools(this)
     }
   }
diff --git a/src/utils.js b/src/utils.js
index f62e25a1..558dd7d3 100644
--- a/src/utils.js
+++ b/src/utils.js
@@ -54,16 +54,20 @@ export function clone<T: MixedObject>(target: T): $Shape<T> {
 /**
  * Merge any number of objects into a provided object.
  */
-export function merge(...args: Array<Object>): Object {
+export function merge(...args: Array<?Object>): Object {
   let copy = null
   let subject = null
 
   for (var i = 0, len = args.length; i < len; i++) {
     copy = copy || args[i]
+
+    if (copy == null) {
+      continue
+    }
+
     subject = subject || copy
 
     var next = args[i]
-
     for (var key in next) {
       if (copy[key] !== next[key]) {
         if (copy === subject) {


Test patch:
diff --git a/test/unit/domain/construction.test.js b/test/unit/domain/construction.test.js
index 18ebaa25..6524bb48 100644
--- a/test/unit/domain/construction.test.js
+++ b/test/unit/domain/construction.test.js
@@ -41,17 +41,71 @@ describe('Domain construction', function() {
     expect(repo).toHaveState('count', 0)
   })
 
-  it('class - extends domain', function() {
+  it('class - with defaults', function() {
+    expect.assertions(1)
+
     const repo = new Microcosm()
 
     class Counter {
-      getInitialState() {
-        return 0
+      static defaults = {
+        start: 0
+      }
+
+      setup(repo, options) {
+        expect(options.start).toBe(0)
       }
     }
 
     repo.addDomain('count', Counter)
+  })
 
-    expect(repo).toHaveState('count', 0)
+  it('class - draw from Microcosm options', function() {
+    expect.assertions(1)
+
+    const repo = new Microcosm({ debug: true })
+
+    class Counter {
+      setup(repo, options) {
+        expect(options.debug).toBe(true)
+      }
+    }
+
+    repo.addDomain('count', Counter)
+  })
+
+  it('class - with defaults override Microcosm options', function() {
+    expect.assertions(1)
+
+    const repo = new Microcosm({ debug: true })
+
+    class Counter {
+      static defaults = {
+        debug: false
+      }
+
+      setup(repo, options) {
+        expect(options.debug).toBe(false)
+      }
+    }
+
+    repo.addDomain('count', Counter)
+  })
+
+  it('class - with passed options override defaults', function() {
+    expect.assertions(1)
+
+    const repo = new Microcosm({ count: 0 })
+
+    class Counter {
+      static defaults = {
+        count: 1
+      }
+
+      setup(repo, options) {
+        expect(options.count).toBe(2)
+      }
+    }
+
+    repo.addDomain('count', Counter, { count: 2 })
   })
 })
diff --git a/test/unit/domain/setup.test.js b/test/unit/domain/setup.test.js
index a5c27327..b0286943 100644
--- a/test/unit/domain/setup.test.js
+++ b/test/unit/domain/setup.test.js
@@ -13,6 +13,9 @@ describe('Domain::setup', function() {
 
     repo.addDomain('count', Counter, { test: true })
 
-    expect(test).toHaveBeenCalledWith(repo, { test: true })
+    expect(test).toHaveBeenCalledWith(
+      repo,
+      expect.objectContaining({ test: true })
+    )
   })
 })
diff --git a/test/unit/effect/construction.test.js b/test/unit/effect/construction.test.js
index e0eea802..fcf8019e 100644
--- a/test/unit/effect/construction.test.js
+++ b/test/unit/effect/construction.test.js
@@ -22,4 +22,72 @@ describe('Effect construction', function() {
 
     expect(spy).toHaveBeenCalledWith(repo, true)
   })
+
+  it('class - with defaults', function() {
+    expect.assertions(1)
+
+    const repo = new Microcosm()
+
+    class Effect {
+      static defaults = {
+        start: 0
+      }
+
+      setup(repo, options) {
+        expect(options.start).toBe(0)
+      }
+    }
+
+    repo.addEffect(Effect)
+  })
+
+  it('class - draw from Microcosm options', function() {
+    expect.assertions(1)
+
+    const repo = new Microcosm({ debug: true })
+
+    class Effect {
+      setup(repo, options) {
+        expect(options.debug).toBe(true)
+      }
+    }
+
+    repo.addEffect(Effect)
+  })
+
+  it('class - with defaults override Microcosm options', function() {
+    expect.assertions(1)
+
+    const repo = new Microcosm({ debug: true })
+
+    class Effect {
+      static defaults = {
+        debug: false
+      }
+
+      setup(repo, options) {
+        expect(options.debug).toBe(false)
+      }
+    }
+
+    repo.addEffect(Effect)
+  })
+
+  it('class - with passed options override defaults', function() {
+    expect.assertions(1)
+
+    const repo = new Microcosm({ count: 0 })
+
+    class Effect {
+      static defaults = {
+        count: 1
+      }
+
+      setup(repo, options) {
+        expect(options.count).toBe(2)
+      }
+    }
+
+    repo.addEffect(Effect, { count: 2 })
+  })
 })
diff --git a/test/unit/effect/setup.test.js b/test/unit/effect/setup.test.js
index afc0c839..7d47b494 100644
--- a/test/unit/effect/setup.test.js
+++ b/test/unit/effect/setup.test.js
@@ -11,6 +11,9 @@ describe('Effect::setup', function() {
 
     repo.addEffect(Effect, { test: true })
 
-    expect(spy).toHaveBeenCalledWith(repo, { test: true })
+    expect(spy).toHaveBeenCalledWith(
+      repo,
+      expect.objectContaining({ test: true })
+    )
   })
 })