Source: F.View.js

(function() {
	// A couple functions required to override delegateEvents
	var delegateEventSplitter = /^(\S+)\s*(.*)$/;
	var getValue = function(object, prop) {
		if (!(object && object[prop])) return null;
		return _.isFunction(object[prop]) ? object[prop]() : object[prop];
	};
	
	F.View = Backbone.View.extend(/** @lends F.View# */{
		/**
			Generic view class. Provides rendering and templating based on a model, eventing based on a component, and element management based on a container or existing element
			
			@constructs
			
			@param {Object}			options				Options for this view
			@param {Template}		options.template	The template to render this view with
			@param {Element}		[options.el]		The DOM element, jQuery selector, or jQuery object to render this view to. Should not be used with options.container
			@param {Element}		[options.container]	The DOM element, jQuery selector, or jQuery object to insert this components element into. Should not be used with options.el
			@param {Backbone.Model}	[options.model]		Instance of a Backbone model to render this view from
			@param {Component}		[options.component]	The component that events should be delegated to
			@param {Object}			[options.events]	Backbone events object indicating events to listen for on this view
			
			@property {Template} template		The template to render this view with
		*/
		initialize: function() {
			if (this.options.container !== undefined && this.options.el !== undefined) {
				throw new Error('View: should provide either options.el or options.container, never both');
			}
			
			var template = this.options.template || this.template;
			if (template) {
				if (F.options.precompiledTemplates)
					this.template = template;
				else // For pre-compiled templates
					this.template = Handlebars.template(template);
			}
			
			// Always call in our scope so parents can remove change listeners on models by referencing view.render
			this.render = this.render.bind(this);
			
			if (this.options.el) {
				// Make sure the element is of the right tag
				var actualNodeName = this.$el[0].nodeName.toUpperCase();
				var requiredNodeName =  this.tagName.toUpperCase();
				
				// TBD: Revisit this check later; must be a better way to allow any node
				if (this.tagName !== 'div' && actualNodeName !== requiredNodeName) {
					throw new Error('View: cannot create view, incorrect tag provided. Expected '+requiredNodeName+', but got '+actualNodeName);
				}
			
				// Add the CSS class if it doesn't have it
				this.$el.addClass(this.className);
			}
			
			// Store container, if provided
			this.container = this.options.container;
			
			// Store the controlling component
			this.component = this.options.component;
			
			// Add events
			if (this.options.events)
				this.delegateEvents(this.options.events);

			// Store the model
			if (this.options.model)
				this.setModel(this.options.model);
			
			this.rendered = null;
		},
		
		setModel: function(model) {
			// Unsubscribe from old model's change and render event in case view.remove() was not called
			if (this.model && this.model.off)
				this.stopListening(this.model);
			
			this.model = model;
			
			// Either the view or the component can have noRerender
			var noRerender = this.options.noRerender || (this.options.component && this.options.component.options.noRerender);
			
			// Add change listeners to the model, but only if has an on method
			if (this.model && this.model.on && !noRerender)
				this.listenTo(this.model, 'change', this.render);
			
			this.rendered = null;
		},
		
		/**
			Get the time since the view was last rendered
			
			@returns {number} Number of milliseconds since this view was rendered
		*/
		age: function() {
			return this.rendered !== null ? new Date().getTime() - this.rendered : -1;
		},
		
		/**
			Remove this view from the DOM and stop listening to model change events
		*/
		remove: function() {
			this.$el.remove();
		
			// Remove change listeners
			if (this.model && this.model.off)
				this.stopListening();
		},
		
		/**
			Show the view. The view will be rendered before it is shown if it hasn't already been rendered
			
			@returns {F.View}	this, chainable
		*/
		show: function() {
			// Ensure the view is rendered
			if (this.template) {
				this.renderOnce();
			}
			
			// Show the view
			this.$el.show();
			
			return this;
		},
	
		/**
			Hide the view
			
			@returns {F.View}	this, chainable
		*/
		hide: function() {
			// Hide the view
			this.$el.hide();
		
			return this;
		},
	
		/**
			Render the view only if it has not been rendered before (or has been reset)
			
			@returns {F.View}	this, chainable
		*/
		renderOnce: function() {
			// Only render the view if it has never been rendered
			if (this.rendered === null) {
				this.render();
			}
		
			return this;
		},

		inDebugMode: function() {
			// Check if the component or F itself is in debug mode
			return F.options.debug || (this.component && this.component.options.debug); 
		},

		/**
			Render the view
			
			@returns {F.View}	this, chainable
		*/
		render: function() {
			if (this.inDebugMode()) {
				console.log('%s: Rendering view...', this.component && this.component.toString() || 'Orphaned view');
			}
			
			// Render template
			if (this.template) {
				// Use this view's model, or the model of the component it's part of
				var model = this.model || (this.component && this.component.model);
				
				// First, see if the model exists. If so, see if it has toJSON. If so, use model.toJSON. Otherwise, if model exists, use model. Otherwise, use {}
				this.$el.html(this.template(model && model.toJSON && model.toJSON() || model || {}));
			}
			
			// Add to container, if not already there
			if (this.container && !$(this.el.parentNode).is(this.container)) {
				$(this.container).append(this.el);
			}
		
			// Store the last time this view was rendered
			this.rendered = new Date().getTime();
			
			// Notify render has completed
			this.trigger('renderComplete');
			
			return this;
		},
		
		/**
			Delegate events to this view. This overrides Backbone.View.delegateEvents and lets us specify an object to call methods on instead of the view
			
			@param {Object}	events	Events hash to delegate
			
			@returns {F.View}	this, chainable
		*/
		delegateEvents: function(events) {
			if (!(events || (events = getValue(this, 'events')))) return;
			this.undelegateEvents();
			for (var key in events) {
				var method = events[key];
				
				var base = this.component;
						
				if (!_.isFunction(method)) {
					if (this.component) {
						var parts = events[key].split('.');
						
						if (parts.length > 1) {
							method = this.component;
						
							for (var i = 0; i < parts.length; i++) {
								var part = parts[i];
							
								// console.log('Looking for part ', part, 'in ', method);
								base = method;
								method = method[part];
							
								if (!method) {
									// console.warn('Could not find method %s', key);
									break;
								}
							}
						}
						else
							method = this.component[events[key]];
					}
					else {
						method = this[events[key]];
					}
				}
				
				if (!method) throw new Error('Method "' + events[key] + '" does not exist');
				
				var match = key.match(delegateEventSplitter);
				var eventName = match[1], selector = match[2];

				// Execute in the scope of the base
				// TBD: determine if we ought to execute in the scope of the view itself ever?
				//		or leave that up to implementors to pass a bound function
				method = this.component ? _.bind(method, base) : _.bind(method, this);
				
				eventName += '.delegateEvents' + this.cid;
				if (selector === '') {
					this.$el.bind(eventName, method);
				} else {
					this.$el.delegate(selector, eventName, method);
				}
			}
			
			return this;
		}
	});
}());