Skip to content

Commit efaaf1b

Browse files
author
msftbot[bot]
authored
Bug fixes to ObservableValidator (#3764)
## Fixes #3763 <!-- Add the relevant issue number after the "#" mentioned above (for ex: Fixes #1234) which will automatically close the issue once the PR is merged. --> <!-- Add a brief overview here of the feature/bug & fix. --> ## PR Type What kind of change does this PR introduce? <!-- Please uncomment one or more that apply to this PR. --> - Bugfix <!-- - Feature --> <!-- - Code style update (formatting) --> <!-- - Refactoring (no functional changes, no api changes) --> <!-- - Build or CI related changes --> <!-- - Documentation content changes --> <!-- - Sample app changes --> <!-- - Other... Please describe: --> ## What is the current behavior? <!-- Please describe the current behavior that you are modifying, or link to a relevant issue. --> - Incorrect loading of display names for validated properties - Repeated error messages after validation ## What is the new behavior? <!-- Describe how was this issue resolved or changed? --> Fixed the errors above. Opening as a draft to have the CI run, but I'm still investigating the second issue from the list above. ## PR Checklist Please check if your PR fulfills the following requirements: - [X] Tested code with current [supported SDKs](../readme.md#supported) - [ ] ~~Pull Request has been submitted to the documentation repository [instructions](..\contributing.md#docs). Link: <!-- docs PR link -->~~ - [ ] ~~Sample in sample app has been added / updated (for bug fixes / features)~~ - [ ] ~~Icon has been created (if new sample) following the [Thumbnail Style Guide and templates](https://github.com/windows-toolkit/WindowsCommunityToolkit-design-assets)~~ - [X] New major technical changes in the toolkit have or will be added to the [Wiki](https://github.com/windows-toolkit/WindowsCommunityToolkit/wiki) e.g. build changes, source generators, testing infrastructure, sample creation changes, etc... - [X] Tests for the changes have been added (for bug fixes / features) (if applicable) - [X] Header has been added to all new source files (run *build/UpdateHeaders.bat*) - [X] Contains **NO** breaking changes
2 parents c37bd7b + 62e4133 commit efaaf1b

File tree

2 files changed

+98
-2
lines changed

2 files changed

+98
-2
lines changed

Microsoft.Toolkit.Mvvm/ComponentModel/ObservableValidator.cs

Lines changed: 46 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,18 @@ public abstract class ObservableValidator : ObservableObject, INotifyDataErrorIn
2626
/// </summary>
2727
private static readonly ConditionalWeakTable<Type, Action<object>> EntityValidatorMap = new();
2828

29+
/// <summary>
30+
/// The <see cref="ConditionalWeakTable{TKey, TValue}"/> instance used to track display names for properties to validate.
31+
/// </summary>
32+
/// <remarks>
33+
/// This is necessary because we want to reuse the same <see cref="ValidationContext"/> instance for all validations, but
34+
/// with the same behavior with repsect to formatted names that new instances would have provided. The issue is that the
35+
/// <see cref="ValidationContext.DisplayName"/> property is not refreshed when we set <see cref="ValidationContext.MemberName"/>,
36+
/// so we need to replicate the same logic to retrieve the right display name for properties to validate and update that
37+
/// property manually right before passing the context to <see cref="Validator"/> and proceed with the normal functionality.
38+
/// </remarks>
39+
private static readonly ConditionalWeakTable<Type, Dictionary<string, string>> DisplayNamesMap = new();
40+
2941
/// <summary>
3042
/// The cached <see cref="PropertyChangedEventArgs"/> for <see cref="HasErrors"/>.
3143
/// </summary>
@@ -68,7 +80,7 @@ protected ObservableValidator()
6880
/// be used to validate all properties, which will reference the current instance.
6981
/// </summary>
7082
/// <param name="items">A set of key/value pairs to make available to consumers.</param>
71-
protected ObservableValidator(IDictionary<object, object?> items)
83+
protected ObservableValidator(IDictionary<object, object?>? items)
7284
{
7385
this.validationContext = new ValidationContext(this, items);
7486
}
@@ -80,7 +92,7 @@ protected ObservableValidator(IDictionary<object, object?> items)
8092
/// </summary>
8193
/// <param name="serviceProvider">An <see cref="IServiceProvider"/> instance to make available during validation.</param>
8294
/// <param name="items">A set of key/value pairs to make available to consumers.</param>
83-
protected ObservableValidator(IServiceProvider serviceProvider, IDictionary<object, object?> items)
95+
protected ObservableValidator(IServiceProvider? serviceProvider, IDictionary<object, object?>? items)
8496
{
8597
this.validationContext = new ValidationContext(this, serviceProvider, items);
8698
}
@@ -541,6 +553,7 @@ protected void ValidateProperty(object? value, [CallerMemberName] string? proper
541553

542554
// Validate the property, by adding new errors to the existing list
543555
this.validationContext.MemberName = propertyName;
556+
this.validationContext.DisplayName = GetDisplayNameForProperty(propertyName!);
544557

545558
bool isValid = Validator.TryValidateProperty(value, this.validationContext, propertyErrors);
546559

@@ -611,6 +624,7 @@ private bool TryValidateProperty(object? value, string? propertyName, out IReadO
611624

612625
// Validate the property, by adding new errors to the local list
613626
this.validationContext.MemberName = propertyName;
627+
this.validationContext.DisplayName = GetDisplayNameForProperty(propertyName!);
614628

615629
bool isValid = Validator.TryValidateProperty(value, this.validationContext, localErrors);
616630

@@ -688,6 +702,36 @@ private void ClearErrorsForProperty(string propertyName)
688702
ErrorsChanged?.Invoke(this, new DataErrorsChangedEventArgs(propertyName));
689703
}
690704

705+
/// <summary>
706+
/// Gets the display name for a given property. It could be a custom name or just the property name.
707+
/// </summary>
708+
/// <param name="propertyName">The target property name being validated.</param>
709+
/// <returns>The display name for the property.</returns>
710+
private string GetDisplayNameForProperty(string propertyName)
711+
{
712+
static Dictionary<string, string> GetDisplayNames(Type type)
713+
{
714+
Dictionary<string, string> displayNames = new();
715+
716+
foreach (PropertyInfo property in type.GetProperties(BindingFlags.Instance | BindingFlags.Public))
717+
{
718+
if (property.GetCustomAttribute<DisplayAttribute>() is DisplayAttribute attribute &&
719+
attribute.GetName() is string displayName)
720+
{
721+
displayNames.Add(property.Name, displayName);
722+
}
723+
}
724+
725+
return displayNames;
726+
}
727+
728+
// This method replicates the logic of DisplayName and GetDisplayName from the
729+
// ValidationContext class. See the original source in the BCL for more details.
730+
DisplayNamesMap.GetValue(GetType(), static t => GetDisplayNames(t)).TryGetValue(propertyName, out string? displayName);
731+
732+
return displayName ?? propertyName;
733+
}
734+
691735
#pragma warning disable SA1204
692736
/// <summary>
693737
/// Throws an <see cref="ArgumentNullException"/> when a property name given as input is <see langword="null"/>.

UnitTests/UnitTests.Shared/Mvvm/Test_ObservableValidator.cs

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -392,6 +392,28 @@ public void Test_ObservableValidator_CustomValidationWithInjectedService()
392392
Assert.AreEqual(model.GetErrors(nameof(ValidationWithServiceModel.Name)).ToArray().Length, 2);
393393
}
394394

395+
[TestCategory("Mvvm")]
396+
[TestMethod]
397+
public void Test_ObservableValidator_ValidationWithFormattedDisplayName()
398+
{
399+
var model = new ValidationWithDisplayName();
400+
401+
Assert.IsTrue(model.HasErrors);
402+
403+
// We need to order because there is no guaranteed order on the members of a type
404+
ValidationResult[] allErrors = model.GetErrors().OrderBy(error => error.ErrorMessage).ToArray();
405+
406+
Assert.AreEqual(allErrors.Length, 2);
407+
408+
Assert.AreEqual(allErrors[0].MemberNames.Count(), 1);
409+
Assert.AreEqual(allErrors[0].MemberNames.Single(), nameof(ValidationWithDisplayName.StringMayNotBeEmpty));
410+
Assert.AreEqual(allErrors[0].ErrorMessage, $"FIRST: {nameof(ValidationWithDisplayName.StringMayNotBeEmpty)}.");
411+
412+
Assert.AreEqual(allErrors[1].MemberNames.Count(), 1);
413+
Assert.AreEqual(allErrors[1].MemberNames.Single(), nameof(ValidationWithDisplayName.AnotherRequiredField));
414+
Assert.AreEqual(allErrors[1].ErrorMessage, $"SECOND: {nameof(ValidationWithDisplayName.AnotherRequiredField)}.");
415+
}
416+
395417
public class Person : ObservableValidator
396418
{
397419
private string name;
@@ -579,5 +601,35 @@ public static ValidationResult ValidateName(string name, ValidationContext conte
579601
return new ValidationResult("The name contains invalid characters");
580602
}
581603
}
604+
605+
/// <summary>
606+
/// Test model for validation with a formatted display name string on each property.
607+
/// This is issue #1 from https://github.com/windows-toolkit/WindowsCommunityToolkit/issues/3763.
608+
/// </summary>
609+
public class ValidationWithDisplayName : ObservableValidator
610+
{
611+
public ValidationWithDisplayName()
612+
{
613+
ValidateAllProperties();
614+
}
615+
616+
private string stringMayNotBeEmpty;
617+
618+
[Required(AllowEmptyStrings = false, ErrorMessage = "FIRST: {0}.")]
619+
public string StringMayNotBeEmpty
620+
{
621+
get => this.stringMayNotBeEmpty;
622+
set => SetProperty(ref this.stringMayNotBeEmpty, value, true);
623+
}
624+
625+
private string anotherRequiredField;
626+
627+
[Required(AllowEmptyStrings = false, ErrorMessage = "SECOND: {0}.")]
628+
public string AnotherRequiredField
629+
{
630+
get => this.anotherRequiredField;
631+
set => SetProperty(ref this.anotherRequiredField, value, true);
632+
}
633+
}
582634
}
583635
}

0 commit comments

Comments
 (0)