Skip to content

[Blazor] Form mapping error handling and validation integration for SSR Blazor #48990

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 28 commits into from
Jun 26, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
faef67a
Add error handling to form data mapper
javiercn Jun 22, 2023
4f052fe
Make resource available in Minimal APIs
javiercn Jun 22, 2023
7cf37c4
Add support for reporting errors
javiercn Jun 22, 2023
f64e412
Fix build
javiercn Jun 22, 2023
e04e298
E2E tests
javiercn Jun 22, 2023
54b7c4e
Edit context integration
javiercn Jun 22, 2023
84ac741
Preserve submitted values on parsing failures
javiercn Jun 23, 2023
c9b93a6
Fix build
javiercn Jun 23, 2023
1f0b6a2
Cleanup binding and wire up validation
javiercn Jun 23, 2023
1404bf9
Cleanups
javiercn Jun 23, 2023
9545ca5
Fix tests
javiercn Jun 23, 2023
69b4351
Cleanups and test fixes
javiercn Jun 23, 2023
843cf48
Dictionary binding support
javiercn Jun 23, 2023
6090e88
Collection binding support
javiercn Jun 23, 2023
f987823
Cleanup minimal API project target
javiercn Jun 23, 2023
f096156
Add E2E tests for binding complex types, collections, and dictionaries
javiercn Jun 23, 2023
6de85fe
Handle validation without requiring the FieldPath
javiercn Jun 23, 2023
f2dad97
Api and test fixes
javiercn Jun 23, 2023
b4e4951
API cleanups and relayering
javiercn Jun 23, 2023
2d0b461
More tests and fixes
javiercn Jun 24, 2023
0876c92
Fix build
javiercn Jun 24, 2023
267486d
Update GetError* APIs to use BindingError
javiercn Jun 26, 2023
ca0ab6c
Update EditContextModelBindingContextExtensions.cs to only call Notif…
javiercn Jun 26, 2023
d645056
Address feedback
javiercn Jun 26, 2023
ca7396b
Address feedback and update E2E tests
javiercn Jun 26, 2023
2cef6d8
Fix accidental change
javiercn Jun 26, 2023
563ef9c
Update minimal APIs form binding integration (#48999)
captainsafia Jun 26, 2023
a8e42a2
Pass ArrayPool.Shared instance to Return invocation
captainsafia Jun 26, 2023
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 2 additions & 15 deletions src/Components/Authorization/test/AuthorizeRouteViewTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -472,20 +472,7 @@ public TestNavigationManager()

private class TestFormValueSupplier : IFormValueSupplier
{
public bool CanBind(string formName, Type valueType)
{
return false;
}

public bool CanConvertSingleValue(Type type)
{
return false;
}

public bool TryBind(string formName, Type valueType, [NotNullWhen(true)] out object boundValue)
{
boundValue = null;
return false;
}
public bool CanBind(Type valueType, string formName = null) => false;
public void Bind(FormValueSupplierContext context) { }
}
}
83 changes: 83 additions & 0 deletions src/Components/Components/src/Binding/BindingError.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

namespace Microsoft.AspNetCore.Components;

/// <summary>
/// An error that occurred during the form mapping process.
/// </summary>
public class BindingError
{
private static readonly char[] Separators = new char[] { '.', '[' };
private readonly List<FormattableString> _errorMessages;

/// <summary>
/// Initializes a new instance of <see cref="BindingError"/>.
/// </summary>
/// <param name="path">The path from the root of the binding operation to the property or element that failed to bind.</param>
/// <param name="errorMessages">The error messages associated with the binding error.</param>
/// <param name="attemptedValue">The attempted value that failed to bind.</param>
internal BindingError(string path, List<FormattableString> errorMessages, string? attemptedValue)
{
_errorMessages = errorMessages;
AttemptedValue = attemptedValue;
Path = path;
Name = GetName(Path);
}

/// <summary>
/// Gets or sets the instance that contains the property or element that failed to bind.
/// </summary>
/// <remarks>
/// For object models, this is the instance of the object that contains the property that failed to bind.
/// For collection models, this is the collection instance that contains the element that failed to bind.
/// For dictionaries, this is the dictionary instance that contains the element that failed to bind.
/// </remarks>
public object Container { get; internal set; } = null!;

/// <summary>
/// Gets or sets the name of the property or element that failed to bind.
/// </summary>
public string Name { get; }

/// <summary>
/// Gets or sets the full path from the model root to the property or element that failed to bind.
/// </summary>
public string Path { get; }

/// <summary>
/// Gets the list of error messages associated with the binding errors for this field.
/// </summary>
public IReadOnlyList<FormattableString> ErrorMessages => _errorMessages;

/// <summary>
/// Gets the attempted value that failed to bind (if any).
/// </summary>
public string? AttemptedValue { get; }

private static string GetName(string path)
{
var errorKey = path;
var lastSeparatorIndex = path.LastIndexOfAny(Separators);
if (lastSeparatorIndex >= 0)
{
if (path[lastSeparatorIndex] == '[')
{
var closingBracket = path.IndexOf(']', lastSeparatorIndex);
// content within brackets
errorKey = path[(lastSeparatorIndex + 1)..closingBracket];
}
else
{
errorKey = path[(lastSeparatorIndex + 1)..];
}
}

return errorKey;
}

internal void AddError(FormattableString error)
{
_errorMessages.Add(error);
}
}
16 changes: 2 additions & 14 deletions src/Components/Components/src/Binding/CascadingModelBinder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,8 @@ _bindingContext is null ||
throw new InvalidOperationException($"'{nameof(CascadingModelBinder)}' 'Name' can't change after initialized.");
}

_bindingContext = new ModelBindingContext(name, bindingId, CanBind);
_bindingContext = new ModelBindingContext(name, bindingId);
ParentContext?.SetErrors(name, _bindingContext);
}

string GenerateBindingContextId(string name)
Expand All @@ -141,19 +142,6 @@ string GenerateBindingContextId(string name)
var hashIndex = bindingId.IndexOf('#');
return hashIndex == -1 ? bindingId : new string(bindingId.AsSpan(0, hashIndex));
}

bool CanBind(Type type)
{
foreach (var provider in ModelBindingProviders)
{
if (provider.SupportsParameterType(type))
{
return true;
}
}

return false;
}
}

bool ICascadingValueSupplier.CanSupplyValue(in CascadingParameterInfo parameterInfo)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

namespace Microsoft.AspNetCore.Components.Binding;

/// <summary>
/// Context for binding a form value.
/// </summary>
public class FormValueSupplierContext
{
private bool _resultSet;

/// <summary>
/// Initializes a new instance of <see cref="FormValueSupplierContext"/>.
/// </summary>
/// <param name="formName">The name of the form to bind data from.</param>
/// <param name="valueType">The <see cref="Type"/> of the value to bind.</param>
/// <param name="parameterName">The name of the parameter to bind data to.</param>
public FormValueSupplierContext(
string formName,
Type valueType,
string parameterName)
{
ArgumentNullException.ThrowIfNull(formName, nameof(formName));
ArgumentNullException.ThrowIfNull(valueType, nameof(valueType));
ArgumentNullException.ThrowIfNull(parameterName, nameof(parameterName));
FormName = formName;
ParameterName = parameterName;
ValueType = valueType;
}

/// <summary>
/// Gets the name of the form to bind data from.
/// </summary>
public string FormName { get; }

/// <summary>
/// Gets the name of the parameter to bind data to.
/// </summary>
public string ParameterName { get; }

/// <summary>
/// Gets the <see cref="Type"/> of the value to bind.
/// </summary>
public Type ValueType { get; }

/// <summary>
/// Gets the callback to invoke when an error occurs.
/// </summary>
public Action<string, FormattableString, string?>? OnError { get; set; }

/// <summary>
/// Maps a set of errors to a concrete containing instance.
/// </summary>
/// <remarks>
/// For example, maps errors for a given property in a class to the class instance.
/// This is required so that validation can work without the need of the full identifier.
/// </remarks>
public Action<string, object>? MapErrorToContainer { get; set; }

/// <summary>
/// Gets the result of the binding operation.
/// </summary>
public object? Result { get; private set; }

/// <summary>
/// Sets the result of the binding operation.
/// </summary>
/// <param name="result">The result of the binding operation.</param>
/// <exception cref="InvalidOperationException">Thrown if the result has already been set.</exception>
public void SetResult(object? result)
{
if (_resultSet)
{
throw new InvalidOperationException($"The result has already been set to '{Result}'.");
}
_resultSet = true;
Result = result;
}
}
23 changes: 5 additions & 18 deletions src/Components/Components/src/Binding/IFormValueSupplier.cs
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System.Diagnostics.CodeAnalysis;

namespace Microsoft.AspNetCore.Components.Binding;

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

/// <summary>
/// Determines whether a given <see cref="Type"/> can be converted from a single string value.
/// For example, strings, numbers, boolean values, enums, guids, etc. fall in this category.
/// Binds the form with the specified name to a value of the specified type.
/// <param name="context">The <see cref="FormValueSupplierContext"/>.</param>
/// </summary>
/// <param name="type">The <see cref="Type"/> to check.</param>
/// <returns><c>true</c> if the type can be converted from a single string value; otherwise, <c>false</c>.</returns>
bool CanConvertSingleValue(Type type);

/// <summary>
/// Tries to bind the form with the specified name to a value of the specified type.
/// </summary>
/// <param name="formName">The form name to bind data from.</param>
/// <param name="valueType">The <see cref="Type"/> for the value to bind.</param>
/// <param name="boundValue">The bound value if succeeded.</param>
/// <returns><c>true</c> if the form was bound successfully; otherwise, <c>false</c>.</returns>
bool TryBind(string formName, Type valueType, [NotNullWhen(true)] out object? boundValue);
void Bind(FormValueSupplierContext context);
}
Loading