Skip to content
This repository has been archived by the owner on Jun 9, 2022. It is now read-only.

Commit

Permalink
Merge pull request #88 from quarterto/mount-lifecycle-event
Browse files Browse the repository at this point in the history
Add mount lifecycle method/event and unmount event
  • Loading branch information
kornelski committed Sep 11, 2015
2 parents 5185d37 + 4c13619 commit 14487c1
Show file tree
Hide file tree
Showing 9 changed files with 251 additions and 60 deletions.
4 changes: 3 additions & 1 deletion docs/defining-modules.md
Expand Up @@ -10,6 +10,7 @@ var Apple = fruitmachine.define({
// Event callbacks (optional)
initialize: function(options){},
setup: function(){},
mount: function(){},
teardown: function(){},
destroy: function(){}
});
Expand All @@ -29,6 +30,7 @@ Internally `define` extends the default `fruitmachine.Module.prototype` with the
- `tag {String}` The html tag to use on the root element (defaults to 'div') *(optional)*
- `classes {Array}` A list of classes to add to the root element. *(optional)*
- `initialize {Function}` Define a function to run when the module is first instantiated (only ever runs once) *(optional)*
- `setup {Function}` A function to be run every time `Module#setup()` is called. Should be used to bind any DOM event listeners. You can safely assume the presence of `this.el` at this point. *(optional)*
- `setup {Function}` A function to be run every time `Module#setup()` is called. You can safely assume the presence of `this.el` at this point; however, this element is not guaranteed to exist or be associated with the module in the future, for example if the module's parent is re-rendered. *(optional)*
- `mount {Function}` A function to be run every time `Module#mount()` is called, i.e. when the module has been associated with a new DOM element. Should be used to bind any DOM event listeners. *(optional)*
- `teardown {Function}` A function to be run when `Module#teardown()` or `Module#destroy()` is called. `teardown` will also run if you attempt to setup an already 'setup' module.
- `destroy {Function}` Run when `Module#destroy()` is called (will only ever run once) *(optional)*
59 changes: 7 additions & 52 deletions docs/module-interactions.md
@@ -1,61 +1,16 @@
## Interactions
## Interacting with the DOM

Not all modules need interaction or logic, but when they do FruitMachine has everything you need.

#### Setting up
Sometimes, modules need to interact with the DOM, for example to register event handlers or set up a non-fruitmachine component. The `mount` lifecycle method is called whenever a module is associated with a new DOM element, allowing you to perform setup that requires the DOM:

```js
var Apple = fruitmachine.define({
name: 'apple',
template: function(){ return '<button>Click Me</button>'; },
setup: function() {
var self = this;
this.button = this.el.querySelector('tear me down');
this.onButtonClick = function() {
alert('clicked');
};

this.button.addEventListener('click', this.onButtonClick);
}
});

var apple = new Apple();

apple
.render()
.inject(document.body)
.setup(); /* 1 */
```

1. *The button is now active*

#### Tearing down ([example](http://ftlabs.github.io/fruitmachine/example/interactions))
template: function() { return '<button>Click me</button>' },

```js
var Apple = fruitmachine.define({
name: 'apple',
template: function(){ return '<button>Click Me</button>'; },
setup: function() {
var self = this;
this.button = this.el.querySelector('tear me down');
this.onButtonClick = function() {
alert('tearing down');
self.teardown(); /* 1 */
};

this.button.addEventListener('click', this.onButtonClick);
mount: function() {
this.el.addEventListener('click', function() {
alert('clicked');
});
},
teardown: function() {
this.button.removeEventListener('click', this.onButtonClick);
}
});

var apple = new Apple();

apple
.render()
.inject(document.body)
.setup(); /* 1 */
```

1. *Teardown is called when the button is clicked, removing the event listener*
3 changes: 2 additions & 1 deletion docs/removing-and-destroying.md
Expand Up @@ -7,6 +7,7 @@ Eventually a module has to be destroyed. To do this you simply call `mymodule.de
1. It runs `.teardown()` to undo any setup logic.
2. It removes the module from any module it may be nested inside.
3. It removes the module from the DOM.
3. It unmounts the module from element.
4. It runs your module's custom destroy logic.
5. It fires a `destroy` event hook.
6. It sets `module.destroyed` to `true` (useful for checking for destroyed views).
Expand All @@ -21,4 +22,4 @@ You may just want to remove a module from it's current context and drop it somew
Remove will:

- Remove a module from from any module it may be nested inside.
- Remove the module's element (`.el`) from the DOM (if applicable), unless the called with `{ fromDOM: false }`.
- Remove the module's element (`.el`) from the DOM (if applicable), unless the called with `{ fromDOM: false }`.
2 changes: 1 addition & 1 deletion docs/rendering.md
Expand Up @@ -4,7 +4,7 @@ When you have [assembled](layout-assembly.md) your modules and populated them wi

#### Re-rendering

Often data changes and you need to re-render your modules. Render replaces the root element with a new one, which means you can easily keep Views up to date.
Often data changes and you need to re-render your modules. Render replaces the root element with a new one, which means you can easily keep Views up to date. This will remove any child module elements and immediately re-mount them.

```js
var model = new fruitmachine.Model({ name: 'Wilson' });
Expand Down
60 changes: 56 additions & 4 deletions lib/module/index.js
Expand Up @@ -254,6 +254,7 @@ module.exports = function(fm) {
// from its parent node.
if (fromDOM && parentNode) {
parentNode.removeChild(el);
this._unmount();
}

if (parent) {
Expand Down Expand Up @@ -557,6 +558,7 @@ module.exports = function(fm) {
var classes;
if (el && el.tagName === this.tag.toUpperCase()) {
el.innerHTML = this._innerHTML();
this._unmountChildren();
classes = el.className.split(/\s+/);
this._classes().forEach(function(add) {
if (!~classes.indexOf(add)) el.className = el.className + ' ' + add;
Expand Down Expand Up @@ -777,11 +779,58 @@ module.exports = function(fm) {
proto._fetchEls = function(root) {
if (!root) return;
this.each(function(child) {
child.el = util.byId(child._fmid, root);
child.mount(util.byId(child._fmid, root));
child._fetchEls(child.el || root);
});
};

/**
* Associate the view with an element.
* Provide events and lifecycle methods
* to fire when the element is newly
* associated.
*
* @param {Element} el
* @return {Element}
*/
proto.mount = function(el) {
if(this.el !== el) {
this.fireStatic('before mount');
this.el = el;
if(this._mount) this._mount();
this.fireStatic('mount');
}

return this.el;
};

/**
* Recursively fire unmount events on
* children. To be called when a view's
* children are implicitly removed from
* the DOM (e.g. setting innerHTML)
*
* @api private
*/
proto._unmountChildren = function() {
this.each(function(child) {
child._unmount();
});
};


/*_setEl * Recursively fire unmount events on
* a view and its children. To be
* called when a view'is implicitly
* removed from the DOM (e.g. _setEl)
*
* @api private
*/
proto._unmount = function() {
this._unmountChildren();
this.fireStatic('unmount');
}

/**
* Returns the Module's root element.
*
Expand All @@ -796,7 +845,7 @@ module.exports = function(fm) {
*/
proto._getEl = function() {
if (!util.hasDom()) return;
return this.el = this.el || document.getElementById(this._fmid);
return this.mount(this.el || document.getElementById(this._fmid));
};

/**
Expand All @@ -818,10 +867,13 @@ module.exports = function(fm) {
var parentNode = existing && existing.parentNode;

// If the existing element has a context, replace it
if (parentNode) parentNode.replaceChild(el, existing);
if (parentNode) {
parentNode.replaceChild(el, existing);
this._unmount();
}

// Update cache
this.el = el;
this.mount(el);

return this;
};
Expand Down
24 changes: 23 additions & 1 deletion test/tests/module._setEl.js
@@ -1,6 +1,7 @@
var assert = buster.assertions.assert;

buster.testCase('View#_setEl()', {
setUp: helpers.createView,

"Should replace the element in context if it has a context": function() {
var layout = fruitmachine({
Expand All @@ -26,6 +27,27 @@ buster.testCase('View#_setEl()', {
orange._setEl(replacement);

assert.equals(replacement.parentNode, apple.el);
}
},

"Should call unmount if replacing the element": function() {
var layoutSpy = this.spy();
var appleSpy = this.spy();
var orangeSpy = this.spy();
var pearSpy = this.spy();

this.view.on('unmount', layoutSpy);
this.view.module('apple').on('unmount', appleSpy);
this.view.module('orange').on('unmount', orangeSpy);
this.view.module('pear').on('unmount', pearSpy);

this.view.render().inject(sandbox);
this.view._setEl(document.createElement('div'));

assert.called(layoutSpy);
assert.called(appleSpy);
assert.called(orangeSpy);
assert.called(pearSpy);
},

tearDown: helpers.destroyView
});
105 changes: 105 additions & 0 deletions test/tests/module.mount.js
@@ -0,0 +1,105 @@
var assert = buster.assertions.assert;

buster.testCase('View#mount()', {
setUp: helpers.createView,

"Should give a view an element": function() {
var el = document.createElement('div');
this.view.mount(el);

assert.equals(this.view.el, el);
},

"Should be called when the view is rendered": function() {
var mount = this.spy(this.view, 'mount');
this.view.render();
assert.called(mount);
},

"Should be called on a child when its parent is rendered": function() {
var mount = this.spy(this.view.module('apple'), 'mount');
this.view.render();
assert.called(mount);
},

"Should be called on a child when its parent is rerendered": function() {
var mount = this.spy(this.view.module('apple'), 'mount');
this.view.render();
this.view.render();
assert.calledTwice(mount);
},

"Should call custom mount logic": function() {
var mount = this.spy();

var Module = fruitmachine.define({
name: 'module',
template: function() {
return 'hello';
},

mount: mount
});

var m = new Module();
m.render();

assert.called(mount);
},


"Should be a good place to attach event handlers that don't get trashed on parent rerender": function() {
var handler = this.spy();

var Module = fruitmachine.define({
name: 'module',
tag: 'button',
template: function() {
return 'hello';
},

mount: function() {
this.el.addEventListener('click', handler);
}
});

var m = new Module();

var layout = new Layout({
children: {
1: m
}
});

layout.render();
m.el.click();

assert.called(handler);

layout.render();
m.el.click();

assert.calledTwice(handler);
},

"before mount and mount events should be fired": function() {
var beforeMountSpy = this.spy();
var mountSpy = this.spy();
this.view.on('before mount', beforeMountSpy);
this.view.on('mount', mountSpy);

this.view.render();
assert.callOrder(beforeMountSpy, mountSpy);
},

"Should only fire events if the element is new": function() {
var mountSpy = this.spy();
this.view.on('mount', mountSpy);

this.view.render();
this.view._getEl();
assert.calledOnce(mountSpy);
},

tearDown: helpers.destroyView
});
34 changes: 34 additions & 0 deletions test/tests/module.remove.js
Expand Up @@ -72,6 +72,40 @@ buster.testCase('View#remove()', {
assert(sandbox.querySelector('#' + apple._fmid));
},

"Should unmount the view by default": function() {
var list = new Layout({
children: {
1: new Apple()
}
});

var layoutSpy = this.spy(); list.on('unmount', layoutSpy);
var appleSpy = this.spy(); list.module('apple').on('unmount', appleSpy);

list.render().inject(sandbox).setup();
list.remove();

assert.called(layoutSpy);
assert.called(appleSpy);
},

"Should not unmount the view if `fromDOM` option is false": function() {
var list = new Layout({
children: {
1: new Apple()
}
});

var layoutSpy = this.spy(); list.on('unmount', layoutSpy);
var appleSpy = this.spy(); list.module('apple').on('unmount', appleSpy);

list.render().inject(sandbox).setup();
list.remove({fromDOM: false});

refute.called(layoutSpy);
refute.called(appleSpy);
},

"Should remove itself if called with no arguments": function() {
var list = new helpers.Views.Layout();
var Apple = helpers.Views.Apple;
Expand Down

0 comments on commit 14487c1

Please sign in to comment.