Просмотр исходного кода

Blazor input component support when EditContext is not supplied (#35640)

Mackinnon Buck 4 лет назад
Родитель
Сommit
afacc8740c

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
src/Components/Web.JS/dist/Release/blazor.server.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
src/Components/Web.JS/dist/Release/blazor.webview.js


+ 31 - 0
src/Components/Web/src/Forms/AttributeUtilities.cs

@@ -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}";
+    }
+}

+ 31 - 44
src/Components/Web/src/Forms/InputBase.cs

@@ -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
 {
@@ -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.
@@ -57,6 +55,7 @@ namespace Microsoft.AspNetCore.Components.Forms
 
         /// <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!;
 
@@ -78,7 +77,7 @@ namespace Microsoft.AspNetCore.Components.Forms
                 {
                     Value = value;
                     _ = ValueChanged.InvokeAsync(Value);
-                    EditContext.NotifyFieldChanged(FieldIdentifier);
+                    EditContext?.NotifyFieldChanged(FieldIdentifier);
                 }
             }
         }
@@ -112,21 +111,21 @@ namespace Microsoft.AspNetCore.Components.Forms
                 {
                     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;
                 }
             }
@@ -159,62 +158,45 @@ namespace Microsoft.AspNetCore.Components.Forms
         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)
             {
@@ -242,6 +224,11 @@ namespace Microsoft.AspNetCore.Components.Forms
 
         private void UpdateAdditionalValidationAttributes()
         {
+            if (EditContext is null)
+            {
+                return;
+            }
+
             var hasAriaInvalidAttribute = AdditionalAttributes != null && AdditionalAttributes.ContainsKey("aria-invalid");
             if (EditContext.GetValidationMessages(FieldIdentifier).Any())
             {

+ 1 - 1
src/Components/Web/src/Forms/InputNumber.cs

@@ -56,7 +56,7 @@ namespace Microsoft.AspNetCore.Components.Forms
             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);

+ 1 - 13
src/Components/Web/src/Forms/InputRadio.cs

@@ -38,18 +38,6 @@ namespace Microsoft.AspNetCore.Components.Forms
 
         [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()
         {
@@ -69,7 +57,7 @@ namespace Microsoft.AspNetCore.Components.Forms
 
             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()));

+ 1 - 1
src/Components/Web/src/Forms/InputRadioGroup.cs

@@ -32,7 +32,7 @@ namespace Microsoft.AspNetCore.Components.Forms
         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);

+ 1 - 1
src/Components/Web/src/Forms/InputSelect.cs

@@ -41,7 +41,7 @@ namespace Microsoft.AspNetCore.Components.Forms
         {
             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)

+ 1 - 1
src/Components/Web/src/Forms/InputText.cs

@@ -33,7 +33,7 @@ namespace Microsoft.AspNetCore.Components.Forms
         {
             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);

+ 1 - 1
src/Components/Web/src/Forms/InputTextArea.cs

@@ -33,7 +33,7 @@ namespace Microsoft.AspNetCore.Components.Forms
         {
             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);

+ 18 - 0
src/Components/Web/src/Forms/RenderTreeBuilderExtensions.cs

@@ -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);
+        }
+    }
+}

+ 17 - 19
src/Components/Web/test/Forms/InputBaseTest.cs

@@ -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()
         {
@@ -131,6 +112,23 @@ namespace Microsoft.AspNetCore.Components.Forms
             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()
         {

+ 47 - 0
src/Components/test/E2ETest/Tests/FormsTest.cs

@@ -929,6 +929,47 @@ namespace Microsoft.AspNetCore.Components.E2ETest.Tests
             Browser.Equal("210", () => rangeWithValueLast.GetDomProperty("value"));
         }
 
+        [Fact]
+        public void InputSelectWorksWithoutEditContext()
+        {
+            var appElement = Browser.MountTestComponent<InputsWithoutEditForm>();
+            var selectElement = new SelectElement(appElement.FindElement(By.Id("selected-cities-input-select")));
+            var selectedElementText = appElement.FindElement(By.Id("selected-cities-text"));
+
+            // The bound value is expected and no class attribute exists
+            Browser.Equal("SanFrancisco", () => selectedElementText.Text);
+            Browser.False(() => ElementHasAttribute(selectElement.WrappedElement, "class"));
+
+            selectElement.SelectByIndex(2);
+            selectElement.SelectByIndex(3);
+
+            // Value binding continues to work without an edit context and the class attribute is unchanged
+            Browser.Equal("SanFrancisco, London, Madrid", () => selectedElementText.Text);
+            Browser.False(() => ElementHasAttribute(selectElement.WrappedElement, "class"));
+        }
+
+        [Fact]
+        public void InputRadioGroupWorksWithoutEditContext()
+        {
+            var appElement = Browser.MountTestComponent<InputsWithoutEditForm>();
+            var selectedInputText = appElement.FindElement(By.Id("selected-airline-text"));
+
+            // The bound value is expected and no inputs have a class attribute
+            Browser.True(() => FindRadioInputs().All(input => !ElementHasAttribute(input, "class")));
+            Browser.True(() => FindRadioInputs().First(input => input.GetAttribute("value") == "Unknown").Selected);
+            Browser.Equal("Unknown", () => selectedInputText.Text);
+
+            FindRadioInputs().First().Click();
+
+            // Value binding continues to work without an edit context and class attributes are unchanged
+            Browser.True(() => FindRadioInputs().All(input => !ElementHasAttribute(input, "class")));
+            Browser.True(() => FindRadioInputs().First(input => input.GetAttribute("value") == "BestAirline").Selected);
+            Browser.Equal("BestAirline", () => selectedInputText.Text);
+
+            IReadOnlyCollection<IWebElement> FindRadioInputs()
+                => appElement.FindElement(By.ClassName("airlines")).FindElements(By.TagName("input"));
+        }
+
         private Func<string[]> CreateValidationMessagesAccessor(IWebElement appElement)
         {
             return () => appElement.FindElements(By.ClassName("validation-message"))
@@ -941,5 +982,11 @@ namespace Microsoft.AspNetCore.Components.E2ETest.Tests
         {
             Browser.Equal(shouldBeRendered, () => element.GetAttribute(attributeName) != null);
         }
+
+        private bool ElementHasAttribute(IWebElement webElement, string attribute)
+        {
+            var jsExecutor = (IJavaScriptExecutor)Browser;
+            return (bool)jsExecutor.ExecuteScript($"return arguments[0].attributes['{attribute}'] !== undefined;", webElement);
+        }
     }
 }

+ 33 - 0
src/Components/test/testassets/BasicTestApp/FormsTest/InputsWithoutEditForm.razor

@@ -0,0 +1,33 @@
+@using Microsoft.AspNetCore.Components.Forms
+
+<p class="cities">
+    Select some cities:<br>
+    <InputSelect @bind-Value="_selectedCities" id="selected-cities-input-select">
+        <option value="@City.SanFrancisco">San Francisco</option>
+        <option value="@City.Tokyo">Tokyo</option>
+        <option value="@City.London">London</option>
+        <option value="@City.Madrid">Madrid</option>
+    </InputSelect>
+</p>
+<p class="airlines">
+    Select an airline:<br>
+    <InputRadioGroup @bind-Value="_selectedAirline" id="selected-airline-input-radio-group">
+        <InputRadio Value="Airline.BestAirline" />BestAirline<br>
+        <InputRadio Value="Airline.CoolAirline" />CoolAirline<br>
+        <InputRadio Value="Airline.NoNameAirline" />NoNameAirline<br>
+        <InputRadio Value="Airline.Unknown" />Unknown<br>
+    </InputRadioGroup>
+</p>
+
+<p>
+    Selected cities: <span id="selected-cities-text">@string.Join(", ", _selectedCities)</span><br>
+    Selected airline: <span id="selected-airline-text">@_selectedAirline</span>
+</p>
+
+@code {
+    enum City { SanFrancisco, Tokyo, London, Madrid }
+    enum Airline { BestAirline, CoolAirline, NoNameAirline, Unknown }
+
+    private City[] _selectedCities = new[] { City.SanFrancisco };
+    private Airline _selectedAirline = Airline.Unknown;
+}

+ 1 - 0
src/Components/test/testassets/BasicTestApp/Index.razor

@@ -42,6 +42,7 @@
         <option value="BasicTestApp.FormsTest.TypicalValidationComponentUsingExperimentalValidator">Typical validation using experimental validator</option>
         <option value="BasicTestApp.FormsTest.InputFileComponent">Input file</option>
         <option value="BasicTestApp.FormsTest.InputRangeComponent">Input range</option>
+        <option value="BasicTestApp.FormsTest.InputsWithoutEditForm">Inputs without EditForm</option>
         <option value="BasicTestApp.NavigateOnSubmit">Navigate to submit</option>
         <option value="BasicTestApp.GlobalizationBindCases">Globalization Bind Cases</option>
         <option value="BasicTestApp.GracefulTermination">Graceful Termination</option>

Некоторые файлы не были показаны из-за большого количества измененных файлов