DisplayProperty(object? item, PropertyConfig prop, object? value = null, object? enumerableValue = null) {
if (item is null) return string.Empty;
diff --git a/src/HopFrame.Web/Components/Pages/HopFrameTablePage.razor b/src/HopFrame.Web/Components/Pages/HopFrameTablePage.razor
index cd7728f..f649f7e 100644
--- a/src/HopFrame.Web/Components/Pages/HopFrameTablePage.razor
+++ b/src/HopFrame.Web/Components/Pages/HopFrameTablePage.razor
@@ -9,8 +9,8 @@
@using HopFrame.Web.Models
@using HopFrame.Web.Plugins
@using HopFrame.Web.Plugins.Events
+@using HopFrame.Web.Services
@using Microsoft.JSInterop
-@using Microsoft.EntityFrameworkCore
@if (!DisplaySelection) {
@_config?.DisplayName
@@ -40,7 +40,27 @@
}
-
+
+
+ { SearchFocus(); UpdateSearchSuggestions(); }"
+ @onfocusout="SearchUnfocus"
+ Style="width: 500px"/>
+
+ @if (_isSearchActive && _searchSuggestions.Count > 0) {
+
+ }
+
@if (_hasCreatePolicy && DisplayActions && _buttonToggles.ShowAddEntityButton) {
Add Entity
@@ -162,6 +182,7 @@
@inject IHopFrameAuthHandler Handler
@inject ICallbackEmitter Emitter
@inject IPluginOrchestrator PluginOrchestrator
+@inject ISearchSuggestionProvider SearchSuggestions
@code {
@@ -191,6 +212,9 @@
private int _totalPages;
private string? _searchTerm;
private bool _loading;
+ private bool _isSearchActive;
+ private IList _searchSuggestions = [];
+ private FluentSearch? _searchBox;
private bool _hasUpdatePolicy;
private bool _hasDeletePolicy;
@@ -255,6 +279,7 @@
_searchTerm = eventArgs.Value?.ToString();
if (_searchTerm is null) return;
_searchCancel = new();
+ UpdateSearchSuggestions();
await Task.Delay(500, _searchCancel.Token);
@@ -274,6 +299,36 @@
await Reload();
}
+
+ private async Task SearchSuggestionSelected(string? suggestion) {
+ if (string.IsNullOrWhiteSpace(suggestion)) return;
+ _searchTerm = SearchSuggestions.CompleteSearchSuggestion(_config!, _searchTerm ?? string.Empty, suggestion);
+ _searchBox!.Value = _searchTerm;
+ _searchBox.FocusAsync();
+ UpdateSearchSuggestions();
+
+ if (!suggestion.EndsWith('='))
+ await OnSearch(new() {
+ Value = _searchTerm
+ });
+ }
+
+ private void UpdateSearchSuggestions() {
+ if (_config is null || !_config.ShowSearchSuggestions) return;
+ _searchSuggestions = SearchSuggestions.GenerateSearchSuggestions(_config, _searchTerm ?? string.Empty).ToList();
+ }
+
+ private CancellationTokenSource _searchFocusCancel = new();
+ private async Task SearchFocus() {
+ _isSearchActive = true;
+ await _searchFocusCancel.CancelAsync();
+ _searchFocusCancel = new();
+ }
+
+ private async Task SearchUnfocus() {
+ await Task.Delay(10, _searchFocusCancel.Token);
+ _isSearchActive = false;
+ }
public async Task Reload() {
_loading = true;
@@ -421,4 +476,5 @@
public void RequestRender() {
StateHasChanged();
}
+
}
\ No newline at end of file
diff --git a/src/HopFrame.Web/Components/Pages/HopFrameTablePage.razor.css b/src/HopFrame.Web/Components/Pages/HopFrameTablePage.razor.css
index f482c66..9912dad 100644
--- a/src/HopFrame.Web/Components/Pages/HopFrameTablePage.razor.css
+++ b/src/HopFrame.Web/Components/Pages/HopFrameTablePage.razor.css
@@ -20,3 +20,13 @@
place-items: center;
border-bottom: calc(var(--stroke-width) * 1px) solid var(--neutral-stroke-divider-rest);
}
+
+.hopframe-search ::deep fluent-listbox {
+ width: 500px;
+ position: absolute;
+ top: 100%;
+ left: 0;
+ background-color: var(--fill-color);
+ z-index: 1;
+ outline: none !important;
+}
diff --git a/src/HopFrame.Web/ServiceCollectionExtensions.cs b/src/HopFrame.Web/ServiceCollectionExtensions.cs
index 2a68b43..5461b83 100644
--- a/src/HopFrame.Web/ServiceCollectionExtensions.cs
+++ b/src/HopFrame.Web/ServiceCollectionExtensions.cs
@@ -10,6 +10,7 @@ using HopFrame.Web.Services.Implementation;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.FluentUI.AspNetCore.Components;
using Microsoft.AspNetCore.Builder;
+using Microsoft.Extensions.DependencyInjection.Extensions;
namespace HopFrame.Web;
@@ -44,6 +45,7 @@ public static class ServiceCollectionExtensions {
services.AddScoped();
services.AddScoped();
+ services.TryAddScoped();
if (addRazorComponents) {
services.AddRazorComponents()
diff --git a/src/HopFrame.Web/Services/ISearchSuggestionProvider.cs b/src/HopFrame.Web/Services/ISearchSuggestionProvider.cs
new file mode 100644
index 0000000..c51e198
--- /dev/null
+++ b/src/HopFrame.Web/Services/ISearchSuggestionProvider.cs
@@ -0,0 +1,11 @@
+using HopFrame.Core.Config;
+
+namespace HopFrame.Web.Services;
+
+public interface ISearchSuggestionProvider {
+
+ public IEnumerable GenerateSearchSuggestions(TableConfig table, string searchText);
+
+ public string CompleteSearchSuggestion(TableConfig table, string searchText, string selectedSuggestion);
+
+}
\ No newline at end of file
diff --git a/src/HopFrame.Web/Services/Implementation/SearchSuggestionProvider.cs b/src/HopFrame.Web/Services/Implementation/SearchSuggestionProvider.cs
new file mode 100644
index 0000000..4ade985
--- /dev/null
+++ b/src/HopFrame.Web/Services/Implementation/SearchSuggestionProvider.cs
@@ -0,0 +1,53 @@
+using HopFrame.Core.Config;
+using HopFrame.Core.Services;
+using HopFrame.Web.Helpers;
+
+namespace HopFrame.Web.Services.Implementation;
+
+public sealed class SearchSuggestionProvider : ISearchSuggestionProvider {
+
+ public IEnumerable GenerateSearchSuggestions(TableConfig table, string searchText) {
+ var searchParts = searchText.Trim().Split(' ');
+ if (searchParts.Length != 0 && searchParts.Last().EndsWith('=') && !searchText.EndsWith(' ')) {
+ var part = searchParts.Last()[..^1];
+ var property = table.Properties
+ .Where(p => p.List)
+ .Where(p => !p.IsVirtualProperty)
+ .FirstOrDefault(p => p.Name == part);
+
+ if (property is null) return [];
+
+ if (property.Info.PropertyType.IsEnum)
+ return Enum.GetNames(property.Info.PropertyType);
+
+ if (property.Info.PropertyType == typeof(DateOnly))
+ return [DateOnly.FromDateTime(DateTime.Now).ToString()];
+
+ if (property.Info.PropertyType == typeof(TimeOnly))
+ return [TimeOnly.FromDateTime(DateTime.Now).ToString()];
+ }
+
+ if (searchText.Length != 0 && !searchText.EndsWith(' '))
+ return [];
+
+ Type[] validTypes = [typeof(string), typeof(Guid), typeof(bool), typeof(DateOnly), typeof(TimeOnly)];
+ var searchableProperties = table.Properties
+ .Where(p => !p.IsVirtualProperty)
+ .Where(p => p.List)
+ .Where(p => p.Searchable)
+ .Where(p =>
+ p.Info.PropertyType.IsEnum ||
+ p.Info.PropertyType.IsNumeric() ||
+ validTypes.Contains(p.Info.PropertyType) ||
+ p.IsRelation)
+ .ToArray();
+
+ return searchableProperties
+ .Select(p => p.Name + "=");
+ }
+
+ public string CompleteSearchSuggestion(TableConfig table, string searchText, string selectedSuggestion) {
+ return searchText + selectedSuggestion;
+ }
+
+}
\ No newline at end of file
diff --git a/testing/HopFrame.Testing/Models/User.cs b/testing/HopFrame.Testing/Models/User.cs
index 22ea5de..f8624e3 100644
--- a/testing/HopFrame.Testing/Models/User.cs
+++ b/testing/HopFrame.Testing/Models/User.cs
@@ -12,8 +12,4 @@ public class User {
public string? LastName { get; set; }
public virtual List Posts { get; set; } = new();
-
- public override string ToString() {
- return Username;
- }
}
\ No newline at end of file
diff --git a/testing/HopFrame.Testing/Program.cs b/testing/HopFrame.Testing/Program.cs
index b5218e8..320b1c8 100644
--- a/testing/HopFrame.Testing/Program.cs
+++ b/testing/HopFrame.Testing/Program.cs
@@ -51,9 +51,13 @@ builder.Services.AddHopFrame(options => {
.FormatEach((post, _) => post.Caption);
});
+ context.Table()
+ .ShowSearchSuggestions(false);
+
context.Table()
.Property(p => p.Author)
- .Format((user, _) => $"{user.FirstName} {user.LastName}")
+ //.Format((user, _) => $"{user.FirstName} {user.LastName}")
+ .SetDisplayedProperty(u => u.Username)
.SetValidator((_, _) => []);
context.Table()
diff --git a/tests/HopFrame.Tests.Core/Services/ContextExplorerTests.cs b/tests/HopFrame.Tests.Core/Services/ContextExplorerTests.cs
index d092f88..e26974f 100644
--- a/tests/HopFrame.Tests.Core/Services/ContextExplorerTests.cs
+++ b/tests/HopFrame.Tests.Core/Services/ContextExplorerTests.cs
@@ -1,6 +1,8 @@
using HopFrame.Core.Config;
+using HopFrame.Core.Services;
using HopFrame.Core.Services.Implementations;
using HopFrame.Tests.Core.Models;
+using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Moq;
@@ -121,6 +123,7 @@ public class ContextExplorerTests {
var dbContext = new MockDbContext();
var provider = new Mock();
provider.Setup(p => p.GetService(typeof(MockDbContext))).Returns(dbContext);
+ provider.Setup(p => p.GetService(typeof(ISearchExpressionBuilder))).Returns(new Mock().Object);
var contextExplorer = new ContextExplorer(config, provider.Object, new Logger(new LoggerFactory()));
// Act
diff --git a/tests/HopFrame.Tests.Core/Services/DisplayPropertyTests.cs b/tests/HopFrame.Tests.Core/Services/DisplayPropertyTests.cs
index 79d6ecb..b68d225 100644
--- a/tests/HopFrame.Tests.Core/Services/DisplayPropertyTests.cs
+++ b/tests/HopFrame.Tests.Core/Services/DisplayPropertyTests.cs
@@ -19,7 +19,7 @@ public class DisplayPropertyTests {
_explorerMock = new Mock();
_config = new TableConfig(new DbContextConfig(typeof(MockDbContext), null!), typeof(MockModel), "Models", 0);
_tableManager =
- new TableManager