diff --git a/src/Components/Web/src/Forms/FieldIdGenerator.cs b/src/Components/Web/src/Forms/FieldIdGenerator.cs new file mode 100644 index 000000000000..f20b2e23dd2f --- /dev/null +++ b/src/Components/Web/src/Forms/FieldIdGenerator.cs @@ -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. + +using System.Buffers; +using System.Text; + +namespace Microsoft.AspNetCore.Components.Forms; + +/// +/// Provides methods for generating valid HTML id attribute values from field names. +/// +internal static class FieldIdGenerator +{ + // Valid characters for HTML 4.01 id attributes (excluding '.' to avoid CSS selector conflicts) + // See: https://www.w3.org/TR/html401/types.html#type-id + private static readonly SearchValues ValidIdChars = + SearchValues.Create("-0123456789:ABCDEFGHIJKLMNOPQRSTUVWXYZ_abcdefghijklmnopqrstuvwxyz"); + + /// + /// Sanitizes a field name to create a valid HTML id attribute value. + /// + /// The field name to sanitize. + /// A valid HTML id attribute value, or an empty string if the input is null or empty. + /// + /// This method follows HTML 4.01 id attribute rules: + /// - The first character must be a letter (A-Z, a-z) + /// - Subsequent characters can be letters, digits, hyphens, underscores, colons, or periods + /// - Periods are replaced with underscores to avoid CSS selector conflicts + /// + public static string SanitizeHtmlId(string? fieldName) + { + if (string.IsNullOrEmpty(fieldName)) + { + return string.Empty; + } + + // Fast path: check if sanitization is needed + var firstChar = fieldName[0]; + var startsWithLetter = char.IsAsciiLetter(firstChar); + var indexOfInvalidChar = fieldName.AsSpan(1).IndexOfAnyExcept(ValidIdChars); + + if (startsWithLetter && indexOfInvalidChar < 0) + { + return fieldName; + } + + // Slow path: build sanitized string + var result = new StringBuilder(fieldName.Length); + + // First character must be a letter + if (startsWithLetter) + { + result.Append(firstChar); + } + else + { + result.Append('z'); + if (IsValidIdChar(firstChar)) + { + result.Append(firstChar); + } + else + { + result.Append('_'); + } + } + + // Process remaining characters + for (var i = 1; i < fieldName.Length; i++) + { + var c = fieldName[i]; + result.Append(IsValidIdChar(c) ? c : '_'); + } + + return result.ToString(); + } + + private static bool IsValidIdChar(char c) + => ValidIdChars.Contains(c); +} diff --git a/src/Components/Web/src/Forms/InputBase.cs b/src/Components/Web/src/Forms/InputBase.cs index c745da39cb82..cd8d7ee8ac50 100644 --- a/src/Components/Web/src/Forms/InputBase.cs +++ b/src/Components/Web/src/Forms/InputBase.cs @@ -220,6 +220,26 @@ protected string NameAttributeValue } } + /// + /// Gets the value to be used for the input's "id" attribute. + /// + /// + /// If an explicit "id" is provided via , that value takes precedence. + /// Otherwise, the id is derived from with invalid characters sanitized. + /// + protected string IdAttributeValue + { + get + { + if (AdditionalAttributes?.TryGetValue("id", out var idAttributeValue) ?? false) + { + return Convert.ToString(idAttributeValue, CultureInfo.InvariantCulture) ?? string.Empty; + } + + return FieldIdGenerator.SanitizeHtmlId(NameAttributeValue); + } + } + /// public override Task SetParametersAsync(ParameterView parameters) { diff --git a/src/Components/Web/src/Forms/InputCheckbox.cs b/src/Components/Web/src/Forms/InputCheckbox.cs index 9538b031960e..fe5c31f89ff0 100644 --- a/src/Components/Web/src/Forms/InputCheckbox.cs +++ b/src/Components/Web/src/Forms/InputCheckbox.cs @@ -34,17 +34,18 @@ protected override void BuildRenderTree(RenderTreeBuilder builder) builder.OpenElement(0, "input"); builder.AddMultipleAttributes(1, AdditionalAttributes); builder.AddAttribute(2, "type", "checkbox"); - builder.AddAttributeIfNotNullOrEmpty(3, "name", NameAttributeValue); - builder.AddAttribute(4, "class", CssClass); - builder.AddAttribute(5, "checked", BindConverter.FormatValue(CurrentValue)); + builder.AddAttributeIfNotNullOrEmpty(3, "id", IdAttributeValue); + builder.AddAttributeIfNotNullOrEmpty(4, "name", NameAttributeValue); + builder.AddAttribute(5, "class", CssClass); + builder.AddAttribute(6, "checked", BindConverter.FormatValue(CurrentValue)); // Include the "value" attribute so that when this is posted by a form, "true" // is included in the form fields. That's how works normally. // It sends the "on" value when the checkbox is checked, and nothing otherwise. - builder.AddAttribute(6, "value", bool.TrueString); + builder.AddAttribute(7, "value", bool.TrueString); - builder.AddAttribute(7, "onchange", EventCallback.Factory.CreateBinder(this, __value => CurrentValue = __value, CurrentValue)); + builder.AddAttribute(8, "onchange", EventCallback.Factory.CreateBinder(this, __value => CurrentValue = __value, CurrentValue)); builder.SetUpdatesAttributeName("checked"); - builder.AddElementReferenceCapture(8, __inputReference => Element = __inputReference); + builder.AddElementReferenceCapture(9, __inputReference => Element = __inputReference); builder.CloseElement(); } diff --git a/src/Components/Web/src/Forms/InputDate.cs b/src/Components/Web/src/Forms/InputDate.cs index 4498dd539b48..3a7b1ba960ca 100644 --- a/src/Components/Web/src/Forms/InputDate.cs +++ b/src/Components/Web/src/Forms/InputDate.cs @@ -86,12 +86,13 @@ protected override void BuildRenderTree(RenderTreeBuilder builder) builder.OpenElement(0, "input"); builder.AddMultipleAttributes(1, AdditionalAttributes); builder.AddAttribute(2, "type", _typeAttributeValue); - builder.AddAttributeIfNotNullOrEmpty(3, "name", NameAttributeValue); - builder.AddAttribute(4, "class", CssClass); - builder.AddAttribute(5, "value", CurrentValueAsString); - builder.AddAttribute(6, "onchange", EventCallback.Factory.CreateBinder(this, __value => CurrentValueAsString = __value, CurrentValueAsString)); + builder.AddAttributeIfNotNullOrEmpty(3, "id", IdAttributeValue); + builder.AddAttributeIfNotNullOrEmpty(4, "name", NameAttributeValue); + builder.AddAttribute(5, "class", CssClass); + builder.AddAttribute(6, "value", CurrentValueAsString); + builder.AddAttribute(7, "onchange", EventCallback.Factory.CreateBinder(this, __value => CurrentValueAsString = __value, CurrentValueAsString)); builder.SetUpdatesAttributeName("value"); - builder.AddElementReferenceCapture(7, __inputReference => Element = __inputReference); + builder.AddElementReferenceCapture(8, __inputReference => Element = __inputReference); builder.CloseElement(); } diff --git a/src/Components/Web/src/Forms/InputHidden.cs b/src/Components/Web/src/Forms/InputHidden.cs index d2f95d1b6272..9e5cac1b030c 100644 --- a/src/Components/Web/src/Forms/InputHidden.cs +++ b/src/Components/Web/src/Forms/InputHidden.cs @@ -25,12 +25,13 @@ protected override void BuildRenderTree(RenderTreeBuilder builder) builder.OpenElement(0, "input"); builder.AddAttribute(1, "type", "hidden"); builder.AddMultipleAttributes(2, AdditionalAttributes); - builder.AddAttributeIfNotNullOrEmpty(3, "name", NameAttributeValue); - builder.AddAttributeIfNotNullOrEmpty(4, "class", CssClass); - builder.AddAttribute(5, "value", CurrentValueAsString); - builder.AddAttribute(6, "onchange", EventCallback.Factory.CreateBinder(this, __value => CurrentValueAsString = __value, CurrentValueAsString)); + builder.AddAttributeIfNotNullOrEmpty(3, "id", IdAttributeValue); + builder.AddAttributeIfNotNullOrEmpty(4, "name", NameAttributeValue); + builder.AddAttributeIfNotNullOrEmpty(5, "class", CssClass); + builder.AddAttribute(6, "value", CurrentValueAsString); + builder.AddAttribute(7, "onchange", EventCallback.Factory.CreateBinder(this, __value => CurrentValueAsString = __value, CurrentValueAsString)); builder.SetUpdatesAttributeName("value"); - builder.AddElementReferenceCapture(7, __inputReference => Element = __inputReference); + builder.AddElementReferenceCapture(8, __inputReference => Element = __inputReference); builder.CloseElement(); } diff --git a/src/Components/Web/src/Forms/InputNumber.cs b/src/Components/Web/src/Forms/InputNumber.cs index b346b4b39772..c2e9a1ebef50 100644 --- a/src/Components/Web/src/Forms/InputNumber.cs +++ b/src/Components/Web/src/Forms/InputNumber.cs @@ -55,12 +55,13 @@ protected override void BuildRenderTree(RenderTreeBuilder builder) builder.AddAttribute(1, "step", _stepAttributeValue); builder.AddAttribute(2, "type", "number"); builder.AddMultipleAttributes(3, AdditionalAttributes); - builder.AddAttributeIfNotNullOrEmpty(4, "name", NameAttributeValue); - builder.AddAttributeIfNotNullOrEmpty(5, "class", CssClass); - builder.AddAttribute(6, "value", CurrentValueAsString); - builder.AddAttribute(7, "onchange", EventCallback.Factory.CreateBinder(this, __value => CurrentValueAsString = __value, CurrentValueAsString)); + builder.AddAttributeIfNotNullOrEmpty(4, "id", IdAttributeValue); + builder.AddAttributeIfNotNullOrEmpty(5, "name", NameAttributeValue); + builder.AddAttributeIfNotNullOrEmpty(6, "class", CssClass); + builder.AddAttribute(7, "value", CurrentValueAsString); + builder.AddAttribute(8, "onchange", EventCallback.Factory.CreateBinder(this, __value => CurrentValueAsString = __value, CurrentValueAsString)); builder.SetUpdatesAttributeName("value"); - builder.AddElementReferenceCapture(8, __inputReference => Element = __inputReference); + builder.AddElementReferenceCapture(9, __inputReference => Element = __inputReference); builder.CloseElement(); } diff --git a/src/Components/Web/src/Forms/InputSelect.cs b/src/Components/Web/src/Forms/InputSelect.cs index 1fa8ecb1df72..f5f3f82a5c40 100644 --- a/src/Components/Web/src/Forms/InputSelect.cs +++ b/src/Components/Web/src/Forms/InputSelect.cs @@ -40,25 +40,26 @@ protected override void BuildRenderTree(RenderTreeBuilder builder) { builder.OpenElement(0, "select"); builder.AddMultipleAttributes(1, AdditionalAttributes); - builder.AddAttributeIfNotNullOrEmpty(2, "name", NameAttributeValue); - builder.AddAttributeIfNotNullOrEmpty(3, "class", CssClass); - builder.AddAttribute(4, "multiple", _isMultipleSelect); + builder.AddAttributeIfNotNullOrEmpty(2, "id", IdAttributeValue); + builder.AddAttributeIfNotNullOrEmpty(3, "name", NameAttributeValue); + builder.AddAttributeIfNotNullOrEmpty(4, "class", CssClass); + builder.AddAttribute(5, "multiple", _isMultipleSelect); if (_isMultipleSelect) { - builder.AddAttribute(5, "value", BindConverter.FormatValue(CurrentValue)?.ToString()); - builder.AddAttribute(6, "onchange", EventCallback.Factory.CreateBinder(this, SetCurrentValueAsStringArray, default)); + builder.AddAttribute(6, "value", BindConverter.FormatValue(CurrentValue)?.ToString()); + builder.AddAttribute(7, "onchange", EventCallback.Factory.CreateBinder(this, SetCurrentValueAsStringArray, default)); builder.SetUpdatesAttributeName("value"); } else { - builder.AddAttribute(7, "value", CurrentValueAsString); - builder.AddAttribute(8, "onchange", EventCallback.Factory.CreateBinder(this, __value => CurrentValueAsString = __value, default)); + builder.AddAttribute(8, "value", CurrentValueAsString); + builder.AddAttribute(9, "onchange", EventCallback.Factory.CreateBinder(this, __value => CurrentValueAsString = __value, default)); builder.SetUpdatesAttributeName("value"); } - builder.AddElementReferenceCapture(9, __selectReference => Element = __selectReference); - builder.AddContent(10, ChildContent); + builder.AddElementReferenceCapture(10, __selectReference => Element = __selectReference); + builder.AddContent(11, ChildContent); builder.CloseElement(); } diff --git a/src/Components/Web/src/Forms/InputText.cs b/src/Components/Web/src/Forms/InputText.cs index c3eb9b5f1d89..83784626308f 100644 --- a/src/Components/Web/src/Forms/InputText.cs +++ b/src/Components/Web/src/Forms/InputText.cs @@ -33,12 +33,13 @@ protected override void BuildRenderTree(RenderTreeBuilder builder) { builder.OpenElement(0, "input"); builder.AddMultipleAttributes(1, AdditionalAttributes); - builder.AddAttributeIfNotNullOrEmpty(2, "name", NameAttributeValue); - builder.AddAttributeIfNotNullOrEmpty(3, "class", CssClass); - builder.AddAttribute(4, "value", CurrentValueAsString); - builder.AddAttribute(5, "onchange", EventCallback.Factory.CreateBinder(this, __value => CurrentValueAsString = __value, CurrentValueAsString)); + builder.AddAttributeIfNotNullOrEmpty(2, "id", IdAttributeValue); + builder.AddAttributeIfNotNullOrEmpty(3, "name", NameAttributeValue); + builder.AddAttributeIfNotNullOrEmpty(4, "class", CssClass); + builder.AddAttribute(5, "value", CurrentValueAsString); + builder.AddAttribute(6, "onchange", EventCallback.Factory.CreateBinder(this, __value => CurrentValueAsString = __value, CurrentValueAsString)); builder.SetUpdatesAttributeName("value"); - builder.AddElementReferenceCapture(6, __inputReference => Element = __inputReference); + builder.AddElementReferenceCapture(7, __inputReference => Element = __inputReference); builder.CloseElement(); } diff --git a/src/Components/Web/src/Forms/InputTextArea.cs b/src/Components/Web/src/Forms/InputTextArea.cs index 2495ce3d07f7..5d402ca84742 100644 --- a/src/Components/Web/src/Forms/InputTextArea.cs +++ b/src/Components/Web/src/Forms/InputTextArea.cs @@ -33,12 +33,13 @@ protected override void BuildRenderTree(RenderTreeBuilder builder) { builder.OpenElement(0, "textarea"); builder.AddMultipleAttributes(1, AdditionalAttributes); - builder.AddAttributeIfNotNullOrEmpty(2, "name", NameAttributeValue); - builder.AddAttributeIfNotNullOrEmpty(3, "class", CssClass); - builder.AddAttribute(4, "value", CurrentValueAsString); - builder.AddAttribute(5, "onchange", EventCallback.Factory.CreateBinder(this, __value => CurrentValueAsString = __value, CurrentValueAsString)); + builder.AddAttributeIfNotNullOrEmpty(2, "id", IdAttributeValue); + builder.AddAttributeIfNotNullOrEmpty(3, "name", NameAttributeValue); + builder.AddAttributeIfNotNullOrEmpty(4, "class", CssClass); + builder.AddAttribute(5, "value", CurrentValueAsString); + builder.AddAttribute(6, "onchange", EventCallback.Factory.CreateBinder(this, __value => CurrentValueAsString = __value, CurrentValueAsString)); builder.SetUpdatesAttributeName("value"); - builder.AddElementReferenceCapture(6, __inputReference => Element = __inputReference); + builder.AddElementReferenceCapture(7, __inputReference => Element = __inputReference); builder.CloseElement(); } diff --git a/src/Components/Web/src/Forms/Label.cs b/src/Components/Web/src/Forms/Label.cs new file mode 100644 index 000000000000..3c3f5b5af5b3 --- /dev/null +++ b/src/Components/Web/src/Forms/Label.cs @@ -0,0 +1,117 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Linq.Expressions; +using Microsoft.AspNetCore.Components.Rendering; + +namespace Microsoft.AspNetCore.Components.Forms; + +/// +/// Renders a <label> element for a specified field, reading the display name from +/// or +/// if present, or falling back to the property name. +/// +/// +/// +/// The component supports two usage patterns: +/// +/// +/// Nested (wrapping) pattern: When is provided, the label wraps +/// the input component, providing implicit HTML association without requiring matching for/id attributes. +/// +/// +/// Non-nested pattern: When is not provided, the label renders +/// with a for attribute matching the field expression. The corresponding input component must have +/// a matching id attribute (which is automatically generated by input components derived from +/// ). +/// +/// +/// The type of the field. +public class Label : IComponent +{ + private RenderHandle _renderHandle; + private string? _displayName; + private string? _fieldId; + + [CascadingParameter] private HtmlFieldPrefix FieldPrefix { get; set; } = default!; + + /// + /// Specifies the field for which the label should be rendered. + /// + [Parameter, EditorRequired] + public Expression>? For { get; set; } + + /// + /// Gets or sets the child content to be rendered inside the label element. + /// Typically this contains an input component that will be implicitly associated with the label. + /// + [Parameter] + public RenderFragment? ChildContent { get; set; } + + /// + /// Gets or sets a collection of additional attributes that will be applied to the label element. + /// + [Parameter(CaptureUnmatchedValues = true)] + public IReadOnlyDictionary? AdditionalAttributes { get; set; } + + /// + void IComponent.Attach(RenderHandle renderHandle) + { + _renderHandle = renderHandle; + } + + /// + Task IComponent.SetParametersAsync(ParameterView parameters) + { + var previousFor = For; + var previousChildContent = ChildContent; + var previousAdditionalAttributes = AdditionalAttributes; + + parameters.SetParameterProperties(this); + + if (For is null) + { + throw new InvalidOperationException($"{GetType()} requires a value for the " + + $"{nameof(For)} parameter."); + } + + // Only recalculate display name and field id if the expression changed + var displayNameChanged = false; + if (For != previousFor) + { + var newDisplayName = ExpressionMemberAccessor.GetDisplayName(For); + if (newDisplayName != _displayName) + { + _displayName = newDisplayName; + displayNameChanged = true; + } + _fieldId = FieldIdGenerator.SanitizeHtmlId( + FieldPrefix != null ? FieldPrefix.GetFieldName(For) : + ExpressionFormatter.FormatLambda(For)); + } + + var otherParamsChanged = ChildContent != previousChildContent || AdditionalAttributes != previousAdditionalAttributes; + if (displayNameChanged || otherParamsChanged) + { + _renderHandle.Render(BuildRenderTree); + } + + return Task.CompletedTask; + } + + private void BuildRenderTree(RenderTreeBuilder builder) + { + builder.OpenElement(0, "label"); + + // For non-nested usage (no ChildContent), add 'for' attribute to associate with input by id + if (ChildContent is null) + { + builder.AddAttribute(1, "for", _fieldId); + } + + builder.AddMultipleAttributes(2, AdditionalAttributes); + builder.AddContent(3, _displayName); + builder.AddContent(4, ChildContent); + builder.CloseElement(); + } +} diff --git a/src/Components/Web/src/PublicAPI.Unshipped.txt b/src/Components/Web/src/PublicAPI.Unshipped.txt index 96e56989f2a9..e5bc7df25502 100644 --- a/src/Components/Web/src/PublicAPI.Unshipped.txt +++ b/src/Components/Web/src/PublicAPI.Unshipped.txt @@ -74,3 +74,12 @@ Microsoft.AspNetCore.Components.Forms.DisplayName Microsoft.AspNetCore.Components.Forms.DisplayName.DisplayName() -> void Microsoft.AspNetCore.Components.Forms.DisplayName.For.get -> System.Linq.Expressions.Expression!>? Microsoft.AspNetCore.Components.Forms.DisplayName.For.set -> void +Microsoft.AspNetCore.Components.Forms.InputBase.IdAttributeValue.get -> string! +Microsoft.AspNetCore.Components.Forms.Label +Microsoft.AspNetCore.Components.Forms.Label.AdditionalAttributes.get -> System.Collections.Generic.IReadOnlyDictionary? +Microsoft.AspNetCore.Components.Forms.Label.AdditionalAttributes.set -> void +Microsoft.AspNetCore.Components.Forms.Label.ChildContent.get -> Microsoft.AspNetCore.Components.RenderFragment? +Microsoft.AspNetCore.Components.Forms.Label.ChildContent.set -> void +Microsoft.AspNetCore.Components.Forms.Label.For.get -> System.Linq.Expressions.Expression!>? +Microsoft.AspNetCore.Components.Forms.Label.For.set -> void +Microsoft.AspNetCore.Components.Forms.Label.Label() -> void diff --git a/src/Components/Web/test/Forms/FieldIdGeneratorTest.cs b/src/Components/Web/test/Forms/FieldIdGeneratorTest.cs new file mode 100644 index 000000000000..abdf6c0f8140 --- /dev/null +++ b/src/Components/Web/test/Forms/FieldIdGeneratorTest.cs @@ -0,0 +1,82 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#nullable enable + +namespace Microsoft.AspNetCore.Components.Forms; + +public class FieldIdGeneratorTest +{ + [Theory] + [InlineData(null, "")] + [InlineData("", "")] + [InlineData("Name", "Name")] + [InlineData("name", "name")] + [InlineData("Model.Property", "Model_Property")] + [InlineData("Model.Address.Street", "Model_Address_Street")] + [InlineData("Items[0]", "Items_0_")] + [InlineData("Items[0].Name", "Items_0__Name")] + [InlineData("Model.Items[0].Name", "Model_Items_0__Name")] + public void SanitizeHtmlId_ProducesValidId(string? input, string expected) + { + // Act + var result = FieldIdGenerator.SanitizeHtmlId(input); + + // Assert + Assert.Equal(expected, result); + } + + [Fact] + public void SanitizeHtmlId_StartsWithNonLetter_PrependsZ() + { + // Arrange + var input = "123Name"; + + // Act + var result = FieldIdGenerator.SanitizeHtmlId(input); + + // Assert + Assert.StartsWith("z", result); + Assert.Equal("z123Name", result); + } + + [Fact] + public void SanitizeHtmlId_StartsWithInvalidChar_PrependsZAndReplaces() + { + // Arrange + var input = ".Property"; + + // Act + var result = FieldIdGenerator.SanitizeHtmlId(input); + + // Assert + Assert.StartsWith("z", result); + Assert.Equal("z_Property", result); + } + + [Fact] + public void SanitizeHtmlId_AllowsHyphensUnderscoresColons() + { + // Arrange + var input = "my-field_name:value"; + + // Act + var result = FieldIdGenerator.SanitizeHtmlId(input); + + // Assert + Assert.Equal("my-field_name:value", result); + } + + [Fact] + public void SanitizeHtmlId_ReplacesSpacesWithUnderscores() + { + // Arrange + var input = "Field Name"; + + // Act + var result = FieldIdGenerator.SanitizeHtmlId(input); + + // Assert + Assert.Equal("Field_Name", result); + } +} diff --git a/src/Components/Web/test/Forms/InputCheckboxTest.cs b/src/Components/Web/test/Forms/InputCheckboxTest.cs new file mode 100644 index 000000000000..c35a95c08d20 --- /dev/null +++ b/src/Components/Web/test/Forms/InputCheckboxTest.cs @@ -0,0 +1,75 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.AspNetCore.Components.RenderTree; +using Microsoft.AspNetCore.Components.Test.Helpers; + +namespace Microsoft.AspNetCore.Components.Forms; + +public class InputCheckboxTest +{ + private readonly TestRenderer _testRenderer = new TestRenderer(); + + [Fact] + public async Task InputElementIsAssignedSuccessfully() + { + var model = new TestModel(); + var rootComponent = new TestInputHostComponent + { + EditContext = new EditContext(model), + ValueExpression = () => model.BoolProperty, + }; + + var inputCheckboxComponent = await InputRenderer.RenderAndGetComponent(rootComponent); + + Assert.NotNull(inputCheckboxComponent.Element); + } + + [Fact] + public async Task RendersIdAttribute() + { + var model = new TestModel(); + var rootComponent = new TestInputHostComponent + { + EditContext = new EditContext(model), + ValueExpression = () => model.BoolProperty, + }; + + var componentId = await RenderAndGetInputCheckboxComponentIdAsync(rootComponent); + var frames = _testRenderer.GetCurrentRenderTreeFrames(componentId); + + var idAttribute = frames.Array.Single(f => f.FrameType == RenderTreeFrameType.Attribute && f.AttributeName == "id"); + Assert.Equal("model_BoolProperty", idAttribute.AttributeValue); + } + + [Fact] + public async Task ExplicitIdOverridesGenerated() + { + var model = new TestModel(); + var rootComponent = new TestInputHostComponent + { + EditContext = new EditContext(model), + ValueExpression = () => model.BoolProperty, + AdditionalAttributes = new Dictionary { { "id", "custom-checkbox-id" } } + }; + + var componentId = await RenderAndGetInputCheckboxComponentIdAsync(rootComponent); + var frames = _testRenderer.GetCurrentRenderTreeFrames(componentId); + + var idAttribute = frames.Array.First(f => f.FrameType == RenderTreeFrameType.Attribute && f.AttributeName == "id"); + Assert.Equal("custom-checkbox-id", idAttribute.AttributeValue); + } + + private async Task RenderAndGetInputCheckboxComponentIdAsync(TestInputHostComponent hostComponent) + { + var hostComponentId = _testRenderer.AssignRootComponentId(hostComponent); + await _testRenderer.RenderRootComponentAsync(hostComponentId); + var batch = _testRenderer.Batches.Single(); + return batch.GetComponentFrames().Single().ComponentId; + } + + private class TestModel + { + public bool BoolProperty { get; set; } + } +} diff --git a/src/Components/Web/test/Forms/InputDateTest.cs b/src/Components/Web/test/Forms/InputDateTest.cs index a698e550be1a..4192bf1f218b 100644 --- a/src/Components/Web/test/Forms/InputDateTest.cs +++ b/src/Components/Web/test/Forms/InputDateTest.cs @@ -1,10 +1,15 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using Microsoft.AspNetCore.Components.RenderTree; +using Microsoft.AspNetCore.Components.Test.Helpers; + namespace Microsoft.AspNetCore.Components.Forms; public class InputDateTest { + private readonly TestRenderer _testRenderer = new TestRenderer(); + [Fact] public async Task ValidationErrorUsesDisplayAttributeName() { @@ -49,6 +54,49 @@ public async Task InputElementIsAssignedSuccessfully() Assert.NotNull(inputSelectComponent.Element); } + [Fact] + public async Task RendersIdAttribute() + { + var model = new TestModel(); + var rootComponent = new TestInputHostComponent + { + EditContext = new EditContext(model), + ValueExpression = () => model.DateProperty, + }; + + var componentId = await RenderAndGetInputDateComponentIdAsync(rootComponent); + var frames = _testRenderer.GetCurrentRenderTreeFrames(componentId); + + var idAttribute = frames.Array.Single(f => f.FrameType == RenderTreeFrameType.Attribute && f.AttributeName == "id"); + Assert.Equal("model_DateProperty", idAttribute.AttributeValue); + } + + [Fact] + public async Task ExplicitIdOverridesGenerated() + { + var model = new TestModel(); + var rootComponent = new TestInputHostComponent + { + EditContext = new EditContext(model), + ValueExpression = () => model.DateProperty, + AdditionalAttributes = new Dictionary { { "id", "custom-date-id" } } + }; + + var componentId = await RenderAndGetInputDateComponentIdAsync(rootComponent); + var frames = _testRenderer.GetCurrentRenderTreeFrames(componentId); + + var idAttribute = frames.Array.First(f => f.FrameType == RenderTreeFrameType.Attribute && f.AttributeName == "id"); + Assert.Equal("custom-date-id", idAttribute.AttributeValue); + } + + private async Task RenderAndGetInputDateComponentIdAsync(TestInputHostComponent hostComponent) + { + var hostComponentId = _testRenderer.AssignRootComponentId(hostComponent); + await _testRenderer.RenderRootComponentAsync(hostComponentId); + var batch = _testRenderer.Batches.Single(); + return batch.GetComponentFrames().Single().ComponentId; + } + private class TestModel { public DateTime DateProperty { get; set; } diff --git a/src/Components/Web/test/Forms/InputHiddenTest.cs b/src/Components/Web/test/Forms/InputHiddenTest.cs index 73cf7d5414f8..f643dc4b1db7 100644 --- a/src/Components/Web/test/Forms/InputHiddenTest.cs +++ b/src/Components/Web/test/Forms/InputHiddenTest.cs @@ -1,14 +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 Microsoft.AspNetCore.Components.RenderTree; +using Microsoft.AspNetCore.Components.Test.Helpers; + namespace Microsoft.AspNetCore.Components.Forms; public class InputHiddenTest { + private readonly TestRenderer _testRenderer = new TestRenderer(); + [Fact] public async Task InputElementIsAssignedSuccessfully() { - // Arrange var model = new TestModel(); var rootComponent = new TestInputHostComponent { @@ -16,13 +20,54 @@ public async Task InputElementIsAssignedSuccessfully() ValueExpression = () => model.StringProperty, }; - // Act var inputHiddenComponent = await InputRenderer.RenderAndGetComponent(rootComponent); - // Assert Assert.NotNull(inputHiddenComponent.Element); } + [Fact] + public async Task RendersIdAttribute() + { + var model = new TestModel(); + var rootComponent = new TestInputHostComponent + { + EditContext = new EditContext(model), + ValueExpression = () => model.StringProperty, + }; + + var componentId = await RenderAndGetInputHiddenComponentIdAsync(rootComponent); + var frames = _testRenderer.GetCurrentRenderTreeFrames(componentId); + + var idAttribute = frames.Array.Single(f => f.FrameType == RenderTreeFrameType.Attribute && f.AttributeName == "id"); + Assert.Equal("model_StringProperty", idAttribute.AttributeValue); + } + + [Fact] + public async Task ExplicitIdOverridesGenerated() + { + var model = new TestModel(); + var rootComponent = new TestInputHostComponent + { + EditContext = new EditContext(model), + ValueExpression = () => model.StringProperty, + AdditionalAttributes = new Dictionary { { "id", "custom-hidden-id" } } + }; + + var componentId = await RenderAndGetInputHiddenComponentIdAsync(rootComponent); + var frames = _testRenderer.GetCurrentRenderTreeFrames(componentId); + + var idAttribute = frames.Array.First(f => f.FrameType == RenderTreeFrameType.Attribute && f.AttributeName == "id"); + Assert.Equal("custom-hidden-id", idAttribute.AttributeValue); + } + + private async Task RenderAndGetInputHiddenComponentIdAsync(TestInputHostComponent hostComponent) + { + var hostComponentId = _testRenderer.AssignRootComponentId(hostComponent); + await _testRenderer.RenderRootComponentAsync(hostComponentId); + var batch = _testRenderer.Batches.Single(); + return batch.GetComponentFrames().Single().ComponentId; + } + private class TestModel { public string StringProperty { get; set; } diff --git a/src/Components/Web/test/Forms/InputNumberTest.cs b/src/Components/Web/test/Forms/InputNumberTest.cs index 12a7891b4bb4..dc4f76ce4acc 100644 --- a/src/Components/Web/test/Forms/InputNumberTest.cs +++ b/src/Components/Web/test/Forms/InputNumberTest.cs @@ -93,6 +93,41 @@ public async Task UserDefinedTypeAttributeOverridesDefault() Assert.Equal("range", typeAttributeFrame.AttributeValue); } + [Fact] + public async Task RendersIdAttribute() + { + var model = new TestModel(); + var rootComponent = new TestInputHostComponent + { + EditContext = new EditContext(model), + ValueExpression = () => model.SomeNumber, + }; + + var componentId = await RenderAndGetTestInputNumberComponentIdAsync(rootComponent); + var frames = _testRenderer.GetCurrentRenderTreeFrames(componentId); + + var idAttribute = frames.Array.Single(f => f.FrameType == RenderTreeFrameType.Attribute && f.AttributeName == "id"); + Assert.Equal("model_SomeNumber", idAttribute.AttributeValue); + } + + [Fact] + public async Task ExplicitIdOverridesGenerated() + { + var model = new TestModel(); + var rootComponent = new TestInputHostComponent + { + EditContext = new EditContext(model), + ValueExpression = () => model.SomeNumber, + AdditionalAttributes = new Dictionary { { "id", "custom-number-id" } } + }; + + var componentId = await RenderAndGetTestInputNumberComponentIdAsync(rootComponent); + var frames = _testRenderer.GetCurrentRenderTreeFrames(componentId); + + var idAttribute = frames.Array.First(f => f.FrameType == RenderTreeFrameType.Attribute && f.AttributeName == "id"); + Assert.Equal("custom-number-id", idAttribute.AttributeValue); + } + private async Task RenderAndGetTestInputNumberComponentIdAsync(TestInputHostComponent hostComponent) { var hostComponentId = _testRenderer.AssignRootComponentId(hostComponent); diff --git a/src/Components/Web/test/Forms/InputRenderer.cs b/src/Components/Web/test/Forms/InputRenderer.cs index 6645beabe012..20e6f804c3fd 100644 --- a/src/Components/Web/test/Forms/InputRenderer.cs +++ b/src/Components/Web/test/Forms/InputRenderer.cs @@ -1,6 +1,8 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +#nullable enable + using Microsoft.AspNetCore.Components.RenderTree; using Microsoft.AspNetCore.Components.Test.Helpers; @@ -8,6 +10,8 @@ namespace Microsoft.AspNetCore.Components.Forms; internal static class InputRenderer { + private static TestRenderer? _testRenderer; + public static async Task RenderAndGetComponent(TestInputHostComponent hostComponent) where TComponent : InputBase { @@ -17,6 +21,20 @@ public static async Task RenderAndGetComponent(T return FindComponent(testRenderer.Batches.Single()); } + public static async Task RenderAndGetId(TestInputHostComponent hostComponent) + where TComponent : InputBase + { + _testRenderer = new TestRenderer(); + var componentId = _testRenderer.AssignRootComponentId(hostComponent); + await _testRenderer.RenderRootComponentAsync(componentId); + return componentId; + } + + public static ArrayRange GetCurrentRenderTreeFrames(int componentId) + { + return _testRenderer!.GetCurrentRenderTreeFrames(componentId); + } + private static TComponent FindComponent(CapturedBatch batch) => batch.ReferenceFrames .Where(f => f.FrameType == RenderTreeFrameType.Component) diff --git a/src/Components/Web/test/Forms/InputSelectTest.cs b/src/Components/Web/test/Forms/InputSelectTest.cs index 8cd1b712e1cd..a2d6d0112138 100644 --- a/src/Components/Web/test/Forms/InputSelectTest.cs +++ b/src/Components/Web/test/Forms/InputSelectTest.cs @@ -1,10 +1,15 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using Microsoft.AspNetCore.Components.RenderTree; +using Microsoft.AspNetCore.Components.Test.Helpers; + namespace Microsoft.AspNetCore.Components.Forms; public class InputSelectTest { + private readonly TestRenderer _testRenderer = new TestRenderer(); + [Fact] public async Task ParsesCurrentValueWhenUsingNotNullableEnumWithNotEmptyValue() { @@ -208,6 +213,49 @@ public async Task InputElementIsAssignedSuccessfully() Assert.NotNull(inputSelectComponent.Element); } + [Fact] + public async Task RendersIdAttribute() + { + var model = new TestModel(); + var rootComponent = new TestInputHostComponent> + { + EditContext = new EditContext(model), + ValueExpression = () => model.NotNullableEnum, + }; + + var componentId = await RenderAndGetInputSelectComponentIdAsync(rootComponent); + var frames = _testRenderer.GetCurrentRenderTreeFrames(componentId); + + var idAttribute = frames.Array.Single(f => f.FrameType == RenderTreeFrameType.Attribute && f.AttributeName == "id"); + Assert.Equal("model_NotNullableEnum", idAttribute.AttributeValue); + } + + [Fact] + public async Task ExplicitIdOverridesGenerated() + { + var model = new TestModel(); + var rootComponent = new TestInputHostComponent> + { + EditContext = new EditContext(model), + ValueExpression = () => model.NotNullableEnum, + AdditionalAttributes = new Dictionary { { "id", "custom-select-id" } } + }; + + var componentId = await RenderAndGetInputSelectComponentIdAsync(rootComponent); + var frames = _testRenderer.GetCurrentRenderTreeFrames(componentId); + + var idAttribute = frames.Array.First(f => f.FrameType == RenderTreeFrameType.Attribute && f.AttributeName == "id"); + Assert.Equal("custom-select-id", idAttribute.AttributeValue); + } + + private async Task RenderAndGetInputSelectComponentIdAsync(TestInputHostComponent> hostComponent) + { + var hostComponentId = _testRenderer.AssignRootComponentId(hostComponent); + await _testRenderer.RenderRootComponentAsync(hostComponentId); + var batch = _testRenderer.Batches.Single(); + return batch.GetComponentFrames>().Single().ComponentId; + } + enum TestEnum { One, diff --git a/src/Components/Web/test/Forms/InputTextAreaTest.cs b/src/Components/Web/test/Forms/InputTextAreaTest.cs index 9337fa03e073..a63ac99697c4 100644 --- a/src/Components/Web/test/Forms/InputTextAreaTest.cs +++ b/src/Components/Web/test/Forms/InputTextAreaTest.cs @@ -1,14 +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 Microsoft.AspNetCore.Components.RenderTree; +using Microsoft.AspNetCore.Components.Test.Helpers; + namespace Microsoft.AspNetCore.Components.Forms; public class InputTextAreaTest { + private readonly TestRenderer _testRenderer = new TestRenderer(); + [Fact] public async Task InputElementIsAssignedSuccessfully() { - // Arrange var model = new TestModel(); var rootComponent = new TestInputHostComponent { @@ -16,13 +20,54 @@ public async Task InputElementIsAssignedSuccessfully() ValueExpression = () => model.StringProperty, }; - // Act var inputSelectComponent = await InputRenderer.RenderAndGetComponent(rootComponent); - // Assert Assert.NotNull(inputSelectComponent.Element); } + [Fact] + public async Task RendersIdAttribute() + { + var model = new TestModel(); + var rootComponent = new TestInputHostComponent + { + EditContext = new EditContext(model), + ValueExpression = () => model.StringProperty, + }; + + var componentId = await RenderAndGetInputTextAreaComponentIdAsync(rootComponent); + var frames = _testRenderer.GetCurrentRenderTreeFrames(componentId); + + var idAttribute = frames.Array.Single(f => f.FrameType == RenderTreeFrameType.Attribute && f.AttributeName == "id"); + Assert.Equal("model_StringProperty", idAttribute.AttributeValue); + } + + [Fact] + public async Task ExplicitIdOverridesGenerated() + { + var model = new TestModel(); + var rootComponent = new TestInputHostComponent + { + EditContext = new EditContext(model), + ValueExpression = () => model.StringProperty, + AdditionalAttributes = new Dictionary { { "id", "custom-textarea-id" } } + }; + + var componentId = await RenderAndGetInputTextAreaComponentIdAsync(rootComponent); + var frames = _testRenderer.GetCurrentRenderTreeFrames(componentId); + + var idAttribute = frames.Array.First(f => f.FrameType == RenderTreeFrameType.Attribute && f.AttributeName == "id"); + Assert.Equal("custom-textarea-id", idAttribute.AttributeValue); + } + + private async Task RenderAndGetInputTextAreaComponentIdAsync(TestInputHostComponent hostComponent) + { + var hostComponentId = _testRenderer.AssignRootComponentId(hostComponent); + await _testRenderer.RenderRootComponentAsync(hostComponentId); + var batch = _testRenderer.Batches.Single(); + return batch.GetComponentFrames().Single().ComponentId; + } + private class TestModel { public string StringProperty { get; set; } diff --git a/src/Components/Web/test/Forms/InputTextTest.cs b/src/Components/Web/test/Forms/InputTextTest.cs index de5dd3754220..f5850029183f 100644 --- a/src/Components/Web/test/Forms/InputTextTest.cs +++ b/src/Components/Web/test/Forms/InputTextTest.cs @@ -1,10 +1,15 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using Microsoft.AspNetCore.Components.RenderTree; +using Microsoft.AspNetCore.Components.Test.Helpers; + namespace Microsoft.AspNetCore.Components.Forms; public class InputTextTest { + private readonly TestRenderer _testRenderer = new TestRenderer(); + [Fact] public async Task InputElementIsAssignedSuccessfully() { @@ -23,6 +28,49 @@ public async Task InputElementIsAssignedSuccessfully() Assert.NotNull(inputSelectComponent.Element); } + [Fact] + public async Task RendersIdAttribute() + { + var model = new TestModel(); + var rootComponent = new TestInputHostComponent + { + EditContext = new EditContext(model), + ValueExpression = () => model.StringProperty, + }; + + var componentId = await RenderAndGetInputTextComponentIdAsync(rootComponent); + var frames = _testRenderer.GetCurrentRenderTreeFrames(componentId); + + var idAttribute = frames.Array.Single(f => f.FrameType == RenderTreeFrameType.Attribute && f.AttributeName == "id"); + Assert.Equal("model_StringProperty", idAttribute.AttributeValue); + } + + [Fact] + public async Task ExplicitIdOverridesGenerated() + { + var model = new TestModel(); + var rootComponent = new TestInputHostComponent + { + EditContext = new EditContext(model), + ValueExpression = () => model.StringProperty, + AdditionalAttributes = new Dictionary { { "id", "custom-id" } } + }; + + var componentId = await RenderAndGetInputTextComponentIdAsync(rootComponent); + var frames = _testRenderer.GetCurrentRenderTreeFrames(componentId); + + var idAttribute = frames.Array.First(f => f.FrameType == RenderTreeFrameType.Attribute && f.AttributeName == "id"); + Assert.Equal("custom-id", idAttribute.AttributeValue); + } + + private async Task RenderAndGetInputTextComponentIdAsync(TestInputHostComponent hostComponent) + { + var hostComponentId = _testRenderer.AssignRootComponentId(hostComponent); + await _testRenderer.RenderRootComponentAsync(hostComponentId); + var batch = _testRenderer.Batches.Single(); + return batch.GetComponentFrames().Single().ComponentId; + } + private class TestModel { public string StringProperty { get; set; } diff --git a/src/Components/Web/test/Forms/LabelTest.cs b/src/Components/Web/test/Forms/LabelTest.cs new file mode 100644 index 000000000000..96d529e71f07 --- /dev/null +++ b/src/Components/Web/test/Forms/LabelTest.cs @@ -0,0 +1,512 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.ComponentModel; +using System.ComponentModel.DataAnnotations; +using System.Linq.Expressions; +using Microsoft.AspNetCore.Components.Rendering; +using Microsoft.AspNetCore.Components.RenderTree; +using Microsoft.AspNetCore.Components.Test.Helpers; + +namespace Microsoft.AspNetCore.Components.Forms; + +public class LabelTest +{ + [Fact] + public async Task RendersLabelElement() + { + var model = new TestModel(); + var rootComponent = new TestHostComponent + { + InnerContent = builder => + { + builder.OpenComponent>(0); + builder.AddComponentParameter(1, "For", (Expression>)(() => model.PlainProperty)); + builder.CloseComponent(); + } + }; + + var frames = await RenderAndGetFrames(rootComponent); + + var labelElement = frames.First(f => f.FrameType == RenderTree.RenderTreeFrameType.Element && f.ElementName == "label"); + Assert.Equal("label", labelElement.ElementName); + } + + [Fact] + public async Task DisplaysDisplayAttributeNameAsContent() + { + var model = new TestModel(); + var rootComponent = new TestHostComponent + { + InnerContent = builder => + { + builder.OpenComponent>(0); + builder.AddComponentParameter(1, "For", (Expression>)(() => model.PropertyWithDisplayAttribute)); + builder.CloseComponent(); + } + }; + + var frames = await RenderAndGetFrames(rootComponent); + + var textFrame = frames.First(f => f.FrameType == RenderTree.RenderTreeFrameType.Text); + Assert.Equal("Custom Display Name", textFrame.TextContent); + } + + [Fact] + public async Task DisplaysPropertyNameWhenNoAttributePresent() + { + var model = new TestModel(); + var rootComponent = new TestHostComponent + { + InnerContent = builder => + { + builder.OpenComponent>(0); + builder.AddComponentParameter(1, "For", (Expression>)(() => model.PlainProperty)); + builder.CloseComponent(); + } + }; + + var frames = await RenderAndGetFrames(rootComponent); + + var textFrame = frames.First(f => f.FrameType == RenderTree.RenderTreeFrameType.Text); + Assert.Equal("PlainProperty", textFrame.TextContent); + } + + [Fact] + public async Task DisplaysDisplayNameAttributeName() + { + var model = new TestModel(); + var rootComponent = new TestHostComponent + { + InnerContent = builder => + { + builder.OpenComponent>(0); + builder.AddComponentParameter(1, "For", (Expression>)(() => model.PropertyWithDisplayNameAttribute)); + builder.CloseComponent(); + } + }; + + var frames = await RenderAndGetFrames(rootComponent); + + var textFrame = frames.First(f => f.FrameType == RenderTree.RenderTreeFrameType.Text); + Assert.Equal("Custom DisplayName", textFrame.TextContent); + } + + [Fact] + public async Task DisplayAttributeTakesPrecedenceOverDisplayNameAttribute() + { + var model = new TestModel(); + var rootComponent = new TestHostComponent + { + InnerContent = builder => + { + builder.OpenComponent>(0); + builder.AddComponentParameter(1, "For", (Expression>)(() => model.PropertyWithBothAttributes)); + builder.CloseComponent(); + } + }; + + var frames = await RenderAndGetFrames(rootComponent); + + var textFrame = frames.First(f => f.FrameType == RenderTree.RenderTreeFrameType.Text); + Assert.Equal("Display Takes Precedence", textFrame.TextContent); + } + + [Fact] + public async Task AppliesAdditionalAttributes() + { + var model = new TestModel(); + var additionalAttributes = new Dictionary + { + { "class", "form-label" }, + { "data-testid", "my-label" } + }; + + var rootComponent = new TestHostComponent + { + InnerContent = builder => + { + builder.OpenComponent>(0); + builder.AddComponentParameter(1, "For", (Expression>)(() => model.PlainProperty)); + builder.AddComponentParameter(2, "AdditionalAttributes", additionalAttributes); + builder.CloseComponent(); + } + }; + + var frames = await RenderAndGetFrames(rootComponent); + + var classAttribute = frames.FirstOrDefault(f => f.FrameType == RenderTree.RenderTreeFrameType.Attribute && f.AttributeName == "class"); + Assert.NotNull(classAttribute.AttributeName); + Assert.Equal("form-label", classAttribute.AttributeValue); + + var dataAttribute = frames.FirstOrDefault(f => f.FrameType == RenderTree.RenderTreeFrameType.Attribute && f.AttributeName == "data-testid"); + Assert.NotNull(dataAttribute.AttributeName); + Assert.Equal("my-label", dataAttribute.AttributeValue); + } + + [Fact] + public async Task RendersChildContent() + { + var model = new TestModel(); + var rootComponent = new TestHostComponent + { + InnerContent = builder => + { + builder.OpenComponent>(0); + builder.AddComponentParameter(1, "For", (Expression>)(() => model.PlainProperty)); + builder.AddComponentParameter(2, "ChildContent", (RenderFragment)(childBuilder => + { + childBuilder.OpenElement(0, "input"); + childBuilder.AddAttribute(1, "type", "text"); + childBuilder.CloseElement(); + })); + builder.CloseComponent(); + } + }; + + var frames = await RenderAndGetFrames(rootComponent); + + var labelElement = frames.First(f => f.FrameType == RenderTree.RenderTreeFrameType.Element && f.ElementName == "label"); + Assert.Equal("label", labelElement.ElementName); + + var inputElement = frames.First(f => f.FrameType == RenderTree.RenderTreeFrameType.Element && f.ElementName == "input"); + Assert.Equal("input", inputElement.ElementName); + + var textFrame = frames.First(f => f.FrameType == RenderTree.RenderTreeFrameType.Text); + Assert.Equal("PlainProperty", textFrame.TextContent); + } + + [Fact] + public async Task WorksWithDifferentPropertyTypes() + { + var model = new TestModel(); + var intComponent = new TestHostComponent + { + InnerContent = builder => + { + builder.OpenComponent>(0); + builder.AddComponentParameter(1, "For", (Expression>)(() => model.IntProperty)); + builder.CloseComponent(); + } + }; + var dateComponent = new TestHostComponent + { + InnerContent = builder => + { + builder.OpenComponent>(0); + builder.AddComponentParameter(1, "For", (Expression>)(() => model.DateProperty)); + builder.CloseComponent(); + } + }; + + var intFrames = await RenderAndGetFrames(intComponent); + var dateFrames = await RenderAndGetFrames(dateComponent); + + var intText = intFrames.First(f => f.FrameType == RenderTree.RenderTreeFrameType.Text); + var dateText = dateFrames.First(f => f.FrameType == RenderTree.RenderTreeFrameType.Text); + Assert.Equal("Integer Value", intText.TextContent); + Assert.Equal("Date Value", dateText.TextContent); + } + + [Fact] + public async Task ThrowsWhenForIsNull() + { + var rootComponent = new TestHostComponent + { + InnerContent = builder => + { + builder.OpenComponent>(0); + // Not setting For parameter + builder.CloseComponent(); + } + }; + + var testRenderer = new TestRenderer(); + var componentId = testRenderer.AssignRootComponentId(rootComponent); + var exception = await Assert.ThrowsAsync( + () => testRenderer.RenderRootComponentAsync(componentId)); + + Assert.Contains("For", exception.Message); + } + + [Fact] + public async Task AllowsForAttributeInAdditionalAttributes() + { + var model = new TestModel(); + var additionalAttributes = new Dictionary + { + { "for", "some-id" } + }; + + var rootComponent = new TestHostComponent + { + InnerContent = builder => + { + builder.OpenComponent>(0); + builder.AddComponentParameter(1, "For", (Expression>)(() => model.PlainProperty)); + builder.AddComponentParameter(2, "AdditionalAttributes", additionalAttributes); + builder.CloseComponent(); + } + }; + + var frames = await RenderAndGetFrames(rootComponent); + + var labelElement = frames.First(f => f.FrameType == RenderTree.RenderTreeFrameType.Element && f.ElementName == "label"); + Assert.Equal("label", labelElement.ElementName); + + var forAttribute = frames.First(f => f.FrameType == RenderTree.RenderTreeFrameType.Attribute && f.AttributeName == "for"); + Assert.Equal("some-id", forAttribute.AttributeValue); + } + + [Fact] + public async Task RendersWithoutChildContent() + { + var model = new TestModel(); + var rootComponent = new TestHostComponent + { + InnerContent = builder => + { + builder.OpenComponent>(0); + builder.AddComponentParameter(1, "For", (Expression>)(() => model.PlainProperty)); + builder.CloseComponent(); + } + }; + + var frames = await RenderAndGetFrames(rootComponent); + + var labelElement = frames.First(f => f.FrameType == RenderTree.RenderTreeFrameType.Element && f.ElementName == "label"); + Assert.Equal("label", labelElement.ElementName); + + var textFrame = frames.First(f => f.FrameType == RenderTree.RenderTreeFrameType.Text); + Assert.Equal("PlainProperty", textFrame.TextContent); + } + + [Fact] + public async Task RendersForAttributeWhenNoChildContent() + { + var model = new TestModel(); + var rootComponent = new TestHostComponent + { + InnerContent = builder => + { + builder.OpenComponent>(0); + builder.AddComponentParameter(1, "For", (Expression>)(() => model.PlainProperty)); + builder.CloseComponent(); + } + }; + + var frames = await RenderAndGetFrames(rootComponent); + + var forAttribute = frames.First(f => f.FrameType == RenderTree.RenderTreeFrameType.Attribute && f.AttributeName == "for"); + Assert.Equal("model_PlainProperty", forAttribute.AttributeValue); + } + + [Fact] + public async Task DoesNotRenderForAttributeWhenChildContentProvided() + { + var model = new TestModel(); + var rootComponent = new TestHostComponent + { + InnerContent = builder => + { + builder.OpenComponent>(0); + builder.AddComponentParameter(1, "For", (Expression>)(() => model.PlainProperty)); + builder.AddComponentParameter(2, "ChildContent", (RenderFragment)(childBuilder => + { + childBuilder.AddContent(0, "Input goes here"); + })); + builder.CloseComponent(); + } + }; + + var frames = await RenderAndGetFrames(rootComponent); + + var forAttributes = frames.Where(f => f.FrameType == RenderTree.RenderTreeFrameType.Attribute && f.AttributeName == "for"); + Assert.Empty(forAttributes); + } + + [Fact] + public async Task NonNestedLabel_ExplicitForOverridesGenerated() + { + var model = new TestModel(); + var additionalAttributes = new Dictionary + { + { "for", "custom-input-id" } + }; + + var rootComponent = new TestHostComponent + { + InnerContent = builder => + { + builder.OpenComponent>(0); + builder.AddComponentParameter(1, "For", (Expression>)(() => model.PlainProperty)); + builder.AddComponentParameter(2, "AdditionalAttributes", additionalAttributes); + builder.CloseComponent(); + } + }; + + var frames = await RenderAndGetFrames(rootComponent); + + var forAttribute = frames.First(f => f.FrameType == RenderTree.RenderTreeFrameType.Attribute && f.AttributeName == "for"); + Assert.Equal("custom-input-id", forAttribute.AttributeValue); + } + + [Fact] + public async Task WorksWithNestedProperties() + { + var model = new TestModelWithNestedProperty(); + var rootComponent = new TestHostComponent + { + InnerContent = builder => + { + builder.OpenComponent>(0); + builder.AddComponentParameter(1, "For", (Expression>)(() => model.Address.Street)); + builder.CloseComponent(); + } + }; + + var frames = await RenderAndGetFrames(rootComponent); + + var textFrame = frames.First(f => f.FrameType == RenderTree.RenderTreeFrameType.Text); + Assert.Equal("Street Address", textFrame.TextContent); + } + + [Fact] + public async Task SupportsLocalizationWithResourceType() + { + var model = new TestModel(); + var rootComponent = new TestHostComponent + { + InnerContent = builder => + { + builder.OpenComponent>(0); + builder.AddComponentParameter(1, "For", (Expression>)(() => model.PropertyWithResourceBasedDisplay)); + builder.CloseComponent(); + } + }; + + var frames = await RenderAndGetFrames(rootComponent); + + var textFrame = frames.First(f => f.FrameType == RenderTree.RenderTreeFrameType.Text); + Assert.Equal("Localized Display Name", textFrame.TextContent); + } + + [Fact] + public async Task ReRendersWhenForChangesWithSameDisplayNameButAttributesChange() + { + var model = new TestModel(); + Expression> forExpression1 = () => model.PlainProperty; + Expression> forExpression2 = () => model.PlainProperty; + + var attributes1 = new Dictionary { { "class", "label-1" } }; + var attributes2 = new Dictionary { { "class", "label-2" } }; + + var currentFor = forExpression1; + var currentAttributes = attributes1; + + var testRenderer = new TestRenderer(); + var rootComponent = new TestHostComponent + { + InnerContent = builder => + { + builder.OpenComponent>(0); + builder.AddComponentParameter(1, "For", currentFor); + builder.AddComponentParameter(2, "AdditionalAttributes", currentAttributes); + builder.CloseComponent(); + } + }; + + var componentId = testRenderer.AssignRootComponentId(rootComponent); + await testRenderer.RenderRootComponentAsync(componentId); + + // Verify initial render has class="label-1" + var initialFrames = testRenderer.Batches.Last().ReferenceFrames; + var initialClassAttr = initialFrames.First(f => + f.FrameType == RenderTree.RenderTreeFrameType.Attribute && f.AttributeName == "class"); + Assert.Equal("label-1", initialClassAttr.AttributeValue); + + // Change both For (different object, same display name) and AdditionalAttributes + currentFor = forExpression2; + currentAttributes = attributes2; + rootComponent.InnerContent = builder => + { + builder.OpenComponent>(0); + builder.AddComponentParameter(1, "For", currentFor); + builder.AddComponentParameter(2, "AdditionalAttributes", currentAttributes); + builder.CloseComponent(); + }; + + await testRenderer.Dispatcher.InvokeAsync(() => rootComponent.TriggerRender()); + + // Should have re-rendered with the new attributes + var updatedFrames = testRenderer.Batches.Last().ReferenceFrames; + var updatedClassAttr = updatedFrames.First(f => + f.FrameType == RenderTree.RenderTreeFrameType.Attribute && f.AttributeName == "class"); + Assert.Equal("label-2", updatedClassAttr.AttributeValue); + } + + private static async Task RenderAndGetFrames(TestHostComponent rootComponent) + { + var testRenderer = new TestRenderer(); + var componentId = testRenderer.AssignRootComponentId(rootComponent); + await testRenderer.RenderRootComponentAsync(componentId); + + var batch = testRenderer.Batches.Single(); + return batch.ReferenceFrames; + } + + private class TestHostComponent : ComponentBase + { + public RenderFragment InnerContent { get; set; } + + protected override void BuildRenderTree(RenderTreeBuilder builder) + { + InnerContent(builder); + } + + public void TriggerRender() + { + StateHasChanged(); + } + } + + private class TestModel + { + public string PlainProperty { get; set; } = string.Empty; + + [Display(Name = "Custom Display Name")] + public string PropertyWithDisplayAttribute { get; set; } = string.Empty; + + [DisplayName("Custom DisplayName")] + public string PropertyWithDisplayNameAttribute { get; set; } = string.Empty; + + [Display(Name = "Display Takes Precedence")] + [DisplayName("This Should Not Be Used")] + public string PropertyWithBothAttributes { get; set; } = string.Empty; + + [Display(Name = "Integer Value")] + public int IntProperty { get; set; } + + [Display(Name = "Date Value")] + public DateTime DateProperty { get; set; } + + [Display(Name = nameof(TestResources.LocalizedDisplayName), ResourceType = typeof(TestResources))] + public string PropertyWithResourceBasedDisplay { get; set; } = string.Empty; + } + + private class TestModelWithNestedProperty + { + public AddressModel Address { get; set; } = new(); + } + + private class AddressModel + { + [Display(Name = "Street Address")] + public string Street { get; set; } = string.Empty; + } + + public static class TestResources + { + public static string LocalizedDisplayName => "Localized Display Name"; + } +}