UNPKG

backbone-skull

Version:

Skull sits on top of Backbone and protects your application's brains.

1,409 lines (1,199 loc) 49.4 kB
(function(root, factory) { 'use strict'; /* global define, module, exports, require */ if (typeof define === 'function' && define.amd) { // AMD define(['underscore', 'jquery', 'backbone', 'exports'], function(_, $, Backbone, exports) { // AMD. Register as an anonymous module. return factory( root, exports, _, $, Backbone ); }); } else if (typeof exports === 'object') { // Node-like CommonJS require system module.exports = factory( root, exports, require('underscore'), require('jquery'), require('backbone') ); } else { // Browser globals root.Skull = factory( root, {}, root._, (root.jQuery || root.Zepto || root.ender || root.$), root.Backbone ); } }(this, function (root, Skull, _, $, Backbone) { 'use strict'; /** * @author Konstantin Kitmanov [doctor.hogart@gmail.com] * @license MIT * @namespace Skull */ // conflict management var previousSkull = root.Skull; Skull.noConflict = function () { root.Skull = previousSkull; return this; }; // Utility classes /** * Abstract class that can be extended in Backbone way. * Also works with {@link Skull.ResourceRegistry} if it was passed as `registry` in first argument, utilizing {@link Skull.ResourceRegistry.processRegistry} * @class Skull.Abstract */ Skull.Abstract = function () { this.initialize.apply(this, arguments); }; Skull.Abstract.prototype = { /** * @param {Object} options */ initialize: function (options) { if (options && options.registry) { this.registry = options.registry; if (_.result(this, '__registry__')) { Skull.ResourceRegistry.processRegistry(this); } } }, /** * Much like a `_.result`, but ascending to parent * @param {Function} cls class derived in usual Skull paradigm (i.e. with `__super__` property pointing to parent's prototype) * @param {String} propertyName * @returns {*} Most of the times it is Object * @protected */ _parentResult: function (cls, propertyName) { var parentProp = cls.__super__[propertyName]; if (_.isFunction(parentProp)) { return parentProp.call(this); } else { return parentProp; } } }; Skull.Abstract.extend = Backbone.Model.extend; /** * Class for creating object which can listen and trigger events. * Useful when creating buses and so on. * @class Skull.Observable * @extends Skull.Abstract */ Skull.Observable = Skull.Abstract.extend(Backbone.Events); /** * Simple implementation of Registry pattern. Can store plain values or factories. * Stored factory gets memoized — that is, returns same object given the same parameters. Memoizing assumes factory is pure function. * @class Skull.ResourceRegistry * @extends Skull.Abstract * @constructor */ Skull.ResourceRegistry = Skull.Abstract.extend(/** @lends Skull.ResourceRegistry.prototype */{ initialize: function () { /** @private */ this._storage = {}; /** @private */ this._fabric = {}; /** @private */ this._fabricCache = {}; }, /** * Registers object or factory by given `key`. Pass third argument to store factory. * @throws TypeError when not a function passed as factory * @param {String} key * @param {Object} value * @param {Object} [options={}] * @return {Object} what was stored */ register: function (key, value, options) { if (arguments.length === 3) { if (!_.isFunction(value)) { throw new TypeError('Not a function passed as factory with "' + key + '" key'); } this._fabric[key] = [value, options]; this._fabricCache[key] = {}; return this._fabric[key]; } else { this._storage[key] = value; return this._storage[key]; } }, /** * Deletes value from registry * @param {String} key * @param {Boolean} isFactory if true, deletes factory */ unregister: function (key, isFactory) { if (isFactory) { delete this._fabric[key]; delete this._fabricCache[key]; } else { delete this._storage[key]; } }, /** * Returns requested object from registry * @param {String} key * @param {Object} [options] if present, factory would be called instead of fetching plain value * @returns {*} */ acquire: function (key, options) { if (arguments.length === 2) { if (this._fabric[key]) { var fabricConfig = this._fabric[key], fabricFn = fabricConfig[0], fabricPreOptions = fabricConfig[1], params = _.extend({}, fabricPreOptions, options), cacheKey = JSON.stringify(params, function (jsonKey, value) { if (value instanceof Skull.ResourceRegistry) { // do not stringify ResourceRegistry instances, they tend to have circular references return undefined; } else { return value; } }); if (this._fabricCache[key][cacheKey]) { return this._fabricCache[key][cacheKey]; } else { return (this._fabricCache[key][cacheKey] = fabricFn(params)); } } else { return this._storage[key]; } } else { return this._storage[key]; } } }, /** @lends Skull.ResourceRegistry */{ /** * Iterates over `context.__registry__`, acquiring dependencies from it via `context.registry.acquire`. * `__registry__` can be hash or array (or a function returning such hash or array). * * Hash keys are keys to inject acquired resources, and values are keys to acquire. If value is array, than first element is key, second is params fo factory. * Array is simplified form of hash. Following hash and array are equivalent: * ``` * { * 'res1': 'res1', * factory1: ['factory1', {param: 42}] * }, // and * ['res1', ['factory1', {param: 42}]] * ``` * @type {Function} */ processRegistry: function (context) { var items = _.result(context, '__registry__'); if (items) { var registry = context.registry, inject = function (injectAs, resourceRequest) { context[injectAs] = registry.acquire.apply(registry, resourceRequest); }; if (_.isArray(items)) { _.each(items, function (resourceRequest) { if (!_.isArray(resourceRequest)) { resourceRequest = [resourceRequest]; } inject(resourceRequest[0], resourceRequest); }); } else { _.each(items, function (resourceRequest, injectAs) { if (!_.isArray(resourceRequest)) { resourceRequest = [resourceRequest]; } inject(injectAs, resourceRequest); }); } } } }); /** * Detects host and protocol for your API from `script[data-api-domain="http://my.api.example.com"]` * @param {String} [attributeName='data-api-domain'] Definitive attribute name * @type {Function} */ Skull.detectDomain = function (attributeName) { attributeName = attributeName || 'data-api-domain'; var script = $('script[' + attributeName + ']'); if (!script.length) { return {}; } var path = script.attr(attributeName) || '', pathParts = path.split('//'); if (pathParts.length === 2) { return { host: pathParts[1], protocol: pathParts[0].substring(0, pathParts[0].length - 1) }; } else { return { host: path }; } }; /** * A tool to combine domains, ports, protocols, API endpoints with versions ans subtypes into URL. * * Any URL consists of following parts: `protocol://domain/prefix/` * None of this parts are required, but you should understand that setting protocol without domain * will result in relative URL from current domain root: `/restEndpoint/`. * * Note that skipping protocol and adding domain will lead to inheriting protocol from current document: * `//my.api.example.com/restEndpoint/`, and this is completely valid URL. * @class Skull.UrlProvider */ Skull.UrlProvider = Skull.Abstract.extend(/** @lends Skull.UrlProvider.prototype */{ defaults: { host: '', protocol: '', port: false, prefix: '' }, initialize: function (options) { this.params = {}; this.set(options); }, /** * Updates inner state of URL pieces * @param {Object} options */ set: function (options) { this.cachedPath = this.cachedUrl = false; // drop cache this.params = _.extend({}, this.defaults, options); }, /** * Get absolute URL, with domain and protocol if provided. * @returns {String} */ getApiUrl: function () { if (!this.cachedPath) { var parts = []; if (this.params.host) { parts.push('//'); if (this.params.protocol) { parts.unshift(this.params.protocol + ':'); } parts.push(this.params.host); if (this.params.port) { parts.push(':' + this.params.port); } } this.cachedPath = parts.join('') + this.getApiPath(); } return this.cachedPath; }, /** * Returns relative URL from root of domain. * @returns {String} */ getApiPath: function () { if (!this.cachedUrl) { var parts = _.compact([this.params.prefix]); this.cachedUrl = parts.length ? ('/' + parts.join('/') + '/') : '/'; } return this.cachedUrl; } }); /** * Backbone.sync OOP-style * Can emit authorized requests, when provided with `getToken` function via registry * @class Skull.Syncer */ Skull.Syncer = Skull.Abstract.extend(/** @lends Skull.Syncer.prototype */{ __registry__: { getToken: 'getToken' }, /** * Map from CRUD operations to HTTP verbs for default syncer implementation. * @protected */ _methodMap: { 'create': 'POST', 'update': 'PUT', 'delete': 'DELETE', 'read': 'GET', 'patch': 'PATCH' }, defaults: { authHeaderName: 'Authorization', emulateHTTP: false, emulateJSON: false }, /** * @protected */ _urlError: function () { throw new Error('A "url" property or function must be specified'); }, /** * @constructs * @param {Object} options * @param {Boolean} [options.emulateHTTP=false] emulate HTTP 1.1 methods for old servers * @param {Boolean} [options.emulateJSON=false] emulate JSON by encoding the request into an HTML-form * @param {String} [options.authHeaderName='Authorization'] Use this header to pass authorization token * @param {Skull.ResourceRegistry} options.registry registry instance */ initialize: function (options) { this.registry = options.registry; Skull.ResourceRegistry.processRegistry(this); this.params = _.extend({}, this.defaults, options); }, /** * Pretty much the same as `Backbone.sync`, only allows to extend requests with authorization headers * @param {String} method * @param {Backbone.Model|Backbone.Collection} model * @param {Object} [options={}] Allows to override any request param * @returns {jQuery.Deferred} */ sync: function(method, model, options) { var type = this._methodMap[method]; // Default options, unless specified. _.defaults(options || (options = {}), _.pick(this.params, 'emulateHTTP', 'emulateJSON')); // Default JSON-request options. var params = {type: type, dataType: 'json'}; // Ensure that we have a URL. if (!options.url) { params.url = _.result(model, 'url') || this._urlError(); } // Ensure that we have the appropriate request data. if (options.data == null && model && (method === 'create' || method === 'update' || method === 'patch')) { params.contentType = 'application/json'; params.data = JSON.stringify(options.attrs || model.toJSON(options)); } // For older servers, emulate JSON by encoding the request into an HTML-form. if (options.emulateJSON) { params.contentType = 'application/x-www-form-urlencoded'; params.data = params.data ? {model: params.data} : {}; } // For older servers, emulate HTTP by mimicking the HTTP method with `_method` // And an `X-HTTP-Method-Override` header. if (options.emulateHTTP && (type === 'PUT' || type === 'DELETE' || type === 'PATCH')) { params.type = 'POST'; if (options.emulateJSON) { params.data._method = type; } var beforeSend = options.beforeSend; options.beforeSend = function (xhr) { // eslint-disable-line no-shadow xhr.setRequestHeader('X-HTTP-Method-Override', type); if (beforeSend) { return beforeSend.apply(this, arguments); } }; } // Don't process data on a non-GET request. if (params.type !== 'GET' && !options.emulateJSON) { params.processData = false; } params = this._authorize(params); var success = options.success; options.success = function (resp, status, xhr) { // eslint-disable-line no-shadow if (success) { success(resp, status, xhr); } }; var error = options.error; options.error = function (xhr/*, status, thrown*/) { // eslint-disable-line no-shadow if (error) { error(model, xhr, options); } }; // Make the request, allowing the user to override any Ajax options. var xhr = this.ajax(_.extend(params, options)); model.trigger('request', model, xhr, options); return xhr; }, /** * Augments request params with authorization header. Feel free to override with your logic. * @param {Object} params * @returns {Object} augmented request params * @protected */ _authorize: function (params) { var token = this.getToken ? this.getToken() : false, headerName = this.params.authHeaderName; if (token && headerName) { if (!params.headers) { params.headers = {}; } params.headers[headerName] = token; } return params; }, /** * Performs ajax request * @returns {jQuery.Deferred} */ ajax: function () { return $.ajax.apply($, arguments); } }); /** * Skull.Template provides wrapper for template engine (_.template by default). * This wrapper performs caching, error handling and adding a bit of debugging info. * By default Skull.Template fetches templates stored in `script` tags with `js-tpl-<templateName>` class. * @class Skull.Template */ Skull.Template = Skull.Abstract.extend(/** @lends Skull.Template.prototype */{ __registry__: { debug: 'debug' }, defaults: { selectorPrefix: 'script.js-tpl-', trim: true, debug: false, dontCache: false }, tplFunction: _.template, /** * @param {Object} options * @param {String} [options.selectorPrefix='script.js-tpl-'] default selector for finding template nodes * @param {Boolean} [options.trim=true] trim whitespaces from template before compiling * @param {Boolean} [options.dontCache=false] Useful when developing, you can change template right on page without reloading it * @param {Function} [options.tplFunction=_.template] Template function must accept template string and return compiled to function template. Another (and preferable) way to change this is to inherit and override. */ initialize: function (options) { Skull.Template.__super__.initialize.call(this, options); /** * Holds all compiled templates. * Instance property. * @private */ this._templates = {}; this.params = _.extend({}, this.defaults, options); if (options && options.tplFunction) { this.tplFunction = options.tplFunction; } }, /** * Fetches template by name. * @param {String} name * @returns {jQuery} * @protected * @throws {Error} 'No such template' */ _getTemplateNode: function (name) { var fullSelector = this.params.selectorPrefix + name, node = $(fullSelector); if (node.length) { if (node.length > 1) { node = node.eq(0); if (this.params.debug) { /* eslint-disable no-console */ console.warn('Too many template nodes: ' + fullSelector); /* eslint-enable no-console */ } } } else { throw new Error('No such template: "' + name + '". Make sure you have "' + fullSelector + '" node on your page'); } return node; }, /** * Primary templates processing – e.g. whitespace trimming * @param {jQuery} node * @returns {String} * @protected */ _preprocessTemplate: function (node) { var rawTemplate = node.text(); if (this.params.trim) { rawTemplate = $.trim(rawTemplate); } return rawTemplate; }, /** * Compiles template to function * @param {String} rawTemplate * @returns {Function} * @protected */ _compileTemplate: function (rawTemplate) { return this.tplFunction(rawTemplate); }, /** * Gets compiled template by its name * @param {String} name * @returns {Function} * @protected */ _getCompiledTemplate: function (name) { var node = this._getTemplateNode(name), processed = this._preprocessTemplate(node), compiled = this._compileTemplate(processed); return compiled; }, /** * Returns either cached compiled template or compiles it, caches and returns it * @param {String} name template name * @returns {Function} compiled template * @protected */ _getTemplate: function (name) { if (this._templates[name] && !this.params.dontCache) { return this._templates[name]; } else { var tpl = this._getCompiledTemplate(name); this._templates[name] = tpl; return tpl; } }, /** * This normally should be only one Template method you call from other places. * When provided with truthie second argument, returns rendered templates, otherwise, compiled. * When provided with third argument, calls it with passing, again, rendered or compiled template. * @param {String} name * @param {Object} [tplData=null] if passed, function returns rendered template. If not, compiled template. * @param {Function} [callback=null] if defined, will be called instead of returning result. * @return {Function|String|undefined} */ tmpl: function (name, tplData, callback) { var tpl = this._getTemplate(name); if (arguments.length > 1 && tplData) { // should return already rendered template // specify context information, e.g. l10n string, common application data… if (this.params.context) { (tplData.__context__ = this.params.context); } tpl = tpl(tplData); if (this.params.debug) { // surround with debugging comment so we can see where template starts and ends tpl = '<!-- tpl:' + name + ' -->\n' + tpl + '\n<!-- /tpl:' + name + ' -->'; } } // can be used with async rendering if (callback) { callback(tpl); } else { return tpl; } } }); /** * Extends given model class with all {@link Skull.Model} qualities. Useful when you need to use other models, * e.g. http://afeld.github.io/backbone-nested/, but still want DI and registry. * @param {Function} baseModelClass * @returns {constructor} */ Skull.extendModel = function (baseModelClass) { var Model = baseModelClass.extend(/** @lends Skull.Model.prototype */{ __registry__: function () { var registry = { syncer: 'syncer' }; if (this.resource) { registry.getApiUrl = 'getApiUrl'; } return registry; }, url: function () { if (this.resource) { // REST model var url = this.getApiUrl() + this.resource + '/'; if (this.id) { url += encodeURIComponent(this.id) + '/'; } return url; } else { // conventional model return this._parentResult(Model, 'url'); } }, /** @constructs */ constructor: function (attributes, options) { this.registry = options.registry; Skull.ResourceRegistry.processRegistry(this); Model.__super__.constructor.call(this, attributes, options); }, /** * Almost the same as .set method, but always do it's work silently (i.e. not firing any event). * Useful when setting values from UI to prevent «event loop». * @param {Object|String} key Either key or properties hash * @param {Object} [val] Either value or options * @param {Object} [options={}] Additional options */ silentSet: function (key, val, options) { var attrs; // Handle both `"key", value` and `{key: value}` -style arguments. if (_.isObject(key)) { attrs = key; options = val; } else { (attrs = {})[key] = val; } (options || (options = {})).silent = true; return this.set(attrs, options); }, /** * Overridden for registry handling * @returns {Skull.Model} */ clone: function () { return new this.constructor(this.attributes, {registry: this.registry}); }, /** * Delegates sync operations to this.syncer * @return {jQuery.Deferred} */ sync: function (method, model, options) { return this.syncer.sync(method, model, options); }, /** * toTemplate is reserved for generating data for rendering, * e.g. for computed values and so on. Feel free to override. * @returns {Object} */ toTemplate: function () { var tplData = _.clone(this.attributes); return tplData; }, /** * {@link Skull.Abstract#_parentResult} * @type Function */ _parentResult: Skull.Abstract.prototype._parentResult }); return Model; }; /** * Skull.Model is basic model with few enhancements: * * registry handling * * easier REST urls generation (when `resource` field provided) * @class Skull.Model * @extends Backbone.Model */ Skull.Model = Skull.extendModel(Backbone.Model); /** * Extends given collection class with all {@link Skull.Collection} qualities. Useful when you need to use other collections, * but still need DI, registry and other stuff. * @param {Function} baseCollectionClass * @param {Function} [modelClass=SkullModel] * @returns {constructor} */ Skull.extendCollection = function (baseCollectionClass, modelClass) { modelClass = modelClass || Skull.Model; var Collection = baseCollectionClass.extend(/** @lends Skull.Collection.prototype */{ __registry__: function () { var registry = { syncer: 'syncer' }; if (this.resource) { registry.getApiUrl = 'getApiUrl'; } return registry; }, url: function () { if (this.resource) { var url = this.getApiUrl(); url += this.resource + '/'; return url; } else { return this._parentResult(Collection, 'url'); } }, model: modelClass, /** @constructs */ constructor: function (models, options) { this.resource = this.model.prototype.resource; this.registry = options.registry; Skull.ResourceRegistry.processRegistry(this); Collection.__super__.constructor.call(this, models, options); }, /** * Delegates sync operations to this.syncer * @return {jQuery.Deferred} */ sync: function (method, model, options) { return this.syncer.sync(method, model, options); }, /** * Provides data for templates. * {@link Skull.Model#toTemplate} * @returns {Object[]} */ toTemplate: function () { return _.invoke(this.models, 'toTemplate'); }, /** * Prepare a hash of attributes (or other model) to be added to this collection. * Takes care of registry passing. * @param {Object} attrs future model attrs * @param {Object} [options={}] * @private */ _prepareModel: function (attrs, options) { (options || (options = {})).registry = this.registry; return Collection.__super__._prepareModel.call(this, attrs, options); }, /** * Returns a new instance of the collection with an identical list of models. * Takes care of registry passing. * @returns {Skull.Collection} */ clone: function () { return new this.constructor(this.models, {registry: this.registry}); }, /** * {@link Skull.Abstract#_parentResult} * @type Function */ _parentResult: Skull.Abstract.prototype._parentResult }); return Collection; }; /** * Skull.Collection is a collection with few enhancements: * * registry handling * * easier REST urls generation * @class Skull.Collection * @extends Backbone.Collection */ Skull.Collection = Skull.extendCollection(Backbone.Collection); /** * Recursively replaces tokens with values from `ctx`. * @example unfold('$test $test2', false, {test: 'simple string', test2: 'string with $catch22', catch22: 'catch'}) === 'simple string string with catch' * @param {String} src * @param {RegExp|Boolean} [tokenRe=unfold.tokenRe] * @param {Object} [ctx=this] * @returns {String} */ function unfold (src, tokenRe, ctx) { tokenRe = (tokenRe || unfold.tokenRe); ctx = (ctx || this); // jshint ignore:line var replace = function (match, key) { return ctx[key]; }; while (src.match(tokenRe)) { src = src.replace(tokenRe, replace); } return src; } unfold.tokenRe = /\$([^\., -]+)/mg; /** * Extends given view class with all {@link Skull.View} goodness. * @param {Function} baseViewClass * @returns {constructor} */ Skull.extendView = function (baseViewClass) { var View = baseViewClass.extend(/** @lends Skull.View.prototype */{ __registry__: { template: 'template' }, /** * Whether `this.$el` will be completely replaced on rendering */ replaceEl: false, /** * Automatically (and not, if you wish) creates and renders nested views. * Actually is a hash. Each field can take 4 forms: * * `'.js-someSelector': MyViewClass` * * `'.js-anotherSelector': [MyViewClass, {answer: 42}]` // second element will be passed to MyViewClass constructor * * `'.js-yetAnotherSelector': [MyViewClass, 'someMethodName']` // this['someMethodName'] will be called in proper context (`this`), * and result will be passed to MyViewClass constructor * * `'.js-selectorToo': [MyViewClass, function () { return {answer: 42} }]` // second element will be called in proper context, * and result will be passed to MyViewClass constructor * * All mentioned views will be placed to `this.children` hash for further managing during {@link Skull.View#onRender}. * * @type {Object} */ __children__: null, /** * Automatically (and not, if you wish) creates links to nodes inside your view. This is useful (and handy), * when you change some node's attributes several times during view's lifecycle. * Actually is a config in following form: * `somePrettyName: '.some .selector'` * * All defined bits will be placed to `this.ui` hash for further managing during {@link Skull.View#onRender}. */ __ui__: null, /** * @constructs * @param {Object} options */ constructor: function (options) { this.registry = options.registry; Skull.ResourceRegistry.processRegistry(this); View.__super__.constructor.call(this, options); }, initialize: function (options) { View.__super__.initialize.call(this.options); // save reference to parent view this.parent = options.parent; // semi-automated child views management. Should be instance property. this.children = {}; }, /** * Shortcut for rendering this.tpl to `this.$el` (or instead of this element) * @param {Skull.Model|Skull.Collection|Object} [tplData={}] if this parameter have `.toTemplate` method, * it would be called and result will be passed instead * @param {Boolean} [replace=false] whether replace whole `$el` or only replace it's `.html()` */ rr: function (tplData, replace) { // work out parameters var data = {}, replaceEl = false; if (arguments.length === 2) { data = tplData; replaceEl = replace; } else if (arguments.length === 1) { if (_.isBoolean(arguments[0])) { replaceEl = arguments[0]; } else { data = arguments[0]; } } // get data if (data && 'toTemplate' in data && _.isFunction(data.toTemplate)) { data = data.toTemplate(); } // get template var tpl = _.result(this, 'tpl'); if (!tpl) { throw new Error('"tpl" property not found while attaching view "' + this.cid + '" to "' + this.$el.selector + '"'); } // rendering at last var renderedStr = this.template.tmpl(tpl, data); if (replaceEl) { var renderedDom = $(renderedStr); this.$el.replaceWith(renderedDom); this.setElement(renderedDom); } else { this.$el.html(renderedStr); } }, /** * Default rendering procedure: renders `this.collection` or `this.model` or `{}`. * Feel free to override if needed. * @return {*} data passed to template */ render: function () { var uiState = this.collection || this.model || {}; // if we haven't yet $el, then we should replace element either way this.rr(uiState, this.replaceEl ? this.$el : undefined); this.onRender(); return uiState; }, /** * Performs declarative bindings: `__children__`, `__ui__`, events. * Call this method when html is ready. */ onRender: function () { this._ensureUI(); this._ensureSubviews(); this.delegateEvents(); }, _unfoldSelector: function (selector) { var ui = _.result(this, '__ui__'); return unfold(selector, false, ui); }, _ensureUI: function (ui) { if (!ui) { ui = _.result(this, '__ui__'); } if (!ui) { return; // nothing to do here anymore } this.ui = {}; _.each(ui, function (selector, name) { this.ui[name] = this.$(this._unfoldSelector(selector)); }, this); }, delegateEvents: function (events) { if (!events) { events = _.result(this, 'events'); } var ui = _.result(this, '__ui__'); if (!ui || _.isEmpty(ui)) { return View.__super__.delegateEvents.call(this, events); } var refinedEvents = {}; _.each(events, function (handler, eventSignature) { refinedEvents[this._unfoldSelector(eventSignature)] = handler; }, this); return View.__super__.delegateEvents.call(this, refinedEvents); }, _ensureSubviews: function (children, options) { if (!children) { children = _.result(this, '__children__'); } if (children) { var renderView = function (viewClass, selector) { this._renderView(viewClass, selector, options); }; _.each(children, renderView, this); } }, _renderView: function (viewClass, selector, options) { var params = { el: selector }; if (this.model) { params.model = this.model; } if (this.collection) { params.collection = this.collection; } params.viewName = selector; if (options) { params = _.extend({}, params, options); } if (_.isArray(viewClass) && viewClass.length > 1) { params.viewName = viewClass[1].viewName ? viewClass[1].viewName : params.viewName; } this.registerChild(params.viewName, viewClass, params); }, /** * Registers nested view * @param {String} [viewName] defaults to `cid`, if falsy * @param {View} viewClass * @param {Object} options * @return {View} */ registerChild: function (viewName, viewClass, options) { var params = _.extend( { parent: this, registry: this.registry }, options ); if (_.isArray(viewClass)) { if (viewClass.length > 1) { if (_.isFunction(viewClass[1])) { params = _.extend(viewClass[1].call(this), params); } else if (_.isString(viewClass[1])) { if (!(viewClass[1] in this)) { throw new TypeError('Method "' + viewClass[1] + '" does not exist'); } params = _.extend(this[viewClass[1]].call(this), params); } else { params = _.extend(viewClass[1], params); } } viewClass = viewClass[0]; } if (_.isString(params.el)) { params.el = this.$(this._unfoldSelector(params.el)); } if (!viewClass) { throw 'Invalid class when registering child: ' + viewName; } var child = new viewClass(params); // eslint-disable-line new-cap if (viewName) { child.cid = viewName; } else { viewName = child.cid; child.viewName = viewName; } this.children[viewName] = child; return child; }, /** * Carefully removes nested view * @param {String} viewName */ unregisterChild: function (viewName) { if (!this.children[viewName]) { return; } this.children[viewName].remove(); delete this.children[viewName]; }, /** * Carefully removes *all* nested views * @private */ _unregisterChildren: function () { _.each(this.children, function (child, childName) { this.unregisterChild(childName); }, this); }, /** * Cleans up: removes nested views, shuts down events both DOM and Backbone's */ onBeforeRemove: function () { this._unregisterChildren(); this.undelegateEvents(); this.$el.off(); this.off(); }, /** * Acts as `destructor` */ remove: function () { this.onBeforeRemove(); View.__super__.remove.call(this); }, /** * {@link Skull.Abstract#_parentResult} * @type Function */ _parentResult: Skull.Abstract.prototype._parentResult }); return View; }; /** * Fused with automagic, Skull.View is highly configurable tool for creating and manipulating your app's views. * Core differences with vanilla Backbone.View is following: * * Full-cycle nested views automated managing, {@link `Skull.View#__children__`} * * Handy access to often used nodes inside view, {@link `Skull.View#__ui__`} * * Preventing memory leaks and "zombie" callbacks with more thorough {@link Skull.View#remove} method * @class Skull.View */ Skull.View = Skull.extendView(Backbone.View); /** * Skull.Application is very basic sample of application. * It does several things: * 1. creates registry and registers itself as `'app'` * 2. detects domain and other passes URL to {@link UrlProvider} * 3. instantiates syncer * 4. instantiates router * 5. Detects if debug mode is on * 5. renders root view and starts Backbone.history, if `autostart` option passed * * App dispatches route changes. Bind to `path` event to handle them. * @class Skull.Application * @extends Skull.Observable */ Skull.Application = Skull.Observable.extend(/** @lends Skull.Application.prototype */{ defaults: { node: 'html', router: Backbone.Router, syncer: Skull.Syncer, template: Skull.Template, history: { root: '/' } }, /** * @param options app options * @param {Skull.View} options.rootView Skull.View class, intended to be root view * @param {Backbone.Router} options.router Router class to be used * @param {Skull.Syncer} [options.syncer=Skull.Syncer] {@link Skull.Syncer} class to be used * @param {$|String|HTMLElement} [options.node='html'] root node for application; gets passed to `options.rootView` * @param {String} [options.dataDomainSelector] selector to be passed to {@link Skull.detectDomain} * @param {Object} [options.urlOptions] options for {@link Skull.UrlProvider}. * @param {Boolean} [options.debug=false] Whether we are in debug mode, you can provide other ways for checking it * * @param {Boolean} [options.autostart=false] Whether application should start right when instantiated */ initialize: function (options) { this.params = _.extend( {}, _.result(this, 'defaults'), options ); var registry = this.registry = this.params.registry || new Skull.ResourceRegistry(), register = _.bind(registry.register, registry), regComp = this._registerComponent.bind(this); register('app', this); // determine if we're in debug mode register('debug', this._isDebug(this.params)); // URLs detecting var domain = register('domain', Skull.detectDomain(this.params.dataDomainSelector)), urlProvider = register( 'urlProvider', new Skull.UrlProvider(_.extend({}, domain, this.params.urlOptions)) ); register('getApiUrl', _.bind(urlProvider.getApiUrl, urlProvider)); // create router regComp('syncer'); // create syncer regComp('router'); // create template handler regComp('template'); // start app, if we should if (this.params.autostart) { this.start(); } }, /** * Creates application component and registers it, following config * @param {String} componentName should be present in params passed to application * @returns {Object} instance of component * @protected */ _registerComponent: function (componentName) { var component = this.params[componentName]; if (_.isFunction(component)) { return this.registry.register( componentName, new component({ // eslint-disable-line new-cap registry: this.registry }) ); } else { var constructor = component[0], params = component[1]; if (_.isFunction(params)) { params = params.call(this); } params.registry = this.registry; return this.registry.register( componentName, new constructor( // jshint ignore:line params ) ); } }, /** * Creates new instance of `constructor` passing registry in `options`, puts it to registry and returns it. * @param {String} name * @param {Function} constructor * @param {Object|Array} data object for models, array for collections * @param {Object} options * @returns {Object} */ instantiate: function (name, constructor, data, options) { if (!options) { options = {}; } options.registry = this.registry; return this.registry.register(name, new constructor(data, options)); }, /** * Renders root view and starts up Backbone.history. * Call this when your app is ready (or pass `options.autostart` to {@link Skull.Application#initialize}). * Feel free to override. */ start: function () { this.listenTo( Backbone.history, 'route', this.onRoute ); this.registry.register( 'rootView', new this.params.rootView({ // eslint-disable-line new-cap el: this.params.node, registry: this.registry }) ); Backbone.history.start(this.params.history); }, /** * Primarily dispatches route change. Feel free to override. * @param {Backbone.Router} router router which fired event * @param {String} routeName name of matched route * @param {Object} params parameters parsed from route, if any */ onRoute: function (router, routeName, params) { this.currentRoute = [routeName, params]; this.trigger('path', routeName, params); }, /** * How application determines if debug is on. Feel free to override, * this naïve implementation considers only if there's truthy field `debug` * @param {Object} params * @private */ _isDebug: function (params) { this.debug = !!params.debug; } }); return Skull; }));