feat(ng-repeat): initial implementaion of ng-repeat.

- adds support for content bindings via '[]'.
- directives module
This commit is contained in:
Rado Kirov 2014-12-05 17:44:00 -08:00
parent 59d6d604b4
commit 60456c8b89
13 changed files with 388 additions and 14 deletions

1
.gitignore vendored
View File

@ -17,6 +17,7 @@ node_modules
pubspec.lock
.c9
.idea/
*.swo
/docs/bower_components/

View File

@ -46,6 +46,7 @@ module.exports = function(config) {
'/packages/change_detection': 'http://localhost:9877/base/modules/change_detection/src',
'/packages/reflection': 'http://localhost:9877/base/modules/reflection/src',
'/packages/di': 'http://localhost:9877/base/modules/di/src',
'/packages/directives': 'http://localhost:9877/base/modules/directives/src',
'/packages/facade': 'http://localhost:9877/base/modules/facade/src',
'/packages/test_lib': 'http://localhost:9877/base/modules/test_lib/src',
},

View File

@ -118,11 +118,16 @@ export class ElementBinderBuilder extends CompileStep {
throw new BaseException('No element binding found for property '+elProp
+' which is required by directive '+stringify(typeWithAnnotation.type));
}
var len = dirProp.length;
var dirBindingName = dirProp;
var isContentWatch = dirProp[len - 2] === '[' && dirProp[len - 1] === ']';
if (isContentWatch) dirBindingName = dirProp.substring(0, len - 2);
protoView.bindDirectiveProperty(
directiveIndex++,
expression,
dirProp,
reflector.setter(dirProp)
dirBindingName,
reflector.setter(dirBindingName),
isContentWatch
);
});
});

View File

@ -417,7 +417,8 @@ export class ProtoView {
directiveIndex:number,
expression:AST,
setterName:string,
setter:SetterFn) {
setter:SetterFn,
isContentWatch: boolean) {
var expMemento = new DirectivePropertyMemento(
this.elementBinders.length-1,
@ -426,7 +427,7 @@ export class ProtoView {
setter
);
var groupMemento = DirectivePropertyGroupMemento.get(expMemento);
this.protoRecordRange.addRecordsFromAST(expression, expMemento, groupMemento, false);
this.protoRecordRange.addRecordsFromAST(expression, expMemento, groupMemento, isContentWatch);
}
// Create a rootView as if the compiler encountered <rootcmp></rootcmp>,

View File

@ -37,7 +37,11 @@ export class ViewPort {
dehydrate() {
this.appInjector = null;
this.hostElementInjector = null;
for (var i = 0; i < this._views.length; i++) {
this.clear();
}
clear() {
for (var i = this._views.length - 1; i >= 0; i--) {
this.remove(i);
}
}

View File

@ -382,7 +382,7 @@ export function main() {
var pv = new ProtoView(createElement('<div class="ng-binding"></div>'),
new ProtoRecordRange());
pv.bindElement(new ProtoElementInjector(null, 0, [SomeDirective]));
pv.bindDirectiveProperty(0, parser.parseBinding('foo'), 'prop', reflector.setter('prop'));
pv.bindDirectiveProperty(0, parser.parseBinding('foo'), 'prop', reflector.setter('prop'), false);
createViewAndChangeDetector(pv);
ctx.foo = 'buz';
@ -395,8 +395,8 @@ export function main() {
new ProtoRecordRange());
pv.bindElement(new ProtoElementInjector(null, 0, [DirectiveImplementingOnChange]));
pv.bindDirectiveProperty( 0, parser.parseBinding('a'), 'a', reflector.setter('a'));
pv.bindDirectiveProperty( 0, parser.parseBinding('b'), 'b', reflector.setter('b'));
pv.bindDirectiveProperty( 0, parser.parseBinding('a'), 'a', reflector.setter('a'), false);
pv.bindDirectiveProperty( 0, parser.parseBinding('b'), 'b', reflector.setter('b'), false);
createViewAndChangeDetector(pv);
ctx.a = 100;
@ -412,9 +412,10 @@ export function main() {
new ProtoRecordRange());
pv.bindElement(new ProtoElementInjector(null, 0, [DirectiveImplementingOnChange]));
pv.bindDirectiveProperty( 0, parser.parseBinding('a').ast, 'a', reflector.setter('a'));
pv.bindDirectiveProperty( 0, parser.parseBinding('b').ast, 'b', reflector.setter('b'));
createView(pv);
pv.bindDirectiveProperty( 0, parser.parseBinding('a').ast, 'a', reflector.setter('a'), false);
pv.bindDirectiveProperty( 0, parser.parseBinding('b').ast, 'b', reflector.setter('b'), false);
createViewAndChangeDetector(pv);
ctx.a = 0;
ctx.b = 0;
cd.detectChanges();

View File

@ -0,0 +1,18 @@
name: directives
environment:
sdk: '>=1.4.0'
dependencies:
core:
path: ../core
change_detection:
path: ../change_detection
di:
path: ../di
facade:
path: ../facade
reflection:
path: ../reflection
dev_dependencies:
test_lib:
path: ../test_lib
guinness: ">=0.1.16 <0.2.0"

View File

@ -0,0 +1,94 @@
import {describe, xit, it, expect, beforeEach, ddescribe, iit} from 'test_lib/test_lib';
import {Decorator, Component, Template} from 'core/annotations/annotations';
import {OnChange} from 'core/compiler/interfaces';
import {ViewPort} from 'core/compiler/viewport';
import {View} from 'core/compiler/view';
import {isPresent, isBlank} from 'facade/lang';
import {ListWrapper, List} from 'facade/collection';
@Template({
selector: '[ng-repeat]',
bind: {
'in': 'iterable[]'
}
})
export class NgRepeat extends OnChange {
viewPort: ViewPort;
iterable;
constructor(viewPort: ViewPort) {
this.viewPort = viewPort;
}
onChange(changes) {
var iteratorChanges = changes['iterable'];
if (isBlank(iteratorChanges) || isBlank(iteratorChanges.currentValue)) {
this.viewPort.clear();
return;
}
// TODO(rado): check if change detection can produce a change record that is
// easier to consume than current.
var recordViewTuples = [];
iteratorChanges.currentValue.forEachRemovedItem(
(removedRecord) => ListWrapper.push(recordViewTuples, new RecordViewTuple(removedRecord, null))
);
iteratorChanges.currentValue.forEachMovedItem(
(movedRecord) => ListWrapper.push(recordViewTuples, new RecordViewTuple(movedRecord, null))
);
var insertTuples = NgRepeat.bulkRemove(recordViewTuples, this.viewPort);
iteratorChanges.currentValue.forEachAddedItem(
(addedRecord) => ListWrapper.push(insertTuples, new RecordViewTuple(addedRecord, null))
);
NgRepeat.bulkInsert(insertTuples, this.viewPort);
for (var i = 0; i < insertTuples.length; i++) {
this.perViewChange(insertTuples[i].view, insertTuples[i].record);
}
}
perViewChange(view, record) {
view.setLocal('ng-repeat', record.item);
// Uncomment when binding is ready.
// view.setLocal('index', record.item);
}
static bulkRemove(tuples, viewPort) {
tuples.sort((a, b) => a.record.previousIndex - b.record.previousIndex);
var movedTuples = [];
for (var i = tuples.length - 1; i >= 0; i--) {
var tuple = tuples[i];
var view = viewPort.remove(tuple.record.previousIndex);
if (isPresent(tuple.record.currentIndex)) {
tuple.view = view;
ListWrapper.push(movedTuples, tuple);
}
}
return movedTuples;
}
static bulkInsert(tuples, viewPort) {
tuples.sort((a, b) => a.record.currentIndex - b.record.currentIndex);
for (var i = 0; i < tuples.length; i++) {
var tuple = tuples[i];
if (isPresent(tuple.view)) {
viewPort.insert(tuple.view, tuple.record.currentIndex);
} else {
tuple.view = viewPort.create(tuple.record.currentIndex);
}
}
return tuples;
}
}
class RecordViewTuple {
view: View;
record: any;
constructor(record, view) {
this.record = record;
this.view = view;
}
}

View File

@ -0,0 +1,239 @@
import {describe, xit, it, expect, beforeEach, ddescribe, iit} from 'test_lib/test_lib';
import {DOM} from 'facade/dom';
import {Injector} from 'di/di';
import {ChangeDetector} from 'change_detection/change_detector';
import {Parser} from 'change_detection/parser/parser';
import {Lexer} from 'change_detection/parser/lexer';
import {Compiler, CompilerCache} from 'core/compiler/compiler';
import {OnChange} from 'core/compiler/interfaces';
import {DirectiveMetadataReader} from 'core/compiler/directive_metadata_reader';
import {Decorator, Component, Template} from 'core/annotations/annotations';
import {TemplateConfig} from 'core/annotations/template_config';
import {ViewPort} from 'core/compiler/viewport';
import {MapWrapper, ListWrapper} from 'facade/collection';
import {NgRepeat} from 'directives/ng_repeat';
export function main() {
describe('ng-repeat', () => {
var view, cd, compiler, component;
beforeEach(() => {
compiler = new Compiler(null, new DirectiveMetadataReader(), new Parser(new Lexer()), new CompilerCache());
});
function createElement(html) {
return DOM.createTemplate(html).content.firstChild;
}
function createView(pv) {
component = new TestComponent();
view = pv.instantiate(null);
view.hydrate(new Injector([]), null, component);
cd = new ChangeDetector(view.recordRange);
}
function compileWithTemplate(template) {
return compiler.compile(TestComponent, createElement(template));
}
var TEMPLATE = '<div><copy-me template="ng-repeat #item in items">{{item.toString()}};</copy-me></div>';
it('should reflect initial elements', (done) => {
compileWithTemplate(TEMPLATE).then((pv) => {
createView(pv);
cd.detectChanges();
expect(DOM.getText(view.nodes[0])).toEqual('1;2;');
done();
});
});
it('should reflect added elements', (done) => {
compileWithTemplate(TEMPLATE).then((pv) => {
createView(pv);
cd.detectChanges();
ListWrapper.push(component.items, 3);
cd.detectChanges();
expect(DOM.getText(view.nodes[0])).toEqual('1;2;3;');
done();
});
});
it('should reflect removed elements', (done) => {
compileWithTemplate(TEMPLATE).then((pv) => {
createView(pv);
cd.detectChanges();
ListWrapper.removeAt(component.items, 1);
cd.detectChanges();
expect(DOM.getText(view.nodes[0])).toEqual('1;');
done();
});
});
it('should reflect moved elements', (done) => {
compileWithTemplate(TEMPLATE).then((pv) => {
createView(pv);
cd.detectChanges();
ListWrapper.removeAt(component.items, 0);
ListWrapper.push(component.items, 1);
cd.detectChanges();
expect(DOM.getText(view.nodes[0])).toEqual('2;1;');
done();
});
});
it('should reflect a mix of all changes (additions/removals/moves)', (done) => {
compileWithTemplate(TEMPLATE).then((pv) => {
createView(pv);
component.items = [0, 1, 2, 3, 4, 5];
cd.detectChanges();
component.items = [6, 2, 7, 0, 4, 8];
cd.detectChanges();
expect(DOM.getText(view.nodes[0])).toEqual('6;2;7;0;4;8;');
done();
});
});
it('should iterate over an array of objects', () => {
compileWithTemplate('<ul><li template="ng-repeat #item in items">{{item["name"]}};</li></ul>').then((pv) => {
createView(pv);
// INIT
component.items = [{'name': 'misko'}, {'name':'shyam'}];
cd.detectChanges();
expect(DOM.getText(view.nodes[0])).toEqual('misko;shyam;');
// GROW
ListWrapper.push(component.items, {'name': 'adam'});
cd.detectChanges();
expect(DOM.getText(view.nodes[0])).toEqual('misko;shyam;adam;');
// SHRINK
ListWrapper.removeAt(component.items, 2);
ListWrapper.removeAt(component.items, 0);
cd.detectChanges();
expect(DOM.getText(view.nodes[0])).toEqual('shyam;');
});
});
it('should gracefully handle nulls', (done) => {
compileWithTemplate('<ul><li template="ng-repeat #item in null">{{item}};</li></ul>').then((pv) => {
createView(pv);
cd.detectChanges();
expect(DOM.getText(view.nodes[0])).toEqual('');
done();
});
});
it('should gracefully handle ref changing to null and back', (done) => {
compileWithTemplate(TEMPLATE).then((pv) => {
createView(pv);
cd.detectChanges();
expect(DOM.getText(view.nodes[0])).toEqual('1;2;');
component.items = null;
cd.detectChanges();
expect(DOM.getText(view.nodes[0])).toEqual('');
component.items = [1, 2, 3];
cd.detectChanges();
expect(DOM.getText(view.nodes[0])).toEqual('1;2;3;');
done();
});
});
it('should throw on ref changing to string', (done) => {
compileWithTemplate(TEMPLATE).then((pv) => {
createView(pv);
cd.detectChanges();
expect(DOM.getText(view.nodes[0])).toEqual('1;2;');
component.items = 'whaaa';
expect(() => cd.detectChanges()).toThrowError();
done();
});
});
it('should works with duplicates', (done) => {
compileWithTemplate(TEMPLATE).then((pv) => {
createView(pv);
var a = new Foo();
component.items = [a, a];
cd.detectChanges();
expect(DOM.getText(view.nodes[0])).toEqual('foo;foo;');
done();
});
});
/*
TODO(rado): enable after compiler is fixed.
it('should repeat over nested arrays', (done) => {
compileWithTemplate(
'<ul><li template="ng-repeat #item in items">{{item.toString()}}' +
'<li template="ng-repeat #subitem in item">' +
'{{subitem}};' +
'</li>X</li></ul>'
).then((pv) => {
createView(pv);
component.items = [['a', 'b'], ['c','d']];
cd.detectChanges();
cd.detectChanges();
cd.detectChanges();
expect(DOM.getText(view.nodes[0])).toEqual('');
done();
});
});
TODO(rado): enable after compiler is fixed.
it('should display indices correctly', (done) => {
var INDEX_TEMPLATE = '<div><copy-me template="ng-repeat #item in items index #i">{{index.toString()}};</copy-me></div>';
compileWithTemplate(INDEX_TEMPLATE).then((pv) => {
createView(pv);
component.items = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
cd.detectChanges();
expect(DOM.getText(view.nodes[0])).toEqual('0123456789');
component.items = [1, 2, 6, 7, 4, 3, 5, 8, 9, 0];
cd.detectChanges();
expect(DOM.getText(view.nodes[0])).toEqual('0123456789');
done();
});
});
*/
});
}
class Foo {
toString() {
return 'foo';
}
}
@Component({
selector: 'test-cmp',
template: new TemplateConfig({
inline: '', // each test swaps with a custom template.
directives: [NgRepeat]
})
})
class TestComponent {
items: any;
item: any;
constructor() {
this.items = [1, 2];
}
}

View File

@ -53,6 +53,9 @@ class DOM {
static insertAfter(el, node) {
el.parentNode.insertBefore(node, el.nextNode);
}
static getText(Element el) {
return el.text;
}
static setText(Text text, String value) {
text.text = value;
}

View File

@ -49,6 +49,9 @@ export class DOM {
static setInnerHTML(el, value) {
el.innerHTML = value;
}
static getText(el: Element) {
return el.textContent;
}
static setText(text:Text, value:string) {
text.nodeValue = value;
}

View File

@ -24,6 +24,9 @@ System.paths = {
'di/*': './di/src/*.js',
'di/test/*': './di/test/*.js',
'directives/*': './directives/src/*.js',
'directives/test/*': './directives/test/*.js',
'reflection/*': './reflection/src/*.js',
'reflection/test/*': './reflection/test/*.js',

View File

@ -3,6 +3,7 @@ System.paths = {
'change_detection/*': '/change_detection/lib/*.js',
'facade/*': '/facade/lib/*.js',
'di/*': '/di/lib/*.js',
'directives/*': '/directives/lib/*.js',
'rtts_assert/*': '/rtts_assert/lib/*.js',
'test_lib/*': '/test_lib/lib/*.js',
'reflection/*': '/reflection/lib/*.js',