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; }
}