diff --git a/src/Markup/Perspex.Markup.Xaml/Data/Binding.cs b/src/Markup/Perspex.Markup.Xaml/Data/Binding.cs index a99582cea7..5a3f647013 100644 --- a/src/Markup/Perspex.Markup.Xaml/Data/Binding.cs +++ b/src/Markup/Perspex.Markup.Xaml/Data/Binding.cs @@ -77,6 +77,11 @@ namespace Perspex.Markup.Xaml.Data /// public object Source { get; set; } + /// + /// Gets or sets the validation methods for the binding to use. + /// + public ValidationMethods ValidationMethods { get; set; } + /// public InstancedBinding Initiate( IPerspexObject target, @@ -202,7 +207,7 @@ namespace Perspex.Markup.Xaml.Data var result = new ExpressionObserver( () => target.GetValue(Control.DataContextProperty), path, - update); + update, ValidationMethods); return result; } @@ -213,7 +218,7 @@ namespace Perspex.Markup.Xaml.Data .OfType() .Select(x => x.GetObservable(Control.DataContextProperty)) .Switch(), - path); + path, ValidationMethods); } } @@ -223,7 +228,7 @@ namespace Perspex.Markup.Xaml.Data var result = new ExpressionObserver( ControlLocator.Track(target, elementName), - path); + path, ValidationMethods); return result; } @@ -231,7 +236,7 @@ namespace Perspex.Markup.Xaml.Data { Contract.Requires(source != null); - return new ExpressionObserver(source, path); + return new ExpressionObserver(source, path, ValidationMethods); } private ExpressionObserver CreateTemplatedParentObserver( diff --git a/src/Markup/Perspex.Markup.Xaml/MarkupExtensions/BindingExtension.cs b/src/Markup/Perspex.Markup.Xaml/MarkupExtensions/BindingExtension.cs index 1e996b2b2a..d0dcd069a3 100644 --- a/src/Markup/Perspex.Markup.Xaml/MarkupExtensions/BindingExtension.cs +++ b/src/Markup/Perspex.Markup.Xaml/MarkupExtensions/BindingExtension.cs @@ -29,6 +29,7 @@ namespace Perspex.Markup.Xaml.MarkupExtensions Mode = Mode, Path = Path, Priority = Priority, + ValidationMethods = ValidationMethods }; } @@ -40,5 +41,6 @@ namespace Perspex.Markup.Xaml.MarkupExtensions public string Path { get; set; } public BindingPriority Priority { get; set; } = BindingPriority.LocalValue; public object Source { get; set; } + public ValidationMethods ValidationMethods { get; set; } = ValidationMethods.None; } } \ No newline at end of file diff --git a/src/Markup/Perspex.Markup/Data/CommonPropertyNames.cs b/src/Markup/Perspex.Markup/Data/CommonPropertyNames.cs index 5866539886..5082a89862 100644 --- a/src/Markup/Perspex.Markup/Data/CommonPropertyNames.cs +++ b/src/Markup/Perspex.Markup/Data/CommonPropertyNames.cs @@ -1,8 +1,5 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; +// Copyright (c) The Perspex Project. All rights reserved. +// Licensed under the MIT license. See licence.md file in the project root for full license information. namespace Perspex.Markup.Data { diff --git a/src/Markup/Perspex.Markup/Data/ExpressionNode.cs b/src/Markup/Perspex.Markup/Data/ExpressionNode.cs index e989a72177..b68c1ed426 100644 --- a/src/Markup/Perspex.Markup/Data/ExpressionNode.cs +++ b/src/Markup/Perspex.Markup/Data/ExpressionNode.cs @@ -105,6 +105,19 @@ namespace Perspex.Markup.Data CurrentValue = reference; } + protected virtual void SendValidationStatus(ValidationStatus status) + { + //Even if elements only bound to sub-values, send validation changes along so they will be surfaced to the UI level. + if (_subject != null) + { + _subject.OnNext(status); + } + else + { + Next?.SendValidationStatus(status); + } + } + protected virtual void Unsubscribe(object target) { } diff --git a/src/Markup/Perspex.Markup/Data/ExpressionObserver.cs b/src/Markup/Perspex.Markup/Data/ExpressionObserver.cs index e5b0d1ac78..d7fd25d237 100644 --- a/src/Markup/Perspex.Markup/Data/ExpressionObserver.cs +++ b/src/Markup/Perspex.Markup/Data/ExpressionObserver.cs @@ -28,6 +28,17 @@ namespace Perspex.Markup.Data new InpcPropertyAccessorPlugin(), }; + /// + /// An ordered collection of validation checker plugins that can be used to customize + /// the validation of view model and model data. + /// + public static readonly IList ValidationCheckers = + new List + { + new IndeiValidationCheckerPlugin(), + new ExceptionValidationCheckerPlugin() + }; + private readonly WeakReference _root; private readonly Func _rootGetter; private readonly IObservable _rootObservable; @@ -36,18 +47,20 @@ namespace Perspex.Markup.Data private IDisposable _updateSubscription; private int _count; private readonly ExpressionNode _node; + private ValidationMethods _methods; /// /// Initializes a new instance of the class. /// /// The root object. /// The expression. - public ExpressionObserver(object root, string expression) + /// The validation methods to enable on this observer. + public ExpressionObserver(object root, string expression, ValidationMethods methods = ValidationMethods.None) { Contract.Requires(expression != null); _root = new WeakReference(root); - + _methods = methods; if (!string.IsNullOrWhiteSpace(expression)) { _node = ExpressionNodeBuilder.Build(expression); @@ -61,13 +74,14 @@ namespace Perspex.Markup.Data /// /// An observable which provides the root object. /// The expression. - public ExpressionObserver(IObservable rootObservable, string expression) + /// The validation methods to enable on this observer. + public ExpressionObserver(IObservable rootObservable, string expression, ValidationMethods methods = ValidationMethods.None) { Contract.Requires(rootObservable != null); Contract.Requires(expression != null); _rootObservable = rootObservable; - + _methods = methods; if (!string.IsNullOrWhiteSpace(expression)) { _node = ExpressionNodeBuilder.Build(expression); @@ -82,10 +96,12 @@ namespace Perspex.Markup.Data /// A function which gets the root object. /// The expression. /// An observable which triggers a re-read of the getter. + /// The validation methods to enable on this observer. public ExpressionObserver( Func rootGetter, string expression, - IObservable update) + IObservable update, + ValidationMethods methods = ValidationMethods.None) { Contract.Requires(rootGetter != null); Contract.Requires(expression != null); @@ -93,7 +109,7 @@ namespace Perspex.Markup.Data _rootGetter = rootGetter; _update = update; - + _methods = methods; if (!string.IsNullOrWhiteSpace(expression)) { _node = ExpressionNodeBuilder.Build(expression); @@ -205,8 +221,8 @@ namespace Perspex.Markup.Data { source = source.TakeUntil(_update.LastOrDefaultAsync()); } - - var subscription = source.Subscribe(observer); + var validationFiltered = source.Where(o => (o as ValidationStatus)?.Match(_methods) ?? true); + var subscription = validationFiltered.Subscribe(observer); return Disposable.Create(() => { diff --git a/src/Markup/Perspex.Markup/Data/ExpressionSubject.cs b/src/Markup/Perspex.Markup/Data/ExpressionSubject.cs index 46df3ce490..2ba0a5b2db 100644 --- a/src/Markup/Perspex.Markup/Data/ExpressionSubject.cs +++ b/src/Markup/Perspex.Markup/Data/ExpressionSubject.cs @@ -155,6 +155,7 @@ namespace Perspex.Markup.Data { var converted = value as BindingError ?? + value as ValidationStatus ?? Converter.Convert( value, _targetType, diff --git a/src/Markup/Perspex.Markup/Data/Plugins/ExceptionValidationCheckerPlugin.cs b/src/Markup/Perspex.Markup/Data/Plugins/ExceptionValidationCheckerPlugin.cs new file mode 100644 index 0000000000..5110f4713e --- /dev/null +++ b/src/Markup/Perspex.Markup/Data/Plugins/ExceptionValidationCheckerPlugin.cs @@ -0,0 +1,74 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Perspex.Data; + +namespace Perspex.Markup.Data.Plugins +{ + /// + /// Validates properties that report errors by throwing exceptions. + /// + public class ExceptionValidationCheckerPlugin : IValidationCheckerPlugin + { + + /// + public bool Match(WeakReference reference) => true; + + + /// + public ValidationCheckerBase Start(WeakReference reference, string name, IPropertyAccessor accessor, Action callback) + { + return new ExceptionValidationChecker(reference, name, accessor, callback); + } + + private class ExceptionValidationChecker : ValidationCheckerBase + { + public ExceptionValidationChecker(WeakReference reference, string name, IPropertyAccessor accessor, Action callback) + : base(reference, name, accessor, callback) + { + } + + public override bool SetValue(object value, BindingPriority priority) + { + try + { + var success = base.SetValue(value, priority); + SendValidationCallback(new ExceptionValidationStatus(null)); + return success; + } + catch (Exception ex) + { + SendValidationCallback(new ExceptionValidationStatus(ex)); + } + return false; + } + } + + /// + /// Describes the current validation status after setting a property value. + /// + public class ExceptionValidationStatus : ValidationStatus + { + internal ExceptionValidationStatus(Exception exception) + { + Exception = exception; + } + + /// + /// The thrown exception. If there was no thrown exception, null. + /// + public Exception Exception { get; } + + + /// + public override bool IsValid => Exception == null; + + public override bool Match(ValidationMethods enabledMethods) + { + return (enabledMethods & ValidationMethods.Exceptions) != 0; + } + } + } +} diff --git a/src/Markup/Perspex.Markup/Data/Plugins/IPropertyAccessorPlugin.cs b/src/Markup/Perspex.Markup/Data/Plugins/IPropertyAccessorPlugin.cs index 8a431041d0..2f36352983 100644 --- a/src/Markup/Perspex.Markup/Data/Plugins/IPropertyAccessorPlugin.cs +++ b/src/Markup/Perspex.Markup/Data/Plugins/IPropertyAccessorPlugin.cs @@ -2,6 +2,7 @@ // Licensed under the MIT license. See licence.md file in the project root for full license information. using System; +using System.Collections; namespace Perspex.Markup.Data.Plugins { diff --git a/src/Markup/Perspex.Markup/Data/Plugins/IValidationCheckerPlugin.cs b/src/Markup/Perspex.Markup/Data/Plugins/IValidationCheckerPlugin.cs new file mode 100644 index 0000000000..9aff3cf3cc --- /dev/null +++ b/src/Markup/Perspex.Markup/Data/Plugins/IValidationCheckerPlugin.cs @@ -0,0 +1,36 @@ +using Perspex.Data; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Perspex.Markup.Data.Plugins +{ + /// + /// Defines how view model data validation is observed by an . + /// + public interface IValidationCheckerPlugin + { + + /// + /// Checks whether the data uses a validation scheme supported by this plugin. + /// + /// A weak reference to the data. + /// true if this plugin can observe the validation; otherwise, false. + bool Match(WeakReference reference); + + /// + /// Starts monitering the validation state of an object for the given property. + /// + /// A weak reference to the object. + /// The property name. + /// An underlying to access the property. + /// A function to call when the validation state changes. + /// + /// A subclass through which future interactions with the + /// property will be made. + /// + ValidationCheckerBase Start(WeakReference reference, string name, IPropertyAccessor accessor, Action callback); + } +} diff --git a/src/Markup/Perspex.Markup/Data/Plugins/IndeiValidationCheckerPlugin.cs b/src/Markup/Perspex.Markup/Data/Plugins/IndeiValidationCheckerPlugin.cs new file mode 100644 index 0000000000..ee4e87076f --- /dev/null +++ b/src/Markup/Perspex.Markup/Data/Plugins/IndeiValidationCheckerPlugin.cs @@ -0,0 +1,96 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Perspex.Data; +using System.ComponentModel; +using System.Collections; +using Perspex.Utilities; + +namespace Perspex.Markup.Data.Plugins +{ + /// + /// Validates properties on objects that implement . + /// + public class IndeiValidationCheckerPlugin : IValidationCheckerPlugin + { + /// + public bool Match(WeakReference reference) + { + return reference.Target is INotifyDataErrorInfo; + } + + /// + public ValidationCheckerBase Start(WeakReference reference, string name, IPropertyAccessor accessor, Action callback) + { + return new IndeiValidationChecker(reference, name, accessor, callback); + } + + private class IndeiValidationChecker : ValidationCheckerBase, IWeakSubscriber + { + public IndeiValidationChecker(WeakReference reference, string name, IPropertyAccessor accessor, Action callback) + : base(reference, name, accessor, callback) + { + var target = reference.Target as INotifyDataErrorInfo; + if (target != null) + { + if (target.HasErrors) + { + SendValidationCallback(new IndeiValidationStatus(target.GetErrors(name))); + } + WeakSubscriptionManager.Subscribe( + target, + nameof(target.ErrorsChanged), + this); + } + } + + public override void Dispose() + { + base.Dispose(); + var target = _reference.Target as INotifyDataErrorInfo; + if (target != null) + { + WeakSubscriptionManager.Unsubscribe( + target, + nameof(target.ErrorsChanged), + this); + } + } + + public void OnEvent(object sender, DataErrorsChangedEventArgs e) + { + if (e.PropertyName == _name || string.IsNullOrEmpty(e.PropertyName)) + { + var indei = _reference.Target as INotifyDataErrorInfo; + SendValidationCallback(new IndeiValidationStatus(indei.GetErrors(e.PropertyName))); + } + } + } + + /// + /// Describes the current validation status of a property as reported by an object that implements . + /// + public class IndeiValidationStatus : ValidationStatus + { + internal IndeiValidationStatus(IEnumerable errors) + { + Errors = errors; + } + + /// + public override bool IsValid => !Errors.OfType().Any(); + + /// + /// The errors on the given property and on the object as a whole. + /// + public IEnumerable Errors { get; } + + public override bool Match(ValidationMethods enabledMethods) + { + return (enabledMethods & ValidationMethods.INotifyDataErrorInfo) != 0; + } + } + } +} diff --git a/src/Markup/Perspex.Markup/Data/Plugins/InpcPropertyAccessorPlugin.cs b/src/Markup/Perspex.Markup/Data/Plugins/InpcPropertyAccessorPlugin.cs index 64421ddc57..a16903ea42 100644 --- a/src/Markup/Perspex.Markup/Data/Plugins/InpcPropertyAccessorPlugin.cs +++ b/src/Markup/Perspex.Markup/Data/Plugins/InpcPropertyAccessorPlugin.cs @@ -9,6 +9,7 @@ using System.Reflection; using Perspex.Data; using Perspex.Logging; using Perspex.Utilities; +using System.Collections; namespace Perspex.Markup.Data.Plugins { @@ -86,7 +87,7 @@ namespace Perspex.Markup.Data.Plugins if (inpc != null) { - WeakSubscriptionManager.Subscribe( + WeakSubscriptionManager.Subscribe( inpc, nameof(inpc.PropertyChanged), this); @@ -113,7 +114,7 @@ namespace Perspex.Markup.Data.Plugins if (inpc != null) { - WeakSubscriptionManager.Unsubscribe( + WeakSubscriptionManager.Unsubscribe( inpc, nameof(inpc.PropertyChanged), this); @@ -131,7 +132,7 @@ namespace Perspex.Markup.Data.Plugins return false; } - public void OnEvent(object sender, PropertyChangedEventArgs e) + void IWeakSubscriber.OnEvent(object sender, PropertyChangedEventArgs e) { if (e.PropertyName == _property.Name || string.IsNullOrEmpty(e.PropertyName)) { diff --git a/src/Markup/Perspex.Markup/Data/Plugins/ValidationCheckerBase.cs b/src/Markup/Perspex.Markup/Data/Plugins/ValidationCheckerBase.cs new file mode 100644 index 0000000000..8ab7b7e43e --- /dev/null +++ b/src/Markup/Perspex.Markup/Data/Plugins/ValidationCheckerBase.cs @@ -0,0 +1,34 @@ +using System; +using Perspex.Data; + +namespace Perspex.Markup.Data.Plugins +{ + public abstract class ValidationCheckerBase : IPropertyAccessor + { + protected readonly WeakReference _reference; + protected readonly string _name; + private readonly IPropertyAccessor _accessor; + private readonly Action _callback; + + protected ValidationCheckerBase(WeakReference reference, string name, IPropertyAccessor accessor, Action callback) + { + _reference = reference; + _name = name; + _accessor = accessor; + _callback = callback; + } + + public Type PropertyType => _accessor.PropertyType; + + public object Value => _accessor.Value; + + public virtual void Dispose() => _accessor.Dispose(); + + public virtual bool SetValue(object value, BindingPriority priority) => _accessor.SetValue(value, priority); + + protected void SendValidationCallback(ValidationStatus status) + { + _callback?.Invoke(status); + } + } +} \ No newline at end of file diff --git a/src/Markup/Perspex.Markup/Data/PropertyAccessorNode.cs b/src/Markup/Perspex.Markup/Data/PropertyAccessorNode.cs index 14883e449a..e0561ecfcf 100644 --- a/src/Markup/Perspex.Markup/Data/PropertyAccessorNode.cs +++ b/src/Markup/Perspex.Markup/Data/PropertyAccessorNode.cs @@ -50,11 +50,18 @@ namespace Perspex.Markup.Data if (instance != null && instance != PerspexProperty.UnsetValue) { - var plugin = ExpressionObserver.PropertyAccessors.FirstOrDefault(x => x.Match(reference)); + var accessorPlugin = ExpressionObserver.PropertyAccessors.FirstOrDefault(x => x.Match(reference)); - if (plugin != null) + if (accessorPlugin != null) { - _accessor = plugin.Start(reference, PropertyName, SetCurrentValue); + _accessor = accessorPlugin.Start(reference, PropertyName, SetCurrentValue); + foreach (var validationPlugin in ExpressionObserver.ValidationCheckers.Where(x => x.Match(reference))) + { + if (validationPlugin != null) + { + _accessor = validationPlugin.Start(reference, PropertyName, _accessor, SendValidationStatus); + } + } if (_accessor != null) { diff --git a/src/Markup/Perspex.Markup/Perspex.Markup.csproj b/src/Markup/Perspex.Markup/Perspex.Markup.csproj index 5427d97346..443991b120 100644 --- a/src/Markup/Perspex.Markup/Perspex.Markup.csproj +++ b/src/Markup/Perspex.Markup/Perspex.Markup.csproj @@ -46,6 +46,10 @@ + + + + diff --git a/src/Perspex.Base/Data/InstancedBinding.cs b/src/Perspex.Base/Data/InstancedBinding.cs index 545d690aa4..50daf56e97 100644 --- a/src/Perspex.Base/Data/InstancedBinding.cs +++ b/src/Perspex.Base/Data/InstancedBinding.cs @@ -30,7 +30,8 @@ namespace Perspex.Data /// The value used for the binding. /// /// The binding priority. - public InstancedBinding(object value, BindingPriority priority = BindingPriority.LocalValue) + public InstancedBinding(object value, + BindingPriority priority = BindingPriority.LocalValue) { Mode = BindingMode.OneTime; Priority = priority; diff --git a/src/Perspex.Base/Data/ValidationMethods.cs b/src/Perspex.Base/Data/ValidationMethods.cs new file mode 100644 index 0000000000..491a7bbe50 --- /dev/null +++ b/src/Perspex.Base/Data/ValidationMethods.cs @@ -0,0 +1,13 @@ +using System; + +namespace Perspex.Data +{ + [Flags] + public enum ValidationMethods + { + None = 0, + Exceptions = 1, + INotifyDataErrorInfo = 2, + All = -1 + } +} \ No newline at end of file diff --git a/src/Perspex.Base/Data/ValidationStatus.cs b/src/Perspex.Base/Data/ValidationStatus.cs new file mode 100644 index 0000000000..ac2600c13c --- /dev/null +++ b/src/Perspex.Base/Data/ValidationStatus.cs @@ -0,0 +1,30 @@ +// Copyright (c) The Perspex Project. All rights reserved. +// Licensed under the MIT license. See licence.md file in the project root for full license information. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Perspex.Data +{ + /// + /// Contains information on if the current object passed validation. + /// Subclasses of this class contain additional information depending on the method of validation checking. + /// + public abstract class ValidationStatus + { + /// + /// True when the data passes validation; otherwise, false. + /// + public abstract bool IsValid { get; } + + /// + /// Checks if this validation status came from a currently enabled method of validation checking. + /// + /// The enabled methods of validation checking. + /// True if enabled; otherwise, false. + public abstract bool Match(ValidationMethods enabledMethods); + } +} diff --git a/src/Perspex.Base/IPriorityValueOwner.cs b/src/Perspex.Base/IPriorityValueOwner.cs index aa79864794..1afc005bad 100644 --- a/src/Perspex.Base/IPriorityValueOwner.cs +++ b/src/Perspex.Base/IPriorityValueOwner.cs @@ -1,6 +1,8 @@ // Copyright (c) The Perspex Project. All rights reserved. // Licensed under the MIT license. See licence.md file in the project root for full license information. +using Perspex.Data; + namespace Perspex { /// @@ -15,5 +17,12 @@ namespace Perspex /// The old value. /// The new value. void Changed(PriorityValue sender, object oldValue, object newValue); + + /// + /// Called when the validation state of a changes. + /// + /// The source of the change. + /// The validation status. + void DataValidationChanged(PriorityValue sender, ValidationStatus status); } } diff --git a/src/Perspex.Base/Perspex.Base.csproj b/src/Perspex.Base/Perspex.Base.csproj index df240ee3b8..234af801ca 100644 --- a/src/Perspex.Base/Perspex.Base.csproj +++ b/src/Perspex.Base/Perspex.Base.csproj @@ -44,6 +44,8 @@ Properties\SharedAssemblyInfo.cs + + diff --git a/src/Perspex.Base/PerspexObject.cs b/src/Perspex.Base/PerspexObject.cs index 0f28f598d8..4cd6998b76 100644 --- a/src/Perspex.Base/PerspexObject.cs +++ b/src/Perspex.Base/PerspexObject.cs @@ -401,16 +401,22 @@ namespace Perspex GetDescription(source)); IDisposable subscription = null; + IDisposable validationSubcription = null; subscription = source + .Where(x => !(x is ValidationStatus)) .Select(x => CastOrDefault(x, property.PropertyType)) .Do(_ => { }, () => s_directBindings.Remove(subscription)) .Subscribe(x => DirectBindingSet(property, x)); + validationSubcription = source + .OfType() + .Subscribe(x => DataValidation(property, x)); s_directBindings.Add(subscription); return Disposable.Create(() => { + validationSubcription.Dispose(); subscription.Dispose(); s_directBindings.Remove(subscription); }); @@ -459,7 +465,7 @@ namespace Perspex { Contract.Requires(property != null); - return Bind((PerspexProperty)property, source.Select(x => (object)x), priority); + return Bind(property, source.Select(x => (object)x), priority); } /// @@ -505,6 +511,23 @@ namespace Perspex } } + /// + void IPriorityValueOwner.DataValidationChanged(PriorityValue sender, ValidationStatus status) + { + var property = sender.Property; + DataValidation(property, status); + } + + /// + /// Called when the validation state on a tracked property is changed. + /// + /// The property whose validation state changed. + /// The new validation state. + protected virtual void DataValidation(PerspexProperty property, ValidationStatus status) + { + + } + /// Delegate[] IPerspexObjectDebug.GetPropertyChangedSubscribers() { diff --git a/src/Perspex.Base/PriorityBindingEntry.cs b/src/Perspex.Base/PriorityBindingEntry.cs index be6c451a71..cc5cd66c2e 100644 --- a/src/Perspex.Base/PriorityBindingEntry.cs +++ b/src/Perspex.Base/PriorityBindingEntry.cs @@ -21,6 +21,7 @@ namespace Perspex /// /// The binding index. Later bindings should have higher indexes. /// + /// The validation settings for the binding. public PriorityBindingEntry(PriorityLevel owner, int index) { _owner = owner; @@ -99,7 +100,13 @@ namespace Perspex _owner.Error(this, bindingError); } - if (bindingError == null || bindingError.UseFallbackValue) + var validationStatus = value as ValidationStatus; + + if (validationStatus != null) + { + _owner.Validation(this, validationStatus); + } + else if (bindingError == null || bindingError.UseFallbackValue) { Value = bindingError == null ? value : bindingError.FallbackValue; _owner.Changed(this); diff --git a/src/Perspex.Base/PriorityLevel.cs b/src/Perspex.Base/PriorityLevel.cs index a3167f1aa3..2ab957f645 100644 --- a/src/Perspex.Base/PriorityLevel.cs +++ b/src/Perspex.Base/PriorityLevel.cs @@ -97,6 +97,7 @@ namespace Perspex /// Adds a binding. /// /// The binding to add. + /// Validation settings for the binding. /// A disposable used to remove the binding. public IDisposable Add(IObservable binding) { @@ -164,6 +165,17 @@ namespace Perspex _owner.LevelError(this, error); } + /// + /// Invoked when an entry in reports validation status. + /// + /// The entry that completed. + /// The validation status. + public void Validation(PriorityBindingEntry entry, ValidationStatus validationStatus) + { + _owner.LevelValidation(this, validationStatus); + } + + /// /// Activates the first binding that has a value. /// diff --git a/src/Perspex.Base/PriorityValue.cs b/src/Perspex.Base/PriorityValue.cs index 7a81466d17..c0e855654c 100644 --- a/src/Perspex.Base/PriorityValue.cs +++ b/src/Perspex.Base/PriorityValue.cs @@ -77,6 +77,7 @@ namespace Perspex /// /// The binding. /// The binding priority. + /// Validation settings for the binding. /// /// A disposable that will remove the binding. /// @@ -178,6 +179,16 @@ namespace Perspex } } + /// + /// Called whenever a priority level validation state changes. + /// + /// The priority level of the changed entry. + /// The validation status. + public void LevelValidation(PriorityLevel priorityLevel, ValidationStatus validationStatus) + { + _owner.DataValidationChanged(this, validationStatus); + } + /// /// Called when a priority level encounters an error. /// diff --git a/src/Perspex.Controls/Control.cs b/src/Perspex.Controls/Control.cs index 74899a336a..601975a19e 100644 --- a/src/Perspex.Controls/Control.cs +++ b/src/Perspex.Controls/Control.cs @@ -86,6 +86,12 @@ namespace Perspex.Controls public static readonly RoutedEvent RequestBringIntoViewEvent = RoutedEvent.Register("RequestBringIntoView", RoutingStrategies.Bubble); + /// + /// Defines the property. + /// + public static readonly DirectProperty ValidationStatusProperty = + PerspexProperty.RegisterDirect(nameof(ValidationStatus), c=> c.ValidationStatus); + private int _initCount; private string _name; private IControl _parent; @@ -108,6 +114,7 @@ namespace Perspex.Controls PseudoClass(IsEnabledCoreProperty, x => !x, ":disabled"); PseudoClass(IsFocusedProperty, ":focus"); PseudoClass(IsPointerOverProperty, ":pointerover"); + PseudoClass(ValidationStatusProperty, status => status != null && !status.IsValid, ":invalid"); } /// @@ -399,6 +406,30 @@ namespace Perspex.Controls /// protected IPseudoClasses PseudoClasses => Classes; + private ControlValidationStatus validationStatus = new ControlValidationStatus(); + + /// + /// The current validation status of the control. + /// + public ControlValidationStatus ValidationStatus + { + get + { + return validationStatus; + } + private set + { + SetAndRaise(ValidationStatusProperty, ref validationStatus, value); + } + } + + /// + protected override void DataValidation(PerspexProperty property, ValidationStatus status) + { + base.DataValidation(property, status); + ValidationStatus.UpdateValidationStatus(status); + } + /// /// Sets the control's logical parent. /// diff --git a/src/Perspex.Controls/ControlValidationStatus.cs b/src/Perspex.Controls/ControlValidationStatus.cs new file mode 100644 index 0000000000..aad65a1a0d --- /dev/null +++ b/src/Perspex.Controls/ControlValidationStatus.cs @@ -0,0 +1,27 @@ +using Perspex.Data; +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Perspex.Controls +{ + public class ControlValidationStatus : ValidationStatus, INotifyPropertyChanged + { + private Dictionary propertyValidation = new Dictionary(); + + public override bool IsValid => propertyValidation.Values.All(status => status.IsValid); + + public event PropertyChangedEventHandler PropertyChanged; + + public override bool Match(ValidationMethods enabledMethods) => true; + + public void UpdateValidationStatus(ValidationStatus status) + { + propertyValidation[status.GetType()] = status; + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs("")); + } + } +} diff --git a/src/Perspex.Controls/Perspex.Controls.csproj b/src/Perspex.Controls/Perspex.Controls.csproj index 96481a1959..1945a6ae4f 100644 --- a/src/Perspex.Controls/Perspex.Controls.csproj +++ b/src/Perspex.Controls/Perspex.Controls.csproj @@ -46,6 +46,7 @@ + diff --git a/tests/Perspex.Controls.UnitTests/Perspex.Controls.UnitTests.csproj b/tests/Perspex.Controls.UnitTests/Perspex.Controls.UnitTests.csproj index 932af42724..47635bf006 100644 --- a/tests/Perspex.Controls.UnitTests/Perspex.Controls.UnitTests.csproj +++ b/tests/Perspex.Controls.UnitTests/Perspex.Controls.UnitTests.csproj @@ -196,9 +196,6 @@ - - - diff --git a/tests/Perspex.Markup.UnitTests/Data/ExceptionValidatorTests.cs b/tests/Perspex.Markup.UnitTests/Data/ExceptionValidatorTests.cs new file mode 100644 index 0000000000..3725bf3934 --- /dev/null +++ b/tests/Perspex.Markup.UnitTests/Data/ExceptionValidatorTests.cs @@ -0,0 +1,99 @@ +// Copyright (c) The Perspex Project. All rights reserved. +// Licensed under the MIT license. See licence.md file in the project root for full license information. + +using Perspex.Data; +using Perspex.Markup.Data.Plugins; +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Linq; +using System.Runtime.CompilerServices; +using System.Text; +using System.Threading.Tasks; +using Xunit; + +namespace Perspex.Markup.UnitTests.Data +{ + public class ExceptionValidatorTests + { + public class Data : INotifyPropertyChanged + { + private int nonValidated; + + public int NonValidated + { + get { return nonValidated; } + set { nonValidated = value; NotifyPropertyChanged(); } + } + + private int mustBePositive; + + public int MustBePositive + { + get { return mustBePositive; } + set + { + if (value <= 0) + { + throw new ArgumentOutOfRangeException(nameof(value)); + } + mustBePositive = value; + } + } + + public event PropertyChangedEventHandler PropertyChanged; + + private void NotifyPropertyChanged([CallerMemberName] string propertyName = "") + { + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); + } + } + + [Fact] + public void Setting_Non_Validating_Triggers_Validation() + { + var inpcAccessorPlugin = new InpcPropertyAccessorPlugin(); + var validatorPlugin = new ExceptionValidationCheckerPlugin(); + var data = new Data(); + var accessor = inpcAccessorPlugin.Start(new WeakReference(data), nameof(data.NonValidated), _ => { }); + ValidationStatus status = null; + var validator = validatorPlugin.Start(new WeakReference(data), nameof(data.NonValidated), accessor, s => status = s); + + validator.SetValue(5, BindingPriority.LocalValue); + + Assert.NotNull(status); + } + + [Fact] + public void Setting_Validating_Property_To_Valid_Value_Returns_Successful_ValidationStatus() + { + var inpcAccessorPlugin = new InpcPropertyAccessorPlugin(); + var validatorPlugin = new ExceptionValidationCheckerPlugin(); + var data = new Data(); + var accessor = inpcAccessorPlugin.Start(new WeakReference(data), nameof(data.MustBePositive), _ => { }); + ValidationStatus status = null; + var validator = validatorPlugin.Start(new WeakReference(data), nameof(data.MustBePositive), accessor, s => status = s); + + validator.SetValue(5, BindingPriority.LocalValue); + + Assert.True(status.IsValid); + } + + + + [Fact] + public void Setting_Validating_Property_To_Invalid_Value_Returns_Failed_ValidationStatus() + { + var inpcAccessorPlugin = new InpcPropertyAccessorPlugin(); + var validatorPlugin = new ExceptionValidationCheckerPlugin(); + var data = new Data(); + var accessor = inpcAccessorPlugin.Start(new WeakReference(data), nameof(data.MustBePositive), _ => { }); + ValidationStatus status = null; + var validator = validatorPlugin.Start(new WeakReference(data), nameof(data.MustBePositive), accessor, s => status = s); + + validator.SetValue(-5, BindingPriority.LocalValue); + + Assert.False(status.IsValid); + } + } +} diff --git a/tests/Perspex.Markup.UnitTests/Data/ExpressionObserverTests_Property.cs b/tests/Perspex.Markup.UnitTests/Data/ExpressionObserverTests_Property.cs index 1a201dcd9c..72c4704dbb 100644 --- a/tests/Perspex.Markup.UnitTests/Data/ExpressionObserverTests_Property.cs +++ b/tests/Perspex.Markup.UnitTests/Data/ExpressionObserverTests_Property.cs @@ -300,15 +300,6 @@ namespace Perspex.Markup.UnitTests.Data Assert.False(target.SetValue("baz")); } - [Fact] - public void SetValue_Should_Throw_For_Wrong_Type() - { - var data = new Class1 { Foo = "foo" }; - var target = new ExpressionObserver(data, "Foo"); - - Assert.Throws(() => target.SetValue(1.2)); - } - [Fact] public async void Should_Handle_Null_Root() { diff --git a/tests/Perspex.Markup.UnitTests/Data/IndeiValidatorTests.cs b/tests/Perspex.Markup.UnitTests/Data/IndeiValidatorTests.cs new file mode 100644 index 0000000000..fd9ce418e0 --- /dev/null +++ b/tests/Perspex.Markup.UnitTests/Data/IndeiValidatorTests.cs @@ -0,0 +1,120 @@ +// Copyright (c) The Perspex Project. All rights reserved. +// Licensed under the MIT license. See licence.md file in the project root for full license information. + + +using Perspex.Data; +using Perspex.Markup.Data.Plugins; +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Linq; +using System.Runtime.CompilerServices; +using System.Text; +using System.Threading.Tasks; +using Xunit; +using System.Collections; + +namespace Perspex.Markup.UnitTests.Data +{ + public class IndeiValidatorTests + { + public class Data : INotifyPropertyChanged, INotifyDataErrorInfo + { + private int nonValidated; + + public int NonValidated + { + get { return nonValidated; } + set { nonValidated = value; NotifyPropertyChanged(); } + } + + private int mustBePositive; + + public int MustBePositive + { + get { return mustBePositive; } + set + { + mustBePositive = value; + NotifyErrorsChanged(); + } + } + + public bool HasErrors + { + get + { + return MustBePositive > 0; + } + } + + public event PropertyChangedEventHandler PropertyChanged; + public event EventHandler ErrorsChanged; + + private void NotifyPropertyChanged([CallerMemberName] string propertyName = "") + { + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); + } + + private void NotifyErrorsChanged([CallerMemberName] string propertyName = "") + { + ErrorsChanged?.Invoke(this, new DataErrorsChangedEventArgs(propertyName)); + } + + public IEnumerable GetErrors(string propertyName) + { + if (propertyName == nameof(MustBePositive) && MustBePositive <= 0) + { + yield return $"{nameof(MustBePositive)} must be positive"; + } + } + } + + [Fact] + public void Setting_Non_Validating_Does_Not_Trigger_Validation() + { + var inpcAccessorPlugin = new InpcPropertyAccessorPlugin(); + var validatorPlugin = new IndeiValidationCheckerPlugin(); + var data = new Data(); + var accessor = inpcAccessorPlugin.Start(new WeakReference(data), nameof(data.NonValidated), _ => { }); + ValidationStatus status = null; + var validator = validatorPlugin.Start(new WeakReference(data), nameof(data.NonValidated), accessor, s => status = s); + + validator.SetValue(5, BindingPriority.LocalValue); + + Assert.Null(status); + } + + [Fact] + public void Setting_Validating_Property_To_Valid_Value_Returns_Successful_ValidationStatus() + { + var inpcAccessorPlugin = new InpcPropertyAccessorPlugin(); + var validatorPlugin = new IndeiValidationCheckerPlugin(); + var data = new Data(); + var accessor = inpcAccessorPlugin.Start(new WeakReference(data), nameof(data.MustBePositive), _ => { }); + ValidationStatus status = null; + var validator = validatorPlugin.Start(new WeakReference(data), nameof(data.MustBePositive), accessor, s => status = s); + + validator.SetValue(5, BindingPriority.LocalValue); + + Assert.True(status.IsValid); + } + + + + [Fact] + public void Setting_Validating_Property_To_Invalid_Value_Returns_Failed_ValidationStatus() + { + var inpcAccessorPlugin = new InpcPropertyAccessorPlugin(); + var validatorPlugin = new IndeiValidationCheckerPlugin(); + var data = new Data(); + var accessor = inpcAccessorPlugin.Start(new WeakReference(data), nameof(data.MustBePositive), _ => { }); + ValidationStatus status = null; + var validator = validatorPlugin.Start(new WeakReference(data), nameof(data.MustBePositive), accessor, s => status = s); + + validator.SetValue(-5, BindingPriority.LocalValue); + + Assert.False(status.IsValid); + } + } +} diff --git a/tests/Perspex.Markup.UnitTests/Perspex.Markup.UnitTests.csproj b/tests/Perspex.Markup.UnitTests/Perspex.Markup.UnitTests.csproj index f32a584d1d..cd1b9f4aa9 100644 --- a/tests/Perspex.Markup.UnitTests/Perspex.Markup.UnitTests.csproj +++ b/tests/Perspex.Markup.UnitTests/Perspex.Markup.UnitTests.csproj @@ -85,6 +85,7 @@ + @@ -97,6 +98,7 @@ + diff --git a/tests/Perspex.Markup.Xaml.UnitTests/Data/BindingTests_Validation.cs b/tests/Perspex.Markup.Xaml.UnitTests/Data/BindingTests_Validation.cs new file mode 100644 index 0000000000..551f930406 --- /dev/null +++ b/tests/Perspex.Markup.Xaml.UnitTests/Data/BindingTests_Validation.cs @@ -0,0 +1,111 @@ +using Perspex.Controls; +using Perspex.Markup.Xaml.Data; +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Linq; +using System.Runtime.CompilerServices; +using System.Text; +using System.Threading.Tasks; +using Xunit; + +namespace Perspex.Markup.Xaml.UnitTests.Data +{ + public class BindingTests_Validation + { + public class Data : INotifyPropertyChanged + { + private string mustbeNonEmpty; + + public string MustBeNonEmpty + { + get { return mustbeNonEmpty; } + set + { + if (string.IsNullOrEmpty(value)) + { + throw new ArgumentException(nameof(value)); + } + mustbeNonEmpty = value; + } + } + + public event PropertyChangedEventHandler PropertyChanged; + + private void NotifyPropertyChanged([CallerMemberName] string propertyName = "") + { + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); + } + } + + [Fact] + public void Disabled_Validation_Should_Not_Trigger_Validation_Change_Direct() + { + var source = new Data { MustBeNonEmpty = "Test" }; + var target = new TextBlock { DataContext = source }; + var binding = new Binding + { + Path = nameof(source.MustBeNonEmpty), + Mode = Perspex.Data.BindingMode.TwoWay, + ValidationMethods = Perspex.Data.ValidationMethods.None + }; + target.Bind(TextBlock.TextProperty, binding); + + target.Text = ""; + + Assert.True(target.ValidationStatus.IsValid); + } + + [Fact] + public void Enabled_Validation_Should_Trigger_Validation_Change_Direct() + { + var source = new Data { MustBeNonEmpty = "Test" }; + var target = new TextBlock { DataContext = source }; + var binding = new Binding + { + Path = nameof(source.MustBeNonEmpty), + Mode = Perspex.Data.BindingMode.TwoWay, + ValidationMethods = Perspex.Data.ValidationMethods.All + }; + target.Bind(TextBlock.TextProperty, binding); + + target.Text = ""; + Assert.False(target.ValidationStatus.IsValid); + } + + [Fact] + public void Disabled_Validation_Should_Not_Trigger_Validation_Change_Styled() + { + var source = new Data { MustBeNonEmpty = "Test" }; + var target = new TextBlock { DataContext = source }; + var binding = new Binding + { + Path = nameof(source.MustBeNonEmpty), + Mode = Perspex.Data.BindingMode.TwoWay, + ValidationMethods = Perspex.Data.ValidationMethods.None + }; + target.Bind(Control.TagProperty, binding); + + target.Tag = ""; + + Assert.True(target.ValidationStatus.IsValid); + } + + [Fact] + public void Enabled_Validation_Should_Trigger_Validation_Change_Styled() + { + var source = new Data { MustBeNonEmpty = "Test" }; + var target = new TextBlock { DataContext = source }; + var binding = new Binding + { + Path = nameof(source.MustBeNonEmpty), + Mode = Perspex.Data.BindingMode.TwoWay, + ValidationMethods = Perspex.Data.ValidationMethods.All + }; + target.Bind(Control.TagProperty, binding); + + target.Tag = ""; + Assert.False(target.ValidationStatus.IsValid); + } + } +} diff --git a/tests/Perspex.Markup.Xaml.UnitTests/Perspex.Markup.Xaml.UnitTests.csproj b/tests/Perspex.Markup.Xaml.UnitTests/Perspex.Markup.Xaml.UnitTests.csproj index 243c0c53f4..cf3e856d75 100644 --- a/tests/Perspex.Markup.Xaml.UnitTests/Perspex.Markup.Xaml.UnitTests.csproj +++ b/tests/Perspex.Markup.Xaml.UnitTests/Perspex.Markup.Xaml.UnitTests.csproj @@ -94,6 +94,7 @@ + diff --git a/tests/Perspex.Styling.UnitTests/SelectorTests_Descendent.cs b/tests/Perspex.Styling.UnitTests/SelectorTests_Descendent.cs index 286a5ec269..62c460403b 100644 --- a/tests/Perspex.Styling.UnitTests/SelectorTests_Descendent.cs +++ b/tests/Perspex.Styling.UnitTests/SelectorTests_Descendent.cs @@ -160,7 +160,7 @@ namespace Perspex.Styling.UnitTests throw new NotImplementedException(); } - public IDisposable Bind(PerspexProperty property, IObservable source, BindingPriority priority) + public IDisposable Bind(PerspexProperty property, IObservable source, BindingPriority priority = BindingPriority.LocalValue) { throw new NotImplementedException(); } diff --git a/tests/Perspex.Styling.UnitTests/TestControlBase.cs b/tests/Perspex.Styling.UnitTests/TestControlBase.cs index d5bd85f1dd..ba9646d5f4 100644 --- a/tests/Perspex.Styling.UnitTests/TestControlBase.cs +++ b/tests/Perspex.Styling.UnitTests/TestControlBase.cs @@ -62,7 +62,7 @@ namespace Perspex.Styling.UnitTests throw new NotImplementedException(); } - public IDisposable Bind(PerspexProperty property, IObservable source, BindingPriority priority) + public IDisposable Bind(PerspexProperty property, IObservable source, BindingPriority priority = BindingPriority.LocalValue) { throw new NotImplementedException(); } diff --git a/tests/Perspex.Styling.UnitTests/TestTemplatedControl.cs b/tests/Perspex.Styling.UnitTests/TestTemplatedControl.cs index 3e16e8a18c..b67cfd79a4 100644 --- a/tests/Perspex.Styling.UnitTests/TestTemplatedControl.cs +++ b/tests/Perspex.Styling.UnitTests/TestTemplatedControl.cs @@ -57,7 +57,7 @@ namespace Perspex.Styling.UnitTests throw new NotImplementedException(); } - public IDisposable Bind(PerspexProperty property, IObservable source, BindingPriority priority) + public IDisposable Bind(PerspexProperty property, IObservable source, BindingPriority priority = BindingPriority.LocalValue) { throw new NotImplementedException(); }