Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 53 additions & 0 deletions FormCraft.ForMudBlazor/Extensions/FieldBuilderExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -85,4 +85,57 @@ public static FieldBuilder<TModel, double> AsSlider<TModel>(
.WithAttribute("ShowValueLabel", showValueLabel);
}

/// <summary>
/// Configures the field as a lookup table with a modal dialog for selecting items from large datasets.
/// </summary>
/// <typeparam name="TModel">The model type that the form binds to.</typeparam>
/// <typeparam name="TValue">The type of the field value.</typeparam>
/// <typeparam name="TItem">The type of the lookup item displayed in the table.</typeparam>
/// <param name="builder">The FieldBuilder instance.</param>
/// <param name="dataProvider">An async function that returns paginated lookup results.</param>
/// <param name="valueSelector">A function that extracts the field value from a selected lookup item.</param>
/// <param name="displaySelector">A function that extracts the display text from a lookup item.</param>
/// <param name="configureColumns">An optional action to configure the columns displayed in the lookup table.</param>
/// <param name="onItemSelected">An optional callback invoked when an item is selected, allowing multi-field mapping.</param>
/// <returns>The FieldBuilder instance for method chaining.</returns>
/// <example>
/// <code>
/// .AddField(x => x.CityId)
/// .AsLookup&lt;MyModel, int, CityDto&gt;(
/// dataProvider: async query => new LookupResult&lt;CityDto&gt; { Items = cities, TotalCount = cities.Count },
/// valueSelector: city => city.Id,
/// displaySelector: city => city.Name,
/// configureColumns: cols =>
/// {
/// cols.Add(new LookupColumn&lt;CityDto&gt; { Title = "Name", ValueSelector = c => c.Name });
/// cols.Add(new LookupColumn&lt;CityDto&gt; { Title = "Country", ValueSelector = c => c.Country });
/// },
/// onItemSelected: (model, city) => model.CityName = city.Name)
/// </code>
/// </example>
public static FieldBuilder<TModel, TValue> AsLookup<TModel, TValue, TItem>(
this FieldBuilder<TModel, TValue> builder,
Func<LookupQuery, Task<LookupResult<TItem>>> dataProvider,
Func<TItem, TValue> valueSelector,
Func<TItem, string> displaySelector,
Action<List<LookupColumn<TItem>>>? configureColumns = null,
Action<TModel, TItem>? onItemSelected = null)
where TModel : new()
{
builder.WithAttribute("LookupDataProvider", dataProvider);
builder.WithAttribute("LookupValueSelector", valueSelector);
builder.WithAttribute("LookupDisplaySelector", displaySelector);

if (configureColumns != null)
{
var columns = new List<LookupColumn<TItem>>();
configureColumns(columns);
builder.WithAttribute("LookupColumns", columns);
}

if (onItemSelected != null)
builder.WithAttribute("LookupOnItemSelected", onItemSelected);

return builder;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ public static IServiceCollection AddFormCraftMudBlazor(this IServiceCollection s
services.AddScoped<IFieldRenderer, MudBlazorSelectFieldRenderer>();
services.AddScoped<IFieldRenderer, MudBlazorFileUploadFieldRenderer>();
services.AddScoped<IFieldRenderer, MudBlazorMultipleFileUploadRenderer>();
services.AddScoped<IFieldRenderer, MudBlazorAutocompleteFieldRenderer>();
services.AddScoped<IFieldRenderer, MudBlazorLookupFieldRenderer>();
// Note: MudBlazorColorPickerRenderer and MudBlazorRatingRenderer are custom renderers,
// not IFieldRenderer implementations. They should be used via WithCustomRenderer().

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
@namespace FormCraft.ForMudBlazor
@typeparam TModel
@typeparam TValue
@inherits FieldComponentBase<TModel, TValue>

<MudAutocomplete
T="TValue"
Label="@Label"
@bind-Value="@CurrentValue"
SearchFunc="@SearchAsync"
ToStringFunc="@_toStringFunc"
Placeholder="@Placeholder"
HelperText="@HelpText"
ReadOnly="@IsReadOnly"
Disabled="@IsDisabled"
Variant="Variant.Outlined"
Margin="Margin.Dense"
ShrinkLabel="true"
DebounceInterval="@_debounceMs"
MinCharacters="@_minCharacters"
ResetValueOnEmptyText="true"
CoerceText="false"
Dense="true" />
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
namespace FormCraft.ForMudBlazor;

public partial class MudBlazorAutocompleteFieldComponent<TModel, TValue>
{
private Func<string, CancellationToken, Task<IEnumerable<SelectOption<TValue>>>>? _searchFunc;
private object? _optionProvider;
private int _debounceMs = 300;
private int _minCharacters = 1;
private Func<TValue, string> _toStringFunc = v => v?.ToString() ?? string.Empty;

/// <summary>
/// Cached lookup from display string back to TValue for MudAutocomplete results.
/// </summary>
private readonly Dictionary<string, TValue> _valueLookup = new();

protected override void OnInitialized()
{
base.OnInitialized();

_searchFunc = GetAttribute<Func<string, CancellationToken, Task<IEnumerable<SelectOption<TValue>>>>>("AutocompleteSearchFunc");
_optionProvider = GetAttribute<object>("AutocompleteOptionProvider");
_debounceMs = GetAttribute("AutocompleteDebounceMs", 300);
_minCharacters = GetAttribute("AutocompleteMinCharacters", 1);

var customToString = GetAttribute<Func<TValue, string>>("AutocompleteToStringFunc");
if (customToString != null)
{
_toStringFunc = customToString;
}
}

private async Task<IEnumerable<TValue>> SearchAsync(string searchText, CancellationToken cancellationToken)
{
IEnumerable<SelectOption<TValue>> options;

if (_searchFunc != null)
{
options = await _searchFunc(searchText ?? string.Empty, cancellationToken);
}
else if (_optionProvider != null)
{
// Use reflection to call SearchAsync on the IOptionProvider<TModel, TValue>
var providerType = typeof(IOptionProvider<,>).MakeGenericType(typeof(TModel), typeof(TValue));
var searchMethod = providerType.GetMethod("SearchAsync");
if (searchMethod != null)
{
var task = (Task<IEnumerable<SelectOption<TValue>>>)searchMethod.Invoke(
_optionProvider,
new object?[] { searchText ?? string.Empty, Context.Model, cancellationToken })!;
options = await task;
}
else
{
return Enumerable.Empty<TValue>();
}
}
else
{
return Enumerable.Empty<TValue>();
}

var optionsList = options.ToList();

// Build the display-to-value lookup and set up ToString
_valueLookup.Clear();
foreach (var option in optionsList)
{
var displayStr = _toStringFunc(option.Value);
_valueLookup[displayStr] = option.Value;
}

// If no custom toString was provided, use label-based display
var customToString = GetAttribute<Func<TValue, string>>("AutocompleteToStringFunc");
if (customToString == null)
{
// Build a value-to-label map for display
var labelMap = optionsList.ToDictionary(o => o.Value!, o => o.Label);
_toStringFunc = v =>
{
if (v == null) return string.Empty;
return labelMap.TryGetValue(v, out var label) ? label : v.ToString() ?? string.Empty;
};
}

return optionsList.Select(o => o.Value);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
namespace FormCraft.ForMudBlazor;

/// <summary>
/// MudBlazor implementation of the autocomplete field renderer.
/// </summary>
public class MudBlazorAutocompleteFieldRenderer : FieldRendererBase
{
/// <inheritdoc />
protected override Type ComponentType => typeof(MudBlazorAutocompleteFieldComponent<,>);

/// <inheritdoc />
public override bool CanRender(Type fieldType, IFieldConfiguration<object, object> field)
{
// This renderer handles fields with AutocompleteSearchFunc or AutocompleteOptionProvider
return field.AdditionalAttributes.ContainsKey("AutocompleteSearchFunc") ||
field.AdditionalAttributes.ContainsKey("AutocompleteOptionProvider");
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
@namespace FormCraft.ForMudBlazor
@using MudBlazor

<MudDialog>
<TitleContent>
<MudText Typo="Typo.h6">
<MudIcon Icon="@Icons.Material.Filled.Search" Class="mr-2" />
@FieldLabel
</MudText>
</TitleContent>
<DialogContent>
<MudTextField
T="string"
@bind-Value="_searchText"
Placeholder="Search..."
Adornment="Adornment.Start"
AdornmentIcon="@Icons.Material.Filled.Search"
Immediate="true"
DebounceInterval="300"
Variant="Variant.Outlined"
Margin="Margin.Dense"
Class="mb-4"
TextChanged="OnSearchTextChanged" />

<MudTable
T="object"
ServerData="LoadServerData"
@ref="_table"
Dense="true"
Hover="true"
Striped="true"
RowClassFunc="@((item, _) => item == _selectedItem ? "selected" : "")"
OnRowClick="OnRowClicked"
Loading="@_loading"
LoadingProgressColor="Color.Primary">
<HeaderContent>
@if (_columnDefinitions.Any())
{
@foreach (var col in _columnDefinitions)
{
<MudTh>@col.Title</MudTh>
}
}
else
{
<MudTh>Value</MudTh>
}
</HeaderContent>
<RowTemplate>
@if (_columnDefinitions.Any())
{
@foreach (var col in _columnDefinitions)
{
<MudTd>@col.GetValue(context)</MudTd>
}
}
else
{
<MudTd>@GetDisplayText(context)</MudTd>
}
</RowTemplate>
<NoRecordsContent>
<MudText Typo="Typo.body1">No matching records found.</MudText>
</NoRecordsContent>
<PagerContent>
<MudTablePager />
</PagerContent>
</MudTable>
</DialogContent>
<DialogActions>
<MudButton OnClick="Cancel">Cancel</MudButton>
<MudButton Color="Color.Primary" Variant="Variant.Filled" Disabled="@(_selectedItem == null)" OnClick="Submit">
Select
</MudButton>
</DialogActions>
</MudDialog>
Loading
Loading