diff --git a/src/collection.js b/src/collection.js index 6bc1bb89..f2698a94 100644 --- a/src/collection.js +++ b/src/collection.js @@ -1,4 +1,8 @@ -/*global assignView, assignTemplate, createRegistryWrapper, dataObject, getValue, modelCidAttributeName, viewCidAttributeName */ +/*global + $serverSide, + assignView, assignTemplate, createRegistryWrapper, dataObject, getValue, + modelCidAttributeName, modelIdAttributeName, viewCidAttributeName +*/ var _fetch = Backbone.Collection.prototype.fetch, _set = Backbone.Collection.prototype.set, _replaceHTML = Thorax.View.prototype._replaceHTML, @@ -107,6 +111,51 @@ Thorax.CollectionView = Thorax.View.extend({ } }, + restore: function(el) { + var self = this, + children = this.$el.children(), + toRemove = []; + + if (!Thorax.View.prototype.restore.call(this, el)) { + // If we had to rerender for other reasons then we don't need to do anything as it + // was already all blown away + return; + } + + this._lookupCollectionElement(); + + // Find any items annotated with server info and restore. Else rerender + $('[' + modelIdAttributeName + ']', el).each(function() { + var id = this.getAttribute(modelIdAttributeName), + model = self.collection.get(id); + if (!model) { + toRemove.push(this); + } else { + self.restoreItem(model, children.index(this), this); + } + }); + $('[data-view-empty]', el).each(function() { + self.restoreEmpty(this); + }); + + if (toRemove.length) { + // TODO : Is it better to just rerender the whole thing since we don't necessarily + // know what state the list is in at this point? + // Kill off any now invalid nodes + _.each(toRemove, function(el) { + el.parentNode.removeChild(el); + }); + + // Render anything that we might have locally but was missed + var $el = this._collectionElement; + this.collection.each(function(model) { + if (!$el.find('[' + modelCidAttributeName + '="' + model.cid + '"]').length) { + self.appendItem(model); + } + }); + } + }, + //appendItem(model [,index]) //appendItem(html_string, index) //appendItem(view, index) @@ -154,7 +203,10 @@ Thorax.CollectionView = Thorax.View.extend({ }); if (model) { - itemElement.attr(modelCidAttributeName, model.cid); + itemElement.attr({ + 'data-model-id': model.id, + 'data-model-cid': model.cid + }); } var previousModel = index > 0 ? collection.at(index - 1) : false; if (!previousModel) { @@ -166,7 +218,10 @@ Thorax.CollectionView = Thorax.View.extend({ } this.trigger('append', null, function($el) { - $el.attr(modelCidAttributeName, model.cid); + $el.attr({ + 'data-model-cid': model.cid, + 'data-model-id': model.id, + }); }); if (!options || !options.silent) { @@ -258,12 +313,20 @@ Thorax.CollectionView = Thorax.View.extend({ viewOptions.template = this.emptyTemplate; } var view = Thorax.Util.getViewInstance(this.emptyView, viewOptions); - view.ensureRendered(); + view.$el.attr('data-view-empty', 'true'); return view; } else { return this.emptyTemplate && this.renderTemplate(this.emptyTemplate); } }, + restoreEmpty: function(el) { + var child = this.renderEmpty(); + + child.restore(el); + this._addChild(child); + return child; + }, + renderItem: function(model, i) { if (!this.itemView) { assignView.call(this, 'itemView', { @@ -294,6 +357,18 @@ Thorax.CollectionView = Thorax.View.extend({ return this.renderTemplate(this.itemTemplate, this.itemContext.call(this, model, i)); } }, + restoreItem: function(model, i, el) { + // Associate the element with the proper model. + el.setAttribute(modelCidAttributeName, model.cid); + + // If we are dealing with something other than a template then reinstantiate the view. + if (this.itemView || this.renderItem !== Thorax.CollectionView.prototype.renderItem) { + var child = this.renderItem(model, i); + + child.restore(el); + this._addChild(child); + } + }, itemContext: function(model /*, i */) { return model.attributes; }, diff --git a/src/data-object.js b/src/data-object.js index 92aae3fb..0b13b14d 100644 --- a/src/data-object.js +++ b/src/data-object.js @@ -1,4 +1,4 @@ -/*global getValue, inheritVars, listenTo, walkInheritTree */ +/*global $serverSide, getValue, inheritVars, listenTo, walkInheritTree */ function dataObject(type, spec) { spec = inheritVars[type] = _.defaults({ @@ -36,7 +36,14 @@ function dataObject(type, spec) { } this.bindDataObject(type, dataObject, _.extend({}, this.options, options)); - $el && $el.attr(spec.cidAttrName, dataObject.cid); + if ($el) { + var attr = {}; + if ($serverSide && spec.idAttrName) { + attr[spec.idAttrName] = dataObject.id; + } + attr[spec.cidAttrName] = dataObject.cid; + $el.attr(attr); + } dataObject.trigger('set', dataObject, old); } else { this[type] = false; diff --git a/src/event.js b/src/event.js index 0f822b3f..72c8d6eb 100644 --- a/src/event.js +++ b/src/event.js @@ -121,7 +121,7 @@ _.extend(Thorax.View.prototype, { } else if (!$serverSide) { // DOM Events if (!params.nested) { - boundHandler = containHandlerToCurentView(boundHandler, this.cid); + boundHandler = containHandlerToCurentView(boundHandler, this); } var name = params.name + '.delegateEvents' + this.cid; @@ -168,10 +168,12 @@ pushDomEvents([ 'focus', 'blur' ]); -function containHandlerToCurentView(handler, cid) { +function containHandlerToCurentView(handler, current) { + // Passing the current view rather than just a cid to allow for updates to the view's cid + // caused by the restore process. return function(event) { var view = $(event.target).view({helper: false}); - if (view && view.cid === cid) { + if (view && view.cid === current.cid) { event.originalContext = this; return handler(event); } diff --git a/src/helper-view.js b/src/helper-view.js index 0e17754d..46f47251 100644 --- a/src/helper-view.js +++ b/src/helper-view.js @@ -1,4 +1,8 @@ -/*global ServerMarshal, $serverSide, getOptionsData, normalizeHTMLAttributeOptions, viewHelperAttributeName */ +/*global + ServerMarshal, + $serverSide, createError, getOptionsData, + normalizeHTMLAttributeOptions, viewHelperAttributeName +*/ var viewPlaceholderAttributeName = 'data-view-tmp', viewTemplateOverrides = {}; @@ -56,17 +60,8 @@ Handlebars.registerViewHelper = function(name, ViewClass, callback) { // Evaluate any nested parameters that we may have to content with var expandTokens = expandHash(this, options.hash); - var viewOptions = { - inverse: options.inverse, - options: options.hash, - declaringView: declaringView, - parent: getParent(declaringView), - _helperName: name, - _helperOptions: { - options: cloneHelperOptions(options), - args: _.clone(args) - } - }; + var viewOptions = createViewOptions(name, args, options, declaringView); + setHelperTemplate(viewOptions, options, ViewClass); normalizeHTMLAttributeOptions(options.hash); var htmlAttributes = _.clone(options.hash); @@ -96,15 +91,6 @@ Handlebars.registerViewHelper = function(name, ViewClass, callback) { return attrs; }; - if (options.fn) { - // Only assign if present, allow helper view class to - // declare template - viewOptions.template = options.fn; - } else if (ViewClass && ViewClass.prototype && !ViewClass.prototype.template) { - // ViewClass may also be an instance or object with factory method - // so need to do this check - viewOptions.template = Handlebars.VM.noop; - } // Check to see if we have an existing instance that we can reuse var instance = _.find(declaringView._previousHelpers, function(child) { @@ -113,25 +99,14 @@ Handlebars.registerViewHelper = function(name, ViewClass, callback) { // Create the instance if we don't already have one if (!instance) { - if (ViewClass.factory) { - instance = ViewClass.factory(args, viewOptions); - if (!instance) { - return ''; - } - - instance._helperName = viewOptions._helperName; - instance._helperOptions = viewOptions._helperOptions; - } else { - instance = new ViewClass(viewOptions); - } - if (!instance.el) { - // ViewClass.factory may return existing objects which may have been destroyed - throw new Error('insert-destroyed-factory'); + instance = getHelperInstance(args, viewOptions, ViewClass); + if (!instance) { + return ''; } instance.$el.attr('data-view-helper-restore', name); - if ($serverSide) { + if ($serverSide && instance.$el.attr('data-view-restore') !== 'false') { try { ServerMarshal.store(instance.$el, 'args', args, options.ids, options); ServerMarshal.store(instance.$el, 'attrs', options.hash, options.hashIds, options); @@ -142,16 +117,11 @@ Handlebars.registerViewHelper = function(name, ViewClass, callback) { ServerMarshal.store(instance.$el, 'inverse', options.inverse.program); } } catch (err) { - instance.$el.attr('data-view-server', 'false'); + instance.$el.attr('data-view-restore', 'false'); } } - args.push(instance); - declaringView._addChild(instance); - declaringView.trigger.apply(declaringView, ['helper', name].concat(args)); - declaringView.trigger.apply(declaringView, ['helper:' + name].concat(args)); - - callback && callback.apply(this, args); + helperInit(args, instance, callback, viewOptions); } else { if (!instance.el) { throw new Error('insert-destroyed'); @@ -178,9 +148,127 @@ Handlebars.registerViewHelper = function(name, ViewClass, callback) { return new Handlebars.SafeString(Thorax.Util.tag(htmlAttributes, '', expandTokens ? this : null)); }); var helper = Handlebars.helpers[name]; + + helper.restore = function(declaringView, el) { + var context = declaringView.context(), + args = ServerMarshal.load(el, 'args', declaringView, context) || [], + attrs = ServerMarshal.load(el, 'attrs', declaringView, context) || {}; + + var options = { + hash: attrs, + fn: ServerMarshal.load(el, 'fn'), + inverse: ServerMarshal.load(el, 'inverse') + }; + if (options.fn) { + options.fn = declaringView.template.child(options.fn); + } + if (options.inverse) { + options.inverse = declaringView.template.child(options.inverse); + } + + var viewOptions = createViewOptions(name, args, options, declaringView); + setHelperTemplate(viewOptions, options, ViewClass); + + if (viewOptionWhiteList) { + _.each(viewOptionWhiteList, function(dest, source) { + if (!_.isUndefined(attrs[source])) { + viewOptions[dest] = attrs[source]; + } + }); + } + + var instance = getHelperInstance(args, viewOptions, ViewClass); + instance._assignCid(el.getAttribute('data-view-cid')); + helperInit(args, instance, callback, viewOptions); + + instance.restore(el); + + return instance; + }; + return helper; }; +Thorax.View.on('restore', function() { + var parent = this, + context; + + function filterAncestors(parent, callback) { + return function() { + if ($(this).parent().view({el: true, helper: true})[0] === parent.el) { + return callback.call(this); + } + }; + } + + parent.$('[data-view-helper-restore]').each(filterAncestors(parent, function() { + if (this.getAttribute('data-view-restore') === 'true') { + var helper = Handlebars.helpers[this.getAttribute('data-view-helper-restore')], + child = helper.restore(parent, this); + parent._addChild(child); + } + })); +}); + +function createViewOptions(name, args, options, declaringView) { + return { + inverse: options.inverse, + options: options.hash, + declaringView: declaringView, + parent: getParent(declaringView), + _helperName: name, + _helperOptions: { + options: cloneHelperOptions(options), + args: _.clone(args) + } + }; +} + +function setHelperTemplate(viewOptions, options, ViewClass) { + if (options.fn) { + // Only assign if present, allow helper view class to + // declare template + viewOptions.template = options.fn; + } else if (ViewClass && ViewClass.prototype && !ViewClass.prototype.template) { + // ViewClass may also be an instance or object with factory method + // so need to do this check + viewOptions.template = Handlebars.VM.noop; + } +} + +function getHelperInstance(args, viewOptions, ViewClass) { + var instance; + + if (ViewClass.factory) { + instance = ViewClass.factory(args, viewOptions); + if (!instance) { + return; + } + + instance._helperName = viewOptions._helperName; + instance._helperOptions = viewOptions._helperOptions; + } else { + instance = new ViewClass(viewOptions); + } + + if (!instance.el) { + // ViewClass.factory may return existing objects which may have been destroyed + throw createError('insert-destroyed-factory'); + } + return instance; +} +function helperInit(args, instance, callback, viewOptions) { + var declaringView = viewOptions.declaringView, + name = viewOptions._helperName; + + args.push(instance); + declaringView._addChild(instance); + declaringView.trigger.apply(declaringView, ['helper', name].concat(args)); + declaringView.trigger.apply(declaringView, ['helper:' + name].concat(args)); + + callback && callback.apply(this, args); +} + function helperAppend(scope, callback) { this._pendingAppend = undefined; @@ -211,6 +299,12 @@ function helperAppend(scope, callback) { function cloneHelperOptions(options) { var ret = _.pick(options, 'fn', 'inverse', 'hash', 'data'); ret.data = _.omit(options.data, 'cid', 'view', 'yield', 'root', '_parent'); + + // This is necessary to prevent failures when mixing restored and rendered data + // as it forces the keys object to be complete. + ret.fn = ret.fn || undefined; + ret.inverse = ret.inverse || undefined; + return ret; } @@ -246,7 +340,7 @@ function compareHelperOptions(a, b) { // Implements a first level depth comparison return a.args.length === b.args.length && compareValues(a.args, b.args) - && _.isEqual(_.keys(a.options), _.keys(b.options)) + && _.isEqual(_.keys(a.options).sort(), _.keys(b.options).sort()) && _.every(a.options, function(value, key) { if (key === 'data' || key === 'hash') { return compareValues(a.options[key], b.options[key]); diff --git a/src/helpers/collection.js b/src/helpers/collection.js index 2dfadf68..0643f315 100644 --- a/src/helpers/collection.js +++ b/src/helpers/collection.js @@ -1,6 +1,8 @@ /* global + $serverSide, collectionElementAttributeName, createErrorMessage, getOptionsData, getParent, - helperViewPrototype, normalizeHTMLAttributeOptions + helperViewPrototype, normalizeHTMLAttributeOptions, + viewRestoreAttribute */ Thorax.CollectionHelperView = Thorax.CollectionView.extend({ @@ -19,6 +21,8 @@ Thorax.CollectionHelperView = Thorax.CollectionView.extend({ }, constructor: function(options) { + var restorable = true; + // need to fetch templates if template name was passed if (options.options['item-template']) { options.itemTemplate = Thorax.Util.getTemplate(options.options['item-template']); @@ -33,10 +37,20 @@ Thorax.CollectionHelperView = Thorax.CollectionView.extend({ if (!options.itemTemplate && options.template && options.template !== Handlebars.VM.noop) { options.itemTemplate = options.template; options.template = Handlebars.VM.noop; + + // We can not restore if the item has a depthed reference, ../foo, so we need to + // force a rerender on the client-side + if (options.itemTemplate.depth) { + restorable = false; + } } if (!options.emptyTemplate && options.inverse && options.inverse !== Handlebars.VM.noop) { options.emptyTemplate = options.inverse; options.inverse = Handlebars.VM.noop; + + if (options.emptyTemplate.depth) { + restorable = false; + } } var shouldBindItemContext = _.isFunction(options.itemContext), @@ -72,6 +86,10 @@ Thorax.CollectionHelperView = Thorax.CollectionView.extend({ } } + if ($serverSide && !restorable) { + this.$el.attr(viewRestoreAttribute, 'false'); + } + return response; }, setAsPrimaryCollectionHelper: function() { diff --git a/src/layout.js b/src/layout.js index e42d5d30..354ed0ec 100644 --- a/src/layout.js +++ b/src/layout.js @@ -1,4 +1,8 @@ -/*global $serverSide, emit, getOptionsData, normalizeHTMLAttributeOptions, createErrorMessage */ +/*global + $serverSide, + createErrorMessage, emit, getLayoutViewsTargetElement, getOptionsData, + normalizeHTMLAttributeOptions, viewNameAttributeName +*/ var layoutCidAttributeName = 'data-layout-cid'; Thorax.LayoutView = Thorax.View.extend({ @@ -22,7 +26,17 @@ Thorax.LayoutView = Thorax.View.extend({ if (_.isString(view)) { view = new (Thorax.Util.registryGet(Thorax, 'Views', view, false))(); } + + if (!$serverSide && !this.hasBeenSet) { + var existing = this.$('[' + viewNameAttributeName + '="' + view.name + '"]')[0]; + if (existing) { + view.restore(existing); + } else { + $(this._layoutViewEl).empty(); + } + } this.ensureRendered(); + var oldView = this._view, append, remove, complete; if (view === oldView) { return false; @@ -61,6 +75,7 @@ Thorax.LayoutView = Thorax.View.extend({ }, this); complete = _.bind(function() { + this.hasBeenSet = true; this.trigger('change:view:end', view, oldView, options); }, this); @@ -80,6 +95,8 @@ Thorax.LayoutView = Thorax.View.extend({ } }); +Thorax.LayoutView.on('restore', ensureLayoutViewsTargetElement); + Handlebars.registerHelper('layout-element', function(options) { var view = getOptionsData(options).view; // duck type check for LayoutView diff --git a/src/model.js b/src/model.js index d56b1086..2e3ffce1 100644 --- a/src/model.js +++ b/src/model.js @@ -1,5 +1,6 @@ /*global createRegistryWrapper, dataObject, getValue, inheritVars */ -var modelCidAttributeName = 'data-model-cid'; +var modelCidAttributeName = 'data-model-cid', + modelIdAttributeName = 'data-model-id'; Thorax.Model = Backbone.Model.extend({ isEmpty: function() { @@ -46,6 +47,7 @@ dataObject('model', { }, change: onModelChange, $el: '$el', + idAttrName: modelIdAttributeName, cidAttrName: modelCidAttributeName }); diff --git a/src/thorax.js b/src/thorax.js index 99907c65..9ea77e99 100644 --- a/src/thorax.js +++ b/src/thorax.js @@ -25,7 +25,14 @@ if (!$.fn.forEach) { var viewNameAttributeName = 'data-view-name', viewCidAttributeName = 'data-view-cid', - viewHelperAttributeName = 'data-view-helper'; + viewHelperAttributeName = 'data-view-helper', + + // Used to identify views that can be restored vs. rerendered on the client side. + // Values are: + // - true - Can be restored + // - false - Must be rerendered + // - Omitted - Normal HTML element without associated view + viewRestoreAttribute = 'data-view-restore'; //view instances var viewsIndexedByCid = {}; @@ -91,16 +98,43 @@ Thorax.View = Backbone.View.extend({ _configure: function() {}, _ensureElement: function () { configureView.call(this); + + if (!$serverSide && this.el) { + var $el = $(_.result(this, 'el')); + if ($el.length) { + return this.restore($el); + } + } + return Backbone.View.prototype._ensureElement.call(this); }, - setElement : function() { + setElement : function(element) { + var $element = $(element), + existingCid = $element.attr('data-view-cid'); + if (existingCid) { + this._assignCid(existingCid); + } + var response = Backbone.View.prototype.setElement.apply(this, arguments); - this.name && this.$el.attr(viewNameAttributeName, this.name); - this.$el.attr(viewCidAttributeName, this.cid); + + // Use a hash here to avoid multiple DOM operations + var attr = {'data-view-cid': this.cid}; + if (this.name) { + attr[viewNameAttributeName] = this.name; + } + this.$el.attr(attr); + return response; }, + _assignCid: function(cid) { + if (this.cid) { + delete viewsIndexedByCid[this.cid]; + } + this.cid = cid; + viewsIndexedByCid[cid] = this; + }, _addChild: function(view) { if (this.children[view.cid]) { @@ -157,6 +191,39 @@ Thorax.View = Backbone.View.extend({ this._helperOptions = undefined; }, + restore: function(element) { + if (this._renderCount) { + // Ensure that we are registered to the right cid (this could have been reset previously) + var oldCid = this.$el.attr('data-view-cid'); + if (this.cid !== oldCid) { + this._assignCid(oldCid); + } + + $(element).replaceWith(this.$el); + return; + } + + this.setElement(element); + + var $element = $(element); + if (!$serverSide && $element.attr(viewRestoreAttribute) === 'true') { + this._renderCount = 1; + this.trigger('restore'); + + var remainingViews = this.$('[data-view-restore=false]'), + rerender = _.any(remainingViews, function(el) { + return $(el).parent().view({el: true, helper: true})[0] === $element[0]; + }); + if (rerender) { + this.render(); + } + + return true; + } else { + this.render(); + } + }, + render: function(output) { var self = this; // NOP for destroyed views @@ -210,6 +277,15 @@ Thorax.View = Backbone.View.extend({ }, self); self._previousHelpers = undefined; + + if ($serverSide) { + if (self.$el.attr(viewRestoreAttribute) !== 'false') { + self.$el.attr(viewRestoreAttribute, $serverSide); + } + } else { + self.$el.removeAttr(viewRestoreAttribute); + } + //accept a view, string, Handlebars.SafeString or DOM element self.html((output && output.el) || (output && output.string) || output); diff --git a/test/src/server-side.js b/test/src/server-side.js index c325a233..f5093301 100644 --- a/test/src/server-side.js +++ b/test/src/server-side.js @@ -1,3 +1,5 @@ +/*global $serverSide */ + describe('serverSide', function() { var _serverSide = window.$serverSide, emit; @@ -76,4 +78,966 @@ describe('serverSide', function() { expect(end.called).to.be(false); }); }); + + describe('rendering', function() { + it('should track server-side rendering vs. not', function() { + var view = new Thorax.View({template: function() { return 'foo'; }}); + view.render(); + expect(view.$el.attr('data-view-restore')).to.equal('true'); + + window.$serverSide = false; + view = new Thorax.View({template: function() { return 'foo'; }}); + view.render(); + expect(view.$el.attr('data-view-restore')).to.not.exist; + }); + }); + + describe('restore', function() { + if ($serverSide) { + return; + } + + var count, + Counter = Thorax.View.extend({ + template: function() { + return 'foo_' + count++; + } + }), + SomethingElse = Thorax.View.extend({ + _name: 'somethingelse', + template: function() { return 'somethingelse'; } + }), + render, + fixture, + server, + view; + + function cleanIds(view) { + view.$('[data-model-cid]').each(function() { + $(this).removeAttr('data-model-cid'); + }); + view.$('[data-view-restore]').each(function() { + $(this).removeAttr('data-view-restore'); + }); + view.$('[data-server-data]').each(function() { + $(this).removeAttr('data-server-data'); + }); + } + function restoreView(shouldRender) { + server.render(); + + var el = server.$el.clone(); + fixture.append(el); + + window.$serverSide = false; + + render.reset(); + view.restore(el); + expect(render.called).to.equal(!!shouldRender); + } + function compareViews() { + cleanIds(view); + cleanIds(server); + expect(view.$el.html()).to.equal(server.$el.html()); + } + + beforeEach(function() { + window.$serverSide = false; + count = 0; + + fixture = $('
'); + $('body').append(fixture); + + render = this.spy(Thorax.View.prototype, 'render'); + + Thorax.Views.registry = Thorax.View.extend({ + name: 'registry', + template: function() { + return 'foo ' + count++; + } + }); + }); + afterEach(function() { + fixture.remove(); + }); + + it('should restore views explicitly', function() { + var el = $('
bar
'); + fixture.append(el); + + var view = new Thorax.View({ + el: '.foo-view' + }); + expect(view.el).to.equal(el[0]); + expect(view._renderCount).to.equal(1); + expect(el.html()).to.equal('bar'); + el.remove(); + + el = $('
bar
'); + fixture.append(el); + view = new Thorax.View({ + el: function() { + return '.foo-view'; + } + }); + expect(view.el).to.equal(el[0]); + expect(view._renderCount).to.equal(1); + expect(el.html()).to.equal('bar'); + }); + it('should re-render non-server elements on restore', function() { + var el = $('
bar
'); + fixture.append(el); + + var view = new Thorax.View({ + el: '.foo-view', + template: function() { + return 'bat'; + } + }); + expect(view.el).to.equal(el[0]); + expect(view._renderCount).to.equal(1); + expect(el.html()).to.equal('bat'); + }); + + it('should restore views with a passed el', function() { + var el = $('
bar
'); + fixture.append(el); + + var view = new Thorax.View({}); + view.restore(el); + + expect(view.el).to.equal(el[0]); + expect(view._renderCount).to.equal(1); + expect(el.html()).to.equal('bar'); + }); + + it('should update view attributes on restore', function() { + var el = $('
bar
'); + fixture.append(el); + + var spy = this.spy(); + var view = new Thorax.View({ + events: { + click: spy + } + }); + view.restore(el); + + expect(view.$el.attr('data-view-cid')).to.equal(view.cid); + }); + + if (!$serverSide) { + it('should restore DOM events', function() { + var el = $('
bar
'); + fixture.append(el); + + var spy = this.spy(); + var view = new Thorax.View({ + events: { + click: spy + } + }); + view.restore(el); + + el.trigger('click'); + expect(spy.calledOnce).to.be(true); + }); + } + + it('should replace restore of rendered view', function() { + var el = $('
bar
'); + fixture.append(el); + + var view = new Thorax.View({}); + view.render('foo'); + view.restore(el); + + var el = fixture.find('div'); + expect(view.el).to.equal(el[0]); + expect(view._renderCount).to.equal(1); + expect(el.html()).to.equal('foo'); + expect(view.$el.attr('data-view-restore')).to.not.exist; + }); + + describe('setView', function() { + var layout, + view; + beforeEach(function() { + layout = new Thorax.LayoutView(); + layout.render(); + + view = new Thorax.View({ + name: 'winning', + template: function() { + return 'not winning'; + } + }); + }); + it('should restore matching views', function() { + var $el = $('
winning
'); + layout.$el.append($el); + + layout.setView(view); + expect(view.$el.html()).to.equal('winning'); + expect(layout._view).to.equal(view); + expect(layout.$el.children().length).to.equal(1); + expect(layout.$el.text()).to.equal('winning'); + }); + it('should rerender non-matching views', function() { + var $el = $('
winning
'); + layout.$el.append($el); + + layout.setView(view); + expect(view.$el.html()).to.equal('not winning'); + expect(layout._view).to.equal(view); + expect(layout.$el.children().length).to.equal(1); + expect(layout.$el.text()).to.equal('not winning'); + }); + it('should rerender non-server views', function() { + var $el = $('
winning
'); + layout.$el.append($el); + + layout.setView(view); + expect(view.$el.html()).to.equal('not winning'); + expect(layout._view).to.equal(view); + expect(layout.$el.children().length).to.equal(1); + expect(layout.$el.text()).to.equal('not winning'); + }); + }); + + describe('view helper', function() { + beforeEach(function() { + window.$serverSide = true; + }); + describe('registry', function() { + it('should rerender anonymous block', function() { + var View = Thorax.View.extend({ + template: Handlebars.compile('{{#view}}something{{/view}}', {trackIds: true}) + }); + + server = new View(); + view = new View(); + restoreView(false); + + compareViews(); + expect(_.keys(view.children).length).to.equal(1); + expect(_.values(view.children)[0].$el.html()).to.equal('something'); + expect(view._renderCount).to.equal(1); + }); + + it('should restore views instantiated through the registry', function() { + server = new Thorax.View({ + template: Handlebars.compile('{{view "registry"}}') + }); + view = new SomethingElse(); + restoreView(); + + compareViews(); + expect(_.keys(view.children).length).to.equal(1); + expect(_.values(view.children)[0]).to.be.a(Thorax.Views.registry); + }); + it('should include view args when instantiating view', function() { + server = new Thorax.View({ + template: Handlebars.compile('{{view "registry" key=4}}', {trackIds: true}) + }); + view = new SomethingElse(); + restoreView(); + + expect(_.values(view.children)[0].key).to.equal(4); + }); + it('should restore named complex args', function() { + server = new Thorax.View({ + foo: { + yes: false + }, + template: Handlebars.compile('{{view "registry" key=4 bar=foo}}', {trackIds: true}) + }); + view = new SomethingElse({ + foo: { + yes: true + } + }); + restoreView(); + + expect(_.values(view.children)[0].key).to.equal(4); + expect(_.values(view.children)[0].bar).to.equal(view.foo); + }); + it('should restore passed classes', function() { + var View = Thorax.View.extend({ + template: Handlebars.compile('{{view ChildClass}}', {trackIds: true}) + }); + + server = new View({ + context: function() { + return { + ChildClass: Counter + }; + } + }); + view = new View({ + context: function() { + return { + ChildClass: SomethingElse + }; + } + }); + restoreView(); + + compareViews(); + expect(_.keys(view.children).length).to.equal(1); + }); + }); + it('should restore named references', function() { + var View = Thorax.View.extend({ + template: Handlebars.compile('{{view child}}', {trackIds: true}) + }); + + server = new View({ + child: new Counter() + }); + view = new View({ + child: new SomethingElse() + }); + restoreView(); + + compareViews(); + expect(_.keys(view.children).length).to.equal(1); + expect(_.values(view.children)[0]).to.equal(view.child); + }); + it('should restore pathed named references', function() { + var View = Thorax.View.extend({ + template: Handlebars.compile('{{view parent.child}}', {trackIds: true}) + }); + + server = new View({ + parent: { + child: new Counter() + } + }); + view = new View({ + parent: { + child: new SomethingElse() + } + }); + restoreView(); + + compareViews(); + expect(_.keys(view.children).length).to.equal(1); + expect(_.values(view.children)[0]).to.equal(view.parent.child); + }); + it('should restore context responses', function() { + var View = Thorax.View.extend({ + template: Handlebars.compile('{{view child}}', {trackIds: true}) + }); + var child = new SomethingElse(); + + server = new View({ + context: function() { + return { + child: new Counter() + }; + } + }); + view = new View({ + context: function() { + return { + child: child + }; + } + }); + restoreView(); + + compareViews(); + expect(_.keys(view.children).length).to.equal(1); + expect(_.values(view.children)[0]).to.equal(child); + }); + it('should restore named references within iterators', function() { + var View = Thorax.View.extend({ + template: Handlebars.compile('{{#each parent}}{{view .}}{{/each}}', {trackIds: true}) + }); + + server = new View({ + parent: { + child: new Counter() + } + }); + view = new View({ + parent: { + child: new SomethingElse() + } + }); + restoreView(); + + compareViews(); + expect(_.keys(view.children).length).to.equal(1); + expect(_.values(view.children)[0]).to.equal(view.parent.child); + }); + it('should rerender ../ depthed references', function() { + var View = Thorax.View.extend({ + template: Handlebars.compile('{{#each parent}}{{view ../parent.child}}{{/each}}', {trackIds: true}) + }); + + server = new View({ + parent: { + child: new Counter() + } + }); + view = new View({ + parent: { + child: new SomethingElse() + } + }); + restoreView(true); + + expect(_.keys(view.children).length).to.equal(1); + expect(_.values(view.children)[0]).to.equal(view.parent.child); + expect(view.parent.child.$el.html()).to.equal('somethingelse'); + expect(view._renderCount).to.equal(2); + }); + it('should restore block view helpers', function() { + var View = Thorax.View.extend({ + template: Handlebars.compile('{{#view child}}something{{/view}}', {trackIds: true}) + }); + + server = new View({ + child: new Counter() + }); + view = new View({ + child: new SomethingElse() + }); + restoreView(); + + expect(_.keys(view.children).length).to.equal(1); + expect(_.values(view.children)[0]).to.equal(view.child); + expect(server.child.$el.html()).to.equal('something'); + expect(view.child.$el.html()).to.equal('something'); + expect(view._renderCount).to.equal(1); + }); + it('should restore block view helper with depth', function() { + var View = Thorax.View.extend({ + template: Handlebars.compile('{{#view child}}{{../root}}{{/view}}', {trackIds: true}), + root: 1 + }); + + server = new View({ + child: new Counter() + }); + view = new View({ + child: new SomethingElse() + }); + restoreView(); + + expect(_.keys(view.children).length).to.equal(1); + expect(_.values(view.children)[0]).to.equal(view.child); + expect(server.child.$el.html()).to.equal('1'); + expect(view.child.$el.html()).to.equal('1'); + expect(view._renderCount).to.equal(1); + }); + it('should restore block view helper with data', function() { + var View = Thorax.View.extend({ + template: Handlebars.compile('{{#view child}}{{@cid}}{{/view}}', {trackIds: true}) + }); + + server = new View({ + child: new Counter() + }); + view = new View({ + child: new SomethingElse() + }); + restoreView(); + + expect(_.keys(view.children).length).to.equal(1); + expect(_.values(view.children)[0]).to.equal(view.child); + expect(view._renderCount).to.equal(1); + }); + + it('should handle partial restore', function() { + var View = Thorax.View.extend({ + template: Handlebars.compile('{{view theGoodOne}}{{view (ambiguousResponse child)}}', {trackIds: true}), + ambiguousResponse: function(child) { + return child; + } + }); + + server = new View({ + theGoodOne: new Counter(), + child: new Counter() + }); + view = new View({ + theGoodOne: new SomethingElse(), + child: new SomethingElse() + }); + restoreView(true); + + expect(_.keys(view.children).length).to.equal(2); + expect(_.values(view.children)[0]).to.equal(view.theGoodOne); + expect(_.values(view.children)[1]).to.equal(view.child); + expect(view.child.$el.html()).to.equal('somethingelse'); + expect(view.theGoodOne.$el.html()).to.equal('foo_0'); + expect(view._renderCount).to.equal(2); + }); + + it('should restore nested views properly', function() { + var View = Thorax.View.extend({ + template: Handlebars.compile('{{view "nested"}}', {trackIds: true}) + }); + + Thorax.Views.nested = Thorax.View.extend({ + name: 'nested', + template: Handlebars.compile('{{view child}}', {trackIds: true}), + child: new Counter() + }); + + server = new View(); + view = new View(); + restoreView(); + + expect(_.keys(view.children).length).to.equal(1); + + var nested = _.values(view.children)[0]; + expect(nested.name).to.equal('nested'); + expect(nested.child.$el.html()).to.equal('foo_0'); + }); + it('should rerender views using subexpressions', function() { + var View = Thorax.View.extend({ + template: Handlebars.compile('{{view (ambiguousResponse child)}}', {trackIds: true}), + ambiguousResponse: function(view) { + return view; + } + }); + + server = new View({ + child: new Counter() + }); + view = new View({ + child: new SomethingElse() + }); + restoreView(true); + + expect(_.keys(view.children).length).to.equal(1); + expect(_.values(view.children)[0]).to.equal(view.child); + expect(server.child.$el.html()).to.equal('foo_0'); + expect(view.child.$el.html()).to.equal('somethingelse'); + expect(view._renderCount).to.equal(2); + }); + + it('should rerender lookup templates if lacking trackId', function() { + var View = Thorax.View.extend({ + template: Handlebars.compile('{{view child}}') + }); + var child = new SomethingElse(); + + server = new View({ + context: function() { + return { + child: new Counter() + }; + } + }); + view = new View({ + context: function() { + return { + child: child + }; + } + }); + restoreView(true); + + expect(_.keys(view.children).length).to.equal(1); + expect(_.values(view.children)[0]).to.equal(child); + }); + }); + describe('collection views', function() { + var collection1, collection2; + beforeEach(function() { + window.$serverSide = true; + count = 0; + + collection1 = new Thorax.Collection([{id: 1}, {id: 2}, {id: 3}, {id: 4}]); + collection2 = new Thorax.Collection([{id: 1}, {id: 2}, {id: 3}, {id: 4}]); + }); + + it('should restore inline views', function() { + server = new Thorax.View({ + template: Handlebars.compile('{{#collection}}something{{/collection}}', {trackIds: true}), + collection: collection1 + }); + view = new Thorax.View({ + template: Handlebars.compile('{{#collection}}somethingelse{{/collection}}', {trackIds: true}), + collection: collection2 + }); + restoreView(); + expect(_.keys(view.children).length).to.equal(1); + + var collectionView = _.values(view.children)[0]; + expect(collectionView.collection).to.equal(collection2); + expect(_.keys(collectionView.children).length).to.equal(0); + expect(collectionView.itemTemplate()).to.equal('somethingelse'); + + var viewCids = _.map(collectionView.$('[data-model-cid]'), function(el) { + return el.getAttribute('data-model-cid'); + }); + expect(viewCids).to.eql(collection2.map(function(model) { return model.cid; })); + expect(view.$el.children().text()).to.equal('somethingsomethingsomethingsomething'); + compareViews(); + }); + it('should restore referenced templates', function() { + var View = Thorax.View.extend({ + template: Handlebars.compile('{{collection tag="ul" empty-template="letter-empty" item-template="letter-item"}}') + }); + server = new View({collection: collection1}); + view = new View({collection: collection2}); + restoreView(); + + expect(_.keys(view.children).length).to.equal(1); + + var collectionView = _.values(view.children)[0]; + expect(collectionView.collection).to.equal(collection2); + expect(collectionView.itemTemplate).to.equal(Thorax.Util.getTemplate('letter-item')); + expect(collectionView.emptyTemplate).to.equal(Thorax.Util.getTemplate('letter-empty')); + + expect(_.keys(collectionView.children).length).to.equal(0); + + var viewCids = _.map(collectionView.$('[data-model-cid]'), function(el) { + return el.getAttribute('data-model-cid'); + }); + expect(viewCids).to.eql(collection2.map(function(model) { return model.cid; })); + compareViews(); + }); + it('should restore implicit named templates', function() { + var _ItemView = Thorax.Views['letter-item']; + Thorax.Views['letter-item'] = undefined; + + var View = Thorax.View.extend({ + name: 'letter', + template: Handlebars.compile('{{collection tag="ul"}}') + }); + server = new View({collection: collection1}); + view = new View({collection: collection2}); + restoreView(); + + expect(_.keys(view.children).length).to.equal(1); + + var collectionView = _.values(view.children)[0]; + expect(collectionView.collection).to.equal(collection2); + expect(collectionView.itemTemplate).to.equal(Thorax.Util.getTemplate('letter-item')); + expect(collectionView.emptyTemplate).to.equal(Thorax.Util.getTemplate('letter-empty')); + + expect(_.keys(collectionView.children).length).to.equal(0); + + var viewCids = _.map(collectionView.$('[data-model-cid]'), function(el) { + return el.getAttribute('data-model-cid'); + }); + expect(viewCids).to.eql(collection2.map(function(model) { return model.cid; })); + compareViews(); + + Thorax.Views['letter-item'] = _ItemView; + }); + it('should restore implicit item views', function() { + var View = Thorax.View.extend({ + name: 'letter', + template: Handlebars.compile('{{collection tag="ul"}}') + }); + server = new View({collection: collection1}); + view = new View({collection: collection2}); + restoreView(); + + expect(_.keys(view.children).length).to.equal(1); + + var collectionView = _.values(view.children)[0]; + expect(collectionView.collection).to.equal(collection2); + expect(collectionView.itemView).to.equal(Thorax.Util.getViewClass('letter-item')); + expect(collectionView.emptyView).to.equal(Thorax.Util.getViewClass('letter-empty')); + + expect(_.keys(collectionView.children).length).to.equal(4); + + var viewCids = _.map(collectionView.$('[data-model-cid]'), function(el) { + return el.getAttribute('data-model-cid'); + }); + expect(viewCids).to.eql(collection2.map(function(model) { return model.cid; })); + compareViews(); + }); + it('should restore referenced item-view', function() { + var View = Thorax.View.extend({ + template: Handlebars.compile('{{collection tag="ul" empty-view="letter-empty" item-view=ChildView}}', {trackIds: true}), + ChildView: Thorax.Util.getViewClass('letter-item') + }); + server = new View({collection: collection1}); + view = new View({collection: collection2}); + restoreView(); + + expect(_.keys(view.children).length).to.equal(1); + + var collectionView = _.values(view.children)[0]; + expect(collectionView.itemView).to.equal(view.ChildView); + expect(collectionView.emptyView).to.equal('letter-empty'); + + expect(_.keys(collectionView.children).length).to.equal(4); + + var viewCids = _.map(collectionView.$('[data-model-cid]'), function(el) { + return el.getAttribute('data-model-cid'); + }); + expect(viewCids).to.eql(collection2.map(function(model) { return model.cid; })); + compareViews(); + }); + it('should restore registry item-view', function() { + var View = Thorax.View.extend({ + template: Handlebars.compile('{{collection tag="ul" empty-view="letter-empty" item-view="letter-item"}}') + }); + server = new View({collection: collection1}); + view = new View({collection: collection2}); + restoreView(); + + expect(_.keys(view.children).length).to.equal(1); + + var collectionView = _.values(view.children)[0]; + expect(collectionView.itemView).to.equal('letter-item'); + expect(collectionView.emptyView).to.equal('letter-empty'); + + expect(_.keys(collectionView.children).length).to.equal(4); + + var viewCids = _.map(collectionView.$('[data-model-cid]'), function(el) { + return el.getAttribute('data-model-cid'); + }); + expect(viewCids).to.eql(collection2.map(function(model) { return model.cid; })); + compareViews(); + }); + it('should restore renderItem views', function() { + var View = Thorax.View.extend({ + template: Handlebars.compile('{{collection}}') + }); + server = new View({ + collection: collection1, + renderItem: function() { + return new Counter(); + } + }); + view = new View({ + collection: collection2, + renderItem: function() { + return new SomethingElse(); + } + }); + restoreView(); + + expect(_.keys(view.children).length).to.equal(1); + + var collectionView = _.values(view.children)[0]; + expect(_.keys(collectionView.children).length).to.equal(4); + expect(_.every(collectionView.children, function(value) { + return value._name === 'somethingelse'; + })).to.be['true']; + + var viewCids = _.map(collectionView.$('[data-model-cid]'), function(el) { + return el.getAttribute('data-model-cid'); + }); + expect(viewCids).to.eql(collection2.map(function(model) { return model.cid; })); + compareViews(); + }); + it('should restore empty-template', function() { + var View = Thorax.View.extend({ + template: Handlebars.compile('{{collection tag="ul" empty-template="letter-empty" item-template="letter-item"}}') + }); + server = new View({collection: new Thorax.Collection()}); + view = new View({collection: new Thorax.Collection()}); + restoreView(); + + expect(_.keys(view.children).length).to.equal(1); + + var collectionView = _.values(view.children)[0]; + expect(collectionView.collection).to.equal(view.collection); + expect(collectionView.itemTemplate).to.equal(Thorax.Util.getTemplate('letter-item')); + expect(collectionView.emptyTemplate).to.equal(Thorax.Util.getTemplate('letter-empty')); + + expect(_.keys(collectionView.children).length).to.equal(0); + + compareViews(); + }); + it('should restore empty-view', function() { + var View = Thorax.View.extend({ + template: Handlebars.compile('{{collection tag="ul" empty-view="letter-empty" item-template="letter-item"}}') + }); + server = new View({collection: new Thorax.Collection()}); + view = new View({collection: new Thorax.Collection()}); + restoreView(); + + expect(_.keys(view.children).length).to.equal(1); + + var collectionView = _.values(view.children)[0]; + expect(collectionView.collection).to.equal(view.collection); + expect(collectionView.itemTemplate).to.equal(Thorax.Util.getTemplate('letter-item')); + expect(collectionView.emptyView).to.equal('letter-empty'); + + expect(_.keys(collectionView.children).length).to.equal(1); + + compareViews(); + }); + it('should restore over non-default collection name', function() { + var View = Thorax.View.extend({ + template: Handlebars.compile('{{collection foo tag="ul" empty-view="letter-empty" item-view="letter-item"}}', {trackIds: true}) + }); + server = new View({foo: collection1}); + view = new View({foo: collection2}); + restoreView(); + + expect(_.keys(view.children).length).to.equal(1); + + var collectionView = _.values(view.children)[0]; + expect(collectionView.itemView).to.equal('letter-item'); + expect(collectionView.emptyView).to.equal('letter-empty'); + + expect(_.keys(collectionView.children).length).to.equal(4); + + var viewCids = _.map(collectionView.$('[data-model-cid]'), function(el) { + return el.getAttribute('data-model-cid'); + }); + expect(viewCids).to.eql(collection2.map(function(model) { return model.cid; })); + compareViews(); + }); + + it('should rerender items for inline view helper with depth', function() { + server = new Thorax.View({ + template: Handlebars.compile('
this
{{#collection}}{{../foo}}something{{/collection}}', {trackIds: true}), + collection: collection1 + }); + view = new Thorax.View({ + template: Handlebars.compile('
that
{{#collection}}{{../foo}}somethingelse{{/collection}}', {trackIds: true}), + collection: collection2 + }); + restoreView(true); + + expect(view.$el.children().length).to.equal(2); + expect(view.$el.children().eq(0).text()).to.equal('that'); + + expect(_.keys(view.children).length).to.equal(1); + + var collectionView = _.values(view.children)[0]; + expect(collectionView.collection).to.equal(collection2); + expect(_.keys(collectionView.children).length).to.equal(0); + expect(collectionView.itemTemplate()).to.equal('somethingelse'); + + var viewCids = _.map(collectionView.$('[data-model-cid]'), function(el) { + return el.getAttribute('data-model-cid'); + }); + expect(viewCids).to.eql(collection2.map(function(model) { return model.cid; })); + + expect(view.$el.text()).to.equal('thatsomethingelsesomethingelsesomethingelsesomethingelse'); + }); + it.skip('should rerender block view helper with data', function() { + server = new Thorax.View({ + template: Handlebars.compile('
this
{{#collection}}{{@foo}}something{{/collection}}', {trackIds: true}), + collection: collection1 + }); + view = new Thorax.View({ + template: Handlebars.compile('
that
{{#collection}}{{@foo}}somethingelse{{/collection}}', {trackIds: true}), + collection: collection2 + }); + restoreView(true); + + expect(view.$el.children().length).to.equal(2); + expect(view.$el.children().eq(0).text()).to.equal('that'); + + expect(_.keys(view.children).length).to.equal(1); + + var collectionView = _.values(view.children)[0]; + expect(collectionView.collection).to.equal(collection2); + expect(_.keys(collectionView.children).length).to.equal(0); + expect(collectionView.itemTemplate()).to.equal('somethingelse'); + + var viewCids = _.map(collectionView.$('[data-model-cid]'), function(el) { + return el.getAttribute('data-model-cid'); + }); + expect(viewCids).to.eql(collection2.map(function(model) { return model.cid; })); + + expect(view.$el.text()).to.equal('thatsomethingelsesomethingelsesomethingelsesomethingelse'); + }); + + it('should rerender collections where elements are missing ids', function() { + collection2 = new Thorax.Collection([{id: 1}, {id: 6}, {id: 3}, {id: 4}]); + + server = new Thorax.View({ + template: Handlebars.compile('{{#collection}}{{id}}. something{{/collection}}', {trackIds: true}), + collection: collection1 + }); + view = new Thorax.View({ + template: Handlebars.compile('{{#collection}}somethingelse{{/collection}}', {trackIds: true}), + collection: collection2 + }); + restoreView(); + expect(_.keys(view.children).length).to.equal(1); + + var collectionView = _.values(view.children)[0]; + expect(collectionView.collection).to.equal(collection2); + expect(_.keys(collectionView.children).length).to.equal(0); + expect(collectionView.itemTemplate()).to.equal('somethingelse'); + + var viewCids = _.map(collectionView.$('[data-model-cid]'), function(el) { + return el.getAttribute('data-model-cid'); + }); + expect(viewCids).to.eql(collection2.map(function(model) { return model.cid; })); + expect(view.$el.children().text()).to.equal('1. somethingsomethingelse3. something4. something'); + }); + }); + + it('should cooperate with custom restore events', function() { + window.$serverSide = true; + + var View = Thorax.View.extend({ + template: Handlebars.compile('{{view child}}', {trackIds: true}) + }); + var child = new SomethingElse({ + restore: function(el) { + this.setElement(el); + + this.$el.text('winning!'); + return true; + } + }); + + server = new View({ + child: new Counter(), + context: function() { + return { + child: this.child + }; + } + }); + view = new View({ + context: function() { + return { + child: child + }; + } + }); + restoreView(); + + expect(view.$el.text()).to.equal('winning!'); + expect(child.$el.attr('data-view-cid')).to.equal(server.child.cid); + }); + + it('should recover views on client-side parent rerender', function() { + window.$serverSide = true; + + server = new Thorax.View({ + foo: { + yes: false + }, + template: Handlebars.compile('{{view "registry" key=4 bar=foo}}', {trackIds: true}) + }); + view = new SomethingElse({ + foo: { + yes: true + }, + template: Handlebars.compile('{{view "registry" key=4 bar=foo}}', {trackIds: true}) + }); + restoreView(); + + var restored = _.values(view.children)[0]; + expect(restored.key).to.equal(4); + expect(restored.bar).to.equal(view.foo); + + view.render(); + expect(_.values(view.children)).to.eql([restored]); + }); + }); });