From c2a36611a1781696ea4c7035aaa439dda182baf1 Mon Sep 17 00:00:00 2001 From: Philippe Matray Date: Thu, 26 Feb 2026 23:57:37 +0100 Subject: [PATCH] fix: support non-string value types in MudBlazor select field renderer The select field renderer in FormCraftComponent hardcoded MudSelect and MudSelectItem, causing all option values to be converted to strings via ToString(). This meant non-string value types (int, enum, etc.) always returned their default value (e.g., 0 for int) when selected. The fix makes RenderSelectField and RenderSelectOptions generic on the actual property type by using reflection to invoke a generic helper method (RenderSelectFieldGeneric) with the correct type parameter derived from the model property. Also adds type conversion safety in UpdateFieldValue. Fixes #61 --- .../FormContainer/FormCraftComponent.razor.cs | 57 +++++++++++++++---- .../Extensions/FieldBuilderExtensionsTests.cs | 49 ++++++++++++++++ 2 files changed, 94 insertions(+), 12 deletions(-) diff --git a/FormCraft.ForMudBlazor/Features/FormContainer/FormCraftComponent.razor.cs b/FormCraft.ForMudBlazor/Features/FormContainer/FormCraftComponent.razor.cs index ef69c58..ec7385f 100644 --- a/FormCraft.ForMudBlazor/Features/FormContainer/FormCraftComponent.razor.cs +++ b/FormCraft.ForMudBlazor/Features/FormContainer/FormCraftComponent.razor.cs @@ -124,26 +124,42 @@ private RenderFragment RenderField(IFieldConfiguration field) private void RenderSelectField(RenderTreeBuilder builder, IFieldConfiguration field, object? value, object optionsObj) { - builder.OpenComponent>(0); + var property = typeof(TModel).GetProperty(field.FieldName); + var valueType = property?.PropertyType ?? typeof(string); + var underlyingType = Nullable.GetUnderlyingType(valueType) ?? valueType; + + // Use reflection to call the generic helper method with the correct TValue type + var method = typeof(FormCraftComponent) + .GetMethod(nameof(RenderSelectFieldGeneric), System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)! + .MakeGenericMethod(underlyingType); + + method.Invoke(this, new object?[] { builder, field, value, optionsObj }); + } + + private void RenderSelectFieldGeneric(RenderTreeBuilder builder, IFieldConfiguration field, object? value, object optionsObj) + { + var typedValue = value is TValue tv ? tv : default; + + builder.OpenComponent>(0); AddCommonFieldAttributes(builder, field, 1); - builder.AddAttribute(2, "Value", value?.ToString() ?? string.Empty); + builder.AddAttribute(2, "Value", typedValue); builder.AddAttribute(3, "ValueChanged", - EventCallback.Factory.Create(this, + EventCallback.Factory.Create(this, newValue => UpdateFieldValue(field.FieldName, newValue))); - builder.AddAttribute(11, "ChildContent", RenderSelectOptions(optionsObj)); + builder.AddAttribute(11, "ChildContent", RenderSelectOptions(optionsObj)); builder.CloseComponent(); } - private RenderFragment RenderSelectOptions(object optionsObj) + private RenderFragment RenderSelectOptions(object optionsObj) { return builder => { var sequence = 0; - if (optionsObj is IEnumerable> stringOptions) + if (optionsObj is IEnumerable> typedOptions) { - foreach (var option in stringOptions) + foreach (var option in typedOptions) { - builder.OpenComponent>(sequence++); + builder.OpenComponent>(sequence++); builder.AddAttribute(sequence++, "Value", option.Value); builder.AddAttribute(sequence++, "ChildContent", (RenderFragment)(itemBuilder => itemBuilder.AddContent(0, option.Label))); @@ -160,10 +176,11 @@ private RenderFragment RenderSelectOptions(object optionsObj) if (valueProperty != null && labelProperty != null) { - var optionValue = valueProperty.GetValue(option)?.ToString() ?? ""; + var rawValue = valueProperty.GetValue(option); + var optionValue = rawValue is TValue tv ? tv : default; var optionLabel = labelProperty.GetValue(option)?.ToString() ?? ""; - builder.OpenComponent>(sequence++); + builder.OpenComponent>(sequence++); builder.AddAttribute(sequence++, "Value", optionValue); builder.AddAttribute(sequence++, "ChildContent", (RenderFragment)(itemBuilder => itemBuilder.AddContent(0, optionLabel))); @@ -305,11 +322,27 @@ private async Task UpdateFieldValue(string fieldName, object? value) var property = typeof(TModel).GetProperty(fieldName); if (property != null) { - property.SetValue(Model, value); + var targetType = Nullable.GetUnderlyingType(property.PropertyType) ?? property.PropertyType; + var convertedValue = value; + + // Convert value to the target type if necessary + if (value != null && value.GetType() != targetType) + { + try + { + convertedValue = Convert.ChangeType(value, targetType); + } + catch + { + // If conversion fails, use the value as-is + } + } + + property.SetValue(Model, convertedValue); if (OnFieldChanged.HasDelegate) { - await OnFieldChanged.InvokeAsync((fieldName, value)); + await OnFieldChanged.InvokeAsync((fieldName, convertedValue)); } // Handle dependencies diff --git a/FormCraft.UnitTests/Extensions/FieldBuilderExtensionsTests.cs b/FormCraft.UnitTests/Extensions/FieldBuilderExtensionsTests.cs index d4b2929..0a16a1f 100644 --- a/FormCraft.UnitTests/Extensions/FieldBuilderExtensionsTests.cs +++ b/FormCraft.UnitTests/Extensions/FieldBuilderExtensionsTests.cs @@ -70,6 +70,55 @@ public void WithOptions_Should_Set_Options_Attribute() options.ShouldContain(o => o.Value == "pending" && o.Label == "Pending"); } + [Fact] + public void WithOptions_Should_Set_Int_Options_Attribute() + { + // Arrange & Act + var config = FormBuilder.Create() + .AddField(x => x.Rating, field => field + .WithOptions( + (1, "Low"), + (2, "Medium"), + (3, "High"))) + .Build(); + + // Assert + var field = config.Fields.First(f => f.FieldName == "Rating"); + field.AdditionalAttributes.ShouldContainKey("Options"); + + var options = (field.AdditionalAttributes["Options"] as IEnumerable>)?.ToList(); + options.ShouldNotBeNull(); + options.Count.ShouldBe(3); + options.ShouldContain(o => o.Value == 1 && o.Label == "Low"); + options.ShouldContain(o => o.Value == 2 && o.Label == "Medium"); + options.ShouldContain(o => o.Value == 3 && o.Label == "High"); + } + + [Fact] + public void WithOptions_Should_Preserve_Int_Value_Types() + { + // Arrange & Act + var config = FormBuilder.Create() + .AddField(x => x.Age, field => field + .WithOptions( + (18, "Eighteen"), + (25, "Twenty-Five"), + (65, "Sixty-Five"))) + .Build(); + + // Assert + var field = config.Fields.First(f => f.FieldName == "Age"); + field.AdditionalAttributes.ShouldContainKey("Options"); + + var options = (field.AdditionalAttributes["Options"] as IEnumerable>)?.ToList(); + options.ShouldNotBeNull(); + options.Count.ShouldBe(3); + + // Verify the values are actual ints, not strings + options[0].Value.ShouldBeOfType(); + options[0].Value.ShouldBe(18); + } + [Fact] public void AsMultiSelect_Should_Set_MultiSelectOptions_Attribute() {