Support custom validation class names (#24835)

* Store arbitrary properties on EditContext

* Define FieldCssClassProvider as a mechanism for customizing field CSS classes

* Add E2E test
This commit is contained in:
Steve Sanderson 2020-08-25 17:27:43 +01:00 committed by GitHub
parent 6333040b5a
commit 64c47b733f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 268 additions and 10 deletions

View File

@ -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 // really don't, you can pass an empty object then ignore it. Ensuring it's nonnull
// simplifies things for all consumers of EditContext. // simplifies things for all consumers of EditContext.
Model = model ?? throw new ArgumentNullException(nameof(model)); Model = model ?? throw new ArgumentNullException(nameof(model));
Properties = new EditContextProperties();
} }
/// <summary> /// <summary>
@ -62,6 +63,11 @@ namespace Microsoft.AspNetCore.Components.Forms
/// </summary> /// </summary>
public object Model { get; } public object Model { get; }
/// <summary>
/// Gets a collection of arbitrary properties associated with this instance.
/// </summary>
public EditContextProperties Properties { get; }
/// <summary> /// <summary>
/// Signals that the value for the specified field has changed. /// Signals that the value for the specified field has changed.
/// </summary> /// </summary>

View File

@ -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
{
/// <summary>
/// Holds arbitrary key/value pairs associated with an <see cref="EditContext"/>.
/// This can be used to track additional metadata for application-specific purposes.
/// </summary>
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<object, object>? _contents;
/// <summary>
/// Gets or sets a value in the collection.
/// </summary>
/// <param name="key">The key under which the value is stored.</param>
/// <returns>The stored value.</returns>
public object this[object key]
{
get => _contents is null ? throw new KeyNotFoundException() : _contents[key];
set
{
_contents ??= new Dictionary<object, object>();
_contents[key] = value;
}
}
/// <summary>
/// Gets the value associated with the specified key, if any.
/// </summary>
/// <param name="key">The key under which the value is stored.</param>
/// <param name="value">The value, if present.</param>
/// <returns>True if the value was present, otherwise false.</returns>
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);
}
}
/// <summary>
/// Removes the specified entry from the collection.
/// </summary>
/// <param name="key">The key of the entry to be removed.</param>
/// <returns>True if the value was present, otherwise false.</returns>
public bool Remove(object key)
{
return _contents?.Remove(key) ?? false;
}
}
}

View File

@ -2,6 +2,7 @@
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
using System; using System;
using System.Collections.Generic;
using System.Linq; using System.Linq;
using Xunit; using Xunit;
@ -252,6 +253,76 @@ namespace Microsoft.AspNetCore.Components.Forms
Assert.True(editContext.IsModified(editContext.Field(nameof(EquatableModel.Property)))); 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<KeyNotFoundException>(() => 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<KeyNotFoundException>(() => 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<KeyNotFoundException>(() => editContext.Properties[key]);
}
class EquatableModel : IEquatable<EquatableModel> class EquatableModel : IEquatable<EquatableModel>
{ {
public string Property { get; set; } = ""; public string Property { get; set; } = "";

View File

@ -2,7 +2,6 @@
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
using System; using System;
using System.Linq;
using System.Linq.Expressions; using System.Linq.Expressions;
namespace Microsoft.AspNetCore.Components.Forms namespace Microsoft.AspNetCore.Components.Forms
@ -13,6 +12,8 @@ namespace Microsoft.AspNetCore.Components.Forms
/// </summary> /// </summary>
public static class EditContextFieldClassExtensions public static class EditContextFieldClassExtensions
{ {
private readonly static object FieldCssClassProviderKey = new object();
/// <summary> /// <summary>
/// Gets a string that indicates the status of the specified field as a CSS class. This will include /// 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. /// 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)); => FieldCssClass(editContext, FieldIdentifier.Create(accessor));
/// <summary> /// <summary>
/// Gets a string that indicates the status of the specified field as a CSS class. This will include /// Gets a string that indicates the status of the specified field as a CSS class.
/// some combination of "modified", "valid", or "invalid", depending on the status of the field.
/// </summary> /// </summary>
/// <param name="editContext">The <see cref="EditContext"/>.</param> /// <param name="editContext">The <see cref="EditContext"/>.</param>
/// <param name="fieldIdentifier">An identifier for the field.</param> /// <param name="fieldIdentifier">An identifier for the field.</param>
/// <returns>A string that indicates the status of the field.</returns> /// <returns>A string that indicates the status of the field.</returns>
public static string FieldCssClass(this EditContext editContext, in FieldIdentifier fieldIdentifier) public static string FieldCssClass(this EditContext editContext, in FieldIdentifier fieldIdentifier)
{ {
var isValid = !editContext.GetValidationMessages(fieldIdentifier).Any(); var provider = editContext.Properties.TryGetValue(FieldCssClassProviderKey, out var customProvider)
if (editContext.IsModified(fieldIdentifier)) ? (FieldCssClassProvider)customProvider
: FieldCssClassProvider.Instance;
return provider.GetFieldCssClass(editContext, fieldIdentifier);
}
/// <summary>
/// Associates the supplied <see cref="FieldCssClassProvider"/> with the supplied <see cref="EditContext"/>.
/// This customizes the field CSS class names used within the <see cref="EditContext"/>.
/// </summary>
/// <param name="editContext">The <see cref="EditContext"/>.</param>
/// <param name="fieldCssClassProvider">The <see cref="FieldCssClassProvider"/>.</param>
public static void SetFieldCssClassProvider(this EditContext editContext, FieldCssClassProvider fieldCssClassProvider)
{
if (fieldCssClassProvider is null)
{ {
return isValid ? "modified valid" : "modified invalid"; throw new ArgumentNullException(nameof(fieldCssClassProvider));
}
else
{
return isValid ? "valid" : "invalid";
} }
editContext.Properties[FieldCssClassProviderKey] = fieldCssClassProvider;
} }
} }
} }

View File

@ -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
{
/// <summary>
/// Supplies CSS class names for form fields to represent their validation state or other
/// state information from an <see cref="EditContext"/>.
/// </summary>
public class FieldCssClassProvider
{
internal readonly static FieldCssClassProvider Instance = new FieldCssClassProvider();
/// <summary>
/// Gets a string that indicates the status of the specified field as a CSS class.
/// </summary>
/// <param name="editContext">The <see cref="EditContext"/>.</param>
/// <param name="fieldIdentifier">The <see cref="FieldIdentifier"/>.</param>
/// <returns>A CSS class name string.</returns>
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";
}
}
}
}

View File

@ -560,6 +560,23 @@ namespace Microsoft.AspNetCore.Components.E2ETest.Tests
Browser.Equal("", () => selectWithoutComponent.GetAttribute("value")); 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] [Fact]
public void NavigateOnSubmitWorks() public void NavigateOnSubmitWorks()
{ {

View File

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

View File

@ -66,6 +66,9 @@
</InputRadioGroup> </InputRadioGroup>
</InputRadioGroup> </InputRadioGroup>
</p> </p>
<p class="socks">
Socks color: <InputText @bind-Value="person.SocksColor" />
</p>
<p class="accepts-terms"> <p class="accepts-terms">
Accepts terms: <InputCheckbox @bind-Value="person.AcceptsTerms" title="You have to check this" /> Accepts terms: <InputCheckbox @bind-Value="person.AcceptsTerms" title="You have to check this" />
</p> </p>
@ -98,6 +101,7 @@
protected override void OnInitialized() protected override void OnInitialized()
{ {
editContext = new EditContext(person); editContext = new EditContext(person);
editContext.SetFieldCssClassProvider(new CustomFieldCssClassProvider());
customValidationMessageStore = new ValidationMessageStore(editContext); customValidationMessageStore = new ValidationMessageStore(editContext);
} }
@ -145,6 +149,9 @@
[Required, EnumDataType(typeof(Country))] [Required, EnumDataType(typeof(Country))]
public Country? Country { get; set; } = null; 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; } public string Username { get; set; }
} }