diff --git a/README.md b/README.md index a2637dc7..76c06ce3 100644 --- a/README.md +++ b/README.md @@ -24,20 +24,20 @@ Install the following package into you class library and platform-specific proje ## How to use -* For ViewModels which need validation, implement `ISupportsValidation`. +* For ViewModels which need validation, implement `IValidatableViewModel`. * Add validation rules to the ViewModel. * Bind to the validation rules in the View. ## Example -1. Decorate existing ViewModel with `ISupportsValidation`, which has a single member, `ValidationContext`. The ValidationContext contains all of the functionality surrounding the validation of the ViewModel. Most access to the specification of validation rules is performed through extension methods on the ISupportsValidation interface. Then, add validation to the ViewModel. +1. Decorate existing ViewModel with `IValidatableViewModel`, which has a single member, `ValidationContext`. The ValidationContext contains all of the functionality surrounding the validation of the ViewModel. Most access to the specification of validation rules is performed through extension methods on the `IValidatableViewModel` interface. Then, add validation to the ViewModel. ```csharp -public class SampleViewModel : ReactiveObject, ISupportsValidation +public class SampleViewModel : ReactiveObject, IValidatableViewModel { public SampleViewModel() { - // Creates the validation for the Name property + // Creates the validation for the Name property. this.ValidationRule( viewModel => viewModel.Name, name => !string.IsNullOrWhiteSpace(name), @@ -69,7 +69,7 @@ public class SampleView : ReactiveContentPage .DisposeWith(disposables); // Bind any validations which reference the Name property - // to the text of the NameError UI control + // to the text of the NameError UI control. this.BindValidation(ViewModel, vm => vm.Name, view => view.NameError.Text) .DisposeWith(disposables); }); @@ -115,6 +115,28 @@ namespace SampleApp.Activities } ``` +## INotifyDataErrorInfo Support + +For those platforms which support the `INotifyDataErrorInfo` interface, ReactiveUI.Validation provides a helper base class named `ReactiveValidationObject`. The helper class implements both the `IValidatableViewModel` interface and the `INotifyDataErrorInfo` interface. It listens to any changes in the `ValidationContext` and invokes `INotifyDataErrorInfo` events. + +```cs +public class SampleViewModel : ReactiveValidationObject +{ + [Reactive] + public string Name { get; set; } = string.Empty; + + public SampleViewModel() + { + this.ValidationRule( + x => x.Name, + name => !string.IsNullOrWhiteSpace(name), + "Name shouldn't be empty."); + } +} +``` + +> **Note** The `Reactive` attribute is from the [ReactiveUI.Fody](https://reactiveui.net/docs/handbook/view-models/boilerplate-code) NuGet package. + ## Capabilities 1. Rules can be composed of single or multiple properties along with more generic Observables. diff --git a/src/ReactiveUI.Validation.Tests/API/ApiApprovalTests.ValidationProject.net472.approved.txt b/src/ReactiveUI.Validation.Tests/API/ApiApprovalTests.ValidationProject.net472.approved.txt index cff0d8c4..a88ca3eb 100644 --- a/src/ReactiveUI.Validation.Tests/API/ApiApprovalTests.ValidationProject.net472.approved.txt +++ b/src/ReactiveUI.Validation.Tests/API/ApiApprovalTests.ValidationProject.net472.approved.txt @@ -1,7 +1,7 @@ [assembly: System.Runtime.Versioning.TargetFrameworkAttribute(".NETFramework,Version=v4.6.1", FrameworkDisplayName=".NET Framework 4.6.1")] namespace ReactiveUI.Validation.Abstractions { - public interface ISupportsValidation + public interface IValidatableViewModel { ReactiveUI.Validation.Contexts.ValidationContext ValidationContext { get; } } @@ -50,6 +50,7 @@ namespace ReactiveUI.Validation.Components public System.IObservable ValidationStatusChange { get; } protected void AddProperty(System.Linq.Expressions.Expression> property) { } public bool ContainsProperty(System.Linq.Expressions.Expression> property, bool exclusively = False) { } + public bool ContainsPropertyName(string propertyName, bool exclusively = False) { } public void Dispose() { } protected virtual void Dispose(bool disposing) { } protected abstract System.IObservable GetValidationChangeObservable(); @@ -104,13 +105,13 @@ namespace ReactiveUI.Validation.Extensions public class static SupportsValidationExtensions { public static System.IObservable IsValid(this TViewModel viewModel) - where TViewModel : ReactiveUI.ReactiveObject, ReactiveUI.Validation.Abstractions.ISupportsValidation { } + where TViewModel : ReactiveUI.ReactiveObject, ReactiveUI.Validation.Abstractions.IValidatableViewModel { } public static ReactiveUI.Validation.Helpers.ValidationHelper ValidationRule(this TViewModel viewModel, System.Linq.Expressions.Expression> viewModelProperty, System.Func isPropertyValid, string message) - where TViewModel : ReactiveUI.ReactiveObject, ReactiveUI.Validation.Abstractions.ISupportsValidation { } + where TViewModel : ReactiveUI.ReactiveObject, ReactiveUI.Validation.Abstractions.IValidatableViewModel { } public static ReactiveUI.Validation.Helpers.ValidationHelper ValidationRule(this TViewModel viewModel, System.Linq.Expressions.Expression> viewModelProperty, System.Func isPropertyValid, System.Func message) - where TViewModel : ReactiveUI.ReactiveObject, ReactiveUI.Validation.Abstractions.ISupportsValidation { } + where TViewModel : ReactiveUI.ReactiveObject, ReactiveUI.Validation.Abstractions.IValidatableViewModel { } public static ReactiveUI.Validation.Helpers.ValidationHelper ValidationRule(this TViewModel viewModel, System.Func> viewModelObservableProperty, System.Func messageFunc) - where TViewModel : ReactiveUI.ReactiveObject, ReactiveUI.Validation.Abstractions.ISupportsValidation { } + where TViewModel : ReactiveUI.ReactiveObject, ReactiveUI.Validation.Abstractions.IValidatableViewModel { } } public class static ValidationContextExtensions { @@ -129,16 +130,16 @@ namespace ReactiveUI.Validation.Extensions public static System.IDisposable BindToDirect(System.IObservable @this, TTarget target, System.Linq.Expressions.Expression viewExpression) { } public static System.IDisposable BindValidation(this TView view, TViewModel viewModel, System.Linq.Expressions.Expression> viewModelProperty, System.Linq.Expressions.Expression> viewProperty) where TView : ReactiveUI.IViewFor - where TViewModel : ReactiveUI.ReactiveObject, ReactiveUI.Validation.Abstractions.ISupportsValidation { } + where TViewModel : ReactiveUI.ReactiveObject, ReactiveUI.Validation.Abstractions.IValidatableViewModel { } public static System.IDisposable BindValidation(this TView view, TViewModel viewModel, System.Linq.Expressions.Expression> viewProperty) where TView : ReactiveUI.IViewFor - where TViewModel : ReactiveUI.ReactiveObject, ReactiveUI.Validation.Abstractions.ISupportsValidation { } + where TViewModel : ReactiveUI.ReactiveObject, ReactiveUI.Validation.Abstractions.IValidatableViewModel { } public static System.IDisposable BindValidation(this TView view, TViewModel viewModel, System.Linq.Expressions.Expression> viewModelHelperProperty, System.Linq.Expressions.Expression> viewProperty) where TView : ReactiveUI.IViewFor - where TViewModel : ReactiveUI.ReactiveObject, ReactiveUI.Validation.Abstractions.ISupportsValidation { } + where TViewModel : ReactiveUI.ReactiveObject, ReactiveUI.Validation.Abstractions.IValidatableViewModel { } public static System.IDisposable BindValidationEx(this TView view, TViewModel viewModel, System.Linq.Expressions.Expression> viewModelProperty, System.Linq.Expressions.Expression> viewProperty) where TView : ReactiveUI.IViewFor - where TViewModel : ReactiveUI.ReactiveObject, ReactiveUI.Validation.Abstractions.ISupportsValidation { } + where TViewModel : ReactiveUI.ReactiveObject, ReactiveUI.Validation.Abstractions.IValidatableViewModel { } } } namespace ReactiveUI.Validation.Formatters.Abstractions @@ -159,6 +160,14 @@ namespace ReactiveUI.Validation.Formatters } namespace ReactiveUI.Validation.Helpers { + public abstract class ReactiveValidationObject : ReactiveUI.ReactiveObject, ReactiveUI.Validation.Abstractions.IValidatableViewModel, System.ComponentModel.INotifyDataErrorInfo + { + protected ReactiveValidationObject(System.Reactive.Concurrency.IScheduler scheduler = null) { } + public bool HasErrors { get; } + public ReactiveUI.Validation.Contexts.ValidationContext ValidationContext { get; } + public event System.EventHandler ErrorsChanged; + public virtual System.Collections.IEnumerable GetErrors(string propertyName) { } + } public class ValidationHelper : ReactiveUI.ReactiveObject, System.IDisposable { public ValidationHelper(ReactiveUI.Validation.Components.Abstractions.IValidationComponent validation) { } @@ -234,31 +243,31 @@ namespace ReactiveUI.Validation.ValidationBindings public void Dispose() { } public static ReactiveUI.Validation.ValidationBindings.Abstractions.IValidationBinding ForProperty(TView view, System.Linq.Expressions.Expression> viewModelProperty, System.Linq.Expressions.Expression> viewProperty, ReactiveUI.Validation.Formatters.Abstractions.IValidationTextFormatter formatter = null, bool strict = True) where TView : ReactiveUI.IViewFor - where TViewModel : ReactiveUI.ReactiveObject, ReactiveUI.Validation.Abstractions.ISupportsValidation { } + where TViewModel : ReactiveUI.ReactiveObject, ReactiveUI.Validation.Abstractions.IValidatableViewModel { } public static ReactiveUI.Validation.ValidationBindings.Abstractions.IValidationBinding ForProperty(TView view, System.Linq.Expressions.Expression> viewModelProperty, System.Action action, ReactiveUI.Validation.Formatters.Abstractions.IValidationTextFormatter formatter = null, bool strict = True) where TView : ReactiveUI.IViewFor - where TViewModel : ReactiveUI.ReactiveObject, ReactiveUI.Validation.Abstractions.ISupportsValidation { } + where TViewModel : ReactiveUI.ReactiveObject, ReactiveUI.Validation.Abstractions.IValidatableViewModel { } public static ReactiveUI.Validation.ValidationBindings.Abstractions.IValidationBinding ForValidationHelperProperty(TView view, System.Linq.Expressions.Expression> viewModelHelperProperty, System.Linq.Expressions.Expression> viewProperty, ReactiveUI.Validation.Formatters.Abstractions.IValidationTextFormatter formatter = null) where TView : ReactiveUI.IViewFor - where TViewModel : ReactiveUI.ReactiveObject, ReactiveUI.Validation.Abstractions.ISupportsValidation { } + where TViewModel : ReactiveUI.ReactiveObject, ReactiveUI.Validation.Abstractions.IValidatableViewModel { } public static ReactiveUI.Validation.ValidationBindings.Abstractions.IValidationBinding ForValidationHelperProperty(TView view, System.Linq.Expressions.Expression> viewModelHelperProperty, System.Action action, ReactiveUI.Validation.Formatters.Abstractions.IValidationTextFormatter formatter = null) where TView : ReactiveUI.IViewFor - where TViewModel : ReactiveUI.ReactiveObject, ReactiveUI.Validation.Abstractions.ISupportsValidation { } + where TViewModel : ReactiveUI.ReactiveObject, ReactiveUI.Validation.Abstractions.IValidatableViewModel { } public static ReactiveUI.Validation.ValidationBindings.Abstractions.IValidationBinding ForViewModel(TView view, System.Action action, ReactiveUI.Validation.Formatters.Abstractions.IValidationTextFormatter formatter) where TView : ReactiveUI.IViewFor - where TViewModel : ReactiveUI.ReactiveObject, ReactiveUI.Validation.Abstractions.ISupportsValidation { } + where TViewModel : ReactiveUI.ReactiveObject, ReactiveUI.Validation.Abstractions.IValidatableViewModel { } public static ReactiveUI.Validation.ValidationBindings.Abstractions.IValidationBinding ForViewModel(TView view, System.Linq.Expressions.Expression> viewProperty, ReactiveUI.Validation.Formatters.Abstractions.IValidationTextFormatter formatter = null) where TView : ReactiveUI.IViewFor - where TViewModel : ReactiveUI.ReactiveObject, ReactiveUI.Validation.Abstractions.ISupportsValidation { } + where TViewModel : ReactiveUI.ReactiveObject, ReactiveUI.Validation.Abstractions.IValidatableViewModel { } } public sealed class ValidationBindingEx : ReactiveUI.Validation.ValidationBindings.Abstractions.IValidationBinding, System.IDisposable { public void Dispose() { } public static ReactiveUI.Validation.ValidationBindings.Abstractions.IValidationBinding ForProperty(TView view, System.Linq.Expressions.Expression> viewModelProperty, System.Linq.Expressions.Expression> viewProperty, ReactiveUI.Validation.Formatters.Abstractions.IValidationTextFormatter formatter = null, bool strict = True) where TView : ReactiveUI.IViewFor - where TViewModel : ReactiveUI.ReactiveObject, ReactiveUI.Validation.Abstractions.ISupportsValidation { } + where TViewModel : ReactiveUI.ReactiveObject, ReactiveUI.Validation.Abstractions.IValidatableViewModel { } public static ReactiveUI.Validation.ValidationBindings.Abstractions.IValidationBinding ForProperty(TView view, System.Linq.Expressions.Expression> viewModelProperty, System.Action, System.Collections.Generic.IList> action, ReactiveUI.Validation.Formatters.Abstractions.IValidationTextFormatter formatter = null, bool strict = True) where TView : ReactiveUI.IViewFor - where TViewModel : ReactiveUI.ReactiveObject, ReactiveUI.Validation.Abstractions.ISupportsValidation { } + where TViewModel : ReactiveUI.ReactiveObject, ReactiveUI.Validation.Abstractions.IValidatableViewModel { } } } \ No newline at end of file diff --git a/src/ReactiveUI.Validation.Tests/API/ApiApprovalTests.ValidationProject.netcoreapp2.1.approved.txt b/src/ReactiveUI.Validation.Tests/API/ApiApprovalTests.ValidationProject.netcoreapp2.1.approved.txt index 38f841eb..38077391 100644 --- a/src/ReactiveUI.Validation.Tests/API/ApiApprovalTests.ValidationProject.netcoreapp2.1.approved.txt +++ b/src/ReactiveUI.Validation.Tests/API/ApiApprovalTests.ValidationProject.netcoreapp2.1.approved.txt @@ -1,7 +1,7 @@ [assembly: System.Runtime.Versioning.TargetFrameworkAttribute(".NETStandard,Version=v2.0", FrameworkDisplayName="")] namespace ReactiveUI.Validation.Abstractions { - public interface ISupportsValidation + public interface IValidatableViewModel { ReactiveUI.Validation.Contexts.ValidationContext ValidationContext { get; } } @@ -50,6 +50,7 @@ namespace ReactiveUI.Validation.Components public System.IObservable ValidationStatusChange { get; } protected void AddProperty(System.Linq.Expressions.Expression> property) { } public bool ContainsProperty(System.Linq.Expressions.Expression> property, bool exclusively = False) { } + public bool ContainsPropertyName(string propertyName, bool exclusively = False) { } public void Dispose() { } protected virtual void Dispose(bool disposing) { } protected abstract System.IObservable GetValidationChangeObservable(); @@ -104,13 +105,13 @@ namespace ReactiveUI.Validation.Extensions public class static SupportsValidationExtensions { public static System.IObservable IsValid(this TViewModel viewModel) - where TViewModel : ReactiveUI.ReactiveObject, ReactiveUI.Validation.Abstractions.ISupportsValidation { } + where TViewModel : ReactiveUI.ReactiveObject, ReactiveUI.Validation.Abstractions.IValidatableViewModel { } public static ReactiveUI.Validation.Helpers.ValidationHelper ValidationRule(this TViewModel viewModel, System.Linq.Expressions.Expression> viewModelProperty, System.Func isPropertyValid, string message) - where TViewModel : ReactiveUI.ReactiveObject, ReactiveUI.Validation.Abstractions.ISupportsValidation { } + where TViewModel : ReactiveUI.ReactiveObject, ReactiveUI.Validation.Abstractions.IValidatableViewModel { } public static ReactiveUI.Validation.Helpers.ValidationHelper ValidationRule(this TViewModel viewModel, System.Linq.Expressions.Expression> viewModelProperty, System.Func isPropertyValid, System.Func message) - where TViewModel : ReactiveUI.ReactiveObject, ReactiveUI.Validation.Abstractions.ISupportsValidation { } + where TViewModel : ReactiveUI.ReactiveObject, ReactiveUI.Validation.Abstractions.IValidatableViewModel { } public static ReactiveUI.Validation.Helpers.ValidationHelper ValidationRule(this TViewModel viewModel, System.Func> viewModelObservableProperty, System.Func messageFunc) - where TViewModel : ReactiveUI.ReactiveObject, ReactiveUI.Validation.Abstractions.ISupportsValidation { } + where TViewModel : ReactiveUI.ReactiveObject, ReactiveUI.Validation.Abstractions.IValidatableViewModel { } } public class static ValidationContextExtensions { @@ -129,16 +130,16 @@ namespace ReactiveUI.Validation.Extensions public static System.IDisposable BindToDirect(System.IObservable @this, TTarget target, System.Linq.Expressions.Expression viewExpression) { } public static System.IDisposable BindValidation(this TView view, TViewModel viewModel, System.Linq.Expressions.Expression> viewModelProperty, System.Linq.Expressions.Expression> viewProperty) where TView : ReactiveUI.IViewFor - where TViewModel : ReactiveUI.ReactiveObject, ReactiveUI.Validation.Abstractions.ISupportsValidation { } + where TViewModel : ReactiveUI.ReactiveObject, ReactiveUI.Validation.Abstractions.IValidatableViewModel { } public static System.IDisposable BindValidation(this TView view, TViewModel viewModel, System.Linq.Expressions.Expression> viewProperty) where TView : ReactiveUI.IViewFor - where TViewModel : ReactiveUI.ReactiveObject, ReactiveUI.Validation.Abstractions.ISupportsValidation { } + where TViewModel : ReactiveUI.ReactiveObject, ReactiveUI.Validation.Abstractions.IValidatableViewModel { } public static System.IDisposable BindValidation(this TView view, TViewModel viewModel, System.Linq.Expressions.Expression> viewModelHelperProperty, System.Linq.Expressions.Expression> viewProperty) where TView : ReactiveUI.IViewFor - where TViewModel : ReactiveUI.ReactiveObject, ReactiveUI.Validation.Abstractions.ISupportsValidation { } + where TViewModel : ReactiveUI.ReactiveObject, ReactiveUI.Validation.Abstractions.IValidatableViewModel { } public static System.IDisposable BindValidationEx(this TView view, TViewModel viewModel, System.Linq.Expressions.Expression> viewModelProperty, System.Linq.Expressions.Expression> viewProperty) where TView : ReactiveUI.IViewFor - where TViewModel : ReactiveUI.ReactiveObject, ReactiveUI.Validation.Abstractions.ISupportsValidation { } + where TViewModel : ReactiveUI.ReactiveObject, ReactiveUI.Validation.Abstractions.IValidatableViewModel { } } } namespace ReactiveUI.Validation.Formatters.Abstractions @@ -159,6 +160,14 @@ namespace ReactiveUI.Validation.Formatters } namespace ReactiveUI.Validation.Helpers { + public abstract class ReactiveValidationObject : ReactiveUI.ReactiveObject, ReactiveUI.Validation.Abstractions.IValidatableViewModel, System.ComponentModel.INotifyDataErrorInfo + { + protected ReactiveValidationObject(System.Reactive.Concurrency.IScheduler scheduler = null) { } + public bool HasErrors { get; } + public ReactiveUI.Validation.Contexts.ValidationContext ValidationContext { get; } + public event System.EventHandler ErrorsChanged; + public virtual System.Collections.IEnumerable GetErrors(string propertyName) { } + } public class ValidationHelper : ReactiveUI.ReactiveObject, System.IDisposable { public ValidationHelper(ReactiveUI.Validation.Components.Abstractions.IValidationComponent validation) { } @@ -234,31 +243,31 @@ namespace ReactiveUI.Validation.ValidationBindings public void Dispose() { } public static ReactiveUI.Validation.ValidationBindings.Abstractions.IValidationBinding ForProperty(TView view, System.Linq.Expressions.Expression> viewModelProperty, System.Linq.Expressions.Expression> viewProperty, ReactiveUI.Validation.Formatters.Abstractions.IValidationTextFormatter formatter = null, bool strict = True) where TView : ReactiveUI.IViewFor - where TViewModel : ReactiveUI.ReactiveObject, ReactiveUI.Validation.Abstractions.ISupportsValidation { } + where TViewModel : ReactiveUI.ReactiveObject, ReactiveUI.Validation.Abstractions.IValidatableViewModel { } public static ReactiveUI.Validation.ValidationBindings.Abstractions.IValidationBinding ForProperty(TView view, System.Linq.Expressions.Expression> viewModelProperty, System.Action action, ReactiveUI.Validation.Formatters.Abstractions.IValidationTextFormatter formatter = null, bool strict = True) where TView : ReactiveUI.IViewFor - where TViewModel : ReactiveUI.ReactiveObject, ReactiveUI.Validation.Abstractions.ISupportsValidation { } + where TViewModel : ReactiveUI.ReactiveObject, ReactiveUI.Validation.Abstractions.IValidatableViewModel { } public static ReactiveUI.Validation.ValidationBindings.Abstractions.IValidationBinding ForValidationHelperProperty(TView view, System.Linq.Expressions.Expression> viewModelHelperProperty, System.Linq.Expressions.Expression> viewProperty, ReactiveUI.Validation.Formatters.Abstractions.IValidationTextFormatter formatter = null) where TView : ReactiveUI.IViewFor - where TViewModel : ReactiveUI.ReactiveObject, ReactiveUI.Validation.Abstractions.ISupportsValidation { } + where TViewModel : ReactiveUI.ReactiveObject, ReactiveUI.Validation.Abstractions.IValidatableViewModel { } public static ReactiveUI.Validation.ValidationBindings.Abstractions.IValidationBinding ForValidationHelperProperty(TView view, System.Linq.Expressions.Expression> viewModelHelperProperty, System.Action action, ReactiveUI.Validation.Formatters.Abstractions.IValidationTextFormatter formatter = null) where TView : ReactiveUI.IViewFor - where TViewModel : ReactiveUI.ReactiveObject, ReactiveUI.Validation.Abstractions.ISupportsValidation { } + where TViewModel : ReactiveUI.ReactiveObject, ReactiveUI.Validation.Abstractions.IValidatableViewModel { } public static ReactiveUI.Validation.ValidationBindings.Abstractions.IValidationBinding ForViewModel(TView view, System.Action action, ReactiveUI.Validation.Formatters.Abstractions.IValidationTextFormatter formatter) where TView : ReactiveUI.IViewFor - where TViewModel : ReactiveUI.ReactiveObject, ReactiveUI.Validation.Abstractions.ISupportsValidation { } + where TViewModel : ReactiveUI.ReactiveObject, ReactiveUI.Validation.Abstractions.IValidatableViewModel { } public static ReactiveUI.Validation.ValidationBindings.Abstractions.IValidationBinding ForViewModel(TView view, System.Linq.Expressions.Expression> viewProperty, ReactiveUI.Validation.Formatters.Abstractions.IValidationTextFormatter formatter = null) where TView : ReactiveUI.IViewFor - where TViewModel : ReactiveUI.ReactiveObject, ReactiveUI.Validation.Abstractions.ISupportsValidation { } + where TViewModel : ReactiveUI.ReactiveObject, ReactiveUI.Validation.Abstractions.IValidatableViewModel { } } public sealed class ValidationBindingEx : ReactiveUI.Validation.ValidationBindings.Abstractions.IValidationBinding, System.IDisposable { public void Dispose() { } public static ReactiveUI.Validation.ValidationBindings.Abstractions.IValidationBinding ForProperty(TView view, System.Linq.Expressions.Expression> viewModelProperty, System.Linq.Expressions.Expression> viewProperty, ReactiveUI.Validation.Formatters.Abstractions.IValidationTextFormatter formatter = null, bool strict = True) where TView : ReactiveUI.IViewFor - where TViewModel : ReactiveUI.ReactiveObject, ReactiveUI.Validation.Abstractions.ISupportsValidation { } + where TViewModel : ReactiveUI.ReactiveObject, ReactiveUI.Validation.Abstractions.IValidatableViewModel { } public static ReactiveUI.Validation.ValidationBindings.Abstractions.IValidationBinding ForProperty(TView view, System.Linq.Expressions.Expression> viewModelProperty, System.Action, System.Collections.Generic.IList> action, ReactiveUI.Validation.Formatters.Abstractions.IValidationTextFormatter formatter = null, bool strict = True) where TView : ReactiveUI.IViewFor - where TViewModel : ReactiveUI.ReactiveObject, ReactiveUI.Validation.Abstractions.ISupportsValidation { } + where TViewModel : ReactiveUI.ReactiveObject, ReactiveUI.Validation.Abstractions.IValidatableViewModel { } } } \ No newline at end of file diff --git a/src/ReactiveUI.Validation.Tests/Models/IndeiTestView.cs b/src/ReactiveUI.Validation.Tests/Models/IndeiTestView.cs new file mode 100644 index 00000000..a7b83f81 --- /dev/null +++ b/src/ReactiveUI.Validation.Tests/Models/IndeiTestView.cs @@ -0,0 +1,37 @@ +namespace ReactiveUI.Validation.Tests.Models +{ + /// + /// Mocked View for INotifyDataErrorInfo testing. + /// + public class IndeiTestView : IViewFor + { + /// + /// Initializes a new instance of the class. + /// + /// ViewModel instance of type . + public IndeiTestView(IndeiTestViewModel viewModel) + { + ViewModel = viewModel; + } + + /// + object IViewFor.ViewModel + { + get => ViewModel; + set => ViewModel = value as IndeiTestViewModel; + } + + /// + public IndeiTestViewModel ViewModel { get; set; } + + /// + /// Gets or sets the Name Label which emulates a Text property (eg. Entry in Xamarin.Forms). + /// + public string NameLabel { get; set; } + + /// + /// Gets or sets the NameError Label which emulates a Text property (eg. Entry in Xamarin.Forms). + /// + public string NameErrorLabel { get; set; } + } +} diff --git a/src/ReactiveUI.Validation.Tests/Models/IndeiTestViewModel.cs b/src/ReactiveUI.Validation.Tests/Models/IndeiTestViewModel.cs new file mode 100644 index 00000000..c21fa3d0 --- /dev/null +++ b/src/ReactiveUI.Validation.Tests/Models/IndeiTestViewModel.cs @@ -0,0 +1,30 @@ +using System.Reactive.Concurrency; +using ReactiveUI.Validation.Helpers; + +namespace ReactiveUI.Validation.Tests.Models +{ + /// + /// Mocked ViewModel for INotifyDataErrorInfo testing. + /// + public class IndeiTestViewModel : ReactiveValidationObject + { + private string _name; + + /// + /// Initializes a new instance of the class. + /// + public IndeiTestViewModel() + : base(ImmediateScheduler.Instance) + { + } + + /// + /// Gets or sets get the Name. + /// + public string Name + { + get => _name; + set => this.RaiseAndSetIfChanged(ref _name, value); + } + } +} diff --git a/src/ReactiveUI.Validation.Tests/Models/TestViewModel.cs b/src/ReactiveUI.Validation.Tests/Models/TestViewModel.cs index 000d7053..41ad9fab 100644 --- a/src/ReactiveUI.Validation.Tests/Models/TestViewModel.cs +++ b/src/ReactiveUI.Validation.Tests/Models/TestViewModel.cs @@ -8,7 +8,7 @@ namespace ReactiveUI.Validation.Tests.Models /// /// Mocked ViewModel. /// - public class TestViewModel : ReactiveObject, ISupportsValidation + public class TestViewModel : ReactiveObject, IValidatableViewModel { private string _name; private string _name2; diff --git a/src/ReactiveUI.Validation.Tests/NotifyDataErrorInfoTests.cs b/src/ReactiveUI.Validation.Tests/NotifyDataErrorInfoTests.cs new file mode 100644 index 00000000..88f7d0c8 --- /dev/null +++ b/src/ReactiveUI.Validation.Tests/NotifyDataErrorInfoTests.cs @@ -0,0 +1,126 @@ +using System.ComponentModel; +using System.Linq; +using ReactiveUI.Validation.Components; +using ReactiveUI.Validation.Extensions; +using ReactiveUI.Validation.Tests.Models; +using Xunit; + +namespace ReactiveUI.Validation.Tests +{ + /// + /// Tests for INotifyDataErrorInfo support. + /// + public class NotifyDataErrorInfoTests + { + private const string NameShouldNotBeEmptyMessage = "Name shouldn't be empty."; + + /// + /// Verifies that the ErrorsChanged event fires on ViewModel initialization. + /// + [Fact] + public void ShouldMarkPropertiesAsInvalidOnInit() + { + var viewModel = new IndeiTestViewModel(); + var view = new IndeiTestView(viewModel); + + var firstValidation = new BasePropertyValidation( + viewModel, + vm => vm.Name, + s => !string.IsNullOrEmpty(s), + NameShouldNotBeEmptyMessage); + + viewModel.ValidationContext.Add(firstValidation); + view.Bind(view.ViewModel, vm => vm.Name, v => v.NameLabel); + view.BindValidationEx(view.ViewModel, vm => vm.Name, v => v.NameErrorLabel); + + // Verify validation context behavior. + Assert.False(viewModel.ValidationContext.IsValid); + Assert.Single(viewModel.ValidationContext.Validations); + Assert.Equal(NameShouldNotBeEmptyMessage, view.NameErrorLabel); + + // Verify INotifyDataErrorInfo behavior. + Assert.True(viewModel.HasErrors); + Assert.Equal(NameShouldNotBeEmptyMessage, viewModel.GetErrors("Name").Cast().First()); + } + + /// + /// Verifies that the view model listens to the INotifyPropertyChanged event + /// and sends INotifyDataErrorInfo notifications. + /// + [Fact] + public void ShouldSynchronizeNotifyDataErrorInfoWithValidationContext() + { + var viewModel = new IndeiTestViewModel(); + var view = new IndeiTestView(viewModel); + + var firstValidation = new BasePropertyValidation( + viewModel, + vm => vm.Name, + s => !string.IsNullOrEmpty(s), + NameShouldNotBeEmptyMessage); + + viewModel.ValidationContext.Add(firstValidation); + view.Bind(view.ViewModel, vm => vm.Name, v => v.NameLabel); + view.BindValidationEx(view.ViewModel, vm => vm.Name, v => v.NameErrorLabel); + + // Verify the initial state. + Assert.True(viewModel.HasErrors); + Assert.False(viewModel.ValidationContext.IsValid); + Assert.Single(viewModel.ValidationContext.Validations); + Assert.Equal(NameShouldNotBeEmptyMessage, viewModel.GetErrors("Name").Cast().First()); + Assert.Equal(NameShouldNotBeEmptyMessage, view.NameErrorLabel); + + // Send INotifyPropertyChanged. + viewModel.Name = "JoJo"; + + // Verify the changed state. + Assert.False(viewModel.HasErrors); + Assert.True(viewModel.ValidationContext.IsValid); + Assert.Empty(viewModel.GetErrors("Name").Cast()); + Assert.Empty(view.NameErrorLabel); + + // Send INotifyPropertyChanged. + viewModel.Name = string.Empty; + + // Verify the changed state. + Assert.True(viewModel.HasErrors); + Assert.False(viewModel.ValidationContext.IsValid); + Assert.Single(viewModel.ValidationContext.Validations); + Assert.Equal(NameShouldNotBeEmptyMessage, viewModel.GetErrors("Name").Cast().First()); + Assert.Equal(NameShouldNotBeEmptyMessage, view.NameErrorLabel); + } + + /// + /// The ErrorsChanged event should fire when properties change. + /// + [Fact] + public void ShouldFireErrorsChangedEventWhenValidationStateChanges() + { + var viewModel = new IndeiTestViewModel(); + + DataErrorsChangedEventArgs arguments = null; + viewModel.ErrorsChanged += (sender, args) => arguments = args; + + var firstValidation = new BasePropertyValidation( + viewModel, + vm => vm.Name, + s => !string.IsNullOrEmpty(s), + NameShouldNotBeEmptyMessage); + + viewModel.ValidationContext.Add(firstValidation); + + Assert.True(viewModel.HasErrors); + Assert.False(viewModel.ValidationContext.IsValid); + Assert.Single(viewModel.ValidationContext.Validations); + Assert.Single(viewModel.GetErrors("Name").Cast()); + Assert.Null(arguments); + + viewModel.Name = "JoJo"; + + Assert.False(viewModel.HasErrors); + Assert.Empty(viewModel.GetErrors("Name").Cast()); + Assert.NotNull(arguments); + Assert.Equal("Name", arguments.PropertyName); + } + } +} diff --git a/src/ReactiveUI.Validation/Abstractions/ISupportsValidation.cs b/src/ReactiveUI.Validation/Abstractions/IValidatableViewModel.cs similarity index 94% rename from src/ReactiveUI.Validation/Abstractions/ISupportsValidation.cs rename to src/ReactiveUI.Validation/Abstractions/IValidatableViewModel.cs index 847b5db9..1ea684c9 100755 --- a/src/ReactiveUI.Validation/Abstractions/ISupportsValidation.cs +++ b/src/ReactiveUI.Validation/Abstractions/IValidatableViewModel.cs @@ -11,7 +11,7 @@ namespace ReactiveUI.Validation.Abstractions /// /// Interface used by view models to indicate they have a validation context. /// - public interface ISupportsValidation + public interface IValidatableViewModel { /// /// Gets get the validation context. diff --git a/src/ReactiveUI.Validation/Components/BasePropertyValidation.cs b/src/ReactiveUI.Validation/Components/BasePropertyValidation.cs index 51640011..7e41b6a4 100755 --- a/src/ReactiveUI.Validation/Components/BasePropertyValidation.cs +++ b/src/ReactiveUI.Validation/Components/BasePropertyValidation.cs @@ -121,7 +121,17 @@ public void Dispose() public bool ContainsProperty(Expression> property, bool exclusively = false) { var propertyName = property.Body.GetMemberInfo().ToString(); + return ContainsPropertyName(propertyName, exclusively); + } + /// + /// Determine if a property name is actually contained within this. + /// + /// ViewModel property name. + /// Indicates if the property to find is unique. + /// Returns true if it contains the property, otherwise false. + public bool ContainsPropertyName(string propertyName, bool exclusively = false) + { return exclusively ? _propertyNames.Contains(propertyName) && _propertyNames.Count == 1 : _propertyNames.Contains(propertyName); @@ -332,4 +342,4 @@ private ValidationText GetMessage(TViewModelProperty value) return _message(value, IsValidFunc(value)); } } -} \ No newline at end of file +} diff --git a/src/ReactiveUI.Validation/Extensions/SupportsValidationExtensions.cs b/src/ReactiveUI.Validation/Extensions/SupportsValidationExtensions.cs index 8c818138..5e62e288 100755 --- a/src/ReactiveUI.Validation/Extensions/SupportsValidationExtensions.cs +++ b/src/ReactiveUI.Validation/Extensions/SupportsValidationExtensions.cs @@ -13,7 +13,7 @@ namespace ReactiveUI.Validation.Extensions { /// - /// Extensions methods associated to instances. + /// Extensions methods associated to instances. /// public static class SupportsValidationExtensions { @@ -32,7 +32,7 @@ public static ValidationHelper ValidationRule( Expression> viewModelProperty, Func isPropertyValid, string message) - where TViewModel : ReactiveObject, ISupportsValidation + where TViewModel : ReactiveObject, IValidatableViewModel { // We need to associate the ViewModel property // with something that can be easily looked up and bound to @@ -62,7 +62,7 @@ public static ValidationHelper ValidationRule( Expression> viewModelProperty, Func isPropertyValid, Func message) - where TViewModel : ReactiveObject, ISupportsValidation + where TViewModel : ReactiveObject, IValidatableViewModel { // We need to associate the ViewModel property // with something that can be easily looked up and bound to @@ -93,7 +93,7 @@ public static ValidationHelper ValidationRule( this TViewModel viewModel, Func> viewModelObservableProperty, Func messageFunc) - where TViewModel : ReactiveObject, ISupportsValidation + where TViewModel : ReactiveObject, IValidatableViewModel { var validation = new ModelObservableValidation(viewModel, viewModelObservableProperty, messageFunc); @@ -110,7 +110,7 @@ public static ValidationHelper ValidationRule( /// ViewModel instance. /// Returns true if the ValidationContext is valid, otherwise false. public static IObservable IsValid(this TViewModel viewModel) - where TViewModel : ReactiveObject, ISupportsValidation + where TViewModel : ReactiveObject, IValidatableViewModel { return viewModel?.ValidationContext.Valid; } diff --git a/src/ReactiveUI.Validation/Extensions/ViewForExtensions.cs b/src/ReactiveUI.Validation/Extensions/ViewForExtensions.cs index 5fb76695..88df380f 100755 --- a/src/ReactiveUI.Validation/Extensions/ViewForExtensions.cs +++ b/src/ReactiveUI.Validation/Extensions/ViewForExtensions.cs @@ -42,7 +42,7 @@ public static IDisposable BindValidationEx> viewModelProperty, Expression> viewProperty) where TView : IViewFor - where TViewModel : ReactiveObject, ISupportsValidation + where TViewModel : ReactiveObject, IValidatableViewModel { return ValidationBindingEx.ForProperty(view, viewModelProperty, viewProperty); } @@ -70,7 +70,7 @@ public static IDisposable BindValidation> viewModelProperty, Expression> viewProperty) where TView : IViewFor - where TViewModel : ReactiveObject, ISupportsValidation + where TViewModel : ReactiveObject, IValidatableViewModel { return ValidationBinding.ForProperty(view, viewModelProperty, viewProperty); } @@ -94,7 +94,7 @@ public static IDisposable BindValidation( this TView view, TViewModel viewModel, Expression> viewProperty) - where TViewModel : ReactiveObject, ISupportsValidation + where TViewModel : ReactiveObject, IValidatableViewModel where TView : IViewFor { return ValidationBinding.ForViewModel(view, viewProperty); @@ -122,7 +122,7 @@ public static IDisposable BindValidation( Expression> viewModelHelperProperty, Expression> viewProperty) where TView : IViewFor - where TViewModel : ReactiveObject, ISupportsValidation + where TViewModel : ReactiveObject, IValidatableViewModel { return ValidationBinding.ForValidationHelperProperty(view, viewModelHelperProperty, viewProperty); } diff --git a/src/ReactiveUI.Validation/Helpers/ReactiveValidationObject.cs b/src/ReactiveUI.Validation/Helpers/ReactiveValidationObject.cs new file mode 100644 index 00000000..e0cf62ef --- /dev/null +++ b/src/ReactiveUI.Validation/Helpers/ReactiveValidationObject.cs @@ -0,0 +1,87 @@ +// +// 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.ComponentModel; +using System.Linq; +using System.Reactive.Concurrency; +using System.Reactive.Linq; +using ReactiveUI.Validation.Abstractions; +using ReactiveUI.Validation.Components; +using ReactiveUI.Validation.Contexts; +using ReactiveUI.Validation.Extensions; + +namespace ReactiveUI.Validation.Helpers +{ + /// + /// Base class for ReactiveObjects that support INotifyDataErrorInfo validation. + /// + /// The parent view model. + public abstract class ReactiveValidationObject : ReactiveObject, IValidatableViewModel, INotifyDataErrorInfo + { + private readonly ObservableAsPropertyHelper _hasErrors; + + /// + /// Initializes a new instance of the class. + /// + /// Scheduler for OAPHs and for the the ValidationContext. + protected ReactiveValidationObject(IScheduler scheduler = null) + { + ValidationContext = new ValidationContext(scheduler); + + _hasErrors = this + .IsValid() + .Select(valid => !valid) + .ToProperty(this, x => x.HasErrors, scheduler: scheduler); + + ValidationContext + .ValidationStatusChange + .CombineLatest(Changed, (_, change) => change.PropertyName) + .Where(name => name != nameof(HasErrors)) + .Select(name => new DataErrorsChangedEventArgs(name)) + .Subscribe(args => ErrorsChanged?.Invoke(this, args)); + } + + /// + public event EventHandler ErrorsChanged; + + /// + public bool HasErrors => _hasErrors.Value; + + /// + public ValidationContext ValidationContext { get; } + + /// + /// Returns a collection of error messages, required by the INotifyDataErrorInfo interface. + /// + /// Property to search error notifications for. + /// A list of error messages, usually strings. + /// + public virtual IEnumerable GetErrors(string propertyName) + { + var memberInfoName = GetType() + .GetMember(propertyName) + .FirstOrDefault()? + .ToString(); + + if (memberInfoName == null) + { + return Enumerable.Empty(); + } + + var relatedPropertyValidations = ValidationContext + .Validations + .OfType>() + .Where(validation => validation.ContainsPropertyName(memberInfoName)); + + return relatedPropertyValidations + .Where(validation => !validation.IsValid) + .SelectMany(validation => validation.Text) + .ToList(); + } + } +} diff --git a/src/ReactiveUI.Validation/Platforms/Android/ViewForExtensions.cs b/src/ReactiveUI.Validation/Platforms/Android/ViewForExtensions.cs index f2ba4517..fe0f162f 100644 --- a/src/ReactiveUI.Validation/Platforms/Android/ViewForExtensions.cs +++ b/src/ReactiveUI.Validation/Platforms/Android/ViewForExtensions.cs @@ -45,7 +45,7 @@ public static IDisposable BindValidation( Expression> viewModelProperty, TextInputLayout viewProperty) where TView : IViewFor - where TViewModel : ReactiveObject, ISupportsValidation + where TViewModel : ReactiveObject, IValidatableViewModel { return ValidationBinding.ForProperty( view, @@ -73,7 +73,7 @@ public static IDisposable BindValidationEx> viewModelProperty, TextInputLayout viewProperty) where TView : IViewFor - where TViewModel : ReactiveObject, ISupportsValidation + where TViewModel : ReactiveObject, IValidatableViewModel { return ValidationBindingEx.ForProperty( view, @@ -103,7 +103,7 @@ public static IDisposable BindValidation( Expression> viewModelHelperProperty, TextInputLayout viewProperty) where TView : IViewFor - where TViewModel : ReactiveObject, ISupportsValidation + where TViewModel : ReactiveObject, IValidatableViewModel { return ValidationBinding.ForValidationHelperProperty( view, @@ -112,4 +112,4 @@ public static IDisposable BindValidation( SingleLineFormatter.Default); } } -} \ No newline at end of file +} diff --git a/src/ReactiveUI.Validation/ValidationBindings/ValidationBinding.cs b/src/ReactiveUI.Validation/ValidationBindings/ValidationBinding.cs index 3dee307f..7626a6d3 100755 --- a/src/ReactiveUI.Validation/ValidationBindings/ValidationBinding.cs +++ b/src/ReactiveUI.Validation/ValidationBindings/ValidationBinding.cs @@ -55,7 +55,7 @@ public static IValidationBinding ForProperty formatter = null, bool strict = true) where TView : IViewFor - where TViewModel : ReactiveObject, ISupportsValidation + where TViewModel : ReactiveObject, IValidatableViewModel { if (formatter == null) { @@ -102,7 +102,7 @@ public static IValidationBinding ForProperty formatter = null, bool strict = true) where TView : IViewFor - where TViewModel : ReactiveObject, ISupportsValidation + where TViewModel : ReactiveObject, IValidatableViewModel { if (formatter == null) { @@ -145,7 +145,7 @@ public static IValidationBinding ForValidationHelperProperty> viewProperty, IValidationTextFormatter formatter = null) where TView : IViewFor - where TViewModel : ReactiveObject, ISupportsValidation + where TViewModel : ReactiveObject, IValidatableViewModel { if (formatter == null) { @@ -188,7 +188,7 @@ public static IValidationBinding ForValidationHelperProperty action, IValidationTextFormatter formatter = null) where TView : IViewFor - where TViewModel : ReactiveObject, ISupportsValidation + where TViewModel : ReactiveObject, IValidatableViewModel { if (formatter == null) { @@ -229,7 +229,7 @@ public static IValidationBinding ForViewModel( Action action, IValidationTextFormatter formatter) where TView : IViewFor - where TViewModel : ReactiveObject, ISupportsValidation + where TViewModel : ReactiveObject, IValidatableViewModel { if (formatter == null) { @@ -266,7 +266,7 @@ public static IValidationBinding ForViewModel( Expression> viewProperty, IValidationTextFormatter formatter = null) where TView : IViewFor - where TViewModel : ReactiveObject, ISupportsValidation + where TViewModel : ReactiveObject, IValidatableViewModel { if (formatter == null) { diff --git a/src/ReactiveUI.Validation/ValidationBindings/ValidationBindingEx.cs b/src/ReactiveUI.Validation/ValidationBindings/ValidationBindingEx.cs index aa0e6bf5..c5d8abfd 100644 --- a/src/ReactiveUI.Validation/ValidationBindings/ValidationBindingEx.cs +++ b/src/ReactiveUI.Validation/ValidationBindings/ValidationBindingEx.cs @@ -52,7 +52,7 @@ public static IValidationBinding ForProperty formatter = null, bool strict = true) where TView : IViewFor - where TViewModel : ReactiveObject, ISupportsValidation + where TViewModel : ReactiveObject, IValidatableViewModel { if (formatter == null) { @@ -96,7 +96,7 @@ public static IValidationBinding ForProperty formatter = null, bool strict = true) where TView : IViewFor - where TViewModel : ReactiveObject, ISupportsValidation + where TViewModel : ReactiveObject, IValidatableViewModel { if (formatter == null) { @@ -148,7 +148,7 @@ private static IObservable BindToView setter(target, x.First(msg => !string.IsNullOrEmpty(msg)), viewExpression.GetArgumentsArray()), + x => setter(target, x.FirstOrDefault(msg => !string.IsNullOrEmpty(msg)) ?? string.Empty, viewExpression.GetArgumentsArray()), ex => LogHost.Default.Error(ex, $"{viewExpression} Binding received an Exception!")); }