backbone-fractal
Version:
Lightweight composite views for Backbone
587 lines (506 loc) • 20 kB
text/typescript
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([]);
});
});
});
});