backbone-fractal
Version:
Lightweight composite views for Backbone
464 lines (396 loc) • 16.5 kB
text/typescript
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);
});
});
});