feat(query): make QueryList notify on changes via an observable

BREAKING CHANGE:

Before: query.onChange(() => ...);
After: query.changes.subscribe((iterable) => {});

Closes #4395
This commit is contained in:
vsavkin 2015-09-30 08:41:00 -07:00 committed by Victor Savkin
parent 9b7378d132
commit 3aa204791b
8 changed files with 131 additions and 180 deletions

View File

@ -383,7 +383,7 @@ export class ElementInjector extends TreeNode<ElementInjector> implements Depend
this._preBuiltObjects = null; this._preBuiltObjects = null;
this._strategy.callOnDestroy(); this._strategy.callOnDestroy();
this._strategy.dehydrate(); this._strategy.dehydrate();
this._queryStrategy.clearQueryLists(); this._queryStrategy.dehydrate();
} }
hydrate(imperativelyCreatedInjector: Injector, host: ElementInjector, hydrate(imperativelyCreatedInjector: Injector, host: ElementInjector,
@ -392,6 +392,7 @@ export class ElementInjector extends TreeNode<ElementInjector> implements Depend
this._preBuiltObjects = preBuiltObjects; this._preBuiltObjects = preBuiltObjects;
this._reattachInjectors(imperativelyCreatedInjector); this._reattachInjectors(imperativelyCreatedInjector);
this._queryStrategy.hydrate();
this._strategy.hydrate(); this._strategy.hydrate();
this.hydrated = true; this.hydrated = true;
@ -604,7 +605,8 @@ export class ElementInjector extends TreeNode<ElementInjector> implements Depend
interface _QueryStrategy { interface _QueryStrategy {
setContentQueriesAsDirty(): void; setContentQueriesAsDirty(): void;
setViewQueriesAsDirty(): void; setViewQueriesAsDirty(): void;
clearQueryLists(): void; hydrate(): void;
dehydrate(): void;
updateContentQueries(): void; updateContentQueries(): void;
updateViewQueries(): void; updateViewQueries(): void;
findQuery(query: QueryMetadata): QueryRef; findQuery(query: QueryMetadata): QueryRef;
@ -613,7 +615,8 @@ interface _QueryStrategy {
class _EmptyQueryStrategy implements _QueryStrategy { class _EmptyQueryStrategy implements _QueryStrategy {
setContentQueriesAsDirty(): void {} setContentQueriesAsDirty(): void {}
setViewQueriesAsDirty(): void {} setViewQueriesAsDirty(): void {}
clearQueryLists(): void {} hydrate(): void {}
dehydrate(): void {}
updateContentQueries(): void {} updateContentQueries(): void {}
updateViewQueries(): void {} updateViewQueries(): void {}
findQuery(query: QueryMetadata): QueryRef { findQuery(query: QueryMetadata): QueryRef {
@ -632,9 +635,9 @@ class InlineQueryStrategy implements _QueryStrategy {
constructor(ei: ElementInjector) { constructor(ei: ElementInjector) {
var protoRefs = ei._proto.protoQueryRefs; var protoRefs = ei._proto.protoQueryRefs;
if (protoRefs.length > 0) this.query0 = new QueryRef(protoRefs[0], new QueryList<any>(), ei); if (protoRefs.length > 0) this.query0 = new QueryRef(protoRefs[0], ei);
if (protoRefs.length > 1) this.query1 = new QueryRef(protoRefs[1], new QueryList<any>(), ei); if (protoRefs.length > 1) this.query1 = new QueryRef(protoRefs[1], ei);
if (protoRefs.length > 2) this.query2 = new QueryRef(protoRefs[2], new QueryList<any>(), ei); if (protoRefs.length > 2) this.query2 = new QueryRef(protoRefs[2], ei);
} }
setContentQueriesAsDirty(): void { setContentQueriesAsDirty(): void {
@ -649,39 +652,39 @@ class InlineQueryStrategy implements _QueryStrategy {
if (isPresent(this.query2) && this.query2.isViewQuery) this.query2.dirty = true; if (isPresent(this.query2) && this.query2.isViewQuery) this.query2.dirty = true;
} }
clearQueryLists(): void { hydrate(): void {
if (isPresent(this.query0)) this.query0.reset(); if (isPresent(this.query0)) this.query0.hydrate();
if (isPresent(this.query1)) this.query1.reset(); if (isPresent(this.query1)) this.query1.hydrate();
if (isPresent(this.query2)) this.query2.reset(); if (isPresent(this.query2)) this.query2.hydrate();
}
dehydrate(): void {
if (isPresent(this.query0)) this.query0.dehydrate();
if (isPresent(this.query1)) this.query1.dehydrate();
if (isPresent(this.query2)) this.query2.dehydrate();
} }
updateContentQueries() { updateContentQueries() {
if (isPresent(this.query0) && !this.query0.isViewQuery) { if (isPresent(this.query0) && !this.query0.isViewQuery) {
this.query0.update(); this.query0.update();
this.query0.list.fireCallbacks();
} }
if (isPresent(this.query1) && !this.query1.isViewQuery) { if (isPresent(this.query1) && !this.query1.isViewQuery) {
this.query1.update(); this.query1.update();
this.query1.list.fireCallbacks();
} }
if (isPresent(this.query2) && !this.query2.isViewQuery) { if (isPresent(this.query2) && !this.query2.isViewQuery) {
this.query2.update(); this.query2.update();
this.query2.list.fireCallbacks();
} }
} }
updateViewQueries() { updateViewQueries() {
if (isPresent(this.query0) && this.query0.isViewQuery) { if (isPresent(this.query0) && this.query0.isViewQuery) {
this.query0.update(); this.query0.update();
this.query0.list.fireCallbacks();
} }
if (isPresent(this.query1) && this.query1.isViewQuery) { if (isPresent(this.query1) && this.query1.isViewQuery) {
this.query1.update(); this.query1.update();
this.query1.list.fireCallbacks();
} }
if (isPresent(this.query2) && this.query2.isViewQuery) { if (isPresent(this.query2) && this.query2.isViewQuery) {
this.query2.update(); this.query2.update();
this.query2.list.fireCallbacks();
} }
} }
@ -703,7 +706,7 @@ class DynamicQueryStrategy implements _QueryStrategy {
queries: QueryRef[]; queries: QueryRef[];
constructor(ei: ElementInjector) { constructor(ei: ElementInjector) {
this.queries = ei._proto.protoQueryRefs.map(p => new QueryRef(p, new QueryList<any>(), ei)); this.queries = ei._proto.protoQueryRefs.map(p => new QueryRef(p, ei));
} }
setContentQueriesAsDirty(): void { setContentQueriesAsDirty(): void {
@ -720,10 +723,17 @@ class DynamicQueryStrategy implements _QueryStrategy {
} }
} }
clearQueryLists(): void { hydrate(): void {
for (var i = 0; i < this.queries.length; ++i) { for (var i = 0; i < this.queries.length; ++i) {
var q = this.queries[i]; var q = this.queries[i];
q.reset(); q.hydrate();
}
}
dehydrate(): void {
for (var i = 0; i < this.queries.length; ++i) {
var q = this.queries[i];
q.dehydrate();
} }
} }
@ -732,7 +742,6 @@ class DynamicQueryStrategy implements _QueryStrategy {
var q = this.queries[i]; var q = this.queries[i];
if (!q.isViewQuery) { if (!q.isViewQuery) {
q.update(); q.update();
q.list.fireCallbacks();
} }
} }
} }
@ -742,7 +751,6 @@ class DynamicQueryStrategy implements _QueryStrategy {
var q = this.queries[i]; var q = this.queries[i];
if (q.isViewQuery) { if (q.isViewQuery) {
q.update(); q.update();
q.list.fireCallbacks();
} }
} }
} }
@ -972,8 +980,10 @@ export class ProtoQueryRef {
} }
export class QueryRef { export class QueryRef {
constructor(public protoQueryRef: ProtoQueryRef, public list: QueryList<any>, public list: QueryList<any>;
private originator: ElementInjector, public dirty: boolean = true) {} public dirty: boolean;
constructor(public protoQueryRef: ProtoQueryRef, private originator: ElementInjector) {}
get isViewQuery(): boolean { return this.protoQueryRef.query.isViewQuery; } get isViewQuery(): boolean { return this.protoQueryRef.query.isViewQuery; }
@ -991,6 +1001,8 @@ export class QueryRef {
this.protoQueryRef.setter(dir, this.list); this.protoQueryRef.setter(dir, this.list);
} }
} }
this.list.notifyOnChanges();
} }
private _update(): void { private _update(): void {
@ -1073,9 +1085,10 @@ export class QueryRef {
inj.addDirectivesMatchingQuery(this.protoQueryRef.query, aggregator); inj.addDirectivesMatchingQuery(this.protoQueryRef.query, aggregator);
} }
reset(): void { dehydrate(): void { this.list = null; }
this.list.reset([]);
this.list.removeAllCallbacks(); hydrate(): void {
this.list = new QueryList<any>();
this.dirty = true; this.dirty = true;
} }
} }

View File

@ -1,6 +1,7 @@
library angular2.src.core.compiler.query_list; library angular2.src.core.compiler.query_list;
import 'dart:collection'; import 'dart:collection';
import 'package:angular2/src/core/facade/async.dart';
/** /**
* See query_list.ts * See query_list.ts
@ -8,33 +9,11 @@ import 'dart:collection';
class QueryList<T> extends Object class QueryList<T> extends Object
with IterableMixin<T> { with IterableMixin<T> {
List<T> _results = []; List<T> _results = [];
List _callbacks = []; EventEmitter _emitter = new EventEmitter();
bool _dirty = false;
Iterator<T> get iterator => _results.iterator; Iterator<T> get iterator => _results.iterator;
/** @private */ Stream<Iterable<T>> get changes => _emitter;
void reset(List<T> newList) {
_results = newList;
_dirty = true;
}
void add(T obj) {
_results.add(obj);
_dirty = true;
}
void onChange(callback) {
_callbacks.add(callback);
}
void removeCallback(callback) {
_callbacks.remove(callback);
}
void removeAllCallbacks() {
this._callbacks = [];
}
int get length => _results.length; int get length => _results.length;
T get first => _results.first; T get first => _results.first;
@ -49,10 +28,12 @@ class QueryList<T> extends Object
} }
/** @private */ /** @private */
void fireCallbacks() { void reset(List<T> newList) {
if (_dirty) { _results = newList;
_callbacks.forEach((c) => c());
_dirty = false;
} }
/** @private */
void notifyOnChanges() {
_emitter.add(this);
} }
} }

View File

@ -1,5 +1,6 @@
import {ListWrapper, MapWrapper} from 'angular2/src/core/facade/collection'; import {ListWrapper, MapWrapper} from 'angular2/src/core/facade/collection';
import {getSymbolIterator} from 'angular2/src/core/facade/lang'; import {getSymbolIterator} from 'angular2/src/core/facade/lang';
import {Observable, EventEmitter} from 'angular2/src/core/facade/async';
/** /**
@ -12,7 +13,7 @@ import {getSymbolIterator} from 'angular2/src/core/facade/lang';
* javascript `for (var i of items)` loops as well as in Angular templates with * javascript `for (var i of items)` loops as well as in Angular templates with
* `*ng-for="#i of myList"`. * `*ng-for="#i of myList"`.
* *
* Changes can be observed by attaching callbacks. * Changes can be observed by subscribing to the changes `Observable`.
* *
* NOTE: In the future this class will implement an `Observable` interface. * NOTE: In the future this class will implement an `Observable` interface.
* *
@ -21,45 +22,16 @@ import {getSymbolIterator} from 'angular2/src/core/facade/lang';
* @Component({...}) * @Component({...})
* class Container { * class Container {
* constructor(@Query(Item) items: QueryList<Item>) { * constructor(@Query(Item) items: QueryList<Item>) {
* items.onChange(() => console.log(items.length)); * items.changes.subscribe(_ => console.log(items.length));
* } * }
* } * }
* ``` * ```
*/ */
export class QueryList<T> { export class QueryList<T> {
protected _results: Array < T >= []; private _results: Array<T> = [];
protected _callbacks: Array < () => void >= []; private _emitter = new EventEmitter();
protected _dirty: boolean = false;
/** @private */
reset(newList: T[]): void {
this._results = newList;
this._dirty = true;
}
/** @private */
add(obj: T): void {
this._results.push(obj);
this._dirty = true;
}
/**
* registers a callback that is called upon each change.
*/
onChange(callback: () => void): void { this._callbacks.push(callback); }
/**
* removes a given callback.
*/
removeCallback(callback: () => void): void { ListWrapper.remove(this._callbacks, callback); }
/**
* removes all callback that have been attached.
*/
removeAllCallbacks(): void { this._callbacks = []; }
toString(): string { return this._results.toString(); }
get changes(): Observable { return this._emitter; }
get length(): number { return this._results.length; } get length(): number { return this._results.length; }
get first(): T { return ListWrapper.first(this._results); } get first(): T { return ListWrapper.first(this._results); }
get last(): T { return ListWrapper.last(this._results); } get last(): T { return ListWrapper.last(this._results); }
@ -71,11 +43,13 @@ export class QueryList<T> {
[getSymbolIterator()](): any { return this._results[getSymbolIterator()](); } [getSymbolIterator()](): any { return this._results[getSymbolIterator()](); }
toString(): string { return this._results.toString(); }
/**
* @private
*/
reset(res: T[]): void { this._results = res; }
/** @private */ /** @private */
fireCallbacks(): void { notifyOnChanges(): void { this._emitter.next(this); }
if (this._dirty) {
ListWrapper.forEach(this._callbacks, (c) => c());
this._dirty = false;
}
}
} }

View File

@ -6,6 +6,7 @@ import {Query, Directive} from 'angular2/src/core/metadata';
import {NgControl} from './ng_control'; import {NgControl} from './ng_control';
import {ControlValueAccessor} from './control_value_accessor'; import {ControlValueAccessor} from './control_value_accessor';
import {isPresent} from 'angular2/src/core/facade/lang'; import {isPresent} from 'angular2/src/core/facade/lang';
import {ObservableWrapper} from 'angular2/src/core/facade/async';
import {setProperty} from './shared'; import {setProperty} from './shared';
/** /**
@ -81,6 +82,6 @@ export class SelectControlValueAccessor implements ControlValueAccessor {
registerOnTouched(fn: () => any): void { this.onTouched = fn; } registerOnTouched(fn: () => any): void { this.onTouched = fn; }
private _updateValueWhenListOfOptionsChanges(query: QueryList<NgSelectOption>) { private _updateValueWhenListOfOptionsChanges(query: QueryList<NgSelectOption>) {
query.onChange(() => this.writeValue(this.value)); ObservableWrapper.subscribe(query.changes, (_) => this.writeValue(this.value));
} }
} }

View File

@ -13,6 +13,7 @@ import {
} from 'angular2/test_lib'; } from 'angular2/test_lib';
import {isPresent} from 'angular2/src/core/facade/lang'; import {isPresent} from 'angular2/src/core/facade/lang';
import {ObservableWrapper} from 'angular2/src/core/facade/async';
import { import {
Component, Component,
@ -263,7 +264,7 @@ export function main() {
}); });
describe("onChange", () => { describe("changes", () => {
it('should notify query on change', it('should notify query on change',
inject([TestComponentBuilder, AsyncTestCompleter], (tcb: TestComponentBuilder, async) => { inject([TestComponentBuilder, AsyncTestCompleter], (tcb: TestComponentBuilder, async) => {
var template = '<needs-query #q>' + var template = '<needs-query #q>' +
@ -277,7 +278,7 @@ export function main() {
var q = view.debugElement.componentViewChildren[0].getLocal("q"); var q = view.debugElement.componentViewChildren[0].getLocal("q");
view.detectChanges(); view.detectChanges();
q.query.onChange(() => { ObservableWrapper.subscribe(q.query.changes, (_) => {
expect(q.query.first.text).toEqual("1"); expect(q.query.first.text).toEqual("1");
expect(q.query.last.text).toEqual("2"); expect(q.query.last.text).toEqual("2");
async.done(); async.done();
@ -304,8 +305,8 @@ export function main() {
var firedQ2 = false; var firedQ2 = false;
q2.query.onChange(() => { firedQ2 = true; }); ObservableWrapper.subscribe(q2.query.changes, (_) => { firedQ2 = true; });
q1.query.onChange(() => { ObservableWrapper.subscribe(q1.query.changes, (_) => {
expect(firedQ2).toBe(true); expect(firedQ2).toBe(true);
async.done(); async.done();
}); });

View File

@ -1,8 +1,21 @@
import {describe, it, expect, beforeEach, ddescribe, iit, xit, el} from 'angular2/test_lib'; import {
describe,
it,
expect,
beforeEach,
ddescribe,
iit,
xit,
el,
fakeAsync,
tick
} from 'angular2/test_lib';
import {MapWrapper, ListWrapper, iterateListLike} from 'angular2/src/core/facade/collection'; import {MapWrapper, ListWrapper, iterateListLike} from 'angular2/src/core/facade/collection';
import {StringWrapper} from 'angular2/src/core/facade/lang'; import {StringWrapper} from 'angular2/src/core/facade/lang';
import {ObservableWrapper} from 'angular2/src/core/facade/async';
import {QueryList} from 'angular2/src/core/compiler/query_list'; import {QueryList} from 'angular2/src/core/compiler/query_list';
import {DOM} from 'angular2/src/core/dom/dom_adapter';
export function main() { export function main() {
@ -16,95 +29,64 @@ export function main() {
function logAppend(item) { log += (log.length == 0 ? '' : ', ') + item; } function logAppend(item) { log += (log.length == 0 ? '' : ', ') + item; }
it('should support adding objects and iterating over them', () => {
queryList.add('one');
queryList.add('two');
iterateListLike(queryList, logAppend);
expect(log).toEqual('one, two');
});
it('should support resetting and iterating over the new objects', () => { it('should support resetting and iterating over the new objects', () => {
queryList.add('one'); queryList.reset(['one']);
queryList.add('two'); queryList.reset(['two']);
queryList.reset(['one again']);
queryList.add('two again');
iterateListLike(queryList, logAppend); iterateListLike(queryList, logAppend);
expect(log).toEqual('one again, two again'); expect(log).toEqual('two');
}); });
it('should support length', () => { it('should support length', () => {
queryList.add('one'); queryList.reset(['one', 'two']);
queryList.add('two');
expect(queryList.length).toEqual(2); expect(queryList.length).toEqual(2);
}); });
it('should support map', () => { it('should support map', () => {
queryList.add('one'); queryList.reset(['one', 'two']);
queryList.add('two');
expect(queryList.map((x) => x)).toEqual(['one', 'two']); expect(queryList.map((x) => x)).toEqual(['one', 'two']);
}); });
it('should support toString', () => { it('should support toString', () => {
queryList.add('one'); queryList.reset(['one', 'two']);
queryList.add('two');
var listString = queryList.toString(); var listString = queryList.toString();
expect(StringWrapper.contains(listString, 'one')).toBeTruthy(); expect(StringWrapper.contains(listString, 'one')).toBeTruthy();
expect(StringWrapper.contains(listString, 'two')).toBeTruthy(); expect(StringWrapper.contains(listString, 'two')).toBeTruthy();
}); });
it('should support first and last', () => { it('should support first and last', () => {
queryList.add('one'); queryList.reset(['one', 'two', 'three']);
queryList.add('two');
queryList.add('three');
expect(queryList.first).toEqual('one'); expect(queryList.first).toEqual('one');
expect(queryList.last).toEqual('three'); expect(queryList.last).toEqual('three');
}); });
if (DOM.supportsDOMEvents()) {
describe('simple observable interface', () => { describe('simple observable interface', () => {
it('should fire callbacks on change', () => { it('should fire callbacks on change', fakeAsync(() => {
var fires = 0; var fires = 0;
queryList.onChange(() => { fires += 1; }); ObservableWrapper.subscribe(queryList.changes, (_) => { fires += 1; });
queryList.fireCallbacks(); queryList.notifyOnChanges();
expect(fires).toEqual(0); tick();
queryList.add('one');
queryList.fireCallbacks();
expect(fires).toEqual(1); expect(fires).toEqual(1);
queryList.fireCallbacks(); queryList.notifyOnChanges();
expect(fires).toEqual(1); tick();
});
expect(fires).toEqual(2);
it('should support removing callbacks', () => { }));
var fires = 0;
var callback = () => fires += 1; it('should provides query list as an argument', fakeAsync(() => {
queryList.onChange(callback); var recorded;
ObservableWrapper.subscribe(queryList.changes, (v: any) => { recorded = v; });
queryList.add('one');
queryList.fireCallbacks(); queryList.reset(["one"]);
expect(fires).toEqual(1); queryList.notifyOnChanges();
tick();
queryList.removeCallback(callback);
expect(recorded).toBe(queryList);
queryList.add('two'); }));
queryList.fireCallbacks();
expect(fires).toEqual(1);
});
it('should support removing all callbacks', () => {
var fires = 0;
var callback = () => fires += 1;
queryList.onChange(callback);
queryList.add('one');
queryList.removeAllCallbacks();
queryList.fireCallbacks();
expect(fires).toEqual(0);
});
}); });
}
}); });
} }

View File

@ -301,24 +301,26 @@ export function main() {
})); }));
it("should support <select> with a dynamic list of options", it("should support <select> with a dynamic list of options",
inject([TestComponentBuilder, AsyncTestCompleter], (tcb: TestComponentBuilder, async) => { inject([TestComponentBuilder], fakeAsync((tcb: TestComponentBuilder) => {
var t = `<div [ng-form-model]="form"> var t = `<div [ng-form-model]="form">
<select ng-control="city"> <select ng-control="city">
<option *ng-for="#c of data" [value]="c"></option> <option *ng-for="#c of data" [value]="c"></option>
</select> </select>
</div>`; </div>`;
tcb.overrideTemplate(MyComp, t).createAsync(MyComp).then((rootTC) => { var rootTC;
tcb.overrideTemplate(MyComp, t).createAsync(MyComp).then((rtc) => rootTC = rtc);
tick();
rootTC.debugElement.componentInstance.form = rootTC.debugElement.componentInstance.form =
new ControlGroup({"city": new Control("NYC")}); new ControlGroup({"city": new Control("NYC")});
rootTC.debugElement.componentInstance.data = ['SF', 'NYC']; rootTC.debugElement.componentInstance.data = ['SF', 'NYC'];
rootTC.detectChanges(); rootTC.detectChanges();
tick();
var select = rootTC.debugElement.query(By.css('select')); var select = rootTC.debugElement.query(By.css('select'));
expect(select.nativeElement.value).toEqual('NYC'); expect(select.nativeElement.value).toEqual('NYC');
async.done(); })));
});
}));
it("should support custom value accessors", it("should support custom value accessors",
inject([TestComponentBuilder, AsyncTestCompleter], (tcb: TestComponentBuilder, async) => { inject([TestComponentBuilder, AsyncTestCompleter], (tcb: TestComponentBuilder, async) => {

View File

@ -809,13 +809,12 @@ var NG_API = [
'Query.token', 'Query.token',
'Query.varBindings', 'Query.varBindings',
'QueryList', 'QueryList',
'QueryList.add()',
'QueryList.any():dart', 'QueryList.any():dart',
'QueryList.contains():dart', 'QueryList.contains():dart',
'QueryList.elementAt():dart', 'QueryList.elementAt():dart',
'QueryList.every():dart', 'QueryList.every():dart',
'QueryList.expand():dart', 'QueryList.expand():dart',
'QueryList.fireCallbacks():', 'QueryList.notifyOnChanges():',
'QueryList.first', 'QueryList.first',
'QueryList.firstWhere():dart', 'QueryList.firstWhere():dart',
'QueryList.fold():dart', 'QueryList.fold():dart',
@ -828,10 +827,8 @@ var NG_API = [
'QueryList.lastWhere():dart', 'QueryList.lastWhere():dart',
'QueryList.length', 'QueryList.length',
'QueryList.map()', 'QueryList.map()',
'QueryList.onChange()', 'QueryList.changes',
'QueryList.reduce():dart', 'QueryList.reduce():dart',
'QueryList.removeAllCallbacks()',
'QueryList.removeCallback()',
'QueryList.reset()', 'QueryList.reset()',
'QueryList.single', 'QueryList.single',
'QueryList.singleWhere():dart', 'QueryList.singleWhere():dart',