UNPKG

backbone-fractal

Version:

Lightweight composite views for Backbone

464 lines (396 loc) 16.5 kB
import { extend, map, template, property, CompiledTemplate } from 'underscore'; import { reverse } from './underscore-compat'; import { Collection, View } from 'backbone'; import CollectionView from './collection-view'; const itemSelector = 'li'; const mockModels = [{ id: 1, name: 'Ada', }, { id: 2, name: 'Brian', }, { id: 3, name: 'Cleo', }, { id: 4, name: 'Duncan', }, { id: 5, name: 'Eliza', }]; class ItemView extends View { template: CompiledTemplate; initialize() { this.render(); } render() { this.$el.html(this.template(this.model.toJSON())); return this; } remove() { // this is a trick to prevent an aliasing effect return super.remove(); } } extend(ItemView.prototype, { tagName: 'li', template: template('<%= name %>'), }); class SpyingCollectionView extends CollectionView<ItemView> { preinitialize(options) { let parentPrototype = Object.getPrototypeOf(CollectionView.prototype); spyOn(parentPrototype, 'remove').and.callThrough(); let self = this as SpyingCollectionView; spyOn(self, 'render').and.callThrough(); spyOn(self, 'beforeRender').and.callThrough(); spyOn(self, 'renderContainer').and.callThrough(); spyOn(self, 'afterRender').and.callThrough(); spyOn(self, 'makeItem').and.callThrough(); spyOn(self, 'initItems').and.callThrough(); spyOn(self, 'insertItem').and.callThrough(); spyOn(self, 'removeItem').and.callThrough(); spyOn(self, 'sortItems').and.callThrough(); spyOn(self, 'placeItems').and.callThrough(); spyOn(self, 'resetItems').and.callThrough(); spyOn(self, 'detachItems').and.callThrough(); spyOn(self, 'clearItems').and.callThrough(); } makeItem(model) { let item = super.makeItem(model); spyOn(item, 'remove').and.callThrough(); spyOn(item.$el, 'detach').and.callThrough(); return item; } } extend(SpyingCollectionView.prototype, { tagName: 'ul', subview: ItemView, }); function expectSameOrder(collection, view, should: boolean = true): void { let collectionOrder = map(collection.models, 'id'), itemOrder = map(view.items, property(['model', 'id'])); let expectation = expect(collectionOrder); if (!should) expectation = expectation.not; expectation.toEqual(itemOrder); } function expectSameOrderDOM(view, should: boolean = true): void { let itemOrder = map(view.items, property(['model', 'attributes', 'name'])), elementOrder = view.$(itemSelector).map( (i, e) => e.textContent ).get(); let expectation = expect(itemOrder); if (!should) expectation = expectation.not; expectation.toEqual(elementOrder); } describe('CollectionView', function() { beforeEach(function() { this.collection = new Collection(mockModels); this.spareModel = this.collection.pop(); this.view = new SpyingCollectionView({collection: this.collection}); }); describe('.render()', function() { beforeEach(function() { this.view.initItems().render(); }); it('detaches and reinserts the subviews', function() { let { beforeRender, detachItems, renderContainer, placeItems, afterRender, } = this.view; expect(afterRender).toHaveBeenCalled(); expect(placeItems).toHaveBeenCalledBefore(afterRender); expect(renderContainer).toHaveBeenCalledBefore(placeItems); expect(detachItems).toHaveBeenCalledBefore(renderContainer); expect(beforeRender).toHaveBeenCalledBefore(detachItems); }); it('correctly preserves DOM element references', function() { let firstChild = this.view.items[0], firstNameInList = () => this.view.$(itemSelector).get(0), nameFromFirst = () => firstChild.el; expect(firstNameInList()).toBe(nameFromFirst()); this.view.render(); expect(firstNameInList()).toBe(nameFromFirst()); }); it('returns this', function() { expect(this.view.render()).toBe(this.view); }); }); describe('.remove()', function() { beforeEach(function() { this.view.initItems().render().initCollectionEvents(); this.returnValue = this.view.remove(); }); it('clears out all the subviews', function() { expect(this.view.clearItems).toHaveBeenCalled(); }); it('calls super.remove() after that', function() { let parentPrototype = Object.getPrototypeOf(CollectionView.prototype); expect(parentPrototype.remove).toHaveBeenCalled(); expect( parentPrototype.remove.calls.mostRecent().object ).toBe(this.view); }); it('returns this', function() { expect(this.returnValue).toBe(this.view); }); }); describe('.makeItem()', function() { beforeEach(function() { this.view.initItems().render(); this.item = this.view.makeItem(this.spareModel); }); it('returns a new ItemView with the passed model', function() { expect(Object.getPrototypeOf(this.item).constructor).toBe(ItemView); expect(this.item.model).toBe(this.spareModel); }); it('does not register the created item as a side effect', function() { expect(this.collection.includes(this.spareModel)).toBeFalsy(); expectSameOrder(this.collection, this.view); expectSameOrderDOM(this.view); }); }); describe('.initItems()', function() { beforeEach(function() { this.view.initItems(); }); it('creates a subview for each model in the collection', function() { expect( this.view.makeItem.calls.count() ).toBe(this.collection.length); expect(this.view.items).toBeDefined(); expect(this.view.items.length).toBe(this.collection.length); expectSameOrder(this.collection, this.view); }); it('leaves the DOM unchanged', function() { this.view.render().clearItems(); this.view.initItems(); expect(this.view.$(itemSelector).length).toBe(0); }); it('returns this', function() { this.view.clearItems(); expect(this.view.initItems()).toBe(this.view); }); }); describe('.clearItems()', function() { beforeEach(function() { this.view.initItems().render(); this.returnValue = this.view.clearItems(); }); it('preserves the references to the subviews', function() { expectSameOrder(this.collection, this.view); }); it('calls .remove on each of the subviews', function() { this.view.items.forEach( item => expect(item.remove).toHaveBeenCalled() ); expect(this.view.$(itemSelector).length).toBe(0); }); it('returns this', function() { expect(this.returnValue).toBe(this.view); }); }); describe('.insertItem()', function() { beforeEach(function() { this.view.initItems().render().initCollectionEvents(); }); it('creates a subview and registers it', function() { let priorCalls = this.view.makeItem.calls.count(); this.view.insertItem(this.spareModel); let posteriorCalls = this.view.makeItem.calls.count(); expect(posteriorCalls - priorCalls).toBe(1); let lastIndex = this.view.items.length - 1; expect(this.view.items[lastIndex].model).toBe(this.spareModel); }); it('leaves the DOM unchanged', function() { let priorLength = this.view.$(itemSelector).length; this.view.insertItem(this.spareModel); let posteriorNames = this.view.$(itemSelector); expect(posteriorNames.length).toBe(priorLength); expect( posteriorNames.last().text() ).not.toEqual(this.spareModel.get('name')); }); it('executes when a model is added to the collection', function() { expect(this.view.insertItem).not.toHaveBeenCalled(); this.collection.add(this.spareModel, {sort: false}); expect(this.view.insertItem).toHaveBeenCalled(); expectSameOrder(this.collection, this.view); }); it('inserts in the same position as in the collection', function() { let targetIndex = 2; expect(this.collection.length).toBeGreaterThan(targetIndex + 1); this.collection.add(this.spareModel, {at: targetIndex, sort: false}); expect(this.view.insertItem).toHaveBeenCalled(); expectSameOrder(this.collection, this.view); }); it('works at index=0', function() { this.collection.add(this.spareModel, {at: 0, sort: false}); expect(this.view.insertItem).toHaveBeenCalled(); expectSameOrder(this.collection, this.view); }); it('returns this', function() { expect(this.view.insertItem(this.spareModel)).toBe(this.view); }); }); describe('.removeItem()', function() { beforeEach(function() { this.view.initItems().render().initCollectionEvents(); }); it('completely removes the subview at the given index', function() { let targetIndex = 2; expect(this.view.items.length).toBeGreaterThan(targetIndex); let removedItem = this.view.items[targetIndex]; let removedName = removedItem.model.get('name'); this.view.removeItem(null, null, {index: targetIndex}); expect(removedItem.remove).toHaveBeenCalled(); expect(this.view.items).not.toContain(removedItem); expect( this.view.$(itemSelector).text() ).not.toContain(removedName); expectSameOrderDOM(this.view); }); function checkRemoveFromCollection(model) { let priorLength = this.collection.length; this.collection.remove(model); expect(priorLength - this.collection.length).toBe(1); expect(this.view.removeItem).toHaveBeenCalled(); expectSameOrder(this.collection, this.view); expectSameOrderDOM(this.view); } it('executes when a model is removed from the collection', function() { expect(this.view.removeItem).not.toHaveBeenCalled(); checkRemoveFromCollection.call(this, this.collection.at(-1)); }); it('works at index=0', function() { checkRemoveFromCollection.call(this, this.collection.at(0)); }); it('works in the middle', function() { let index = 2; expect(this.collection.length).toBeGreaterThan(index + 1); checkRemoveFromCollection.call(this, this.collection.at(index)); }); it('returns this', function() { expect( this.view.removeItem(null, null, {index: 0}) ).toBe(this.view); }); }); describe('.sortItems()', function() { beforeEach(function() { this.view.initItems().render().initCollectionEvents(); }); it('syncs the subview order with the collection order', function() { reverse(this.view.items); expectSameOrder(this.collection, this.view, false); this.view.sortItems(); expectSameOrder(this.collection, this.view); }); it('leaves the DOM unchanged', function() { reverse(this.view.items); this.view.placeItems(); let priorCalls = this.view.placeItems.calls.count(); expectSameOrderDOM(this.view); this.view.sortItems(); expectSameOrderDOM(this.view, false); let posteriorCalls = this.view.placeItems.calls.count(); expect(posteriorCalls).toBe(priorCalls); }); it('executes when the collection is sorted', function() { expect(this.view.sortItems).not.toHaveBeenCalled(); this.collection.trigger('sort'); expect(this.view.sortItems).toHaveBeenCalled(); }); it('returns this', function() { expect(this.view.sortItems()).toBe(this.view); }); }); describe('.detachItems()', function() { beforeEach(function() { this.view.initItems().render(); this.returnValue = this.view.detachItems(); }); it('leaves the registered subviews in place', function() { expectSameOrder(this.collection, this.view); }); it('calls .$el.detach() on each subview', function() { this.view.items.forEach( item => expect(item.$el.detach).toHaveBeenCalled() ); expect(this.view.$(itemSelector).length).toBe(0); }); it('does not call .remove() on any subview', function() { this.view.items.forEach( item => expect(item.remove).not.toHaveBeenCalled() ); }); it('returns this', function() { expect(this.returnValue).toBe(this.view); }); }); describe('.placeItems()', function() { beforeEach(function() { this.view.initItems().render().initCollectionEvents(); }); it('puts the subviews in the DOM', function() { this.view.detachItems(); this.view.placeItems(); expectSameOrderDOM(this.view); }); it('moves the subviews in the right order', function() { reverse(this.view.items); this.view.placeItems(); expectSameOrderDOM(this.view); }); it('executes on collection update events', function() { let priorCalls = this.view.placeItems.calls.count(); this.collection.trigger('update'); let posteriorCalls = this.view.placeItems.calls.count(); expect(posteriorCalls - priorCalls).toBe(1); }); it('returns this', function() { expect(this.view.placeItems()).toBe(this.view); }); }); describe('.resetItems()', function() { beforeEach(function() { this.view.initItems().render().initCollectionEvents(); }); it('rebuilds the subviews from scratch', function() { let { initItems, clearItems, sortItems, detachItems, placeItems, } = this.view; expect(clearItems).not.toHaveBeenCalled(); this.view.resetItems(); expect(sortItems).not.toHaveBeenCalled(); expect(detachItems).toHaveBeenCalledTimes(1); expect(placeItems).toHaveBeenCalledTimes(2); expect(initItems).toHaveBeenCalledTimes(2); expect(clearItems).toHaveBeenCalled(); let lastClear = clearItems.calls.mostRecent().invocationOrder, lastInit = initItems.calls.mostRecent().invocationOrder, lastPlace = placeItems.calls.mostRecent().invocationOrder; expect(lastClear).toBeLessThan(lastInit); expect(lastInit).toBeLessThan(lastPlace); expectSameOrder(this.collection, this.view); expectSameOrderDOM(this.view); }); it('executes on collection reset events', function() { expect(this.view.resetItems).not.toHaveBeenCalled(); this.collection.reset(); expect(this.view.resetItems).toHaveBeenCalled(); expect(this.view.items).toBeDefined(); expect(this.view.items.length).toBe(0); expect(this.view.$(itemSelector).length).toBe(0); }); it('can efficiently replace all subviews', function() { this.collection.reset([this.spareModel]); expectSameOrder(this.collection, this.view); expectSameOrderDOM(this.view); }); it('returns this', function() { expect(this.view.resetItems()).toBe(this.view); }); }); });