diff --git a/Microsoft.Toolkit.Mvvm/ComponentModel/ObservableObject.cs b/Microsoft.Toolkit.Mvvm/ComponentModel/ObservableObject.cs index 61f5c41f65e..e1f2f36df6f 100644 --- a/Microsoft.Toolkit.Mvvm/ComponentModel/ObservableObject.cs +++ b/Microsoft.Toolkit.Mvvm/ComponentModel/ObservableObject.cs @@ -10,8 +10,6 @@ using System; using System.Collections.Generic; using System.ComponentModel; -using System.Linq.Expressions; -using System.Reflection; using System.Runtime.CompilerServices; using System.Threading.Tasks; @@ -123,6 +121,11 @@ protected bool SetProperty(ref T field, T newValue, IEqualityComparer comp /// This overload is much less efficient than and it /// should only be used when the former is not viable (eg. when the target property being /// updated does not directly expose a backing field that can be passed by reference). + /// For performance reasons, it is recommended to use a stateful callback if possible through + /// the whenever possible + /// instead of this overload, as that will allow the C# compiler to cache the input callback and + /// reduce the memory allocations. More info on that overload are available in the related XML + /// docs. This overload is here for completeness and in cases where that is not applicable. /// /// The type of the property that changed. /// The current property value. @@ -136,7 +139,19 @@ protected bool SetProperty(ref T field, T newValue, IEqualityComparer comp /// protected bool SetProperty(T oldValue, T newValue, Action callback, [CallerMemberName] string? propertyName = null) { - return SetProperty(oldValue, newValue, EqualityComparer.Default, callback, propertyName); + // We avoid calling the overload again to ensure the comparison is inlined + if (EqualityComparer.Default.Equals(oldValue, newValue)) + { + return false; + } + + OnPropertyChanging(propertyName); + + callback(newValue); + + OnPropertyChanged(propertyName); + + return true; } /// @@ -197,32 +212,43 @@ protected bool SetProperty(T oldValue, T newValue, IEqualityComparer compa /// public string Name /// { /// get => Model.Name; - /// set => Set(() => Model.Name, value); + /// set => Set(Model.Name, value, Model, (model, name) => model.Name = name); /// } /// } /// /// This way we can then use the wrapping object in our application, and all those "proxy" properties will /// also raise notifications when changed. Note that this method is not meant to be a replacement for - /// , which offers better performance and less memory usage. Only use this - /// overload when relaying properties to a model that doesn't support notifications, and only if you can't - /// implement notifications to that model directly (eg. by having it inherit from ). + /// , and it should only be used when relaying properties to a model that + /// doesn't support notifications, and only if you can't implement notifications to that model directly (eg. by having + /// it inherit from ). The syntax relies on passing the target model and a stateless callback + /// to allow the C# compiler to cache the function, which results in much better performance and no memory usage. /// - /// The type of property to set. - /// An returning the property to update. + /// The type of model whose property (or field) to set. + /// The type of property (or field) to set. + /// The current property value. /// The property's value after the change occurred. + /// The model + /// The callback to invoke to set the target property value, if a change has occurred. /// (optional) The name of the property that changed. /// if the property was changed, otherwise. /// - /// The and events are not raised - /// if the current and new value for the target property are the same. Additionally, - /// must return a property from a model that is stored as another property in the current instance. - /// This method only supports one level of indirection: can only - /// be used to access properties of a model that is directly stored as a property of the current instance. - /// Additionally, this method can only be used if the wrapped item is a reference type. + /// The and events are not + /// raised if the current and new value for the target property are the same. /// - protected bool SetProperty(Expression> propertyExpression, T newValue, [CallerMemberName] string? propertyName = null) + protected bool SetProperty(T oldValue, T newValue, TModel model, Action callback, [CallerMemberName] string? propertyName = null) { - return SetProperty(propertyExpression, newValue, EqualityComparer.Default, out _, propertyName); + if (EqualityComparer.Default.Equals(oldValue, newValue)) + { + return false; + } + + OnPropertyChanging(propertyName); + + callback(model, newValue); + + OnPropertyChanged(propertyName); + + return true; } /// @@ -230,56 +256,19 @@ protected bool SetProperty(Expression> propertyExpression, T newValue /// raises the event, updates the property and then raises the /// event. The behavior mirrors that of , /// with the difference being that this method is used to relay properties from a wrapped model in the - /// current instance. See additional notes about this overload in . - /// - /// The type of property to set. - /// An returning the property to update. - /// The property's value after the change occurred. - /// The instance to use to compare the input values. - /// (optional) The name of the property that changed. - /// if the property was changed, otherwise. - protected bool SetProperty(Expression> propertyExpression, T newValue, IEqualityComparer comparer, [CallerMemberName] string? propertyName = null) - { - return SetProperty(propertyExpression, newValue, comparer, out _, propertyName); - } - - /// - /// Implements the shared logic for + /// current instance. See additional notes about this overload in . /// - /// The type of property to set. - /// An returning the property to update. + /// The type of model whose property (or field) to set. + /// The type of property (or field) to set. + /// The current property value. /// The property's value after the change occurred. /// The instance to use to compare the input values. - /// The resulting initial value for the target property. + /// The model + /// The callback to invoke to set the target property value, if a change has occurred. /// (optional) The name of the property that changed. /// if the property was changed, otherwise. - private protected bool SetProperty(Expression> propertyExpression, T newValue, IEqualityComparer comparer, out T oldValue, [CallerMemberName] string? propertyName = null) + protected bool SetProperty(T oldValue, T newValue, IEqualityComparer comparer, TModel model, Action callback, [CallerMemberName] string? propertyName = null) { - PropertyInfo? parentPropertyInfo; - FieldInfo? parentFieldInfo = null; - - // Get the target property info - if (!(propertyExpression.Body is MemberExpression targetExpression && - targetExpression.Member is PropertyInfo targetPropertyInfo && - targetExpression.Expression is MemberExpression parentExpression && - (!((parentPropertyInfo = parentExpression.Member as PropertyInfo) is null) || - !((parentFieldInfo = parentExpression.Member as FieldInfo) is null)) && - parentExpression.Expression is ConstantExpression instanceExpression && - instanceExpression.Value is object instance)) - { - ThrowArgumentExceptionForInvalidPropertyExpression(); - - // This is never executed, as the method above always throws - oldValue = default!; - - return false; - } - - object parent = parentPropertyInfo is null - ? parentFieldInfo!.GetValue(instance) - : parentPropertyInfo.GetValue(instance); - oldValue = (T)targetPropertyInfo.GetValue(parent); - if (comparer.Equals(oldValue, newValue)) { return false; @@ -287,7 +276,7 @@ parentExpression.Expression is ConstantExpression instanceExpression && OnPropertyChanging(propertyName); - targetPropertyInfo.SetValue(parent, newValue); + callback(model, newValue); OnPropertyChanged(propertyName); @@ -298,38 +287,36 @@ parentExpression.Expression is ConstantExpression instanceExpression && /// Compares the current and new values for a given field (which should be the backing /// field for a property). If the value has changed, raises the /// event, updates the field and then raises the event. - /// The behavior mirrors that of , with the difference being that this method - /// will also monitor the new value of the property (a generic ) and will also + /// The behavior mirrors that of , with the difference being that + /// this method will also monitor the new value of the property (a generic ) and will also /// raise the again for the target property when it completes. /// This can be used to update bindings observing that or any of its properties. + /// This method and its overload specifically rely on the type, which needs + /// to be used in the backing field for the target property. The field doesn't need to be + /// initialized, as this method will take care of doing that automatically. The + /// type also includes an implicit operator, so it can be assigned to any instance directly. /// Here is a sample property declaration using this method: /// - /// private Task myTask; + /// private TaskNotifier myTask; /// /// public Task MyTask /// { /// get => myTask; - /// private set => SetAndNotifyOnCompletion(ref myTask, () => myTask, value); + /// private set => SetAndNotifyOnCompletion(ref myTask, value); /// } /// /// - /// The type of to set and monitor. - /// The field storing the property's value. - /// - /// An returning the field to update. This is needed to be - /// able to raise the to notify the completion of the input task. - /// + /// The field notifier to modify. /// The property's value after the change occurred. /// (optional) The name of the property that changed. /// if the property was changed, otherwise. /// /// The and events are not raised if the current /// and new value for the target property are the same. The return value being only - /// indicates that the new value being assigned to is different than the previous one, - /// and it does not mean the new instance passed as argument is in any particular state. + /// indicates that the new value being assigned to is different than the previous one, + /// and it does not mean the new instance passed as argument is in any particular state. /// - protected bool SetPropertyAndNotifyOnCompletion(ref TTask? field, Expression> fieldExpression, TTask? newValue, [CallerMemberName] string? propertyName = null) - where TTask : Task + protected bool SetPropertyAndNotifyOnCompletion(ref TaskNotifier? taskNotifier, Task? newValue, [CallerMemberName] string? propertyName = null) { // We invoke the overload with a callback here to avoid code duplication, and simply pass an empty callback. // The lambda expression here is transformed by the C# compiler into an empty closure class with a @@ -337,21 +324,80 @@ protected bool SetPropertyAndNotifyOnCompletion(ref TTask? field, Express // instance. This will result in no further allocations after the first time this method is called for a given // generic type. We only pay the cost of the virtual call to the delegate, but this is not performance critical // code and that overhead would still be much lower than the rest of the method anyway, so that's fine. - return SetPropertyAndNotifyOnCompletion(ref field, fieldExpression, newValue, _ => { }, propertyName); + return SetPropertyAndNotifyOnCompletion(taskNotifier ??= new TaskNotifier(), newValue, _ => { }, propertyName); } /// /// Compares the current and new values for a given field (which should be the backing /// field for a property). If the value has changed, raises the /// event, updates the field and then raises the event. - /// This method is just like , + /// This method is just like , /// with the difference being an extra parameter with a callback being invoked /// either immediately, if the new task has already completed or is , or upon completion. /// - /// The type of to set and monitor. - /// The field storing the property's value. - /// - /// An returning the field to update. + /// The field notifier to modify. + /// The property's value after the change occurred. + /// A callback to invoke to update the property value. + /// (optional) The name of the property that changed. + /// if the property was changed, otherwise. + /// + /// The and events are not raised + /// if the current and new value for the target property are the same. + /// + protected bool SetPropertyAndNotifyOnCompletion(ref TaskNotifier? taskNotifier, Task? newValue, Action callback, [CallerMemberName] string? propertyName = null) + { + return SetPropertyAndNotifyOnCompletion(taskNotifier ??= new TaskNotifier(), newValue, callback, propertyName); + } + + /// + /// Compares the current and new values for a given field (which should be the backing + /// field for a property). If the value has changed, raises the + /// event, updates the field and then raises the event. + /// The behavior mirrors that of , with the difference being that + /// this method will also monitor the new value of the property (a generic ) and will also + /// raise the again for the target property when it completes. + /// This can be used to update bindings observing that or any of its properties. + /// This method and its overload specifically rely on the type, which needs + /// to be used in the backing field for the target property. The field doesn't need to be + /// initialized, as this method will take care of doing that automatically. The + /// type also includes an implicit operator, so it can be assigned to any instance directly. + /// Here is a sample property declaration using this method: + /// + /// private TaskNotifier<int> myTask; + /// + /// public Task<int> MyTask + /// { + /// get => myTask; + /// private set => SetAndNotifyOnCompletion(ref myTask, value); + /// } + /// + /// + /// The type of result for the to set and monitor. + /// The field notifier to modify. + /// The property's value after the change occurred. + /// (optional) The name of the property that changed. + /// if the property was changed, otherwise. + /// + /// The and events are not raised if the current + /// and new value for the target property are the same. The return value being only + /// indicates that the new value being assigned to is different than the previous one, + /// and it does not mean the new instance passed as argument is in any particular state. + /// + protected bool SetPropertyAndNotifyOnCompletion(ref TaskNotifier? taskNotifier, Task? newValue, [CallerMemberName] string? propertyName = null) + { + return SetPropertyAndNotifyOnCompletion(taskNotifier ??= new TaskNotifier(), newValue, _ => { }, propertyName); + } + + /// + /// Compares the current and new values for a given field (which should be the backing + /// field for a property). If the value has changed, raises the + /// event, updates the field and then raises the event. + /// This method is just like , + /// with the difference being an extra parameter with a callback being invoked + /// either immediately, if the new task has already completed or is , or upon completion. + /// + /// The type of result for the to set and monitor. + /// The field notifier to modify. /// The property's value after the change occurred. /// A callback to invoke to update the property value. /// (optional) The name of the property that changed. @@ -360,10 +406,24 @@ protected bool SetPropertyAndNotifyOnCompletion(ref TTask? field, Express /// The and events are not raised /// if the current and new value for the target property are the same. /// - protected bool SetPropertyAndNotifyOnCompletion(ref TTask? field, Expression> fieldExpression, TTask? newValue, Action callback, [CallerMemberName] string? propertyName = null) + protected bool SetPropertyAndNotifyOnCompletion(ref TaskNotifier? taskNotifier, Task? newValue, Action?> callback, [CallerMemberName] string? propertyName = null) + { + return SetPropertyAndNotifyOnCompletion(taskNotifier ??= new TaskNotifier(), newValue, callback, propertyName); + } + + /// + /// Implements the notification logic for the related methods. + /// + /// The type of to set and monitor. + /// The field notifier. + /// The property's value after the change occurred. + /// A callback to invoke to update the property value. + /// (optional) The name of the property that changed. + /// if the property was changed, otherwise. + private bool SetPropertyAndNotifyOnCompletion(ITaskNotifier taskNotifier, TTask? newValue, Action callback, [CallerMemberName] string? propertyName = null) where TTask : Task { - if (ReferenceEquals(field, newValue)) + if (ReferenceEquals(taskNotifier.Task, newValue)) { return false; } @@ -376,7 +436,7 @@ protected bool SetPropertyAndNotifyOnCompletion(ref TTask? field, Express OnPropertyChanging(propertyName); - field = newValue; + taskNotifier.Task = newValue; OnPropertyChanged(propertyName); @@ -393,16 +453,6 @@ protected bool SetPropertyAndNotifyOnCompletion(ref TTask? field, Express return true; } - // Get the target field to set. This is needed because we can't - // capture the ref field in a closure (for the async method). - if (!((fieldExpression.Body as MemberExpression)?.Member is FieldInfo fieldInfo)) - { - ThrowArgumentExceptionForInvalidFieldExpression(); - - // This is never executed, as the method above always throws - return false; - } - // We use a local async function here so that the main method can // remain synchronous and return a value that can be immediately // used by the caller. This mirrors Set(ref T, T, string). @@ -422,10 +472,8 @@ async void MonitorTask() { } - TTask? currentTask = (TTask?)fieldInfo.GetValue(this); - // Only notify if the property hasn't changed - if (ReferenceEquals(newValue, currentTask)) + if (ReferenceEquals(taskNotifier.Task, newValue)) { OnPropertyChanged(propertyName); } @@ -439,19 +487,79 @@ async void MonitorTask() } /// - /// Throws an when a given is invalid for a property. + /// An interface for task notifiers of a specified type. /// - private static void ThrowArgumentExceptionForInvalidPropertyExpression() + /// The type of value to store. + private interface ITaskNotifier + where TTask : Task + { + /// + /// Gets or sets the wrapped value. + /// + TTask? Task { get; set; } + } + + /// + /// A wrapping class that can hold a value. + /// + protected sealed class TaskNotifier : ITaskNotifier { - throw new ArgumentException("The given expression must be in the form () => MyModel.MyProperty"); + /// + /// Initializes a new instance of the class. + /// + internal TaskNotifier() + { + } + + private Task? task; + + /// + Task? ITaskNotifier.Task + { + get => this.task; + set => this.task = value; + } + + /// + /// Unwraps the value stored in the current instance. + /// + /// The input instance. + public static implicit operator Task?(TaskNotifier? notifier) + { + return notifier?.task; + } } /// - /// Throws an when a given is invalid for a property field. + /// A wrapping class that can hold a value. /// - private static void ThrowArgumentExceptionForInvalidFieldExpression() + /// The type of value for the wrapped instance. + protected sealed class TaskNotifier : ITaskNotifier> { - throw new ArgumentException("The given expression must be in the form () => field"); + /// + /// Initializes a new instance of the class. + /// + internal TaskNotifier() + { + } + + private Task? task; + + /// + Task? ITaskNotifier>.Task + { + get => this.task; + set => this.task = value; + } + + /// + /// Unwraps the value stored in the current instance. + /// + /// The input instance. + public static implicit operator Task?(TaskNotifier? notifier) + { + return notifier?.task; + } } } } \ No newline at end of file diff --git a/Microsoft.Toolkit.Mvvm/ComponentModel/ObservableRecipient.cs b/Microsoft.Toolkit.Mvvm/ComponentModel/ObservableRecipient.cs index ca847383b87..80249075d38 100644 --- a/Microsoft.Toolkit.Mvvm/ComponentModel/ObservableRecipient.cs +++ b/Microsoft.Toolkit.Mvvm/ComponentModel/ObservableRecipient.cs @@ -9,7 +9,6 @@ using System; using System.Collections.Generic; -using System.Linq.Expressions; using System.Runtime.CompilerServices; using Microsoft.Toolkit.Mvvm.Messaging; using Microsoft.Toolkit.Mvvm.Messaging.Messages; @@ -204,7 +203,14 @@ protected bool SetProperty(ref T field, T newValue, IEqualityComparer comp /// protected bool SetProperty(T oldValue, T newValue, Action callback, bool broadcast, [CallerMemberName] string? propertyName = null) { - return SetProperty(oldValue, newValue, EqualityComparer.Default, callback, broadcast, propertyName); + bool propertyChanged = SetProperty(oldValue, newValue, callback, propertyName); + + if (propertyChanged && broadcast) + { + Broadcast(oldValue, newValue, propertyName); + } + + return propertyChanged; } /// @@ -237,40 +243,53 @@ protected bool SetProperty(T oldValue, T newValue, IEqualityComparer compa /// Compares the current and new values for a given nested property. If the value has changed, /// raises the event, updates the property and then raises the /// event. The behavior mirrors that of - /// , with the difference being that this + /// , with the difference being that this /// method is used to relay properties from a wrapped model in the current instance. For more info, see the docs for - /// . + /// . /// - /// The type of property to set. - /// An returning the property to update. + /// The type of model whose property (or field) to set. + /// The type of property (or field) to set. + /// The current property value. /// The property's value after the change occurred. + /// The model + /// The callback to invoke to set the target property value, if a change has occurred. /// If , will also be invoked. /// (optional) The name of the property that changed. /// if the property was changed, otherwise. - protected bool SetProperty(Expression> propertyExpression, T newValue, bool broadcast, [CallerMemberName] string? propertyName = null) + protected bool SetProperty(T oldValue, T newValue, TModel model, Action callback, bool broadcast, [CallerMemberName] string? propertyName = null) { - return SetProperty(propertyExpression, newValue, EqualityComparer.Default, broadcast, propertyName); + bool propertyChanged = SetProperty(oldValue, newValue, model, callback, propertyName); + + if (propertyChanged && broadcast) + { + Broadcast(oldValue, newValue, propertyName); + } + + return propertyChanged; } /// /// Compares the current and new values for a given nested property. If the value has changed, /// raises the event, updates the property and then raises the /// event. The behavior mirrors that of - /// , + /// , /// with the difference being that this method is used to relay properties from a wrapped model in the /// current instance. For more info, see the docs for - /// . + /// . /// - /// The type of property to set. - /// An returning the property to update. + /// The type of model whose property (or field) to set. + /// The type of property (or field) to set. + /// The current property value. /// The property's value after the change occurred. /// The instance to use to compare the input values. + /// The model + /// The callback to invoke to set the target property value, if a change has occurred. /// If , will also be invoked. /// (optional) The name of the property that changed. /// if the property was changed, otherwise. - protected bool SetProperty(Expression> propertyExpression, T newValue, IEqualityComparer comparer, bool broadcast, [CallerMemberName] string? propertyName = null) + protected bool SetProperty(T oldValue, T newValue, IEqualityComparer comparer, TModel model, Action callback, bool broadcast, [CallerMemberName] string? propertyName = null) { - bool propertyChanged = SetProperty(propertyExpression, newValue, comparer, out T oldValue, propertyName); + bool propertyChanged = SetProperty(oldValue, newValue, comparer, model, callback, propertyName); if (propertyChanged && broadcast) { diff --git a/Microsoft.Toolkit.Mvvm/ComponentModel/ObservableValidator.cs b/Microsoft.Toolkit.Mvvm/ComponentModel/ObservableValidator.cs new file mode 100644 index 00000000000..a1c20d82817 --- /dev/null +++ b/Microsoft.Toolkit.Mvvm/ComponentModel/ObservableValidator.cs @@ -0,0 +1,305 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections; +using System.Collections.Generic; +using System.ComponentModel; +using System.ComponentModel.DataAnnotations; +using System.Diagnostics.Contracts; +using System.Linq; +using System.Runtime.CompilerServices; + +namespace Microsoft.Toolkit.Mvvm.ComponentModel +{ + /// + /// A base class for objects implementing the interface. This class + /// also inherits from , so it can be used for observable items too. + /// + public abstract class ObservableValidator : ObservableObject, INotifyDataErrorInfo + { + /// + /// The instance used to store previous validation results. + /// + private readonly Dictionary> errors = new Dictionary>(); + + /// + public event EventHandler? ErrorsChanged; + + /// + public bool HasErrors + { + get + { + // This uses the value enumerator for Dictionary.ValueCollection, so it doesn't + // allocate. Accessing this property is O(n), but we can stop as soon as we find at least one + // error in the whole entity, and doing this saves 8 bytes in the object size (no fields needed). + foreach (var value in this.errors.Values) + { + if (value.Count > 0) + { + return true; + } + } + + return false; + } + } + + /// + /// Compares the current and new values for a given property. If the value has changed, + /// raises the event, updates the property with + /// the new value, then raises the event. + /// + /// The type of the property that changed. + /// The field storing the property's value. + /// The property's value after the change occurred. + /// If , will also be validated. + /// (optional) The name of the property that changed. + /// if the property was changed, otherwise. + /// + /// This method is just like , just with the addition + /// of the parameter. If that is set to , the new value will be + /// validated and will be raised if needed. Following the behavior of the base method, + /// the and events + /// are not raised if the current and new value for the target property are the same. + /// + protected bool SetProperty(ref T field, T newValue, bool validate, [CallerMemberName] string? propertyName = null) + { + if (validate) + { + ValidateProperty(newValue, propertyName); + } + + return SetProperty(ref field, newValue, propertyName); + } + + /// + /// Compares the current and new values for a given property. If the value has changed, + /// raises the event, updates the property with + /// the new value, then raises the event. + /// See additional notes about this overload in . + /// + /// The type of the property that changed. + /// The field storing the property's value. + /// The property's value after the change occurred. + /// The instance to use to compare the input values. + /// If , will also be validated. + /// (optional) The name of the property that changed. + /// if the property was changed, otherwise. + protected bool SetProperty(ref T field, T newValue, IEqualityComparer comparer, bool validate, [CallerMemberName] string? propertyName = null) + { + if (validate) + { + ValidateProperty(newValue, propertyName); + } + + return SetProperty(ref field, newValue, comparer, propertyName); + } + + /// + /// Compares the current and new values for a given property. If the value has changed, + /// raises the event, updates the property with + /// the new value, then raises the event. Similarly to + /// the method, this overload should only be + /// used when can't be used directly. + /// + /// The type of the property that changed. + /// The current property value. + /// The property's value after the change occurred. + /// A callback to invoke to update the property value. + /// If , will also be validated. + /// (optional) The name of the property that changed. + /// if the property was changed, otherwise. + /// + /// This method is just like , just with the addition + /// of the parameter. As such, following the behavior of the base method, + /// the and events + /// are not raised if the current and new value for the target property are the same. + /// + protected bool SetProperty(T oldValue, T newValue, Action callback, bool validate, [CallerMemberName] string? propertyName = null) + { + if (validate) + { + ValidateProperty(newValue, propertyName); + } + + return SetProperty(oldValue, newValue, callback, propertyName); + } + + /// + /// Compares the current and new values for a given property. If the value has changed, + /// raises the event, updates the property with + /// the new value, then raises the event. + /// See additional notes about this overload in . + /// + /// The type of the property that changed. + /// The current property value. + /// The property's value after the change occurred. + /// The instance to use to compare the input values. + /// A callback to invoke to update the property value. + /// If , will also be validated. + /// (optional) The name of the property that changed. + /// if the property was changed, otherwise. + protected bool SetProperty(T oldValue, T newValue, IEqualityComparer comparer, Action callback, bool validate, [CallerMemberName] string? propertyName = null) + { + if (validate) + { + ValidateProperty(newValue, propertyName); + } + + return SetProperty(oldValue, newValue, comparer, callback, propertyName); + } + + /// + /// Compares the current and new values for a given nested property. If the value has changed, + /// raises the event, updates the property and then raises the + /// event. The behavior mirrors that of + /// , with the difference being that this + /// method is used to relay properties from a wrapped model in the current instance. For more info, see the docs for + /// . + /// + /// The type of model whose property (or field) to set. + /// The type of property (or field) to set. + /// The current property value. + /// The property's value after the change occurred. + /// The model + /// The callback to invoke to set the target property value, if a change has occurred. + /// If , will also be validated. + /// (optional) The name of the property that changed. + /// if the property was changed, otherwise. + protected bool SetProperty(T oldValue, T newValue, TModel model, Action callback, bool validate, [CallerMemberName] string? propertyName = null) + { + if (validate) + { + ValidateProperty(newValue, propertyName); + } + + return SetProperty(oldValue, newValue, model, callback, propertyName); + } + + /// + /// Compares the current and new values for a given nested property. If the value has changed, + /// raises the event, updates the property and then raises the + /// event. The behavior mirrors that of + /// , + /// with the difference being that this method is used to relay properties from a wrapped model in the + /// current instance. For more info, see the docs for + /// . + /// + /// The type of model whose property (or field) to set. + /// The type of property (or field) to set. + /// The current property value. + /// The property's value after the change occurred. + /// The instance to use to compare the input values. + /// The model + /// The callback to invoke to set the target property value, if a change has occurred. + /// If , will also be validated. + /// (optional) The name of the property that changed. + /// if the property was changed, otherwise. + protected bool SetProperty(T oldValue, T newValue, IEqualityComparer comparer, TModel model, Action callback, bool validate, [CallerMemberName] string? propertyName = null) + { + if (validate) + { + ValidateProperty(newValue, propertyName); + } + + return SetProperty(oldValue, newValue, comparer, model, callback, propertyName); + } + + /// + [Pure] + public IEnumerable GetErrors(string? propertyName) + { + // Entity-level errors when the target property is null or empty + if (string.IsNullOrEmpty(propertyName)) + { + return this.GetAllErrors(); + } + + // Property-level errors, if any + if (this.errors.TryGetValue(propertyName!, out List errors)) + { + return errors; + } + + // The INotifyDataErrorInfo.GetErrors method doesn't specify exactly what to + // return when the input property name is invalid, but given that the return + // type is marked as a non-nullable reference type, here we're returning an + // empty array to respect the contract. This also matches the behavior of + // this method whenever errors for a valid properties are retrieved. + return Array.Empty(); + } + + /// + /// Implements the logic for entity-level errors gathering for . + /// + /// An instance with all the errors in . + [Pure] + [MethodImpl(MethodImplOptions.NoInlining)] + private IEnumerable GetAllErrors() + { + return this.errors.Values.SelectMany(errors => errors); + } + + /// + /// Validates a property with a specified name and a given input value. + /// + /// The value to test for the specified property. + /// The name of the property to validate. + /// Thrown when is . + private void ValidateProperty(object? value, string? propertyName) + { + if (propertyName is null) + { + ThrowArgumentNullExceptionForNullPropertyName(); + } + + // Check if the property had already been previously validated, and if so retrieve + // the reusable list of validation errors from the errors dictionary. This list is + // used to add new validation errors below, if any are produced by the validator. + // If the property isn't present in the dictionary, add it now to avoid allocations. + if (!this.errors.TryGetValue(propertyName!, out List? propertyErrors)) + { + propertyErrors = new List(); + + this.errors.Add(propertyName!, propertyErrors); + } + + bool errorsChanged = false; + + // Clear the errors for the specified property, if any + if (propertyErrors.Count > 0) + { + propertyErrors.Clear(); + + errorsChanged = true; + } + + // Validate the property, by adding new errors to the existing list + bool isValid = Validator.TryValidateProperty( + value, + new ValidationContext(this, null, null) { MemberName = propertyName }, + propertyErrors); + + // Only raise the event once if needed. This happens either when the target property + // had existing errors and is now valid, or if the validation has failed and there are + // new errors to broadcast, regardless of the previous validation state for the property. + if (errorsChanged || !isValid) + { + ErrorsChanged?.Invoke(this, new DataErrorsChangedEventArgs(propertyName)); + } + } + +#pragma warning disable SA1204 + /// + /// Throws an when a property name given as input is . + /// + private static void ThrowArgumentNullExceptionForNullPropertyName() + { + throw new ArgumentNullException("propertyName", "The input property name cannot be null when validating a property"); + } +#pragma warning restore SA1204 + } +} \ No newline at end of file diff --git a/Microsoft.Toolkit.Mvvm/DependencyInjection/Ioc.cs b/Microsoft.Toolkit.Mvvm/DependencyInjection/Ioc.cs deleted file mode 100644 index 7546d684da8..00000000000 --- a/Microsoft.Toolkit.Mvvm/DependencyInjection/Ioc.cs +++ /dev/null @@ -1,145 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. -// See the LICENSE file in the project root for more information. - -using System; -using System.Threading; -using Microsoft.Extensions.DependencyInjection; - -#nullable enable - -namespace Microsoft.Toolkit.Mvvm.DependencyInjection -{ - /// - /// A type that facilitates the use of the type. - /// The provides the ability to configure services in a singleton, thread-safe - /// service provider instance, which can then be used to resolve service instances. - /// The first step to use this feature is to declare some services, for instance: - /// - /// public interface ILogger - /// { - /// void Log(string text); - /// } - /// - /// - /// public class ConsoleLogger : ILogger - /// { - /// void Log(string text) => Console.WriteLine(text); - /// } - /// - /// Then the services configuration should then be done at startup, by calling one of - /// the available overloads, like so: - /// - /// Ioc.Default.ConfigureServices(services => - /// { - /// services.AddSingleton<ILogger, Logger>(); - /// }); - /// - /// Finally, you can use the instance (which implements ) - /// to retrieve the service instances from anywhere in your application, by doing as follows: - /// - /// Ioc.Default.GetService<ILogger>().Log("Hello world!"); - /// - /// - public sealed class Ioc : IServiceProvider - { - /// - /// Gets the default instance. - /// - public static Ioc Default { get; } = new Ioc(); - - /// - /// The instance to use, if initialized. - /// - private volatile ServiceProvider? serviceProvider; - - /// - object? IServiceProvider.GetService(Type serviceType) - { - // As per section I.12.6.6 of the official CLI ECMA-335 spec: - // "[...] read and write access to properly aligned memory locations no larger than the native - // word size is atomic when all the write accesses to a location are the same size. Atomic writes - // shall alter no bits other than those written. Unless explicit layout control is used [...], - // data elements no larger than the natural word size [...] shall be properly aligned. - // Object references shall be treated as though they are stored in the native word size." - // The field being accessed here is of native int size (reference type), and is only ever accessed - // directly and atomically by a compare exchange instruction (see below), or here. We can therefore - // assume this read is thread safe with respect to accesses to this property or to invocations to one - // of the available configuration methods. So we can just read the field directly and make the necessary - // check with our local copy, without the need of paying the locking overhead from this get accessor. - ServiceProvider? provider = this.serviceProvider; - - if (provider is null) - { - ThrowInvalidOperationExceptionForMissingInitialization(); - } - - return provider!.GetService(serviceType); - } - - /// - /// Initializes the shared instance. - /// - /// The configuration delegate to use to add services. - public void ConfigureServices(Action setup) - { - ConfigureServices(setup, new ServiceProviderOptions()); - } - - /// - /// Initializes the shared instance. - /// - /// The configuration delegate to use to add services. - /// The instance to configure the service provider behaviors. - public void ConfigureServices(Action setup, ServiceProviderOptions options) - { - var collection = new ServiceCollection(); - - setup(collection); - - ConfigureServices(collection, options); - } - - /// - /// Initializes the shared instance. - /// - /// The input instance to use. - public void ConfigureServices(IServiceCollection services) - { - ConfigureServices(services, new ServiceProviderOptions()); - } - - /// - /// Initializes the shared instance. - /// - /// The input instance to use. - /// The instance to configure the service provider behaviors. - public void ConfigureServices(IServiceCollection services, ServiceProviderOptions options) - { - ServiceProvider newServices = services.BuildServiceProvider(options); - - ServiceProvider? oldServices = Interlocked.CompareExchange(ref this.serviceProvider, newServices, null); - - if (!(oldServices is null)) - { - ThrowInvalidOperationExceptionForRepeatedConfiguration(); - } - } - - /// - /// Throws an when the property is used before initialization. - /// - private static void ThrowInvalidOperationExceptionForMissingInitialization() - { - throw new InvalidOperationException("The service provider has not been configured yet"); - } - - /// - /// Throws an when a configuration is attempted more than once. - /// - private static void ThrowInvalidOperationExceptionForRepeatedConfiguration() - { - throw new InvalidOperationException("The default service provider has already been configured"); - } - } -} diff --git a/Microsoft.Toolkit.Mvvm/Input/AsyncRelayCommand.cs b/Microsoft.Toolkit.Mvvm/Input/AsyncRelayCommand.cs index ee03b23a1f0..d7edbca8e05 100644 --- a/Microsoft.Toolkit.Mvvm/Input/AsyncRelayCommand.cs +++ b/Microsoft.Toolkit.Mvvm/Input/AsyncRelayCommand.cs @@ -4,6 +4,7 @@ using System; using System.Runtime.CompilerServices; +using System.Threading; using System.Threading.Tasks; using Microsoft.Toolkit.Mvvm.ComponentModel; @@ -20,13 +21,25 @@ public sealed class AsyncRelayCommand : ObservableObject, IAsyncRelayCommand /// /// The to invoke when is used. /// - private readonly Func execute; + private readonly Func? execute; + + /// + /// The cancelable to invoke when is used. + /// + /// Only one between this and is not . + private readonly Func? cancelableExecute; /// /// The optional action to invoke when is used. /// private readonly Func? canExecute; + /// + /// The instance to use to cancel . + /// + /// This is only used when is not . + private CancellationTokenSource? cancellationTokenSource; + /// public event EventHandler? CanExecuteChanged; @@ -42,6 +55,15 @@ public AsyncRelayCommand(Func execute) /// /// Initializes a new instance of the class that can always execute. /// + /// The cancelable execution logic. + public AsyncRelayCommand(Func cancelableExecute) + { + this.cancelableExecute = cancelableExecute; + } + + /// + /// Initializes a new instance of the class. + /// /// The execution logic. /// The execution status logic. public AsyncRelayCommand(Func execute, Func canExecute) @@ -50,7 +72,18 @@ public AsyncRelayCommand(Func execute, Func canExecute) this.canExecute = canExecute; } - private Task? executionTask; + /// + /// Initializes a new instance of the class. + /// + /// The cancelable execution logic. + /// The execution status logic. + public AsyncRelayCommand(Func cancelableExecute, Func canExecute) + { + this.cancelableExecute = cancelableExecute; + this.canExecute = canExecute; + } + + private TaskNotifier? executionTask; /// public Task? ExecutionTask @@ -58,13 +91,19 @@ public Task? ExecutionTask get => this.executionTask; private set { - if (SetPropertyAndNotifyOnCompletion(ref this.executionTask, () => this.executionTask, value, _ => OnPropertyChanged(nameof(IsRunning)))) + if (SetPropertyAndNotifyOnCompletion(ref this.executionTask, value, _ => OnPropertyChanged(nameof(IsRunning)))) { OnPropertyChanged(nameof(IsRunning)); } } } + /// + public bool CanBeCanceled => !(this.cancelableExecute is null); + + /// + public bool IsCancellationRequested => this.cancellationTokenSource?.IsCancellationRequested == true; + /// public bool IsRunning => ExecutionTask?.IsCompleted == false; @@ -92,10 +131,32 @@ public Task ExecuteAsync(object? parameter) { if (CanExecute(parameter)) { - return ExecutionTask = this.execute(); + // Non cancelable command delegate + if (!(this.execute is null)) + { + return ExecutionTask = this.execute(); + } + + // Cancel the previous operation, if one is pending + this.cancellationTokenSource?.Cancel(); + + var cancellationTokenSource = this.cancellationTokenSource = new CancellationTokenSource(); + + OnPropertyChanged(nameof(IsCancellationRequested)); + + // Invoke the cancelable command delegate with a new linked token + return ExecutionTask = this.cancelableExecute!(cancellationTokenSource.Token); } return Task.CompletedTask; } + + /// + public void Cancel() + { + this.cancellationTokenSource?.Cancel(); + + OnPropertyChanged(nameof(IsCancellationRequested)); + } } } \ No newline at end of file diff --git a/Microsoft.Toolkit.Mvvm/Input/AsyncRelayCommand{T}.cs b/Microsoft.Toolkit.Mvvm/Input/AsyncRelayCommand{T}.cs index 14d2c65d4b7..1acf8e72a23 100644 --- a/Microsoft.Toolkit.Mvvm/Input/AsyncRelayCommand{T}.cs +++ b/Microsoft.Toolkit.Mvvm/Input/AsyncRelayCommand{T}.cs @@ -4,6 +4,7 @@ using System; using System.Runtime.CompilerServices; +using System.Threading; using System.Threading.Tasks; using Microsoft.Toolkit.Mvvm.ComponentModel; @@ -18,13 +19,23 @@ public sealed class AsyncRelayCommand : ObservableObject, IAsyncRelayCommand< /// /// The to invoke when is used. /// - private readonly Func execute; + private readonly Func? execute; + + /// + /// The cancelable to invoke when is used. + /// + private readonly Func? cancelableExecute; /// /// The optional action to invoke when is used. /// private readonly Func? canExecute; + /// + /// The instance to use to cancel . + /// + private CancellationTokenSource? cancellationTokenSource; + /// public event EventHandler? CanExecuteChanged; @@ -41,6 +52,16 @@ public AsyncRelayCommand(Func execute) /// /// Initializes a new instance of the class that can always execute. /// + /// The cancelable execution logic. + /// See notes in . + public AsyncRelayCommand(Func cancelableExecute) + { + this.cancelableExecute = cancelableExecute; + } + + /// + /// Initializes a new instance of the class. + /// /// The execution logic. /// The execution status logic. /// See notes in . @@ -50,7 +71,19 @@ public AsyncRelayCommand(Func execute, Func canExecute) this.canExecute = canExecute; } - private Task? executionTask; + /// + /// Initializes a new instance of the class. + /// + /// The cancelable execution logic. + /// The execution status logic. + /// See notes in . + public AsyncRelayCommand(Func cancelableExecute, Func canExecute) + { + this.cancelableExecute = cancelableExecute; + this.canExecute = canExecute; + } + + private TaskNotifier? executionTask; /// public Task? ExecutionTask @@ -58,13 +91,19 @@ public Task? ExecutionTask get => this.executionTask; private set { - if (SetPropertyAndNotifyOnCompletion(ref this.executionTask, () => this.executionTask, value, _ => OnPropertyChanged(nameof(IsRunning)))) + if (SetPropertyAndNotifyOnCompletion(ref this.executionTask, value, _ => OnPropertyChanged(nameof(IsRunning)))) { OnPropertyChanged(nameof(IsRunning)); } } } + /// + public bool CanBeCanceled => !(this.cancelableExecute is null); + + /// + public bool IsCancellationRequested => this.cancellationTokenSource?.IsCancellationRequested == true; + /// public bool IsRunning => ExecutionTask?.IsCompleted == false; @@ -113,7 +152,21 @@ public Task ExecuteAsync(T parameter) { if (CanExecute(parameter)) { - return ExecutionTask = this.execute(parameter); + // Non cancelable command delegate + if (!(this.execute is null)) + { + return ExecutionTask = this.execute(parameter); + } + + // Cancel the previous operation, if one is pending + this.cancellationTokenSource?.Cancel(); + + var cancellationTokenSource = this.cancellationTokenSource = new CancellationTokenSource(); + + OnPropertyChanged(nameof(IsCancellationRequested)); + + // Invoke the cancelable command delegate with a new linked token + return ExecutionTask = this.cancelableExecute!(parameter, cancellationTokenSource.Token); } return Task.CompletedTask; @@ -124,5 +177,13 @@ public Task ExecuteAsync(object? parameter) { return ExecuteAsync((T)parameter!); } + + /// + public void Cancel() + { + this.cancellationTokenSource?.Cancel(); + + OnPropertyChanged(nameof(IsCancellationRequested)); + } } } \ No newline at end of file diff --git a/Microsoft.Toolkit.Mvvm/Input/Interfaces/IAsyncRelayCommand.cs b/Microsoft.Toolkit.Mvvm/Input/Interfaces/IAsyncRelayCommand.cs index ea56dfae4e3..c3f663feb20 100644 --- a/Microsoft.Toolkit.Mvvm/Input/Interfaces/IAsyncRelayCommand.cs +++ b/Microsoft.Toolkit.Mvvm/Input/Interfaces/IAsyncRelayCommand.cs @@ -18,6 +18,16 @@ public interface IAsyncRelayCommand : IRelayCommand, INotifyPropertyChanged /// Task? ExecutionTask { get; } + /// + /// Gets a value indicating whether running operations for this command can be canceled. + /// + bool CanBeCanceled { get; } + + /// + /// Gets a value indicating whether a cancelation request has been issued for the current operation. + /// + bool IsCancellationRequested { get; } + /// /// Gets a value indicating whether the command currently has a pending operation being executed. /// @@ -30,5 +40,14 @@ public interface IAsyncRelayCommand : IRelayCommand, INotifyPropertyChanged /// The input parameter. /// The representing the async operation being executed. Task ExecuteAsync(object? parameter); + + /// + /// Communicates a request for cancelation. + /// + /// + /// If the underlying command is not running, or if it does not support cancelation, this method will perform no action. + /// Note that even with a successful cancelation, the completion of the current operation might not be immediate. + /// + void Cancel(); } } diff --git a/Microsoft.Toolkit.Mvvm/Microsoft.Toolkit.Mvvm.csproj b/Microsoft.Toolkit.Mvvm/Microsoft.Toolkit.Mvvm.csproj index 25371e3abc3..d9e4a16cdea 100644 --- a/Microsoft.Toolkit.Mvvm/Microsoft.Toolkit.Mvvm.csproj +++ b/Microsoft.Toolkit.Mvvm/Microsoft.Toolkit.Mvvm.csproj @@ -16,8 +16,9 @@ UWP Toolkit Windows MVVM MVVMToolkit observable Ioc dependency injection services extensions helpers - + + @@ -27,7 +28,7 @@ - + diff --git a/UnitTests/UnitTests.Shared/Mvvm/Test_AsyncRelayCommand.cs b/UnitTests/UnitTests.Shared/Mvvm/Test_AsyncRelayCommand.cs index 4a10690c6b2..09ff1d35a90 100644 --- a/UnitTests/UnitTests.Shared/Mvvm/Test_AsyncRelayCommand.cs +++ b/UnitTests/UnitTests.Shared/Mvvm/Test_AsyncRelayCommand.cs @@ -28,6 +28,9 @@ public async Task Test_AsyncRelayCommand_AlwaysEnabled() Assert.IsTrue(command.CanExecute(null)); Assert.IsTrue(command.CanExecute(new object())); + Assert.IsFalse(command.CanBeCanceled); + Assert.IsFalse(command.IsCancellationRequested); + (object, EventArgs) args = default; command.CanExecuteChanged += (s, e) => args = (s, e); @@ -75,6 +78,9 @@ public void Test_AsyncRelayCommand_WithCanExecuteFunctionTrue() Assert.IsTrue(command.CanExecute(null)); Assert.IsTrue(command.CanExecute(new object())); + Assert.IsFalse(command.CanBeCanceled); + Assert.IsFalse(command.IsCancellationRequested); + command.Execute(null); Assert.AreEqual(ticks, 1); @@ -100,6 +106,9 @@ public void Test_AsyncRelayCommand_WithCanExecuteFunctionFalse() Assert.IsFalse(command.CanExecute(null)); Assert.IsFalse(command.CanExecute(new object())); + Assert.IsFalse(command.CanBeCanceled); + Assert.IsFalse(command.IsCancellationRequested); + command.Execute(null); Assert.AreEqual(ticks, 0); @@ -108,5 +117,34 @@ public void Test_AsyncRelayCommand_WithCanExecuteFunctionFalse() Assert.AreEqual(ticks, 0); } + + [TestCategory("Mvvm")] + [TestMethod] + public async Task Test_AsyncRelayCommand_WithCancellation() + { + TaskCompletionSource tcs = new TaskCompletionSource(); + + var command = new AsyncRelayCommand(token => tcs.Task); + + Assert.IsTrue(command.CanExecute(null)); + Assert.IsTrue(command.CanExecute(new object())); + + Assert.IsTrue(command.CanBeCanceled); + Assert.IsFalse(command.IsCancellationRequested); + + command.Execute(null); + + Assert.IsFalse(command.IsCancellationRequested); + + command.Cancel(); + + Assert.IsTrue(command.IsCancellationRequested); + + tcs.SetResult(null); + + await command.ExecutionTask!; + + Assert.IsTrue(command.IsCancellationRequested); + } } } diff --git a/UnitTests/UnitTests.Shared/Mvvm/Test_Ioc.cs b/UnitTests/UnitTests.Shared/Mvvm/Test_Ioc.cs deleted file mode 100644 index 44fbf61301e..00000000000 --- a/UnitTests/UnitTests.Shared/Mvvm/Test_Ioc.cs +++ /dev/null @@ -1,154 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. -// See the LICENSE file in the project root for more information. - -using System; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Toolkit.Mvvm.ComponentModel; -using Microsoft.Toolkit.Mvvm.DependencyInjection; -using Microsoft.Toolkit.Mvvm.Messaging; -using Microsoft.VisualStudio.TestTools.UnitTesting; - -namespace UnitTests.Mvvm -{ - [TestClass] - public class Test_Ioc - { - [TestCategory("Mvvm")] - [TestMethod] - public void Test_Ioc_ServicesNotConfigured() - { - var ioc = new Ioc(); - - Assert.ThrowsException(() => ioc.GetService()); - } - - [TestCategory("Mvvm")] - [TestMethod] - public void Test_Ioc_LambdaInitialization() - { - var ioc = new Ioc(); - - ioc.ConfigureServices(services => - { - services.AddSingleton(); - }); - - var service = ioc.GetRequiredService(); - - Assert.IsNotNull(service); - Assert.IsInstanceOfType(service, typeof(AliceService)); - } - - [TestCategory("Mvvm")] - [TestMethod] - public void Test_Ioc_LambdaInitialization_ConcreteType() - { - var ioc = new Ioc(); - - ioc.ConfigureServices(services => - { - services.AddSingleton(); - }); - - var service = ioc.GetRequiredService(); - - Assert.IsNotNull(service); - Assert.IsInstanceOfType(service, typeof(AliceService)); - } - - [TestCategory("Mvvm")] - [TestMethod] - public void Test_Ioc_LambdaInitialization_ConstructorInjection() - { - var ioc = new Ioc(); - var messenger = new Messenger(); - - ioc.ConfigureServices(services => - { - services.AddSingleton(); - services.AddSingleton(messenger); - services.AddTransient(); - }); - - var service = ioc.GetRequiredService(); - - Assert.IsNotNull(service); - Assert.IsInstanceOfType(service, typeof(MyRecipient)); - Assert.IsNotNull(service.NameService); - Assert.IsInstanceOfType(service.NameService, typeof(AliceService)); - Assert.IsNotNull(service.MessengerService); - Assert.AreSame(service.MessengerService, messenger); - } - - [TestCategory("Mvvm")] - [TestMethod] - public void Test_Ioc_CollectionInitialization() - { - var ioc = new Ioc(); - - var services = new ServiceCollection(); - - services.AddSingleton(); - - ioc.ConfigureServices(services); - - var service = ioc.GetRequiredService(); - - Assert.IsNotNull(service); - Assert.IsInstanceOfType(service, typeof(AliceService)); - } - - [TestCategory("Mvvm")] - [TestMethod] - public void Test_Ioc_RepeatedLambdaInitialization() - { - var ioc = new Ioc(); - - ioc.ConfigureServices(services => { }); - - Assert.ThrowsException(() => ioc.ConfigureServices(services => { })); - } - - [TestCategory("Mvvm")] - [TestMethod] - public void Test_Ioc_RepeatedCollectionInitialization() - { - var ioc = new Ioc(); - - var services = new ServiceCollection(); - - ioc.ConfigureServices(services); - - Assert.ThrowsException(() => ioc.ConfigureServices(services)); - } - - public interface INameService - { - string GetName(); - } - - public class BobService : INameService - { - public string GetName() => "Bob"; - } - - public class AliceService : INameService - { - public string GetName() => "Alice"; - } - - public class MyRecipient : ObservableRecipient - { - public MyRecipient(INameService nameService, IMessenger messengerService) - : base(messengerService) - { - NameService = nameService; - } - - public INameService NameService { get; } - - public IMessenger MessengerService => Messenger; - } - } -} diff --git a/UnitTests/UnitTests.Shared/Mvvm/Test_ObservableObject.cs b/UnitTests/UnitTests.Shared/Mvvm/Test_ObservableObject.cs index 89e70171139..b9ba27a8921 100644 --- a/UnitTests/UnitTests.Shared/Mvvm/Test_ObservableObject.cs +++ b/UnitTests/UnitTests.Shared/Mvvm/Test_ObservableObject.cs @@ -114,7 +114,7 @@ public WrappingModelWithProperty(Person person) public string Name { get => Person.Name; - set => SetProperty(() => Person.Name, value); + set => SetProperty(Person.Name, value, Person, (person, name) => person.Name = name); } } @@ -164,7 +164,7 @@ public WrappingModelWithField(Person person) public string Name { get => this.person.Name; - set => SetProperty(() => this.person.Name, value); + set => SetProperty(this.person.Name, value, this.person, (person, name) => person.Name = name); } } @@ -221,12 +221,12 @@ static async Task TestAsync(Action> callback) public class SampleModelWithTask : ObservableObject { - private Task data; + private TaskNotifier data; public Task Data { get => data; - set => SetPropertyAndNotifyOnCompletion(ref data, () => data, value); + set => SetPropertyAndNotifyOnCompletion(ref data, value); } } } diff --git a/UnitTests/UnitTests.Shared/Mvvm/Test_ObservableValidator.cs b/UnitTests/UnitTests.Shared/Mvvm/Test_ObservableValidator.cs new file mode 100644 index 00000000000..d5f6e243f40 --- /dev/null +++ b/UnitTests/UnitTests.Shared/Mvvm/Test_ObservableValidator.cs @@ -0,0 +1,168 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Collections.Generic; +using System.ComponentModel; +using System.ComponentModel.DataAnnotations; +using System.Linq; +using Microsoft.Toolkit.Mvvm.ComponentModel; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace UnitTests.Mvvm +{ + [TestClass] + public class Test_ObservableValidator + { + [TestCategory("Mvvm")] + [TestMethod] + public void Test_ObservableValidator_HasErrors() + { + var model = new Person(); + + Assert.IsFalse(model.HasErrors); + + model.Name = "No"; + + Assert.IsTrue(model.HasErrors); + + model.Name = "Valid"; + + Assert.IsFalse(model.HasErrors); + } + + [TestCategory("Mvvm")] + [TestMethod] + public void Test_ObservableValidator_ErrorsChanged() + { + var model = new Person(); + + List<(object Sender, DataErrorsChangedEventArgs Args)> errors = new List<(object, DataErrorsChangedEventArgs)>(); + + model.ErrorsChanged += (s, e) => errors.Add((s, e)); + + model.Name = "Foo"; + + Assert.AreEqual(errors.Count, 1); + Assert.AreSame(errors[0].Sender, model); + Assert.AreEqual(errors[0].Args.PropertyName, nameof(Person.Name)); + + errors.Clear(); + + model.Name = "Bar"; + + Assert.AreEqual(errors.Count, 1); + Assert.AreSame(errors[0].Sender, model); + Assert.AreEqual(errors[0].Args.PropertyName, nameof(Person.Name)); + + errors.Clear(); + + model.Name = "Valid"; + + Assert.AreEqual(errors.Count, 1); + Assert.AreSame(errors[0].Sender, model); + Assert.AreEqual(errors[0].Args.PropertyName, nameof(Person.Name)); + + errors.Clear(); + + model.Name = "This is fine"; + + Assert.AreEqual(errors.Count, 0); + } + + [TestCategory("Mvvm")] + [TestMethod] + public void Test_ObservableValidator_GetErrors() + { + var model = new Person(); + + Assert.AreEqual(model.GetErrors(null).Cast().Count(), 0); + Assert.AreEqual(model.GetErrors(string.Empty).Cast().Count(), 0); + Assert.AreEqual(model.GetErrors("ThereIsntAPropertyWithThisName").Cast().Count(), 0); + Assert.AreEqual(model.GetErrors(nameof(Person.Name)).Cast().Count(), 0); + + model.Name = "Foo"; + + var errors = model.GetErrors(nameof(Person.Name)).Cast().ToArray(); + + Assert.AreEqual(errors.Length, 1); + Assert.AreEqual(errors[0].MemberNames.First(), nameof(Person.Name)); + + Assert.AreEqual(model.GetErrors("ThereIsntAPropertyWithThisName").Cast().Count(), 0); + + errors = model.GetErrors(null).Cast().ToArray(); + + Assert.AreEqual(errors.Length, 1); + Assert.AreEqual(errors[0].MemberNames.First(), nameof(Person.Name)); + + errors = model.GetErrors(string.Empty).Cast().ToArray(); + + Assert.AreEqual(errors.Length, 1); + Assert.AreEqual(errors[0].MemberNames.First(), nameof(Person.Name)); + + model.Age = -1; + + errors = model.GetErrors(null).Cast().ToArray(); + + Assert.AreEqual(errors.Length, 2); + Assert.IsTrue(errors.Any(e => e.MemberNames.First().Equals(nameof(Person.Name)))); + Assert.IsTrue(errors.Any(e => e.MemberNames.First().Equals(nameof(Person.Age)))); + + model.Age = 26; + + errors = model.GetErrors(null).Cast().ToArray(); + + Assert.AreEqual(errors.Length, 1); + Assert.IsTrue(errors.Any(e => e.MemberNames.First().Equals(nameof(Person.Name)))); + Assert.IsFalse(errors.Any(e => e.MemberNames.First().Equals(nameof(Person.Age)))); + } + + [TestCategory("Mvvm")] + [TestMethod] + [DataRow(null, false)] + [DataRow("", false)] + [DataRow("No", false)] + [DataRow("This text is really, really too long for the target property", false)] + [DataRow("1234", true)] + [DataRow("01234567890123456789", true)] + [DataRow("Hello world", true)] + public void Test_ObservableValidator_ValidateReturn(string value, bool isValid) + { + var model = new Person { Name = value }; + + Assert.AreEqual(model.HasErrors, !isValid); + + if (isValid) + { + Assert.IsTrue(!model.GetErrors(nameof(Person.Name)).Cast().Any()); + } + else + { + Assert.IsTrue(model.GetErrors(nameof(Person.Name)).Cast().Any()); + } + } + + public class Person : ObservableValidator + { + private string name; + + [MinLength(4)] + [MaxLength(20)] + [Required] + public string Name + { + get => this.name; + set => SetProperty(ref this.name, value, true); + } + + private int age; + + [Range(0, 100)] + public int Age + { + get => this.age; + set => SetProperty(ref this.age, value, true); + } + } + } +} diff --git a/UnitTests/UnitTests.Shared/UnitTests.Shared.projitems b/UnitTests/UnitTests.Shared/UnitTests.Shared.projitems index 14147cede7e..1d958a159c5 100644 --- a/UnitTests/UnitTests.Shared/UnitTests.Shared.projitems +++ b/UnitTests/UnitTests.Shared/UnitTests.Shared.projitems @@ -23,9 +23,9 @@ - +