diff --git a/FormCraft.ForMudBlazor/Features/CollectionField/CollectionFieldComponent.razor b/FormCraft.ForMudBlazor/Features/CollectionField/CollectionFieldComponent.razor new file mode 100644 index 0000000..4e741f3 --- /dev/null +++ b/FormCraft.ForMudBlazor/Features/CollectionField/CollectionFieldComponent.razor @@ -0,0 +1,85 @@ +@namespace FormCraft.ForMudBlazor +@typeparam TModel where TModel : new() +@typeparam TItem where TItem : new() +@inject IFieldRendererService FieldRendererService + +@if (Configuration.IsVisible) +{ + +
+ @(Configuration.Label ?? Configuration.FieldName) + @if (Configuration.CanAdd && !HasReachedMax) + { + + @Configuration.AddButtonText + + } +
+ + @if (Items.Count == 0) + { + + @Configuration.EmptyText + + } + else + { + @for (var i = 0; i < Items.Count; i++) + { + var index = i; + + +
+ + Item @(index + 1) + +
+ @if (Configuration.CanReorder) + { + + + } + @if (Configuration.CanRemove && !HasReachedMin) + { + + } +
+
+ @if (Configuration.ItemFormConfiguration != null) + { + @RenderItemFields(index) + } +
+
+ } + } + + @if (ValidationErrors.Any()) + { + + + + } +
+} diff --git a/FormCraft.ForMudBlazor/Features/CollectionField/CollectionFieldComponent.razor.cs b/FormCraft.ForMudBlazor/Features/CollectionField/CollectionFieldComponent.razor.cs new file mode 100644 index 0000000..a54f5d4 --- /dev/null +++ b/FormCraft.ForMudBlazor/Features/CollectionField/CollectionFieldComponent.razor.cs @@ -0,0 +1,240 @@ +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Rendering; +using MudBlazor; + +namespace FormCraft.ForMudBlazor; + +/// +/// A MudBlazor component that renders a collection (one-to-many) field with add, remove, reorder capabilities. +/// Each item in the collection is rendered as a sub-form using the configured item form fields. +/// +/// The parent model type. +/// The type of items in the collection. +public partial class CollectionFieldComponent + where TModel : new() + where TItem : new() +{ + /// + /// Gets or sets the parent model instance. + /// + [Parameter] + public TModel Model { get; set; } = default!; + + /// + /// Gets or sets the collection field configuration. + /// + [Parameter] + public ICollectionFieldConfiguration Configuration { get; set; } = default!; + + /// + /// Gets or sets the callback invoked when the collection changes (items added, removed, or reordered). + /// + [Parameter] + public EventCallback OnCollectionChanged { get; set; } + + private List Items => Configuration.CollectionAccessor(Model); + + private bool HasReachedMax => Configuration.MaxItems > 0 && Items.Count >= Configuration.MaxItems; + + private bool HasReachedMin => Configuration.MinItems > 0 && Items.Count <= Configuration.MinItems; + + private List ValidationErrors { get; set; } = new(); + + private async Task AddItem() + { + if (HasReachedMax) return; + + Items.Add(new TItem()); + await NotifyCollectionChanged(); + } + + private async Task RemoveItem(int index) + { + if (HasReachedMin) return; + if (index < 0 || index >= Items.Count) return; + + Items.RemoveAt(index); + await NotifyCollectionChanged(); + } + + private async Task MoveItemUp(int index) + { + if (index <= 0 || index >= Items.Count) return; + + (Items[index], Items[index - 1]) = (Items[index - 1], Items[index]); + await NotifyCollectionChanged(); + } + + private async Task MoveItemDown(int index) + { + if (index < 0 || index >= Items.Count - 1) return; + + (Items[index], Items[index + 1]) = (Items[index + 1], Items[index]); + await NotifyCollectionChanged(); + } + + private async Task NotifyCollectionChanged() + { + if (OnCollectionChanged.HasDelegate) + { + await OnCollectionChanged.InvokeAsync(); + } + + StateHasChanged(); + } + + private async Task UpdateItemFieldValue(int itemIndex, string fieldName, object? value) + { + if (itemIndex < 0 || itemIndex >= Items.Count) return; + + var item = Items[itemIndex]; + var property = typeof(TItem).GetProperty(fieldName); + if (property != null) + { + var targetType = Nullable.GetUnderlyingType(property.PropertyType) ?? property.PropertyType; + var convertedValue = value; + + if (value != null && value.GetType() != targetType) + { + try + { + convertedValue = Convert.ChangeType(value, targetType); + } + catch + { + // If conversion fails, use the value as-is + } + } + + property.SetValue(item, convertedValue); + } + + await NotifyCollectionChanged(); + } + + private RenderFragment RenderItemFields(int itemIndex) + { + return builder => + { + if (Configuration.ItemFormConfiguration == null) return; + + var item = Items[itemIndex]; + + foreach (var field in Configuration.ItemFormConfiguration.Fields.OrderBy(f => f.Order)) + { + var capturedIndex = itemIndex; + var capturedFieldName = field.FieldName; + + builder.OpenElement(0, "div"); + builder.AddAttribute(1, "class", "mb-3"); + builder.AddContent(2, RenderItemField(item, field, capturedIndex)); + builder.CloseElement(); + } + }; + } + + private RenderFragment RenderItemField(TItem item, IFieldConfiguration field, int itemIndex) + { + return builder => + { + var property = typeof(TItem).GetProperty(field.FieldName); + if (property == null) return; + + var fieldType = property.PropertyType; + var underlyingType = Nullable.GetUnderlyingType(fieldType) ?? fieldType; + var value = property.GetValue(item); + + if (fieldType == typeof(string)) + { + RenderTextField(builder, field, value as string, itemIndex); + } + else if (underlyingType == typeof(int)) + { + RenderNumericField(builder, field, (int)(value ?? 0), itemIndex); + } + else if (underlyingType == typeof(decimal)) + { + RenderNumericField(builder, field, (decimal)(value ?? 0m), itemIndex); + } + else if (underlyingType == typeof(double)) + { + RenderNumericField(builder, field, (double)(value ?? 0.0), itemIndex); + } + else if (underlyingType == typeof(bool)) + { + RenderBooleanField(builder, field, value ?? false, itemIndex); + } + else if (underlyingType == typeof(DateTime)) + { + RenderDateTimeField(builder, field, value as DateTime?, itemIndex); + } + }; + } + + private void RenderTextField(RenderTreeBuilder builder, IFieldConfiguration field, string? value, int itemIndex) + { + builder.OpenComponent>(0); + AddCommonFieldAttributes(builder, field, 1); + builder.AddAttribute(2, "Value", value); + builder.AddAttribute(3, "ValueChanged", + EventCallback.Factory.Create(this, + newValue => UpdateItemFieldValue(itemIndex, field.FieldName, newValue))); + builder.AddAttribute(4, "Immediate", true); + builder.CloseComponent(); + } + + private void RenderNumericField(RenderTreeBuilder builder, IFieldConfiguration field, T value, int itemIndex) + where T : struct + { + builder.OpenComponent(0, typeof(MudNumericField<>).MakeGenericType(typeof(T))); + AddCommonFieldAttributes(builder, field, 1); + builder.AddAttribute(2, "Value", value); + builder.AddAttribute(3, "ValueChanged", + EventCallback.Factory.Create(this, + newValue => UpdateItemFieldValue(itemIndex, field.FieldName, newValue))); + builder.AddAttribute(4, "Immediate", true); + builder.AddAttribute(5, "Culture", System.Globalization.CultureInfo.InvariantCulture); + if (typeof(T) == typeof(decimal)) + { + builder.AddAttribute(6, "Pattern", "[0-9]+([.,][0-9]+)?"); + } + builder.CloseComponent(); + } + + private void RenderBooleanField(RenderTreeBuilder builder, IFieldConfiguration field, object value, int itemIndex) + { + builder.OpenComponent>(0); + builder.AddAttribute(1, "Label", field.Label); + builder.AddAttribute(2, "Value", value); + builder.AddAttribute(3, "ValueChanged", + EventCallback.Factory.Create(this, + newValue => UpdateItemFieldValue(itemIndex, field.FieldName, newValue))); + builder.AddAttribute(4, "ReadOnly", field.IsReadOnly); + builder.AddAttribute(5, "Disabled", field.IsDisabled); + builder.CloseComponent(); + } + + private void RenderDateTimeField(RenderTreeBuilder builder, IFieldConfiguration field, DateTime? value, int itemIndex) + { + builder.OpenComponent(0); + AddCommonFieldAttributes(builder, field, 1); + builder.AddAttribute(2, "Date", value); + builder.AddAttribute(3, "DateChanged", + EventCallback.Factory.Create(this, + newValue => UpdateItemFieldValue(itemIndex, field.FieldName, newValue))); + builder.CloseComponent(); + } + + private void AddCommonFieldAttributes(RenderTreeBuilder builder, IFieldConfiguration field, int startIndex) + { + builder.AddAttribute(startIndex++, "Label", field.Label); + builder.AddAttribute(startIndex++, "Placeholder", field.Placeholder); + builder.AddAttribute(startIndex++, "HelperText", field.HelpText); + builder.AddAttribute(startIndex++, "Required", field.IsRequired); + builder.AddAttribute(startIndex++, "ReadOnly", field.IsReadOnly); + builder.AddAttribute(startIndex++, "Disabled", field.IsDisabled); + builder.AddAttribute(startIndex++, "Variant", Variant.Outlined); + builder.AddAttribute(startIndex++, "Margin", Margin.Dense); + builder.AddAttribute(startIndex, "ShrinkLabel", true); + } +} diff --git a/FormCraft.ForMudBlazor/Features/CollectionField/CollectionFieldRenderer.cs b/FormCraft.ForMudBlazor/Features/CollectionField/CollectionFieldRenderer.cs new file mode 100644 index 0000000..27c9959 --- /dev/null +++ b/FormCraft.ForMudBlazor/Features/CollectionField/CollectionFieldRenderer.cs @@ -0,0 +1,39 @@ +using Microsoft.AspNetCore.Components; + +namespace FormCraft.ForMudBlazor; + +/// +/// Helper class that creates the appropriate generic CollectionFieldComponent for a given +/// collection field configuration using reflection. This bridges the non-generic +/// ICollectionFieldConfigurationBase to the generic CollectionFieldComponent. +/// +public static class CollectionFieldRenderer +{ + /// + /// Creates a RenderFragment that renders the appropriate CollectionFieldComponent + /// for the given collection field configuration. + /// + /// The parent model type. + /// The parent model instance. + /// The collection field configuration (must implement ICollectionFieldConfigurationBase). + /// Callback invoked when the collection changes. + /// A RenderFragment that renders the collection field. + public static RenderFragment Render( + TModel model, + ICollectionFieldConfigurationBase collectionFieldConfig, + EventCallback onCollectionChanged) + where TModel : new() + { + return builder => + { + var itemType = collectionFieldConfig.ItemType; + var componentType = typeof(CollectionFieldComponent<,>).MakeGenericType(typeof(TModel), itemType); + + builder.OpenComponent(0, componentType); + builder.AddAttribute(1, "Model", model); + builder.AddAttribute(2, "Configuration", collectionFieldConfig); + builder.AddAttribute(3, "OnCollectionChanged", onCollectionChanged); + builder.CloseComponent(); + }; + } +} diff --git a/FormCraft.ForMudBlazor/Features/FormContainer/FormCraftComponent.razor b/FormCraft.ForMudBlazor/Features/FormContainer/FormCraftComponent.razor index 1c7079f..b299c6a 100644 --- a/FormCraft.ForMudBlazor/Features/FormContainer/FormCraftComponent.razor +++ b/FormCraft.ForMudBlazor/Features/FormContainer/FormCraftComponent.razor @@ -80,6 +80,19 @@ } } } + + @* Render collection fields *@ + @if (CollectionConfiguration != null) + { + @foreach (var collectionField in CollectionConfiguration.CollectionFields.OrderBy(f => f.Order)) + { + if (collectionField.IsVisible) + { + @(CollectionFieldRenderer.Render(Model, collectionField, + EventCallback.Factory.Create(this, HandleCollectionChanged))) + } + } + } @if (ShowSubmitButton) { diff --git a/FormCraft.ForMudBlazor/Features/FormContainer/FormCraftComponent.razor.cs b/FormCraft.ForMudBlazor/Features/FormContainer/FormCraftComponent.razor.cs index 6fa6dce..d877291 100644 --- a/FormCraft.ForMudBlazor/Features/FormContainer/FormCraftComponent.razor.cs +++ b/FormCraft.ForMudBlazor/Features/FormContainer/FormCraftComponent.razor.cs @@ -45,6 +45,7 @@ public partial class FormCraftComponent private EditContext? _editContext; private IGroupedFormConfiguration? GroupedConfiguration => Configuration as IGroupedFormConfiguration; + private ICollectionFormConfiguration? CollectionConfiguration => Configuration as ICollectionFormConfiguration; protected override async Task OnInitializedAsync() { @@ -413,6 +414,12 @@ private Task HandleFieldDependencyChanged(string fieldName) return Task.CompletedTask; } + private void HandleCollectionChanged() + { + _editContext?.NotifyValidationStateChanged(); + StateHasChanged(); + } + private bool ShouldShowField(IFieldConfiguration field) { if (field.VisibilityCondition != null) diff --git a/FormCraft.ForMudBlazor/Features/Validation/DynamicFormValidator.cs b/FormCraft.ForMudBlazor/Features/Validation/DynamicFormValidator.cs index be9d4b5..aef6cd5 100644 --- a/FormCraft.ForMudBlazor/Features/Validation/DynamicFormValidator.cs +++ b/FormCraft.ForMudBlazor/Features/Validation/DynamicFormValidator.cs @@ -59,9 +59,35 @@ private async void HandleValidationRequested(object? sender, ValidationRequested } } + // Validate collection fields + if (Configuration is ICollectionFormConfiguration collectionConfig) + { + foreach (var collectionField in collectionConfig.CollectionFields) + { + var errors = await ValidateCollectionFieldAsync(model, collectionField); + foreach (var error in errors) + { + _messageStore.Add(_editContext.Field(collectionField.FieldName), error); + } + } + } + _editContext.NotifyValidationStateChanged(); } + private async Task> ValidateCollectionFieldAsync(TModel model, ICollectionFieldConfigurationBase collectionField) + { + // Use reflection to create the typed validator and invoke it + var validatorType = typeof(CollectionFieldValidator<,>).MakeGenericType(typeof(TModel), collectionField.ItemType); + var validator = Activator.CreateInstance(validatorType, collectionField); + + var validateMethod = validatorType.GetMethod("ValidateAsync"); + if (validateMethod == null) return new List(); + + var task = (Task>)validateMethod.Invoke(validator, new object[] { model!, ServiceProvider })!; + return await task; + } + private async void HandleFieldChanged(object? sender, FieldChangedEventArgs e) { // Find the field configuration for the changed field diff --git a/FormCraft.UnitTests/Builders/CollectionFieldBuilderTests.cs b/FormCraft.UnitTests/Builders/CollectionFieldBuilderTests.cs new file mode 100644 index 0000000..514a110 --- /dev/null +++ b/FormCraft.UnitTests/Builders/CollectionFieldBuilderTests.cs @@ -0,0 +1,266 @@ +namespace FormCraft.UnitTests.Builders; + +public class CollectionFieldBuilderTests +{ + [Fact] + public void AddCollectionField_Should_Add_Collection_To_Configuration() + { + // Arrange & Act + var config = FormBuilder.Create() + .AddCollectionField(x => x.Items) + .Build(); + + // Assert + var collectionConfig = config as ICollectionFormConfiguration; + collectionConfig.ShouldNotBeNull(); + collectionConfig.CollectionFields.Count.ShouldBe(1); + collectionConfig.CollectionFields[0].FieldName.ShouldBe("Items"); + } + + [Fact] + public void AddCollectionField_Should_Return_FormBuilder_For_Chaining() + { + // Arrange + var builder = FormBuilder.Create(); + + // Act + var result = builder.AddCollectionField(x => x.Items); + + // Assert + result.ShouldBeSameAs(builder); + } + + [Fact] + public void AddCollectionField_Should_Support_AllowAdd() + { + // Arrange & Act + var config = FormBuilder.Create() + .AddCollectionField(x => x.Items, collection => collection + .AllowAdd("Add Order Item")) + .Build(); + + // Assert + var collectionConfig = (ICollectionFormConfiguration)config; + var field = collectionConfig.CollectionFields[0]; + field.CanAdd.ShouldBeTrue(); + field.AddButtonText.ShouldBe("Add Order Item"); + } + + [Fact] + public void AddCollectionField_Should_Support_AllowRemove() + { + // Arrange & Act + var config = FormBuilder.Create() + .AddCollectionField(x => x.Items, collection => collection + .AllowRemove()) + .Build(); + + // Assert + var collectionConfig = (ICollectionFormConfiguration)config; + collectionConfig.CollectionFields[0].CanRemove.ShouldBeTrue(); + } + + [Fact] + public void AddCollectionField_Should_Support_AllowReorder() + { + // Arrange & Act + var config = FormBuilder.Create() + .AddCollectionField(x => x.Items, collection => collection + .AllowReorder()) + .Build(); + + // Assert + var collectionConfig = (ICollectionFormConfiguration)config; + collectionConfig.CollectionFields[0].CanReorder.ShouldBeTrue(); + } + + [Fact] + public void AddCollectionField_Should_Support_MinMaxItems() + { + // Arrange & Act + var config = FormBuilder.Create() + .AddCollectionField(x => x.Items, collection => collection + .WithMinItems(1) + .WithMaxItems(10)) + .Build(); + + // Assert + var collectionConfig = (ICollectionFormConfiguration)config; + var field = collectionConfig.CollectionFields[0]; + field.MinItems.ShouldBe(1); + field.MaxItems.ShouldBe(10); + } + + [Fact] + public void AddCollectionField_Should_Support_WithLabel() + { + // Arrange & Act + var config = FormBuilder.Create() + .AddCollectionField(x => x.Items, collection => collection + .WithLabel("Order Items")) + .Build(); + + // Assert + var collectionConfig = (ICollectionFormConfiguration)config; + collectionConfig.CollectionFields[0].Label.ShouldBe("Order Items"); + } + + [Fact] + public void AddCollectionField_Should_Support_EmptyText() + { + // Arrange & Act + var config = FormBuilder.Create() + .AddCollectionField(x => x.Items, collection => collection + .WithEmptyText("No items yet")) + .Build(); + + // Assert + var collectionConfig = (ICollectionFormConfiguration)config; + collectionConfig.CollectionFields[0].EmptyText.ShouldBe("No items yet"); + } + + [Fact] + public void AddCollectionField_Should_Support_WithItemForm() + { + // Arrange & Act + var config = FormBuilder.Create() + .AddCollectionField(x => x.Items, collection => collection + .WithItemForm(item => item + .AddField(x => x.ProductName, field => field.Required()) + .AddField(x => x.Quantity, field => field.WithRange(1, 100)) + .AddField(x => x.UnitPrice))) + .Build(); + + // Assert + var collectionConfig = (ICollectionFormConfiguration)config; + var collectionField = collectionConfig.CollectionFields[0] as CollectionFieldConfiguration; + collectionField.ShouldNotBeNull(); + collectionField.ItemFormConfiguration.ShouldNotBeNull(); + collectionField.ItemFormConfiguration!.Fields.Count.ShouldBe(3); + } + + [Fact] + public void AddCollectionField_Should_Assign_Correct_Order() + { + // Arrange & Act + var config = FormBuilder.Create() + .AddField(x => x.OrderNumber) + .AddCollectionField(x => x.Items) + .Build(); + + // Assert + var collectionConfig = (ICollectionFormConfiguration)config; + config.Fields[0].Order.ShouldBe(0); + collectionConfig.CollectionFields[0].Order.ShouldBe(1); + } + + [Fact] + public void AddCollectionField_Should_Have_Default_Values() + { + // Arrange & Act + var config = FormBuilder.Create() + .AddCollectionField(x => x.Items) + .Build(); + + // Assert + var collectionConfig = (ICollectionFormConfiguration)config; + var field = collectionConfig.CollectionFields[0]; + field.CanAdd.ShouldBeFalse(); + field.CanRemove.ShouldBeFalse(); + field.CanReorder.ShouldBeFalse(); + field.MinItems.ShouldBe(0); + field.MaxItems.ShouldBe(0); + field.IsVisible.ShouldBeTrue(); + field.AddButtonText.ShouldBe("Add Item"); + } + + [Fact] + public void CollectionField_Accessor_Should_Return_Collection() + { + // Arrange + var model = new OrderModel + { + Items = new List + { + new() { ProductName = "Widget", Quantity = 2 } + } + }; + + var config = FormBuilder.Create() + .AddCollectionField(x => x.Items) + .Build(); + + var collectionConfig = (ICollectionFormConfiguration)config; + var collectionField = (CollectionFieldConfiguration)collectionConfig.CollectionFields[0]; + + // Act + var items = collectionField.CollectionAccessor(model); + + // Assert + items.Count.ShouldBe(1); + items[0].ProductName.ShouldBe("Widget"); + } + + [Fact] + public void CollectionField_ItemType_Should_Return_Correct_Type() + { + // Arrange & Act + var config = FormBuilder.Create() + .AddCollectionField(x => x.Items) + .Build(); + + var collectionConfig = (ICollectionFormConfiguration)config; + + // Assert + collectionConfig.CollectionFields[0].ItemType.ShouldBe(typeof(OrderItemModel)); + } + + [Fact] + public void Full_Api_Example_Should_Match_Proposed_Pattern() + { + // This test verifies the full API matches the owner's proposed pattern from the issue. + // Arrange & Act + var formConfig = FormBuilder.Create() + .AddField(x => x.OrderNumber) + .AddCollectionField(x => x.Items, collection => collection + .AllowAdd() + .AllowRemove() + .WithMinItems(1) + .WithItemForm(item => item + .AddField(x => x.ProductName, field => field.Required()) + .AddField(x => x.Quantity, field => field.WithRange(1, 100)) + .AddField(x => x.UnitPrice, field => field.WithRange(0.01m, 10000m)))) + .Build(); + + // Assert + formConfig.Fields.Count.ShouldBe(1); + formConfig.Fields[0].FieldName.ShouldBe("OrderNumber"); + + var collectionConfig = (ICollectionFormConfiguration)formConfig; + collectionConfig.CollectionFields.Count.ShouldBe(1); + + var collectionField = (CollectionFieldConfiguration)collectionConfig.CollectionFields[0]; + collectionField.FieldName.ShouldBe("Items"); + collectionField.CanAdd.ShouldBeTrue(); + collectionField.CanRemove.ShouldBeTrue(); + collectionField.MinItems.ShouldBe(1); + collectionField.ItemFormConfiguration.ShouldNotBeNull(); + collectionField.ItemFormConfiguration!.Fields.Count.ShouldBe(3); + } + + // Test models matching the owner's proposed API + public class OrderModel + { + public string OrderNumber { get; set; } = ""; + public List Items { get; set; } = new(); + public decimal TotalAmount => Items.Sum(x => x.TotalPrice); + } + + public class OrderItemModel + { + public string ProductName { get; set; } = ""; + public int Quantity { get; set; } = 1; + public decimal UnitPrice { get; set; } = 0m; + public decimal TotalPrice => Quantity * UnitPrice; + } +} diff --git a/FormCraft.UnitTests/Validators/CollectionFieldValidatorTests.cs b/FormCraft.UnitTests/Validators/CollectionFieldValidatorTests.cs new file mode 100644 index 0000000..e7278cb --- /dev/null +++ b/FormCraft.UnitTests/Validators/CollectionFieldValidatorTests.cs @@ -0,0 +1,161 @@ +namespace FormCraft.UnitTests.Validators; + +public class CollectionFieldValidatorTests +{ + [Fact] + public async Task Validate_Should_Pass_When_No_Constraints() + { + // Arrange + var config = CreateCollectionConfig(); + var validator = new CollectionFieldValidator(config); + var model = new OrderModel(); + var services = A.Fake(); + + // Act + var errors = await validator.ValidateAsync(model, services); + + // Assert + errors.ShouldBeEmpty(); + } + + [Fact] + public async Task Validate_Should_Fail_When_Below_MinItems() + { + // Arrange + var config = CreateCollectionConfig(minItems: 1); + var validator = new CollectionFieldValidator(config); + var model = new OrderModel(); // Empty items list + var services = A.Fake(); + + // Act + var errors = await validator.ValidateAsync(model, services); + + // Assert + errors.Count.ShouldBe(1); + errors[0].ShouldContain("at least 1"); + } + + [Fact] + public async Task Validate_Should_Pass_When_At_MinItems() + { + // Arrange + var config = CreateCollectionConfig(minItems: 1); + var validator = new CollectionFieldValidator(config); + var model = new OrderModel + { + Items = new List { new() { ProductName = "Widget" } } + }; + var services = A.Fake(); + + // Act + var errors = await validator.ValidateAsync(model, services); + + // Assert + errors.ShouldBeEmpty(); + } + + [Fact] + public async Task Validate_Should_Fail_When_Above_MaxItems() + { + // Arrange + var config = CreateCollectionConfig(maxItems: 2); + var validator = new CollectionFieldValidator(config); + var model = new OrderModel + { + Items = new List + { + new(), new(), new() // 3 items, max is 2 + } + }; + var services = A.Fake(); + + // Act + var errors = await validator.ValidateAsync(model, services); + + // Assert + errors.Count.ShouldBe(1); + errors[0].ShouldContain("at most 2"); + } + + [Fact] + public async Task Validate_Should_Validate_Individual_Items_With_ItemForm() + { + // Arrange + var config = CreateCollectionConfigWithItemForm(); + var validator = new CollectionFieldValidator(config); + var model = new OrderModel + { + Items = new List + { + new() { ProductName = "" } // Empty product name should fail required validation + } + }; + var services = A.Fake(); + + // Act + var errors = await validator.ValidateAsync(model, services); + + // Assert + errors.Count.ShouldBeGreaterThan(0); + errors.ShouldContain(e => e.Contains("[1]") && e.Contains("ProductName")); + } + + [Fact] + public async Task Validate_Should_Pass_When_Items_Are_Valid() + { + // Arrange + var config = CreateCollectionConfigWithItemForm(); + var validator = new CollectionFieldValidator(config); + var model = new OrderModel + { + Items = new List + { + new() { ProductName = "Widget", Quantity = 5, UnitPrice = 10.00m } + } + }; + var services = A.Fake(); + + // Act + var errors = await validator.ValidateAsync(model, services); + + // Assert + errors.ShouldBeEmpty(); + } + + private CollectionFieldConfiguration CreateCollectionConfig( + int minItems = 0, int maxItems = 0) + { + return new CollectionFieldConfiguration(x => x.Items) + { + MinItems = minItems, + MaxItems = maxItems + }; + } + + private CollectionFieldConfiguration CreateCollectionConfigWithItemForm() + { + var itemForm = FormBuilder.Create() + .AddField(x => x.ProductName, field => field.Required("Product name is required")) + .AddField(x => x.Quantity, field => field.WithRange(1, 100)) + .Build(); + + return new CollectionFieldConfiguration(x => x.Items) + { + ItemFormConfiguration = itemForm + }; + } + + public class OrderModel + { + public string OrderNumber { get; set; } = ""; + public List Items { get; set; } = new(); + } + + public class OrderItemModel + { + public string ProductName { get; set; } = ""; + public int Quantity { get; set; } = 1; + public decimal UnitPrice { get; set; } = 0m; + public decimal TotalPrice => Quantity * UnitPrice; + } +} diff --git a/FormCraft/Forms/Builders/CollectionFieldBuilder.cs b/FormCraft/Forms/Builders/CollectionFieldBuilder.cs new file mode 100644 index 0000000..43bf643 --- /dev/null +++ b/FormCraft/Forms/Builders/CollectionFieldBuilder.cs @@ -0,0 +1,129 @@ +namespace FormCraft; + +/// +/// Provides a fluent API for configuring collection (one-to-many) fields in a form. +/// +/// The parent model type that contains the collection. +/// The type of items in the collection. +/// +/// +/// .AddCollectionField(x => x.Items, collection => collection +/// .AllowAdd() +/// .AllowRemove() +/// .WithMinItems(1) +/// .WithItemForm(item => item +/// .AddField(x => x.ProductName, field => field.Required()) +/// .AddField(x => x.Quantity, field => field.WithRange(1, 100)))) +/// +/// +public class CollectionFieldBuilder + where TModel : new() + where TItem : new() +{ + private readonly CollectionFieldConfiguration _configuration; + + internal CollectionFieldBuilder(CollectionFieldConfiguration configuration) + { + _configuration = configuration; + } + + /// + /// Sets the display label for the collection field. + /// + /// The text to display as the collection label. + /// The CollectionFieldBuilder instance for method chaining. + public CollectionFieldBuilder WithLabel(string label) + { + _configuration.Label = label; + return this; + } + + /// + /// Allows users to add new items to the collection. + /// + /// Optional text for the add button. + /// The CollectionFieldBuilder instance for method chaining. + public CollectionFieldBuilder AllowAdd(string? buttonText = null) + { + _configuration.CanAdd = true; + if (buttonText != null) + { + _configuration.AddButtonText = buttonText; + } + return this; + } + + /// + /// Allows users to remove items from the collection. + /// + /// The CollectionFieldBuilder instance for method chaining. + public CollectionFieldBuilder AllowRemove() + { + _configuration.CanRemove = true; + return this; + } + + /// + /// Allows users to reorder items in the collection using up/down buttons. + /// + /// The CollectionFieldBuilder instance for method chaining. + public CollectionFieldBuilder AllowReorder() + { + _configuration.CanReorder = true; + return this; + } + + /// + /// Sets the minimum number of items required in the collection. + /// + /// The minimum number of items. + /// The CollectionFieldBuilder instance for method chaining. + public CollectionFieldBuilder WithMinItems(int min) + { + _configuration.MinItems = min; + return this; + } + + /// + /// Sets the maximum number of items allowed in the collection. + /// + /// The maximum number of items. + /// The CollectionFieldBuilder instance for method chaining. + public CollectionFieldBuilder WithMaxItems(int max) + { + _configuration.MaxItems = max; + return this; + } + + /// + /// Sets the text to display when the collection is empty. + /// + /// The empty state message. + /// The CollectionFieldBuilder instance for method chaining. + public CollectionFieldBuilder WithEmptyText(string text) + { + _configuration.EmptyText = text; + return this; + } + + /// + /// Configures the form for individual items in the collection using a nested FormBuilder. + /// + /// A lambda that configures the item form using a FormBuilder. + /// The CollectionFieldBuilder instance for method chaining. + /// + /// + /// .WithItemForm(item => item + /// .AddField(x => x.ProductName, field => field.Required()) + /// .AddField(x => x.Quantity, field => field.WithRange(1, 100)) + /// .AddField(x => x.UnitPrice, field => field.WithRange(0.01m, 10000m))) + /// + /// + public CollectionFieldBuilder WithItemForm(Action> itemFormConfig) + { + var itemBuilder = FormBuilder.Create(); + itemFormConfig(itemBuilder); + _configuration.ItemFormConfiguration = itemBuilder.Build(); + return this; + } +} diff --git a/FormCraft/Forms/Builders/FormBuilder.cs b/FormCraft/Forms/Builders/FormBuilder.cs index 3c68405..8d2fc64 100644 --- a/FormCraft/Forms/Builders/FormBuilder.cs +++ b/FormCraft/Forms/Builders/FormBuilder.cs @@ -62,6 +62,46 @@ public FormBuilder AddField( return this; } + /// + /// Adds a collection (one-to-many) field to the form configuration with fluent configuration. + /// Collection fields allow users to manage a list of sub-items within the form. + /// + /// The type of items in the collection. Must have a parameterless constructor. + /// A lambda expression that identifies the collection property on the model (e.g., x => x.Items). + /// A lambda expression to configure the collection field's behavior and item form. + /// The FormBuilder instance for method chaining. + /// + /// + /// builder.AddCollectionField(x => x.Items, collection => collection + /// .AllowAdd() + /// .AllowRemove() + /// .WithMinItems(1) + /// .WithItemForm(item => item + /// .AddField(x => x.ProductName, field => field.Required()) + /// .AddField(x => x.Quantity, field => field.WithRange(1, 100)))); + /// + /// + public FormBuilder AddCollectionField( + Expression>> expression, + Action>? collectionConfig = null) + where TItem : new() + { + var fieldConfiguration = new CollectionFieldConfiguration(expression) + { + Order = _fieldOrder++ + }; + + _configuration.CollectionFields.Add(fieldConfiguration); + + if (collectionConfig != null) + { + var builder = new CollectionFieldBuilder(fieldConfiguration); + collectionConfig(builder); + } + + return this; + } + /// /// Adds a field group to the form using a lambda expression for configuration. /// diff --git a/FormCraft/Forms/Core/CollectionFieldConfiguration.cs b/FormCraft/Forms/Core/CollectionFieldConfiguration.cs new file mode 100644 index 0000000..4ce9481 --- /dev/null +++ b/FormCraft/Forms/Core/CollectionFieldConfiguration.cs @@ -0,0 +1,82 @@ +using System.Linq.Expressions; + +namespace FormCraft; + +/// +/// Default implementation of that stores +/// all configuration for a collection field. +/// +/// The parent model type that contains the collection. +/// The type of items in the collection. +public class CollectionFieldConfiguration : ICollectionFieldConfiguration, ICollectionFieldConfigurationBase + where TModel : new() + where TItem : new() +{ + /// + public Type ItemType => typeof(TItem); + /// + public string FieldName { get; } + + /// + public string? Label { get; set; } + + /// + public int Order { get; set; } + + /// + public bool CanAdd { get; set; } + + /// + public bool CanRemove { get; set; } + + /// + public bool CanReorder { get; set; } + + /// + public int MinItems { get; set; } + + /// + public int MaxItems { get; set; } + + /// + public string AddButtonText { get; set; } = "Add Item"; + + /// + public string EmptyText { get; set; } = "No items added yet. Click 'Add Item' to begin."; + + /// + public IFormConfiguration? ItemFormConfiguration { get; set; } + + /// + public bool IsVisible { get; set; } = true; + + /// + public Func> CollectionAccessor { get; } + + /// + public Action> CollectionSetter { get; } + + /// + /// Initializes a new instance of the CollectionFieldConfiguration class. + /// + /// A lambda expression that identifies the collection property on the model. + /// Thrown when the expression does not represent a valid property access. + public CollectionFieldConfiguration(Expression>> collectionExpression) + { + var memberExpression = collectionExpression.Body as MemberExpression + ?? throw new ArgumentException("Expression must be a property access expression.", nameof(collectionExpression)); + + FieldName = memberExpression.Member.Name; + Label = FieldName; + + // Compile the accessor + CollectionAccessor = collectionExpression.Compile(); + + // Build the setter + var parameter = Expression.Parameter(typeof(TModel), "model"); + var valueParameter = Expression.Parameter(typeof(List), "value"); + var property = Expression.Property(parameter, memberExpression.Member.Name); + var assign = Expression.Assign(property, valueParameter); + CollectionSetter = Expression.Lambda>>(assign, parameter, valueParameter).Compile(); + } +} diff --git a/FormCraft/Forms/Core/ICollectionFieldConfiguration.cs b/FormCraft/Forms/Core/ICollectionFieldConfiguration.cs new file mode 100644 index 0000000..2dceab9 --- /dev/null +++ b/FormCraft/Forms/Core/ICollectionFieldConfiguration.cs @@ -0,0 +1,84 @@ +namespace FormCraft; + +/// +/// Represents the configuration for a collection (one-to-many) field in a form. +/// Collection fields allow users to add, remove, and edit multiple items of the same type. +/// +/// The parent model type that contains the collection. +/// The type of items in the collection. +public interface ICollectionFieldConfiguration + where TModel : new() + where TItem : new() +{ + /// + /// Gets the name of the collection property on the model. + /// + string FieldName { get; } + + /// + /// Gets or sets the display label for the collection field. + /// + string? Label { get; set; } + + /// + /// Gets or sets the display order of this collection field relative to other fields. + /// + int Order { get; set; } + + /// + /// Gets or sets whether users can add new items to the collection. + /// + bool CanAdd { get; set; } + + /// + /// Gets or sets whether users can remove items from the collection. + /// + bool CanRemove { get; set; } + + /// + /// Gets or sets whether users can reorder items in the collection. + /// + bool CanReorder { get; set; } + + /// + /// Gets or sets the minimum number of items required in the collection. + /// A value of 0 means no minimum is enforced. + /// + int MinItems { get; set; } + + /// + /// Gets or sets the maximum number of items allowed in the collection. + /// A value of 0 means no maximum is enforced. + /// + int MaxItems { get; set; } + + /// + /// Gets or sets the text to display on the "Add" button. + /// + string AddButtonText { get; set; } + + /// + /// Gets or sets the text to display when the collection is empty. + /// + string EmptyText { get; set; } + + /// + /// Gets the form configuration for individual items in the collection. + /// + IFormConfiguration? ItemFormConfiguration { get; } + + /// + /// Gets or sets whether the collection field is visible. + /// + bool IsVisible { get; set; } + + /// + /// Gets the function to retrieve the collection from the model. + /// + Func> CollectionAccessor { get; } + + /// + /// Gets the action to set the collection on the model. + /// + Action> CollectionSetter { get; } +} diff --git a/FormCraft/Forms/Core/ICollectionFieldConfigurationBase.cs b/FormCraft/Forms/Core/ICollectionFieldConfigurationBase.cs new file mode 100644 index 0000000..9980d3e --- /dev/null +++ b/FormCraft/Forms/Core/ICollectionFieldConfigurationBase.cs @@ -0,0 +1,68 @@ +namespace FormCraft; + +/// +/// Non-generic base interface for collection field configurations, enabling storage in +/// a single collection regardless of the item type. +/// +public interface ICollectionFieldConfigurationBase +{ + /// + /// Gets the name of the collection property on the model. + /// + string FieldName { get; } + + /// + /// Gets or sets the display label for the collection field. + /// + string? Label { get; set; } + + /// + /// Gets or sets the display order of this collection field relative to other fields. + /// + int Order { get; set; } + + /// + /// Gets or sets whether users can add new items. + /// + bool CanAdd { get; set; } + + /// + /// Gets or sets whether users can remove items. + /// + bool CanRemove { get; set; } + + /// + /// Gets or sets whether users can reorder items. + /// + bool CanReorder { get; set; } + + /// + /// Gets or sets the minimum number of items required. + /// + int MinItems { get; set; } + + /// + /// Gets or sets the maximum number of items allowed. + /// + int MaxItems { get; set; } + + /// + /// Gets or sets the text for the add button. + /// + string AddButtonText { get; set; } + + /// + /// Gets or sets the text displayed when the collection is empty. + /// + string EmptyText { get; set; } + + /// + /// Gets or sets whether the collection field is visible. + /// + bool IsVisible { get; set; } + + /// + /// Gets the CLR type of items in the collection. + /// + Type ItemType { get; } +} diff --git a/FormCraft/Forms/Core/IFormConfiguration.cs b/FormCraft/Forms/Core/IFormConfiguration.cs index a7aba23..18da52b 100644 --- a/FormCraft/Forms/Core/IFormConfiguration.cs +++ b/FormCraft/Forms/Core/IFormConfiguration.cs @@ -73,6 +73,18 @@ public enum FormLayout Grid } +/// +/// Interface for form configurations that support collection (one-to-many) fields. +/// +/// The model type that the form will bind to. +public interface ICollectionFormConfiguration : IFormConfiguration where TModel : new() +{ + /// + /// Gets the collection of collection field configurations. + /// + List CollectionFields { get; } +} + /// /// Interface for form configurations that support field groups. /// @@ -94,7 +106,7 @@ public enum FormLayout /// Default implementation of IFormConfiguration that stores form settings and field collections. /// /// The model type that the form will bind to. -public class FormConfiguration : IGroupedFormConfiguration where TModel : new() +public class FormConfiguration : IGroupedFormConfiguration, ICollectionFormConfiguration where TModel : new() { /// public List> Fields { get; } = new(); @@ -122,7 +134,10 @@ public enum FormLayout /// public bool UseFieldGroups { get; set; } = false; - + /// public IFormSecurity? Security { get; set; } + + /// + public List CollectionFields { get; } = new(); } \ No newline at end of file diff --git a/FormCraft/Forms/Validators/CollectionFieldValidator.cs b/FormCraft/Forms/Validators/CollectionFieldValidator.cs new file mode 100644 index 0000000..826a5b7 --- /dev/null +++ b/FormCraft/Forms/Validators/CollectionFieldValidator.cs @@ -0,0 +1,73 @@ +namespace FormCraft; + +/// +/// Validates collection fields by checking item count constraints and recursively validating each item +/// using the item form configuration's validators. +/// +/// The parent model type. +/// The type of items in the collection. +public class CollectionFieldValidator + where TModel : new() + where TItem : new() +{ + private readonly ICollectionFieldConfiguration _configuration; + + /// + /// Initializes a new instance of the CollectionFieldValidator class. + /// + /// The collection field configuration to validate against. + public CollectionFieldValidator(ICollectionFieldConfiguration configuration) + { + _configuration = configuration; + } + + /// + /// Validates the collection field including item count and per-item field validation. + /// + /// The parent model instance. + /// The service provider for dependency injection. + /// A list of validation error messages. Empty if validation passed. + public async Task> ValidateAsync(TModel model, IServiceProvider services) + { + var errors = new List(); + var items = _configuration.CollectionAccessor(model); + var itemCount = items?.Count ?? 0; + + // Validate min items + if (_configuration.MinItems > 0 && itemCount < _configuration.MinItems) + { + errors.Add($"{_configuration.Label ?? _configuration.FieldName} requires at least {_configuration.MinItems} item(s)."); + } + + // Validate max items + if (_configuration.MaxItems > 0 && itemCount > _configuration.MaxItems) + { + errors.Add($"{_configuration.Label ?? _configuration.FieldName} allows at most {_configuration.MaxItems} item(s)."); + } + + // Validate individual items using the item form configuration + if (items != null && _configuration.ItemFormConfiguration != null) + { + for (var i = 0; i < items.Count; i++) + { + var item = items[i]; + foreach (var field in _configuration.ItemFormConfiguration.Fields) + { + var getter = field.ValueExpression.Compile(); + var value = getter(item); + + foreach (var validator in field.Validators) + { + var result = await validator.ValidateAsync(item, value, services); + if (!result.IsValid) + { + errors.Add($"{_configuration.Label ?? _configuration.FieldName} [{i + 1}] - {field.Label ?? field.FieldName}: {result.ErrorMessage}"); + } + } + } + } + } + + return errors; + } +}