Skip to content

Commit eeb5186

Browse files
oroztociljaviercn
authored andcommitted
Add support for enumerable collections
1 parent c1946e7 commit eeb5186

File tree

3 files changed

+128
-32
lines changed

3 files changed

+128
-32
lines changed

src/Components/Forms/src/EditContextDataAnnotationsExtensions.cs

Lines changed: 76 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,15 @@
11
// Licensed to the .NET Foundation under one or more agreements.
22
// The .NET Foundation licenses this file to you under the MIT license.
33

4+
using System.Collections;
45
using System.Collections.Concurrent;
56
using System.ComponentModel.DataAnnotations;
67
using System.Diagnostics.CodeAnalysis;
8+
using System.Linq;
79
using System.Reflection;
810
using System.Reflection.Metadata;
911
using System.Runtime.InteropServices;
12+
using System.Text.RegularExpressions;
1013
using Microsoft.AspNetCore.Http.Validation;
1114
using Microsoft.Extensions.DependencyInjection;
1215
using Microsoft.Extensions.Options;
@@ -18,7 +21,7 @@ namespace Microsoft.AspNetCore.Components.Forms;
1821
/// <summary>
1922
/// Extension methods to add DataAnnotations validation to an <see cref="EditContext"/>.
2023
/// </summary>
21-
public static class EditContextDataAnnotationsExtensions
24+
public static partial class EditContextDataAnnotationsExtensions
2225
{
2326
/// <summary>
2427
/// Adds DataAnnotations validation support to the <see cref="EditContext"/>.
@@ -62,7 +65,7 @@ private static void ClearCache(Type[]? _)
6265
}
6366
#pragma warning restore IDE0051 // Remove unused private members
6467

65-
private sealed class DataAnnotationsEventSubscriptions : IDisposable
68+
private sealed partial class DataAnnotationsEventSubscriptions : IDisposable
6669
{
6770
private static readonly ConcurrentDictionary<(Type ModelType, string FieldName), PropertyInfo?> _propertyInfoCache = new();
6871

@@ -85,6 +88,7 @@ public DataAnnotationsEventSubscriptions(EditContext editContext, IServiceProvid
8588
}
8689
}
8790

91+
// TODO(OR): Should this also use ValidatablePropertyInfo.ValidateAsync?
8892
[UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Model types are expected to be defined in assemblies that do not get trimmed.")]
8993
private void OnFieldChanged(object? sender, FieldChangedEventArgs eventArgs)
9094
{
@@ -180,22 +184,81 @@ private bool TryValidateTypeInfo(ValidationContext validationContext)
180184

181185
if (validationErrors is not null && validationErrors.Count > 0)
182186
{
183-
foreach (var (key, value) in validationErrors)
187+
foreach (var (fieldPath, messages) in validationErrors)
184188
{
185-
var keySegments = key.Split('.');
186-
var container = keySegments.Length > 1
187-
? GetPropertyByPath(_editContext.Model, keySegments[..^1])
188-
: _editContext.Model;
189-
var fieldName = keySegments[^1];
189+
var dotSegments = fieldPath.Split('.');
190+
var fieldName = dotSegments[^1];
191+
var fieldContainer = GetFieldContainer(_editContext.Model, dotSegments[..^1]);
190192

191-
_messages.Add(new FieldIdentifier(container, fieldName), value);
193+
_messages.Add(new FieldIdentifier(fieldContainer, fieldName), messages);
192194
}
193195
}
194196

195197
return true;
196198
}
197199
#pragma warning restore ASP0029 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
198200

201+
// TODO(OR): Replace this with a more robust implementation or a different approach. Ideally, collect references during the validation process itself.
202+
private static object GetFieldContainer(object obj, string[] dotSegments)
203+
{
204+
// The method does not check nullity and index bounds everywhere as the path is constructed internally and assumed to be correct.
205+
object currentObject = obj;
206+
207+
for (int i = 0; i < dotSegments.Length; i++)
208+
{
209+
string segment = dotSegments[i];
210+
211+
if (currentObject == null)
212+
{
213+
string traversedPath = string.Join(".", dotSegments.Take(i));
214+
throw new ArgumentException($"Cannot access segment '{segment}' because the path '{traversedPath}' resolved to null.");
215+
}
216+
217+
Match match = _pathSegmentRegex.Match(segment);
218+
if (!match.Success)
219+
{
220+
throw new ArgumentException($"Invalid path segment: '{segment}'.");
221+
}
222+
223+
string propertyName = match.Groups[1].Value;
224+
string? indexStr = match.Groups[2].Success ? match.Groups[2].Value : null;
225+
226+
Type currentType = currentObject.GetType();
227+
PropertyInfo propertyInfo = currentType.GetProperty(propertyName, BindingFlags.Public | BindingFlags.Instance | BindingFlags.IgnoreCase);
228+
object propertyValue = propertyInfo!.GetValue(currentObject)!;
229+
230+
if (indexStr != null) // Indexed access
231+
{
232+
if (!int.TryParse(indexStr, out int index))
233+
{
234+
throw new ArgumentException($"Invalid index '{indexStr}' in segment '{segment}'.");
235+
}
236+
237+
if (propertyValue is Array array)
238+
{
239+
currentObject = array.GetValue(index)!;
240+
}
241+
else if (propertyValue is IList list)
242+
{
243+
currentObject = list[index]!;
244+
}
245+
else if (propertyValue is IEnumerable enumerable)
246+
{
247+
currentObject = enumerable.Cast<object>().ElementAt(index);
248+
}
249+
else
250+
{
251+
throw new ArgumentException($"Property '{propertyName}' is not an array, list, or enumerable. Cannot access by index in segment '{segment}'.");
252+
}
253+
}
254+
else // Simple property access
255+
{
256+
currentObject = propertyValue;
257+
}
258+
}
259+
return currentObject!;
260+
}
261+
199262
public void Dispose()
200263
{
201264
_messages.Clear();
@@ -231,19 +294,10 @@ internal void ClearCache()
231294
_propertyInfoCache.Clear();
232295
}
233296

234-
// TODO(OR): Replace this with more robust implementation.
235-
private static object GetPropertyByPath(object obj, string[] path)
236-
{
237-
var currentObject = obj;
238-
239-
foreach (string propertyName in path)
240-
{
241-
Type currentType = currentObject!.GetType();
242-
PropertyInfo propertyInfo = currentType.GetProperty(propertyName)!;
243-
currentObject = propertyInfo.GetValue(currentObject);
244-
}
297+
private static readonly Regex _pathSegmentRegex = PathSegmentRegexGen();
245298

246-
return currentObject!;
247-
}
299+
// Regex to parse "PropertyName" or "PropertyName[index]"
300+
[GeneratedRegex(@"^([a-zA-Z_]\w*)(?:\[(\d+)\])?$", RegexOptions.Compiled)]
301+
private static partial Regex PathSegmentRegexGen();
248302
}
249303
}

src/Components/Samples/BlazorUnitedApp/Pages/ValidatedForm.razor

Lines changed: 43 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,10 @@
1111

1212
<EditForm Model="@Model" OnValidSubmit="@HandleValidSubmit" OnInvalidSubmit="@HandleInvalidSubmit">
1313
<div class="alert @StatusClass">@StatusMessage</div>
14-
14+
1515
<DataAnnotationsValidator />
1616
<ValidationSummary />
17-
17+
1818
<div class="form-group">
1919
<label for="name">Author.Name: </label>
2020
<InputText Id="name" Class="form-control" @bind-Value="@Model.Author.Name"></InputText>
@@ -41,27 +41,60 @@
4141
<ValidationMessage For="@(() => Model.Author.Address.Street)" />
4242
</div>
4343
<div class="form-group">
44-
<label for="body">Text: </label>
45-
<InputTextArea Id="body" Class="form-control" @bind-Value="@Model.Text"></InputTextArea>
46-
<ValidationMessage For="@(() => Model.Text)" />
44+
<label for="body">Title: </label>
45+
<InputTextArea Id="body" Class="form-control" @bind-Value="@Model.Title"></InputTextArea>
46+
<ValidationMessage For="@(() => Model.Title)" />
4747
</div>
48+
49+
50+
@for (int i = 0; i < Model.Messages.Count; i++)
51+
{
52+
var itemIndex = i;
53+
<div class="item-row" style="margin-bottom: 15px; padding: 10px; border: 1px solid #ccc;">
54+
<h5>Item @(itemIndex + 1)</h5>
55+
<div class="form-group">
56+
<label for="@($"itemName{itemIndex}")">Name:</label>
57+
<InputText id="@($"itemName{itemIndex}")" @bind-Value="Model.Messages[itemIndex].Text" class="form-control" />
58+
<ValidationMessage For="@(() => Model.Messages[itemIndex].Text)" />
59+
</div>
60+
<button type="button" class="btn btn-danger btn-sm" @onclick="() => RemoveItem(itemIndex)">Remove Item</button>
61+
</div>
62+
}
63+
64+
<button type="button" class="btn btn-primary" @onclick="AddMessage">Add Item</button>
65+
4866
<button type="submit">Ok</button>
49-
67+
5068
</EditForm>
51-
69+
5270
@code
5371
{
5472
private string? StatusMessage;
5573
private string? StatusClass;
56-
74+
75+
private List<List<Address>> ListModel = new List<List<Address>> { new List<Address> { new Address { City = "Brno" }, new Address { } } };
76+
5777
private GuestbookEntry Model = new GuestbookEntry();
58-
78+
79+
private void AddMessage()
80+
{
81+
Model.Messages.Add(new Message());
82+
}
83+
84+
private void RemoveItem(int index)
85+
{
86+
if (index >= 0 && index < Model.Messages.Count)
87+
{
88+
Model.Messages.RemoveAt(index);
89+
}
90+
}
91+
5992
protected void HandleValidSubmit()
6093
{
6194
StatusClass = "alert-info";
6295
StatusMessage = DateTime.Now + " Handle valid submit";
6396
}
64-
97+
6598
protected void HandleInvalidSubmit()
6699
{
67100
StatusClass = "alert-danger";

src/Components/Samples/BlazorUnitedApp/Validation/Types.cs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,15 @@ public class GuestbookEntry
1515
public Author Author { get; set; } = new Author();
1616

1717
[Required]
18+
public string? Title { get; set; }
19+
20+
public List<Message> Messages { get; set; } = [];
21+
}
22+
23+
public class Message
24+
{
25+
[Required]
26+
[StringLength(50, ErrorMessage = "Name is too long.")]
1827
public string? Text { get; set; }
1928
}
2029

0 commit comments

Comments
 (0)