Skip to content

Commit cdfd8c0

Browse files
authored
[Blazor] Form mapping error handling and validation integration for SSR Blazor (#48990)
[Blazor] Model binding error handling and validation integration * Implemented error handling and reporting for form data mapping for Minimal APIs and Blazor. * Minimal APIs: An exception is throw on the first validation error. * Blazor: A callback is provided to capture the errors. Binding continues when no exception is thrown from the callback. * This enables us to capture as much information as possible in the presence of errors. * Errors are represented as FormattableString so that the error message can be localized if necessary by developers. * The error might contain the attempted value to allow redisplaying the correct value on the UI. * Adds support for reporting errors in Blazor. ModelBindingContext captures the errors and the attempted values during the binding process and makes them available to the application. * Updated the FormValueSupplier API to better integrate with model binding. * Removed the need for CanConvertSingleValue. * We now use the parameter name as part of the path to bind the parameter, for simple and complex values. * Updated CanBind to enable calling it without a form name when we just want to determine if a given type is bindable. * Updated the TryBind API to Bind and passed in a context object instead of multiple parameters. * Integrates with EditForm and EditContext: * Automatically wire up the a validator to invalidate the form in the presence of errors. * Populates Input elements with the attempted value when possible after binding failed.
1 parent a31d83b commit cdfd8c0

File tree

59 files changed

+2684
-463
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

59 files changed

+2684
-463
lines changed

src/Components/Authorization/test/AuthorizeRouteViewTest.cs

Lines changed: 2 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -472,20 +472,7 @@ public TestNavigationManager()
472472

473473
private class TestFormValueSupplier : IFormValueSupplier
474474
{
475-
public bool CanBind(string formName, Type valueType)
476-
{
477-
return false;
478-
}
479-
480-
public bool CanConvertSingleValue(Type type)
481-
{
482-
return false;
483-
}
484-
485-
public bool TryBind(string formName, Type valueType, [NotNullWhen(true)] out object boundValue)
486-
{
487-
boundValue = null;
488-
return false;
489-
}
475+
public bool CanBind(Type valueType, string formName = null) => false;
476+
public void Bind(FormValueSupplierContext context) { }
490477
}
491478
}
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
namespace Microsoft.AspNetCore.Components;
5+
6+
/// <summary>
7+
/// An error that occurred during the form mapping process.
8+
/// </summary>
9+
public class BindingError
10+
{
11+
private static readonly char[] Separators = new char[] { '.', '[' };
12+
private readonly List<FormattableString> _errorMessages;
13+
14+
/// <summary>
15+
/// Initializes a new instance of <see cref="BindingError"/>.
16+
/// </summary>
17+
/// <param name="path">The path from the root of the binding operation to the property or element that failed to bind.</param>
18+
/// <param name="errorMessages">The error messages associated with the binding error.</param>
19+
/// <param name="attemptedValue">The attempted value that failed to bind.</param>
20+
internal BindingError(string path, List<FormattableString> errorMessages, string? attemptedValue)
21+
{
22+
_errorMessages = errorMessages;
23+
AttemptedValue = attemptedValue;
24+
Path = path;
25+
Name = GetName(Path);
26+
}
27+
28+
/// <summary>
29+
/// Gets or sets the instance that contains the property or element that failed to bind.
30+
/// </summary>
31+
/// <remarks>
32+
/// For object models, this is the instance of the object that contains the property that failed to bind.
33+
/// For collection models, this is the collection instance that contains the element that failed to bind.
34+
/// For dictionaries, this is the dictionary instance that contains the element that failed to bind.
35+
/// </remarks>
36+
public object Container { get; internal set; } = null!;
37+
38+
/// <summary>
39+
/// Gets or sets the name of the property or element that failed to bind.
40+
/// </summary>
41+
public string Name { get; }
42+
43+
/// <summary>
44+
/// Gets or sets the full path from the model root to the property or element that failed to bind.
45+
/// </summary>
46+
public string Path { get; }
47+
48+
/// <summary>
49+
/// Gets the list of error messages associated with the binding errors for this field.
50+
/// </summary>
51+
public IReadOnlyList<FormattableString> ErrorMessages => _errorMessages;
52+
53+
/// <summary>
54+
/// Gets the attempted value that failed to bind (if any).
55+
/// </summary>
56+
public string? AttemptedValue { get; }
57+
58+
private static string GetName(string path)
59+
{
60+
var errorKey = path;
61+
var lastSeparatorIndex = path.LastIndexOfAny(Separators);
62+
if (lastSeparatorIndex >= 0)
63+
{
64+
if (path[lastSeparatorIndex] == '[')
65+
{
66+
var closingBracket = path.IndexOf(']', lastSeparatorIndex);
67+
// content within brackets
68+
errorKey = path[(lastSeparatorIndex + 1)..closingBracket];
69+
}
70+
else
71+
{
72+
errorKey = path[(lastSeparatorIndex + 1)..];
73+
}
74+
}
75+
76+
return errorKey;
77+
}
78+
79+
internal void AddError(FormattableString error)
80+
{
81+
_errorMessages.Add(error);
82+
}
83+
}

src/Components/Components/src/Binding/CascadingModelBinder.cs

Lines changed: 2 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -132,7 +132,8 @@ _bindingContext is null ||
132132
throw new InvalidOperationException($"'{nameof(CascadingModelBinder)}' 'Name' can't change after initialized.");
133133
}
134134

135-
_bindingContext = new ModelBindingContext(name, bindingId, CanBind);
135+
_bindingContext = new ModelBindingContext(name, bindingId);
136+
ParentContext?.SetErrors(name, _bindingContext);
136137
}
137138

138139
string GenerateBindingContextId(string name)
@@ -141,19 +142,6 @@ string GenerateBindingContextId(string name)
141142
var hashIndex = bindingId.IndexOf('#');
142143
return hashIndex == -1 ? bindingId : new string(bindingId.AsSpan(0, hashIndex));
143144
}
144-
145-
bool CanBind(Type type)
146-
{
147-
foreach (var provider in ModelBindingProviders)
148-
{
149-
if (provider.SupportsParameterType(type))
150-
{
151-
return true;
152-
}
153-
}
154-
155-
return false;
156-
}
157145
}
158146

159147
bool ICascadingValueSupplier.CanSupplyValue(in CascadingParameterInfo parameterInfo)
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
namespace Microsoft.AspNetCore.Components.Binding;
5+
6+
/// <summary>
7+
/// Context for binding a form value.
8+
/// </summary>
9+
public class FormValueSupplierContext
10+
{
11+
private bool _resultSet;
12+
13+
/// <summary>
14+
/// Initializes a new instance of <see cref="FormValueSupplierContext"/>.
15+
/// </summary>
16+
/// <param name="formName">The name of the form to bind data from.</param>
17+
/// <param name="valueType">The <see cref="Type"/> of the value to bind.</param>
18+
/// <param name="parameterName">The name of the parameter to bind data to.</param>
19+
public FormValueSupplierContext(
20+
string formName,
21+
Type valueType,
22+
string parameterName)
23+
{
24+
ArgumentNullException.ThrowIfNull(formName, nameof(formName));
25+
ArgumentNullException.ThrowIfNull(valueType, nameof(valueType));
26+
ArgumentNullException.ThrowIfNull(parameterName, nameof(parameterName));
27+
FormName = formName;
28+
ParameterName = parameterName;
29+
ValueType = valueType;
30+
}
31+
32+
/// <summary>
33+
/// Gets the name of the form to bind data from.
34+
/// </summary>
35+
public string FormName { get; }
36+
37+
/// <summary>
38+
/// Gets the name of the parameter to bind data to.
39+
/// </summary>
40+
public string ParameterName { get; }
41+
42+
/// <summary>
43+
/// Gets the <see cref="Type"/> of the value to bind.
44+
/// </summary>
45+
public Type ValueType { get; }
46+
47+
/// <summary>
48+
/// Gets the callback to invoke when an error occurs.
49+
/// </summary>
50+
public Action<string, FormattableString, string?>? OnError { get; set; }
51+
52+
/// <summary>
53+
/// Maps a set of errors to a concrete containing instance.
54+
/// </summary>
55+
/// <remarks>
56+
/// For example, maps errors for a given property in a class to the class instance.
57+
/// This is required so that validation can work without the need of the full identifier.
58+
/// </remarks>
59+
public Action<string, object>? MapErrorToContainer { get; set; }
60+
61+
/// <summary>
62+
/// Gets the result of the binding operation.
63+
/// </summary>
64+
public object? Result { get; private set; }
65+
66+
/// <summary>
67+
/// Sets the result of the binding operation.
68+
/// </summary>
69+
/// <param name="result">The result of the binding operation.</param>
70+
/// <exception cref="InvalidOperationException">Thrown if the result has already been set.</exception>
71+
public void SetResult(object? result)
72+
{
73+
if (_resultSet)
74+
{
75+
throw new InvalidOperationException($"The result has already been set to '{Result}'.");
76+
}
77+
_resultSet = true;
78+
Result = result;
79+
}
80+
}
Lines changed: 5 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,6 @@
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.Diagnostics.CodeAnalysis;
5-
64
namespace Microsoft.AspNetCore.Components.Binding;
75

86
/// <summary>
@@ -13,25 +11,14 @@ public interface IFormValueSupplier
1311
/// <summary>
1412
/// Determines whether the specified value type can be bound.
1513
/// </summary>
16-
/// <param name="formName">The form name to bind data from.</param>
1714
/// <param name="valueType">The <see cref="Type"/> for the value to bind.</param>
15+
/// <param name="formName">The form name to bind data from or null to only validate the type can be bound.</param>
1816
/// <returns><c>true</c> if the value type can be bound; otherwise, <c>false</c>.</returns>
19-
bool CanBind(string formName, Type valueType);
17+
bool CanBind(Type valueType, string? formName = null);
2018

2119
/// <summary>
22-
/// Determines whether a given <see cref="Type"/> can be converted from a single string value.
23-
/// For example, strings, numbers, boolean values, enums, guids, etc. fall in this category.
20+
/// Binds the form with the specified name to a value of the specified type.
21+
/// <param name="context">The <see cref="FormValueSupplierContext"/>.</param>
2422
/// </summary>
25-
/// <param name="type">The <see cref="Type"/> to check.</param>
26-
/// <returns><c>true</c> if the type can be converted from a single string value; otherwise, <c>false</c>.</returns>
27-
bool CanConvertSingleValue(Type type);
28-
29-
/// <summary>
30-
/// Tries to bind the form with the specified name to a value of the specified type.
31-
/// </summary>
32-
/// <param name="formName">The form name to bind data from.</param>
33-
/// <param name="valueType">The <see cref="Type"/> for the value to bind.</param>
34-
/// <param name="boundValue">The bound value if succeeded.</param>
35-
/// <returns><c>true</c> if the form was bound successfully; otherwise, <c>false</c>.</returns>
36-
bool TryBind(string formName, Type valueType, [NotNullWhen(true)] out object? boundValue);
23+
void Bind(FormValueSupplierContext context);
3724
}

0 commit comments

Comments
 (0)