Skip to content

Commit 64c47b7

Browse files
Support custom validation class names (#24835)
* Store arbitrary properties on EditContext * Define FieldCssClassProvider as a mechanism for customizing field CSS classes * Add E2E test
1 parent 6333040 commit 64c47b7

File tree

8 files changed

+268
-10
lines changed

8 files changed

+268
-10
lines changed

src/Components/Forms/src/EditContext.cs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ public EditContext(object model)
3131
// really don't, you can pass an empty object then ignore it. Ensuring it's nonnull
3232
// simplifies things for all consumers of EditContext.
3333
Model = model ?? throw new ArgumentNullException(nameof(model));
34+
Properties = new EditContextProperties();
3435
}
3536

3637
/// <summary>
@@ -62,6 +63,11 @@ public FieldIdentifier Field(string fieldName)
6263
/// </summary>
6364
public object Model { get; }
6465

66+
/// <summary>
67+
/// Gets a collection of arbitrary properties associated with this instance.
68+
/// </summary>
69+
public EditContextProperties Properties { get; }
70+
6571
/// <summary>
6672
/// Signals that the value for the specified field has changed.
6773
/// </summary>
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
// Copyright (c) .NET Foundation. All rights reserved.
2+
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
3+
4+
using System.Collections.Generic;
5+
using System.Diagnostics.CodeAnalysis;
6+
7+
namespace Microsoft.AspNetCore.Components.Forms
8+
{
9+
/// <summary>
10+
/// Holds arbitrary key/value pairs associated with an <see cref="EditContext"/>.
11+
/// This can be used to track additional metadata for application-specific purposes.
12+
/// </summary>
13+
public sealed class EditContextProperties
14+
{
15+
// We don't want to expose any way of enumerating the underlying dictionary, because that would
16+
// prevent its usage to store private information. So we only expose an indexer and TryGetValue.
17+
private Dictionary<object, object>? _contents;
18+
19+
/// <summary>
20+
/// Gets or sets a value in the collection.
21+
/// </summary>
22+
/// <param name="key">The key under which the value is stored.</param>
23+
/// <returns>The stored value.</returns>
24+
public object this[object key]
25+
{
26+
get => _contents is null ? throw new KeyNotFoundException() : _contents[key];
27+
set
28+
{
29+
_contents ??= new Dictionary<object, object>();
30+
_contents[key] = value;
31+
}
32+
}
33+
34+
/// <summary>
35+
/// Gets the value associated with the specified key, if any.
36+
/// </summary>
37+
/// <param name="key">The key under which the value is stored.</param>
38+
/// <param name="value">The value, if present.</param>
39+
/// <returns>True if the value was present, otherwise false.</returns>
40+
public bool TryGetValue(object key, [NotNullWhen(true)] out object? value)
41+
{
42+
if (_contents is null)
43+
{
44+
value = default;
45+
return false;
46+
}
47+
else
48+
{
49+
return _contents.TryGetValue(key, out value);
50+
}
51+
}
52+
53+
/// <summary>
54+
/// Removes the specified entry from the collection.
55+
/// </summary>
56+
/// <param name="key">The key of the entry to be removed.</param>
57+
/// <returns>True if the value was present, otherwise false.</returns>
58+
public bool Remove(object key)
59+
{
60+
return _contents?.Remove(key) ?? false;
61+
}
62+
}
63+
}

src/Components/Forms/test/EditContextTest.cs

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
33

44
using System;
5+
using System.Collections.Generic;
56
using System.Linq;
67
using Xunit;
78

@@ -252,6 +253,76 @@ public void LookingUpModel_ThatOverridesGetHashCodeAndEquals_Works()
252253
Assert.True(editContext.IsModified(editContext.Field(nameof(EquatableModel.Property))));
253254
}
254255

256+
[Fact]
257+
public void Properties_CanRetrieveViaIndexer()
258+
{
259+
// Arrange
260+
var editContext = new EditContext(new object());
261+
var key1 = new object();
262+
var key2 = new object();
263+
var key3 = new object();
264+
var value1 = new object();
265+
var value2 = new object();
266+
267+
// Initially, the values are not present
268+
Assert.Throws<KeyNotFoundException>(() => editContext.Properties[key1]);
269+
270+
// Can store and retrieve values
271+
editContext.Properties[key1] = value1;
272+
editContext.Properties[key2] = value2;
273+
Assert.Same(value1, editContext.Properties[key1]);
274+
Assert.Same(value2, editContext.Properties[key2]);
275+
276+
// Unrelated keys are still not found
277+
Assert.Throws<KeyNotFoundException>(() => editContext.Properties[key3]);
278+
}
279+
280+
[Fact]
281+
public void Properties_CanRetrieveViaTryGetValue()
282+
{
283+
// Arrange
284+
var editContext = new EditContext(new object());
285+
var key1 = new object();
286+
var key2 = new object();
287+
var key3 = new object();
288+
var value1 = new object();
289+
var value2 = new object();
290+
291+
// Initially, the values are not present
292+
Assert.False(editContext.Properties.TryGetValue(key1, out _));
293+
294+
// Can store and retrieve values
295+
editContext.Properties[key1] = value1;
296+
editContext.Properties[key2] = value2;
297+
Assert.True(editContext.Properties.TryGetValue(key1, out var retrievedValue1));
298+
Assert.True(editContext.Properties.TryGetValue(key2, out var retrievedValue2));
299+
Assert.Same(value1, retrievedValue1);
300+
Assert.Same(value2, retrievedValue2);
301+
302+
// Unrelated keys are still not found
303+
Assert.False(editContext.Properties.TryGetValue(key3, out _));
304+
}
305+
306+
[Fact]
307+
public void Properties_CanRemove()
308+
{
309+
// Arrange
310+
var editContext = new EditContext(new object());
311+
var key = new object();
312+
var value = new object();
313+
editContext.Properties[key] = value;
314+
315+
// Act
316+
var resultForExistingKey = editContext.Properties.Remove(key);
317+
var resultForNonExistingKey = editContext.Properties.Remove(new object());
318+
319+
// Assert
320+
Assert.True(resultForExistingKey);
321+
Assert.False(resultForNonExistingKey);
322+
Assert.False(editContext.Properties.TryGetValue(key, out _));
323+
Assert.Throws<KeyNotFoundException>(() => editContext.Properties[key]);
324+
}
325+
255326
class EquatableModel : IEquatable<EquatableModel>
256327
{
257328
public string Property { get; set; } = "";

src/Components/Web/src/Forms/EditContextFieldClassExtensions.cs

Lines changed: 22 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
33

44
using System;
5-
using System.Linq;
65
using System.Linq.Expressions;
76

87
namespace Microsoft.AspNetCore.Components.Forms
@@ -13,6 +12,8 @@ namespace Microsoft.AspNetCore.Components.Forms
1312
/// </summary>
1413
public static class EditContextFieldClassExtensions
1514
{
15+
private readonly static object FieldCssClassProviderKey = new object();
16+
1617
/// <summary>
1718
/// Gets a string that indicates the status of the specified field as a CSS class. This will include
1819
/// some combination of "modified", "valid", or "invalid", depending on the status of the field.
@@ -24,23 +25,34 @@ public static string FieldCssClass<TField>(this EditContext editContext, Express
2425
=> FieldCssClass(editContext, FieldIdentifier.Create(accessor));
2526

2627
/// <summary>
27-
/// Gets a string that indicates the status of the specified field as a CSS class. This will include
28-
/// some combination of "modified", "valid", or "invalid", depending on the status of the field.
28+
/// Gets a string that indicates the status of the specified field as a CSS class.
2929
/// </summary>
3030
/// <param name="editContext">The <see cref="EditContext"/>.</param>
3131
/// <param name="fieldIdentifier">An identifier for the field.</param>
3232
/// <returns>A string that indicates the status of the field.</returns>
3333
public static string FieldCssClass(this EditContext editContext, in FieldIdentifier fieldIdentifier)
3434
{
35-
var isValid = !editContext.GetValidationMessages(fieldIdentifier).Any();
36-
if (editContext.IsModified(fieldIdentifier))
37-
{
38-
return isValid ? "modified valid" : "modified invalid";
39-
}
40-
else
35+
var provider = editContext.Properties.TryGetValue(FieldCssClassProviderKey, out var customProvider)
36+
? (FieldCssClassProvider)customProvider
37+
: FieldCssClassProvider.Instance;
38+
39+
return provider.GetFieldCssClass(editContext, fieldIdentifier);
40+
}
41+
42+
/// <summary>
43+
/// Associates the supplied <see cref="FieldCssClassProvider"/> with the supplied <see cref="EditContext"/>.
44+
/// This customizes the field CSS class names used within the <see cref="EditContext"/>.
45+
/// </summary>
46+
/// <param name="editContext">The <see cref="EditContext"/>.</param>
47+
/// <param name="fieldCssClassProvider">The <see cref="FieldCssClassProvider"/>.</param>
48+
public static void SetFieldCssClassProvider(this EditContext editContext, FieldCssClassProvider fieldCssClassProvider)
49+
{
50+
if (fieldCssClassProvider is null)
4151
{
42-
return isValid ? "valid" : "invalid";
52+
throw new ArgumentNullException(nameof(fieldCssClassProvider));
4353
}
54+
55+
editContext.Properties[FieldCssClassProviderKey] = fieldCssClassProvider;
4456
}
4557
}
4658
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
// Copyright (c) .NET Foundation. All rights reserved.
2+
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
3+
4+
using System.Linq;
5+
6+
namespace Microsoft.AspNetCore.Components.Forms
7+
{
8+
/// <summary>
9+
/// Supplies CSS class names for form fields to represent their validation state or other
10+
/// state information from an <see cref="EditContext"/>.
11+
/// </summary>
12+
public class FieldCssClassProvider
13+
{
14+
internal readonly static FieldCssClassProvider Instance = new FieldCssClassProvider();
15+
16+
/// <summary>
17+
/// Gets a string that indicates the status of the specified field as a CSS class.
18+
/// </summary>
19+
/// <param name="editContext">The <see cref="EditContext"/>.</param>
20+
/// <param name="fieldIdentifier">The <see cref="FieldIdentifier"/>.</param>
21+
/// <returns>A CSS class name string.</returns>
22+
public virtual string GetFieldCssClass(EditContext editContext, in FieldIdentifier fieldIdentifier)
23+
{
24+
var isValid = !editContext.GetValidationMessages(fieldIdentifier).Any();
25+
if (editContext.IsModified(fieldIdentifier))
26+
{
27+
return isValid ? "modified valid" : "modified invalid";
28+
}
29+
else
30+
{
31+
return isValid ? "valid" : "invalid";
32+
}
33+
}
34+
}
35+
}

src/Components/test/E2ETest/Tests/FormsTest.cs

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -560,6 +560,23 @@ public void SelectComponentSupportsOptionsComponent()
560560
Browser.Equal("", () => selectWithoutComponent.GetAttribute("value"));
561561
}
562562

563+
[Fact]
564+
public void RespectsCustomFieldCssClassProvider()
565+
{
566+
var appElement = MountTypicalValidationComponent();
567+
var socksInput = appElement.FindElement(By.ClassName("socks")).FindElement(By.TagName("input"));
568+
var messagesAccessor = CreateValidationMessagesAccessor(appElement);
569+
570+
// Validates on edit
571+
Browser.Equal("valid-socks", () => socksInput.GetAttribute("class"));
572+
socksInput.SendKeys("Purple\t");
573+
Browser.Equal("modified valid-socks", () => socksInput.GetAttribute("class"));
574+
575+
// Can become invalid
576+
socksInput.SendKeys(" with yellow spots\t");
577+
Browser.Equal("modified invalid-socks", () => socksInput.GetAttribute("class"));
578+
}
579+
563580
[Fact]
564581
public void NavigateOnSubmitWorks()
565582
{
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
// Copyright (c) .NET Foundation. All rights reserved.
2+
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
3+
4+
using System;
5+
using System.Linq;
6+
using Microsoft.AspNetCore.Components.Forms;
7+
8+
namespace BasicTestApp.FormsTest
9+
{
10+
// For E2E testing, this is a rough example of a field CSS class provider that looks for
11+
// a custom attribute defining CSS class names. It isn't very efficient (it does reflection
12+
// and allocates on every invocation) but is sufficient for testing purposes.
13+
public class CustomFieldCssClassProvider : FieldCssClassProvider
14+
{
15+
public override string GetFieldCssClass(EditContext editContext, in FieldIdentifier fieldIdentifier)
16+
{
17+
var cssClassName = base.GetFieldCssClass(editContext, fieldIdentifier);
18+
19+
// If we can find a [CustomValidationClassName], use it
20+
var propertyInfo = fieldIdentifier.Model.GetType().GetProperty(fieldIdentifier.FieldName);
21+
if (propertyInfo != null)
22+
{
23+
var customValidationClassName = (CustomValidationClassNameAttribute)propertyInfo
24+
.GetCustomAttributes(typeof(CustomValidationClassNameAttribute), true)
25+
.FirstOrDefault();
26+
if (customValidationClassName != null)
27+
{
28+
cssClassName = string.Join(' ', cssClassName.Split(' ').Select(token => token switch
29+
{
30+
"valid" => customValidationClassName.Valid ?? token,
31+
"invalid" => customValidationClassName.Invalid ?? token,
32+
_ => token,
33+
}));
34+
}
35+
}
36+
37+
return cssClassName;
38+
}
39+
}
40+
41+
[AttributeUsage(AttributeTargets.Property)]
42+
public class CustomValidationClassNameAttribute : Attribute
43+
{
44+
public string Valid { get; set; }
45+
public string Invalid { get; set; }
46+
}
47+
}

src/Components/test/testassets/BasicTestApp/FormsTest/TypicalValidationComponent.razor

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,9 @@
6666
</InputRadioGroup>
6767
</InputRadioGroup>
6868
</p>
69+
<p class="socks">
70+
Socks color: <InputText @bind-Value="person.SocksColor" />
71+
</p>
6972
<p class="accepts-terms">
7073
Accepts terms: <InputCheckbox @bind-Value="person.AcceptsTerms" title="You have to check this" />
7174
</p>
@@ -98,6 +101,7 @@
98101
protected override void OnInitialized()
99102
{
100103
editContext = new EditContext(person);
104+
editContext.SetFieldCssClassProvider(new CustomFieldCssClassProvider());
101105
customValidationMessageStore = new ValidationMessageStore(editContext);
102106
}
103107

@@ -145,6 +149,9 @@
145149
[Required, EnumDataType(typeof(Country))]
146150
public Country? Country { get; set; } = null;
147151

152+
[Required, StringLength(10), CustomValidationClassName(Valid = "valid-socks", Invalid = "invalid-socks")]
153+
public string SocksColor { get; set; }
154+
148155
public string Username { get; set; }
149156
}
150157

0 commit comments

Comments
 (0)