diff --git a/SpecRunner.html b/SpecRunner.html index 805c210ac2..3e371b53c0 100644 --- a/SpecRunner.html +++ b/SpecRunner.html @@ -103,6 +103,7 @@ + diff --git a/api/region.jsdoc b/api/region.jsdoc index 15161879bf..503abd8945 100644 --- a/api/region.jsdoc +++ b/api/region.jsdoc @@ -132,6 +132,20 @@ properties: The `currentView` property references the currently shown view. If `currentView` is `undefined`, the region is empty. + triggerBeforeAttach: | + Whether or not the Region will trigger the `before:attach` event on nested views that are about + to be attached. Defaults to `true`. + + In general, the only time you'll want to set this to false is if you're rendering hundreds or + thousands of views and are running into performance problems. + + triggerAttach: | + Whether or not the Region will trigger the `attach` event on nested views that have been attached. + Defaults to `true`. + + As with triggerBeforeAttach, you might want to set this to `false` if you're rendering many, many + views and are running into performance problems. + initialize: | If `initialize` is set in the Region class, it will be called when new regions are instantiated. @@ -189,7 +203,7 @@ functions: Shows `newView` inside the region if `newView` is not already shown within the region. The previous view, if one exists, will be destroyed in this process. The `show` methods fires the show and swap triggerMethods. - You can modify the behavior of `show` by passing in an options object. + You can modify the behavior of `show` by passing in an `options` object. The following options are supported: `preventDestroy` Pass this as `true` to prevent the destruction of the old view. This is not recommended, as Views @@ -221,6 +235,14 @@ functions: // the second show call will re-show the view MyApp.mainRegion.show(myView, {forceShow: true}); ``` + + `triggerAttach` + Whether or not to trigger the `attach` event on the views being shown. This overrides the property + on the prototype. + + `triggerBeforeAttach` + Whether or not to trigger the `before:attach` event on the views being shown. This overrides the property + on the prototype. @api public @param {Marionette.View} view diff --git a/docs/marionette.layoutview.md b/docs/marionette.layoutview.md index 371871cf00..25ec3647b1 100644 --- a/docs/marionette.layoutview.md +++ b/docs/marionette.layoutview.md @@ -33,6 +33,8 @@ will provide features such as `onShow` callbacks, etc. Please see * [Re-Rendering A LayoutView](#re-rendering-a-layoutview) * [Avoid Re-Rendering The Entire LayoutView](#avoid-re-rendering-the-entire-layoutview) * [Nested LayoutViews And Views](#nested-layoutviews-and-views) + * [Efficient Nested View Structures](#efficient-nested-view-structures) + * [Use of the `attach` Event](#use-of-the-attach-event) * [Destroying A LayoutView](#destroying-a-layoutview) * [Custom Region Class](#custom-region-class) * [Adding And Removing Regions](#adding-and-removing-regions) @@ -224,6 +226,41 @@ layout1.getRegion('region1').show(layout2); layout2.getRegion('region2').show(layout3); ``` +### Efficient Nested View Structures + +The above example works great, but it causes three separate paints: one for each layout that's being +shown. Marionette provides a simple mechanism to infinitely nest views in a single paint: just render all +of the children in the `onBeforeShow` callback. + +```js +var ParentLayout = Marionette.LayoutView.extend({ + onBeforeShow: function() { + this.getRegion('header').show(new HeaderView()); + this.getRegion('footer').show(new FooterView()); + } +}); + +myRegion.show(new ParentLayout()); +``` + +In this example, the doubly-nested view structure will be rendered in a single paint. + +This system is recursive, so it works for any deeply nested structure. The child views +you show can render their *own* child views within their `onBeforeShow` callbacks! + +#### Use of the `attach` event + +Often times you need to know when your views in the view tree have been attached to the `document`, +like when using certain jQuery plugins. The `attach` event, and associated `onAttach` callback, are perfect for this +use case. Start with a Region that's a child of the `document` and show any LayoutView in it: every view in the tree +(including the parent LayoutView) will have the `attach` event triggered on it when they have been +attached to the `document`. + +Note that inefficient tree rendering will cause the `attach` event to be fired multiple times. This +situation can occur if you render the children views *after* the parent has been rendered, such as using +`onShow` to render children. As a rule of thumb, most of the time you'll want to render any nested views in +the `onBeforeShow` callback. + ## Destroying A LayoutView When you are finished with a layoutView, you can call the diff --git a/docs/marionette.region.md b/docs/marionette.region.md index 943c70c619..22a0908b0c 100644 --- a/docs/marionette.region.md +++ b/docs/marionette.region.md @@ -227,6 +227,7 @@ MyApp.mainRegion.empty(); ``` #### preventDestroy + If you replace the current view with a new view by calling `show`, by default it will automatically destroy the previous view. You can prevent this behavior by passing `{preventDestroy: true}` in the options @@ -252,8 +253,8 @@ MyApp.mainRegion.show(anotherView2, { preventDestroy: true }); NOTE: When using `preventDestroy: true` you must be careful to cleanup your old views manually to prevent memory leaks. - #### forceShow + If you re-call `show` with the same view, by default nothing will happen because the view is already in the region. You can force the view to be re-shown by passing in `{forceShow: true}` in the options parameter. @@ -266,6 +267,31 @@ MyApp.mainRegion.show(myView); MyApp.mainRegion.show(myView, {forceShow: true}); ``` +#### onBeforeAttach & onAttach + +Regions that are attached to the document when you execute `show` are special in that the +views that they show will also become attached to the document. These regions fire a pair of triggerMethods on *all* +of the views that are about to be attached – even the nested ones. This can cause a performance issue if you're +rendering hundreds or thousands of views at once. + +If you think these events might be causing some lag in your app, you can selectively turn them off +with the `triggerBeforeAttach` and `triggerAttach` properties. + +```js +// No longer trigger attach +myRegion.triggerAttach = false; +``` + +You can override this on a per-show basis by passing it in as an option to show. + +```js +// This region won't trigger beforeAttach... +myRegion.triggerBeforeAttach = false; + +// Unless we tell it to +myRegion.show(myView, {triggerBeforeAttach: true}); +``` + ### Checking whether a region is showing a view If you wish to check whether a region has a view, you can use the `hasView` diff --git a/docs/marionette.view.md b/docs/marionette.view.md index 2701ac7da8..8b2b4b870d 100644 --- a/docs/marionette.view.md +++ b/docs/marionette.view.md @@ -17,6 +17,8 @@ behaviors that are shared across all views. * [View onShow](#view-onshow) * [View destroy](#view-destroy) * [View onBeforeDestroy](#view-onbeforedestroy) +* [View "attach" / onAttach event](#view-attach--onattach-event) +* [View "before:attach" / onBeforeAttach event](#view-beforeattach--onbeforeattach-event) * [View "dom:refresh" / onDomRefresh event](#view-domrefresh--ondomrefresh-event) * [View.triggers](#viewtriggers) * [View.events](#viewevents) @@ -128,6 +130,28 @@ When destroying a view, an `onBeforeDestroy` method will be called, if it has been provided, just before the view destroys. It will be passed any arguments that `destroy` was invoked with. +### View "attach" / onAttach event + +Every view in Marionette has a special event called "attach," which is triggered anytime that showing +the view in a Region causes it to be attached to the `document`. Like other Marionette events, it also +executes a callback method, `onAttach`, if you've specified one. The `"attach"` event is great for jQuery +plugins or other logic that must be executed *after* the view is attached to the `document`. + +Because the `attach` event is only fired when the view is a child of the `document`, it is a requirement +that the Region you're showing it in be a child of the `document` at the time that you call `show`. + +This event is unique in that it propagates down the view tree. For instance, when a CollectionView's +`attach` event is fired, all of its children views will have the `attach` event fired as well. In +addition, deeply nested Layout View structures will all have their `attach` event fired at the proper +time, too. + +For more on efficient, deeply-nested view structures, refer to the LayoutView docs. + +### View "before:attach" / onBeforeAttach + +This is just like the attach event described above, but it's triggered right before the view is +attached to the document. + ### View "dom:refresh" / onDomRefresh event Triggered after the view has been rendered, has been shown in the DOM via a Marionette.Region, and has been diff --git a/src/region.js b/src/region.js index a25d29eee7..61efb66844 100644 --- a/src/region.js +++ b/src/region.js @@ -1,4 +1,4 @@ -/* jshint maxcomplexity: 10, maxstatements: 29, maxlen: 120 */ +/* jshint maxcomplexity: 15, maxstatements: 40, maxlen: 120 */ // Region // ------ @@ -180,8 +180,27 @@ _.extend(Marionette.Region.prototype, Backbone.Events, { this.triggerMethod('swapOut', this.currentView); } + // An array of views that we're about to display + var attachedRegion = Marionette.isNodeAttached(this.el); + + // The views that we're about to attach to the document + // It's important that we prevent _getNestedViews from being executed unnecessarily + // as it's a potentially-slow method + var displayedViews = []; + + var triggerBeforeAttach = showOptions.triggerBeforeAttach || this.triggerBeforeAttach; + var triggerAttach = showOptions.triggerAttach || this.triggerAttach; + + if (attachedRegion && triggerBeforeAttach) { + displayedViews = [view].concat(_.result(view, '_getNestedViews')); + this._triggerAttach(displayedViews, 'before:'); + } this.attachHtml(view); this.currentView = view; + if (attachedRegion && triggerAttach) { + displayedViews = [view].concat(_.result(view, '_getNestedViews')); + this._triggerAttach(displayedViews); + } if (isChangingView) { this.triggerMethod('swap', view); @@ -195,6 +214,17 @@ _.extend(Marionette.Region.prototype, Backbone.Events, { return this; }, + triggerBeforeAttach: true, + triggerAttach: true, + + _triggerAttach: function(views, prefix) { + var eventName = (prefix || '') + 'attach'; + _.each(views, function(view) { + if (!view) { return; } + Marionette.triggerMethodOn(view, eventName); + }); + }, + _ensureElement: function(){ if (!_.isObject(this.el)) { this.$el = this.getEl(this.el); diff --git a/test/unit/on-attach.spec.js b/test/unit/on-attach.spec.js new file mode 100644 index 0000000000..205fd0a509 --- /dev/null +++ b/test/unit/on-attach.spec.js @@ -0,0 +1,720 @@ +describe('onAttach', function() { + 'use strict'; + + beforeEach(function() { + + // A Region to show our LayoutView within + this.setFixtures('
'); + this.el = $('#region')[0]; + this.region = new (Backbone.Marionette.Region.extend({ + el: this.el + }))(); + + // A view we can use as nested child views + this.BasicView = Marionette.ItemView.extend({ + template: false, + onAttach: function() {}, + onBeforeAttach: function() {} + }); + }); + + describe('when showing a region that is not attached to the document', function() { + beforeEach(function() { + this.detachedRegion = new (Backbone.Marionette.Region.extend({ + el: $('
'), + _triggerAttach: this.sinon.stub() + }))(); + + this.detachedRegion.show(new this.BasicView()); + }); + + it('should not call _triggerAttach', function() { + expect(this.detachedRegion._triggerAttach).to.not.have.been.called; + }); + }); + + describe('when showing a region that is attached to the document', function() { + beforeEach(function() { + this.region._triggerAttach = this.sinon.stub(); + this.childView = new this.BasicView(); + + this.region.show(this.childView); + }); + + it('should call _triggerAttach twice', function() { + expect(this.region._triggerAttach) + .to.have.been.calledTwice + .and.to.have.been.calledWithExactly([this.childView], 'before:') + .and.to.have.been.calledWithExactly([this.childView]); + }); + }); + + describe('when showing a region that is attached to the document & has triggerBeforeAttach set to false', function() { + beforeEach(function() { + this.region._triggerAttach = this.sinon.stub(); + this.childView = new this.BasicView(); + this.region.triggerBeforeAttach = false; + + this.region.show(this.childView); + }); + + it('should call _triggerAttach once', function() { + expect(this.region._triggerAttach) + .to.have.been.calledOnce + .and.to.have.been.calledWithExactly([this.childView]); + }); + }); + + describe('when showing a region that is attached to the document & has triggerBeforeAttach set to false, but the option is passed as true', function() { + beforeEach(function() { + this.region._triggerAttach = this.sinon.stub(); + this.childView = new this.BasicView(); + this.region.triggerBeforeAttach = false; + + this.region.show(this.childView, {triggerBeforeAttach: true}); + }); + + it('should call _triggerAttach twice', function() { + expect(this.region._triggerAttach) + .to.have.been.calledTwice + .and.to.have.been.calledWithExactly([this.childView], 'before:') + .and.to.have.been.calledWithExactly([this.childView]); + }); + }); + + describe('when showing a region that is attached to the document & has triggerAttach set to false', function() { + beforeEach(function() { + this.region._triggerAttach = this.sinon.stub(); + this.childView = new this.BasicView(); + this.region.triggerAttach = false; + + this.region.show(this.childView); + }); + + it('should call _triggerAttach once', function() { + expect(this.region._triggerAttach) + .to.have.been.calledOnce + .and.to.have.been.calledWithExactly([this.childView], 'before:'); + }); + }); + + describe('when showing a region that is attached to the document & has triggerAttach set to false, but the option is passed as true', function() { + beforeEach(function() { + this.region._triggerAttach = this.sinon.stub(); + this.childView = new this.BasicView(); + this.region.triggerAttach = false; + + this.region.show(this.childView, {triggerAttach: true}); + }); + + it('should call _triggerAttach twice', function() { + expect(this.region._triggerAttach) + .to.have.been.calledTwice + .and.to.have.been.calledWithExactly([this.childView], 'before:') + .and.to.have.been.calledWithExactly([this.childView]); + }); + }); + + describe('when a view is shown in a region', function() { + beforeEach(function() { + this.childView = new this.BasicView(); + this.childView.onBeforeAttach = function() { + this.beforeAttached = Marionette.isNodeAttached(this.el); + }; + this.childView.onAttach = function() { + this.attached = Marionette.isNodeAttached(this.el); + }; + this.region.show(this.childView); + }); + + it('should be detached in onBeforeAttach', function() { + expect(this.childView.beforeAttached).to.be.false; + }); + + it('should be attached in onAttach', function() { + expect(this.childView.attached).to.be.true; + }); + }); + + describe('when the parent view is initially detached', function() { + beforeEach(function() { + + // A LayoutView class that we can use for all of our tests + this.LayoutView = Backbone.Marionette.LayoutView.extend({ + template: _.template('
'), + regions: { + main: 'main', + footer: 'footer' + }, + onBeforeAttach: function() {}, + onAttach: function() {} + }); + }); + + describe('When showing a View in a Region', function() { + beforeEach(function() { + this.MyView = Marionette.ItemView.extend({ + template: _.template(''), + onBeforeAttach: this.sinon.stub(), + onAttach: this.sinon.stub() + }); + + this.myView = new this.MyView(); + this.region.show(this.myView); + }); + + it('should trigger onAttach on the View a single time', function() { + expect(this.myView.onAttach).to.have.been.calledOnce; + }); + + it('should trigger onBeforeAttach on the View a single time', function() { + expect(this.myView.onBeforeAttach).to.have.been.calledOnce; + }); + + it('should trigger onBeforeAttach before onAttach', function() { + expect(this.myView.onBeforeAttach).to.have.been.calledBefore(this.myView.onAttach); + }); + }); + + describe('When showing a LayoutView with a single level of nested views that are attached within onBeforeShow', function() { + beforeEach(function() { + this.mainView = new this.BasicView(); + this.footerView = new this.BasicView(); + + this.sinon.spy(this.mainView, 'onAttach'); + this.sinon.spy(this.mainView, 'onBeforeAttach'); + this.sinon.spy(this.footerView, 'onAttach'); + this.sinon.spy(this.footerView, 'onBeforeAttach'); + + var suite = this; + + this.CustomLayoutView = this.LayoutView.extend({ + onBeforeShow: function() { + this.getRegion('main').show(suite.mainView); + this.getRegion('footer').show(suite.footerView); + } + }); + + this.layoutView = new this.CustomLayoutView(); + this.sinon.spy(this.layoutView, 'onAttach'); + this.sinon.spy(this.layoutView, 'onBeforeAttach'); + + this.region.show(this.layoutView); + }); + + it('should trigger onBeforeAttach & onAttach on the layoutView a single time', function() { + expect(this.layoutView.onBeforeAttach).to.have.been.calledOnce; + expect(this.layoutView.onAttach).to.have.been.calledOnce; + }); + + it('should trigger onBeforeAttach & onAttach on the mainView a single time', function() { + expect(this.mainView.onBeforeAttach).to.have.been.calledOnce; + expect(this.mainView.onAttach).to.have.been.calledOnce; + }); + + it('should trigger onBeforeAttach & onAttach on the footerView a single time', function() { + expect(this.footerView.onBeforeAttach).to.have.been.calledOnce; + expect(this.footerView.onAttach).to.have.been.calledOnce; + }); + }); + + describe('When showing a LayoutView with a single level of nested views that are attached within onBeforeAttach', function() { + beforeEach(function() { + this.mainView = new this.BasicView(); + this.footerView = new this.BasicView(); + + this.sinon.spy(this.mainView, 'onAttach'); + this.sinon.spy(this.mainView, 'onBeforeAttach'); + this.sinon.spy(this.footerView, 'onAttach'); + this.sinon.spy(this.footerView, 'onBeforeAttach'); + + var suite = this; + + this.CustomLayoutView = this.LayoutView.extend({ + onBeforeAttach: function() { + this.getRegion('main').show(suite.mainView); + this.getRegion('footer').show(suite.footerView); + } + }); + + this.layoutView = new this.CustomLayoutView(); + this.sinon.spy(this.layoutView, 'onAttach'); + this.sinon.spy(this.layoutView, 'onBeforeAttach'); + + this.region.show(this.layoutView); + }); + + it('should trigger onBeforeAttach & onAttach on the layoutView a single time', function() { + expect(this.layoutView.onBeforeAttach).to.have.been.calledOnce; + expect(this.layoutView.onAttach).to.have.been.calledOnce; + }); + + it('should trigger onAttach on the mainView a single time, but not onBeforeAttach', function() { + expect(this.mainView.onBeforeAttach).to.not.have.been.calledOnce; + expect(this.mainView.onAttach).to.have.been.calledOnce; + }); + + it('should trigger onAttach on the footerView a single time, but not onBeforeAttach', function() { + expect(this.footerView.onBeforeAttach).to.not.have.been.calledOnce; + expect(this.footerView.onAttach).to.have.been.calledOnce; + }); + }); + + describe('When showing a LayoutView with two levels of nested views; with onBeforeShow for the first and second level', function() { + beforeEach(function() { + var suite = this; + this.headerView = new this.BasicView(); + this.sinon.spy(this.headerView, 'onAttach'); + this.sinon.spy(this.headerView, 'onBeforeAttach'); + + this.MainView = this.LayoutView.extend({ + template: _.template('
'), + regions: { + header: 'header' + }, + onBeforeShow: function() { + this.getRegion('header').show(suite.headerView); + } + }); + this.mainView = new this.MainView(); + this.sinon.spy(this.mainView, 'onAttach'); + this.sinon.spy(this.mainView, 'onBeforeAttach'); + + this.CustomLayoutView = this.LayoutView.extend({ + onBeforeShow: function() { + this.getRegion('main').show(suite.mainView); + } + }); + + this.layoutView = new this.CustomLayoutView(); + this.sinon.spy(this.layoutView, 'onAttach'); + this.sinon.spy(this.layoutView, 'onBeforeAttach'); + + this.region.show(this.layoutView); + }); + + it('should trigger onBeforeAttach & onAttach on the layoutView a single time', function() { + expect(this.layoutView.onAttach).to.have.been.calledOnce; + expect(this.layoutView.onBeforeAttach).to.have.been.calledOnce; + }); + + it('should trigger onBeforeAttach & onAttach on the mainView a single time', function() { + expect(this.mainView.onBeforeAttach).to.have.been.calledOnce; + expect(this.mainView.onAttach).to.have.been.calledOnce; + }); + + it('should trigger onBeforeAttach & onAttach on the headerView a single time', function() { + expect(this.headerView.onBeforeAttach).to.have.been.calledOnce; + expect(this.headerView.onAttach).to.have.been.calledOnce; + }); + }); + + describe('When showing a LayoutView with two levels of nested views; onBeforeShow for the first level, then onShow for the second', function() { + beforeEach(function() { + var suite = this; + this.headerView = new this.BasicView(); + this.sinon.spy(this.headerView, 'onBeforeAttach'); + this.sinon.spy(this.headerView, 'onAttach'); + + this.MainView = Marionette.LayoutView.extend({ + template: _.template('
'), + onAttach: this.sinon.stub(), + onBeforeAttach: this.sinon.stub(), + regions: { + header: 'header' + }, + onShow: function() { + this.getRegion('header').show(suite.headerView); + } + }); + this.mainView = new this.MainView(); + + this.CustomLayoutView = this.LayoutView.extend({ + onBeforeShow: function() { + this.getRegion('main').show(suite.mainView); + } + }); + + this.layoutView = new this.CustomLayoutView(); + this.sinon.spy(this.layoutView, 'onAttach'); + this.sinon.spy(this.layoutView, 'onBeforeAttach'); + + this.region.show(this.layoutView); + }); + + it('should trigger onBeforeAttach & onAttach on the layoutView a single time', function() { + expect(this.layoutView.onBeforeAttach).to.have.been.calledOnce; + expect(this.layoutView.onAttach).to.have.been.calledOnce; + }); + + it('should trigger onBeforeAttach & onAttach on the mainView a single time', function() { + expect(this.mainView.onBeforeAttach).to.have.been.calledOnce; + expect(this.mainView.onAttach).to.have.been.calledOnce; + }); + + it('should trigger onBeforeAttach & onAttach on the headerView a single time', function() { + expect(this.headerView.onBeforeAttach).to.have.been.calledOnce; + expect(this.headerView.onAttach).to.have.been.calledOnce; + }); + }); + + describe('When showing a LayoutView with two levels of nested views; with onShow for the first level, onBeforeShow for the second', function() { + beforeEach(function() { + var suite = this; + this.headerView = new this.BasicView(); + this.sinon.spy(this.headerView, 'onBeforeAttach'); + this.sinon.spy(this.headerView, 'onAttach'); + + this.MainView = Marionette.LayoutView.extend({ + template: _.template('
'), + onAttach: this.sinon.stub(), + onBeforeAttach: this.sinon.stub(), + regions: { + header: 'header' + }, + onBeforeShow: function() { + this.getRegion('header').show(suite.headerView); + } + }); + this.mainView = new this.MainView(); + + this.CustomLayoutView = this.LayoutView.extend({ + onShow: function() { + this.getRegion('main').show(suite.mainView); + } + }); + + this.layoutView = new this.CustomLayoutView(); + this.sinon.spy(this.layoutView, 'onBeforeAttach'); + this.sinon.spy(this.layoutView, 'onAttach'); + + this.region.show(this.layoutView); + }); + + it('should trigger onBeforeAttach & onAttach on the layoutView a single time', function() { + expect(this.layoutView.onBeforeAttach).to.have.been.calledOnce; + expect(this.layoutView.onAttach).to.have.been.calledOnce; + }); + + it('should trigger onBeforeAttach & onAttach on the mainView a single time', function() { + expect(this.mainView.onBeforeAttach).to.have.been.calledOnce; + expect(this.mainView.onAttach).to.have.been.calledOnce; + }); + + it('should trigger onBeforeAttach & onAttach on the headerView a single time', function() { + expect(this.headerView.onBeforeAttach).to.have.been.calledOnce; + expect(this.headerView.onAttach).to.have.been.calledOnce; + }); + }); + + describe('When showing a LayoutView with a single level of nested views that are attached within onShow', function() { + beforeEach(function() { + this.mainView = new this.BasicView(); + this.footerView = new this.BasicView(); + + this.sinon.spy(this.mainView, 'onBeforeAttach'); + this.sinon.spy(this.mainView, 'onAttach'); + this.sinon.spy(this.footerView, 'onBeforeAttach'); + this.sinon.spy(this.footerView, 'onAttach'); + + var suite = this; + + this.CustomLayoutView = this.LayoutView.extend({ + onShow: function() { + this.getRegion('main').show(suite.mainView); + this.getRegion('footer').show(suite.footerView); + } + }); + + this.layoutView = new this.CustomLayoutView(); + this.sinon.spy(this.layoutView, 'onBeforeAttach'); + this.sinon.spy(this.layoutView, 'onAttach'); + + this.region.show(this.layoutView); + }); + + it('should trigger onBeforeAttach & onAttach on the layoutView a single time', function() { + expect(this.layoutView.onBeforeAttach).to.have.been.calledOnce; + expect(this.layoutView.onAttach).to.have.been.calledOnce; + }); + + it('should trigger onBeforeAttach & onAttach on the mainView a single time', function() { + expect(this.mainView.onBeforeAttach).to.have.been.calledOnce; + expect(this.mainView.onAttach).to.have.been.calledOnce; + }); + + it('should trigger onBeforeAttach & onAttach on the footerView a single time', function() { + expect(this.footerView.onBeforeAttach).to.have.been.calledOnce; + expect(this.footerView.onAttach).to.have.been.calledOnce; + }); + }); + }); + + describe('when the parent view is initially attached', function() { + beforeEach(function() { + this.setFixtures('
'); + + // A LayoutView class that we can use for all of our tests + this.LayoutView = Backbone.Marionette.LayoutView.extend({ + el: '.layout-view', + template: _.template('
'), + regions: { + main: 'main', + footer: 'footer' + }, + onAttach: function() {}, + onBeforeAttach: function() {} + }); + }); + + describe('When showing a View in a Region', function() { + beforeEach(function() { + this.MyView = Marionette.ItemView.extend({ + el: '.layout-view', + template: _.template(''), + onBeforeAttach: this.sinon.stub(), + onAttach: this.sinon.stub() + }); + + this.myView = new this.MyView(); + this.region.show(this.myView); + }); + + it('should trigger onBeforeAttach & onAttach on the View a single time', function() { + expect(this.myView.onBeforeAttach).to.have.been.calledOnce; + expect(this.myView.onAttach).to.have.been.calledOnce; + }); + }); + + describe('When showing a LayoutView with a single level of nested views that are attached within onBeforeShow', function() { + beforeEach(function() { + this.mainView = new this.BasicView(); + this.footerView = new this.BasicView(); + + this.sinon.spy(this.mainView, 'onBeforeAttach'); + this.sinon.spy(this.mainView, 'onAttach'); + this.sinon.spy(this.footerView, 'onBeforeAttach'); + this.sinon.spy(this.footerView, 'onAttach'); + + var suite = this; + + this.CustomLayoutView = this.LayoutView.extend({ + onBeforeShow: function() { + this.getRegion('main').show(suite.mainView); + this.getRegion('footer').show(suite.footerView); + } + }); + + this.layoutView = new this.CustomLayoutView(); + this.sinon.spy(this.layoutView, 'onBeforeAttach'); + this.sinon.spy(this.layoutView, 'onAttach'); + + this.region.show(this.layoutView); + }); + + it('should trigger onBeforeAttach & onAttach on the layoutView a single time', function() { + expect(this.layoutView.onBeforeAttach).to.have.been.calledOnce; + expect(this.layoutView.onAttach).to.have.been.calledOnce; + }); + + it('should trigger onBeforeAttach & onAttach on the mainView twice', function() { + expect(this.mainView.onBeforeAttach).to.have.been.calledTwice; + expect(this.mainView.onAttach).to.have.been.calledTwice; + }); + + it('should trigger onBeforeAttach & onAttach on the footerView twice', function() { + expect(this.footerView.onBeforeAttach).to.have.been.calledTwice; + expect(this.footerView.onAttach).to.have.been.calledTwice; + }); + }); + + describe('When showing a LayoutView with two levels of nested views; with onBeforeShow for the first and second level', function() { + beforeEach(function() { + var suite = this; + this.headerView = new this.BasicView(); + this.sinon.spy(this.headerView, 'onBeforeAttach'); + this.sinon.spy(this.headerView, 'onAttach'); + + this.MainView = Marionette.LayoutView.extend({ + template: _.template('
'), + onAttach: this.sinon.stub(), + onBeforeAttach: this.sinon.stub(), + regions: { + header: 'header' + }, + onBeforeShow: function() { + this.getRegion('header').show(suite.headerView); + } + }); + this.mainView = new this.MainView(); + + this.CustomLayoutView = this.LayoutView.extend({ + onBeforeShow: function() { + this.getRegion('main').show(suite.mainView); + } + }); + + this.layoutView = new this.CustomLayoutView(); + this.sinon.spy(this.layoutView, 'onBeforeAttach'); + this.sinon.spy(this.layoutView, 'onAttach'); + + this.region.show(this.layoutView); + }); + + it('should trigger onBeforeAttach & onAttach on the layoutView a single time', function() { + expect(this.layoutView.onBeforeAttach).to.have.been.calledOnce; + expect(this.layoutView.onAttach).to.have.been.calledOnce; + }); + + it('should trigger onBeforeAttach & onAttach on the mainView twice', function() { + expect(this.mainView.onBeforeAttach).to.have.been.calledTwice; + expect(this.mainView.onAttach).to.have.been.calledTwice; + }); + + it('should trigger onBeforeAttach & onAttach on the headerView twice', function() { + expect(this.headerView.onBeforeAttach).to.have.been.calledTwice; + expect(this.headerView.onAttach).to.have.been.calledTwice; + }); + }); + + describe('When showing a LayoutView with two levels of nested views; onBeforeShow for the first level, then onShow for the second', function() { + beforeEach(function() { + var suite = this; + this.headerView = new this.BasicView(); + this.sinon.spy(this.headerView, 'onBeforeAttach'); + this.sinon.spy(this.headerView, 'onAttach'); + + this.MainView = Marionette.LayoutView.extend({ + template: _.template('
'), + onAttach: this.sinon.stub(), + onBeforeAttach: this.sinon.stub(), + regions: { + header: 'header' + }, + onShow: function() { + this.getRegion('header').show(suite.headerView); + } + }); + this.mainView = new this.MainView(); + + this.CustomLayoutView = this.LayoutView.extend({ + onBeforeShow: function() { + this.getRegion('main').show(suite.mainView); + } + }); + + this.layoutView = new this.CustomLayoutView(); + this.sinon.spy(this.layoutView, 'onBeforeAttach'); + this.sinon.spy(this.layoutView, 'onAttach'); + + this.region.show(this.layoutView); + }); + + it('should trigger onBeforeAttach & onAttach on the layoutView a single time', function() { + expect(this.layoutView.onBeforeAttach).to.have.been.calledOnce; + expect(this.layoutView.onAttach).to.have.been.calledOnce; + }); + + it('should trigger onBeforeAttach & onAttach on the mainView twice', function() { + expect(this.mainView.onBeforeAttach).to.have.been.calledTwice; + expect(this.mainView.onAttach).to.have.been.calledTwice; + }); + + it('should trigger onBeforeAttach & onAttach on the headerView twice', function() { + expect(this.headerView.onBeforeAttach).to.have.been.calledTwice; + expect(this.headerView.onAttach).to.have.been.calledTwice; + }); + }); + + describe('When showing a LayoutView with two levels of nested views; with onShow for the first level, onBeforeShow for the second', function() { + beforeEach(function() { + var suite = this; + this.headerView = new this.BasicView(); + this.sinon.spy(this.headerView, 'onBeforeAttach'); + this.sinon.spy(this.headerView, 'onAttach'); + + this.MainView = Marionette.LayoutView.extend({ + template: _.template('
'), + onAttach: this.sinon.stub(), + onBeforeAttach: this.sinon.stub(), + regions: { + header: 'header' + }, + onBeforeShow: function() { + this.getRegion('header').show(suite.headerView); + } + }); + this.mainView = new this.MainView(); + + this.CustomLayoutView = this.LayoutView.extend({ + onShow: function() { + this.getRegion('main').show(suite.mainView); + } + }); + + this.layoutView = new this.CustomLayoutView(); + this.sinon.spy(this.layoutView, 'onBeforeAttach'); + this.sinon.spy(this.layoutView, 'onAttach'); + + this.region.show(this.layoutView); + }); + + it('should trigger onBeforeAttach & onAttach on the layoutView a single time', function() { + expect(this.layoutView.onBeforeAttach).to.have.been.calledOnce; + expect(this.layoutView.onAttach).to.have.been.calledOnce; + }); + + it('should trigger onBeforeAttach & onAttach on the mainView a single time', function() { + expect(this.mainView.onBeforeAttach).to.have.been.calledOnce; + expect(this.mainView.onAttach).to.have.been.calledOnce; + }); + + it('should trigger onBeforeAttach & onAttach on the headerView a single time', function() { + expect(this.headerView.onBeforeAttach).to.have.been.calledOnce; + expect(this.headerView.onAttach).to.have.been.calledOnce; + }); + }); + + describe('When showing a LayoutView with a single level of nested views that are attached within onShow', function() { + beforeEach(function() { + this.mainView = new this.BasicView(); + this.footerView = new this.BasicView(); + + this.sinon.spy(this.mainView, 'onBeforeAttach'); + this.sinon.spy(this.mainView, 'onAttach'); + this.sinon.spy(this.footerView, 'onBeforeAttach'); + this.sinon.spy(this.footerView, 'onAttach'); + + var suite = this; + + this.CustomLayoutView = this.LayoutView.extend({ + onShow: function() { + this.getRegion('main').show(suite.mainView); + this.getRegion('footer').show(suite.footerView); + } + }); + + this.layoutView = new this.CustomLayoutView(); + this.sinon.spy(this.layoutView, 'onBeforeAttach'); + this.sinon.spy(this.layoutView, 'onAttach'); + + this.region.show(this.layoutView); + }); + + it('should trigger onBeforeAttach & onAttach on the layoutView a single time', function() { + expect(this.layoutView.onBeforeAttach).to.have.been.calledOnce; + expect(this.layoutView.onAttach).to.have.been.calledOnce; + }); + + it('should trigger onBeforeAttach & onAttach on the mainView a single time', function() { + expect(this.mainView.onBeforeAttach).to.have.been.calledOnce; + expect(this.mainView.onAttach).to.have.been.calledOnce; + }); + + it('should trigger onBeforeAttach & onAttach on the footerView a single time', function() { + expect(this.footerView.onBeforeAttach).to.have.been.calledOnce; + expect(this.footerView.onAttach).to.have.been.calledOnce; + }); + }); + }); +});