diff --git a/FormCraft.ForMudBlazor/Extensions/FieldBuilderExtensions.cs b/FormCraft.ForMudBlazor/Extensions/FieldBuilderExtensions.cs index 0b5ec68..7456a2f 100644 --- a/FormCraft.ForMudBlazor/Extensions/FieldBuilderExtensions.cs +++ b/FormCraft.ForMudBlazor/Extensions/FieldBuilderExtensions.cs @@ -85,4 +85,57 @@ public static FieldBuilder AsSlider( .WithAttribute("ShowValueLabel", showValueLabel); } + /// + /// Configures the field as a lookup table with a modal dialog for selecting items from large datasets. + /// + /// The model type that the form binds to. + /// The type of the field value. + /// The type of the lookup item displayed in the table. + /// The FieldBuilder instance. + /// An async function that returns paginated lookup results. + /// A function that extracts the field value from a selected lookup item. + /// A function that extracts the display text from a lookup item. + /// An optional action to configure the columns displayed in the lookup table. + /// An optional callback invoked when an item is selected, allowing multi-field mapping. + /// The FieldBuilder instance for method chaining. + /// + /// + /// .AddField(x => x.CityId) + /// .AsLookup<MyModel, int, CityDto>( + /// dataProvider: async query => new LookupResult<CityDto> { Items = cities, TotalCount = cities.Count }, + /// valueSelector: city => city.Id, + /// displaySelector: city => city.Name, + /// configureColumns: cols => + /// { + /// cols.Add(new LookupColumn<CityDto> { Title = "Name", ValueSelector = c => c.Name }); + /// cols.Add(new LookupColumn<CityDto> { Title = "Country", ValueSelector = c => c.Country }); + /// }, + /// onItemSelected: (model, city) => model.CityName = city.Name) + /// + /// + public static FieldBuilder AsLookup( + this FieldBuilder builder, + Func>> dataProvider, + Func valueSelector, + Func displaySelector, + Action>>? configureColumns = null, + Action? onItemSelected = null) + where TModel : new() + { + builder.WithAttribute("LookupDataProvider", dataProvider); + builder.WithAttribute("LookupValueSelector", valueSelector); + builder.WithAttribute("LookupDisplaySelector", displaySelector); + + if (configureColumns != null) + { + var columns = new List>(); + configureColumns(columns); + builder.WithAttribute("LookupColumns", columns); + } + + if (onItemSelected != null) + builder.WithAttribute("LookupOnItemSelected", onItemSelected); + + return builder; + } } \ No newline at end of file diff --git a/FormCraft.ForMudBlazor/Extensions/ServiceCollectionExtensions.cs b/FormCraft.ForMudBlazor/Extensions/ServiceCollectionExtensions.cs index 169bb39..aa11dce 100644 --- a/FormCraft.ForMudBlazor/Extensions/ServiceCollectionExtensions.cs +++ b/FormCraft.ForMudBlazor/Extensions/ServiceCollectionExtensions.cs @@ -39,6 +39,8 @@ public static IServiceCollection AddFormCraftMudBlazor(this IServiceCollection s services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); + services.AddScoped(); // Note: MudBlazorColorPickerRenderer and MudBlazorRatingRenderer are custom renderers, // not IFieldRenderer implementations. They should be used via WithCustomRenderer(). diff --git a/FormCraft.ForMudBlazor/Fields/AutocompleteField/MudBlazorAutocompleteFieldComponent.razor b/FormCraft.ForMudBlazor/Fields/AutocompleteField/MudBlazorAutocompleteFieldComponent.razor new file mode 100644 index 0000000..535af54 --- /dev/null +++ b/FormCraft.ForMudBlazor/Fields/AutocompleteField/MudBlazorAutocompleteFieldComponent.razor @@ -0,0 +1,23 @@ +@namespace FormCraft.ForMudBlazor +@typeparam TModel +@typeparam TValue +@inherits FieldComponentBase + + diff --git a/FormCraft.ForMudBlazor/Fields/AutocompleteField/MudBlazorAutocompleteFieldComponent.razor.cs b/FormCraft.ForMudBlazor/Fields/AutocompleteField/MudBlazorAutocompleteFieldComponent.razor.cs new file mode 100644 index 0000000..3291ae8 --- /dev/null +++ b/FormCraft.ForMudBlazor/Fields/AutocompleteField/MudBlazorAutocompleteFieldComponent.razor.cs @@ -0,0 +1,87 @@ +namespace FormCraft.ForMudBlazor; + +public partial class MudBlazorAutocompleteFieldComponent +{ + private Func>>>? _searchFunc; + private object? _optionProvider; + private int _debounceMs = 300; + private int _minCharacters = 1; + private Func _toStringFunc = v => v?.ToString() ?? string.Empty; + + /// + /// Cached lookup from display string back to TValue for MudAutocomplete results. + /// + private readonly Dictionary _valueLookup = new(); + + protected override void OnInitialized() + { + base.OnInitialized(); + + _searchFunc = GetAttribute>>>>("AutocompleteSearchFunc"); + _optionProvider = GetAttribute("AutocompleteOptionProvider"); + _debounceMs = GetAttribute("AutocompleteDebounceMs", 300); + _minCharacters = GetAttribute("AutocompleteMinCharacters", 1); + + var customToString = GetAttribute>("AutocompleteToStringFunc"); + if (customToString != null) + { + _toStringFunc = customToString; + } + } + + private async Task> SearchAsync(string searchText, CancellationToken cancellationToken) + { + IEnumerable> options; + + if (_searchFunc != null) + { + options = await _searchFunc(searchText ?? string.Empty, cancellationToken); + } + else if (_optionProvider != null) + { + // Use reflection to call SearchAsync on the IOptionProvider + var providerType = typeof(IOptionProvider<,>).MakeGenericType(typeof(TModel), typeof(TValue)); + var searchMethod = providerType.GetMethod("SearchAsync"); + if (searchMethod != null) + { + var task = (Task>>)searchMethod.Invoke( + _optionProvider, + new object?[] { searchText ?? string.Empty, Context.Model, cancellationToken })!; + options = await task; + } + else + { + return Enumerable.Empty(); + } + } + else + { + return Enumerable.Empty(); + } + + 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>("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); + } +} diff --git a/FormCraft.ForMudBlazor/Fields/AutocompleteField/MudBlazorAutocompleteFieldRenderer.cs b/FormCraft.ForMudBlazor/Fields/AutocompleteField/MudBlazorAutocompleteFieldRenderer.cs new file mode 100644 index 0000000..73a70ba --- /dev/null +++ b/FormCraft.ForMudBlazor/Fields/AutocompleteField/MudBlazorAutocompleteFieldRenderer.cs @@ -0,0 +1,18 @@ +namespace FormCraft.ForMudBlazor; + +/// +/// MudBlazor implementation of the autocomplete field renderer. +/// +public class MudBlazorAutocompleteFieldRenderer : FieldRendererBase +{ + /// + protected override Type ComponentType => typeof(MudBlazorAutocompleteFieldComponent<,>); + + /// + public override bool CanRender(Type fieldType, IFieldConfiguration field) + { + // This renderer handles fields with AutocompleteSearchFunc or AutocompleteOptionProvider + return field.AdditionalAttributes.ContainsKey("AutocompleteSearchFunc") || + field.AdditionalAttributes.ContainsKey("AutocompleteOptionProvider"); + } +} diff --git a/FormCraft.ForMudBlazor/Fields/LookupField/MudBlazorLookupDialog.razor b/FormCraft.ForMudBlazor/Fields/LookupField/MudBlazorLookupDialog.razor new file mode 100644 index 0000000..68560eb --- /dev/null +++ b/FormCraft.ForMudBlazor/Fields/LookupField/MudBlazorLookupDialog.razor @@ -0,0 +1,76 @@ +@namespace FormCraft.ForMudBlazor +@using MudBlazor + + + + + + @FieldLabel + + + + + + + + @if (_columnDefinitions.Any()) + { + @foreach (var col in _columnDefinitions) + { + @col.Title + } + } + else + { + Value + } + + + @if (_columnDefinitions.Any()) + { + @foreach (var col in _columnDefinitions) + { + @col.GetValue(context) + } + } + else + { + @GetDisplayText(context) + } + + + No matching records found. + + + + + + + + Cancel + + Select + + + diff --git a/FormCraft.ForMudBlazor/Fields/LookupField/MudBlazorLookupDialog.razor.cs b/FormCraft.ForMudBlazor/Fields/LookupField/MudBlazorLookupDialog.razor.cs new file mode 100644 index 0000000..6eba8d5 --- /dev/null +++ b/FormCraft.ForMudBlazor/Fields/LookupField/MudBlazorLookupDialog.razor.cs @@ -0,0 +1,194 @@ +using Microsoft.AspNetCore.Components; +using MudBlazor; + +namespace FormCraft.ForMudBlazor; + +/// +/// A dialog component that displays a searchable, paginated table for lookup field selection. +/// +public partial class MudBlazorLookupDialog : ComponentBase +{ + [CascadingParameter] + private IMudDialogInstance MudDialog { get; set; } = default!; + + /// + /// The async data provider delegate (typed as object to handle generic TItem). + /// + [Parameter] + public object DataProvider { get; set; } = default!; + + /// + /// The value selector delegate. + /// + [Parameter] + public object ValueSelector { get; set; } = default!; + + /// + /// The display selector delegate. + /// + [Parameter] + public object DisplaySelector { get; set; } = default!; + + /// + /// The column definitions (as object to handle generic LookupColumn list). + /// + [Parameter] + public object? Columns { get; set; } + + /// + /// The label for the field. + /// + [Parameter] + public string FieldLabel { get; set; } = "Select"; + + private MudTable _table = default!; + private string _searchText = string.Empty; + private object? _selectedItem; + private bool _loading; + private List _columnDefinitions = new(); + + protected override void OnInitialized() + { + base.OnInitialized(); + ExtractColumnDefinitions(); + } + + private void ExtractColumnDefinitions() + { + if (Columns == null) return; + + // The columns parameter is a List> stored as object. + // We use reflection to extract column info. + var columnsType = Columns.GetType(); + if (!columnsType.IsGenericType) return; + + var enumerableInterface = columnsType.GetInterfaces() + .FirstOrDefault(i => i.IsGenericType && i.GetGenericTypeDefinition() == typeof(IEnumerable<>)); + if (enumerableInterface == null) return; + + foreach (var col in (System.Collections.IEnumerable)Columns) + { + var colType = col.GetType(); + var titleProp = colType.GetProperty("Title"); + var valueSelectorProp = colType.GetProperty("ValueSelector"); + + if (titleProp != null && valueSelectorProp != null) + { + var title = titleProp.GetValue(col)?.ToString() ?? ""; + var valueFunc = valueSelectorProp.GetValue(col); + + _columnDefinitions.Add(new ColumnDef + { + Title = title, + ValueFunc = valueFunc as Delegate + }); + } + } + } + + private async Task> LoadServerData(TableState state, CancellationToken cancellationToken) + { + _loading = true; + + try + { + var query = new LookupQuery + { + SearchText = _searchText, + Page = state.Page, + PageSize = state.PageSize, + SortField = state.SortLabel, + SortDescending = state.SortDirection == SortDirection.Descending + }; + + // Invoke the data provider delegate + var task = ((Delegate)DataProvider).DynamicInvoke(query) as Task; + if (task == null) return new TableData { Items = Array.Empty(), TotalItems = 0 }; + + await task; + + // Get the result from the completed task + var resultProp = task.GetType().GetProperty("Result"); + var result = resultProp?.GetValue(task); + if (result == null) return new TableData { Items = Array.Empty(), TotalItems = 0 }; + + // Extract Items and TotalCount from LookupResult + var itemsProp = result.GetType().GetProperty("Items"); + var totalCountProp = result.GetType().GetProperty("TotalCount"); + + var items = itemsProp?.GetValue(result) as System.Collections.IEnumerable; + var totalCount = totalCountProp?.GetValue(result) is int count ? count : 0; + + var objectItems = items?.Cast().ToList() ?? new List(); + + return new TableData + { + Items = objectItems, + TotalItems = totalCount + }; + } + finally + { + _loading = false; + } + } + + private string GetDisplayText(object item) + { + try + { + var result = ((Delegate)DisplaySelector).DynamicInvoke(item); + return result?.ToString() ?? string.Empty; + } + catch + { + return item.ToString() ?? string.Empty; + } + } + + private async Task OnSearchTextChanged(string text) + { + _searchText = text; + _selectedItem = null; + await _table.ReloadServerData(); + } + + private void OnRowClicked(TableRowClickEventArgs args) + { + _selectedItem = args.Item; + } + + private void Cancel() + { + MudDialog.Cancel(); + } + + private void Submit() + { + if (_selectedItem != null) + { + MudDialog.Close(DialogResult.Ok(_selectedItem)); + } + } + + /// + /// Internal column definition used for rendering. + /// + private class ColumnDef + { + public string Title { get; set; } = string.Empty; + public Delegate? ValueFunc { get; set; } + + public object? GetValue(object item) + { + try + { + return ValueFunc?.DynamicInvoke(item); + } + catch + { + return null; + } + } + } +} diff --git a/FormCraft.ForMudBlazor/Fields/LookupField/MudBlazorLookupFieldComponent.razor b/FormCraft.ForMudBlazor/Fields/LookupField/MudBlazorLookupFieldComponent.razor new file mode 100644 index 0000000..56673a3 --- /dev/null +++ b/FormCraft.ForMudBlazor/Fields/LookupField/MudBlazorLookupFieldComponent.razor @@ -0,0 +1,20 @@ +@namespace FormCraft.ForMudBlazor +@typeparam TModel +@typeparam TValue +@inherits FieldComponentBase + + diff --git a/FormCraft.ForMudBlazor/Fields/LookupField/MudBlazorLookupFieldComponent.razor.cs b/FormCraft.ForMudBlazor/Fields/LookupField/MudBlazorLookupFieldComponent.razor.cs new file mode 100644 index 0000000..3b60520 --- /dev/null +++ b/FormCraft.ForMudBlazor/Fields/LookupField/MudBlazorLookupFieldComponent.razor.cs @@ -0,0 +1,111 @@ +using Microsoft.AspNetCore.Components; +using MudBlazor; + +namespace FormCraft.ForMudBlazor; + +public partial class MudBlazorLookupFieldComponent +{ + [Inject] + private IDialogService DialogService { get; set; } = default!; + + private string _displayText = string.Empty; + + protected override void OnInitialized() + { + base.OnInitialized(); + UpdateDisplayText(); + } + + protected override void OnParametersSet() + { + base.OnParametersSet(); + UpdateDisplayText(); + } + + private void UpdateDisplayText() + { + // Try to get the display selector and use it to show current value + var displaySelector = GetAttribute("LookupDisplaySelector"); + if (displaySelector != null && CurrentValue != null) + { + // We can't directly call the display selector since it takes TItem, not TValue. + // The display text is stored after selection. If the field has a current value but + // no stored display text, show the value's ToString. + if (string.IsNullOrEmpty(_displayText)) + { + _displayText = CurrentValue?.ToString() ?? string.Empty; + } + } + } + + private async Task OpenLookupDialog() + { + if (IsReadOnly || IsDisabled) + return; + + var dataProvider = GetAttribute("LookupDataProvider"); + var valueSelector = GetAttribute("LookupValueSelector"); + var displaySelector = GetAttribute("LookupDisplaySelector"); + var columns = GetAttribute("LookupColumns"); + var onItemSelected = GetAttribute("LookupOnItemSelected"); + + if (dataProvider == null || valueSelector == null || displaySelector == null) + return; + + var parameters = new DialogParameters + { + { "DataProvider", dataProvider }, + { "ValueSelector", valueSelector }, + { "DisplaySelector", displaySelector }, + { "Columns", columns }, + { "FieldLabel", Label ?? "Select" } + }; + + var options = new DialogOptions + { + MaxWidth = MaxWidth.Medium, + FullWidth = true, + CloseButton = true, + CloseOnEscapeKey = true + }; + + var dialog = await DialogService.ShowAsync( + Label ?? "Lookup", + parameters, + options); + + var result = await dialog.Result; + + if (result is { Canceled: false, Data: not null }) + { + // result.Data is the selected item (as object) + var selectedItem = result.Data; + + // Extract value using the value selector + try + { + var value = ((Delegate)valueSelector).DynamicInvoke(selectedItem); + if (value is TValue typedValue) + { + CurrentValue = typedValue; + } + + // Extract display text + var display = ((Delegate)displaySelector).DynamicInvoke(selectedItem); + _displayText = display?.ToString() ?? string.Empty; + + // Invoke onItemSelected callback for multi-field mapping + if (onItemSelected != null) + { + ((Delegate)onItemSelected).DynamicInvoke(Context.Model, selectedItem); + } + + StateHasChanged(); + } + catch + { + // Silently handle type mismatches + } + } + } +} diff --git a/FormCraft.ForMudBlazor/Fields/LookupField/MudBlazorLookupFieldRenderer.cs b/FormCraft.ForMudBlazor/Fields/LookupField/MudBlazorLookupFieldRenderer.cs new file mode 100644 index 0000000..109f94b --- /dev/null +++ b/FormCraft.ForMudBlazor/Fields/LookupField/MudBlazorLookupFieldRenderer.cs @@ -0,0 +1,17 @@ +namespace FormCraft.ForMudBlazor; + +/// +/// MudBlazor implementation of the lookup table field renderer. +/// +public class MudBlazorLookupFieldRenderer : FieldRendererBase +{ + /// + protected override Type ComponentType => typeof(MudBlazorLookupFieldComponent<,>); + + /// + public override bool CanRender(Type fieldType, IFieldConfiguration field) + { + // This renderer handles fields with LookupDataProvider in AdditionalAttributes + return field.AdditionalAttributes.ContainsKey("LookupDataProvider"); + } +} diff --git a/FormCraft.UnitTests/Extensions/AutocompleteFieldBuilderTests.cs b/FormCraft.UnitTests/Extensions/AutocompleteFieldBuilderTests.cs new file mode 100644 index 0000000..6a46b8c --- /dev/null +++ b/FormCraft.UnitTests/Extensions/AutocompleteFieldBuilderTests.cs @@ -0,0 +1,208 @@ +namespace FormCraft.UnitTests.Extensions; + +public class AutocompleteFieldBuilderTests +{ + [Fact] + public void AsAutocomplete_WithSearchFunc_Should_Set_SearchFunc_Attribute() + { + // Arrange + Func>>> searchFunc = + (text, ct) => Task.FromResult>>( + new List>()); + + // Act + var config = FormBuilder.Create() + .AddField(x => x.City, field => field + .AsAutocomplete(searchFunc)) + .Build(); + + // Assert + var field = config.Fields.First(f => f.FieldName == "City"); + field.AdditionalAttributes.ShouldContainKey("AutocompleteSearchFunc"); + field.AdditionalAttributes["AutocompleteSearchFunc"].ShouldBeSameAs(searchFunc); + } + + [Fact] + public void AsAutocomplete_WithSearchFunc_Should_Set_Default_DebounceMs() + { + // Arrange + Func>>> searchFunc = + (text, ct) => Task.FromResult>>( + new List>()); + + // Act + var config = FormBuilder.Create() + .AddField(x => x.City, field => field + .AsAutocomplete(searchFunc)) + .Build(); + + // Assert + var field = config.Fields.First(f => f.FieldName == "City"); + field.AdditionalAttributes.ShouldContainKey("AutocompleteDebounceMs"); + field.AdditionalAttributes["AutocompleteDebounceMs"].ShouldBe(300); + } + + [Fact] + public void AsAutocomplete_WithSearchFunc_Should_Set_Custom_DebounceMs() + { + // Arrange + Func>>> searchFunc = + (text, ct) => Task.FromResult>>( + new List>()); + + // Act + var config = FormBuilder.Create() + .AddField(x => x.City, field => field + .AsAutocomplete(searchFunc, debounceMs: 500)) + .Build(); + + // Assert + var field = config.Fields.First(f => f.FieldName == "City"); + field.AdditionalAttributes["AutocompleteDebounceMs"].ShouldBe(500); + } + + [Fact] + public void AsAutocomplete_WithSearchFunc_Should_Set_Custom_MinCharacters() + { + // Arrange + Func>>> searchFunc = + (text, ct) => Task.FromResult>>( + new List>()); + + // Act + var config = FormBuilder.Create() + .AddField(x => x.City, field => field + .AsAutocomplete(searchFunc, minCharacters: 3)) + .Build(); + + // Assert + var field = config.Fields.First(f => f.FieldName == "City"); + field.AdditionalAttributes.ShouldContainKey("AutocompleteMinCharacters"); + field.AdditionalAttributes["AutocompleteMinCharacters"].ShouldBe(3); + } + + [Fact] + public void AsAutocomplete_WithSearchFunc_Should_Set_ToStringFunc_When_Provided() + { + // Arrange + Func>>> searchFunc = + (text, ct) => Task.FromResult>>( + new List>()); + Func toStringFunc = v => $"City: {v}"; + + // Act + var config = FormBuilder.Create() + .AddField(x => x.City, field => field + .AsAutocomplete(searchFunc, toStringFunc: toStringFunc)) + .Build(); + + // Assert + var field = config.Fields.First(f => f.FieldName == "City"); + field.AdditionalAttributes.ShouldContainKey("AutocompleteToStringFunc"); + field.AdditionalAttributes["AutocompleteToStringFunc"].ShouldBeSameAs(toStringFunc); + } + + [Fact] + public void AsAutocomplete_WithSearchFunc_Should_Not_Set_ToStringFunc_When_Null() + { + // Arrange + Func>>> searchFunc = + (text, ct) => Task.FromResult>>( + new List>()); + + // Act + var config = FormBuilder.Create() + .AddField(x => x.City, field => field + .AsAutocomplete(searchFunc)) + .Build(); + + // Assert + var field = config.Fields.First(f => f.FieldName == "City"); + field.AdditionalAttributes.ShouldNotContainKey("AutocompleteToStringFunc"); + } + + [Fact] + public void AsAutocomplete_WithOptionProvider_Should_Set_OptionProvider_Attribute() + { + // Arrange + var optionProvider = A.Fake>(); + + // Act + var config = FormBuilder.Create() + .AddField(x => x.City, field => field + .AsAutocomplete(optionProvider)) + .Build(); + + // Assert + var field = config.Fields.First(f => f.FieldName == "City"); + field.AdditionalAttributes.ShouldContainKey("AutocompleteOptionProvider"); + field.AdditionalAttributes["AutocompleteOptionProvider"].ShouldBeSameAs(optionProvider); + } + + [Fact] + public void AsAutocomplete_WithOptionProvider_Should_Set_Default_Settings() + { + // Arrange + var optionProvider = A.Fake>(); + + // Act + var config = FormBuilder.Create() + .AddField(x => x.City, field => field + .AsAutocomplete(optionProvider)) + .Build(); + + // Assert + var field = config.Fields.First(f => f.FieldName == "City"); + field.AdditionalAttributes["AutocompleteDebounceMs"].ShouldBe(300); + field.AdditionalAttributes["AutocompleteMinCharacters"].ShouldBe(1); + field.AdditionalAttributes.ShouldNotContainKey("AutocompleteToStringFunc"); + } + + [Fact] + public void AsAutocomplete_Should_Support_Int_Value_Type() + { + // Arrange + Func>>> searchFunc = + (text, ct) => Task.FromResult>>( + new List>()); + + // Act + var config = FormBuilder.Create() + .AddField(x => x.CityId, field => field + .AsAutocomplete(searchFunc)) + .Build(); + + // Assert + var field = config.Fields.First(f => f.FieldName == "CityId"); + field.AdditionalAttributes.ShouldContainKey("AutocompleteSearchFunc"); + } + + [Fact] + public void AsAutocomplete_Should_Be_Chainable() + { + // Arrange + Func>>> searchFunc = + (text, ct) => Task.FromResult>>( + new List>()); + + // Act + var config = FormBuilder.Create() + .AddField(x => x.City, field => field + .WithLabel("City") + .AsAutocomplete(searchFunc) + .WithHelpText("Start typing to search")) + .Build(); + + // Assert + var field = config.Fields.First(f => f.FieldName == "City"); + field.Label.ShouldBe("City"); + field.HelpText.ShouldBe("Start typing to search"); + field.AdditionalAttributes.ShouldContainKey("AutocompleteSearchFunc"); + } + + public class TestModel + { + public string City { get; set; } = string.Empty; + public int CityId { get; set; } + } +} diff --git a/FormCraft.UnitTests/Extensions/LookupFieldBuilderTests.cs b/FormCraft.UnitTests/Extensions/LookupFieldBuilderTests.cs new file mode 100644 index 0000000..16ba3cd --- /dev/null +++ b/FormCraft.UnitTests/Extensions/LookupFieldBuilderTests.cs @@ -0,0 +1,242 @@ +namespace FormCraft.UnitTests.Extensions; + +public class LookupFieldBuilderTests +{ + [Fact] + public void AsLookup_Should_Set_DataProvider_Attribute() + { + // Arrange + Func>> dataProvider = + query => Task.FromResult(new LookupResult()); + + // Act + var config = FormBuilder.Create() + .AddField(x => x.CityId, field => field + .AsLookup( + dataProvider: dataProvider, + valueSelector: city => city.Id, + displaySelector: city => city.Name)) + .Build(); + + // Assert + var field = config.Fields.First(f => f.FieldName == "CityId"); + field.AdditionalAttributes.ShouldContainKey("LookupDataProvider"); + field.AdditionalAttributes["LookupDataProvider"].ShouldBeSameAs(dataProvider); + } + + [Fact] + public void AsLookup_Should_Set_ValueSelector_Attribute() + { + // Arrange + Func valueSelector = city => city.Id; + + // Act + var config = FormBuilder.Create() + .AddField(x => x.CityId, field => field + .AsLookup( + dataProvider: query => Task.FromResult(new LookupResult()), + valueSelector: valueSelector, + displaySelector: city => city.Name)) + .Build(); + + // Assert + var field = config.Fields.First(f => f.FieldName == "CityId"); + field.AdditionalAttributes.ShouldContainKey("LookupValueSelector"); + field.AdditionalAttributes["LookupValueSelector"].ShouldBeSameAs(valueSelector); + } + + [Fact] + public void AsLookup_Should_Set_DisplaySelector_Attribute() + { + // Arrange + Func displaySelector = city => city.Name; + + // Act + var config = FormBuilder.Create() + .AddField(x => x.CityId, field => field + .AsLookup( + dataProvider: query => Task.FromResult(new LookupResult()), + valueSelector: city => city.Id, + displaySelector: displaySelector)) + .Build(); + + // Assert + var field = config.Fields.First(f => f.FieldName == "CityId"); + field.AdditionalAttributes.ShouldContainKey("LookupDisplaySelector"); + field.AdditionalAttributes["LookupDisplaySelector"].ShouldBeSameAs(displaySelector); + } + + [Fact] + public void AsLookup_Should_Set_Columns_When_Configured() + { + // Act + var config = FormBuilder.Create() + .AddField(x => x.CityId, field => field + .AsLookup( + dataProvider: query => Task.FromResult(new LookupResult()), + valueSelector: city => city.Id, + displaySelector: city => city.Name, + configureColumns: cols => + { + cols.Add(new LookupColumn { Title = "Name", ValueSelector = c => c.Name }); + cols.Add(new LookupColumn { Title = "Country", ValueSelector = c => c.Country }); + })) + .Build(); + + // Assert + var field = config.Fields.First(f => f.FieldName == "CityId"); + field.AdditionalAttributes.ShouldContainKey("LookupColumns"); + + var columns = field.AdditionalAttributes["LookupColumns"] as List>; + columns.ShouldNotBeNull(); + columns.Count.ShouldBe(2); + columns[0].Title.ShouldBe("Name"); + columns[1].Title.ShouldBe("Country"); + } + + [Fact] + public void AsLookup_Should_Not_Set_Columns_When_Not_Configured() + { + // Act + var config = FormBuilder.Create() + .AddField(x => x.CityId, field => field + .AsLookup( + dataProvider: query => Task.FromResult(new LookupResult()), + valueSelector: city => city.Id, + displaySelector: city => city.Name)) + .Build(); + + // Assert + var field = config.Fields.First(f => f.FieldName == "CityId"); + field.AdditionalAttributes.ShouldNotContainKey("LookupColumns"); + } + + [Fact] + public void AsLookup_Should_Set_OnItemSelected_When_Provided() + { + // Arrange + Action onItemSelected = (model, city) => model.CityName = city.Name; + + // Act + var config = FormBuilder.Create() + .AddField(x => x.CityId, field => field + .AsLookup( + dataProvider: query => Task.FromResult(new LookupResult()), + valueSelector: city => city.Id, + displaySelector: city => city.Name, + onItemSelected: onItemSelected)) + .Build(); + + // Assert + var field = config.Fields.First(f => f.FieldName == "CityId"); + field.AdditionalAttributes.ShouldContainKey("LookupOnItemSelected"); + field.AdditionalAttributes["LookupOnItemSelected"].ShouldBeSameAs(onItemSelected); + } + + [Fact] + public void AsLookup_Should_Not_Set_OnItemSelected_When_Null() + { + // Act + var config = FormBuilder.Create() + .AddField(x => x.CityId, field => field + .AsLookup( + dataProvider: query => Task.FromResult(new LookupResult()), + valueSelector: city => city.Id, + displaySelector: city => city.Name)) + .Build(); + + // Assert + var field = config.Fields.First(f => f.FieldName == "CityId"); + field.AdditionalAttributes.ShouldNotContainKey("LookupOnItemSelected"); + } + + [Fact] + public void AsLookup_Should_Be_Chainable() + { + // Act + var config = FormBuilder.Create() + .AddField(x => x.CityId, field => field + .WithLabel("City") + .AsLookup( + dataProvider: query => Task.FromResult(new LookupResult()), + valueSelector: city => city.Id, + displaySelector: city => city.Name) + .WithHelpText("Click search to find a city")) + .Build(); + + // Assert + var field = config.Fields.First(f => f.FieldName == "CityId"); + field.Label.ShouldBe("City"); + field.HelpText.ShouldBe("Click search to find a city"); + field.AdditionalAttributes.ShouldContainKey("LookupDataProvider"); + } + + [Fact] + public void AsLookup_Column_ValueSelector_Should_Extract_Values() + { + // Arrange + var city = new CityDto { Id = 1, Name = "Paris", Country = "France" }; + var column = new LookupColumn + { + Title = "Name", + ValueSelector = c => c.Name + }; + + // Act + var result = column.ValueSelector(city); + + // Assert + result.ShouldBe("Paris"); + } + + [Fact] + public void LookupQuery_Should_Have_Default_Values() + { + // Act + var query = new LookupQuery(); + + // Assert + query.SearchText.ShouldBe(string.Empty); + query.Page.ShouldBe(0); + query.PageSize.ShouldBe(10); + query.SortField.ShouldBeNull(); + query.SortDescending.ShouldBeFalse(); + } + + [Fact] + public void LookupResult_Should_Have_Default_Values() + { + // Act + var result = new LookupResult(); + + // Assert + result.Items.ShouldBeEmpty(); + result.TotalCount.ShouldBe(0); + } + + [Fact] + public void LookupColumn_Should_Have_Default_Values() + { + // Act + var column = new LookupColumn(); + + // Assert + column.Title.ShouldBe(string.Empty); + column.Sortable.ShouldBeTrue(); + column.Filterable.ShouldBeTrue(); + column.ValueSelector.ShouldNotBeNull(); + } + + public class TestModel + { + public int CityId { get; set; } + public string CityName { get; set; } = string.Empty; + } + + public class CityDto + { + public int Id { get; set; } + public string Name { get; set; } = string.Empty; + public string Country { get; set; } = string.Empty; + } +} diff --git a/FormCraft/Forms/Abstractions/IOptionProvider.cs b/FormCraft/Forms/Abstractions/IOptionProvider.cs new file mode 100644 index 0000000..2b5e980 --- /dev/null +++ b/FormCraft/Forms/Abstractions/IOptionProvider.cs @@ -0,0 +1,21 @@ +namespace FormCraft; + +/// +/// Provides async search-based options for autocomplete and lookup fields. +/// +/// The model type that the form binds to. +/// The type of the option value. +public interface IOptionProvider +{ + /// + /// Searches for options matching the given text. + /// + /// The text to search for. + /// The current model instance for context-aware searching. + /// A cancellation token to cancel the operation. + /// A collection of matching options. + Task>> SearchAsync( + string searchText, + TModel model, + CancellationToken cancellationToken = default); +} diff --git a/FormCraft/Forms/Core/LookupColumn.cs b/FormCraft/Forms/Core/LookupColumn.cs new file mode 100644 index 0000000..bb7c0c4 --- /dev/null +++ b/FormCraft/Forms/Core/LookupColumn.cs @@ -0,0 +1,28 @@ +namespace FormCraft; + +/// +/// Defines a column in a lookup table dialog. +/// +/// The type of the lookup item. +public class LookupColumn +{ + /// + /// Gets or sets the column header title. + /// + public string Title { get; set; } = string.Empty; + + /// + /// Gets or sets the function that extracts the column value from an item. + /// + public Func ValueSelector { get; set; } = _ => null; + + /// + /// Gets or sets whether the column is sortable. + /// + public bool Sortable { get; set; } = true; + + /// + /// Gets or sets whether the column is filterable. + /// + public bool Filterable { get; set; } = true; +} diff --git a/FormCraft/Forms/Core/LookupQuery.cs b/FormCraft/Forms/Core/LookupQuery.cs new file mode 100644 index 0000000..d14c415 --- /dev/null +++ b/FormCraft/Forms/Core/LookupQuery.cs @@ -0,0 +1,32 @@ +namespace FormCraft; + +/// +/// Query parameters for lookup table data provider. +/// +public class LookupQuery +{ + /// + /// Gets or sets the search text to filter results. + /// + public string SearchText { get; set; } = string.Empty; + + /// + /// Gets or sets the zero-based page index. + /// + public int Page { get; set; } + + /// + /// Gets or sets the number of items per page. + /// + public int PageSize { get; set; } = 10; + + /// + /// Gets or sets the field name to sort by, or null for default ordering. + /// + public string? SortField { get; set; } + + /// + /// Gets or sets whether to sort in descending order. + /// + public bool SortDescending { get; set; } +} diff --git a/FormCraft/Forms/Core/LookupResult.cs b/FormCraft/Forms/Core/LookupResult.cs new file mode 100644 index 0000000..3080c00 --- /dev/null +++ b/FormCraft/Forms/Core/LookupResult.cs @@ -0,0 +1,18 @@ +namespace FormCraft; + +/// +/// Represents a page of lookup results with total count for pagination. +/// +/// The type of the lookup item. +public class LookupResult +{ + /// + /// Gets or sets the items in the current page. + /// + public IEnumerable Items { get; set; } = Enumerable.Empty(); + + /// + /// Gets or sets the total number of items matching the query. + /// + public int TotalCount { get; set; } +} diff --git a/FormCraft/Forms/Extensions/FieldBuilderExtensions.cs b/FormCraft/Forms/Extensions/FieldBuilderExtensions.cs index 5c6616c..97869ec 100644 --- a/FormCraft/Forms/Extensions/FieldBuilderExtensions.cs +++ b/FormCraft/Forms/Extensions/FieldBuilderExtensions.cs @@ -349,6 +349,80 @@ public static FieldBuilder> AsMultipleFileUp return builder; } + /// + /// Configures the field as an autocomplete with async search using a search function. + /// + /// The model type that the form binds to. + /// The type of the field value. + /// The FieldBuilder instance. + /// An async function that returns matching options for the given search text. + /// Debounce delay in milliseconds before triggering search (default: 300). + /// Minimum number of characters before triggering search (default: 1). + /// Optional function to convert a value to its display string. + /// The FieldBuilder instance for method chaining. + /// + /// + /// .AddField(x => x.City) + /// .AsAutocomplete( + /// searchFunc: async (text, ct) => cities + /// .Where(c => c.Name.Contains(text, StringComparison.OrdinalIgnoreCase)) + /// .Select(c => new SelectOption<string>(c.Name, c.Name)), + /// debounceMs: 300, + /// minCharacters: 2) + /// + /// + public static FieldBuilder AsAutocomplete( + this FieldBuilder builder, + Func>>> searchFunc, + int debounceMs = 300, + int minCharacters = 1, + Func? toStringFunc = null) + where TModel : new() + { + builder.WithAttribute("AutocompleteSearchFunc", searchFunc); + builder.WithAttribute("AutocompleteDebounceMs", debounceMs); + builder.WithAttribute("AutocompleteMinCharacters", minCharacters); + if (toStringFunc != null) + builder.WithAttribute("AutocompleteToStringFunc", toStringFunc); + return builder; + } + + /// + /// Configures the field as an autocomplete with async search using an IOptionProvider. + /// + /// The model type that the form binds to. + /// The type of the field value. + /// The FieldBuilder instance. + /// An option provider that supplies search results based on model context. + /// Debounce delay in milliseconds before triggering search (default: 300). + /// Minimum number of characters before triggering search (default: 1). + /// Optional function to convert a value to its display string. + /// The FieldBuilder instance for method chaining. + /// + /// + /// .AddField(x => x.City) + /// .AsAutocomplete( + /// optionProvider: new CityOptionProvider(), + /// debounceMs: 300, + /// minCharacters: 2) + /// + /// + public static FieldBuilder AsAutocomplete( + this FieldBuilder builder, + IOptionProvider optionProvider, + int debounceMs = 300, + int minCharacters = 1, + Func? toStringFunc = null) + where TModel : new() + { + builder.WithAttribute("AutocompleteOptionProvider", optionProvider); + builder.WithAttribute("AutocompleteDebounceMs", debounceMs); + builder.WithAttribute("AutocompleteMinCharacters", minCharacters); + if (toStringFunc != null) + builder.WithAttribute("AutocompleteToStringFunc", toStringFunc); + return builder; + } + private static bool IsValidEmail(string email) { if (string.IsNullOrWhiteSpace(email))