UNPKG

rendr

Version:

Render your Backbone.js apps on the client and the server.

584 lines (490 loc) 15.9 kB
/** * Since we make rendr files AMD friendly on app setup stage * we need to pretend that this code is pure commonjs * means no AMD-style require calls */ var requireAMD = require; var _ = require('underscore'), Backbone = require('backbone'), async = require('async'), isServer = (typeof window === 'undefined'), BaseView; if (!isServer) { Backbone.$ = window.$ || require('jquery'); } module.exports = BaseView = Backbone.View.extend({ constructor: function(options) { this.options = _.extend( this.options || {}, options || {} ); this.parseOptions(options); this.name = this.name || this.app.modelUtils.underscorize(this.constructor.id || this.constructor.name); // parseOptions deals w/ models and collections, but the BaseView will override those changes Backbone.View.call(this, _.omit(options, ['model', 'collection'])); this.render = this.render.bind(this); }, parseOptions: function(options) { /** * Populate `this.options` and alias as `options`. */ var obj; options = _.extend(this.options, options || {}); if (options.app != null) { this.app = this.options.app; } else { throw new Error("options.app expected when initializing a new view") } if (options.parentView != null) { this.parentView = options.parentView; } options = BaseView.parseModelAndCollection(this.app.modelUtils, _.extend({ parse: true }, options)); this.model = options.model; this.collection = options.collection; }, /** * Key for the template */ name: null, /** * Parent of the current view. * We make sure to stick this on the prototype as a runtime optimization * for V8. It's best not to add properties to the instance after initialization. */ parentView: null, /** * Children of the current view. */ childViews: null, /** * Flag whether or not the view is currently being viewed */ viewing: false, /** * Gets array of child views by their name * Empty array is returned when no match is found */ getChildViewsByName: function(name) { return _.where(this.childViews, {name: name}); }, /** * Get data for template. This also acts as a view-model. * Try to return proper data if model or collection is available. */ getTemplateData: function() { var retVal, parsedOptions; if (this.model) { retVal = this.model.toJSON(); } else if (this.collection) { retVal = { models: this.collection.toJSON(), meta: this.collection.meta, params: this.collection.params }; } // Remove options that are duplicates in the templates parsedOptions = _.omit(this.options, ['model', 'collection', 'app']); return _.extend({}, retVal, parsedOptions); }, /** * Add special properties `_app` and `_model` or `_collection` to pass to * the templates. */ decorateTemplateData: function(data) { if (this.app) { data._app = this.app; } if (this.model) { data._model = this.model; } if (this.collection) { data._collection = this.collection; } data._view = this; return data; }, getTemplateName: function() { return this.options.template_name || this.name; }, /** * Get template function */ getTemplate: function() { return this.app.templateAdapter.getTemplate(this.getTemplateName()); }, /** * Any options not to create data-attributes for. */ nonAttributeOptions: ['id', 'className', 'tagName'], /** * Get HTML attributes to add to el. */ getAttributes: function() { var attributes = {}, fetchSummary = {}, modelUtils = this.app.modelUtils, nonAttributeOptions = this.nonAttributeOptions; if (this.attributes) { _.extend(attributes, _.result(this, 'attributes')); } if (this.id) { attributes.id = _.result(this, "id"); } if (this.className) { attributes['class'] = _.result(this, "className"); } // Add `data-view` attribute with view key. // For now, view key is same as template. attributes['data-view'] = this.name; // Add model & collection meta data from options, // as well as any non-object option values. _.each(this.options, function(value, key) { if (!_.isObject(value) && !_.include(nonAttributeOptions, key)) { attributes["data-" + key] = value; } }); fetchSummary = BaseView.extractFetchSummary(modelUtils, this.options); if (!_.isEmpty(fetchSummary)) { attributes['data-fetch_summary'] = JSON.stringify(fetchSummary); } return attributes; }, /** * Turn template into HTML, minus the wrapper element. */ getInnerHtml: function() { var template = this.getTemplate(), data; this._preRender(); data = this.getTemplateData(); data = this.decorateTemplateData(data); if (template == null) { throw new Error(this.name + ": template \"" + this.getTemplateName() + "\" not found."); } return template(data); }, /** * Get the HTML for the view, including the wrapper element. */ getHtml: function() { var html = this.getInnerHtml(), attributes = this.getAttributes(), tagName = _.result(this, "tagName"), attrString; attrString = _.inject(attributes, function(memo, value, key) { return memo += " " + key + "=\"" + _.escape(value) + "\""; }, ''); return "<" + tagName + attrString + ">" + html + "</" + tagName + ">"; }, render: function() { var html = this.getInnerHtml(); this.$el.html(html); // Because we only set the attributes of the outer element // when calling getHtml() (server), let's make sure it also // happens during render() (client). this.$el.attr(this.getAttributes()); this._postRender(); return this; }, /** * If rendered on the client missing its data, * fetch it based on the parameters passed in. */ fetchLazy: function() { var params = {}, fetchOptions, fetchSpec; if (this.options.fetch_params) { if (!_.isObject(this.options.fetch_params)) { throw new Error('fetch_params must be an object for lazy loaded views'); } params = this.options.fetch_params; } else if (this.options.param_name) { params[this.options.param_name] = this.options.param_value; } if (this.options.fetch_options) { if (!_.isObject(this.options.fetch_options)) { throw new Error('fetch_options must be an object for lazy loaded views'); } fetchOptions = this.options.fetch_options; } if (this.options.model_id != null) { params.id = this.options.model_id; } if (this.options.model_name != null) { fetchSpec = { model: { model: this.options.model_name, params: params } }; } else if (this.options.collection_name != null) { fetchSpec = { collection: { collection: this.options.collection_name, params: params } }; } // Allow ability to just pass the full "spec" to a lazy loaded view if (this.options.fetch_spec) { if (!_.isObject(this.options.fetch_spec)) { throw new Error('fetch_spec must be an object for lazy loaded views'); } fetchSpec = this.options.fetch_spec; } this.setLoading(true); this._preRender(); this.app.fetch(fetchSpec, fetchOptions, this._fetchLazyCallback.bind(this)); }, _fetchLazyCallback: function(err, results) { this.setLoading(false); if (err) { this.lazyErrorCallback(err); } else if (this.viewing) { // It's possible that by the time the XHR returns, the user has navigated // away to a new page, check for whether we are viewing first this.parseOptions(results); this.lazyCallback(results); } }, // Override for error in lazy loading lazyErrorCallback: function(err) { console.log("FETCH ERR: " + err); }, // override for successful lazy load lazyCallback: function (result) { this.render(); }, /** * Anything to do before rendering on the client or server. * This is useful for i.e. accessing @model in the client after * @hydrate() is called, but before @getTemplateData() is called. */ _preRender: function() { this.preRender(); this.trigger('preRender'); }, /** * Anything to do after rendering on the client, such initializing jQuery * plugins like sliders, slideshows, etc. */ _postRender: function() { this.attachChildViews(function triggerPostRenderActions() { this.postRender(); this.trigger('postRender'); }); }, /** * To be overridden by subclasses. */ preRender: _.noop, /** * To be overridden by subclasses. */ postRender: _.noop, setLoading: function(loading) { this.$el.toggleClass('loading', loading); this.trigger('loading', loading); }, attachOrRender: function(element, parentView) { var $el = Backbone.$(element); this.parentView = parentView; this.viewing = true; if (this.options.lazy === true && this.options.collection == null && this.options.model == null) { $el.attr('data-view-attached', true); this.setElement($el); return this.fetchLazy(); } if ($el.data('render')) { $el.replaceWith(this.$el); this.render(); } else { $el.attr('data-view-attached', true); this.setElement($el); this.attach(); } }, /** * When HTML is already present (rendered by server), * this is what gets called to bind to the element. */ attach: function() { /** * Call preRender() so we can access things setup by @hydrate() * (like @model) in i.e. @getTemplateData(). */ this._preRender(); /** * We have to call postRender() so client-only things happen, * i.e. initialize slideshows, etc. */ this._postRender(); this.trigger('attach'); }, /** * Happens client-side. * Find all of sub view DOM elements * Get the view key * Call this.getView() * Attach childView */ attachChildViews: function(callback) { var _baseView = this; // Remove all child views in case we are re-rendering through // manual .render() or 'refresh' being triggered on the view. this.removeChildViews(); BaseView.getChildViews(this.app, this, function(views) { _baseView.childViews = views; callback.call(_baseView); }); }, removeChildViews: function() { (this.childViews || []).forEach(function(view) { view.remove(); }); }, remove: function() { // Remove reference to this view from its parentView if (this.parentView && this.parentView.childViews) { this.parentView.childViews = _.without(this.parentView.childViews, this); } this.removeChildViews(); this.childViews = null; this.parentView = null; this.viewing = false; var obj = this.model || this.collection; if (obj) { obj.off(null, null, this); } BaseView.__super__.remove.apply(this, arguments); this.trigger('remove'); } }); /** * Class methods * ------------- */ BaseView.getView = function(viewName, entryPath, callback) { var viewPath; if (!entryPath) entryPath = ''; viewPath = entryPath + "app/views/" + viewName; // check for AMD environment if (typeof callback == 'function') { // Only used in AMD environment if (typeof define != 'undefined') { requireAMD([viewPath], callback); } else { callback(require(viewPath)); } } else { return require(viewPath); } }; BaseView.createChildView = function (ViewClass, options, $el, parentView, cb) { if (!$el.data('view-attached')) { var view = BaseView.attachNewChildView(ViewClass, options, $el, parentView); cb(null, view); } else { cb(null, null); } }; BaseView.getViewOptions = function ($el) { var options = $el.data(); _.each(options, function(value, key) { if (_.isString(value)) { try { value = JSON.parse(value); } catch (err) {} options[key] = value; } }); return options; }; BaseView.attachNewChildView = function(ViewClass, options, $el, parentView) { var view = new ViewClass(options); view.attachOrRender($el, parentView); return view; }; BaseView.getChildViews = function(app, parentView, callback) { var scope = parentView ? parentView.$el : null, list = Backbone.$('[data-view]', scope).toArray(); async.mapSeries(list, function(el, cb) { var $el, options, viewName, fetchSummary; $el = Backbone.$(el); if (!$el.data('view-attached')) { options = BaseView.getViewOptions($el); options.app = app; viewName = options.view; fetchSummary = options.fetch_summary || {}; app.fetcher.hydrate(fetchSummary, { app: app }, function (err, results) { options = _.extend(options, results); BaseView.getView(viewName, app.options.entryPath, function(ViewClass) { BaseView.createChildView(ViewClass, options, $el, parentView, cb); }); }); } else { cb(null, null); } }, function(err, views) { // no error handling originally callback(_.compact(views)); }); }; BaseView.parseModelAndCollection = function(modelUtils, options) { if (options.model != null) { if (!(options.model instanceof Backbone.Model) && options.model_name) { options.model = modelUtils.getModel(options.model_name, options.model, { parse: !!options.parse, app: options.app }); } options.model_name = options.model_name || modelUtils.modelName(options.model.constructor); options.model_id = options.model.id; } if (options.collection != null) { if (!(options.collection instanceof Backbone.Collection) && options.collection_name) { options.collection = modelUtils.getCollection(options.collection_name, options.collection, { parse: !!options.parse, app: options.app, params: options.collection_params }); } options.collection_name = options.collection_name || modelUtils.modelName(options.collection.constructor); options.collection_params = options.collection_params || options.collection.params; } return options; }; BaseView.extractFetchSummary = function (modelUtils, options) { var fetchSummary = {}; _.each(options, function(value, key) { var id, modelOrCollectionId; if (value != null) { if (_.isFunction(value.constructor) && value.constructor.id != null) { modelOrCollectionId = value.constructor.id; if (modelUtils.isModel(value)) { id = value.get(value.idAttribute); if (id == null) { // Bail if there's no ID; someone's using `this.model` in a // non-standard way, and that's okay. return; } // Cast the `id` attribute to string to ensure it's included in attributes. // On the server, it can be i.e. an `ObjectId` from Mongoose. value = id.toString(); fetchSummary[key] = {model: modelOrCollectionId, id: value}; return; } if (modelUtils.isCollection(value) && value.params != null) { fetchSummary[key] = {collection: modelOrCollectionId, params: value.params}; return; } } } }); return fetchSummary; } /** * Noops on the server, because they do DOM stuff. */ if (typeof window === 'undefined') { BaseView.prototype._ensureElement = _.noop; BaseView.prototype.delegateEvents = _.noop; }