From a6542c0201362df1a874039a5a26bd3f96f5f02f Mon Sep 17 00:00:00 2001 From: Nedyalko Nikolov Date: Tue, 25 Aug 2015 15:48:25 +0300 Subject: [PATCH] Added support for binding to multiple observable objects (properties). --- apps/tests/ui/bindable-tests.ts | 254 ++++++++++++++++++++++++++++++++ data/observable/observable.d.ts | 5 + data/observable/observable.ts | 8 +- ui/core/bindable.ts | 201 ++++++++++++++++--------- 4 files changed, 396 insertions(+), 72 deletions(-) diff --git a/apps/tests/ui/bindable-tests.ts b/apps/tests/ui/bindable-tests.ts index fcefcf755..fa74af4f9 100644 --- a/apps/tests/ui/bindable-tests.ts +++ b/apps/tests/ui/bindable-tests.ts @@ -633,4 +633,258 @@ export function test_UpdatingNestedPropertyViaBinding() { TKUnit.assertEqual(parentViewModel.get("name"), expectedValue2); TKUnit.assertEqual(testElement2.get("targetProperty"), expectedValue2); +} + +class Person extends observable.Observable { + private _firstName: string; + private _lastName: string; + + public get FirstName(): string { + return this._firstName; + } + + public set FirstName(value: string) { + if (this._firstName !== value) { + this._firstName = value; + this.notifyPropertyChange("FirstName", value); + } + } + + public get LastName(): string { + return this._lastName; + } + + public set LastName(value: string) { + if (this._lastName !== value) { + this._lastName = value; + this.notifyPropertyChange("LastName", value); + } + } +} + +class Activity extends observable.Observable { + private _text: string; + private _owner: Person; + + public get Text(): string { + return this._text; + } + + public set Text(value: string) { + if (this._text !== value) { + this._text = value; + this.notifyPropertyChange("Text", value); + } + } + + public get Owner(): Person { + return this._owner; + } + + public set Owner(value: Person) { + if (this._owner !== value) { + this._owner = value; + this.notifyPropertyChange("Owner", value); + } + } + + constructor(text: string, firstName: string, lastName: string) { + super(); + this._text = text; + var owner = new Person(); + owner.FirstName = firstName; + owner.LastName = lastName; + this.Owner = owner; + } +} + +export function test_NestedPropertiesBinding() { + var expectedValue = "Default Text"; + var viewModel = new observable.Observable(); + viewModel.set("activity", new Activity(expectedValue, "Default First Name", "Default Last Name")); + + var target1 = new bindable.Bindable(); + target1.bind({ + sourceProperty: "activity.Text", + targetProperty: "targetProperty", + twoWay: true + }, viewModel); + + TKUnit.assertEqual(target1.get("targetProperty"), expectedValue); + + var newExpectedValue = "Alabala"; + var act = new Activity(newExpectedValue, "Default First Name", "Default Last Name"); + + viewModel.set("activity", act); + + TKUnit.assertEqual(target1.get("targetProperty"), newExpectedValue); +} + +export function test_NestedPropertiesBindingTwoTargets() { + var expectedText = "Default Text"; + var expectedFirstName = "Default First Name"; + var expectedLastName = "Default Last Name"; + var viewModel = new observable.Observable(); + viewModel.set("activity", new Activity(expectedText, expectedFirstName, expectedLastName)); + + var target1 = new bindable.Bindable(); + target1.bind({ + sourceProperty: "activity.Text", + targetProperty: "targetProperty", + twoWay: true + }, viewModel); + + var target2 = new bindable.Bindable(); + target2.bind({ + sourceProperty: "activity.Owner.FirstName", + targetProperty: "targetProp", + twoWay: true + }, viewModel); + + TKUnit.assertEqual(target1.get("targetProperty"), expectedText); + TKUnit.assertEqual(target2.get("targetProp"), expectedFirstName); + + var newExpectedText = "Alabala"; + var newExpectedFirstName = "First Tralala"; + var newExpectedLastName = "Last Tralala"; + var act = new Activity(newExpectedText, newExpectedFirstName, newExpectedLastName); + + viewModel.set("activity", act); + + TKUnit.assertEqual(target1.get("targetProperty"), newExpectedText); + TKUnit.assertEqual(target2.get("targetProp"), newExpectedFirstName); +} + +export function test_NestedPropertiesBindingTwoTargetsAndSecondChange() { + var expectedText = "Default Text"; + var expectedFirstName = "Default First Name"; + var expectedLastName = "Default Last Name"; + var viewModel = new observable.Observable(); + viewModel.set("activity", new Activity(expectedText, expectedFirstName, expectedLastName)); + + var target1 = new bindable.Bindable(); + target1.bind({ + sourceProperty: "activity.Text", + targetProperty: "targetProperty", + twoWay: true + }, viewModel); + + var target2 = new bindable.Bindable(); + target2.bind({ + sourceProperty: "activity.Owner.FirstName", + targetProperty: "targetProp", + twoWay: true + }, viewModel); + + TKUnit.assertEqual(target1.get("targetProperty"), expectedText); + TKUnit.assertEqual(target2.get("targetProp"), expectedFirstName); + + var newExpectedText = "Alabala"; + var newExpectedFirstName = "First Tralala"; + var newExpectedLastName = "Last Tralala"; + var act = new Activity(newExpectedText, newExpectedFirstName, newExpectedLastName); + + viewModel.set("activity", act); + + TKUnit.assertEqual(target1.get("targetProperty"), newExpectedText); + TKUnit.assertEqual(target2.get("targetProp"), newExpectedFirstName); + + var secondExpectedText = "Second expected text"; + var secondExpectedFirstName = "Second expected first name"; + var secondExpectedLastName = "Second expected last name"; + var act1 = new Activity(secondExpectedText, secondExpectedFirstName, secondExpectedLastName); + + viewModel.set("activity", act1); + + TKUnit.assertEqual(target1.get("targetProperty"), secondExpectedText); + TKUnit.assertEqual(target2.get("targetProp"), secondExpectedFirstName); +} + +export function test_NestedPropertiesBindingTwoTargetsAndRegularChange() { + var expectedText = "Default Text"; + var expectedFirstName = "Default First Name"; + var expectedLastName = "Default Last Name"; + var viewModel = new observable.Observable(); + viewModel.set("activity", new Activity(expectedText, expectedFirstName, expectedLastName)); + + var target1 = new bindable.Bindable(); + target1.bind({ + sourceProperty: "activity.Text", + targetProperty: "targetProperty", + twoWay: true + }, viewModel); + + var target2 = new bindable.Bindable(); + target2.bind({ + sourceProperty: "activity.Owner.FirstName", + targetProperty: "targetProp", + twoWay: true + }, viewModel); + + TKUnit.assertEqual(target1.get("targetProperty"), expectedText); + TKUnit.assertEqual(target2.get("targetProp"), expectedFirstName); + + var newExpectedText = "Alabala"; + var newExpectedFirstName = "First Tralala"; + var newExpectedLastName = "Last Tralala"; + var act = new Activity(newExpectedText, newExpectedFirstName, newExpectedLastName); + + viewModel.set("activity", act); + + TKUnit.assertEqual(target1.get("targetProperty"), newExpectedText); + TKUnit.assertEqual(target2.get("targetProp"), newExpectedFirstName); + + var newAct = viewModel.get("activity"); + var secondExpectedText = "Second expected text"; + newAct.Text = secondExpectedText; + var secondExpectedFirstName = "Second expected First Name"; + newAct.Owner.FirstName = secondExpectedFirstName; + + TKUnit.assertEqual(target1.get("targetProperty"), secondExpectedText); + TKUnit.assertEqual(target2.get("targetProp"), secondExpectedFirstName); +} + +export function test_NestedPropertiesBindingTwoTargetsAndReplacingSomeNestedObject() { + var expectedText = "Default Text"; + var expectedFirstName = "Default First Name"; + var expectedLastName = "Default Last Name"; + var viewModel = new observable.Observable(); + viewModel.set("activity", new Activity(expectedText, expectedFirstName, expectedLastName)); + + var target1 = new bindable.Bindable(); + target1.bind({ + sourceProperty: "activity.Text", + targetProperty: "targetProperty", + twoWay: true + }, viewModel); + + var target2 = new bindable.Bindable(); + target2.bind({ + sourceProperty: "activity.Owner.FirstName", + targetProperty: "targetProp", + twoWay: true + }, viewModel); + + TKUnit.assertEqual(target1.get("targetProperty"), expectedText); + TKUnit.assertEqual(target2.get("targetProp"), expectedFirstName); + + var newExpectedText = "Alabala"; + var newExpectedFirstName = "First Tralala"; + var newExpectedLastName = "Last Tralala"; + var act = new Activity(newExpectedText, newExpectedFirstName, newExpectedLastName); + + viewModel.set("activity", act); + + TKUnit.assertEqual(target1.get("targetProperty"), newExpectedText); + TKUnit.assertEqual(target2.get("targetProp"), newExpectedFirstName); + + var secondExpectedFirstName = "Second expected first name"; + var newPerson = new Person(); + newPerson.FirstName = secondExpectedFirstName; + newPerson.LastName = "Last Name"; + + var act1 = viewModel.get("activity"); + (act1).Owner = newPerson; + + TKUnit.assertEqual(target2.get("targetProp"), secondExpectedFirstName); } \ No newline at end of file diff --git a/data/observable/observable.d.ts b/data/observable/observable.d.ts index 45c2c33e0..df3546ea6 100644 --- a/data/observable/observable.d.ts +++ b/data/observable/observable.d.ts @@ -99,6 +99,11 @@ declare module "data/observable" { */ notify(data: EventData): void; + /** + * Notifies all the registered listeners for the property change event. + */ + notifyPropertyChange(propertyName: string, newValue: any): void; + /** * Checks whether a listener is registered for the specified event name. * @param eventName The name of the event to check for. diff --git a/data/observable/observable.ts b/data/observable/observable.ts index 0051686de..adbcadc19 100644 --- a/data/observable/observable.ts +++ b/data/observable/observable.ts @@ -91,6 +91,10 @@ export class Observable implements definition.Observable { } } + public notifyPropertyChange(propertyName: string, newValue: any) { + this.notify(this._createPropertyChangeData(propertyName, newValue)); + } + public set(name: string, value: any) { // TODO: Parameter validation if (this[name] === value) { @@ -122,8 +126,8 @@ export class Observable implements definition.Observable { var i; var entry: ListenerEntry; - - for (i = 0; i < observers.length; i++) { + var observersLength = observers.length; + for (i = 0; i < observersLength; i++) { entry = observers[i]; if (entry.thisArg) { entry.callback.apply(entry.thisArg, [data]); diff --git a/ui/core/bindable.ts b/ui/core/bindable.ts index 1d4902180..928163686 100644 --- a/ui/core/bindable.ts +++ b/ui/core/bindable.ts @@ -133,9 +133,13 @@ export class Binding { source: WeakRef; target: WeakRef; + private propertyChangeListeners = {}; + private sourceOptions: { instance: WeakRef; property: any }; private targetOptions: { instance: WeakRef; property: any }; + private sourcePropertiesArray: Array; + constructor(target: Bindable, options: definition.BindingOptions) { this.target = new WeakRef(target); this.options = options; @@ -160,20 +164,70 @@ export class Binding { } /* tslint:enable */ this.source = new WeakRef(obj); - this.updateTarget(this.getSourceProperty()); + this.updateTarget(this.getSourcePropertyValue()); if (!this.sourceOptions) { - this.sourceOptions = this.resolveOptions(this.source, this.options.sourceProperty); + this.sourceOptions = this.resolveOptions(this.source, this.getSourceProperties()); } - if (this.sourceOptions) { - var sourceOptionsInstance = this.sourceOptions.instance.get(); - if (sourceOptionsInstance instanceof observable.Observable) { - weakEvents.addWeakEventListener( - sourceOptionsInstance, - observable.Observable.propertyChangeEvent, - this.onSourcePropertyChanged, - this); + this.addPropertyChangeListeners(this.source, this.getSourceProperties()); + } + + private getSourceProperties(): Array { + if (!this.sourcePropertiesArray) { + this.sourcePropertiesArray = Binding.getProperties(this.options.sourceProperty); + } + return this.sourcePropertiesArray; + } + + private static getProperties(property: string) { + return property.split("."); + } + + private resolveObjectsAndProperties(source: Object, propsArray: Array) { + var result = []; + var i; + var propsArrayLength = propsArray.length; + var currentObject = source; + var objProp = ""; + var currentObjectChanged = false; + for (i = 0; i < propsArrayLength; i++) { + objProp = propsArray[i]; + if (propsArray[i] === bc.bindingValueKey) { + currentObjectChanged = true; + } + if (propsArray[i] === bc.parentValueKey || propsArray[i].indexOf(bc.parentsValueKey) === 0) { + var parentView = this.getParentView(this.target.get(), propsArray[i]).view; + if (parentView) { + currentObject = parentView.bindingContext; + } + currentObjectChanged = true; + } + result.push({ instance: currentObject, property: objProp }); + if (!currentObjectChanged) { + currentObject = currentObject[propsArray[i]]; + currentObjectChanged = false; + } + } + return result; + } + + private addPropertyChangeListeners(source: WeakRef, sourceProperty: Array) { + var objectsAndProperties = this.resolveObjectsAndProperties(source.get(), sourceProperty) + var objectsAndPropertiesLength = objectsAndProperties.length; + if (objectsAndPropertiesLength > 0) { + var i; + for (i = 0; i < objectsAndPropertiesLength; i++) { + var prop = objectsAndProperties[i].property; + var currentObject = objectsAndProperties[i].instance; + if (!this.propertyChangeListeners[prop] && currentObject instanceof observable.Observable) { + weakEvents.addWeakEventListener( + currentObject, + observable.Observable.propertyChangeEvent, + this.onSourcePropertyChanged, + this); + this.propertyChangeListeners[prop] = currentObject; + } } } } @@ -183,14 +237,15 @@ export class Binding { return; } - if (this.sourceOptions) { - var sourceOptionsInstance = this.sourceOptions.instance.get(); - if (sourceOptionsInstance) { - weakEvents.removeWeakEventListener(sourceOptionsInstance, - observable.Observable.propertyChangeEvent, - this.onSourcePropertyChanged, - this); - } + var i; + var propertyChangeListenersKeys = Object.keys(this.propertyChangeListeners); + for (i = 0; i < propertyChangeListenersKeys.length; i++) { + weakEvents.removeWeakEventListener( + this.propertyChangeListeners[propertyChangeListenersKeys[i]], + observable.Observable.propertyChangeEvent, + this.onSourcePropertyChanged, + this); + delete this.propertyChangeListeners[propertyChangeListenersKeys[i]]; } if (this.source) { @@ -203,6 +258,7 @@ export class Binding { if (this.targetOptions) { this.targetOptions = undefined; } + this.sourcePropertiesArray = undefined; } private prepareExpressionForUpdate(): string { @@ -281,7 +337,6 @@ export class Binding { public onSourcePropertyChanged(data: observable.PropertyChangeData) { if (this.options.expression) { - //this.prepareContextForExpression(this.source.get(), this.options.expression); var expressionValue = this._getExpressionValue(this.options.expression, false, undefined); if (expressionValue instanceof Error) { trace.write((expressionValue).message, trace.categories.Binding, trace.messageType.error); @@ -289,8 +344,47 @@ export class Binding { else { this.updateTarget(expressionValue); } - } else if (data.propertyName === this.sourceOptions.property) { - this.updateTarget(data.value); + } else { + var propIndex = this.getSourceProperties().indexOf(data.propertyName); + if (propIndex > -1) { + var props = this.getSourceProperties().slice(propIndex + 1); + var propsLength = props.length; + if (propsLength > 0) { + var value = data.value; + var i; + for (i = 0; i < propsLength; i++) { + value = value[props[i]]; + } + this.updateTarget(value); + } + else if (data.propertyName === this.sourceOptions.property) { + this.updateTarget(data.value); + } + } + } + + var sourceProps = Binding.getProperties(this.options.sourceProperty); + var sourcePropsLength = sourceProps.length; + var changedPropertyIndex = sourceProps.indexOf(data.propertyName); + if (changedPropertyIndex > -1) { + var probablyChangedObject = this.propertyChangeListeners[sourceProps[changedPropertyIndex + 1]]; + if (probablyChangedObject && + probablyChangedObject !== data.object[sourceProps[changedPropertyIndex]]) { + // remove all weakevent listeners after change, because changed object replaces object that is hooked for + // propertyChange event + for (i = sourcePropsLength - 1; i > changedPropertyIndex; i--) { + weakEvents.removeWeakEventListener( + this.propertyChangeListeners[sourceProps[i]], + observable.Observable.propertyChangeEvent, + this.onSourcePropertyChanged, + this); + delete this.propertyChangeListeners[sourceProps[i]]; + } + //var newProps = this.options.sourceProperty.substr(this.options.sourceProperty.indexOf(data.propertyName) + data.propertyName.length + 1); + var newProps = sourceProps.slice(changedPropertyIndex + 1); + // add new weakevent listeners + this.addPropertyChangeListeners(new WeakRef(data.object[sourceProps[changedPropertyIndex]]), newProps); + } } } @@ -317,11 +411,10 @@ export class Binding { } } - private getSourceProperty() { + private getSourcePropertyValue() { if (this.options.expression) { var changedModel = {}; changedModel[bc.bindingValueKey] = this.source.get(); - //this.prepareContextForExpression(this.source.get(), this.options.expression); var expressionValue = this._getExpressionValue(this.options.expression, false, changedModel); if (expressionValue instanceof Error) { trace.write((expressionValue).message, trace.categories.Binding, trace.messageType.error); @@ -332,7 +425,7 @@ export class Binding { } if (!this.sourceOptions) { - this.sourceOptions = this.resolveOptions(this.source, this.options.sourceProperty); + this.sourceOptions = this.resolveOptions(this.source, this.getSourceProperties()); } var value; @@ -359,7 +452,7 @@ export class Binding { } if (!this.targetOptions) { - this.targetOptions = this.resolveOptions(this.target, this.options.targetProperty); + this.targetOptions = this.resolveOptions(this.target, Binding.getProperties(this.options.targetProperty)); } this.updateOptions(this.targetOptions, value); @@ -371,7 +464,7 @@ export class Binding { } if (!this.sourceOptions) { - this.sourceOptions = this.resolveOptions(this.source, this.options.sourceProperty); + this.sourceOptions = this.resolveOptions(this.source, this.getSourceProperties()); } this.updateOptions(this.sourceOptions, value); @@ -412,51 +505,19 @@ export class Binding { return { view: result, index: index }; } - private resolveOptions(obj: WeakRef, property: string): { instance: any; property: any } { - var options; - - if (property === bc.bindingValueKey) { - options = { - instance: obj, - property: property - }; - return options; - } - - if (types.isString(property) && property.indexOf(".") !== -1) { - var properties = property.split("."); - - var i: number; - var currentObject = obj.get(); - - for (i = 0; i < properties.length - 1; i++) { - if (properties[i] === bc.bindingValueKey) { - continue; - } - if (properties[i] === bc.parentValueKey || properties[i].indexOf(bc.parentsValueKey) === 0) { - var parentView = this.getParentView(this.target.get(), properties[i]).view; - if (parentView) { - currentObject = parentView.bindingContext; - } - continue; - } - currentObject = currentObject[properties[i]]; - } - - if (!types.isNullOrUndefined(currentObject)) { - options = { - instance: new WeakRef(currentObject), - property: properties[properties.length - 1] - } - } - } else { - options = { - instance: obj, - property: property + private resolveOptions(obj: WeakRef, properties: Array): { instance: any; property: any } { + var objectsAndProperties = this.resolveObjectsAndProperties(obj.get(), properties); + if (objectsAndProperties.length > 0) { + var resolvedObj = objectsAndProperties[objectsAndProperties.length - 1].instance; + var prop = objectsAndProperties[objectsAndProperties.length - 1].property; + return { + instance: new WeakRef(resolvedObj), + property: prop } } - - return options; + else { + return null; + } } private updateOptions(options: { instance: WeakRef; property: any }, value: any) { @@ -485,4 +546,4 @@ export class Binding { this.updating = false; } -} +} \ No newline at end of file