Skip to content

Commit

Permalink
feature: Use IValidationState Instead of ValidationState (#130)
Browse files Browse the repository at this point in the history
* Use IValidationState where possible
* The new pattern plays well with our sample app
* The new validation rule overloads
* Use the new overload in our sample app
* Additional unit tests
* Approve the API
* Update README.md (#131)
Proposed update to indicate use of `IValidationState` or `ValidationState` with `ValidationRule`.
* Refactor: change validation state to have a static valid state (#132)
* Update ValidationState.cs
Added static Valid property, for `ValidationState.Valid` as mentioned in #130 (comment)
Updated document to use `ValidationState.Valid` as introduced in #131
* Replaced construction of valid validation states with `ValidationState.Valid`.
* Added `ValidationState.Valid` to public API.
* Don't break the public ObservableValidationBase API if possible
* Use ValidationState.Valid in our sample app
Co-authored-by: Craig Dean <thargy@yahoo.com>
  • Loading branch information
worldbeater authored Oct 17, 2020
1 parent 86e9e8a commit e6aadc5
Show file tree
Hide file tree
Showing 22 changed files with 401 additions and 151 deletions.
13 changes: 12 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,17 @@ this.ValidationRule(
state => $"Passwords must match: {state.Password} != {state.Confirmation}");
```

Finally, you can directly supply any state that implements `IValidationState`; or you can use the `ValidationState` base class which already implements the interface. As the resulting object is stored directly against the context without further transformation, this can be the most performant approach:
```csharp
IObservable<IValidationState> usernameNotEmpty =
this.WhenAnyValue(x => x.UserName)
.Select(name => string.IsNullOrEmpty(name)
? new ValidationState(false, "The username must not be empty")
: ValidationState.Valid);

this.ValidationRule(vm => vm.UserName, usernameNotEmpty);
```

2. Add validation presentation to the View.

```csharp
Expand Down Expand Up @@ -174,7 +185,7 @@ public class SampleViewModel : ReactiveValidationObject
}
```

When using the `ValidationRule` overload that uses `IObservable<bool>` for more complex scenarios please keep in mind to supply the property which the validation rule is targeting as the first argument. Otherwise it is not possible for `INotifyDataErrorInfo` to conclude which property the error message is for.
When using the `ValidationRule` overload that uses `IObservable<bool>` for more complex scenarios please remember to supply the property which the validation rule is targeting as the first argument. Otherwise it is not possible for `INotifyDataErrorInfo` to conclude which property the error message is for.

```csharp
this.ValidationRule(
Expand Down
35 changes: 9 additions & 26 deletions samples/LoginApp/ViewModels/SignUpViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
using ReactiveUI.Fody.Helpers;
using ReactiveUI.Validation.Extensions;
using ReactiveUI.Validation.Helpers;
using ReactiveUI.Validation.States;
using Splat;

namespace LoginApp.ViewModels
Expand Down Expand Up @@ -80,21 +81,19 @@ public SignUpViewModel(IScreen hostScreen = null, IUserDialogs dialogs = null)
// emits an empty string when UserName is valid, and emits a non-empty when UserName
// is either invalid, or just changed and hasn't been validated yet.

IObservable<ValidationResult> usernameValidated =
IObservable<IValidationState> usernameValidated =
this.WhenAnyValue(x => x.UserName)
.Throttle(TimeSpan.FromSeconds(0.7), RxApp.TaskpoolScheduler)
.SelectMany(ValidateNameImpl)
.ObserveOn(RxApp.MainThreadScheduler);

IObservable<ValidationResult> usernameDirty =
IObservable<IValidationState> usernameDirty =
this.WhenAnyValue(x => x.UserName)
.Select(name => ValidationResult.Error("Please wait..."));
.Select(name => new ValidationState(false, "Please wait..."));

this.ValidationRule(
vm => vm.UserName,
usernameValidated.Merge(usernameDirty),
state => state.IsValid,
state => $"Server says: {state.ErrorMessage}");
usernameValidated.Merge(usernameDirty));

_isBusy = usernameValidated
.Select(message => false)
Expand Down Expand Up @@ -147,30 +146,14 @@ public SignUpViewModel(IScreen hostScreen = null, IUserDialogs dialogs = null)

private void SignUpImpl() => _dialogs.ShowDialog("User created successfully.");

private static async Task<ValidationResult> ValidateNameImpl(string username)
private static async Task<IValidationState> ValidateNameImpl(string username)
{
await Task.Delay(TimeSpan.FromSeconds(0.5));
return username.Length < 2
? ValidationResult.Error("The name is too short.")
? new ValidationState(false, "The name is too short.")
: username.Any(letter => !char.IsLetter(letter))
? ValidationResult.Error("Only letters allowed.")
: ValidationResult.Success();
}

private class ValidationResult
{
public bool IsValid { get; }
public string ErrorMessage { get; }

private ValidationResult(bool isValid, string errorMessage)
{
IsValid = isValid;
ErrorMessage = errorMessage;
}

public static ValidationResult Success() => new ValidationResult(true, string.Empty);

public static ValidationResult Error(string error) => new ValidationResult(false, error);
? new ValidationState(false, "Only letters allowed.")
: ValidationState.Valid;
}
}
}

Large diffs are not rendered by default.

Large diffs are not rendered by default.

32 changes: 32 additions & 0 deletions src/ReactiveUI.Validation.Tests/ObservableValidationTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,12 @@
// See the LICENSE file in the project root for full license information.

using System;
using System.Collections.Generic;
using System.Reactive.Subjects;
using ReactiveUI.Validation.Components;
using ReactiveUI.Validation.Components.Abstractions;
using ReactiveUI.Validation.Extensions;
using ReactiveUI.Validation.States;
using ReactiveUI.Validation.Tests.Models;
using Xunit;

Expand Down Expand Up @@ -184,5 +186,35 @@ public void ShouldResolveTypedProperties()
Assert.False(component.ContainsProperty<TestViewModel, string>(model => model.Name2, true));
Assert.Throws<ArgumentNullException>(() => component.ContainsProperty<TestViewModel, string>(null!));
}

/// <summary>
/// Verifies that we support the simplest possible observable-based validation component.
/// </summary>
[Fact]
public void ShouldSupportMinimalObservableValidation()
{
var stream = new Subject<IValidationState>();
var arguments = new List<IValidationState>();
var component = new ObservableValidation<TestViewModel, bool>(stream);
component.ValidationStatusChange.Subscribe(arguments.Add);
stream.OnNext(ValidationState.Valid);

Assert.True(component.IsValid);
Assert.Empty(component.Text!.ToSingleLine());
Assert.Single(arguments);

Assert.True(arguments[0].IsValid);
Assert.Empty(arguments[0].Text.ToSingleLine());

const string errorMessage = "Errors exist.";
stream.OnNext(new ValidationState(false, errorMessage));

Assert.False(component.IsValid);
Assert.Equal(errorMessage, component.Text.ToSingleLine());
Assert.Equal(2, arguments.Count);

Assert.False(arguments[1].IsValid);
Assert.Equal(errorMessage, arguments[1].Text.ToSingleLine());
}
}
}
6 changes: 3 additions & 3 deletions src/ReactiveUI.Validation.Tests/PropertyValidationTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -108,19 +108,19 @@ public void MessageUpdatedWhenPropertyChanged()

model.Name = testValue;

var changes = new List<ValidationState>();
var changes = new List<IValidationState>();

validation.ValidationStatusChange.Subscribe(v => changes.Add(v));

Assert.Equal("The value 'bongo' is incorrect", validation.Text.ToSingleLine());
Assert.Single(changes);
Assert.Equal(new ValidationState(false, "The value 'bongo' is incorrect", validation), changes[0], new ValidationStateComparer());
Assert.Equal(new ValidationState(false, "The value 'bongo' is incorrect"), changes[0], new ValidationStateComparer());

model.Name = testRoot;

Assert.Equal("The value 'bon' is incorrect", validation.Text.ToSingleLine());
Assert.Equal(2, changes.Count);
Assert.Equal(new ValidationState(false, "The value 'bon' is incorrect", validation), changes[1], new ValidationStateComparer());
Assert.Equal(new ValidationState(false, "The value 'bon' is incorrect"), changes[1], new ValidationStateComparer());
}

/// <summary>
Expand Down
59 changes: 59 additions & 0 deletions src/ReactiveUI.Validation.Tests/ValidationBindingTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,19 @@
// 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;
using System.Linq;
using System.Reactive.Concurrency;
using System.Reactive.Linq;
using System.Reactive.Subjects;
using ReactiveUI.Validation.Collections;
using ReactiveUI.Validation.Components;
using ReactiveUI.Validation.Contexts;
using ReactiveUI.Validation.Extensions;
using ReactiveUI.Validation.Formatters;
using ReactiveUI.Validation.Formatters.Abstractions;
using ReactiveUI.Validation.Helpers;
using ReactiveUI.Validation.States;
using ReactiveUI.Validation.Tests.Models;
using ReactiveUI.Validation.ValidationBindings;
using Xunit;
Expand Down Expand Up @@ -655,6 +658,62 @@ public void ShouldUpdateValidationHelperBindingOnPropertyChange()
Assert.Equal(secretMessage, view.NameErrorLabel);
}

/// <summary>
/// Verifies that the ValidationRule(IValidationState) methods work.
/// </summary>
[Fact]
public void ShouldBindValidationRuleEmittingValidationStates()
{
const StringComparison comparison = StringComparison.InvariantCulture;
const string viewModelIsBlockedMessage = "View model is blocked.";
const string nameErrorMessage = "Name shouldn't be empty.";
var view = new TestView(new TestViewModel { Name = string.Empty });
var isViewModelBlocked = new ReplaySubject<bool>(1);
isViewModelBlocked.OnNext(true);

view.ViewModel.ValidationRule(
viewModel => viewModel.Name,
view.ViewModel.WhenAnyValue(
vm => vm.Name,
name => new CustomValidationState(
!string.IsNullOrWhiteSpace(name),
nameErrorMessage)));

view.ViewModel.ValidationRule(
isViewModelBlocked.Select(blocked =>
new CustomValidationState(!blocked, viewModelIsBlockedMessage)));

view.Bind(view.ViewModel, x => x.Name, x => x.NameLabel);
view.BindValidation(view.ViewModel, x => x.Name, x => x.NameErrorLabel);
view.BindValidation(view.ViewModel, x => x.NameErrorContainer.Text);

Assert.Equal(2, view.ViewModel.ValidationContext.Validations.Count);
Assert.False(view.ViewModel.ValidationContext.IsValid);
Assert.Contains(nameErrorMessage, view.NameErrorLabel, comparison);
Assert.Contains(viewModelIsBlockedMessage, view.NameErrorContainer.Text, comparison);

view.ViewModel.Name = "Qwerty";
isViewModelBlocked.OnNext(false);

Assert.Equal(2, view.ViewModel.ValidationContext.Validations.Count);
Assert.True(view.ViewModel.ValidationContext.IsValid);
Assert.DoesNotContain(nameErrorMessage, view.NameErrorLabel, comparison);
Assert.DoesNotContain(viewModelIsBlockedMessage, view.NameErrorContainer.Text, comparison);
}

private class CustomValidationState : IValidationState
{
public CustomValidationState(bool isValid, string message)
{
IsValid = isValid;
Text = new ValidationText(isValid ? string.Empty : message);
}

public ValidationText Text { get; }

public bool IsValid { get; }
}

private class ConstFormatter : IValidationTextFormatter<string>
{
private readonly string _text;
Expand Down
19 changes: 9 additions & 10 deletions src/ReactiveUI.Validation/Comparators/ValidationStateComparer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,18 +11,18 @@ namespace ReactiveUI.Validation.Comparators
{
/// <inheritdoc />
/// <summary>
/// Utility class used to compare <see cref="ReactiveUI.Validation.States.ValidationState" /> instances.
/// Utility class used to compare <see cref="ReactiveUI.Validation.States.IValidationState" /> instances.
/// </summary>
public class ValidationStateComparer : EqualityComparer<ValidationState>
public class ValidationStateComparer : EqualityComparer<IValidationState>
{
/// <summary>
/// Checks if two <see cref="ValidationState"/> objects are equals based on both
/// <see cref="ValidationState.IsValid"/> and <see cref="ValidationState.Component"/> properties.
/// Checks if two <see cref="IValidationState"/> objects are equals based on both
/// <see cref="IValidationState.IsValid"/> and <see cref="IValidationState.Text"/> properties.
/// </summary>
/// <param name="x">Source <see cref="ValidationState"/> object.</param>
/// <param name="y">Target <see cref="ValidationState"/> object.</param>
/// <param name="x">Source <see cref="IValidationState"/> object.</param>
/// <param name="y">Target <see cref="IValidationState"/> object.</param>
/// <returns>Returns true if both objects are equals, otherwise false.</returns>
public override bool Equals(ValidationState x, ValidationState y)
public override bool Equals(IValidationState x, IValidationState y)
{
if (x == null && y == null)
{
Expand All @@ -34,12 +34,11 @@ public override bool Equals(ValidationState x, ValidationState y)
return false;
}

return x.IsValid == y.IsValid && x.Text.ToSingleLine() == y.Text.ToSingleLine()
&& x.Component == y.Component;
return x.IsValid == y.IsValid && x.Text.ToSingleLine() == y.Text.ToSingleLine();
}

/// <inheritdoc />
public override int GetHashCode(ValidationState obj)
public override int GetHashCode(IValidationState obj)
{
if (obj == null)
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,6 @@ public interface IValidationComponent
/// <summary>
/// Gets the observable for validation state changes.
/// </summary>
IObservable<ValidationState> ValidationStatusChange { get; }
IObservable<IValidationState> ValidationStatusChange { get; }
}
}
10 changes: 5 additions & 5 deletions src/ReactiveUI.Validation/Components/BasePropertyValidation.cs
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ public abstract class BasePropertyValidation<TViewModel> : ReactiveObject, IDisp
private readonly ReplaySubject<bool> _isValidSubject = new ReplaySubject<bool>(1);
private readonly HashSet<string> _propertyNames = new HashSet<string>();
private readonly CompositeDisposable _disposables = new CompositeDisposable();
private IConnectableObservable<ValidationState>? _connectedChange;
private IConnectableObservable<IValidationState>? _connectedChange;
private bool _isConnected;
private bool _isValid;
private ValidationText? _text;
Expand Down Expand Up @@ -64,7 +64,7 @@ public bool IsValid
/// <summary>
/// Gets the public mechanism indicating that the validation state has changed.
/// </summary>
public IObservable<ValidationState> ValidationStatusChange
public IObservable<IValidationState> ValidationStatusChange
{
get
{
Expand Down Expand Up @@ -140,8 +140,8 @@ protected void AddProperty<TProp>(Expression<Func<TViewModel, TProp>> property)
/// <summary>
/// Get the validation change observable, implemented by concrete classes.
/// </summary>
/// <returns>Returns the <see cref="ValidationState"/> collection.</returns>
protected abstract IObservable<ValidationState> GetValidationChangeObservable();
/// <returns>Returns the <see cref="IValidationState"/> collection.</returns>
protected abstract IObservable<IValidationState> GetValidationChangeObservable();

/// <summary>
/// Disposes of the managed resources.
Expand Down Expand Up @@ -279,7 +279,7 @@ private BasePropertyValidation(
/// Get the validation change observable.
/// </summary>
/// <returns></returns>
protected override IObservable<ValidationState> GetValidationChangeObservable()
protected override IObservable<IValidationState> GetValidationChangeObservable()
{
Activate();
return _valueSubject
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -193,9 +193,9 @@ private ModelObservableValidation(
public abstract class ModelObservableValidationBase<TViewModel> : ReactiveObject, IDisposable, IPropertyValidationComponent, IPropertyValidationComponent<TViewModel>
{
[SuppressMessage("Usage", "CA2213:Disposable fields should be disposed", Justification = "Disposed by field _disposables.")]
private readonly ReplaySubject<ValidationState> _lastValidationStateSubject = new ReplaySubject<ValidationState>(1);
private readonly ReplaySubject<IValidationState> _lastValidationStateSubject = new ReplaySubject<IValidationState>(1);
private readonly HashSet<string> _propertyNames = new HashSet<string>();
private readonly IConnectableObservable<ValidationState> _validityConnectedObservable;
private readonly IConnectableObservable<IValidationState> _validityConnectedObservable;
private readonly CompositeDisposable _disposables = new CompositeDisposable();
private bool _isActive;
private bool _isValid;
Expand Down Expand Up @@ -254,7 +254,7 @@ public bool IsValid
}

/// <inheritdoc/>
public IObservable<ValidationState> ValidationStatusChange
public IObservable<IValidationState> ValidationStatusChange
{
get
{
Expand Down
Loading

0 comments on commit e6aadc5

Please sign in to comment.