define([
'jquery', 'backbone', 'underscore', '../utilities/channels', './viewContext', './lifeCycle',
'../utilities/accessors', '../utilities/createOptions', '../models/masseuseModel', '../models/proxyProperty',
'../models/observerProperty'
], function ($, Backbone, _, Channels, ViewContext, lifeCycle, accessors, createOptions, MasseuseModel, ProxyProperty,
ObserverProperty) {
'use strict';
var viewOptions = ['model', 'collection', 'el', 'id', 'attributes', 'className', 'tagName', 'events',
'name', 'appendTo', 'prependTo', 'wrapper'],
BEFORE_RENDER_DONE = 'beforeRenderDone',
AFTER_TEMPLATING_DONE = 'afterTemplatingDone',
RENDER_DONE = 'renderDone',
AFTER_RENDER_DONE = 'afterRenderDone',
MODEL_DATA = 'modelData',
/**
*
*
* The BaseView is a custom extension of Backbone.View with some built in functionality and a defined life
* cycle. The life
* cycle methods can be run either synchronously or asynchronously.
*
* To initialize a BaseView, there are several choices of options to pass in.
*
* The life cycle has three main parts: `beforeRender`, `render`, and `afterRender`. `beforeRender` and
* `afterRender` are
* noops by default. If they are defined with zero arguments, they are executed synchronously. If they are
* defined with
* one argument, then a `$.Deferred` is passed in as that argument, and the life cycle doesn't continue until
* that
* `$.Deferred` is resolved. The render method uses the same convention.
*
* ```
* beforeRender : function() { synchronous },
* beforeRender : function($deferred) { must resolve or reject $deferred }
* ```
*
* The lifecycle is started with `view.start()`. This returns a promise. During the life cycle the promise is
* notifed with:
* `beforeRenderDone`, `renderDone`, and `afterRenderDone`.
*
* Child views are rendered during a parent views `afterDone`. The views start promise is not resolved until
* all the
* children's promises are resolved. Children can be nested arbitrariluy deep. Child views can be added
* before start using
* `parent.addChild(child)`.
*
*
* @class A View that adds lifecycle methods to Views that are optionally async using jQuery promises.
* Adds support for adding child Views
* @namespace masseuse/BaseView
* @type {*|extend|extend|extend|void|Object}
*/
BaseView = Backbone.View.extend({
constructor : constructor,
initialize : initialize,
start : start,
render : render,
dataToJSON : dataToJSON,
bindEventListeners : bindEventListeners,
remove : remove,
children : null,
addChild : addChild,
addChildren : addChildren,
removeChild : removeChild,
refresh : refresh,
restart : refresh,
refreshChildren : refreshChildren,
removeAllChildren : removeAllChildren,
appendOrInsertView : appendOrInsertView
// Dynamically created, so the cache is not shared on the prototype:
// elementCache: elementCache
});
BaseView.beforeRenderDone = BEFORE_RENDER_DONE;
BaseView.afterTemplatingDone = AFTER_TEMPLATING_DONE;
BaseView.renderDone = RENDER_DONE;
BaseView.afterRenderDone = AFTER_RENDER_DONE;
// Share channels among all Views
BaseView.prototype.channels = BaseView.prototype.channels || new Channels();
return BaseView;
/**
* The constructor of BaseView overrides the default BB constructor, so that the options object can be created and
* applied to `this.el` before `this.initialize` is called.
*
* * `name` - convenience name string for debugging - will be available on the view instance
* `appendTo` - if set and `el` is not set then the view will be appended to this eleeent. `appendTo` can be a
* sizzle selector, a DOM element, or a jQuery object
* `bindings` - declarative syntax to setup view listeners
* array of arrays
* each sub array contains: what to listen to, the event, and the callback
* view context is assumed
*
* ```javascript
* bindings : [
* ['model', 'change:price', 'showNewPrice'],
* ['model', 'change:discount', 'animateAdvertisement']
* ],
* ```
*
* * `defaultOptions` - the options to be used as the default. Passed in options will extend this.
* * `ModelType` - if supplied, `this.model` will be of type `ModelType` - default is `MasseuseModel`
* * `modelData` - if supplied, `this.model` will be initialized with, `this.model.set(modelData)`
* * ViewContext - Convenience helper to acces the view context from within modelData
* * this helps in separating view options and view definitions into separate AMDs
*
* ```javascript
* modelData : {
* viewId : ViewContext('cid')
* }
* ```
*
* * `template` - String to be used as the umderscore template
* * `viewOptions` - list of keys for which each key value pair from `options` will be transferred to the view
* instance
*
* ```javascript
* var view = new BaseView({
* name : 'MyName',
* appendView : false,
* ModelType : MyCustomModel,
* modelData : { ... },
* bindings : [
* ['model', 'change:price', 'showNewPrice'],
* ['model', 'change:discount', 'animateAdvertisement']
* ],
* templateHtml : '<div><%= price %> : <%= discount %></div>',
* // Underscore templating that will - if provided - be turned into this.template using
* _.template(templateHtml)
*
* });
* ```
*
* @method constructor
* @memberof masseuse/BaseView#
* @param options
* @param useDefaultOptions
*/
function constructor() {
var options,
args = Array.prototype.slice.call(arguments, 0),
length = args.length,
last = args[length - 1],
useDefaultOptions = false !== last,
ViewType;
// remove optional boolean indicator of wanting to use defaultOptions
if (length && 'object' !== typeof last) {
args.pop();
}
if (useDefaultOptions && this.defaultOptions) {
args.unshift(this.defaultOptions);
}
options = createOptions.apply(null, args);
ViewType = options.ViewType;
if (ViewType) {
delete options.ViewType;
return new ViewType(options);
}
this.cid = _.uniqueId('view');
_.extend(this, _.pick(options, viewOptions));
this._ensureElement();
this.initialize.call(this, options);
this.delegateEvents();
}
/**
* @method initialize
* @memberof masseuse/BaseView#
* @param options
*/
function initialize (options) {
var self = this;
this.elementCache = _.memoize(elementCache.bind(this));
if(options) {
if (options.viewOptions) {
viewOptions = viewOptions.concat(options.viewOptions);
}
_.extend(this, _.pick(options, viewOptions));
}
_setTemplate.call(this, options);
_setModel.call(this, options);
_setBoundEventListeners.call(this, options);
if (options && options.plugins && options.plugins.length) {
_.each(options.plugins, function (plugin) {
plugin.call(self, options);
});
}
this.children = [];
}
/**
* Wrapper method for lifecycle methods. Life cycle event are notifed both throw the progress returned by this
* method's promise, and by events triggered on the view. So - for example - plugins can hook into life cycle
* events.
*
* In order, this method:
* - Calls this.beforeRender()
* - Starts any child views
* - Notifies that beforeRender is done
* - If the view has a parent, waits for its parent to render
* - Calls this.render()
* - Notifies that render is done
* - Calls this.afterRender()
* - Notifies that afterRender is done
* - Resolves the returned promise
*
* @memberof masseuse/BaseView#
* @param {jQuery.promise} $parentRenderPromise - If passed in, the start method was called from a parent view
* start() method.
* @returns {jQuery.promise} A promise that is resolved when when the start method has completed
*/
function start ($parentRenderPromise) {
var self = this,
$deferred;
if (this.$startPromise) {
return this.$startPromise;
}
$deferred = new $.Deferred();
$deferred.done(function() {
self.hasStarted = true;
});
this.$startPromise = $deferred.promise();
lifeCycle.runAllMethods.call(this, $deferred, $parentRenderPromise);
return this.$startPromise;
}
/**
* @memberof masseuse/BaseView#
*/
function render () {
this.appendOrInsertView(arguments[arguments.length - 1]);
this.elementCache = _.memoize(elementCache.bind(this));
}
/**
* @memberof masseuse/BaseView#
* @param $startDeferred
*/
function appendOrInsertView ($startDeferred) {
this.appendTo || this.prependTo ? _appendTo.call(this, $startDeferred) : _insertView.call(this, $startDeferred);
}
/**
* And element cache that uses sizzle selectors and the context of the view.
* @param selector - the sizzle selector to look for and cache
* @returns {*|Mixed}
*/
function elementCache (selector) {
return this.$el.find(selector);
}
/**
* @memberof masseuse/BaseView#
* @returns {Object|string|*}
*/
function dataToJSON () {
return this.model ? this.model.toJSON() : {};
}
/**
* bindEventListeners
* Bind all event listeners specified in and 'options.listeners' using 'listenTo'. Either `option.bindings` or
* `options.listeners` can be used.
*
* @memberof masseuse/BaseView#
* @param bindingsArray (Array[Array]) - A collection of arrays of arguments that will be used with
* 'Backbone.Events.listenTo'
*
* @example:
* bindEventListeners([['myModel', 'change:something', 'myCallbackFunction']]);
*
* @remarks
* Passing in an array with a string as the first parameter will attempt to bind to this[firstArgument] so that
* it is possible to listen to view properties that have not yet been instantiated (i.e. viewModels)
*/
function bindEventListeners (bindingsArray) {
var self = this;
this.stopListening();
bindingsArray = _.map(bindingsArray, function (oneBindingArray) {
var onListener = 2 === oneBindingArray.length,
excludedIndex = onListener ? 0 : 1;
// Since the view config object doesn't have access to the view's context, we must provide it
_.each(oneBindingArray, function (arg, index) {
// Leave the second array item as a string
if (_.isString(arg) && index != excludedIndex) {
oneBindingArray[index] = accessors.getProperty(self, arg);
}
});
if (onListener) {
oneBindingArray.unshift(self);
}
return oneBindingArray;
});
// TODO: test that duplicate items will pick the bindings from options, throwing out defaults
bindingsArray = _.uniq(bindingsArray, function (a) {
return _.identity(a);
});
_.each(bindingsArray, function (listenerArgs) {
self.listenTo.apply(self, listenerArgs);
});
}
/**
* Removes this view and all its children. Additionally, this view removes itself from its parent view.
* @memberof masseuse/BaseView#
*/
function remove () {
this.removeAllChildren();
if (this.parent && _.contains(this.parent.children, this)) {
this.parent.removeChild(this);
}
this.trigger('onRemove');
if (this.model) {
this.model.off();
this.model.stopListening();
delete this.model;
}
if (!this.$el) {
this.$el = $('<div/>');
}
Backbone.View.prototype.remove.apply(this, arguments);
if (this.$el) {
this.undelegateEvents();
this.$el.removeData().unbind();
this.$el.empty();
delete this.$el;
}
if (this.el) {
delete this.el;
}
}
/**
* Add multiple child views. The method receives either an array of views to be
* added or is called with all the views to be added.
* @memberof masseuse/BaseView#
* @method
* @param childView
*/
function addChildren () {
var args = _.isArray(arguments[0]) ? arguments[0] : arguments,
self = this,
children = [];
_.each(args, function(child) {
children.push(self.addChild.call(self, child));
});
return children;
}
/**
* Add a child view to the array of this views child view references.
* The child must be started later. This happens in start or manually.
*
* This method can take either a view instance or options for a view.
*
* If options for a view are passed in, then BaseView is the default ViewType. The
* ViewType can be declared on the `options.ViewType`.
*
* @memberof masseuse/BaseView#
* @method
* @param childView
*/
function addChild (childView) {
if (childView instanceof Backbone.View) {
return _addChildInstance.call(this, childView);
} else {
return _addChildInstance.call(this, new BaseView(childView));
}
}
function _addChildInstance (childView) {
var child;
if (!_(this.children).contains(childView)) {
this.children.push(child = childView);
childView.parent = this;
if(this.hasStarted && !childView.hasStarted) {
childView.start();
}
}
return child;
}
/**
* Remove one child from the parentView.
* @memberof masseuse/BaseView#
* @param childView
*/
function removeChild (childView) {
// TODO: increase efficiency here
this.children = _(this.children).without(childView);
childView.remove();
}
/**
* Remove all children from the parentView.
* @memberof masseuse/BaseView#
*/
function removeAllChildren () {
_(this.children).each(this.removeChild.bind(this));
}
/**
* Remove this view and all its children. Then restart them all.
* @memberof masseuse/BaseView#
* @returns {$promise}
*/
function refresh () {
if (this.hasStarted) {
Backbone.View.prototype.remove.apply(this);
delete this.hasStarted;
delete this.$startPromise;
}
return this.start();
}
/**
* Remove all children and restart them.
* @memberof masseuse/BaseView#
* @returns $promise - will be resolved once all children are restarted
*/
function refreshChildren () {
var $deferred = new $.Deferred(),
childPromiseArray = [];
_(this.children).each(function (child) {
childPromiseArray.push(child.refresh());
});
$.when.apply($, childPromiseArray).then($deferred.resolve);
return $deferred.promise();
}
/**
* Private Methods - must be supplied with context
* @private
*/
function _appendTo ($startDeferred) {
var template = this.template,
$newEl,
wrapper = this.wrapper !== false;
template = template ? template(this.dataToJSON()) : '';
$newEl = wrapper ? this.el : $(template);
// More than 1 root level element and no wrapper leads to this.el being incorrect.
if (!wrapper && 1 === $newEl.length) {
this.setElement($newEl);
} else {
this.$el.html(template);
}
$startDeferred && $startDeferred.notify && $startDeferred.notify(AFTER_TEMPLATING_DONE);
if (this.appendTo) {
_addViewElement.call(this, 'appendTo');
} else if (this.prependTo) {
_addViewElement.call(this, 'prependTo');
}
}
function _addViewElement (action) {
if (this.parent && _.isString(this[action])) {
$(this.el)[action](this.parent.$(this[action]));
} else {
$(this.el)[action]($(this[action]));
}
}
function _insertView ($startDeferred) {
var template = this.template;
if (template) {
this.$el.html(template(this.dataToJSON()));
}
$startDeferred && $startDeferred.notify && $startDeferred.notify(AFTER_TEMPLATING_DONE);
}
function _setTemplate (options) {
if (options && options.template) {
this.template = _.template(options.template);
}
}
function _setModel (options) {
var self = this,
ModelType = MasseuseModel,
modelData;
if (options && options.ModelType) {
ModelType = options.ModelType;
}
if (!this.model) {
modelData = _.result(options, MODEL_DATA);
_.each(modelData, function (datum, key) {
if (datum instanceof ViewContext) {
modelData[key] = datum.getBoundFunction(self);
}
if (datum instanceof ProxyProperty || datum instanceof ObserverProperty) {
if (datum.model instanceof ViewContext) {
datum.model = datum.model.getBoundFunction(self);
}
}
});
this.model = new ModelType(modelData);
} else {
this.model = options.model;
}
}
function _setBoundEventListeners (options) {
if (!options) {
return;
}
this.bindEventListeners(options.listeners || options.bindings);
}
});