From f64690d767dc1695374210b91e91aa6d68f27d81 Mon Sep 17 00:00:00 2001 From: Chris Pulman Date: Sun, 24 Mar 2024 22:03:36 +0000 Subject: [PATCH] Feature Add Validation to ReactiveProperty --- ...valTests.ReactiveUI.DotNet6_0.verified.txt | 28 +- ...valTests.ReactiveUI.DotNet7_0.verified.txt | 28 +- ...valTests.ReactiveUI.DotNet8_0.verified.txt | 28 +- ...provalTests.ReactiveUI.Net4_7.verified.txt | 28 +- .../Properties/Resources.Designer.cs | 81 ++++ .../Properties/Resources.resx | 126 +++++ .../Mocks/ReactivePropertyVM.cs | 54 +++ .../ReactiveProperty/ReactivePropertyTest.cs | 452 +++++++++++++++++- src/ReactiveUI.Tests/ReactiveUI.Tests.csproj | 11 + .../ReactiveProperty/IReactiveProperty.cs | 21 +- .../ReactiveProperty/ReactiveProperty.cs | 241 +++++++++- .../ReactivePropertyMixins.cs | 86 ++++ .../SingletonDataErrorsChangedEventArgs.cs | 11 + .../SingletonPropertyChangedEventArgs.cs | 13 + src/global.json | 5 +- version.json | 2 +- 16 files changed, 1186 insertions(+), 29 deletions(-) create mode 100644 src/ReactiveUI.Tests/Properties/Resources.Designer.cs create mode 100644 src/ReactiveUI.Tests/Properties/Resources.resx create mode 100644 src/ReactiveUI.Tests/ReactiveProperty/Mocks/ReactivePropertyVM.cs create mode 100644 src/ReactiveUI/ReactiveProperty/ReactivePropertyMixins.cs create mode 100644 src/ReactiveUI/ReactiveProperty/SingletonDataErrorsChangedEventArgs.cs create mode 100644 src/ReactiveUI/ReactiveProperty/SingletonPropertyChangedEventArgs.cs diff --git a/src/ReactiveUI.Tests/API/ApiApprovalTests.ReactiveUI.DotNet6_0.verified.txt b/src/ReactiveUI.Tests/API/ApiApprovalTests.ReactiveUI.DotNet6_0.verified.txt index afe71e7047..95a96c5b6c 100644 --- a/src/ReactiveUI.Tests/API/ApiApprovalTests.ReactiveUI.DotNet6_0.verified.txt +++ b/src/ReactiveUI.Tests/API/ApiApprovalTests.ReactiveUI.DotNet6_0.verified.txt @@ -349,9 +349,12 @@ namespace ReactiveUI string? PropertyName { get; } TSender Sender { get; } } - public interface IReactiveProperty : System.IDisposable, System.IObservable, System.Reactive.Disposables.ICancelable + public interface IReactiveProperty : System.ComponentModel.INotifyDataErrorInfo, System.ComponentModel.INotifyPropertyChanged, System.IDisposable, System.IObservable, System.Reactive.Disposables.ICancelable { + System.IObservable ObserveErrorChanged { get; } + System.IObservable ObserveHasErrors { get; } T Value { get; set; } + void Refresh(); } public interface IRoutableViewModel : ReactiveUI.IReactiveObject, Splat.IEnableLogger, System.ComponentModel.INotifyPropertyChanged, System.ComponentModel.INotifyPropertyChanging { @@ -753,18 +756,37 @@ namespace ReactiveUI public ReactivePropertyChangingEventArgs(TSender sender, string? propertyName) { } public TSender Sender { get; } } + public static class ReactivePropertyMixins + { + public static ReactiveUI.ReactiveProperty AddValidation(this ReactiveUI.ReactiveProperty self, System.Linq.Expressions.Expression?>> selfSelector) { } + public static System.IObservable ObserveValidationErrors(this ReactiveUI.ReactiveProperty self) { } + } [System.Runtime.Serialization.DataContract] - public class ReactiveProperty : ReactiveUI.ReactiveObject, ReactiveUI.IReactiveProperty, System.IDisposable, System.IObservable, System.Reactive.Disposables.ICancelable + public class ReactiveProperty : ReactiveUI.ReactiveObject, ReactiveUI.IReactiveProperty, System.ComponentModel.INotifyDataErrorInfo, System.ComponentModel.INotifyPropertyChanged, System.IDisposable, System.IObservable, System.Reactive.Disposables.ICancelable { public ReactiveProperty() { } public ReactiveProperty(T? initialValue) { } - public ReactiveProperty(T? initialValue, System.Reactive.Concurrency.IScheduler? scheduler) { } + public ReactiveProperty(T? initialValue, bool skipCurrentValueOnSubscribe, bool allowDuplicateValues) { } + public ReactiveProperty(T? initialValue, System.Reactive.Concurrency.IScheduler? scheduler, bool skipCurrentValueOnSubscribe, bool allowDuplicateValues) { } + public bool HasErrors { get; } public bool IsDisposed { get; } + public System.IObservable ObserveErrorChanged { get; } + public System.IObservable ObserveHasErrors { get; } [System.Runtime.Serialization.DataMember] [System.Text.Json.Serialization.JsonInclude] public T Value { get; set; } + public event System.EventHandler? ErrorsChanged; + public ReactiveUI.ReactiveProperty AddValidationError(System.Func, System.IObservable> validator, bool ignoreInitialError = false) { } + public ReactiveUI.ReactiveProperty AddValidationError(System.Func, System.IObservable> validator, bool ignoreInitialError = false) { } + public ReactiveUI.ReactiveProperty AddValidationError(System.Func validator, bool ignoreInitialError = false) { } + public ReactiveUI.ReactiveProperty AddValidationError(System.Func> validator, bool ignoreInitialError = false) { } + public ReactiveUI.ReactiveProperty AddValidationError(System.Func> validator, bool ignoreInitialError = false) { } + public ReactiveUI.ReactiveProperty AddValidationError(System.Func validator, bool ignoreInitialError = false) { } + public void CheckValidation() { } public void Dispose() { } protected virtual void Dispose(bool disposing) { } + public System.Collections.IEnumerable? GetErrors(string? propertyName) { } + public void Refresh() { } public System.IDisposable Subscribe(System.IObserver observer) { } } [System.Runtime.Serialization.DataContract] diff --git a/src/ReactiveUI.Tests/API/ApiApprovalTests.ReactiveUI.DotNet7_0.verified.txt b/src/ReactiveUI.Tests/API/ApiApprovalTests.ReactiveUI.DotNet7_0.verified.txt index 2ca65762ff..cfaa430cd5 100644 --- a/src/ReactiveUI.Tests/API/ApiApprovalTests.ReactiveUI.DotNet7_0.verified.txt +++ b/src/ReactiveUI.Tests/API/ApiApprovalTests.ReactiveUI.DotNet7_0.verified.txt @@ -349,9 +349,12 @@ namespace ReactiveUI string? PropertyName { get; } TSender Sender { get; } } - public interface IReactiveProperty : System.IDisposable, System.IObservable, System.Reactive.Disposables.ICancelable + public interface IReactiveProperty : System.ComponentModel.INotifyDataErrorInfo, System.ComponentModel.INotifyPropertyChanged, System.IDisposable, System.IObservable, System.Reactive.Disposables.ICancelable { + System.IObservable ObserveErrorChanged { get; } + System.IObservable ObserveHasErrors { get; } T Value { get; set; } + void Refresh(); } public interface IRoutableViewModel : ReactiveUI.IReactiveObject, Splat.IEnableLogger, System.ComponentModel.INotifyPropertyChanged, System.ComponentModel.INotifyPropertyChanging { @@ -753,18 +756,37 @@ namespace ReactiveUI public ReactivePropertyChangingEventArgs(TSender sender, string? propertyName) { } public TSender Sender { get; } } + public static class ReactivePropertyMixins + { + public static ReactiveUI.ReactiveProperty AddValidation(this ReactiveUI.ReactiveProperty self, System.Linq.Expressions.Expression?>> selfSelector) { } + public static System.IObservable ObserveValidationErrors(this ReactiveUI.ReactiveProperty self) { } + } [System.Runtime.Serialization.DataContract] - public class ReactiveProperty : ReactiveUI.ReactiveObject, ReactiveUI.IReactiveProperty, System.IDisposable, System.IObservable, System.Reactive.Disposables.ICancelable + public class ReactiveProperty : ReactiveUI.ReactiveObject, ReactiveUI.IReactiveProperty, System.ComponentModel.INotifyDataErrorInfo, System.ComponentModel.INotifyPropertyChanged, System.IDisposable, System.IObservable, System.Reactive.Disposables.ICancelable { public ReactiveProperty() { } public ReactiveProperty(T? initialValue) { } - public ReactiveProperty(T? initialValue, System.Reactive.Concurrency.IScheduler? scheduler) { } + public ReactiveProperty(T? initialValue, bool skipCurrentValueOnSubscribe, bool allowDuplicateValues) { } + public ReactiveProperty(T? initialValue, System.Reactive.Concurrency.IScheduler? scheduler, bool skipCurrentValueOnSubscribe, bool allowDuplicateValues) { } + public bool HasErrors { get; } public bool IsDisposed { get; } + public System.IObservable ObserveErrorChanged { get; } + public System.IObservable ObserveHasErrors { get; } [System.Runtime.Serialization.DataMember] [System.Text.Json.Serialization.JsonInclude] public T Value { get; set; } + public event System.EventHandler? ErrorsChanged; + public ReactiveUI.ReactiveProperty AddValidationError(System.Func, System.IObservable> validator, bool ignoreInitialError = false) { } + public ReactiveUI.ReactiveProperty AddValidationError(System.Func, System.IObservable> validator, bool ignoreInitialError = false) { } + public ReactiveUI.ReactiveProperty AddValidationError(System.Func validator, bool ignoreInitialError = false) { } + public ReactiveUI.ReactiveProperty AddValidationError(System.Func> validator, bool ignoreInitialError = false) { } + public ReactiveUI.ReactiveProperty AddValidationError(System.Func> validator, bool ignoreInitialError = false) { } + public ReactiveUI.ReactiveProperty AddValidationError(System.Func validator, bool ignoreInitialError = false) { } + public void CheckValidation() { } public void Dispose() { } protected virtual void Dispose(bool disposing) { } + public System.Collections.IEnumerable? GetErrors(string? propertyName) { } + public void Refresh() { } public System.IDisposable Subscribe(System.IObserver observer) { } } [System.Runtime.Serialization.DataContract] diff --git a/src/ReactiveUI.Tests/API/ApiApprovalTests.ReactiveUI.DotNet8_0.verified.txt b/src/ReactiveUI.Tests/API/ApiApprovalTests.ReactiveUI.DotNet8_0.verified.txt index 6e4fff54e9..f133d6ca9d 100644 --- a/src/ReactiveUI.Tests/API/ApiApprovalTests.ReactiveUI.DotNet8_0.verified.txt +++ b/src/ReactiveUI.Tests/API/ApiApprovalTests.ReactiveUI.DotNet8_0.verified.txt @@ -349,9 +349,12 @@ namespace ReactiveUI string? PropertyName { get; } TSender Sender { get; } } - public interface IReactiveProperty : System.IDisposable, System.IObservable, System.Reactive.Disposables.ICancelable + public interface IReactiveProperty : System.ComponentModel.INotifyDataErrorInfo, System.ComponentModel.INotifyPropertyChanged, System.IDisposable, System.IObservable, System.Reactive.Disposables.ICancelable { + System.IObservable ObserveErrorChanged { get; } + System.IObservable ObserveHasErrors { get; } T Value { get; set; } + void Refresh(); } public interface IRoutableViewModel : ReactiveUI.IReactiveObject, Splat.IEnableLogger, System.ComponentModel.INotifyPropertyChanged, System.ComponentModel.INotifyPropertyChanging { @@ -753,18 +756,37 @@ namespace ReactiveUI public ReactivePropertyChangingEventArgs(TSender sender, string? propertyName) { } public TSender Sender { get; } } + public static class ReactivePropertyMixins + { + public static ReactiveUI.ReactiveProperty AddValidation(this ReactiveUI.ReactiveProperty self, System.Linq.Expressions.Expression?>> selfSelector) { } + public static System.IObservable ObserveValidationErrors(this ReactiveUI.ReactiveProperty self) { } + } [System.Runtime.Serialization.DataContract] - public class ReactiveProperty : ReactiveUI.ReactiveObject, ReactiveUI.IReactiveProperty, System.IDisposable, System.IObservable, System.Reactive.Disposables.ICancelable + public class ReactiveProperty : ReactiveUI.ReactiveObject, ReactiveUI.IReactiveProperty, System.ComponentModel.INotifyDataErrorInfo, System.ComponentModel.INotifyPropertyChanged, System.IDisposable, System.IObservable, System.Reactive.Disposables.ICancelable { public ReactiveProperty() { } public ReactiveProperty(T? initialValue) { } - public ReactiveProperty(T? initialValue, System.Reactive.Concurrency.IScheduler? scheduler) { } + public ReactiveProperty(T? initialValue, bool skipCurrentValueOnSubscribe, bool allowDuplicateValues) { } + public ReactiveProperty(T? initialValue, System.Reactive.Concurrency.IScheduler? scheduler, bool skipCurrentValueOnSubscribe, bool allowDuplicateValues) { } + public bool HasErrors { get; } public bool IsDisposed { get; } + public System.IObservable ObserveErrorChanged { get; } + public System.IObservable ObserveHasErrors { get; } [System.Runtime.Serialization.DataMember] [System.Text.Json.Serialization.JsonInclude] public T Value { get; set; } + public event System.EventHandler? ErrorsChanged; + public ReactiveUI.ReactiveProperty AddValidationError(System.Func, System.IObservable> validator, bool ignoreInitialError = false) { } + public ReactiveUI.ReactiveProperty AddValidationError(System.Func, System.IObservable> validator, bool ignoreInitialError = false) { } + public ReactiveUI.ReactiveProperty AddValidationError(System.Func validator, bool ignoreInitialError = false) { } + public ReactiveUI.ReactiveProperty AddValidationError(System.Func> validator, bool ignoreInitialError = false) { } + public ReactiveUI.ReactiveProperty AddValidationError(System.Func> validator, bool ignoreInitialError = false) { } + public ReactiveUI.ReactiveProperty AddValidationError(System.Func validator, bool ignoreInitialError = false) { } + public void CheckValidation() { } public void Dispose() { } protected virtual void Dispose(bool disposing) { } + public System.Collections.IEnumerable? GetErrors(string? propertyName) { } + public void Refresh() { } public System.IDisposable Subscribe(System.IObserver observer) { } } [System.Runtime.Serialization.DataContract] diff --git a/src/ReactiveUI.Tests/API/ApiApprovalTests.ReactiveUI.Net4_7.verified.txt b/src/ReactiveUI.Tests/API/ApiApprovalTests.ReactiveUI.Net4_7.verified.txt index b6b8144d15..931b9b2fb7 100644 --- a/src/ReactiveUI.Tests/API/ApiApprovalTests.ReactiveUI.Net4_7.verified.txt +++ b/src/ReactiveUI.Tests/API/ApiApprovalTests.ReactiveUI.Net4_7.verified.txt @@ -347,9 +347,12 @@ namespace ReactiveUI string? PropertyName { get; } TSender Sender { get; } } - public interface IReactiveProperty : System.IDisposable, System.IObservable, System.Reactive.Disposables.ICancelable + public interface IReactiveProperty : System.ComponentModel.INotifyDataErrorInfo, System.ComponentModel.INotifyPropertyChanged, System.IDisposable, System.IObservable, System.Reactive.Disposables.ICancelable { + System.IObservable ObserveErrorChanged { get; } + System.IObservable ObserveHasErrors { get; } T Value { get; set; } + void Refresh(); } public interface IRoutableViewModel : ReactiveUI.IReactiveObject, Splat.IEnableLogger, System.ComponentModel.INotifyPropertyChanged, System.ComponentModel.INotifyPropertyChanging { @@ -751,18 +754,37 @@ namespace ReactiveUI public ReactivePropertyChangingEventArgs(TSender sender, string? propertyName) { } public TSender Sender { get; } } + public static class ReactivePropertyMixins + { + public static ReactiveUI.ReactiveProperty AddValidation(this ReactiveUI.ReactiveProperty self, System.Linq.Expressions.Expression?>> selfSelector) { } + public static System.IObservable ObserveValidationErrors(this ReactiveUI.ReactiveProperty self) { } + } [System.Runtime.Serialization.DataContract] - public class ReactiveProperty : ReactiveUI.ReactiveObject, ReactiveUI.IReactiveProperty, System.IDisposable, System.IObservable, System.Reactive.Disposables.ICancelable + public class ReactiveProperty : ReactiveUI.ReactiveObject, ReactiveUI.IReactiveProperty, System.ComponentModel.INotifyDataErrorInfo, System.ComponentModel.INotifyPropertyChanged, System.IDisposable, System.IObservable, System.Reactive.Disposables.ICancelable { public ReactiveProperty() { } public ReactiveProperty(T? initialValue) { } - public ReactiveProperty(T? initialValue, System.Reactive.Concurrency.IScheduler? scheduler) { } + public ReactiveProperty(T? initialValue, bool skipCurrentValueOnSubscribe, bool allowDuplicateValues) { } + public ReactiveProperty(T? initialValue, System.Reactive.Concurrency.IScheduler? scheduler, bool skipCurrentValueOnSubscribe, bool allowDuplicateValues) { } + public bool HasErrors { get; } public bool IsDisposed { get; } + public System.IObservable ObserveErrorChanged { get; } + public System.IObservable ObserveHasErrors { get; } [System.Runtime.Serialization.DataMember] [System.Text.Json.Serialization.JsonInclude] public T Value { get; set; } + public event System.EventHandler? ErrorsChanged; + public ReactiveUI.ReactiveProperty AddValidationError(System.Func, System.IObservable> validator, bool ignoreInitialError = false) { } + public ReactiveUI.ReactiveProperty AddValidationError(System.Func, System.IObservable> validator, bool ignoreInitialError = false) { } + public ReactiveUI.ReactiveProperty AddValidationError(System.Func validator, bool ignoreInitialError = false) { } + public ReactiveUI.ReactiveProperty AddValidationError(System.Func> validator, bool ignoreInitialError = false) { } + public ReactiveUI.ReactiveProperty AddValidationError(System.Func> validator, bool ignoreInitialError = false) { } + public ReactiveUI.ReactiveProperty AddValidationError(System.Func validator, bool ignoreInitialError = false) { } + public void CheckValidation() { } public void Dispose() { } protected virtual void Dispose(bool disposing) { } + public System.Collections.IEnumerable? GetErrors(string? propertyName) { } + public void Refresh() { } public System.IDisposable Subscribe(System.IObserver observer) { } } [System.Runtime.Serialization.DataContract] diff --git a/src/ReactiveUI.Tests/Properties/Resources.Designer.cs b/src/ReactiveUI.Tests/Properties/Resources.Designer.cs new file mode 100644 index 0000000000..47f85b0001 --- /dev/null +++ b/src/ReactiveUI.Tests/Properties/Resources.Designer.cs @@ -0,0 +1,81 @@ +//------------------------------------------------------------------------------ +// +// This code was generated by a tool. +// Runtime Version:4.0.30319.42000 +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +//------------------------------------------------------------------------------ + +namespace ReactiveUI.Tests.Properties { + using System; + + + /// + /// A strongly-typed resource class, for looking up localized strings, etc. + /// + // This class was auto-generated by the StronglyTypedResourceBuilder + // class via a tool like ResGen or Visual Studio. + // To add or remove a member, edit your .ResX file then rerun ResGen + // with the /str option, or rebuild your VS project. + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "17.0.0.0")] + [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] + [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] + public class Resources { + + private static global::System.Resources.ResourceManager resourceMan; + + private static global::System.Globalization.CultureInfo resourceCulture; + + [global::System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")] + internal Resources() { + } + + /// + /// Returns the cached ResourceManager instance used by this class. + /// + [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] + public static global::System.Resources.ResourceManager ResourceManager { + get { + if (object.ReferenceEquals(resourceMan, null)) { + global::System.Resources.ResourceManager temp = new global::System.Resources.ResourceManager("ReactiveUI.Tests.Properties.Resources", typeof(Resources).Assembly); + resourceMan = temp; + } + return resourceMan; + } + } + + /// + /// Overrides the current thread's CurrentUICulture property for all + /// resource lookups using this strongly typed resource class. + /// + [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] + public static global::System.Globalization.CultureInfo Culture { + get { + return resourceCulture; + } + set { + resourceCulture = value; + } + } + + /// + /// Looks up a localized string similar to Oops!? {0} is required.. + /// + public static string ValidationErrorMessage { + get { + return ResourceManager.GetString("ValidationErrorMessage", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to FromResource. + /// + public static string ValidationTargetPropertyName { + get { + return ResourceManager.GetString("ValidationTargetPropertyName", resourceCulture); + } + } + } +} diff --git a/src/ReactiveUI.Tests/Properties/Resources.resx b/src/ReactiveUI.Tests/Properties/Resources.resx new file mode 100644 index 0000000000..394f38f4f2 --- /dev/null +++ b/src/ReactiveUI.Tests/Properties/Resources.resx @@ -0,0 +1,126 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Oops!? {0} is required. + + + FromResource + + \ No newline at end of file diff --git a/src/ReactiveUI.Tests/ReactiveProperty/Mocks/ReactivePropertyVM.cs b/src/ReactiveUI.Tests/ReactiveProperty/Mocks/ReactivePropertyVM.cs new file mode 100644 index 0000000000..3cb46bd8b8 --- /dev/null +++ b/src/ReactiveUI.Tests/ReactiveProperty/Mocks/ReactivePropertyVM.cs @@ -0,0 +1,54 @@ +// Copyright (c) 2023 .NET Foundation and Contributors. All rights reserved. +// 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 full license information. + +using System.ComponentModel.DataAnnotations; +using ReactiveUI.Tests.Properties; + +namespace ReactiveUI.Tests.ReactiveProperty.Mocks +{ + internal class ReactivePropertyVM : ReactiveObject + { + public ReactivePropertyVM() + { + IsRequiredProperty = new ReactiveProperty() + .AddValidation(() => IsRequiredProperty); + + LengthLessThanFiveProperty = new ReactiveProperty() + .AddValidation(() => LengthLessThanFiveProperty) + .AddValidationError(s => string.IsNullOrWhiteSpace(s) ? "required" : null); + + TaskValidationTestProperty = new ReactiveProperty() + .AddValidationError(async s => await Task.FromResult(string.IsNullOrWhiteSpace(s) ? "required" : default)); + + CustomValidationErrorMessageProperty = new ReactiveProperty() + .AddValidation(() => CustomValidationErrorMessageProperty); + + CustomValidationErrorMessageWithDisplayNameProperty = new ReactiveProperty() + .AddValidation(() => CustomValidationErrorMessageWithDisplayNameProperty); + + CustomValidationErrorMessageWithResourceProperty = new ReactiveProperty() + .AddValidation(() => CustomValidationErrorMessageWithResourceProperty); + } + + [Required(ErrorMessage = "error!")] + public ReactiveProperty IsRequiredProperty { get; } + + [StringLength(5, ErrorMessage = "5over")] + public ReactiveProperty LengthLessThanFiveProperty { get; } + + public ReactiveProperty TaskValidationTestProperty { get; } + + [Required(ErrorMessage = "Custom validation error message for {0}")] + public ReactiveProperty CustomValidationErrorMessageProperty { get; } + + [Required(ErrorMessage = "Custom validation error message for {0}")] + [Display(Name = "CustomName")] + public ReactiveProperty CustomValidationErrorMessageWithDisplayNameProperty { get; } + + [Required(ErrorMessageResourceType = typeof(Resources), ErrorMessageResourceName = nameof(Resources.ValidationErrorMessage))] + [Display(ResourceType = typeof(Resources), Name = nameof(Resources.ValidationTargetPropertyName))] + public ReactiveProperty CustomValidationErrorMessageWithResourceProperty { get; } + } +} diff --git a/src/ReactiveUI.Tests/ReactiveProperty/ReactivePropertyTest.cs b/src/ReactiveUI.Tests/ReactiveProperty/ReactivePropertyTest.cs index 3338337fb2..90eda295f9 100644 --- a/src/ReactiveUI.Tests/ReactiveProperty/ReactivePropertyTest.cs +++ b/src/ReactiveUI.Tests/ReactiveProperty/ReactivePropertyTest.cs @@ -3,26 +3,466 @@ // The .NET Foundation licenses this file to you under the MIT license. // See the LICENSE file in the project root for full license information. +using System.Collections; +using FluentAssertions; using Microsoft.Reactive.Testing; +using ReactiveUI.Testing; +using ReactiveUI.Tests.ReactiveProperty.Mocks; namespace ReactiveUI.Tests.ReactiveProperty { public class ReactivePropertyTest : ReactiveTest { [Fact] - public void NormalCase() + public void DefaultValueIsRaisedOnSubscribe() { var rp = new ReactiveProperty(); - Assert.Null(rp.Value); - rp.Subscribe(x => Assert.Null(x)); + rp.Value.Should().BeNull(); + rp.Subscribe(Assert.Null); } [Fact] public void InitialValue() { - var rp = new ReactiveProperty("Hello world"); - Assert.Equal(rp.Value, "Hello world"); - rp.Subscribe(x => Assert.Equal(x, "Hello world")); + var rp = new ReactiveProperty("ReactiveUI"); + Assert.Equal(rp.Value, "ReactiveUI"); + rp.Subscribe(x => Assert.Equal(x, "ReactiveUI")); + } + + [Fact] + public void InitialValueSkipCurrent() + { + var rp = new ReactiveProperty("ReactiveUI", true, false); + Assert.Equal(rp.Value, "ReactiveUI"); + + // current value should be skipped + rp.Subscribe(x => Assert.Equal(x, "ReactiveUI 2")); + rp.Value = "ReactiveUI 2"; + Assert.Equal(rp.Value, "ReactiveUI 2"); + } + + [Fact] + public void SetValueRaisesEvents() + { + var rp = new ReactiveProperty(); + rp.Value.Should().BeNull(); + rp.Value = "ReactiveUI"; + Assert.Equal(rp.Value, "ReactiveUI"); + rp.Subscribe(x => Assert.Equal(x, "ReactiveUI")); + } + + [Fact] + public void ValidationLengthIsCorrectlyHandled() + { + var target = new ReactivePropertyVM(); + IEnumerable? error = default; + target.LengthLessThanFiveProperty + .ObserveErrorChanged + .Subscribe(x => error = x); + + target.LengthLessThanFiveProperty.HasErrors.Should().BeTrue(); + Assert.Equal(error?.OfType().First(), "required"); + + target.LengthLessThanFiveProperty.Value = "a"; + target.LengthLessThanFiveProperty.HasErrors.Should().BeFalse(); + error.Should().BeNull(); + + target.LengthLessThanFiveProperty.Value = "aaaaaa"; + target.LengthLessThanFiveProperty.HasErrors.Should().BeTrue(); + error.Should().NotBeNull(); + Assert.Equal(error?.OfType().First(), "5over"); + + target.LengthLessThanFiveProperty.Value = null; + target.LengthLessThanFiveProperty.HasErrors.Should().BeTrue(); + Assert.Equal(error?.OfType().First(), "required"); + } + + [Fact] + public void ValidationIsRequiredIsCorrectlyHandled() + { + var target = new ReactivePropertyVM(); + var errors = new List(); + target.IsRequiredProperty + .ObserveErrorChanged + .Where(x => x != null) + .Subscribe(errors.Add); + + errors.Count.Should().Be(1); + errors[0]?.Cast().Should().Equal("error!"); + target.IsRequiredProperty.HasErrors.Should().BeTrue(); + + target.IsRequiredProperty.Value = "a"; + errors.Count.Should().Be(1); + target.IsRequiredProperty.HasErrors.Should().BeFalse(); + + target.IsRequiredProperty.Value = null; + errors.Count.Should().Be(2); + errors[1]?.Cast().Should().Equal("error!"); + target.IsRequiredProperty.HasErrors.Should().BeTrue(); + } + + [Fact] + public void ValidationTaskTest() + { + var target = new ReactivePropertyVM(); + var errors = new List(); + target.TaskValidationTestProperty + .ObserveErrorChanged + .Where(x => x != null) + .Subscribe(errors.Add); + errors.Count.Should().Be(1); + errors[0]?.OfType().Should().Equal("required"); + + target.TaskValidationTestProperty.Value = "a"; + target.TaskValidationTestProperty.HasErrors.Should().BeFalse(); + errors.Count.Should().Be(1); + + target.TaskValidationTestProperty.Value = null; + target.TaskValidationTestProperty.HasErrors.Should().BeTrue(); + errors.Count.Should().Be(2); + } + + [Fact] + public void ValidationWithCustomErrorMessage() + { + var target = new ReactivePropertyVM(); + target.CustomValidationErrorMessageProperty.Value = string.Empty; + var errorMessage = target? + .CustomValidationErrorMessageProperty? + .GetErrors(nameof(ReactivePropertyVM.CustomValidationErrorMessageProperty))! + .Cast() + .First(); + + Assert.Equal(errorMessage, "Custom validation error message for CustomValidationErrorMessageProperty"); + } + + [Fact] + public void ValidationWithCustomErrorMessageWithDisplayName() + { + var target = new ReactivePropertyVM(); + target.CustomValidationErrorMessageWithDisplayNameProperty.Value = string.Empty; + var errorMessage = target + .CustomValidationErrorMessageWithDisplayNameProperty? + .GetErrors(nameof(ReactivePropertyVM.CustomValidationErrorMessageWithDisplayNameProperty))! + .Cast() + .First(); + + Assert.Equal(errorMessage, "Custom validation error message for CustomName"); + } + + [Fact] + public void ValidationWithCustomErrorMessageWithResource() + { + var target = new ReactivePropertyVM(); + target.CustomValidationErrorMessageWithResourceProperty.Value = string.Empty; + var errorMessage = target + .CustomValidationErrorMessageWithResourceProperty? + .GetErrors(nameof(ReactivePropertyVM.CustomValidationErrorMessageWithResourceProperty))! + .Cast() + .First(); + + Assert.Equal(errorMessage, "Oops!? FromResource is required."); + } + + [Fact] + public async Task ValidationWithAsyncSuccessCase() + { + var tcs = new TaskCompletionSource(); + var rp = new ReactiveProperty().AddValidationError(_ => tcs.Task); + + IEnumerable? error = null; + rp.ObserveErrorChanged.Subscribe(x => error = x); + + rp.HasErrors.Should().BeFalse(); + error.Should().BeNull(); + + rp.Value = "dummy"; + tcs.SetResult(null); + await Task.Yield(); + + rp.HasErrors.Should().BeFalse(); + error.Should().BeNull(); + } + + [Fact] + public async Task ValidationWithAsyncFailedCase() + { + var tcs = new TaskCompletionSource(); + var rp = new ReactiveProperty().AddValidationError(_ => tcs.Task); + + IEnumerable? error = null; + rp.ObserveErrorChanged.Subscribe(x => error = x); + + rp.HasErrors.Should().BeFalse(); + error.Should().BeNull(); + + var errorMessage = "error occured!!"; + rp.Value = "dummy"; //--- push value + tcs.SetResult(errorMessage); //--- validation error! + await Task.Delay(10); + + rp.HasErrors.Should().BeTrue(); + error.Should().NotBeNull(); + error?.Cast().Should().Equal(errorMessage); + rp.GetErrors("Value")?.Cast().Should().Equal(errorMessage); + } + + [Fact] + public void ValidationWithAsyncThrottleTest() + { + var scheduler = new TestScheduler(); + var rp = new ReactiveProperty() + .AddValidationError(xs => xs + .Throttle(TimeSpan.FromSeconds(1), scheduler) + .Select(x => string.IsNullOrEmpty(x) ? "required" : null)); + + IEnumerable? error = null; + rp.ObserveErrorChanged.Subscribe(x => error = x); + + scheduler.AdvanceTo(TimeSpan.FromMilliseconds(0).Ticks); + rp.Value = string.Empty; + rp.HasErrors.Should().BeFalse(); + error.Should().BeNull(); + + scheduler.AdvanceTo(TimeSpan.FromMilliseconds(300).Ticks); + rp.Value = "a"; + rp.HasErrors.Should().BeFalse(); + error.Should().BeNull(); + + scheduler.AdvanceTo(TimeSpan.FromMilliseconds(700).Ticks); + rp.Value = "b"; + rp.HasErrors.Should().BeFalse(); + error.Should().BeNull(); + + scheduler.AdvanceTo(TimeSpan.FromMilliseconds(1100).Ticks); + rp.Value = string.Empty; + rp.HasErrors.Should().BeFalse(); + error.Should().BeNull(); + + scheduler.AdvanceTo(TimeSpan.FromMilliseconds(2500).Ticks); + rp.HasErrors.Should().BeTrue(); + error.Should().NotBeNull(); + error?.Cast().Should().Equal("required"); + } + + [Fact] + public void ValidationErrorChangedTest() + { + var errors = new List(); + var rprop = new ReactiveProperty() + .AddValidationError(x => string.IsNullOrWhiteSpace(x) ? "error" : null); + + // old version behavior + rprop.ObserveErrorChanged.Skip(1).Subscribe(errors.Add); + + errors.Count.Should().Be(0); + + rprop.Value = "OK"; + errors.Count.Should().Be(1); + errors.Last().Should().BeNull(); + + rprop.Value = null; + errors.Count.Should().Be(2); + errors.Last()?.OfType().Should().Equal("error"); + } + + [Fact] + public void ValidationIgnoreInitialErrorAndRefresh() + { + var rp = new ReactiveProperty() + .AddValidationError(x => string.IsNullOrEmpty(x) ? "error" : null, true); + + rp.HasErrors.Should().BeFalse(); + rp.Refresh(); + rp.HasErrors.Should().BeTrue(); + } + + [Fact] + public void IgnoreInitialErrorAndCheckValidation() + { + var rp = new ReactiveProperty() + .AddValidationError(x => string.IsNullOrEmpty(x) ? "error" : null, true); + + rp.HasErrors.Should().BeFalse(); + rp.CheckValidation(); + rp.HasErrors.Should().BeTrue(); + } + + [Fact] + public void IgnoreInitErrorAndUpdateValue() + { + var rp = new ReactiveProperty() + .AddValidationError(x => string.IsNullOrEmpty(x) ? "error" : null, true); + + rp.HasErrors.Should().BeFalse(); + rp.Value = string.Empty; + rp.HasErrors.Should().BeTrue(); + } + + [Fact] + public void ObserveErrors() + { + var rp = new ReactiveProperty() + .AddValidationError(x => x == null ? "Error" : null); + + var results = new List(); + rp.ObserveErrorChanged.Subscribe(results.Add); + rp.Value = "OK"; + + results.Count.Should().Be(2); + results[0]?.OfType().Should().Equal("Error"); + results[1].Should().BeNull(); + } + + [Fact] + public void ObserveHasError() + { + var rp = new ReactiveProperty() + .AddValidationError(x => x == null ? "Error" : null); + + var results = new List(); + rp.ObserveHasErrors.Subscribe(x => results.Add(x)); + rp.Value = "OK"; + + results.Count.Should().Be(2); + results[0].Should().BeTrue(); + results[1].Should().BeFalse(); + } + + [Fact] + public void CheckValidation() + { + var minValue = 0; + var rp = new ReactiveProperty(0) + .AddValidationError(x => x < minValue ? "Error" : null); + rp.GetErrors("Value").Should().BeNull(); + + minValue = 1; + rp.GetErrors("Value").Should().BeNull(); + + rp.CheckValidation(); + rp.GetErrors("Value")?.OfType().Should().Equal("Error"); + } + + [Fact] + public async Task ValueUpdatesMultipleTimesWithDifferentValues() + { + using var testSequencer = new TestSequencer(); + var rp = new ReactiveProperty(0); + var collector = new List(); + rp.Subscribe(async x => + { + collector.Add(x); + await testSequencer.AdvancePhaseAsync(); + }); + + rp.Value.Should().Be(0); + await testSequencer.AdvancePhaseAsync(); + collector.Should().Equal(0); + rp.Value = 1; + rp.Value.Should().Be(1); + await testSequencer.AdvancePhaseAsync(); + collector.Should().Equal(0, 1); + rp.Value = 2; + rp.Value.Should().Be(2); + await testSequencer.AdvancePhaseAsync(); + collector.Should().Equal(0, 1, 2); + rp.Value = 3; + rp.Value.Should().Be(3); + await testSequencer.AdvancePhaseAsync(); + collector.Should().Equal(0, 1, 2, 3); + } + + [Fact] + public async Task Refresh() + { + using var testSequencer = new TestSequencer(); + var rp = new ReactiveProperty(0); + var collector = new List(); + rp.Subscribe(async x => + { + collector.Add(x); + await testSequencer.AdvancePhaseAsync(); + }); + + await testSequencer.AdvancePhaseAsync(); + collector.Should().Equal(0); + rp.Refresh(); + await testSequencer.AdvancePhaseAsync(); + collector.Should().Equal(0, 0); + } + + [Fact] + public async Task ValueUpdatesMultipleTimesWithSameValues() + { + using var testSequencer = new TestSequencer(); + var rp = new ReactiveProperty(0, false, true); + var collector = new List(); + rp.Subscribe(async x => + { + collector.Add(x); + await testSequencer.AdvancePhaseAsync(); + }); + + rp.Value.Should().Be(0); + await testSequencer.AdvancePhaseAsync(); + collector.Should().Equal(0); + rp.Value = 0; + rp.Value.Should().Be(0); + await testSequencer.AdvancePhaseAsync(); + collector.Should().Equal(0, 0); + rp.Value = 0; + rp.Value.Should().Be(0); + await testSequencer.AdvancePhaseAsync(); + collector.Should().Equal(0, 0, 0); + rp.Value = 0; + rp.Value.Should().Be(0); + await testSequencer.AdvancePhaseAsync(); + collector.Should().Equal(0, 0, 0, 0); + } + + [Fact] + public async Task MultipleSubscribersGetCurrentValue() + { + using var testSequencer1 = new TestSequencer(); + using var testSequencer2 = new TestSequencer(); + var rp = new ReactiveProperty(0); + var collector1 = new List(); + var collector2 = new List(); + rp.Subscribe(async x => + { + collector1.Add(x); + await testSequencer1.AdvancePhaseAsync(); + }); + + rp.Value.Should().Be(0); + await testSequencer1.AdvancePhaseAsync(); + collector1.Should().Equal(0); + rp.Value = 1; + rp.Value.Should().Be(1); + await testSequencer1.AdvancePhaseAsync(); + collector1.Should().Equal(0, 1); + rp.Value = 2; + rp.Value.Should().Be(2); + await testSequencer1.AdvancePhaseAsync(); + collector1.Should().Equal(0, 1, 2); + + // second subscriber + rp.Subscribe(async x => + { + collector2.Add(x); + await testSequencer2.AdvancePhaseAsync(); + }); + rp.Value.Should().Be(2); + await testSequencer2.AdvancePhaseAsync(); + collector2.Should().Equal(2); + + rp.Value = 3; + rp.Value.Should().Be(3); + await testSequencer1.AdvancePhaseAsync(); + collector1.Should().Equal(0, 1, 2, 3); + await testSequencer2.AdvancePhaseAsync(); + collector2.Should().Equal(2, 3); } } } diff --git a/src/ReactiveUI.Tests/ReactiveUI.Tests.csproj b/src/ReactiveUI.Tests/ReactiveUI.Tests.csproj index 28b4b7af6d..58b2ddac27 100644 --- a/src/ReactiveUI.Tests/ReactiveUI.Tests.csproj +++ b/src/ReactiveUI.Tests/ReactiveUI.Tests.csproj @@ -73,6 +73,17 @@ True TestFormNotCanActivate.resx + + True + True + Resources.resx + + + + + PublicResXFileCodeGenerator + Resources.Designer.cs + diff --git a/src/ReactiveUI/ReactiveProperty/IReactiveProperty.cs b/src/ReactiveUI/ReactiveProperty/IReactiveProperty.cs index 22d3f38566..b2d6cae722 100644 --- a/src/ReactiveUI/ReactiveProperty/IReactiveProperty.cs +++ b/src/ReactiveUI/ReactiveProperty/IReactiveProperty.cs @@ -3,6 +3,8 @@ // The .NET Foundation licenses this file to you under the MIT license. // See the LICENSE file in the project root for full license information. +using System.Collections; + namespace ReactiveUI; /// @@ -11,7 +13,7 @@ namespace ReactiveUI; /// The type of the property. /// /// -public interface IReactiveProperty : IObservable, ICancelable +public interface IReactiveProperty : IObservable, ICancelable, INotifyDataErrorInfo, INotifyPropertyChanged { /// /// Gets or sets the value. @@ -20,4 +22,21 @@ public interface IReactiveProperty : IObservable, ICancelable /// The value. /// public T? Value { get; set; } + + /// + /// Gets the observe error changed. + /// + /// The observe error changed. + IObservable ObserveErrorChanged { get; } + + /// + /// Gets the observe has errors. + /// + /// The observe has errors. + IObservable ObserveHasErrors { get; } + + /// + /// Refreshes this instance. + /// + void Refresh(); } diff --git a/src/ReactiveUI/ReactiveProperty/ReactiveProperty.cs b/src/ReactiveUI/ReactiveProperty/ReactiveProperty.cs index dedea1a4cd..8740e0c182 100644 --- a/src/ReactiveUI/ReactiveProperty/ReactiveProperty.cs +++ b/src/ReactiveUI/ReactiveProperty/ReactiveProperty.cs @@ -3,6 +3,8 @@ // The .NET Foundation licenses this file to you under the MIT license. // See the LICENSE file in the project root for full license information. +using System.Collections; + namespace ReactiveUI; /// @@ -16,34 +18,66 @@ public class ReactiveProperty : ReactiveObject, IReactiveProperty { private readonly IScheduler _scheduler; private readonly CompositeDisposable _disposables = []; + private readonly EqualityComparer _checkIf = EqualityComparer.Default; + private readonly Subject _checkValidation = new(); + private readonly Subject _valueRefereshed = new(); + private readonly SerialDisposable _validationDisposable = new(); + private readonly Lazy> _errorChanged; + private readonly Lazy, IObservable>>> _validatorStore = new(() => []); + private readonly int _skipCurrentValue; + private readonly bool _isDistinctUntilChanged; private T? _value; + private IEnumerable? _currentErrors; /// /// Initializes a new instance of the class. + /// The Value will be default(T). DistinctUntilChanged is true. Current Value is published on subscribe. /// - public ReactiveProperty() => _scheduler = RxApp.TaskpoolScheduler; + public ReactiveProperty() + : this(default, RxApp.TaskpoolScheduler, false, false) + { + } /// /// Initializes a new instance of the class. + /// The Value will be initialValue. DistinctUntilChanged is true. Current Value is published on subscribe. /// /// The initial value. public ReactiveProperty(T? initialValue) + : this(initialValue, RxApp.TaskpoolScheduler, false, false) { - Value = initialValue; - _scheduler = RxApp.TaskpoolScheduler; } /// - /// Initializes a new instance of the class. + /// Initializes a new instance of the class. + /// + /// The initial value. + /// if set to true [skip current value on subscribe]. + /// if set to true [allow duplicate concurrent values]. + public ReactiveProperty(T? initialValue, bool skipCurrentValueOnSubscribe, bool allowDuplicateValues) + : this(initialValue, RxApp.TaskpoolScheduler, skipCurrentValueOnSubscribe, allowDuplicateValues) + { + } + + /// + /// Initializes a new instance of the class. /// /// The initial value. /// The scheduler. - public ReactiveProperty(T? initialValue, IScheduler? scheduler) + /// if set to true [skip current value on subscribe]. + /// if set to true [allow duplicate concurrent values]. + public ReactiveProperty(T? initialValue, IScheduler? scheduler, bool skipCurrentValueOnSubscribe, bool allowDuplicateValues) { + _skipCurrentValue = skipCurrentValueOnSubscribe ? 1 : 0; + _isDistinctUntilChanged = !allowDuplicateValues; Value = initialValue; _scheduler = scheduler ?? RxApp.TaskpoolScheduler; + _errorChanged = new Lazy>(() => new BehaviorSubject(GetErrors(null))); } + /// + public event EventHandler? ErrorsChanged; + /// /// Gets a value indicating whether gets a value that indicates whether the object is disposed. /// @@ -60,9 +94,156 @@ public ReactiveProperty(T? initialValue, IScheduler? scheduler) public T? Value { get => _value; - set => this.RaiseAndSetIfChanged(ref _value, value); + set + { + if (_checkIf.Equals(_value, value)) + { + if (!_isDistinctUntilChanged) + { + _valueRefereshed.OnNext(_value); + } + + return; + } + + SetValue(value); + this.RaisePropertyChanged(); + } + } + + /// + /// Gets a value indicating whether this instance has errors. + /// + /// + /// true if this instance has errors; otherwise, false. + /// + public bool HasErrors => _currentErrors != null; + + /// + /// Gets the observe error changed. + /// + /// + /// The observe error changed. + /// + public IObservable ObserveErrorChanged => _errorChanged.Value.AsObservable(); + + /// + /// Gets the observe has errors. + /// + /// + /// The observe has errors. + /// + public IObservable ObserveHasErrors => ObserveErrorChanged.Select(_ => HasErrors); + + /// + /// Set INotifyDataErrorInfo's asynchronous validation, return value is self. + /// + /// If success return IO<null>, failure return IO<IEnumerable>(Errors). + /// if set to true [ignore initial error]. + /// + /// Self. + /// + public ReactiveProperty AddValidationError(Func, IObservable> validator, bool ignoreInitialError = false) + { + _validatorStore.Value.Add(validator); + var validators = _validatorStore.Value + .Select(x => x(ignoreInitialError ? _checkValidation : _checkValidation.StartWith(_value))) + .ToArray(); + + _validationDisposable.Disposable = Observable + .CombineLatest(validators) + .Select(xs => + { + if (xs.Count == 0 || xs.All(x => x == null)) + { + return null; + } + + var strings = xs + .Where(x => x != null) + .OfType(); + var others = xs + .Where(x => x is not string or null) + .SelectMany(x => x!.Cast()); + + return strings.Concat(others); + }) + .Subscribe(x => + { + var lastHasErrors = HasErrors; + _currentErrors = x; + var currentHasErrors = HasErrors; + var handler = ErrorsChanged; + if (handler != null) + { + _scheduler.Schedule(() => handler(this, SingletonDataErrorsChangedEventArgs.Value)); + } + + if (lastHasErrors != currentHasErrors) + { + _scheduler.Schedule(() => this.RaisePropertyChanged(SingletonPropertyChangedEventArgs.HasErrors.PropertyName)); + } + + _errorChanged.Value.OnNext(x); + }).DisposeWith(_disposables); + return this; } + /// + /// Set INotifyDataErrorInfo's asynchronous validation, return value is self. + /// + /// If success return IO<null>, failure return IO<IEnumerable>(Errors). + /// if set to true [ignore initial error]. + /// + /// Self. + /// + public ReactiveProperty AddValidationError(Func, IObservable> validator, bool ignoreInitialError = false) => + AddValidationError(xs => validator(xs).Select(x => (IEnumerable?)x), ignoreInitialError); + + /// + /// Set INotifyDataErrorInfo's asynchronous validation. + /// + /// Validation logic. + /// if set to true [ignore initial error]. + /// + /// Self. + /// + public ReactiveProperty AddValidationError(Func> validator, bool ignoreInitialError = false) => + AddValidationError(xs => xs.SelectMany(x => validator(x)), ignoreInitialError); + + /// + /// Set INotifyDataErrorInfo's asynchronous validation. + /// + /// Validation logic. + /// if set to true [ignore initial error]. + /// + /// Self. + /// + public ReactiveProperty AddValidationError(Func> validator, bool ignoreInitialError = false) => + AddValidationError(xs => xs.SelectMany(x => validator(x)), ignoreInitialError); + + /// + /// Set INotifyDataErrorInfo validation. + /// + /// Validation logic. + /// if set to true [ignore initial error]. + /// + /// Self. + /// + public ReactiveProperty AddValidationError(Func validator, bool ignoreInitialError = false) => + AddValidationError(xs => xs.Select(x => validator(x)), ignoreInitialError); + + /// + /// Set INotifyDataErrorInfo validation. + /// + /// Validation logic. + /// if set to true [ignore initial error]. + /// + /// Self. + /// + public ReactiveProperty AddValidationError(Func validator, bool ignoreInitialError = false) => + AddValidationError(xs => xs.Select(x => validator(x)), ignoreInitialError); + /// /// Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources. /// @@ -72,6 +253,35 @@ public void Dispose() GC.SuppressFinalize(this); } + /// + /// Check validation. + /// + public void CheckValidation() => _checkValidation.OnNext(_value); + + /// + /// Invoke OnNext. + /// + public void Refresh() + { + SetValue(_value); + _valueRefereshed.OnNext(_value); + this.RaisePropertyChanged(nameof(Value)); + } + + /// + /// Gets the errors. + /// + /// Name of the property. + /// A IEnumerable. + public IEnumerable? GetErrors(string? propertyName) => _currentErrors; + + /// + /// Gets the errors. + /// + /// Name of the property. + /// A IEnumerable. + IEnumerable INotifyDataErrorInfo.GetErrors(string? propertyName) => _currentErrors ?? Enumerable.Empty(); + /// /// Notifies the provider that an observer is to receive notifications. /// @@ -82,6 +292,8 @@ public void Dispose() /// public IDisposable Subscribe(IObserver observer) => this.WhenAnyValue(vm => vm.Value) + .Merge(_valueRefereshed) + .Skip(_skipCurrentValue) .ObserveOn(_scheduler) .Subscribe(observer) .DisposeWith(_disposables); @@ -95,6 +307,23 @@ protected virtual void Dispose(bool disposing) if (_disposables?.IsDisposed == false && disposing) { _disposables?.Dispose(); + _checkValidation.Dispose(); + _valueRefereshed.Dispose(); + _validationDisposable.Dispose(); + if (_errorChanged.IsValueCreated) + { + _errorChanged.Value.OnCompleted(); + _errorChanged.Value.Dispose(); + } + } + } + + private void SetValue(T? value) + { + _value = value; + if (!IsDisposed) + { + _checkValidation.OnNext(value); } } } diff --git a/src/ReactiveUI/ReactiveProperty/ReactivePropertyMixins.cs b/src/ReactiveUI/ReactiveProperty/ReactivePropertyMixins.cs new file mode 100644 index 0000000000..d00b13d9c2 --- /dev/null +++ b/src/ReactiveUI/ReactiveProperty/ReactivePropertyMixins.cs @@ -0,0 +1,86 @@ +// Copyright (c) 2023 .NET Foundation and Contributors. All rights reserved. +// 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 full license information. + +using System.Reflection; + +namespace ReactiveUI; + +/// +/// Reactive Property Extensions. +/// +public static class ReactivePropertyMixins +{ +#if !XAMARINIOS && !XAMARINMAC && !XAMARINTVOS && !MONOANDROID + /// + /// Set validation logic from DataAnnotations attributes. + /// + /// Property type. + /// Target ReactiveProperty. + /// The self selector. + /// + /// Self. + /// + /// + /// selfSelector + /// or + /// self. + /// + public static ReactiveProperty AddValidation(this ReactiveProperty self, Expression?>> selfSelector) + { + if (selfSelector == null) + { + throw new ArgumentNullException(nameof(selfSelector)); + } + + if (self == null) + { + throw new ArgumentNullException(nameof(self)); + } + + var memberExpression = (MemberExpression)selfSelector.Body; + var propertyInfo = (PropertyInfo)memberExpression.Member; + var display = propertyInfo.GetCustomAttribute(); + var attrs = propertyInfo.GetCustomAttributes().ToArray(); + var context = new System.ComponentModel.DataAnnotations.ValidationContext(self) + { + DisplayName = display?.GetName() ?? propertyInfo.Name, + MemberName = nameof(ReactiveProperty.Value), + }; + + if (attrs.Length != 0) + { + self.AddValidationError(x => + { + var validationResults = new List(); + if (System.ComponentModel.DataAnnotations.Validator.TryValidateValue(x!, context, validationResults, attrs)) + { + return null; + } + + return validationResults[0].ErrorMessage; + }); + } + + return self; + } +#endif + + /// + /// Create an IObservable instance to observe validation error messages of ReactiveProperty. + /// + /// Property type. + /// Target ReactiveProperty. + /// A IObservable of string. + public static IObservable ObserveValidationErrors(this ReactiveProperty self) + { + if (self == null) + { + throw new ArgumentNullException(nameof(self)); + } + + return self.ObserveErrorChanged + .Select(x => x?.OfType()?.FirstOrDefault()); + } +} diff --git a/src/ReactiveUI/ReactiveProperty/SingletonDataErrorsChangedEventArgs.cs b/src/ReactiveUI/ReactiveProperty/SingletonDataErrorsChangedEventArgs.cs new file mode 100644 index 0000000000..130f4bfab3 --- /dev/null +++ b/src/ReactiveUI/ReactiveProperty/SingletonDataErrorsChangedEventArgs.cs @@ -0,0 +1,11 @@ +// Copyright (c) 2023 .NET Foundation and Contributors. All rights reserved. +// 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 full license information. + +namespace ReactiveUI; + +internal static class SingletonDataErrorsChangedEventArgs +{ + public static readonly DataErrorsChangedEventArgs Value = new(nameof(Value)); +} diff --git a/src/ReactiveUI/ReactiveProperty/SingletonPropertyChangedEventArgs.cs b/src/ReactiveUI/ReactiveProperty/SingletonPropertyChangedEventArgs.cs new file mode 100644 index 0000000000..446b0100a9 --- /dev/null +++ b/src/ReactiveUI/ReactiveProperty/SingletonPropertyChangedEventArgs.cs @@ -0,0 +1,13 @@ +// Copyright (c) 2023 .NET Foundation and Contributors. All rights reserved. +// 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 full license information. + +namespace ReactiveUI; + +internal static class SingletonPropertyChangedEventArgs +{ + public static readonly PropertyChangedEventArgs Value = new(nameof(Value)); + public static readonly PropertyChangedEventArgs HasErrors = new(nameof(INotifyDataErrorInfo.HasErrors)); + public static readonly PropertyChangedEventArgs ErrorMessage = new(nameof(ErrorMessage)); +} diff --git a/src/global.json b/src/global.json index 58d79105c8..efada42564 100644 --- a/src/global.json +++ b/src/global.json @@ -1,8 +1,7 @@ { "sdk": { - "version": "8.0.203", - "rollForward": "latestMinor", - "allowPrerelease": true + "version": "8.0.20", + "rollForward": "latestMinor" }, "msbuild-sdks": { "MSBuild.Sdk.Extras": "3.0.44" diff --git a/version.json b/version.json index a8c8b39baa..1ad9f48dc6 100644 --- a/version.json +++ b/version.json @@ -1,6 +1,6 @@ { "$schema": "https://raw.githubusercontent.com/dotnet/Nerdbank.GitVersioning/master/src/NerdBank.GitVersioning/version.schema.json", - "version": "19.5", + "version": "19.6", "publicReleaseRefSpec": [ "^refs/heads/master$", // we release out of master "^refs/heads/main$",