From 64c47b733f3eb5a71ff370f6d1bcadc6854ff496 Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Tue, 25 Aug 2020 17:27:43 +0100 Subject: [PATCH] Support custom validation class names (#24835) * Store arbitrary properties on EditContext * Define FieldCssClassProvider as a mechanism for customizing field CSS classes * Add E2E test --- src/Components/Forms/src/EditContext.cs | 6 ++ .../Forms/src/EditContextProperties.cs | 63 ++++++++++++++++ src/Components/Forms/test/EditContextTest.cs | 71 +++++++++++++++++++ .../Forms/EditContextFieldClassExtensions.cs | 32 ++++++--- .../Web/src/Forms/FieldCssClassProvider.cs | 35 +++++++++ .../test/E2ETest/Tests/FormsTest.cs | 17 +++++ .../FormsTest/CustomFieldCssClassProvider.cs | 47 ++++++++++++ .../TypicalValidationComponent.razor | 7 ++ 8 files changed, 268 insertions(+), 10 deletions(-) create mode 100644 src/Components/Forms/src/EditContextProperties.cs create mode 100644 src/Components/Web/src/Forms/FieldCssClassProvider.cs create mode 100644 src/Components/test/testassets/BasicTestApp/FormsTest/CustomFieldCssClassProvider.cs diff --git a/src/Components/Forms/src/EditContext.cs b/src/Components/Forms/src/EditContext.cs index aed3b69ded..223f892de7 100644 --- a/src/Components/Forms/src/EditContext.cs +++ b/src/Components/Forms/src/EditContext.cs @@ -31,6 +31,7 @@ namespace Microsoft.AspNetCore.Components.Forms // really don't, you can pass an empty object then ignore it. Ensuring it's nonnull // simplifies things for all consumers of EditContext. Model = model ?? throw new ArgumentNullException(nameof(model)); + Properties = new EditContextProperties(); } /// @@ -62,6 +63,11 @@ namespace Microsoft.AspNetCore.Components.Forms /// public object Model { get; } + /// + /// Gets a collection of arbitrary properties associated with this instance. + /// + public EditContextProperties Properties { get; } + /// /// Signals that the value for the specified field has changed. /// diff --git a/src/Components/Forms/src/EditContextProperties.cs b/src/Components/Forms/src/EditContextProperties.cs new file mode 100644 index 0000000000..9337d44871 --- /dev/null +++ b/src/Components/Forms/src/EditContextProperties.cs @@ -0,0 +1,63 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; + +namespace Microsoft.AspNetCore.Components.Forms +{ + /// + /// Holds arbitrary key/value pairs associated with an . + /// This can be used to track additional metadata for application-specific purposes. + /// + public sealed class EditContextProperties + { + // We don't want to expose any way of enumerating the underlying dictionary, because that would + // prevent its usage to store private information. So we only expose an indexer and TryGetValue. + private Dictionary? _contents; + + /// + /// Gets or sets a value in the collection. + /// + /// The key under which the value is stored. + /// The stored value. + public object this[object key] + { + get => _contents is null ? throw new KeyNotFoundException() : _contents[key]; + set + { + _contents ??= new Dictionary(); + _contents[key] = value; + } + } + + /// + /// Gets the value associated with the specified key, if any. + /// + /// The key under which the value is stored. + /// The value, if present. + /// True if the value was present, otherwise false. + public bool TryGetValue(object key, [NotNullWhen(true)] out object? value) + { + if (_contents is null) + { + value = default; + return false; + } + else + { + return _contents.TryGetValue(key, out value); + } + } + + /// + /// Removes the specified entry from the collection. + /// + /// The key of the entry to be removed. + /// True if the value was present, otherwise false. + public bool Remove(object key) + { + return _contents?.Remove(key) ?? false; + } + } +} diff --git a/src/Components/Forms/test/EditContextTest.cs b/src/Components/Forms/test/EditContextTest.cs index e26bda2c7f..78198c4114 100644 --- a/src/Components/Forms/test/EditContextTest.cs +++ b/src/Components/Forms/test/EditContextTest.cs @@ -2,6 +2,7 @@ // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using System; +using System.Collections.Generic; using System.Linq; using Xunit; @@ -252,6 +253,76 @@ namespace Microsoft.AspNetCore.Components.Forms Assert.True(editContext.IsModified(editContext.Field(nameof(EquatableModel.Property)))); } + [Fact] + public void Properties_CanRetrieveViaIndexer() + { + // Arrange + var editContext = new EditContext(new object()); + var key1 = new object(); + var key2 = new object(); + var key3 = new object(); + var value1 = new object(); + var value2 = new object(); + + // Initially, the values are not present + Assert.Throws(() => editContext.Properties[key1]); + + // Can store and retrieve values + editContext.Properties[key1] = value1; + editContext.Properties[key2] = value2; + Assert.Same(value1, editContext.Properties[key1]); + Assert.Same(value2, editContext.Properties[key2]); + + // Unrelated keys are still not found + Assert.Throws(() => editContext.Properties[key3]); + } + + [Fact] + public void Properties_CanRetrieveViaTryGetValue() + { + // Arrange + var editContext = new EditContext(new object()); + var key1 = new object(); + var key2 = new object(); + var key3 = new object(); + var value1 = new object(); + var value2 = new object(); + + // Initially, the values are not present + Assert.False(editContext.Properties.TryGetValue(key1, out _)); + + // Can store and retrieve values + editContext.Properties[key1] = value1; + editContext.Properties[key2] = value2; + Assert.True(editContext.Properties.TryGetValue(key1, out var retrievedValue1)); + Assert.True(editContext.Properties.TryGetValue(key2, out var retrievedValue2)); + Assert.Same(value1, retrievedValue1); + Assert.Same(value2, retrievedValue2); + + // Unrelated keys are still not found + Assert.False(editContext.Properties.TryGetValue(key3, out _)); + } + + [Fact] + public void Properties_CanRemove() + { + // Arrange + var editContext = new EditContext(new object()); + var key = new object(); + var value = new object(); + editContext.Properties[key] = value; + + // Act + var resultForExistingKey = editContext.Properties.Remove(key); + var resultForNonExistingKey = editContext.Properties.Remove(new object()); + + // Assert + Assert.True(resultForExistingKey); + Assert.False(resultForNonExistingKey); + Assert.False(editContext.Properties.TryGetValue(key, out _)); + Assert.Throws(() => editContext.Properties[key]); + } + class EquatableModel : IEquatable { public string Property { get; set; } = ""; diff --git a/src/Components/Web/src/Forms/EditContextFieldClassExtensions.cs b/src/Components/Web/src/Forms/EditContextFieldClassExtensions.cs index 687328043a..fb8bac72bb 100644 --- a/src/Components/Web/src/Forms/EditContextFieldClassExtensions.cs +++ b/src/Components/Web/src/Forms/EditContextFieldClassExtensions.cs @@ -2,7 +2,6 @@ // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using System; -using System.Linq; using System.Linq.Expressions; namespace Microsoft.AspNetCore.Components.Forms @@ -13,6 +12,8 @@ namespace Microsoft.AspNetCore.Components.Forms /// public static class EditContextFieldClassExtensions { + private readonly static object FieldCssClassProviderKey = new object(); + /// /// Gets a string that indicates the status of the specified field as a CSS class. This will include /// some combination of "modified", "valid", or "invalid", depending on the status of the field. @@ -24,23 +25,34 @@ namespace Microsoft.AspNetCore.Components.Forms => FieldCssClass(editContext, FieldIdentifier.Create(accessor)); /// - /// Gets a string that indicates the status of the specified field as a CSS class. This will include - /// some combination of "modified", "valid", or "invalid", depending on the status of the field. + /// Gets a string that indicates the status of the specified field as a CSS class. /// /// The . /// An identifier for the field. /// A string that indicates the status of the field. public static string FieldCssClass(this EditContext editContext, in FieldIdentifier fieldIdentifier) { - var isValid = !editContext.GetValidationMessages(fieldIdentifier).Any(); - if (editContext.IsModified(fieldIdentifier)) + var provider = editContext.Properties.TryGetValue(FieldCssClassProviderKey, out var customProvider) + ? (FieldCssClassProvider)customProvider + : FieldCssClassProvider.Instance; + + return provider.GetFieldCssClass(editContext, fieldIdentifier); + } + + /// + /// Associates the supplied with the supplied . + /// This customizes the field CSS class names used within the . + /// + /// The . + /// The . + public static void SetFieldCssClassProvider(this EditContext editContext, FieldCssClassProvider fieldCssClassProvider) + { + if (fieldCssClassProvider is null) { - return isValid ? "modified valid" : "modified invalid"; - } - else - { - return isValid ? "valid" : "invalid"; + throw new ArgumentNullException(nameof(fieldCssClassProvider)); } + + editContext.Properties[FieldCssClassProviderKey] = fieldCssClassProvider; } } } diff --git a/src/Components/Web/src/Forms/FieldCssClassProvider.cs b/src/Components/Web/src/Forms/FieldCssClassProvider.cs new file mode 100644 index 0000000000..dd56db64aa --- /dev/null +++ b/src/Components/Web/src/Forms/FieldCssClassProvider.cs @@ -0,0 +1,35 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Linq; + +namespace Microsoft.AspNetCore.Components.Forms +{ + /// + /// Supplies CSS class names for form fields to represent their validation state or other + /// state information from an . + /// + public class FieldCssClassProvider + { + internal readonly static FieldCssClassProvider Instance = new FieldCssClassProvider(); + + /// + /// Gets a string that indicates the status of the specified field as a CSS class. + /// + /// The . + /// The . + /// A CSS class name string. + public virtual string GetFieldCssClass(EditContext editContext, in FieldIdentifier fieldIdentifier) + { + var isValid = !editContext.GetValidationMessages(fieldIdentifier).Any(); + if (editContext.IsModified(fieldIdentifier)) + { + return isValid ? "modified valid" : "modified invalid"; + } + else + { + return isValid ? "valid" : "invalid"; + } + } + } +} diff --git a/src/Components/test/E2ETest/Tests/FormsTest.cs b/src/Components/test/E2ETest/Tests/FormsTest.cs index 265314d4ab..9c4f840dcf 100644 --- a/src/Components/test/E2ETest/Tests/FormsTest.cs +++ b/src/Components/test/E2ETest/Tests/FormsTest.cs @@ -560,6 +560,23 @@ namespace Microsoft.AspNetCore.Components.E2ETest.Tests Browser.Equal("", () => selectWithoutComponent.GetAttribute("value")); } + [Fact] + public void RespectsCustomFieldCssClassProvider() + { + var appElement = MountTypicalValidationComponent(); + var socksInput = appElement.FindElement(By.ClassName("socks")).FindElement(By.TagName("input")); + var messagesAccessor = CreateValidationMessagesAccessor(appElement); + + // Validates on edit + Browser.Equal("valid-socks", () => socksInput.GetAttribute("class")); + socksInput.SendKeys("Purple\t"); + Browser.Equal("modified valid-socks", () => socksInput.GetAttribute("class")); + + // Can become invalid + socksInput.SendKeys(" with yellow spots\t"); + Browser.Equal("modified invalid-socks", () => socksInput.GetAttribute("class")); + } + [Fact] public void NavigateOnSubmitWorks() { diff --git a/src/Components/test/testassets/BasicTestApp/FormsTest/CustomFieldCssClassProvider.cs b/src/Components/test/testassets/BasicTestApp/FormsTest/CustomFieldCssClassProvider.cs new file mode 100644 index 0000000000..bee221d307 --- /dev/null +++ b/src/Components/test/testassets/BasicTestApp/FormsTest/CustomFieldCssClassProvider.cs @@ -0,0 +1,47 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Linq; +using Microsoft.AspNetCore.Components.Forms; + +namespace BasicTestApp.FormsTest +{ + // For E2E testing, this is a rough example of a field CSS class provider that looks for + // a custom attribute defining CSS class names. It isn't very efficient (it does reflection + // and allocates on every invocation) but is sufficient for testing purposes. + public class CustomFieldCssClassProvider : FieldCssClassProvider + { + public override string GetFieldCssClass(EditContext editContext, in FieldIdentifier fieldIdentifier) + { + var cssClassName = base.GetFieldCssClass(editContext, fieldIdentifier); + + // If we can find a [CustomValidationClassName], use it + var propertyInfo = fieldIdentifier.Model.GetType().GetProperty(fieldIdentifier.FieldName); + if (propertyInfo != null) + { + var customValidationClassName = (CustomValidationClassNameAttribute)propertyInfo + .GetCustomAttributes(typeof(CustomValidationClassNameAttribute), true) + .FirstOrDefault(); + if (customValidationClassName != null) + { + cssClassName = string.Join(' ', cssClassName.Split(' ').Select(token => token switch + { + "valid" => customValidationClassName.Valid ?? token, + "invalid" => customValidationClassName.Invalid ?? token, + _ => token, + })); + } + } + + return cssClassName; + } + } + + [AttributeUsage(AttributeTargets.Property)] + public class CustomValidationClassNameAttribute : Attribute + { + public string Valid { get; set; } + public string Invalid { get; set; } + } +} diff --git a/src/Components/test/testassets/BasicTestApp/FormsTest/TypicalValidationComponent.razor b/src/Components/test/testassets/BasicTestApp/FormsTest/TypicalValidationComponent.razor index 89a53802ee..4f91e46538 100644 --- a/src/Components/test/testassets/BasicTestApp/FormsTest/TypicalValidationComponent.razor +++ b/src/Components/test/testassets/BasicTestApp/FormsTest/TypicalValidationComponent.razor @@ -66,6 +66,9 @@

+

+ Socks color: +

Accepts terms:

@@ -98,6 +101,7 @@ protected override void OnInitialized() { editContext = new EditContext(person); + editContext.SetFieldCssClassProvider(new CustomFieldCssClassProvider()); customValidationMessageStore = new ValidationMessageStore(editContext); } @@ -145,6 +149,9 @@ [Required, EnumDataType(typeof(Country))] public Country? Country { get; set; } = null; + [Required, StringLength(10), CustomValidationClassName(Valid = "valid-socks", Invalid = "invalid-socks")] + public string SocksColor { get; set; } + public string Username { get; set; } }