From 06b0d15fc27e4d87e607d024268fadfe2ad017c9 Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Thu, 11 Aug 2016 23:12:10 +0200 Subject: [PATCH] Surface BindingNotifications in AvaloniaObject. --- src/Avalonia.Base/AvaloniaObject.cs | 31 ++- src/Avalonia.Base/AvaloniaProperty.cs | 22 +- src/Avalonia.Base/Data/BindingNotification.cs | 57 +++-- src/Avalonia.Base/DirectPropertyMetadata`1.cs | 8 +- src/Avalonia.Base/IPriorityValueOwner.cs | 8 + src/Avalonia.Base/PriorityValue.cs | 22 +- src/Avalonia.Base/PropertyMetadata.cs | 19 +- src/Avalonia.Base/StyledPropertyMetadata`1.cs | 8 +- .../Data/BindingTests_Validation.cs | 232 +++++++++++------- 9 files changed, 292 insertions(+), 115 deletions(-) diff --git a/src/Avalonia.Base/AvaloniaObject.cs b/src/Avalonia.Base/AvaloniaObject.cs index 469ea573c6..1ac534ce5e 100644 --- a/src/Avalonia.Base/AvaloniaObject.cs +++ b/src/Avalonia.Base/AvaloniaObject.cs @@ -461,6 +461,12 @@ namespace Avalonia } } + /// + void IPriorityValueOwner.BindingNotificationReceived(PriorityValue sender, BindingNotification notification) + { + BindingNotificationReceived(sender.Property, notification); + } + /// Delegate[] IAvaloniaObjectDebug.GetPropertyChangedSubscribers() { @@ -492,6 +498,18 @@ namespace Avalonia }); } + /// + /// Occurs when a is received for a property which has + /// data validation enabled. + /// + /// The property. + /// The binding notification. + protected virtual void BindingNotificationReceived( + AvaloniaProperty property, + BindingNotification notification) + { + } + /// /// Called when a avalonia property changes on the object. /// @@ -580,15 +598,20 @@ namespace Avalonia /// The cast value, or a . private static object CastOrDefault(object value, Type type) { - var error = value as BindingNotification; + var notification = value as BindingNotification; - if (error == null) + if (notification == null) { return TypeUtilities.CastOrDefault(value, type); } else { - return error; + if (notification.HasValue) + { + notification.Value = TypeUtilities.CastOrDefault(value, type); + } + + return notification; } } @@ -637,6 +660,8 @@ namespace Avalonia SetValue(property, notification.Value); } + BindingNotificationReceived(property, notification); + if (notification.ErrorType == BindingErrorType.Error) { Logger.Error( diff --git a/src/Avalonia.Base/AvaloniaProperty.cs b/src/Avalonia.Base/AvaloniaProperty.cs index 0f5500b116..6374042aab 100644 --- a/src/Avalonia.Base/AvaloniaProperty.cs +++ b/src/Avalonia.Base/AvaloniaProperty.cs @@ -251,6 +251,9 @@ namespace Avalonia /// Whether the property inherits its value. /// The default binding mode for the property. /// A validation function. + /// + /// Whether the property is interested in data validation. + /// /// /// A method that gets called before and after the property starts being notified on an /// object; the bool argument will be true before and false afterwards. This callback is @@ -263,6 +266,7 @@ namespace Avalonia bool inherits = false, BindingMode defaultBindingMode = BindingMode.OneWay, Func validate = null, + bool enableDataValidation = false, Action notifying = null) where TOwner : IAvaloniaObject { @@ -294,13 +298,17 @@ namespace Avalonia /// Whether the property inherits its value. /// The default binding mode for the property. /// A validation function. + /// + /// Whether the property is interested in data validation. + /// /// A public static AttachedProperty RegisterAttached( string name, TValue defaultValue = default(TValue), bool inherits = false, BindingMode defaultBindingMode = BindingMode.OneWay, - Func validate = null) + Func validate = null, + bool enableDataValidation = false) where THost : IAvaloniaObject { Contract.Requires(name != null); @@ -326,6 +334,9 @@ namespace Avalonia /// Whether the property inherits its value. /// The default binding mode for the property. /// A validation function. + /// + /// Whether the property is interested in data validation. + /// /// A public static AttachedProperty RegisterAttached( string name, @@ -333,7 +344,8 @@ namespace Avalonia TValue defaultValue = default(TValue), bool inherits = false, BindingMode defaultBindingMode = BindingMode.OneWay, - Func validate = null) + Func validate = null, + bool enableDataValidation = false) where THost : IAvaloniaObject { Contract.Requires(name != null); @@ -360,13 +372,17 @@ namespace Avalonia /// The value to use when the property is set to /// /// The default binding mode for the property. + /// + /// Whether the property is interested in data validation. + /// /// A public static DirectProperty RegisterDirect( string name, Func getter, Action setter = null, TValue unsetValue = default(TValue), - BindingMode defaultBindingMode = BindingMode.OneWay) + BindingMode defaultBindingMode = BindingMode.OneWay, + bool enableDataValidation = false) where TOwner : IAvaloniaObject { Contract.Requires(name != null); diff --git a/src/Avalonia.Base/Data/BindingNotification.cs b/src/Avalonia.Base/Data/BindingNotification.cs index 992e4093fc..b1c98c72d0 100644 --- a/src/Avalonia.Base/Data/BindingNotification.cs +++ b/src/Avalonia.Base/Data/BindingNotification.cs @@ -87,7 +87,7 @@ namespace Avalonia.Data /// Gets the value that should be passed to the target when /// is true. /// - public object Value { get; } + public object Value { get; set; } /// /// Gets a value indicating whether should be pushed to the target. @@ -97,13 +97,19 @@ namespace Avalonia.Data /// /// Gets the error that occurred on the source, if any. /// - public Exception Error { get; } + public Exception Error { get; private set; } /// /// Gets the type of error that represents, if any. /// - public BindingErrorType ErrorType { get; } + public BindingErrorType ErrorType { get; private set; } + /// + /// Compares two instances of for equality. + /// + /// The first instance. + /// The second instance. + /// true if the two instances are equal; otherwise false. public static bool operator ==(BindingNotification a, BindingNotification b) { if (object.ReferenceEquals(a, b)) @@ -122,45 +128,68 @@ namespace Avalonia.Data (a.ErrorType == BindingErrorType.None || ExceptionEquals(a.Error, b.Error)); } + /// + /// Compares two instances of for inequality. + /// + /// The first instance. + /// The second instance. + /// true if the two instances are unequal; otherwise false. public static bool operator !=(BindingNotification a, BindingNotification b) { return !(a == b); } + /// + /// Compares an object to an instance of for equality. + /// + /// The object to compare. + /// true if the two instances are equal; otherwise false. public override bool Equals(object obj) { return Equals(obj as BindingNotification); } + /// + /// Compares a value to an instance of for equality. + /// + /// The value to compare. + /// true if the two instances are equal; otherwise false. public bool Equals(BindingNotification other) { return this == other; } + /// + /// Gets the hash code for this instance of . + /// + /// A hash code. public override int GetHashCode() { return base.GetHashCode(); } - public BindingNotification WithError(Exception e) + /// + /// Adds an error to the . + /// + /// The error to add. + /// The error type. + public void AddError(Exception e, BindingErrorType type) { - if (e == null) - { - return this; - } + Contract.Requires(e != null); + Contract.Requires(type != BindingErrorType.None); if (Error != null) { - e = new AggregateException(Error, e); + Error = new AggregateException(Error, e); } - - if (HasValue) + else { - return new BindingNotification(e, BindingErrorType.Error, Value); + Error = e; } - else + + if (type == BindingErrorType.Error || ErrorType == BindingErrorType.Error) { - return new BindingNotification(e, BindingErrorType.Error, Value); + ErrorType = BindingErrorType.Error; } } diff --git a/src/Avalonia.Base/DirectPropertyMetadata`1.cs b/src/Avalonia.Base/DirectPropertyMetadata`1.cs index 69dac6e8e2..e446095c06 100644 --- a/src/Avalonia.Base/DirectPropertyMetadata`1.cs +++ b/src/Avalonia.Base/DirectPropertyMetadata`1.cs @@ -17,10 +17,14 @@ namespace Avalonia /// The value to use when the property is set to /// /// The default binding mode. + /// + /// Whether the property is interested in data validation. + /// public DirectPropertyMetadata( TValue unsetValue = default(TValue), - BindingMode defaultBindingMode = BindingMode.Default) - : base(defaultBindingMode) + BindingMode defaultBindingMode = BindingMode.Default, + bool enableDataValidation = false) + : base(defaultBindingMode, enableDataValidation) { UnsetValue = unsetValue; } diff --git a/src/Avalonia.Base/IPriorityValueOwner.cs b/src/Avalonia.Base/IPriorityValueOwner.cs index 2483739d54..57f98c0717 100644 --- a/src/Avalonia.Base/IPriorityValueOwner.cs +++ b/src/Avalonia.Base/IPriorityValueOwner.cs @@ -17,5 +17,13 @@ namespace Avalonia /// The old value. /// The new value. void Changed(PriorityValue sender, object oldValue, object newValue); + + /// + /// Called when a is received by a + /// . + /// + /// The source of the change. + /// The notification. + void BindingNotificationReceived(PriorityValue sender, BindingNotification notification); } } diff --git a/src/Avalonia.Base/PriorityValue.cs b/src/Avalonia.Base/PriorityValue.cs index 8673ab5f44..cfe6e8818d 100644 --- a/src/Avalonia.Base/PriorityValue.cs +++ b/src/Avalonia.Base/PriorityValue.cs @@ -237,8 +237,14 @@ namespace Avalonia /// The priority level that the value came from. private void UpdateValue(object value, int priority) { + var notification = value as BindingNotification; object castValue; + if (notification != null) + { + value = (notification.HasValue) ? notification.Value : null; + } + if (TypeUtilities.TryCast(_valueType, value, out castValue)) { var old = _value; @@ -250,7 +256,21 @@ namespace Avalonia ValuePriority = priority; _value = castValue; - _owner?.Changed(this, old, _value); + + if (notification?.HasValue == true) + { + notification.Value = castValue; + } + + if (notification == null || notification.HasValue) + { + _owner?.Changed(this, old, _value); + } + + if (notification != null) + { + _owner?.BindingNotificationReceived(this, notification); + } } else { diff --git a/src/Avalonia.Base/PropertyMetadata.cs b/src/Avalonia.Base/PropertyMetadata.cs index 4cbb99f7e4..a4d17407f8 100644 --- a/src/Avalonia.Base/PropertyMetadata.cs +++ b/src/Avalonia.Base/PropertyMetadata.cs @@ -17,9 +17,15 @@ namespace Avalonia /// Initializes a new instance of the class. /// /// The default binding mode. - public PropertyMetadata(BindingMode defaultBindingMode = BindingMode.Default) + /// + /// Whether the property is interested in data validation. + /// + public PropertyMetadata( + BindingMode defaultBindingMode = BindingMode.Default, + bool enableDataValidation = false) { _defaultBindingMode = defaultBindingMode; + EnabledDataValidation = enableDataValidation; } /// @@ -34,6 +40,17 @@ namespace Avalonia } } + /// + /// Gets a value indicating whether the property is interested in data validation. + /// + /// + /// Data validation is validation performed at the target of a binding, for example in a + /// view model using the INotifyDataErrorInfo interface. Only certain properties on a + /// control (such as a TextBox's Text property) will be interested in recieving data + /// validation messages so this feature must be explicitly enabled by setting this flag. + /// + public bool EnabledDataValidation { get; } + /// /// Merges the metadata with the base metadata. /// diff --git a/src/Avalonia.Base/StyledPropertyMetadata`1.cs b/src/Avalonia.Base/StyledPropertyMetadata`1.cs index ed01f1bc70..5982ca4506 100644 --- a/src/Avalonia.Base/StyledPropertyMetadata`1.cs +++ b/src/Avalonia.Base/StyledPropertyMetadata`1.cs @@ -18,11 +18,15 @@ namespace Avalonia /// The default value of the property. /// A validation function. /// The default binding mode. + /// + /// Whether the property is interested in data validation. + /// public StyledPropertyMetadata( TValue defaultValue = default(TValue), Func validate = null, - BindingMode defaultBindingMode = BindingMode.Default) - : base(defaultBindingMode) + BindingMode defaultBindingMode = BindingMode.Default, + bool enableDataValidation = false) + : base(defaultBindingMode, enableDataValidation) { DefaultValue = defaultValue; Validate = validate; diff --git a/tests/Avalonia.Markup.Xaml.UnitTests/Data/BindingTests_Validation.cs b/tests/Avalonia.Markup.Xaml.UnitTests/Data/BindingTests_Validation.cs index 82ea33257e..835e65d9e5 100644 --- a/tests/Avalonia.Markup.Xaml.UnitTests/Data/BindingTests_Validation.cs +++ b/tests/Avalonia.Markup.Xaml.UnitTests/Data/BindingTests_Validation.cs @@ -1,7 +1,10 @@ using System; +using System.Collections; +using System.Collections.Generic; using Avalonia.Controls; using Avalonia.Data; using Avalonia.Markup.Xaml.Data; +using Avalonia.UnitTests; using Xunit; namespace Avalonia.Markup.Xaml.UnitTests.Data @@ -9,124 +12,173 @@ namespace Avalonia.Markup.Xaml.UnitTests.Data public class BindingTests_Validation { [Fact] - public void Disabled_Validation_Should_Trigger_Validation_Change_On_Exception() + public void Non_Validated_Property_Does_Not_Receive_BindingNotifications() { var source = new ValidationTestModel { MustBePositive = 5 }; - var target = new TestControl { DataContext = source }; - var binding = new Binding + var target = new TestControl { - Path = nameof(source.MustBePositive), - Mode = BindingMode.TwoWay, - - // Even though EnableValidation = false, exception validation is enabled. - EnableValidation = false, + DataContext = source, + [!TestControl.NonValidatedProperty] = new Binding(nameof(source.MustBePositive)), }; - target.Bind(TestControl.ValidationTestProperty, binding); - - target.ValidationTest = -5; - - Assert.True(false); - //Assert.False(target.ValidationStatus.IsValid); + Assert.Empty(target.Notifications); } [Fact] - public void Enabled_Validation_Should_Trigger_Validation_Change_On_Exception() + public void Validated_Property_Does_Not_Receive_BindingNotifications() { var source = new ValidationTestModel { MustBePositive = 5 }; - var target = new TestControl { DataContext = source }; - var binding = new Binding + var target = new TestControl { - Path = nameof(source.MustBePositive), - Mode = BindingMode.TwoWay, - EnableValidation = true, + DataContext = source, + [!TestControl.ValidatedProperty] = new Binding(nameof(source.MustBePositive)), }; - target.Bind(TestControl.ValidationTestProperty, binding); + source.MustBePositive = 6; - target.ValidationTest = -5; - Assert.True(false); - //Assert.False(target.ValidationStatus.IsValid); + Assert.Equal( + new[] + { + new BindingNotification(5), + new BindingNotification(new ArgumentOutOfRangeException("value"), BindingErrorType.DataValidationError), + new BindingNotification(6), + }, + target.Notifications); } + //[Fact] + //public void Disabled_Validation_Should_Trigger_Validation_Change_On_Exception() + //{ + // var source = new ValidationTestModel { MustBePositive = 5 }; + // var target = new TestControl { DataContext = source }; + // var binding = new Binding + // { + // Path = nameof(source.MustBePositive), + // Mode = BindingMode.TwoWay, + + // // Even though EnableValidation = false, exception validation is enabled. + // EnableValidation = false, + // }; + + // target.Bind(TestControl.ValidationTestProperty, binding); + + // target.ValidationTest = -5; + + // Assert.True(false); + // //Assert.False(target.ValidationStatus.IsValid); + //} + + //[Fact] + //public void Enabled_Validation_Should_Trigger_Validation_Change_On_Exception() + //{ + // var source = new ValidationTestModel { MustBePositive = 5 }; + // var target = new TestControl { DataContext = source }; + // var binding = new Binding + // { + // Path = nameof(source.MustBePositive), + // Mode = BindingMode.TwoWay, + // EnableValidation = true, + // }; + + // target.Bind(TestControl.ValidationTestProperty, binding); + + // target.ValidationTest = -5; + // Assert.True(false); + // //Assert.False(target.ValidationStatus.IsValid); + //} + + + //[Fact] + //public void Passed_Validation_Should_Not_Add_Invalid_Pseudo_Class() + //{ + // var control = new TestControl(); + // var model = new ValidationTestModel { MustBePositive = 1 }; + // var binding = new Binding + // { + // Path = nameof(model.MustBePositive), + // Mode = BindingMode.TwoWay, + // EnableValidation = true, + // }; + + // control.Bind(TestControl.ValidationTestProperty, binding); + // control.DataContext = model; + // Assert.DoesNotContain(control.Classes, x => x == ":invalid"); + //} + + //[Fact] + //public void Failed_Validation_Should_Add_Invalid_Pseudo_Class() + //{ + // var control = new TestControl(); + // var model = new ValidationTestModel { MustBePositive = 1 }; + // var binding = new Binding + // { + // Path = nameof(model.MustBePositive), + // Mode = BindingMode.TwoWay, + // EnableValidation = true, + // }; + + // control.Bind(TestControl.ValidationTestProperty, binding); + // control.DataContext = model; + // control.ValidationTest = -5; + // Assert.Contains(control.Classes, x => x == ":invalid"); + //} + + //[Fact] + //public void Failed_Then_Passed_Validation_Should_Remove_Invalid_Pseudo_Class() + //{ + // var control = new TestControl(); + // var model = new ValidationTestModel { MustBePositive = 1 }; + + // var binding = new Binding + // { + // Path = nameof(model.MustBePositive), + // Mode = BindingMode.TwoWay, + // EnableValidation = true, + // }; + + // control.Bind(TestControl.ValidationTestProperty, binding); + // control.DataContext = model; + + + // control.ValidationTest = -5; + // Assert.Contains(control.Classes, x => x == ":invalid"); + // control.ValidationTest = 5; + // Assert.DoesNotContain(control.Classes, x => x == ":invalid"); + //} - [Fact] - public void Passed_Validation_Should_Not_Add_Invalid_Pseudo_Class() + private class TestControl : Control { - var control = new TestControl(); - var model = new ValidationTestModel { MustBePositive = 1 }; - var binding = new Binding - { - Path = nameof(model.MustBePositive), - Mode = BindingMode.TwoWay, - EnableValidation = true, - }; + public static readonly StyledProperty NonValidatedProperty = + AvaloniaProperty.Register( + nameof(Validated), + enableDataValidation: false); - control.Bind(TestControl.ValidationTestProperty, binding); - control.DataContext = model; - Assert.DoesNotContain(control.Classes, x => x == ":invalid"); - } + public static readonly StyledProperty ValidatedProperty = + AvaloniaProperty.Register( + nameof(Validated), + enableDataValidation: true); - [Fact] - public void Failed_Validation_Should_Add_Invalid_Pseudo_Class() - { - var control = new TestControl(); - var model = new ValidationTestModel { MustBePositive = 1 }; - var binding = new Binding + public int NonValidated { - Path = nameof(model.MustBePositive), - Mode = BindingMode.TwoWay, - EnableValidation = true, - }; - - control.Bind(TestControl.ValidationTestProperty, binding); - control.DataContext = model; - control.ValidationTest = -5; - Assert.Contains(control.Classes, x => x == ":invalid"); - } - - [Fact] - public void Failed_Then_Passed_Validation_Should_Remove_Invalid_Pseudo_Class() - { - var control = new TestControl(); - var model = new ValidationTestModel { MustBePositive = 1 }; + get { return GetValue(NonValidatedProperty); } + set { SetValue(NonValidatedProperty, value); } + } - var binding = new Binding + public int Validated { - Path = nameof(model.MustBePositive), - Mode = BindingMode.TwoWay, - EnableValidation = true, - }; - - control.Bind(TestControl.ValidationTestProperty, binding); - control.DataContext = model; - - - control.ValidationTest = -5; - Assert.Contains(control.Classes, x => x == ":invalid"); - control.ValidationTest = 5; - Assert.DoesNotContain(control.Classes, x => x == ":invalid"); - } + get { return GetValue(ValidatedProperty); } + set { SetValue(ValidatedProperty, value); } + } - private class TestControl : Control - { - public static readonly StyledProperty ValidationTestProperty - = AvaloniaProperty.Register(nameof(ValidationTest), 1, defaultBindingMode: BindingMode.TwoWay); + public IList Notifications { get; } = new List(); - public int ValidationTest + protected override void BindingNotificationReceived(AvaloniaProperty property, BindingNotification notification) { - get - { - return GetValue(ValidationTestProperty); - } - set - { - SetValue(ValidationTestProperty, value); - } + Notifications.Add(notification); } } - private class ValidationTestModel + private class ValidationTestModel : NotifyingBase { private int mustBePositive; @@ -139,7 +191,9 @@ namespace Avalonia.Markup.Xaml.UnitTests.Data { throw new ArgumentOutOfRangeException(nameof(value)); } + mustBePositive = value; + RaisePropertyChanged(); } } }