ampersand-view
Version:
A smart base view for Backbone apps, to make it easy to bind collections and properties to the DOM.
444 lines (412 loc) • 15.6 kB
JavaScript
/*$AMPERSAND_VERSION*/
var State = require('ampersand-state');
var CollectionView = require('ampersand-collection-view');
var domify = require('domify');
var uniqueId = require("lodash/uniqueId");
var pick = require("lodash/pick");
var assign = require("lodash/assign");
var forEach = require("lodash/forEach");
var result = require("lodash/result");
var last = require("lodash/last");
var isString = require("lodash/isString");
var bind = require("lodash/bind");
var flatten = require("lodash/flatten");
var invokeMap = require("lodash/invokeMap");
var events = require('events-mixin');
var matches = require('matches-selector');
var bindings = require('ampersand-dom-bindings');
var getPath = require('lodash/get');
function View(attrs) {
this.cid = uniqueId('view');
attrs || (attrs = {});
var parent = attrs.parent;
delete attrs.parent;
BaseState.call(this, attrs, {init: false, parent: parent});
this.on('change:el', this._handleElementChange, this);
this._upsertBindings();
this.template = attrs.template || this.template;
this._cache.rendered = false; // prep `rendered` derived cache immediately
this.initialize.apply(this, arguments);
if (this.autoRender && this.template) {
this.render();
}
}
var BaseState = State.extend({
dataTypes: {
element: {
set: function (newVal) {
return {
val: newVal,
type: newVal instanceof Element ? 'element' : typeof newVal
};
},
compare: function (el1, el2) {
return el1 === el2;
}
},
collection: {
set: function (newVal) {
return {
val: newVal,
type: newVal && newVal.isCollection ? 'collection' : typeof newVal
};
},
compare: function (currentVal, newVal) {
return currentVal === newVal;
}
}
},
props: {
model: 'state',
el: 'element',
collection: 'collection',
},
session: {
_rendered: ['boolean', true, false]
},
derived: {
hasData: {
deps: ['model'],
fn: function () {
return !!this.model;
}
},
rendered: {
deps: ['_rendered'],
fn: function() {
if (this._rendered) {
this.trigger('render', this);
return true;
}
this.trigger('remove', this);
return false;
}
}
}
});
View.prototype = Object.create(BaseState.prototype);
var queryNoElMsg = 'Query cannot be performed as this.el is not defined. Ensure that the view has been rendered.';
// Set up all inheritable properties and methods.
assign(View.prototype, {
// ## query
// Get an single element based on CSS selector scoped to this.el
// if you pass an empty string it return `this.el`.
// If you pass an element we just return it back.
// This lets us use `get` to handle cases where users
// can pass a selector or an already selected element.
query: function (selector) {
if (!this.el) {
throw new Error(queryNoElMsg);
}
if (!selector) return this.el;
if (typeof selector === 'string') {
if (matches(this.el, selector)) return this.el;
return this.el.querySelector(selector) || undefined;
}
return selector;
},
// ## queryAll
// Returns an array of elements based on CSS selector scoped to this.el
// if you pass an empty string it return `this.el`. Also includes root
// element.
queryAll: function (selector) {
if (!this.el) {
throw new Error(queryNoElMsg);
}
if (!selector) return [this.el];
var res = [];
if (matches(this.el, selector)) res.push(this.el);
return res.concat(Array.prototype.slice.call(this.el.querySelectorAll(selector)));
},
// ## queryByHook
// Convenience method for fetching element by it's `data-hook` attribute.
// Also tries to match against root element.
// Also supports matching 'one' of several space separated hooks.
queryByHook: function (hook) {
return this.query('[data-hook~="' + hook + '"]');
},
// ## queryAllByHook
// Convenience method for fetching all elements by their's `data-hook` attribute.
queryAllByHook: function (hook) {
return this.queryAll('[data-hook~="' + hook + '"]');
},
// Initialize is an empty function by default. Override it with your own
// initialization logic.
initialize: function () {},
// **render** is the core function that your view can override. Its job is
// to populate its element (`this.el`), with the appropriate HTML.
_render: function () {
this._upsertBindings();
this.renderWithTemplate(this);
this._rendered = true;
return this;
},
// Removes this view by taking the element out of the DOM, and removing any
// applicable events listeners.
_remove: function () {
if (this.el && this.el.parentNode) this.el.parentNode.removeChild(this.el);
this._rendered = false;
this._downsertBindings();
return this;
},
// Change the view's element (`this.el` property), including event
// re-delegation.
_handleElementChange: function (element, delegate) {
if (this.eventManager) this.eventManager.unbind();
this.eventManager = events(this.el, this);
this.delegateEvents();
this._applyBindingsForKey();
return this;
},
// Set callbacks, where `this.events` is a hash of
//
// *{"event selector": "callback"}*
//
// {
// 'mousedown .title': 'edit',
// 'click .button': 'save',
// 'click .open': function (e) { ... }
// }
//
// pairs. Callbacks will be bound to the view, with `this` set properly.
// Uses event delegation for efficiency.
// Omitting the selector binds the event to `this.el`.
// This only works for delegate-able events: not `focus`, `blur`, and
// not `change`, `submit`, and `reset` in Internet Explorer.
delegateEvents: function (events) {
if (!(events || (events = result(this, 'events')))) return this;
this.undelegateEvents();
for (var key in events) {
this.eventManager.bind(key, events[key]);
}
return this;
},
// Clears all callbacks previously bound to the view with `delegateEvents`.
// You usually don't need to use this, but may wish to if you have multiple
// Backbone views attached to the same DOM element.
undelegateEvents: function () {
this.eventManager.unbind();
return this;
},
// ## registerSubview
// Pass it a view. This can be anything with a `remove` method
registerSubview: function (view) {
// Storage for our subviews.
this._subviews = this._subviews || [];
this._subviews.push(view);
// set the parent reference if it has not been set
if (!view.parent) view.parent = this;
return view;
},
// ## renderSubview
// Pass it a view instance and a container element
// to render it in. It's `remove` method will be called
// when the parent view is destroyed.
renderSubview: function (view, container) {
if (typeof container === 'string') {
container = this.query(container);
}
if (!container) container = this.el;
this.registerSubview(view);
view.render();
container.appendChild(view.el);
return view;
},
_applyBindingsForKey: function (name) {
if (!this.el) return;
var fns = this._parsedBindings.getGrouped(name);
var item;
for (item in fns) {
fns[item].forEach(function (fn) {
fn(this.el, getPath(this, item), last(item.split('.')));
}, this);
}
},
_initializeBindings: function () {
if (!this.bindings) return;
this.on('all', function (eventName) {
if (eventName.slice(0, 7) === 'change:') {
this._applyBindingsForKey(eventName.split(':')[1]);
}
}, this);
},
// ## _initializeSubviews
// this is called at setup and grabs declared subviews
_initializeSubviews: function () {
if (!this.subviews) return;
for (var item in this.subviews) {
this._parseSubview(this.subviews[item], item);
}
},
// ## _parseSubview
// helper for parsing out the subview declaration and registering
// the `waitFor` if need be.
_parseSubview: function (subview, name) {
//backwards compatibility with older versions, when `container` was a valid property (#114)
if (subview.container) {
subview.selector = subview.container;
}
var opts = this._parseSubviewOpts(subview);
function action() {
var el, subview;
// if not rendered or we can't find our element, stop here.
if (!this.el || !(el = this.query(opts.selector))) return;
if (!opts.waitFor || getPath(this, opts.waitFor)) {
subview = this[name] = opts.prepareView.call(this, el);
if (!subview.el) {
this.renderSubview(subview, el);
} else {
subview.render();
this.registerSubview(subview);
}
this.off('change', action);
}
}
// we listen for main `change` items
this.on('change', action, this);
},
// Parses the declarative subview definition.
// You may overload this method to create your own declarative subview style.
// You must return an object with members 'selector', 'waitFor' and 'prepareView'.
// waitFor is trigged on the view 'change' event and so one way to extend the deferred view
// construction is to add an additional property (props) to the view. Then setting this property
// will satisfy the waitFor condition. You can then extend the prepareView function to pass in
// additional data from the parent view. This can allow you to have multi-stage rendering of
// custom data formats and to declaratively define.
_parseSubviewOpts: function (subview) {
var self = this;
var opts = {
selector: subview.selector || '[data-hook="' + subview.hook + '"]',
waitFor: subview.waitFor || '',
prepareView: subview.prepareView || function () {
return new subview.constructor({
parent: self
});
}
};
return opts;
},
// Shortcut for doing everything we need to do to
// render and fully replace current root element.
// Either define a `template` property of your view
// or pass in a template directly.
// The template can either be a string or a function.
// If it's a function it will be passed the `context`
// argument.
renderWithTemplate: function (context, templateArg) {
var template = templateArg || this.template;
if (!template) throw new Error('Template string or function needed.');
var newDom = isString(template) ? template : template.call(this, context || this);
if (isString(newDom)) newDom = domify(newDom);
var parent = this.el && this.el.parentNode;
if (parent) parent.replaceChild(newDom, this.el);
if (newDom.nodeName === '#document-fragment') throw new Error('Views can only have one root element, including comment nodes.');
this.el = newDom;
return this;
},
// ## cacheElements
// This is a shortcut for adding reference to specific elements within your view for
// access later. This avoids excessive DOM queries and makes it easier to update
// your view if your template changes.
//
// In your `render` method. Use it like so:
//
// render: function () {
// this.basicRender();
// this.cacheElements({
// pages: '#pages',
// chat: '#teamChat',
// nav: 'nav#views ul',
// me: '#me',
// cheatSheet: '#cheatSheet',
// omniBox: '#awesomeSauce'
// });
// }
//
// Then later you can access elements by reference like so: `this.pages`, or `this.chat`.
cacheElements: function (hash) {
for (var item in hash) {
this[item] = this.query(hash[item]);
}
return this;
},
// ## animateRemove
// Placeholder for if you want to do something special when they're removed.
// For example fade it out, etc.
// Any override here should call `.remove()` when done.
animateRemove: function () {
this.remove();
},
// ## renderCollection
// Method for rendering a collections with individual views.
// Just pass it the collection, and the view to use for the items in the
// collection. The collectionView is returned.
renderCollection: function (collection, ViewClass, container, opts) {
var containerEl = (typeof container === 'string') ? this.query(container) : container;
var config = assign({
collection: collection,
el: containerEl || this.el,
view: ViewClass,
parent: this,
viewOptions: {
parent: this
}
}, opts);
var collectionView = new CollectionView(config);
collectionView.render();
return this.registerSubview(collectionView);
},
_setRender: function(obj) {
Object.defineProperty(obj, 'render', {
get: function() {
return this._render;
},
set: function(fn) {
this._render = function() {
fn.apply(this, arguments);
this._rendered = true;
return this;
};
}
});
},
_setRemove: function(obj) {
Object.defineProperty(obj, 'remove', {
get: function() {
return this._remove;
},
set: function(fn) {
this._remove = function() {
fn.apply(this, arguments);
this._rendered = false;
return this;
};
}
});
},
_downsertBindings: function() {
var parsedBindings = this._parsedBindings;
if (!this.bindingsSet) return;
if (this._subviews){
invokeMap(flatten(this._subviews), 'remove');
this._subviews = [];
}
this.stopListening();
this.bindingsSet = false;
},
_upsertBindings: function(attrs) {
attrs = attrs || this;
if (this.bindingsSet) return;
this._parsedBindings = bindings(this.bindings, this);
this._initializeBindings();
if (attrs.el && !this.autoRender) {
this._handleElementChange();
}
this._initializeSubviews();
this.bindingsSet = true;
}
});
View.prototype._setRender(View.prototype);
View.prototype._setRemove(View.prototype);
View.extend = BaseState.extend;
module.exports = View;