Skip to content

Commit

Permalink
Blazor input component support when EditContext is not supplied (#35640)
Browse files Browse the repository at this point in the history
  • Loading branch information
MackinnonBuck authored Aug 26, 2021
1 parent abdc2a2 commit afacc87
Show file tree
Hide file tree
Showing 15 changed files with 186 additions and 83 deletions.
2 changes: 1 addition & 1 deletion src/Components/Web.JS/dist/Release/blazor.server.js

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion src/Components/Web.JS/dist/Release/blazor.webview.js

Large diffs are not rendered by default.

31 changes: 31 additions & 0 deletions src/Components/Web/src/Forms/AttributeUtilities.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System.Globalization;

namespace Microsoft.AspNetCore.Components.Forms;

internal static class AttributeUtilities
{
public static string CombineClassNames(IReadOnlyDictionary<string, object>? additionalAttributes, string classNames)
{
if (additionalAttributes is null || !additionalAttributes.TryGetValue("class", out var @class))
{
return classNames;
}

var classAttributeValue = Convert.ToString(@class, CultureInfo.InvariantCulture);

if (string.IsNullOrEmpty(classAttributeValue))
{
return classNames;
}

if (string.IsNullOrEmpty(classNames))
{
return classAttributeValue;
}

return $"{classAttributeValue} {classNames}";
}
}
75 changes: 31 additions & 44 deletions src/Components/Web/src/Forms/InputBase.cs
Original file line number Diff line number Diff line change
@@ -1,13 +1,10 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Globalization;
using System.Linq;
using System.Linq.Expressions;
using System.Threading.Tasks;

namespace Microsoft.AspNetCore.Components.Forms
{
Expand All @@ -19,11 +16,12 @@ namespace Microsoft.AspNetCore.Components.Forms
public abstract class InputBase<TValue> : ComponentBase, IDisposable
{
private readonly EventHandler<ValidationStateChangedEventArgs> _validationStateChangedHandler;
private bool _hasInitializedParameters;
private bool _previousParsingAttemptFailed;
private ValidationMessageStore? _parsingValidationMessages;
private Type? _nullableUnderlyingType;

[CascadingParameter] EditContext CascadedEditContext { get; set; } = default!;
[CascadingParameter] private EditContext? CascadedEditContext { get; set; }

/// <summary>
/// Gets or sets a collection of additional attributes that will be applied to the created element.
Expand Down Expand Up @@ -57,6 +55,7 @@ public abstract class InputBase<TValue> : ComponentBase, IDisposable

/// <summary>
/// Gets the associated <see cref="Forms.EditContext"/>.
/// This property is uninitialized if the input does not have a parent <see cref="EditForm"/>.
/// </summary>
protected EditContext EditContext { get; set; } = default!;

Expand All @@ -78,7 +77,7 @@ protected TValue? CurrentValue
{
Value = value;
_ = ValueChanged.InvokeAsync(Value);
EditContext.NotifyFieldChanged(FieldIdentifier);
EditContext?.NotifyFieldChanged(FieldIdentifier);
}
}
}
Expand Down Expand Up @@ -112,21 +111,21 @@ protected string? CurrentValueAsString
{
parsingFailed = true;

if (_parsingValidationMessages == null)
// EditContext may be null if the input is not a child component of EditForm.
if (EditContext is not null)
{
_parsingValidationMessages = new ValidationMessageStore(EditContext);
}

_parsingValidationMessages.Add(FieldIdentifier, validationErrorMessage);
_parsingValidationMessages ??= new ValidationMessageStore(EditContext);
_parsingValidationMessages.Add(FieldIdentifier, validationErrorMessage);

// Since we're not writing to CurrentValue, we'll need to notify about modification from here
EditContext.NotifyFieldChanged(FieldIdentifier);
// Since we're not writing to CurrentValue, we'll need to notify about modification from here
EditContext.NotifyFieldChanged(FieldIdentifier);
}
}

// We can skip the validation notification if we were previously valid and still are
if (parsingFailed || _previousParsingAttemptFailed)
{
EditContext.NotifyValidationStateChanged();
EditContext?.NotifyValidationStateChanged();
_previousParsingAttemptFailed = parsingFailed;
}
}
Expand Down Expand Up @@ -159,62 +158,45 @@ protected InputBase()
protected abstract bool TryParseValueFromString(string? value, [MaybeNullWhen(false)] out TValue result, [NotNullWhen(false)] out string? validationErrorMessage);

/// <summary>
/// Gets a string that indicates the status of the field being edited. This will include
/// some combination of "modified", "valid", or "invalid", depending on the status of the field.
/// </summary>
private string FieldClass
=> EditContext.FieldCssClass(FieldIdentifier);

/// <summary>
/// Gets a CSS class string that combines the <c>class</c> attribute and <see cref="FieldClass"/>
/// properties. Derived components should typically use this value for the primary HTML element's
/// 'class' attribute.
/// Gets a CSS class string that combines the <c>class</c> attribute and and a string indicating
/// the status of the field being edited (a combination of "modified", "valid", and "invalid").
/// Derived components should typically use this value for the primary HTML element's 'class' attribute.
/// </summary>
protected string CssClass
{
get
{
if (AdditionalAttributes != null &&
AdditionalAttributes.TryGetValue("class", out var @class) &&
!string.IsNullOrEmpty(Convert.ToString(@class, CultureInfo.InvariantCulture)))
{
return $"{@class} {FieldClass}";
}

return FieldClass; // Never null or empty
var fieldClass = EditContext?.FieldCssClass(FieldIdentifier) ?? string.Empty;
return AttributeUtilities.CombineClassNames(AdditionalAttributes, fieldClass);
}
}


/// <inheritdoc />
[MemberNotNull(nameof(EditContext), nameof(CascadedEditContext))]
public override Task SetParametersAsync(ParameterView parameters)
{
parameters.SetParameterProperties(this);

if (EditContext == null)
if (!_hasInitializedParameters)
{
// This is the first run
// Could put this logic in OnInit, but its nice to avoid forcing people who override OnInit to call base.OnInit()

if (CascadedEditContext == null)
{
throw new InvalidOperationException($"{GetType()} requires a cascading parameter " +
$"of type {nameof(Forms.EditContext)}. For example, you can use {GetType().FullName} inside " +
$"an {nameof(EditForm)}.");
}

if (ValueExpression == null)
{
throw new InvalidOperationException($"{GetType()} requires a value for the 'ValueExpression' " +
$"parameter. Normally this is provided automatically when using 'bind-Value'.");
}

EditContext = CascadedEditContext;
FieldIdentifier = FieldIdentifier.Create(ValueExpression);
_nullableUnderlyingType = Nullable.GetUnderlyingType(typeof(TValue));

EditContext.OnValidationStateChanged += _validationStateChangedHandler;
if (CascadedEditContext != null)
{
EditContext = CascadedEditContext;
EditContext.OnValidationStateChanged += _validationStateChangedHandler;
}

_nullableUnderlyingType = Nullable.GetUnderlyingType(typeof(TValue));
_hasInitializedParameters = true;
}
else if (CascadedEditContext != EditContext)
{
Expand Down Expand Up @@ -242,6 +224,11 @@ private void OnValidateStateChanged(object? sender, ValidationStateChangedEventA

private void UpdateAdditionalValidationAttributes()
{
if (EditContext is null)
{
return;
}

var hasAriaInvalidAttribute = AdditionalAttributes != null && AdditionalAttributes.ContainsKey("aria-invalid");
if (EditContext.GetValidationMessages(FieldIdentifier).Any())
{
Expand Down
2 changes: 1 addition & 1 deletion src/Components/Web/src/Forms/InputNumber.cs
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ protected override void BuildRenderTree(RenderTreeBuilder builder)
builder.AddAttribute(1, "step", _stepAttributeValue);
builder.AddMultipleAttributes(2, AdditionalAttributes);
builder.AddAttribute(3, "type", "number");
builder.AddAttribute(4, "class", CssClass);
builder.AddAttributeIfNotNullOrEmpty(4, "class", CssClass);
builder.AddAttribute(5, "value", BindConverter.FormatValue(CurrentValueAsString));
builder.AddAttribute(6, "onchange", EventCallback.Factory.CreateBinder<string?>(this, __value => CurrentValueAsString = __value, CurrentValueAsString));
builder.AddElementReferenceCapture(7, __inputReference => Element = __inputReference);
Expand Down
14 changes: 1 addition & 13 deletions src/Components/Web/src/Forms/InputRadio.cs
Original file line number Diff line number Diff line change
Expand Up @@ -38,18 +38,6 @@ public class InputRadio<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTyp

[CascadingParameter] private InputRadioContext? CascadedContext { get; set; }

private string GetCssClass(string fieldClass)
{
if (AdditionalAttributes != null &&
AdditionalAttributes.TryGetValue("class", out var @class) &&
!string.IsNullOrEmpty(Convert.ToString(@class, CultureInfo.InvariantCulture)))
{
return $"{@class} {fieldClass}";
}

return fieldClass;
}

/// <inheritdoc />
protected override void OnParametersSet()
{
Expand All @@ -69,7 +57,7 @@ protected override void BuildRenderTree(RenderTreeBuilder builder)

builder.OpenElement(0, "input");
builder.AddMultipleAttributes(1, AdditionalAttributes);
builder.AddAttribute(2, "class", GetCssClass(Context.FieldClass));
builder.AddAttributeIfNotNullOrEmpty(2, "class", AttributeUtilities.CombineClassNames(AdditionalAttributes, Context.FieldClass));
builder.AddAttribute(3, "type", "radio");
builder.AddAttribute(4, "name", Context.GroupName);
builder.AddAttribute(5, "value", BindConverter.FormatValue(Value?.ToString()));
Expand Down
2 changes: 1 addition & 1 deletion src/Components/Web/src/Forms/InputRadioGroup.cs
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ public class InputRadioGroup<[DynamicallyAccessedMembers(DynamicallyAccessedMemb
protected override void OnParametersSet()
{
var groupName = !string.IsNullOrEmpty(Name) ? Name : _defaultGroupName;
var fieldClass = EditContext.FieldCssClass(FieldIdentifier);
var fieldClass = EditContext?.FieldCssClass(FieldIdentifier) ?? string.Empty;
var changeEventCallback = EventCallback.Factory.CreateBinder<string?>(this, __value => CurrentValueAsString = __value, CurrentValueAsString);

_context = new InputRadioContext(CascadedContext, groupName, CurrentValue, fieldClass, changeEventCallback);
Expand Down
2 changes: 1 addition & 1 deletion src/Components/Web/src/Forms/InputSelect.cs
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ protected override void BuildRenderTree(RenderTreeBuilder builder)
{
builder.OpenElement(0, "select");
builder.AddMultipleAttributes(1, AdditionalAttributes);
builder.AddAttribute(2, "class", CssClass);
builder.AddAttributeIfNotNullOrEmpty(2, "class", CssClass);
builder.AddAttribute(3, "multiple", _isMultipleSelect);

if (_isMultipleSelect)
Expand Down
2 changes: 1 addition & 1 deletion src/Components/Web/src/Forms/InputText.cs
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ protected override void BuildRenderTree(RenderTreeBuilder builder)
{
builder.OpenElement(0, "input");
builder.AddMultipleAttributes(1, AdditionalAttributes);
builder.AddAttribute(2, "class", CssClass);
builder.AddAttributeIfNotNullOrEmpty(2, "class", CssClass);
builder.AddAttribute(3, "value", BindConverter.FormatValue(CurrentValue));
builder.AddAttribute(4, "onchange", EventCallback.Factory.CreateBinder<string?>(this, __value => CurrentValueAsString = __value, CurrentValueAsString));
builder.AddElementReferenceCapture(5, __inputReference => Element = __inputReference);
Expand Down
2 changes: 1 addition & 1 deletion src/Components/Web/src/Forms/InputTextArea.cs
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ protected override void BuildRenderTree(RenderTreeBuilder builder)
{
builder.OpenElement(0, "textarea");
builder.AddMultipleAttributes(1, AdditionalAttributes);
builder.AddAttribute(2, "class", CssClass);
builder.AddAttributeIfNotNullOrEmpty(2, "class", CssClass);
builder.AddAttribute(3, "value", BindConverter.FormatValue(CurrentValue));
builder.AddAttribute(4, "onchange", EventCallback.Factory.CreateBinder<string?>(this, __value => CurrentValueAsString = __value, CurrentValueAsString));
builder.AddElementReferenceCapture(5, __inputReference => Element = __inputReference);
Expand Down
18 changes: 18 additions & 0 deletions src/Components/Web/src/Forms/RenderTreeBuilderExtensions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System.Runtime.CompilerServices;

namespace Microsoft.AspNetCore.Components.Rendering;

internal static class RenderTreeBuilderExtensions
{
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static void AddAttributeIfNotNullOrEmpty(this RenderTreeBuilder builder, int sequence, string name, string? value)
{
if (!string.IsNullOrEmpty(value))
{
builder.AddAttribute(sequence, name, value);
}
}
}
36 changes: 17 additions & 19 deletions src/Components/Web/test/Forms/InputBaseTest.cs
Original file line number Diff line number Diff line change
@@ -1,32 +1,13 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Components.Test.Helpers;
using Xunit;

namespace Microsoft.AspNetCore.Components.Forms
{
public class InputBaseTest
{
[Fact]
public async Task ThrowsOnFirstRenderIfNoEditContextIsSupplied()
{
// Arrange
var inputComponent = new TestInputComponent<string>();
var testRenderer = new TestRenderer();
var componentId = testRenderer.AssignRootComponentId(inputComponent);

// Act/Assert
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
() => testRenderer.RenderRootComponentAsync(componentId));
Assert.StartsWith($"{typeof(TestInputComponent<string>)} requires a cascading parameter of type {nameof(EditContext)}", ex.Message);
}

[Fact]
public async Task ThrowsIfEditContextChanges()
{
Expand Down Expand Up @@ -131,6 +112,23 @@ public async Task CanReadBackChangesToCurrentValue()
Assert.Equal("new value", inputComponent.CurrentValue);
}

[Fact]
public async Task CanRenderWithoutEditContext()
{
// Arrange
var model = new TestModel();
var value = "some value";
var rootComponent = new TestInputHostComponent<string, TestInputComponent<string>>
{
Value = value,
ValueExpression = () => value
};

// Act/Assert
var inputComponent = await InputRenderer.RenderAndGetComponent(rootComponent);
Assert.Null(inputComponent.EditContext);
}

[Fact]
public async Task WritingToCurrentValueInvokesValueChangedIfDifferent()
{
Expand Down
Loading

0 comments on commit afacc87

Please sign in to comment.