Skip to content

Commit

Permalink
[release/8.0-preview6] [Blazor] Form mapping error handling and valid…
Browse files Browse the repository at this point in the history
…ation integration for SSR Blazor (#49031)

Backport of #48990 to release/8.0-preview6

/cc @javiercn

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

Adds support for error handling and validation to Server Side Rendered Blazor.

## Description

Integrates the form data binding experience with Blazor and provides support for error handling.

Fixes #46983 (in this specific format)

## Customer Impact

This is one of the remaining major functionalities for Blazor Server Side rendering, so we want to make sure we can get as much feedback as possible on the experience. Specially since we are planning an app building exercise.

## Regression?

- [ ] Yes
- [X] No

[If yes, specify the version the behavior has regressed from]

## Risk

- [ ] High
- [ ] Medium
- [X] Low

The changes should be additive, and we've included a significant amount of test coverage for the new functionality.

## Verification

- [X] Manual (required)
- [X] Automated

## Packaging changes reviewed?

- [ ] Yes
- [ ] No
- [X] N/A

----

## When servicing release/2.1

- [ ] Make necessary changes in eng/PatchConfig.props
  • Loading branch information
github-actions[bot] authored Jun 26, 2023
1 parent 7d75f00 commit 417ce3b
Show file tree
Hide file tree
Showing 59 changed files with 2,684 additions and 463 deletions.
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
80 changes: 80 additions & 0 deletions src/Components/Components/src/Binding/FormValueSupplierContext.cs
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

0 comments on commit 417ce3b

Please sign in to comment.