Skip to content

Commit acdc198

Browse files
authored
Merge pull request #89 from phmatray/feat/20-one-to-many-model-support
feat: add one-to-many collection field support
2 parents c3615fd + df2e6cb commit acdc198

15 files changed

Lines changed: 1330 additions & 2 deletions
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
@namespace FormCraft.ForMudBlazor
2+
@typeparam TModel where TModel : new()
3+
@typeparam TItem where TItem : new()
4+
@inject IFieldRendererService FieldRendererService
5+
6+
@if (Configuration.IsVisible)
7+
{
8+
<MudPaper Class="pa-4 mb-4" Outlined="true">
9+
<div class="d-flex justify-space-between align-center mb-3">
10+
<MudText Typo="Typo.h6">@(Configuration.Label ?? Configuration.FieldName)</MudText>
11+
@if (Configuration.CanAdd && !HasReachedMax)
12+
{
13+
<MudButton Variant="Variant.Outlined"
14+
Color="Color.Primary"
15+
StartIcon="@Icons.Material.Filled.Add"
16+
Size="Size.Small"
17+
OnClick="@AddItem">
18+
@Configuration.AddButtonText
19+
</MudButton>
20+
}
21+
</div>
22+
23+
@if (Items.Count == 0)
24+
{
25+
<MudAlert Severity="Severity.Info" Variant="Variant.Text" Dense="true" Class="mb-2">
26+
@Configuration.EmptyText
27+
</MudAlert>
28+
}
29+
else
30+
{
31+
@for (var i = 0; i < Items.Count; i++)
32+
{
33+
var index = i;
34+
<MudCard Class="mb-3" Elevation="0" Outlined="true">
35+
<MudCardContent>
36+
<div class="d-flex justify-space-between align-center mb-2">
37+
<MudText Typo="Typo.subtitle2" Color="Color.Default">
38+
Item @(index + 1)
39+
</MudText>
40+
<div class="d-flex gap-1">
41+
@if (Configuration.CanReorder)
42+
{
43+
<MudIconButton Icon="@Icons.Material.Filled.ArrowUpward"
44+
Size="Size.Small"
45+
Disabled="@(index == 0)"
46+
OnClick="@(() => MoveItemUp(index))"
47+
Title="Move up" />
48+
<MudIconButton Icon="@Icons.Material.Filled.ArrowDownward"
49+
Size="Size.Small"
50+
Disabled="@(index == Items.Count - 1)"
51+
OnClick="@(() => MoveItemDown(index))"
52+
Title="Move down" />
53+
}
54+
@if (Configuration.CanRemove && !HasReachedMin)
55+
{
56+
<MudIconButton Icon="@Icons.Material.Filled.Delete"
57+
Size="Size.Small"
58+
Color="Color.Error"
59+
OnClick="@(() => RemoveItem(index))"
60+
Title="Remove item" />
61+
}
62+
</div>
63+
</div>
64+
@if (Configuration.ItemFormConfiguration != null)
65+
{
66+
@RenderItemFields(index)
67+
}
68+
</MudCardContent>
69+
</MudCard>
70+
}
71+
}
72+
73+
@if (ValidationErrors.Any())
74+
{
75+
<MudAlert Severity="Severity.Error" Variant="Variant.Text" Dense="true" Class="mt-2">
76+
<ul class="mb-0">
77+
@foreach (var error in ValidationErrors)
78+
{
79+
<li>@error</li>
80+
}
81+
</ul>
82+
</MudAlert>
83+
}
84+
</MudPaper>
85+
}
Lines changed: 240 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,240 @@
1+
using Microsoft.AspNetCore.Components;
2+
using Microsoft.AspNetCore.Components.Rendering;
3+
using MudBlazor;
4+
5+
namespace FormCraft.ForMudBlazor;
6+
7+
/// <summary>
8+
/// A MudBlazor component that renders a collection (one-to-many) field with add, remove, reorder capabilities.
9+
/// Each item in the collection is rendered as a sub-form using the configured item form fields.
10+
/// </summary>
11+
/// <typeparam name="TModel">The parent model type.</typeparam>
12+
/// <typeparam name="TItem">The type of items in the collection.</typeparam>
13+
public partial class CollectionFieldComponent<TModel, TItem>
14+
where TModel : new()
15+
where TItem : new()
16+
{
17+
/// <summary>
18+
/// Gets or sets the parent model instance.
19+
/// </summary>
20+
[Parameter]
21+
public TModel Model { get; set; } = default!;
22+
23+
/// <summary>
24+
/// Gets or sets the collection field configuration.
25+
/// </summary>
26+
[Parameter]
27+
public ICollectionFieldConfiguration<TModel, TItem> Configuration { get; set; } = default!;
28+
29+
/// <summary>
30+
/// Gets or sets the callback invoked when the collection changes (items added, removed, or reordered).
31+
/// </summary>
32+
[Parameter]
33+
public EventCallback OnCollectionChanged { get; set; }
34+
35+
private List<TItem> Items => Configuration.CollectionAccessor(Model);
36+
37+
private bool HasReachedMax => Configuration.MaxItems > 0 && Items.Count >= Configuration.MaxItems;
38+
39+
private bool HasReachedMin => Configuration.MinItems > 0 && Items.Count <= Configuration.MinItems;
40+
41+
private List<string> ValidationErrors { get; set; } = new();
42+
43+
private async Task AddItem()
44+
{
45+
if (HasReachedMax) return;
46+
47+
Items.Add(new TItem());
48+
await NotifyCollectionChanged();
49+
}
50+
51+
private async Task RemoveItem(int index)
52+
{
53+
if (HasReachedMin) return;
54+
if (index < 0 || index >= Items.Count) return;
55+
56+
Items.RemoveAt(index);
57+
await NotifyCollectionChanged();
58+
}
59+
60+
private async Task MoveItemUp(int index)
61+
{
62+
if (index <= 0 || index >= Items.Count) return;
63+
64+
(Items[index], Items[index - 1]) = (Items[index - 1], Items[index]);
65+
await NotifyCollectionChanged();
66+
}
67+
68+
private async Task MoveItemDown(int index)
69+
{
70+
if (index < 0 || index >= Items.Count - 1) return;
71+
72+
(Items[index], Items[index + 1]) = (Items[index + 1], Items[index]);
73+
await NotifyCollectionChanged();
74+
}
75+
76+
private async Task NotifyCollectionChanged()
77+
{
78+
if (OnCollectionChanged.HasDelegate)
79+
{
80+
await OnCollectionChanged.InvokeAsync();
81+
}
82+
83+
StateHasChanged();
84+
}
85+
86+
private async Task UpdateItemFieldValue(int itemIndex, string fieldName, object? value)
87+
{
88+
if (itemIndex < 0 || itemIndex >= Items.Count) return;
89+
90+
var item = Items[itemIndex];
91+
var property = typeof(TItem).GetProperty(fieldName);
92+
if (property != null)
93+
{
94+
var targetType = Nullable.GetUnderlyingType(property.PropertyType) ?? property.PropertyType;
95+
var convertedValue = value;
96+
97+
if (value != null && value.GetType() != targetType)
98+
{
99+
try
100+
{
101+
convertedValue = Convert.ChangeType(value, targetType);
102+
}
103+
catch
104+
{
105+
// If conversion fails, use the value as-is
106+
}
107+
}
108+
109+
property.SetValue(item, convertedValue);
110+
}
111+
112+
await NotifyCollectionChanged();
113+
}
114+
115+
private RenderFragment RenderItemFields(int itemIndex)
116+
{
117+
return builder =>
118+
{
119+
if (Configuration.ItemFormConfiguration == null) return;
120+
121+
var item = Items[itemIndex];
122+
123+
foreach (var field in Configuration.ItemFormConfiguration.Fields.OrderBy(f => f.Order))
124+
{
125+
var capturedIndex = itemIndex;
126+
var capturedFieldName = field.FieldName;
127+
128+
builder.OpenElement(0, "div");
129+
builder.AddAttribute(1, "class", "mb-3");
130+
builder.AddContent(2, RenderItemField(item, field, capturedIndex));
131+
builder.CloseElement();
132+
}
133+
};
134+
}
135+
136+
private RenderFragment RenderItemField(TItem item, IFieldConfiguration<TItem, object> field, int itemIndex)
137+
{
138+
return builder =>
139+
{
140+
var property = typeof(TItem).GetProperty(field.FieldName);
141+
if (property == null) return;
142+
143+
var fieldType = property.PropertyType;
144+
var underlyingType = Nullable.GetUnderlyingType(fieldType) ?? fieldType;
145+
var value = property.GetValue(item);
146+
147+
if (fieldType == typeof(string))
148+
{
149+
RenderTextField(builder, field, value as string, itemIndex);
150+
}
151+
else if (underlyingType == typeof(int))
152+
{
153+
RenderNumericField<int>(builder, field, (int)(value ?? 0), itemIndex);
154+
}
155+
else if (underlyingType == typeof(decimal))
156+
{
157+
RenderNumericField<decimal>(builder, field, (decimal)(value ?? 0m), itemIndex);
158+
}
159+
else if (underlyingType == typeof(double))
160+
{
161+
RenderNumericField<double>(builder, field, (double)(value ?? 0.0), itemIndex);
162+
}
163+
else if (underlyingType == typeof(bool))
164+
{
165+
RenderBooleanField(builder, field, value ?? false, itemIndex);
166+
}
167+
else if (underlyingType == typeof(DateTime))
168+
{
169+
RenderDateTimeField(builder, field, value as DateTime?, itemIndex);
170+
}
171+
};
172+
}
173+
174+
private void RenderTextField(RenderTreeBuilder builder, IFieldConfiguration<TItem, object> field, string? value, int itemIndex)
175+
{
176+
builder.OpenComponent<MudTextField<string>>(0);
177+
AddCommonFieldAttributes(builder, field, 1);
178+
builder.AddAttribute(2, "Value", value);
179+
builder.AddAttribute(3, "ValueChanged",
180+
EventCallback.Factory.Create<string>(this,
181+
newValue => UpdateItemFieldValue(itemIndex, field.FieldName, newValue)));
182+
builder.AddAttribute(4, "Immediate", true);
183+
builder.CloseComponent();
184+
}
185+
186+
private void RenderNumericField<T>(RenderTreeBuilder builder, IFieldConfiguration<TItem, object> field, T value, int itemIndex)
187+
where T : struct
188+
{
189+
builder.OpenComponent(0, typeof(MudNumericField<>).MakeGenericType(typeof(T)));
190+
AddCommonFieldAttributes(builder, field, 1);
191+
builder.AddAttribute(2, "Value", value);
192+
builder.AddAttribute(3, "ValueChanged",
193+
EventCallback.Factory.Create<T>(this,
194+
newValue => UpdateItemFieldValue(itemIndex, field.FieldName, newValue)));
195+
builder.AddAttribute(4, "Immediate", true);
196+
builder.AddAttribute(5, "Culture", System.Globalization.CultureInfo.InvariantCulture);
197+
if (typeof(T) == typeof(decimal))
198+
{
199+
builder.AddAttribute(6, "Pattern", "[0-9]+([.,][0-9]+)?");
200+
}
201+
builder.CloseComponent();
202+
}
203+
204+
private void RenderBooleanField(RenderTreeBuilder builder, IFieldConfiguration<TItem, object> field, object value, int itemIndex)
205+
{
206+
builder.OpenComponent<MudCheckBox<bool>>(0);
207+
builder.AddAttribute(1, "Label", field.Label);
208+
builder.AddAttribute(2, "Value", value);
209+
builder.AddAttribute(3, "ValueChanged",
210+
EventCallback.Factory.Create<bool>(this,
211+
newValue => UpdateItemFieldValue(itemIndex, field.FieldName, newValue)));
212+
builder.AddAttribute(4, "ReadOnly", field.IsReadOnly);
213+
builder.AddAttribute(5, "Disabled", field.IsDisabled);
214+
builder.CloseComponent();
215+
}
216+
217+
private void RenderDateTimeField(RenderTreeBuilder builder, IFieldConfiguration<TItem, object> field, DateTime? value, int itemIndex)
218+
{
219+
builder.OpenComponent<MudDatePicker>(0);
220+
AddCommonFieldAttributes(builder, field, 1);
221+
builder.AddAttribute(2, "Date", value);
222+
builder.AddAttribute(3, "DateChanged",
223+
EventCallback.Factory.Create<DateTime?>(this,
224+
newValue => UpdateItemFieldValue(itemIndex, field.FieldName, newValue)));
225+
builder.CloseComponent();
226+
}
227+
228+
private void AddCommonFieldAttributes(RenderTreeBuilder builder, IFieldConfiguration<TItem, object> field, int startIndex)
229+
{
230+
builder.AddAttribute(startIndex++, "Label", field.Label);
231+
builder.AddAttribute(startIndex++, "Placeholder", field.Placeholder);
232+
builder.AddAttribute(startIndex++, "HelperText", field.HelpText);
233+
builder.AddAttribute(startIndex++, "Required", field.IsRequired);
234+
builder.AddAttribute(startIndex++, "ReadOnly", field.IsReadOnly);
235+
builder.AddAttribute(startIndex++, "Disabled", field.IsDisabled);
236+
builder.AddAttribute(startIndex++, "Variant", Variant.Outlined);
237+
builder.AddAttribute(startIndex++, "Margin", Margin.Dense);
238+
builder.AddAttribute(startIndex, "ShrinkLabel", true);
239+
}
240+
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
using Microsoft.AspNetCore.Components;
2+
3+
namespace FormCraft.ForMudBlazor;
4+
5+
/// <summary>
6+
/// Helper class that creates the appropriate generic CollectionFieldComponent for a given
7+
/// collection field configuration using reflection. This bridges the non-generic
8+
/// ICollectionFieldConfigurationBase to the generic CollectionFieldComponent.
9+
/// </summary>
10+
public static class CollectionFieldRenderer
11+
{
12+
/// <summary>
13+
/// Creates a RenderFragment that renders the appropriate CollectionFieldComponent
14+
/// for the given collection field configuration.
15+
/// </summary>
16+
/// <typeparam name="TModel">The parent model type.</typeparam>
17+
/// <param name="model">The parent model instance.</param>
18+
/// <param name="collectionFieldConfig">The collection field configuration (must implement ICollectionFieldConfigurationBase).</param>
19+
/// <param name="onCollectionChanged">Callback invoked when the collection changes.</param>
20+
/// <returns>A RenderFragment that renders the collection field.</returns>
21+
public static RenderFragment Render<TModel>(
22+
TModel model,
23+
ICollectionFieldConfigurationBase collectionFieldConfig,
24+
EventCallback onCollectionChanged)
25+
where TModel : new()
26+
{
27+
return builder =>
28+
{
29+
var itemType = collectionFieldConfig.ItemType;
30+
var componentType = typeof(CollectionFieldComponent<,>).MakeGenericType(typeof(TModel), itemType);
31+
32+
builder.OpenComponent(0, componentType);
33+
builder.AddAttribute(1, "Model", model);
34+
builder.AddAttribute(2, "Configuration", collectionFieldConfig);
35+
builder.AddAttribute(3, "OnCollectionChanged", onCollectionChanged);
36+
builder.CloseComponent();
37+
};
38+
}
39+
}

FormCraft.ForMudBlazor/Features/FormContainer/FormCraftComponent.razor

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,19 @@
8080
}
8181
}
8282
}
83+
84+
@* Render collection fields *@
85+
@if (CollectionConfiguration != null)
86+
{
87+
@foreach (var collectionField in CollectionConfiguration.CollectionFields.OrderBy(f => f.Order))
88+
{
89+
if (collectionField.IsVisible)
90+
{
91+
@(CollectionFieldRenderer.Render(Model, collectionField,
92+
EventCallback.Factory.Create(this, HandleCollectionChanged)))
93+
}
94+
}
95+
}
8396

8497
@if (ShowSubmitButton)
8598
{

0 commit comments

Comments
 (0)