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:
parent
6333040b5a
commit
64c47b733f
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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; } = "";
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue