UNPKG

backbone-fractal

Version:

Lightweight composite views for Backbone

587 lines (506 loc) 20 kB
import { extend, times, uniq, result, map } from 'underscore'; import { reverse } from './underscore-compat'; import { View, Model } from 'backbone'; import { Spy } from 'jasmine-core'; import CompositeView, { SubView, SubViewDescription, _removeSubview, _detachSubview, _placeSubview, } from './composite-view'; class DerivedModel extends Model { different: boolean = true; } class Sub<M extends Model> extends View<M> { text: string; constructor(text: string) { super(); let self = this as Sub<M>; spyOn(self, 'remove').and.callThrough(); spyOn(self.$el, 'detach').and.callThrough(); this.text = text; this.render(); } render() { this.$el.text(this.text); return this; } remove() { // this is a trick to prevent an aliasing effect return super.remove(); } } extend(Sub.prototype, { tagName: 'p', }); const subviewSelector = Sub.prototype.tagName; const template = ` a<div id=sub1>b <div id=sub4></div> <div id=sub5> <div id=sub7></div> <div id=sub8></div> </div> </div> 1<div id=sub2>2</div>3 <div id=sub3> <div id=sub6></div> c</div>d `; class SpyingCompositeView extends CompositeView { subview1: Sub<DerivedModel>; subview2: Sub<DerivedModel>; subview3: Sub<DerivedModel>; subview4: Sub<Model>; subview5: Sub<Model>; subview6: Sub<Model>; subview7: Sub<Model>; subview8: Sub<Model>; items: Sub<Model>[]; _subviews: SubView[]; preinitialize(options) { let parentPrototype = Object.getPrototypeOf(CompositeView.prototype); spyOn(parentPrototype, 'remove').and.callThrough(); let self = this as SpyingCompositeView; spyOn(self, 'render').and.callThrough(); spyOn(self, 'beforeRender').and.callThrough(); spyOn(self, 'renderContainer').and.callThrough(); spyOn(self, 'afterRender').and.callThrough(); spyOn(self, 'placeSubviews').and.callThrough(); spyOn(self, 'detachSubviews').and.callThrough(); spyOn(self, 'clearSubviews').and.callThrough(); } initialize(options) { this.subview1 = new Sub<DerivedModel>('one'); this.subview2 = new Sub<DerivedModel>('two'); this.subview3 = new Sub<DerivedModel>('three'); this.subview4 = new Sub<Model>('four'); this.subview5 = new Sub<Model>('five'); this.subview6 = new Sub<Model>('six'); this.subview7 = new Sub<Model>('seven'); this.subview8 = new Sub<Model>('eight'); this.items = times(8, i => this[`subview${i + 1}`]); this._subviews = [ 'subview1', this.subview2, this.getSubview3, { view: 'subview4' }, this.describeSubview5, { view: this.subview6, selector: '#sub6', method: 'prepend', place: 'placeSubview6', }, { view: this.getSubview7, selector: this.getSelector7, method: this.getMethod7, place: true, }, { view: 'subview8', place: this.placeSubview8, }, ] as SubView[]; } subviews(): SubView[] { return this._subviews; } renderContainer(): this { this.$el.html(template); return this; } getSubview3() { return this.subview3; } describeSubview5() { return { view: 'subview5', selector: '#sub5', method: this.getMethod5, place: this.placeSubview5, }; } getMethod5() { return 'after'; } placeSubview5() { return true; } placeSubview6() { return true; } getSubview7() { return this.subview7; } getSelector7() { return '#sub7'; } getMethod7() { return 'before'; } placeSubview8() { return true; } } const whitespace = /\s+/g; function expectElementText<M extends Model>(view: View<M>, text: string) { expect(view.$el.text().replace(whitespace, ' ')).toBe(text); } describe('CompositeView', function() { let view: SpyingCompositeView; let baseInstance: CompositeView; function disableSubviews() { delete view.subview1; view.placeSubview5 = view.placeSubview6 = () => false; (view._subviews[7] as SubViewDescription).place = false; } beforeEach(function() { view = new SpyingCompositeView(); baseInstance = new CompositeView(); }); describe('.render()', function() { beforeEach(function() { view.render(); }); it('detaches and reinserts the subviews', function() { let { beforeRender, detachSubviews, renderContainer, placeSubviews, afterRender, } = view as { [T in keyof SpyingCompositeView]: Spy }; expect(afterRender).toHaveBeenCalled(); expect(placeSubviews).toHaveBeenCalledBefore(afterRender); expect(renderContainer).toHaveBeenCalledBefore(placeSubviews); expect(detachSubviews).toHaveBeenCalledBefore(renderContainer); expect(beforeRender).toHaveBeenCalledBefore(detachSubviews); }); it('correctly preserves DOM element references', function() { let subElements = view.$(subviewSelector).get(); expect(subElements.length).toBe(view.items.length); expect(uniq(subElements).length).toBe(subElements.length); view.items.forEach( subview => expect(subElements).toContain(subview.el) ); view.render(); expect(view.$(subviewSelector).get()).toEqual(subElements); }); it('returns this', function() { expect(view.render()).toBe(view); }); }); describe('.remove()', function() { beforeEach(function() { view.render(); this.returnValue = view.remove(); }); it('clears out all the subviews', function() { expect(view.clearSubviews).toHaveBeenCalled(); }); it('calls super.remove() after that', function() { let parentPrototype = Object.getPrototypeOf(CompositeView.prototype); expect(parentPrototype.remove).toHaveBeenCalled(); expect( parentPrototype.remove.calls.mostRecent().object ).toBe(view); }); it('returns this', function() { expect(this.returnValue).toBe(view); }); }); describe('.subviews', function() { it('by default is just an empty array', function() { expect(result(baseInstance, 'subviews')).toEqual([]); }); }); describe('.clearSubviews()', function() { beforeEach(function() { view.render(); this.returnValue = view.clearSubviews(); }); it('calls .remove on each of the subviews', function() { view.items.forEach( subview => expect(subview.remove).toHaveBeenCalled() ); expect(view.$(subviewSelector).length).toBe(0); }); it('returns this', function() { expect(this.returnValue).toBe(view); }); }); describe('.detachSubviews()', function() { beforeEach(function() { view.render(); this.returnValue = view.detachSubviews(); }); it('calls .$el.detach() on each subview', function() { view.items.forEach( subview => expect(subview.$el.detach).toHaveBeenCalled() ); expect(view.$(subviewSelector).length).toBe(0); }); it('does not call .remove() on any subview', function() { view.items.forEach( subview => expect(subview.remove).not.toHaveBeenCalled() ); }); it('returns this', function() { expect(this.returnValue).toBe(view); }); }); describe('.placeSubviews()', function() { beforeEach(function() { view.renderContainer(); }); it('puts the subviews in the DOM', function() { view.placeSubviews(); expectElementText( view, ' ab seven five 123 six cd onetwothreefoureight', ); }); it('respects the .defaultPlacement', function() { view.defaultPlacement = 'prepend'; view.placeSubviews(); expectElementText( view, 'eightfourthreetwoone ab seven five 123 six cd ', ); }); it('moves the subviews in the right order', function() { view.placeSubviews(); reverse(result(view, 'subviews')); view.placeSubviews(); expectElementText( view, ' ab seven five 123 six cd eightfourthreetwoone', ); }); it('returns this', function() { expect(view.placeSubviews()).toBe(view); }); }); describe('.forEachSubview()', function() { let words: string[]; let spy: Spy; let root: JQuery<Element>; function appendText<V extends View>(subview: V): void { words.push(subview.$el.text()); } function expectAppendedText(options, text: string): void { view.forEachSubview(appendText, options); expect(words.join('')).toBe(text); } beforeEach(function() { words = []; spy = jasmine.createSpy(); root = view.$el; }); it('lets you iterate over the subviews', function() { expectAppendedText(undefined, 'onetwothreefourfivesixseveneight'); }); it('passes view, element and method to the iteratee', function() { view.forEachSubview(spy); expect(map(spy.calls.all(), 'args')).toEqual([ [view.subview1, root, 'append'], [view.subview2, root, 'append'], [view.subview3, root, 'append'], [view.subview4, root, 'append'], [view.subview5, view.$('#sub5').first(), 'after'], [view.subview6, view.$('#sub6').first(), 'prepend'], [view.subview7, view.$('#sub7').first(), 'before'], [view.subview8, root, 'append'], ]); }); it('binds this to the view (if not already bound)', function() { view.forEachSubview(spy); expect(map(spy.calls.all(), 'object')).toEqual([ view, view, view, view, view, view, view, view, ]); }); it('can run in reverse', function() { expectAppendedText( { reverse: true }, 'eightsevensixfivefourthreetwoone', ); }); it('returns this', function() { expect(view.forEachSubview(spy)).toBe(view); }) describe('with disabled subviews', function() { beforeEach(disableSubviews); it('will not include subviews that stopped existing', function() { expectAppendedText(undefined, 'twothreefourfivesixseveneight'); }); it('can skip subviews that fail the placement check', function() { expectAppendedText({ placeOnly: true }, 'twothreefourseven'); }); it('can run in reverse as well', function() { expectAppendedText({ placeOnly: true, reverse: true, }, 'sevenfourthreetwo'); }); }); }); describe('._getSubviews()', function() { let root: JQuery<Element>; beforeEach(function() { root = view.$el; }); it('returns a standardized list of subviews', function(){ expect(view._getSubviews(true)).toEqual([ [view.subview1, root, 'append'], [view.subview2, root, 'append'], [view.subview3, root, 'append'], [view.subview4, root, 'append'], [view.subview5, view.$('#sub5').first(), 'after'], [view.subview6, view.$('#sub6').first(), 'prepend'], [view.subview7, view.$('#sub7').first(), 'before'], [view.subview8, root, 'append'], ]); }); describe('with disabled subviews', function() { beforeEach(disableSubviews); it('returns only the ones that currently exist', function() { expect(view._getSubviews(false)).toEqual([ [view.subview2, root, 'append'], [view.subview3, root, 'append'], [view.subview4, root, 'append'], [view.subview5, view.$('#sub5').first(), 'after'], [view.subview6, view.$('#sub6').first(), 'prepend'], [view.subview7, view.$('#sub7').first(), 'before'], [view.subview8, root, 'append'], ]); }); it('can also omit ones that fail the placement check', function() { expect(view._getSubviews(true)).toEqual([ [view.subview2, root, 'append'], [view.subview3, root, 'append'], [view.subview4, root, 'append'], [view.subview7, view.$('#sub7').first(), 'before'], ]); }); }); }); describe('._normalizeSubview()', function() { let root: JQuery<Element>; beforeEach(function() { this.normalize = view._normalizeSubview.bind(view, true); view._resetContainer(); root = view.$el; }); it('coerces various ways to specify subviews to one format', function(){ expect(map(result(view, 'subviews'), this.normalize)).toEqual([ [view.subview1, root, 'append'], [view.subview2, root, 'append'], [view.subview3, root, 'append'], [view.subview4, root, 'append'], [view.subview5, view.$('#sub5').first(), 'after'], [view.subview6, view.$('#sub6').first(), 'prepend'], [view.subview7, view.$('#sub7').first(), 'before'], [view.subview8, root, 'append'], ]); }); it('respects the .defaultPlacement', function(){ view.defaultPlacement = 'prepend'; expect(map(result(view, 'subviews'), this.normalize)).toEqual([ [view.subview1, root, 'prepend'], [view.subview2, root, 'prepend'], [view.subview3, root, 'prepend'], [view.subview4, root, 'prepend'], [view.subview5, view.$('#sub5').first(), 'after'], [view.subview6, view.$('#sub6').first(), 'prepend'], [view.subview7, view.$('#sub7').first(), 'before'], [view.subview8, root, 'prepend'], ]); }); it('allows only safe insertion methods without a selector', function() { let risky = method => () => this.normalize({ view: 'subview1', method, }); ['append', 'prepend'].forEach(method => { expect(risky(method)).not.toThrow(); }); ['after', 'before', 'replaceWith'].forEach(method => { expect(risky(method)).toThrowError(TypeError); }); }); describe('for disabled subviews', function() { beforeEach(disableSubviews); it('returns null if the subview does not exist', function() { this.normalize = view._normalizeSubview.bind(view, false); expect(map(result(view, 'subviews'), this.normalize)).toEqual([ null, [view.subview2, root, 'append'], [view.subview3, root, 'append'], [view.subview4, root, 'append'], [view.subview5, view.$('#sub5').first(), 'after'], [view.subview6, view.$('#sub6').first(), 'prepend'], [view.subview7, view.$('#sub7').first(), 'before'], [view.subview8, root, 'append'], ]); }); it('can also return null for no-place subviews', function() { expect(map(result(view, 'subviews'), this.normalize)).toEqual([ null, [view.subview2, root, 'append'], [view.subview3, root, 'append'], [view.subview4, root, 'append'], null, null, [view.subview7, view.$('#sub7').first(), 'before'], null, ]); }); }); }); describe('._resolve()', function() { it('returns the value of a property when given the name', function() { expect(view._resolve('subview1')).toBe(view.subview1); }); it('returns the result of a method when given the name', function() { expect(view._resolve('getMethod5')).toBe('after'); }); it('returns the same value when given a plain value', function() { expect(view._resolve(view.subview1)).toBe(view.subview1); }); it('returns the result when given a function', function() { expect(view._resolve(() => 'bla')).toBe('bla'); }); it('binds this to the view when invoking a function', function() { function tester() { return this.subview1; } expect(view._resolve(tester)).toBe(view.subview1); }); }); describe('utility functions', function() { beforeEach(function() { this.sub = view.subview1; }); describe('_removeSubview', function() { it('takes a View and calls .remove on it', function() { _removeSubview(this.sub); expect(this.sub.remove).toHaveBeenCalled(); }); }); describe('_detachSubview', function() { it('takes a View and calls .$el.detach on it', function() { _detachSubview(this.sub); expect(this.sub.$el.detach).toHaveBeenCalled(); }); }); describe('_placeSubview()', function() { let sub2: JQuery<Element>; beforeEach(function() { view._resetContainer(); sub2 = view.$('#sub2').first(); }); it('applies the method relative to the container', function(){ _placeSubview.call(view, this.sub, sub2, 'append'); expectElementText(view, ' ab 12one3 cd '); expect(sub2.children().get()).toEqual([this.sub.el]); _placeSubview.call(view, this.sub, sub2, 'prepend'); expectElementText(view, ' ab 1one23 cd '); expect(sub2.children().get()).toEqual([this.sub.el]); _placeSubview.call(view, this.sub, sub2, 'after'); expectElementText(view, ' ab 12one3 cd '); expect(sub2.children().get()).toEqual([]); _placeSubview.call(view, this.sub, sub2, 'before'); expectElementText(view, ' ab 1one23 cd '); expect(sub2.children().get()).toEqual([]); _placeSubview.call(view, this.sub, sub2, 'replaceWith'); expectElementText(view, ' ab 1one3 cd '); expect(view.$('#sub2').get()).toEqual([]); }); }); }); });